Add full application: receipt scanning, auth, billing, and account deletion

Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-19 20:59:04 +02:00
parent 650a74da97
commit 84b9987c49
415 changed files with 96619 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import fs from 'fs';
import path from 'path';
function walk(dir) {
let results = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results = results.concat(walk(fullPath));
} else if (/\.(tsx|ts|jsx|js|css|scss)$/.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
const files = walk('src');
console.log(`Deep scanning ${files.length} source files in src/...`);
const rawMatches = [];
files.forEach(f => {
const content = fs.readFileSync(f, 'utf8');
const lines = content.split('\n');
lines.forEach((l, i) => {
if (/round|shadow|gradient/i.test(l)) {
rawMatches.push({ file: f, line: i + 1, content: l.trim() });
}
});
});
console.log(`Total occurrences found: ${rawMatches.length}`);
rawMatches.forEach((m, idx) => {
console.log(`${idx + 1}. ${m.file}:${m.line} -> ${m.content}`);
});

View File

@@ -0,0 +1,197 @@
import fs from 'fs';
import path from 'path';
function walk(dir) {
let results = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results = results.concat(walk(fullPath));
} else if (/\.(tsx|ts|jsx|js|css|scss)$/.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
const files = walk('src');
console.log(`Found ${files.length} source files under src/ to inspect.`);
const findings = [];
// Patterns:
// 1. Any shadow class except shadow-none, shadow-[none], shadow-[0px]
const shadowPattern = /\bshadow(-[a-zA-Z0-9_/\[\]#.-]+)?\b/g;
// 2. Any rounded class except rounded-none, rounded-[0px], rounded-0, rounded-[0]
const roundedPattern = /\brounded(-[a-zA-Z0-9_/\[\]#.-]+)?\b/g;
// 3. bg-gradient-*
const bgGradientPattern = /\bbg-gradient(-[a-zA-Z0-9_-]+)?\b/g;
// 4. from-* gradient stop
const fromPattern = /\bfrom-[a-zA-Z0-9_/\[\]#.-]+\b/g;
// 5. via-* gradient stop
const viaPattern = /\bvia-[a-zA-Z0-9_/\[\]#.-]+\b/g;
// 6. to-* gradient stop (e.g., to-black, to-white, to-slate-900, to-[#...])
const toPattern = /\bto-(black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|transparent|current|\[#[a-fA-F0-9]+\])(-[0-9]+)?(\/[0-9]+)?\b/g;
// 7. CSS box-shadow
const cssBoxShadowPattern = /box-shadow\s*:\s*([^;]+)/gi;
// 8. CSS border-radius
const cssBorderRadiusPattern = /border-radius\s*:\s*([^;]+)/gi;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
lines.forEach((line, index) => {
const lineNum = index + 1;
const trimmed = line.trim();
// Check CSS properties
let match;
while ((match = cssBoxShadowPattern.exec(line)) !== null) {
const val = match[1].trim();
if (val !== 'none' && val !== '0' && val !== '0px') {
findings.push({
type: 'CSS_BOX_SHADOW',
file: filePath,
line: lineNum,
matched: match[0],
raw: trimmed,
});
}
}
while ((match = cssBorderRadiusPattern.exec(line)) !== null) {
const val = match[1].trim();
if (val !== '0' && val !== '0px' && val !== '0 0 0 0') {
findings.push({
type: 'CSS_BORDER_RADIUS',
file: filePath,
line: lineNum,
matched: match[0],
raw: trimmed,
});
}
}
// Check Tailwind / Class matches
// Look for string literals or className definitions
// To be thorough, check any occurrence in the file
let rMatch;
while ((rMatch = roundedPattern.exec(line)) !== null) {
const cls = rMatch[0];
if (
cls !== 'rounded-none' &&
cls !== 'rounded-[0px]' &&
cls !== 'rounded-0' &&
cls !== 'rounded-[0]'
) {
// Exclude JS identifier words like roundedTotal, Math.round, etc. if not a class token
// A class token is typically in quotes, backticks, or preceded/followed by whitespace/quotes
const before = line[rMatch.index - 1] || ' ';
const after = line[rMatch.index + cls.length] || ' ';
if (
/['"`\s=:({[,>]/.test(before) &&
/['"`\s=:)}],<]/.test(after)
) {
findings.push({
type: 'FORBIDDEN_ROUNDED_CLASS',
file: filePath,
line: lineNum,
matched: cls,
raw: trimmed,
});
}
}
}
let sMatch;
while ((sMatch = shadowPattern.exec(line)) !== null) {
const cls = sMatch[0];
if (
cls !== 'shadow-none' &&
cls !== 'shadow-[none]' &&
cls !== 'shadow-[0px]'
) {
const before = line[sMatch.index - 1] || ' ';
const after = line[sMatch.index + cls.length] || ' ';
if (
/['"`\s=:({[,>]/.test(before) &&
/['"`\s=:)}],<]/.test(after)
) {
findings.push({
type: 'FORBIDDEN_SHADOW_CLASS',
file: filePath,
line: lineNum,
matched: cls,
raw: trimmed,
});
}
}
}
let gMatch;
while ((gMatch = bgGradientPattern.exec(line)) !== null) {
findings.push({
type: 'FORBIDDEN_BG_GRADIENT_CLASS',
file: filePath,
line: lineNum,
matched: gMatch[0],
raw: trimmed,
});
}
let fMatch;
while ((fMatch = fromPattern.exec(line)) !== null) {
// Exclude JS imports: import { ... } from '...'
if (!/\bimport\b|\bexport\b/.test(line)) {
findings.push({
type: 'FORBIDDEN_FROM_GRADIENT_CLASS',
file: filePath,
line: lineNum,
matched: fMatch[0],
raw: trimmed,
});
}
}
let vMatch;
while ((vMatch = viaPattern.exec(line)) !== null) {
findings.push({
type: 'FORBIDDEN_VIA_GRADIENT_CLASS',
file: filePath,
line: lineNum,
matched: vMatch[0],
raw: trimmed,
});
}
let tMatch;
while ((tMatch = toPattern.exec(line)) !== null) {
// Check if it's inside className or class attribute or tailwind string
findings.push({
type: 'FORBIDDEN_TO_GRADIENT_CLASS',
file: filePath,
line: lineNum,
matched: tMatch[0],
raw: trimmed,
});
}
});
}
console.log('=== ADVERSARIAL SCAN REPORT ===');
console.log(`Scanned files: ${files.length}`);
console.log(`Total violations detected: ${findings.length}`);
if (findings.length > 0) {
console.log('\n--- VIOLATIONS LIST ---');
findings.forEach((f, idx) => {
console.log(`${idx + 1}. [${f.type}] ${f.file}:${f.line}`);
console.log(` Token: "${f.matched}"`);
console.log(` Line: ${f.raw}`);
});
process.exit(1);
} else {
console.log('CLEAN: No forbidden rounded, shadow, gradient, or box-shadow tokens found in src/');
process.exit(0);
}

View File

@@ -0,0 +1,120 @@
// Script to query Apple iTunes Search API for ASO & Competition Analysis
import https from 'https';
const KEYWORDS_DE = [
'receipt scanner',
'beleg scanner',
'belege digitalisieren',
'rechnung scanner',
'kassenbon scanner',
'quittung scanner',
'spesen app',
'buchhaltung scanner',
'datev scanner',
'receipt to excel',
'expense tracker',
'ausgaben tracker',
'belegmanager',
'rechnungsprogramm',
'fahrtenbuch und belege',
'steuer belege',
'ocr scanner excel'
];
const KEYWORDS_US = [
'receipt scanner',
'receipt to excel',
'receipt scanner to excel',
'expense tracker',
'receipt keeper',
'invoice scanner',
'receipts and expenses',
'ocr receipt scanner',
'bookkeeping scanner',
'mileage and receipts',
'tax receipt organizer',
'smart receipt'
];
function fetchAppleSearch(term, country = 'de', limit = 25) {
return new Promise((resolve, reject) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=${limit}`;
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
async function analyzeKeywords(keywords, country) {
console.log(`\n======================================================`);
console.log(`🔍 ANALYSING APPLE APP STORE SEARCH DATA [Country: ${country.toUpperCase()}]`);
console.log(`======================================================\n`);
const results = [];
for (const kw of keywords) {
try {
const data = await fetchAppleSearch(kw, country, 25);
const totalResults = data.resultCount;
const apps = data.results || [];
// Metrics calculation
const top5 = apps.slice(0, 5);
const top5Names = top5.map(a => a.trackName);
const avgRating = top5.reduce((acc, a) => acc + (a.averageUserRating || 0), 0) / (top5.length || 1);
const totalTop5Ratings = top5.reduce((acc, a) => acc + (a.userRatingCount || 0), 0);
const avgTop5RatingCount = Math.round(totalTop5Ratings / (top5.length || 1));
// Check title keyword match density in top 10
const titleMatches = apps.slice(0, 10).filter(a =>
(a.trackName || '').toLowerCase().includes(kw.toLowerCase()) ||
(a.description || '').toLowerCase().includes(kw.toLowerCase())
).length;
// Price distribution
const freeCount = top5.filter(a => a.price === 0).length;
results.push({
keyword: kw,
resultCount: totalResults,
top1: apps[0] ? `${apps[0].trackName} (${apps[0].userRatingCount || 0} reviews, ★${apps[0].averageUserRating?.toFixed(1) || '0'})` : 'None',
top5AvgRating: avgRating.toFixed(2),
avgTop5Reviews: avgTop5RatingCount,
titleMatchInTop10: titleMatches,
topCompetitors: top5.map(a => ({
name: a.trackName,
seller: a.sellerName,
reviews: a.userRatingCount || 0,
rating: a.averageUserRating || 0,
price: a.price,
genres: a.genres
}))
});
// Avoid hitting rate limits
await new Promise(r => setTimeout(r, 200));
} catch (err) {
console.error(`Error fetching "${kw}":`, err.message);
}
}
return results;
}
async function run() {
const deResults = await analyzeKeywords(KEYWORDS_DE, 'de');
const usResults = await analyzeKeywords(KEYWORDS_US, 'us');
console.log(JSON.stringify({ de: deResults, us: usResults }, null, 2));
}
run();

View File

@@ -0,0 +1,63 @@
import https from 'https';
const keywords = [
'zimmerpflanze',
'zimmerpflanzen',
'zimmerpflanzen pflege',
'pflanzen bestimmen',
'pflanzen app',
'pflanzendoktor',
'pflanzen gießen erinnerung',
'houseplant',
'plant care'
];
const storefronts = [
{ code: 'de', name: 'Deutschland' },
{ code: 'at', name: 'Österreich' },
{ code: 'ch', name: 'Schweiz' }
];
function fetchSearch(term, country) {
return new Promise((resolve) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=10`;
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data).results || []);
} catch {
resolve([]);
}
});
}).on('error', () => resolve([]));
});
}
async function run() {
console.log("Analysiere 'Zimmerpflanze' und verwandte Keywords im App Store...\n");
for (const sf of storefronts) {
console.log(`========================================`);
console.log(`Storefront: ${sf.name} (${sf.code.toUpperCase()})`);
console.log(`========================================`);
for (const kw of keywords) {
const apps = await fetchSearch(kw, sf.code);
const top5 = apps.slice(0, 5);
const avgReviews = top5.length ? Math.round(top5.reduce((s, a) => s + (a.userRatingCount || 0), 0) / top5.length) : 0;
const top1 = top5[0] || {};
console.log(`\nKeyword: "${kw}"`);
console.log(`- Apps gefunden: ${apps.length}`);
console.log(`- Top 1: ${top1.trackName || 'Keine'} (${top1.userRatingCount || 0} Reviews, ★${top1.averageUserRating?.toFixed(1) || 0})`);
console.log(`- Ø Reviews Top 5: ${avgReviews.toLocaleString('de-DE')}`);
await new Promise(r => setTimeout(r, 150));
}
console.log("\n");
}
}
run();

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Apply scripts/db-permissions.sql to a PostgreSQL database.
*
* Connects with the OWNER / migration URL (DATABASE_URL from the environment,
* .env.local or .env) because creating roles, setting default privileges and
* revoking CREATE from PUBLIC require owner/superuser rights. The runtime role
* it provisions (`receipt_app`) is the least-privilege role the application
* should use at runtime — see scripts/db-permissions.sql for the full grant
* list.
*
* Safe to re-run: the SQL file is idempotent. If APP_DATABASE_PASSWORD is set
* (env or .env.local), it replaces the documented default password in the SQL
* and appends an ALTER ROLE so an existing role's password is rotated too.
*
* Usage:
* node scripts/apply-db-permissions.mjs
* APP_DATABASE_PASSWORD=<secret> node scripts/apply-db-permissions.mjs
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "pg";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_RUNTIME_PASSWORD = "receipt_app_secure_password";
/** Minimal .env parser: strips quotes, ignores comments; first key wins. */
function parseEnvFile(filePath) {
const out = {};
let text;
try {
text = readFileSync(filePath, "utf8");
} catch {
return out;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in out)) out[key] = value;
}
return out;
}
/** SQL single-quoted literal (doubles embedded quotes). */
function quoteLiteral(value) {
return "'" + String(value).replace(/'/g, "''") + "'";
}
// Env precedence: process.env > .env.local > .env.
const fileEnv = { ...parseEnvFile(path.join(ROOT, ".env")), ...parseEnvFile(path.join(ROOT, ".env.local")) };
const ownerUrl = process.env.DATABASE_URL || fileEnv.DATABASE_URL;
if (!ownerUrl) {
console.error("FATAL: DATABASE_URL not found. Set it in the environment, .env.local or .env.");
process.exit(1);
}
const runtimePassword = process.env.APP_DATABASE_PASSWORD || fileEnv.APP_DATABASE_PASSWORD || null;
let sql = readFileSync(path.join(ROOT, "scripts", "db-permissions.sql"), "utf8");
// Optional password override: swap the documented default for the provided one
// and ensure the role's password matches APP_DATABASE_PASSWORD (ALTER ROLE is
// always appended when the variable is set, so re-runs converge — including
// rotating a previously custom password back to the default).
let passwordSource = "documented default (receipt_app_secure_password)";
if (runtimePassword) {
if (runtimePassword === DEFAULT_RUNTIME_PASSWORD) {
console.log("APP_DATABASE_PASSWORD equals the documented default - CREATE ROLE token unchanged, password confirmed via ALTER ROLE.");
} else {
const token = `PASSWORD '${DEFAULT_RUNTIME_PASSWORD}'`;
if (!sql.includes(token)) {
console.error("FATAL: could not locate the default password token in db-permissions.sql.");
process.exit(1);
}
sql = sql.split(token).join(`PASSWORD ${quoteLiteral(runtimePassword)}`);
passwordSource = "APP_DATABASE_PASSWORD (custom)";
}
sql +=
`\n-- Password convergence appended by scripts/apply-db-permissions.mjs\n` +
`ALTER ROLE receipt_app WITH PASSWORD ${quoteLiteral(runtimePassword)};\n`;
} else {
console.log("APP_DATABASE_PASSWORD not set - role password left at the documented default from db-permissions.sql.");
}
const client = new Client({ connectionString: ownerUrl });
await client.connect();
try {
console.log(`Connected as owner (${new URL(ownerUrl).username || "?"}) to execute db-permissions.sql ...`);
await client.query(sql); // pg executes the multi-statement SQL in one round trip
console.log("Applied scripts/db-permissions.sql successfully.");
console.log(" runtime role : receipt_app (LOGIN, NO SUPERUSER, NO CREATEDB, NO CREATEROLE)");
console.log(` password : ${passwordSource}`);
console.log(" grants : CONNECT, schema USAGE (no CREATE), table SELECT/INSERT/UPDATE/DELETE,");
console.log(" sequence USAGE/SELECT, matching DEFAULT PRIVILEGES for future objects,");
console.log(" PUBLIC CREATE on schema public revoked.");
} catch (err) {
console.error("Failed to apply db-permissions.sql:", err.message);
process.exitCode = 1;
} finally {
await client.end();
}

30
scripts/check_luna.mjs Normal file
View File

@@ -0,0 +1,30 @@
import fs from "fs";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
async function checkLuna56() {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: { "Authorization": `Bearer ${apiKey}` }
});
const data = await res.json();
const models = data.data || [];
const matches = models.filter(m =>
m.id.toLowerCase().includes("5.6") ||
m.id.toLowerCase().includes("luna") ||
m.name.toLowerCase().includes("5.6") ||
m.name.toLowerCase().includes("luna")
);
console.log("=== MATCHES FOR '5.6' / 'LUNA' ===");
matches.forEach(m => {
console.log(`- ID: ${m.id}`);
console.log(` Name: ${m.name}`);
console.log(` Modalities: ${JSON.stringify(m.architecture?.modality || 'unknown')}`);
console.log(` Pricing: ${JSON.stringify(m.pricing)}`);
});
}
checkLuna56().catch(console.error);

View File

@@ -0,0 +1,8 @@
/**
* Registers scripts/cors-resolve-hook.mjs before the main script runs.
*
* Usage: node --import ./scripts/cors-register-hook.mjs scripts/verify_cors.mjs
*/
import { register } from "node:module";
register("./cors-resolve-hook.mjs", import.meta.url);

View File

@@ -0,0 +1,15 @@
/**
* Node.js ESM resolve hook used by the CORS verification harness.
*
* Next.js ships no package.json "exports" map, so the canonical bare specifier
* `next/server` does not resolve under raw Node ESM (the Next.js bundler
* resolves it itself). This hook maps it to the actual CJS file so the
* middleware module under test (src/lib/http/cors.ts, which imports
* `next/server`) can be loaded by plain `node` for verification.
*/
export async function resolve(specifier, context, nextResolve) {
if (specifier === "next/server") {
return nextResolve("next/server.js", context);
}
return nextResolve(specifier, context);
}

View File

@@ -0,0 +1,70 @@
import https from 'https';
const KEYWORDS = [
'receipt scanner to excel',
'receipt to excel',
'receipt scanner',
'expense tracker',
'tax receipt organizer',
'receipt keeper',
'invoice scanner',
'bookkeeping scanner',
'ocr receipt scanner',
'extract receipt to csv'
];
const COUNTRIES = [
{ code: 'us', name: 'USA' },
{ code: 'gb', name: 'UK' },
{ code: 'ca', name: 'Canada' },
{ code: 'au', name: 'Australia' }
];
function fetchSearch(term, country) {
return new Promise((resolve) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=5`;
const req = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
resolve({ results: [] });
}
});
});
req.on('error', () => resolve({ results: [] }));
req.setTimeout(4000, () => {
req.destroy();
resolve({ results: [] });
});
});
}
async function run() {
const table = [];
for (const kw of KEYWORDS) {
const row = { keyword: kw, countries: {} };
for (const c of COUNTRIES) {
const data = await fetchSearch(kw, c.code);
const apps = data.results || [];
const top1 = apps[0] || {};
const avgReviews = apps.length ? Math.round(apps.reduce((s, a) => s + (a.userRatingCount || 0), 0) / apps.length) : 0;
row.countries[c.name] = {
top1Name: top1.trackName ? top1.trackName.slice(0, 25) : 'None',
top1Reviews: top1.userRatingCount || 0,
avgReviews,
resultCount: apps.length
};
await new Promise(r => setTimeout(r, 150));
}
table.push(row);
}
console.log(JSON.stringify(table, null, 2));
}
run();

72
scripts/create-admin.ts Normal file
View File

@@ -0,0 +1,72 @@
import fs from "fs";
// Load .env.local into process.env (same pattern as other scripts) BEFORE the
// db module initialises, so it picks up the real DATABASE_URL (port 5436).
const dotenvContent = fs.readFileSync(".env.local", "utf8");
for (const l of dotenvContent.split("\n")) {
const trimmed = l.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx !== -1) {
process.env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
}
}
async function main() {
const { db } = await import("../src/lib/db");
const { users } = await import("../src/lib/schema/db");
const { hashPassword } = await import("../src/lib/auth/password");
const { canonicaliseEmail, normaliseEmail } = await import("../src/lib/auth/email");
const { newId } = await import("../src/lib/auth/tokens");
const { eq } = await import("drizzle-orm");
const rawEmail = process.env.ADMIN_EMAILS?.split(",")[0]?.trim() ?? "";
const password = process.env.ADMIN_PASSWORD ?? "fiesta";
if (!rawEmail) throw new Error("ADMIN_EMAILS not set");
const email = canonicaliseEmail(rawEmail);
const emailKey = normaliseEmail(rawEmail);
if (!emailKey) throw new Error(`Invalid admin email: ${rawEmail}`);
const passwordHash = await hashPassword(password);
const existing = await db.select().from(users).where(eq(users.emailKey, emailKey)).limit(1);
if (existing.length > 0) {
await db
.update(users)
.set({
passwordHash,
emailVerifiedAt: new Date(),
isGuest: false,
updatedAt: new Date(),
})
.where(eq(users.id, existing[0].id));
console.log(`Updated admin ${email} (${existing[0].id}) — password reset, email verified`);
} else {
const row = (
await db
.insert(users)
.values({
id: newId("usr"),
email,
emailKey,
name: "Timo Knuth",
passwordHash,
emailVerifiedAt: new Date(),
isGuest: false,
plan: "free",
})
.returning()
)[0];
console.log(`Created admin ${email} (${row.id}) — email verified`);
}
process.exit(0);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,94 @@
-- ============================================================================
-- Database least-privilege provisioning — receipt scanner app
-- ============================================================================
-- Creates the runtime role `receipt_app` and grants it ONLY the privileges the
-- application needs at runtime:
--
-- * CONNECT on the database
-- * USAGE on the public schema — explicitly NO CREATE (no DDL of any kind)
-- * SELECT / INSERT / UPDATE / DELETE on all tables in public
-- * USAGE / SELECT on all sequences in public
-- * the same DML grants as DEFAULT PRIVILEGES, so tables/sequences created
-- by the owner during later migrations are covered automatically
--
-- The role is deliberately NOT a superuser and has no CREATEDB / CREATEROLE /
-- CREATE-on-schema rights: if the application is compromised, the attacker's
-- database blast radius is limited to reading and modifying rows. They cannot
-- create/drop tables, alter the schema, or grant themselves more rights.
--
-- ----------------------------------------------------------------------------
-- Who must run this? The database owner or a superuser.
-- * `receipt_user` (docker-compose's POSTGRES_USER / the owner URL) KEEPS all
-- of its current rights — including DDL for migrations and schema init —
-- and is the only role this script ever grants privileges to (besides the
-- new runtime role). Nothing here weakens the owner.
-- * REVOKE CREATE ON SCHEMA public FROM PUBLIC requires the schema owner or
-- a superuser. The official postgres image's /docker-entrypoint-initdb.d
-- scripts run as POSTGRES_USER, so that path satisfies this.
--
-- How is it executed?
-- * Fresh database / volume: docker-compose mounts this file into
-- /docker-entrypoint-initdb.d/10-db-permissions.sql; the postgres image
-- runs it once, before the app starts, as POSTGRES_USER. (Init scripts do
-- NOT run on an existing volume.)
-- * Existing database (e.g. the local dev DB): `node scripts/apply-db-permissions.mjs`
--
-- Idempotent: safe to run any number of times. CREATE ROLE is guarded by an
-- existence check; every GRANT / REVOKE / ALTER DEFAULT PRIVILEGES is a no-op
-- when already applied.
--
-- Runtime role password:
-- The default below (receipt_app_secure_password) matches the docker-compose
-- default APP_DATABASE_PASSWORD and is for LOCAL DEVELOPMENT only. For
-- production, choose a strong password:
-- * docker init path: edit the CREATE ROLE statement below before first
-- volume creation (this file runs as-is under psql), or
-- * apply-script path: set APP_DATABASE_PASSWORD when running
-- scripts/apply-db-permissions.mjs — the script substitutes that value
-- and rotates the password of an already-existing role (ALTER ROLE).
-- ============================================================================
-- 1. Create the least-privilege runtime role (guarded -> re-runnable).
DO $dbpermissions$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'receipt_app') THEN
CREATE ROLE receipt_app
LOGIN
PASSWORD 'receipt_app_secure_password' -- documented default, see header
NOSUPERUSER
NOCREATEDB
NOCREATEROLE;
END IF;
END
$dbpermissions$;
-- 2. Allow the app to connect. The database name matches POSTGRES_DB /
-- the DATABASE_URL database (receipt_scanner); adjust if you use a
-- different database name.
GRANT CONNECT ON DATABASE receipt_scanner TO receipt_app;
-- 3. Schema access: strip anything pre-existing, then grant USAGE only.
-- NOTE: CREATE is deliberately NOT granted — receipt_app can never create,
-- alter or drop schema objects (no DDL).
REVOKE ALL ON SCHEMA public FROM receipt_app;
GRANT USAGE ON SCHEMA public TO receipt_app;
-- 4. DML on the tables / sequences that exist right now. (On a fresh database
-- this grants nothing yet — step 5 covers the objects the owner creates
-- during migrations.)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO receipt_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO receipt_app;
-- 5. Future objects: when the owner (receipt_user) creates tables / sequences
-- during later migrations, the runtime role gets the same DML automatically.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO receipt_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO receipt_app;
-- 6. Harden the schema: take CREATE away from the PUBLIC pseudo-role so no
-- other database user can create objects in public either. Must be run by
-- the schema owner / a superuser (see header). The owner (receipt_user) is
-- unaffected — it keeps full rights through pg_database_owner — so
-- migrations and schema init keep working.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

View File

@@ -0,0 +1,83 @@
const EN_KEYWORDS = [
'receipt scanner',
'receipt to excel',
'receipt scanner to excel',
'expense tracker',
'receipt keeper',
'invoice scanner',
'tax receipts',
'ocr receipt scanner',
'smart receipts',
'mileage and receipts',
'receipt organizer',
'bookkeeping scanner',
'scan receipts for taxes',
'extract receipt to csv',
'business expense tracker'
];
const DE_KEYWORDS = [
'beleg scanner',
'belege digitalisieren',
'rechnung scanner',
'kassenbon scanner',
'quittung scanner',
'spesen app',
'buchhaltung scanner',
'datev scanner',
'receipt to excel',
'haushaltsbuch',
'ausgaben tracker',
'steuer belege',
'belegmanager'
];
const COUNTRIES = {
US: 'us',
GB: 'gb',
CA: 'ca',
AU: 'au',
DE: 'de'
};
async function fetchSearch(term, country) {
try {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=5`;
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (!res.ok) return [];
const data = await res.json();
return data.results || [];
} catch {
return [];
}
}
async function run() {
const summary = {};
for (const [cName, cCode] of Object.entries(COUNTRIES)) {
summary[cName] = {};
const kws = cName === 'DE' ? DE_KEYWORDS : EN_KEYWORDS;
// run in chunks of 5
for (let i = 0; i < kws.length; i += 5) {
const chunk = kws.slice(i, i + 5);
await Promise.all(chunk.map(async (kw) => {
const apps = await fetchSearch(kw, cCode);
const top5Reviews = apps.reduce((sum, a) => sum + (a.userRatingCount || 0), 0);
const avgReviews = Math.round(top5Reviews / (apps.length || 1));
const top1 = apps[0] || {};
summary[cName][kw] = {
count: apps.length,
top1: top1.trackName ? `${top1.trackName.slice(0, 30)} (${top1.userRatingCount || 0} rev)` : 'None',
avgReviews
};
}));
}
}
console.log(JSON.stringify(summary, null, 2));
}
run();

View File

@@ -0,0 +1,113 @@
import https from 'https';
import fs from 'fs';
const EN_KEYWORDS = [
'receipt scanner to excel',
'receipt to excel',
'receipt scanner',
'expense tracker',
'tax receipt organizer',
'receipt keeper',
'invoice scanner',
'bookkeeping scanner',
'ocr receipt scanner',
'extract receipt to csv'
];
const DE_KEYWORDS = [
'beleg scanner',
'belege digitalisieren',
'kassenbon scanner',
'quittung scanner',
'spesen app',
'rechnung scanner',
'receipt to excel',
'datev scanner',
'belegmanager',
'ausgaben tracker'
];
const STOREFRONTS = [
{ id: 'US', country: 'us', name: 'USA', lang: 'en' },
{ id: 'GB', country: 'gb', name: 'UK', lang: 'en' },
{ id: 'CA', country: 'ca', name: 'Kanada', lang: 'en' },
{ id: 'AU', country: 'au', name: 'Australien', lang: 'en' },
{ id: 'DE', country: 'de', name: 'Deutschland', lang: 'de' },
{ id: 'AT', country: 'at', name: 'Österreich', lang: 'de' },
{ id: 'CH', country: 'ch', name: 'Schweiz', lang: 'de' }
];
function fetchStoreData(term, country) {
return new Promise((resolve) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=10`;
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json.results || []);
} catch {
resolve([]);
}
});
}).on('error', () => resolve([]));
});
}
function calculateDifficulty(apps, term) {
if (!apps || apps.length === 0) return { score: 10, tier: 'Sehr gering' };
const top5 = apps.slice(0, 5);
const totalReviews = top5.reduce((sum, a) => sum + (a.userRatingCount || 0), 0);
const avgReviews = totalReviews / top5.length;
const titleMatches = top5.filter(a => (a.trackName || '').toLowerCase().includes(term.toLowerCase())).length;
// Logarithmic scale for reviews
let reviewScore = 0;
if (avgReviews > 500000) reviewScore = 95;
else if (avgReviews > 100000) reviewScore = 85;
else if (avgReviews > 20000) reviewScore = 70;
else if (avgReviews > 5000) reviewScore = 55;
else if (avgReviews > 1000) reviewScore = 40;
else if (avgReviews > 100) reviewScore = 25;
else reviewScore = 15;
const matchScore = titleMatches * 5;
const finalScore = Math.min(99, Math.max(10, Math.round((reviewScore * 0.8) + (matchScore * 0.2))));
return {
score: finalScore,
avgReviews: Math.round(avgReviews),
top1: top5[0] ? `${top5[0].trackName} (${top5[0].userRatingCount || 0} rev, ★${top5[0].averageUserRating?.toFixed(1) || '0'})` : 'Keine',
resultCount: apps.length
};
}
async function run() {
console.log("Starte Live-Abfrage der Apple Storefront API...");
const report = {};
for (const sf of STOREFRONTS) {
console.log(`\nPrüfe Storefront: ${sf.name} (${sf.id})...`);
report[sf.id] = { name: sf.name, data: [] };
const keywords = sf.lang === 'de' ? DE_KEYWORDS : EN_KEYWORDS;
for (const kw of keywords) {
const apps = await fetchStoreData(kw, sf.country);
const diff = calculateDifficulty(apps, kw);
report[sf.id].data.push({
keyword: kw,
...diff
});
await new Promise(r => setTimeout(r, 200));
}
}
fs.writeFileSync('scripts/live_api_results.json', JSON.stringify(report, null, 2));
console.log("\nLive-Ergebnisse erfolgreich in scripts/live_api_results.json gespeichert!");
}
run();

View File

@@ -0,0 +1,65 @@
import fs from "fs";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
const candidateModels = [
"google/gemini-2.0-flash-001",
"openai/gpt-4o-mini",
"qwen/qwen-2.5-vl-72b-instruct:free",
"qwen/qwen-2.5-vl-72b-instruct",
"meta-llama/llama-3.2-11b-vision-instruct",
];
async function findWorkingVisionModel() {
for (const model of candidateModels) {
console.log(`\nTesting model: ${model}...`);
try {
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Receipt Scanner",
},
body: JSON.stringify({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: "Welcher Händler steht auf diesem Beleg und was ist die Gesamtsumme? Antworte kurz im JSON Format { merchant: string, total: number }" },
{
type: "image_url",
image_url: {
url: dataUrl
}
}
]
}
]
})
});
console.log(`Status: ${res.status} ${res.statusText}`);
const data = await res.json();
if (res.ok && data.choices && data.choices[0]) {
console.log(`✓ SUCCESS with ${model}!`);
console.log("Content:", data.choices[0].message.content);
return model;
} else {
console.log("Error response:", JSON.stringify(data));
}
} catch (e) {
console.log("Fetch error:", e.message);
}
}
}
findWorkingVisionModel().catch(console.error);

View File

@@ -0,0 +1,82 @@
import fs from "fs";
import path from "path";
import os from "os";
import { generateReceiptPdf } from "../src/lib/export/pdfGenerator";
import { ProcessedReceipt } from "../src/lib/schema/receipt";
const sampleReceipt: ProcessedReceipt = {
id: "rec-aral-1",
imageHash: "hash-aral",
originalFileName: "aral-tankstelle.jpg",
fileSizeBytes: 204800,
createdAt: "2026-08-18T10:00:00.000Z",
updatedAt: "2026-08-18T10:00:00.000Z",
status: "ready",
merchant: {
name: "Aral Tankstelle Station",
address: "Musterstraße 12, 10115 Berlin",
taxId: null,
confidence: 0.98,
},
date: {
isoDate: "2026-08-14",
time: "14:22",
confidence: 0.96,
},
documentType: "TANKBELEG",
receiptNumber: "AR-982341",
currency: "EUR",
totalAmount: {
value: 68.45,
confidence: 0.99,
},
netAmount: 57.52,
tipAmount: null,
taxBreakdown: [
{
ratePercent: 19,
taxAmount: 10.93,
netAmount: 57.52,
},
],
lineItems: [
{
description: "Diesel",
quantity: 27.46,
unitPrice: 1.8,
taxRate: 19,
price: 49.43,
},
{
description: "Shop Artikel",
quantity: 1,
unitPrice: null,
taxRate: 19,
price: 11.86,
},
],
suggestedCategory: "Tanken & KFZ",
paymentMethod: "EC_KARTE",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
userConfirmed: false,
},
} as ProcessedReceipt;
async function run() {
const pdfBytes = await generateReceiptPdf([sampleReceipt], { locale: "de" });
const downloadsDir = path.join(os.homedir(), "Downloads");
const targetFile = path.join(downloadsDir, "Belege_Export_ScanReceipts.pdf");
fs.writeFileSync(targetFile, Buffer.from(pdfBytes));
console.log("SUCCESS: PDF saved to", targetFile);
}
run().catch((err) => {
console.error("ERROR generating PDF:", err);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
import crypto from 'crypto';
import fs from 'fs';
// Generate EC key pair with curve prime256v1 (P-256) as required by Apple
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
console.log("=== NEUER PUBLIC KEY (FÜR APPLE SEARCH ADS) ===");
console.log(publicKey);
// Update .env.local with the new private key
const envPath = '.env.local';
let envContent = fs.readFileSync(envPath, 'utf8');
// Replace or set APPLE_SEARCH_ADS_PRIVATE_KEY
const cleanPrivateKey = privateKey.replace(/\r\n/g, '\n');
const formattedPrivateKey = `APPLE_SEARCH_ADS_PRIVATE_KEY="${cleanPrivateKey.replace(/\n/g, '\\n')}"`;
if (envContent.includes('APPLE_SEARCH_ADS_PRIVATE_KEY=')) {
envContent = envContent.replace(/APPLE_SEARCH_ADS_PRIVATE_KEY=.*/g, formattedPrivateKey);
} else {
envContent += `\n${formattedPrivateKey}\n`;
}
// Update public key in .env.local too
const formattedPublicKey = `APPLE_SEARCH_ADS_PUBLIC_KEY="${publicKey.replace(/\r\n/g, '\n').replace(/\n/g, '\\n')}"`;
if (envContent.includes('APPLE_SEARCH_ADS_PUBLIC_KEY=')) {
envContent = envContent.replace(/APPLE_SEARCH_ADS_PUBLIC_KEY=.*/g, formattedPublicKey);
} else {
envContent += `\n${formattedPublicKey}\n`;
}
fs.writeFileSync(envPath, envContent);
console.log("\n-> Private Key wurde automatisch in .env.local gespeichert!");

View File

@@ -0,0 +1,383 @@
import fs from "fs";
import path from "path";
import sharp from "sharp";
const showcaseDir = path.join(process.cwd(), "public", "showcase");
if (!fs.existsSync(showcaseDir)) {
fs.mkdirSync(showcaseDir, { recursive: true });
}
// 1. Synthwave / Cyberpunk Sunset (2400 x 1350 - 16:9 2K)
const synthwaveSvg = `
<svg width="2400" height="1350" viewBox="0 0 2400 1350" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0a051b" />
<stop offset="35%" stop-color="#1b0a3a" />
<stop offset="65%" stop-color="#4a0e4e" />
<stop offset="85%" stop-color="#a01a7d" />
<stop offset="100%" stop-color="#ff5e62" />
</linearGradient>
<linearGradient id="sunGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffee55" />
<stop offset="40%" stop-color="#ff5483" />
<stop offset="100%" stop-color="#8000ff" />
</linearGradient>
<linearGradient id="gridGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ff007f" stop-opacity="0.9" />
<stop offset="100%" stop-color="#00f2fe" stop-opacity="0.1" />
</linearGradient>
<linearGradient id="mountGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2d0b4d" />
<stop offset="100%" stop-color="#0e041d" />
</linearGradient>
<linearGradient id="mountGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#551268" />
<stop offset="100%" stop-color="#120420" />
</linearGradient>
<filter id="neonGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="25" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="softGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="60" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Sky -->
<rect width="2400" height="1350" fill="url(#skyGrad)" />
<!-- Stars -->
${Array.from({ length: 120 })
.map(() => {
const cx = Math.floor(Math.random() * 2400);
const cy = Math.floor(Math.random() * 700);
const r = (Math.random() * 1.8 + 0.5).toFixed(1);
const op = (Math.random() * 0.7 + 0.3).toFixed(2);
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#fff" opacity="${op}" />`;
})
.join("\n ")}
<!-- Sun Glow & Sun -->
<circle cx="1200" cy="720" r="340" fill="url(#sunGrad)" filter="url(#softGlow)" />
<!-- Sun Horizontal Blinds (Synthwave Bars) -->
${[600, 640, 675, 705, 730, 750, 765, 777]
.map((y, i) => `<rect x="800" y="${y}" width="800" height="${4 + i * 2.2}" fill="#1b0a3a" />`)
.join("\n ")}
<!-- Distant Mountains Layer 1 -->
<polygon points="0,850 280,680 520,790 850,620 1100,740 1450,590 1780,750 2050,650 2400,820 2400,1350 0,1350" fill="url(#mountGrad1)" opacity="0.95"/>
<!-- Foreground Mountains Layer 2 -->
<polygon points="0,920 380,730 750,880 1200,670 1620,870 1950,710 2400,900 2400,1350 0,1350" fill="url(#mountGrad2)" opacity="0.9"/>
<!-- Horizon Glow -->
<line x1="0" y1="850" x2="2400" y2="850" stroke="#00f2fe" stroke-width="4" filter="url(#neonGlow)" />
<!-- 3D Perspective Grid Plane -->
<rect x="0" y="850" width="2400" height="500" fill="#070210" />
<!-- Horizontal Grid Lines with exponential spacing -->
${[860, 875, 895, 920, 955, 1000, 1060, 1135, 1225, 1335]
.map((y) => `<line x1="0" y1="${y}" x2="2400" y2="${y}" stroke="url(#gridGrad)" stroke-width="${1 + (y - 850) / 100}" />`)
.join("\n ")}
<!-- Perspective Vanishing Lines radiating from center horizon -->
${Array.from({ length: 33 })
.map((_, i) => {
const bottomX = (i - 16) * 160 + 1200;
return `<line x1="1200" y1="850" x2="${bottomX}" y2="1350" stroke="#ff00a0" stroke-width="1.8" opacity="0.75" />`;
})
.join("\n ")}
<!-- Title / Logo Typography -->
<text x="1200" y="240" font-family="'Inter', 'Montserrat', 'Helvetica', sans-serif" font-size="64" font-weight="900" letter-spacing="16" fill="#ffffff" text-anchor="middle" filter="url(#neonGlow)">NEON HORIZON</text>
<text x="1200" y="295" font-family="'Courier New', monospace" font-size="20" letter-spacing="10" fill="#00f2fe" text-anchor="middle" opacity="0.9">PROCEDURAL VECTOR GRAPHICS ENGINE</text>
</svg>
`;
// 2. Premium Dark Mode Glassmorphic FinTech Dashboard (2000 x 2000 - 1:1 Square)
const glassmorphismSvg = `
<svg width="2000" height="2000" viewBox="0 0 2000 2000" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Background Gradient -->
<radialGradient id="bgDark" cx="50%" cy="30%" r="90%">
<stop offset="0%" stop-color="#14192b" />
<stop offset="50%" stop-color="#0b0e17" />
<stop offset="100%" stop-color="#05070c" />
</radialGradient>
<!-- Orb Gradients -->
<radialGradient id="orbPurple" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="#b55fe6" />
<stop offset="60%" stop-color="#6e24db" />
<stop offset="100%" stop-color="#2a0873" />
</radialGradient>
<radialGradient id="orbCyan" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="#5bf0e4" />
<stop offset="50%" stop-color="#0b9df2" />
<stop offset="100%" stop-color="#023b82" />
</radialGradient>
<linearGradient id="cardGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255, 255, 255, 0.12)" />
<stop offset="100%" stop-color="rgba(255, 255, 255, 0.02)" />
</linearGradient>
<linearGradient id="accentGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="50%" stop-color="#a855f7" />
<stop offset="100%" stop-color="#ec4899" />
</linearGradient>
<filter id="blurOrb" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80" />
</filter>
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="30" stdDeviation="40" flood-color="#000" flood-opacity="0.6"/>
</filter>
</defs>
<!-- Background -->
<rect width="2000" height="2000" fill="url(#bgDark)" />
<!-- Background Ambient Glow Orbs -->
<circle cx="450" cy="500" r="380" fill="url(#orbPurple)" filter="url(#blurOrb)" opacity="0.65" />
<circle cx="1550" cy="1400" r="440" fill="url(#orbCyan)" filter="url(#blurOrb)" opacity="0.55" />
<circle cx="1600" cy="400" r="280" fill="#ec4899" filter="url(#blurOrb)" opacity="0.35" />
<!-- Subtle Matrix Dot Grid -->
${Array.from({ length: 20 })
.map((_, row) =>
Array.from({ length: 20 })
.map((_, col) => `<circle cx="${100 + col * 95}" cy="${100 + row * 95}" r="2" fill="#ffffff" opacity="0.08" />`)
.join("")
)
.join("\n ")}
<!-- Main Central Glass Card -->
<g filter="url(#cardShadow)">
<rect x="300" y="320" width="1400" height="1360" rx="40" fill="url(#cardGrad)" stroke="rgba(255,255,255,0.22)" stroke-width="2" />
<!-- Top Bar inside Card -->
<circle cx="380" cy="400" r="12" fill="#ef4444" opacity="0.8" />
<circle cx="420" cy="400" r="12" fill="#f59e0b" opacity="0.8" />
<circle cx="460" cy="400" r="12" fill="#10b981" opacity="0.8" />
<text x="1000" y="408" font-family="'Inter', sans-serif" font-size="22" font-weight="600" fill="#a1a1aa" text-anchor="middle" letter-spacing="2">QUANTUM ANALYTICS ENTERPRISE</text>
<!-- Header Stats -->
<text x="380" y="520" font-family="'Inter', sans-serif" font-size="20" font-weight="500" fill="#94a3b8" letter-spacing="1">TOTAL REVENUE (YTD)</text>
<text x="380" y="590" font-family="'Inter', sans-serif" font-size="64" font-weight="800" fill="#ffffff" letter-spacing="-1">$4,892,340.50</text>
<!-- Growth Badge -->
<rect x="920" y="535" width="140" height="44" rx="22" fill="rgba(16, 185, 129, 0.15)" stroke="#10b981" stroke-width="1.5" />
<text x="990" y="563" font-family="'Inter', sans-serif" font-size="18" font-weight="700" fill="#10b981" text-anchor="middle">+34.8% ↑</text>
<!-- Chart Area -->
<g transform="translate(380, 680)">
<!-- Grid lines -->
<line x1="0" y1="0" x2="1240" y2="0" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
<line x1="0" y1="120" x2="1240" y2="120" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
<line x1="0" y1="240" x2="1240" y2="240" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
<line x1="0" y1="360" x2="1240" y2="360" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-dasharray="6,6" />
<!-- Area Chart Gradient Fill -->
<defs>
<linearGradient id="areaGlow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8b5cf6" stop-opacity="0.45" />
<stop offset="100%" stop-color="#8b5cf6" stop-opacity="0.0" />
</linearGradient>
</defs>
<!-- Smooth Spline Path -->
<path d="M 0,320 C 150,280 250,180 400,210 C 550,240 700,80 850,110 C 1000,140 1100,30 1240,10 L 1240,400 L 0,400 Z" fill="url(#areaGlow)" />
<path d="M 0,320 C 150,280 250,180 400,210 C 550,240 700,80 850,110 C 1000,140 1100,30 1240,10" fill="none" stroke="url(#accentGrad)" stroke-width="6" stroke-linecap="round" />
<!-- Glowing Data Points -->
<circle cx="400" cy="210" r="9" fill="#8b5cf6" stroke="#fff" stroke-width="3" />
<circle cx="850" cy="110" r="9" fill="#ec4899" stroke="#fff" stroke-width="3" />
<circle cx="1240" cy="10" r="12" fill="#00f2fe" stroke="#fff" stroke-width="4" />
</g>
<!-- Sub Cards (Mini Metrics) -->
<!-- Card 1 -->
<g transform="translate(380, 1180)">
<rect width="380" height="240" rx="24" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" stroke-width="1.5" />
<text x="40" y="60" font-family="'Inter', sans-serif" font-size="18" font-weight="600" fill="#94a3b8">Active Workflows</text>
<text x="40" y="130" font-family="'Inter', sans-serif" font-size="44" font-weight="800" fill="#ffffff">18,492</text>
<text x="40" y="185" font-family="'Inter', sans-serif" font-size="16" font-weight="500" fill="#38bdf8">99.98% uptime</text>
</g>
<!-- Card 2 -->
<g transform="translate(810, 1180)">
<rect width="380" height="240" rx="24" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" stroke-width="1.5" />
<text x="40" y="60" font-family="'Inter', sans-serif" font-size="18" font-weight="600" fill="#94a3b8">AI Inference Latency</text>
<text x="40" y="130" font-family="'Inter', sans-serif" font-size="44" font-weight="800" fill="#ffffff">14.2 ms</text>
<text x="40" y="185" font-family="'Inter', sans-serif" font-size="16" font-weight="500" fill="#a855f7">Realtime stream</text>
</g>
<!-- Card 3 -->
<g transform="translate(1240, 1180)">
<rect width="380" height="240" rx="24" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" stroke-width="1.5" />
<text x="40" y="60" font-family="'Inter', sans-serif" font-size="18" font-weight="600" fill="#94a3b8">Data Processed</text>
<text x="40" y="130" font-family="'Inter', sans-serif" font-size="44" font-weight="800" fill="#ffffff">942.6 TB</text>
<text x="40" y="185" font-family="'Inter', sans-serif" font-size="16" font-weight="500" fill="#10b981">+12.4% vs last week</text>
</g>
</g>
</svg>
`;
// 3. Sacred Geometry / Algorithmic Golden Mandala (2400 x 2400 - Ultra HD)
function generateMandalaSvg() {
const size = 2400;
const center = size / 2;
const rings = 12;
const petals = 24;
let elements = [];
for (let r = 1; r <= rings; r++) {
const radius = r * 85;
const strokeWidth = (r % 3 === 0 ? 3 : 1.2);
const opacity = (0.2 + (r / rings) * 0.7).toFixed(2);
elements.push(`<circle cx="${center}" cy="${center}" r="${radius}" fill="none" stroke="url(#goldGrad)" stroke-width="${strokeWidth}" opacity="${opacity}" />`);
}
for (let i = 0; i < petals; i++) {
const angle = (i * 360) / petals;
elements.push(`
<g transform="rotate(${angle} ${center} ${center})">
<ellipse cx="${center}" cy="${center - 380}" rx="90" ry="340" fill="none" stroke="url(#goldGrad)" stroke-width="1.8" opacity="0.6" />
<circle cx="${center}" cy="${center - 650}" r="25" fill="none" stroke="#ffd700" stroke-width="2.5" />
<line x1="${center}" y1="${center - 100}" x2="${center}" y2="${center - 950}" stroke="url(#goldGrad)" stroke-width="1" opacity="0.4" stroke-dasharray="5,8" />
<polygon points="${center},${center - 850} ${center - 30},${center - 780} ${center + 30},${center - 780}" fill="url(#goldGrad)" opacity="0.75" />
</g>
`);
}
for (let i = 0; i < 48; i++) {
const angle = (i * 360) / 48;
elements.push(`
<g transform="rotate(${angle} ${center} ${center})">
<circle cx="${center}" cy="${center - 880}" r="6" fill="#fff5cc" opacity="0.8" />
</g>
`);
}
return `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="darkCosmos" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#120c02" />
<stop offset="60%" stop-color="#070501" />
<stop offset="100%" stop-color="#000000" />
</radialGradient>
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe259" />
<stop offset="50%" stop-color="#ffa751" />
<stop offset="100%" stop-color="#e67e22" />
</linearGradient>
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffdd59" stop-opacity="0.8" />
<stop offset="40%" stop-color="#ff9f43" stop-opacity="0.3" />
<stop offset="100%" stop-color="#000" stop-opacity="0" />
</radialGradient>
<filter id="goldGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="15" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="${size}" height="${size}" fill="url(#darkCosmos)" />
<!-- Center Ambient Sun Glow -->
<circle cx="${center}" cy="${center}" r="550" fill="url(#centerGlow)" />
<!-- Mandala Geometry -->
<g filter="url(#goldGlow)">
${elements.join("\n")}
</g>
<!-- Center Focal Core -->
<circle cx="${center}" cy="${center}" r="60" fill="url(#goldGrad)" />
<circle cx="${center}" cy="${center}" r="80" fill="none" stroke="#fff" stroke-width="2" opacity="0.8" />
<circle cx="${center}" cy="${center}" r="110" fill="none" stroke="url(#goldGrad)" stroke-width="4" />
<text x="${center}" y="${size - 120}" font-family="'Cinzel', 'Times New Roman', serif" font-size="36" letter-spacing="18" fill="#ffd700" text-anchor="middle" opacity="0.9">SACRED ALGORITHMIC HARMONY</text>
</svg>
`;
}
async function runShowcase() {
console.log("🎨 Generating High-Resolution Showcase Art in public/showcase/...\n");
const items = [
{
filename: "01_synthwave_cyberpunk_sunset.png",
svg: synthwaveSvg,
type: "png",
},
{
filename: "02_glassmorphic_fintech_dashboard.png",
svg: glassmorphismSvg,
type: "png",
},
{
filename: "03_sacred_geometry_mandala_2400.png",
svg: generateMandalaSvg(),
type: "png",
},
];
for (const item of items) {
const destPath = path.join(showcaseDir, item.filename);
const svgBuffer = Buffer.from(item.svg);
console.log(`Rendering ${item.filename}...`);
if (item.type === "png") {
await sharp(svgBuffer)
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(destPath);
} else {
await sharp(svgBuffer)
.jpeg({ quality: 98, mozjpeg: true })
.toFile(destPath);
}
const stat = fs.statSync(destPath);
console.log(`✓ Created: ${item.filename} (${(stat.size / 1024).toFixed(1)} KB)`);
}
console.log("\n🚀 All showcase art generated successfully in public/showcase/!");
}
runShowcase().catch((err) => {
console.error("Error generating showcase images:", err);
process.exit(1);
});

View File

@@ -0,0 +1,368 @@
import fs from "fs";
import path from "path";
import sharp from "sharp";
const demoDir = path.join(process.cwd(), "public", "demo");
if (!fs.existsSync(demoDir)) {
fs.mkdirSync(demoDir, { recursive: true });
}
// 1. Aral Tankstelle Receipt SVG
const aralSvg = `
<svg width="600" height="900" xmlns="http://www.w3.org/2000/svg" style="background:#f7f6f2; font-family:'Courier New', monospace;">
<!-- Receipt Paper Background with slight grunge -->
<rect x="20" y="20" width="560" height="860" fill="#fffdfa" stroke="#d5d0c8" stroke-width="2" rx="4" />
<text x="300" y="80" font-size="28" font-weight="bold" text-anchor="middle" fill="#111">ARAL TANKSTELLE</text>
<text x="300" y="110" font-size="16" text-anchor="middle" fill="#333">Station 10492 München</text>
<text x="300" y="130" font-size="14" text-anchor="middle" fill="#555">Hauptstraße 42, 80331 München</text>
<text x="300" y="150" font-size="14" text-anchor="middle" fill="#555">USt-IdNr: DE123456789</text>
<line x1="50" y1="180" x2="550" y2="180" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
<text x="50" y="210" font-size="14" fill="#333">Datum: 14.08.2026</text>
<text x="400" y="210" font-size="14" fill="#333">Uhrzeit: 08:42</text>
<text x="50" y="235" font-size="14" fill="#333">Beleg-Nr: AR-982341</text>
<text x="400" y="235" font-size="14" fill="#333">Säule: 04</text>
<line x1="50" y1="260" x2="550" y2="260" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
<!-- Line items header -->
<text x="50" y="290" font-size="15" font-weight="bold" fill="#111">ARTIKEL</text>
<text x="360" y="290" font-size="15" font-weight="bold" fill="#111">MENGE</text>
<text x="480" y="290" font-size="15" font-weight="bold" fill="#111">EUR</text>
<!-- Items -->
<text x="50" y="330" font-size="15" fill="#222">SUPER E10 (1.699 EUR/l)</text>
<text x="370" y="330" font-size="15" fill="#222">38.24 l</text>
<text x="480" y="330" font-size="15" fill="#222">64.95 B</text>
<text x="50" y="365" font-size="15" fill="#222">KAFFEE CREMA GROSS</text>
<text x="370" y="365" font-size="15" fill="#222">1</text>
<text x="480" y="365" font-size="15" fill="#222">3.50 B</text>
<line x1="50" y1="410" x2="550" y2="410" stroke="#222" stroke-width="2" />
<!-- Total -->
<text x="50" y="455" font-size="24" font-weight="bold" fill="#000">SUMME EUR</text>
<text x="430" y="455" font-size="28" font-weight="bold" fill="#000">68,45</text>
<line x1="50" y1="480" x2="550" y2="480" stroke="#222" stroke-width="2" />
<!-- Tax breakdown -->
<text x="50" y="520" font-size="14" font-weight="bold" fill="#333">MwSt-Satz</text>
<text x="220" y="520" font-size="14" font-weight="bold" fill="#333">Netto</text>
<text x="380" y="520" font-size="14" font-weight="bold" fill="#333">MwSt</text>
<text x="480" y="520" font-size="14" font-weight="bold" fill="#333">Brutto</text>
<text x="50" y="550" font-size="14" fill="#333">B = 19%</text>
<text x="220" y="550" font-size="14" fill="#333">57,52</text>
<text x="380" y="550" font-size="14" fill="#333">10,93</text>
<text x="480" y="550" font-size="14" fill="#333">68,45</text>
<!-- Payment -->
<text x="50" y="610" font-size="14" fill="#333">ZAHLUNG: EC-Karte Maestro / Girocard</text>
<text x="50" y="635" font-size="14" fill="#333">TERMINAL: 89012241 • TRACE: 4491</text>
<text x="50" y="660" font-size="14" fill="#333">TSE-Signatur: VALIDATED</text>
<!-- Fake Barcode -->
<rect x="120" y="700" width="360" height="50" fill="#111" />
<line x1="130" y1="700" x2="130" y2="750" stroke="#fff" stroke-width="4" />
<line x1="145" y1="700" x2="145" y2="750" stroke="#fff" stroke-width="2" />
<line x1="160" y1="700" x2="160" y2="750" stroke="#fff" stroke-width="6" />
<line x1="180" y1="700" x2="180" y2="750" stroke="#fff" stroke-width="3" />
<line x1="200" y1="700" x2="200" y2="750" stroke="#fff" stroke-width="5" />
<line x1="220" y1="700" x2="220" y2="750" stroke="#fff" stroke-width="2" />
<line x1="240" y1="700" x2="240" y2="750" stroke="#fff" stroke-width="4" />
<line x1="260" y1="700" x2="260" y2="750" stroke="#fff" stroke-width="2" />
<line x1="285" y1="700" x2="285" y2="750" stroke="#fff" stroke-width="6" />
<line x1="310" y1="700" x2="310" y2="750" stroke="#fff" stroke-width="3" />
<line x1="330" y1="700" x2="330" y2="750" stroke="#fff" stroke-width="5" />
<line x1="350" y1="700" x2="350" y2="750" stroke="#fff" stroke-width="2" />
<line x1="370" y1="700" x2="370" y2="750" stroke="#fff" stroke-width="4" />
<line x1="390" y1="700" x2="390" y2="750" stroke="#fff" stroke-width="2" />
<line x1="410" y1="700" x2="410" y2="750" stroke="#fff" stroke-width="5" />
<line x1="430" y1="700" x2="430" y2="750" stroke="#fff" stroke-width="3" />
<line x1="450" y1="700" x2="450" y2="750" stroke="#fff" stroke-width="6" />
<text x="300" y="800" font-size="14" text-anchor="middle" fill="#555">Vielen Dank für Ihren Besuch!</text>
</svg>
`;
// 2. Trattoria Restaurant Bewirtungsbeleg SVG
const restaurantSvg = `
<svg width="600" height="980" xmlns="http://www.w3.org/2000/svg" style="background:#f5f3ee; font-family:'Courier New', monospace;">
<rect x="20" y="20" width="560" height="940" fill="#fffefb" stroke="#ded8cf" stroke-width="2" rx="4" />
<text x="300" y="80" font-size="26" font-weight="bold" text-anchor="middle" fill="#111">TRATTORIA BELLA VISTA</text>
<text x="300" y="110" font-size="15" text-anchor="middle" fill="#333">Italienische Spezialitäten</text>
<text x="300" y="130" font-size="14" text-anchor="middle" fill="#555">Marktplatz 12, 10115 Berlin</text>
<text x="300" y="150" font-size="14" text-anchor="middle" fill="#555">St.-Nr: 34/815/09123 • USt-Id: DE987654321</text>
<line x1="50" y1="180" x2="550" y2="180" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
<text x="50" y="210" font-size="14" fill="#333">Tisch: 14</text>
<text x="250" y="210" font-size="14" fill="#333">Bedienung: Marco</text>
<text x="420" y="210" font-size="14" fill="#333">Pax: 2</text>
<text x="50" y="235" font-size="14" fill="#333">Datum: 14.08.2026</text>
<text x="250" y="235" font-size="14" fill="#333">Zeit: 20:15</text>
<text x="420" y="235" font-size="14" fill="#333">Rechn-Nr: TR-44201</text>
<line x1="50" y1="260" x2="550" y2="260" stroke="#888" stroke-dasharray="6,4" stroke-width="2" />
<!-- Header -->
<text x="50" y="295" font-size="15" font-weight="bold" fill="#111">POS</text>
<text x="110" y="295" font-size="15" font-weight="bold" fill="#111">BESCHREIBUNG</text>
<text x="380" y="295" font-size="15" font-weight="bold" fill="#111">STK</text>
<text x="480" y="295" font-size="15" font-weight="bold" fill="#111">GESAMT</text>
<!-- Items -->
<text x="50" y="335" font-size="14" fill="#222">1</text>
<text x="110" y="335" font-size="14" fill="#222">Pasta Tartufo</text>
<text x="380" y="335" font-size="14" fill="#222">2</text>
<text x="480" y="335" font-size="14" fill="#222">44.00 A</text>
<text x="50" y="370" font-size="14" fill="#222">2</text>
<text x="110" y="370" font-size="14" fill="#222">S.Pellegrino 0.75l</text>
<text x="380" y="370" font-size="14" fill="#222">1</text>
<text x="480" y="370" font-size="14" fill="#222">7.50 A</text>
<text x="50" y="405" font-size="14" fill="#222">3</text>
<text x="110" y="405" font-size="14" fill="#222">Tiramisu Hausgemacht</text>
<text x="380" y="405" font-size="14" fill="#222">2</text>
<text x="480" y="405" font-size="14" fill="#222">16.00 B</text>
<text x="50" y="440" font-size="14" fill="#222">4</text>
<text x="110" y="440" font-size="14" fill="#222">Espresso Doppio</text>
<text x="380" y="440" font-size="14" fill="#222">1</text>
<text x="480" y="440" font-size="14" fill="#222">4.50 A</text>
<text x="50" y="475" font-size="14" fill="#222">5</text>
<text x="110" y="475" font-size="14" fill="#222">Trinkgeld / Tip freiwillig</text>
<text x="380" y="475" font-size="14" fill="#222">1</text>
<text x="480" y="475" font-size="14" fill="#222">12.50 -</text>
<line x1="50" y1="515" x2="550" y2="515" stroke="#222" stroke-width="2" />
<!-- Total -->
<text x="50" y="560" font-size="24" font-weight="bold" fill="#000">GESAMTBETRAG</text>
<text x="430" y="560" font-size="28" font-weight="bold" fill="#000">84,50 €</text>
<line x1="50" y1="585" x2="550" y2="585" stroke="#222" stroke-width="2" />
<!-- Steuern -->
<text x="50" y="625" font-size="13" font-weight="bold" fill="#444">MwSt-Satz</text>
<text x="210" y="625" font-size="13" font-weight="bold" fill="#444">Netto</text>
<text x="360" y="625" font-size="13" font-weight="bold" fill="#444">MwSt</text>
<text x="470" y="625" font-size="13" font-weight="bold" fill="#444">Brutto</text>
<text x="50" y="650" font-size="13" fill="#444">A = 19% (Getr./Speis.)</text>
<text x="210" y="650" font-size="13" fill="#444">47,06</text>
<text x="360" y="650" font-size="13" fill="#444">8,94</text>
<text x="470" y="650" font-size="13" fill="#444">56,00</text>
<text x="50" y="675" font-size="13" fill="#444">B = 7% (Dessert to go)</text>
<text x="210" y="675" font-size="13" fill="#444">14,95</text>
<text x="360" y="675" font-size="13" fill="#444">1,05</text>
<text x="470" y="675" font-size="13" fill="#444">16,00</text>
<!-- Bewirtungsbeleg Fields -->
<rect x="45" y="720" width="510" height="150" fill="#faf8f5" stroke="#bbb" stroke-width="1" />
<text x="60" y="745" font-size="12" font-weight="bold" fill="#333">Angaben zur geschäftlichen Bewirtung (§ 4 Abs. 5 EStG):</text>
<text x="60" y="775" font-size="12" fill="#666">Anlass der Bewirtung: ................................................</text>
<text x="60" y="805" font-size="12" fill="#666">Bewirtete Personen: ................................................</text>
<text x="60" y="835" font-size="12" fill="#666">Ort, Datum: Berlin, 14.08.2026 Unterschrift: .......................</text>
<text x="300" y="910" font-size="14" text-anchor="middle" fill="#555">Arrivederci &amp; Grazie Mille!</text>
</svg>
`;
// 3. MediaMarkt Saturn IT Receipt SVG
const mediamarktSvg = `
<svg width="600" height="920" xmlns="http://www.w3.org/2000/svg" style="background:#f4f5f7; font-family:'Courier New', monospace;">
<rect x="20" y="20" width="560" height="880" fill="#ffffff" stroke="#cbd5e1" stroke-width="2" rx="4" />
<text x="300" y="75" font-size="28" font-weight="bold" text-anchor="middle" fill="#df0000">MediaMarkt</text>
<text x="300" y="105" font-size="15" text-anchor="middle" fill="#333">MediaMarkt Saturn Holding GmbH</text>
<text x="300" y="125" font-size="14" text-anchor="middle" fill="#555">Alexanderplatz 3, 10178 Berlin</text>
<text x="300" y="145" font-size="14" text-anchor="middle" fill="#555">USt-IdNr: DE119876543 • Filiale 204</text>
<line x1="50" y1="175" x2="550" y2="175" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
<text x="50" y="205" font-size="14" fill="#333">Datum: 14.08.2026</text>
<text x="400" y="205" font-size="14" fill="#333">Zeit: 14:30:12</text>
<text x="50" y="230" font-size="14" fill="#333">Kassenbon-Nr: MM-2026-9811</text>
<text x="400" y="230" font-size="14" fill="#333">Kasse: 02</text>
<line x1="50" y1="255" x2="550" y2="255" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
<text x="50" y="290" font-size="15" font-weight="bold" fill="#0f172a">ARTIKEL</text>
<text x="380" y="290" font-size="15" font-weight="bold" fill="#0f172a">MENGE</text>
<text x="480" y="290" font-size="15" font-weight="bold" fill="#0f172a">PREIS</text>
<!-- Items -->
<text x="50" y="330" font-size="14" fill="#1e293b">USB-C DOCK 100W PRO</text>
<text x="50" y="350" font-size="12" fill="#64748b">Art-Nr: 48920194 • 19% MwSt</text>
<text x="390" y="330" font-size="14" fill="#1e293b">1</text>
<text x="480" y="330" font-size="14" fill="#1e293b">89.99</text>
<text x="50" y="390" font-size="14" fill="#1e293b">LOGITECH MX MASTER 3S</text>
<text x="50" y="410" font-size="12" fill="#64748b">Art-Nr: 29014811 • 19% MwSt</text>
<text x="390" y="390" font-size="14" fill="#1e293b">1</text>
<text x="480" y="390" font-size="14" fill="#1e293b">40.00</text>
<line x1="50" y1="455" x2="550" y2="455" stroke="#0f172a" stroke-width="2" />
<!-- Total -->
<text x="50" y="500" font-size="24" font-weight="bold" fill="#0f172a">GESAMTBETRAG</text>
<text x="410" y="500" font-size="28" font-weight="bold" fill="#0f172a">129,99 €</text>
<line x1="50" y1="525" x2="550" y2="525" stroke="#0f172a" stroke-width="2" />
<!-- Tax breakdown -->
<text x="50" y="565" font-size="14" font-weight="bold" fill="#334155">Steuersatz</text>
<text x="220" y="565" font-size="14" font-weight="bold" fill="#334155">Netto</text>
<text x="370" y="565" font-size="14" font-weight="bold" fill="#334155">Steuer</text>
<text x="470" y="565" font-size="14" font-weight="bold" fill="#334155">Brutto</text>
<text x="50" y="595" font-size="14" fill="#334155">19%</text>
<text x="220" y="595" font-size="14" fill="#334155">109,24 €</text>
<text x="370" y="595" font-size="14" fill="#334155">20,75 €</text>
<text x="470" y="595" font-size="14" fill="#334155">129,99 €</text>
<!-- Payment -->
<text x="50" y="655" font-size="14" fill="#334155">Zahlart: Apple Pay / VISA (**** 4819)</text>
<text x="50" y="680" font-size="14" fill="#334155">Autorisierung: 902184 • TSE: OK</text>
<!-- Barcode -->
<rect x="120" y="730" width="360" height="50" fill="#0f172a" />
<line x1="140" y1="730" x2="140" y2="780" stroke="#fff" stroke-width="4" />
<line x1="165" y1="730" x2="165" y2="780" stroke="#fff" stroke-width="3" />
<line x1="190" y1="730" x2="190" y2="780" stroke="#fff" stroke-width="6" />
<line x1="220" y1="730" x2="220" y2="780" stroke="#fff" stroke-width="2" />
<line x1="250" y1="730" x2="250" y2="780" stroke="#fff" stroke-width="5" />
<line x1="280" y1="730" x2="280" y2="780" stroke="#fff" stroke-width="3" />
<line x1="310" y1="730" x2="310" y2="780" stroke="#fff" stroke-width="6" />
<line x1="340" y1="730" x2="340" y2="780" stroke="#fff" stroke-width="2" />
<line x1="370" y1="730" x2="370" y2="780" stroke="#fff" stroke-width="5" />
<line x1="400" y1="730" x2="400" y2="780" stroke="#fff" stroke-width="4" />
<line x1="430" y1="730" x2="430" y2="780" stroke="#fff" stroke-width="2" />
<text x="300" y="830" font-size="14" text-anchor="middle" fill="#64748b">Umtausch innerhalb 14 Tagen mit Kassenbon.</text>
</svg>
`;
// 4. REWE Supermarkt Kassenbon SVG
const reweSvg = `
<svg width="600" height="920" xmlns="http://www.w3.org/2000/svg" style="background:#fbfbf9; font-family:'Courier New', monospace;">
<rect x="20" y="20" width="560" height="880" fill="#ffffff" stroke="#e2e8f0" stroke-width="2" rx="4" />
<text x="300" y="75" font-size="32" font-weight="bold" text-anchor="middle" fill="#c00">REWE</text>
<text x="300" y="105" font-size="15" text-anchor="middle" fill="#333">REWE Markt GmbH • Filiale 1089</text>
<text x="300" y="125" font-size="14" text-anchor="middle" fill="#555">Friedrichstraße 190, 10117 Berlin</text>
<text x="300" y="145" font-size="14" text-anchor="middle" fill="#555">USt-IdNr: DE811122334</text>
<line x1="50" y1="175" x2="550" y2="175" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
<text x="50" y="205" font-size="14" fill="#333">Datum: 14.08.2026</text>
<text x="400" y="205" font-size="14" fill="#333">Uhrzeit: 17:45</text>
<text x="50" y="230" font-size="14" fill="#333">Beleg-Nr: RW-77821</text>
<text x="400" y="230" font-size="14" fill="#333">Bon-Kasse: 03</text>
<line x1="50" y1="255" x2="550" y2="255" stroke="#94a3b8" stroke-dasharray="6,4" stroke-width="2" />
<text x="50" y="290" font-size="15" font-weight="bold" fill="#000">ARTIKEL</text>
<text x="380" y="290" font-size="15" font-weight="bold" fill="#000">MENGE</text>
<text x="480" y="290" font-size="15" font-weight="bold" fill="#000">EUR</text>
<!-- Items -->
<text x="50" y="330" font-size="14" fill="#111">REWE Bio Vollmilch 3.8%</text>
<text x="380" y="330" font-size="14" fill="#111">2</text>
<text x="480" y="330" font-size="14" fill="#111">3.18 A</text>
<text x="50" y="365" font-size="14" fill="#111">Bio Bananen (0.85 kg)</text>
<text x="380" y="365" font-size="14" fill="#111">1</text>
<text x="480" y="365" font-size="14" fill="#111">1.89 A</text>
<text x="50" y="400" font-size="14" fill="#111">Bio Espresso Bohnen 1kg</text>
<text x="380" y="400" font-size="14" fill="#111">1</text>
<text x="480" y="400" font-size="14" fill="#111">14.99 A</text>
<text x="50" y="435" font-size="14" fill="#111">Küchenrolle 4er Pack</text>
<text x="380" y="435" font-size="14" fill="#111">1</text>
<text x="480" y="435" font-size="14" fill="#111">4.74 B</text>
<line x1="50" y1="475" x2="550" y2="475" stroke="#000" stroke-width="2" />
<!-- Total -->
<text x="50" y="520" font-size="24" font-weight="bold" fill="#000">SUMME EUR</text>
<text x="420" y="520" font-size="28" font-weight="bold" fill="#000">24,80</text>
<line x1="50" y1="545" x2="550" y2="545" stroke="#000" stroke-width="2" />
<text x="50" y="585" font-size="13" font-weight="bold" fill="#333">MwSt-Satz</text>
<text x="220" y="585" font-size="13" font-weight="bold" fill="#333">Netto</text>
<text x="370" y="585" font-size="13" font-weight="bold" fill="#333">MwSt</text>
<text x="480" y="585" font-size="13" font-weight="bold" fill="#333">Brutto</text>
<text x="50" y="615" font-size="13" fill="#333">A = 7% (Lebensmittel)</text>
<text x="220" y="615" font-size="13" fill="#333">18,75</text>
<text x="370" y="615" font-size="13" fill="#333">1,31</text>
<text x="480" y="615" font-size="13" fill="#333">20,06</text>
<text x="50" y="640" font-size="13" fill="#333">B = 19% (Drogerie)</text>
<text x="220" y="640" font-size="13" fill="#333">3,98</text>
<text x="370" y="640" font-size="13" fill="#333">0,76</text>
<text x="480" y="640" font-size="13" fill="#333">4,74</text>
<!-- Payment -->
<text x="50" y="700" font-size="14" fill="#333">Gegeben: Bar 50,00 EUR • Rückgeld: 25,20 EUR</text>
<text x="50" y="725" font-size="14" fill="#333">TSE-Signatur: VALIDATED</text>
<!-- Barcode -->
<rect x="120" y="760" width="360" height="45" fill="#111" />
<line x1="140" y1="760" x2="140" y2="805" stroke="#fff" stroke-width="4" />
<line x1="170" y1="760" x2="170" y2="805" stroke="#fff" stroke-width="2" />
<line x1="200" y1="760" x2="200" y2="805" stroke="#fff" stroke-width="5" />
<line x1="230" y1="760" x2="230" y2="805" stroke="#fff" stroke-width="3" />
<line x1="260" y1="760" x2="260" y2="805" stroke="#fff" stroke-width="6" />
<line x1="290" y1="760" x2="290" y2="805" stroke="#fff" stroke-width="2" />
<line x1="320" y1="760" x2="320" y2="805" stroke="#fff" stroke-width="5" />
<line x1="350" y1="760" x2="350" y2="805" stroke="#fff" stroke-width="3" />
<line x1="380" y1="760" x2="380" y2="805" stroke="#fff" stroke-width="6" />
<line x1="410" y1="760" x2="410" y2="805" stroke="#fff" stroke-width="2" />
<line x1="440" y1="760" x2="440" y2="805" stroke="#fff" stroke-width="4" />
<text x="300" y="850" font-size="14" text-anchor="middle" fill="#555">Vielen Dank für Ihren Einkauf bei REWE!</text>
</svg>
`;
async function generateDemoImages() {
console.log("Generating Demo Receipt Images in public/demo/...");
const images = [
{ filename: "01_aral_tankbeleg_muenchen.jpg", svg: aralSvg },
{ filename: "02_trattoria_bewirtungsbeleg_berlin.jpg", svg: restaurantSvg },
{ filename: "03_mediamarkt_it_rechnung.jpg", svg: mediamarktSvg },
{ filename: "04_rewe_supermarkt_kassenbon.jpg", svg: reweSvg },
];
for (const img of images) {
const destPath = path.join(demoDir, img.filename);
const svgBuffer = Buffer.from(img.svg);
await sharp(svgBuffer)
.jpeg({ quality: 90, mozjpeg: true })
.toFile(destPath);
console.log(`✓ Created demo receipt: ${img.filename}`);
}
console.log("All demo receipt images created successfully in public/demo/");
}
generateDemoImages().catch((err) => {
console.error("Failed to generate demo images:", err);
process.exit(1);
});

29
scripts/inspect_excel.mjs Normal file
View File

@@ -0,0 +1,29 @@
import ExcelJS from "exceljs";
async function inspect() {
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile("C:/Users/timo/Downloads/Belege_Export_2026-08-14.xlsx");
console.log("=== WORKBOOK SHEETS ===");
wb.eachSheet((sheet, id) => {
console.log(`Sheet ${id}: "${sheet.name}" (Rows: ${sheet.rowCount}, Cols: ${sheet.columnCount})`);
});
const s1 = wb.getWorksheet("Belegübersicht");
if (s1) {
console.log("\n=== SHEET 1: Belegübersicht ===");
s1.eachRow((row, rowNumber) => {
console.log(`Row ${rowNumber}:`, JSON.stringify(row.values));
});
}
const s2 = wb.getWorksheet("Einzelpositionen Detail");
if (s2) {
console.log("\n=== SHEET 2: Einzelpositionen Detail ===");
s2.eachRow((row, rowNumber) => {
console.log(`Row ${rowNumber}:`, JSON.stringify(row.values));
});
}
}
inspect().catch(console.error);

View File

@@ -0,0 +1,527 @@
{
"US": {
"name": "USA",
"data": [
{
"keyword": "receipt scanner to excel",
"score": 68,
"avgReviews": 394356,
"top1": "Photo to PDF Convert Scanner + (26467 rev, ★4.6)",
"resultCount": 9
},
{
"keyword": "receipt to excel",
"score": 68,
"avgReviews": 121747,
"top1": "Intuit QuickBooks for Business (259333 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "receipt scanner",
"score": 77,
"avgReviews": 1800587,
"top1": "Fetch: Receipts for Gift Cards (7545646 rev, ★4.9)",
"resultCount": 9
},
{
"keyword": "expense tracker",
"score": 56,
"avgReviews": 39492,
"top1": "Spending Tracker (19354 rev, ★4.8)",
"resultCount": 9
},
{
"keyword": "tax receipt organizer",
"score": 56,
"avgReviews": 23178,
"top1": "Smart Receipts: Expenses & Tax (12768 rev, ★4.8)",
"resultCount": 9
},
{
"keyword": "receipt keeper",
"score": 44,
"avgReviews": 10555,
"top1": "SimplyWise: Receipts, Expenses (36875 rev, ★4.9)",
"resultCount": 10
},
{
"keyword": "invoice scanner",
"score": 76,
"avgReviews": 766384,
"top1": "Scanner App: Genius Scan (1353025 rev, ★4.9)",
"resultCount": 9
},
{
"keyword": "bookkeeping scanner",
"score": 44,
"avgReviews": 9568,
"top1": "Receipt Lens - Expense Tracker (4250 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "ocr receipt scanner",
"score": 68,
"avgReviews": 325217,
"top1": "Receipt Scanner・Track Expenses (3850 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "extract receipt to csv",
"score": 56,
"avgReviews": 57993,
"top1": "Smart Receipts: Expenses & Tax (12768 rev, ★4.8)",
"resultCount": 10
}
]
},
"GB": {
"name": "UK",
"data": [
{
"keyword": "receipt scanner to excel",
"score": 56,
"avgReviews": 25470,
"top1": "CamScanner - PDF Scanner App (118125 rev, ★4.8)",
"resultCount": 8
},
{
"keyword": "receipt to excel",
"score": 56,
"avgReviews": 28152,
"top1": "Shoppix (17604 rev, ★4.7)",
"resultCount": 8
},
{
"keyword": "receipt scanner",
"score": 57,
"avgReviews": 23797,
"top1": "Scanner App: Genius Scan (99819 rev, ★4.8)",
"resultCount": 9
},
{
"keyword": "expense tracker",
"score": 44,
"avgReviews": 9617,
"top1": "Spending Tracker (5229 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "tax receipt organizer",
"score": 32,
"avgReviews": 1265,
"top1": "Smart Receipts: Tax, Expenses (1259 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "receipt keeper",
"score": 44,
"avgReviews": 8806,
"top1": "1tap receipts: Track Expenses (4879 rev, ★4.7)",
"resultCount": 9
},
{
"keyword": "invoice scanner",
"score": 56,
"avgReviews": 39909,
"top1": "Scanner Scan PDF & Document (33855 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "bookkeeping scanner",
"score": 44,
"avgReviews": 7408,
"top1": "JotNot Scanner App Pro (1774 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "ocr receipt scanner",
"score": 56,
"avgReviews": 84959,
"top1": "Scanner Scan PDF & Document (33855 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "extract receipt to csv",
"score": 32,
"avgReviews": 4779,
"top1": "1tap receipts: Track Expenses (4879 rev, ★4.7)",
"resultCount": 8
}
]
},
"CA": {
"name": "Kanada",
"data": [
{
"keyword": "receipt scanner to excel",
"score": 56,
"avgReviews": 32453,
"top1": "CamScanner - PDF Scanner App (149843 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "receipt to excel",
"score": 56,
"avgReviews": 39481,
"top1": "Receipt Hog: Shopping Rewards (28938 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "receipt scanner",
"score": 57,
"avgReviews": 30270,
"top1": "Receipt Scanner: Easy Expense (1235 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "expense tracker",
"score": 44,
"avgReviews": 5733,
"top1": "Money Manager Expense & Budget (5217 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "tax receipt organizer",
"score": 32,
"avgReviews": 2179,
"top1": "Smart Receipts: Expenses & Tax (1427 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "receipt keeper",
"score": 32,
"avgReviews": 3992,
"top1": "Smart Receipts: Expenses & Tax (1427 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "invoice scanner",
"score": 56,
"avgReviews": 50676,
"top1": "Scanner Scan PDF & Document (34215 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "bookkeeping scanner",
"score": 44,
"avgReviews": 8397,
"top1": "Receipt Lens - Receipt Scanner (455 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "ocr receipt scanner",
"score": 56,
"avgReviews": 97427,
"top1": "Scanner Scan PDF & Document (34215 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "extract receipt to csv",
"score": 44,
"avgReviews": 12339,
"top1": "Dext: Expense Tracker (12900 rev, ★4.8)",
"resultCount": 9
}
]
},
"AU": {
"name": "Australien",
"data": [
{
"keyword": "receipt scanner to excel",
"score": 44,
"avgReviews": 17539,
"top1": "CamScanner - PDF Scanner App (81525 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "receipt to excel",
"score": 56,
"avgReviews": 46549,
"top1": "Microsoft Excel (132204 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "receipt scanner",
"score": 57,
"avgReviews": 45599,
"top1": "Scanner App: Genius Scan (78375 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "expense tracker",
"score": 33,
"avgReviews": 1640,
"top1": "Buddy: Money & Budget Planner (3519 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "tax receipt organizer",
"score": 44,
"avgReviews": 5513,
"top1": "QuickBooks Self-Employed (3372 rev, ★4.5)",
"resultCount": 10
},
{
"keyword": "receipt keeper",
"score": 32,
"avgReviews": 4088,
"top1": "Keep Receipt (248 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "invoice scanner",
"score": 56,
"avgReviews": 24672,
"top1": "Scanner Scan PDF & Document (17872 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "bookkeeping scanner",
"score": 32,
"avgReviews": 4241,
"top1": "Receipt Lens - Expense Tracker (435 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "ocr receipt scanner",
"score": 56,
"avgReviews": 58577,
"top1": "Scanner Scan PDF & Document (17872 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "extract receipt to csv",
"score": 32,
"avgReviews": 4235,
"top1": "Crunchr Receipt Scanner (695 rev, ★4.7)",
"resultCount": 9
}
]
},
"DE": {
"name": "Deutschland",
"data": [
{
"keyword": "beleg scanner",
"score": 44,
"avgReviews": 15304,
"top1": "Lexware Scan (14051 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "belege digitalisieren",
"score": 12,
"avgReviews": 45,
"top1": "Belegsammler (0 rev, ★0.0)",
"resultCount": 9
},
{
"keyword": "kassenbon scanner",
"score": 32,
"avgReviews": 1513,
"top1": "epap Kassenbon & Haushaltsbuch (3045 rev, ★4.5)",
"resultCount": 10
},
{
"keyword": "quittung scanner",
"score": 56,
"avgReviews": 26086,
"top1": "QuittungScanner: Klug Sparen (0 rev, ★0.0)",
"resultCount": 10
},
{
"keyword": "spesen app",
"score": 32,
"avgReviews": 2190,
"top1": "Spesen und Reisekosten App (16 rev, ★4.2)",
"resultCount": 10
},
{
"keyword": "rechnung scanner",
"score": 57,
"avgReviews": 26969,
"top1": "Rechnung Scanner (0 rev, ★0.0)",
"resultCount": 9
},
{
"keyword": "receipt to excel",
"score": 44,
"avgReviews": 17090,
"top1": "CamScanner - PDF Scanner App (85416 rev, ★4.7)",
"resultCount": 7
},
{
"keyword": "datev scanner",
"score": 56,
"avgReviews": 33934,
"top1": "DATEV Upload mobil (57685 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "belegmanager",
"score": 13,
"avgReviews": 0,
"top1": "Belegmanager: Belegthek (1 rev, ★5.0)",
"resultCount": 4
},
{
"keyword": "ausgaben tracker",
"score": 33,
"avgReviews": 3841,
"top1": "Haushaltsbuch Ausgaben Monee (9844 rev, ★4.9)",
"resultCount": 9
}
]
},
"AT": {
"name": "Österreich",
"data": [
{
"keyword": "beleg scanner",
"score": 21,
"avgReviews": 558,
"top1": "Belegscanner: Foto & Speichern (0 rev, ★0.0)",
"resultCount": 9
},
{
"keyword": "belege digitalisieren",
"score": 12,
"avgReviews": 3,
"top1": "Belegsammler (1 rev, ★5.0)",
"resultCount": 8
},
{
"keyword": "kassenbon scanner",
"score": 13,
"avgReviews": 63,
"top1": "epap Kassenbon & Haushaltsbuch (105 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "quittung scanner",
"score": 32,
"avgReviews": 3721,
"top1": "Scanner App: Genius Scan (18584 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "spesen app",
"score": 12,
"avgReviews": 38,
"top1": "Spesen und Reisekosten App (1 rev, ★3.0)",
"resultCount": 7
},
{
"keyword": "rechnung scanner",
"score": 21,
"avgReviews": 887,
"top1": "Rechnungsscanner (1 rev, ★4.0)",
"resultCount": 9
},
{
"keyword": "receipt to excel",
"score": 32,
"avgReviews": 1425,
"top1": "CamScanner - PDF Scanner App (7116 rev, ★4.7)",
"resultCount": 7
},
{
"keyword": "datev scanner",
"score": 20,
"avgReviews": 210,
"top1": "DATEV Upload mobil (368 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "belegmanager",
"score": 13,
"avgReviews": 0,
"top1": "Belegmanager: Belegthek (0 rev, ★0.0)",
"resultCount": 4
},
{
"keyword": "ausgaben tracker",
"score": 21,
"avgReviews": 781,
"top1": "Haushaltsbuch Ausgaben Monee (437 rev, ★4.9)",
"resultCount": 10
}
]
},
"CH": {
"name": "Schweiz",
"data": [
{
"keyword": "beleg scanner",
"score": 33,
"avgReviews": 2143,
"top1": "TurboScan™ (3556 rev, ★4.8)",
"resultCount": 10
},
{
"keyword": "belege digitalisieren",
"score": 12,
"avgReviews": 26,
"top1": "Spesen und Reisekosten App (80 rev, ★4.6)",
"resultCount": 7
},
{
"keyword": "kassenbon scanner",
"score": 21,
"avgReviews": 404,
"top1": "epap Kassenbon & Haushaltsbuch (98 rev, ★4.5)",
"resultCount": 10
},
{
"keyword": "quittung scanner",
"score": 44,
"avgReviews": 8381,
"top1": "QuittungScanner: Klug Sparen (0 rev, ★0.0)",
"resultCount": 9
},
{
"keyword": "spesen app",
"score": 20,
"avgReviews": 229,
"top1": "Spesen und Reisekosten App (80 rev, ★4.6)",
"resultCount": 10
},
{
"keyword": "rechnung scanner",
"score": 44,
"avgReviews": 10844,
"top1": "Scanner Mini: Scannen & Fax (9875 rev, ★4.7)",
"resultCount": 10
},
{
"keyword": "receipt to excel",
"score": 32,
"avgReviews": 1695,
"top1": "CamScanner - PDF Scanner App (8471 rev, ★4.7)",
"resultCount": 7
},
{
"keyword": "datev scanner",
"score": 12,
"avgReviews": 92,
"top1": "DATEV Upload mobil (50 rev, ★4.7)",
"resultCount": 9
},
{
"keyword": "belegmanager",
"score": 13,
"avgReviews": 0,
"top1": "Belegmanager: Belegthek (0 rev, ★0.0)",
"resultCount": 4
},
{
"keyword": "ausgaben tracker",
"score": 21,
"avgReviews": 456,
"top1": "Haushaltsbuch Ausgaben Monee (366 rev, ★4.9)",
"resultCount": 10
}
]
}
}

View File

@@ -0,0 +1,90 @@
import https from 'https';
const EN_KEYWORDS = [
'receipt scanner',
'receipt to excel',
'receipt scanner to excel',
'expense tracker',
'receipt keeper',
'invoice scanner',
'tax receipts',
'ocr receipt scanner',
'smart receipts',
'mileage and receipts',
'receipt organizer',
'bookkeeping scanner',
'scan receipts for taxes',
'extract receipt to csv',
'business expense tracker'
];
const DE_KEYWORDS = [
'beleg scanner',
'belege digitalisieren',
'rechnung scanner',
'kassenbon scanner',
'quittung scanner',
'spesen app',
'buchhaltung scanner',
'datev scanner',
'receipt to excel',
'haushaltsbuch',
'ausgaben tracker',
'steuer belege',
'belegmanager'
];
const COUNTRIES = {
US: 'us',
GB: 'gb',
CA: 'ca',
AU: 'au',
DE: 'de'
};
function fetchSearch(term, country) {
return new Promise((resolve) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&country=${country}&entity=software&limit=10`;
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
resolve({ results: [] });
}
});
}).on('error', () => resolve({ results: [] }));
});
}
async function analyze() {
console.log("Analyzing Multi-Country App Store data...");
const countryResults = {};
for (const [cName, cCode] of Object.entries(COUNTRIES)) {
countryResults[cName] = {};
const kws = cName === 'DE' ? DE_KEYWORDS : EN_KEYWORDS;
for (const kw of kws) {
const data = await fetchSearch(kw, cCode);
const apps = data.results || [];
const top5 = apps.slice(0, 5);
const top5Reviews = top5.reduce((sum, a) => sum + (a.userRatingCount || 0), 0);
const avgTop5Reviews = Math.round(top5Reviews / (top5.length || 1));
const top1 = top5[0] || {};
countryResults[cName][kw] = {
resultCount: data.resultCount,
top1Name: top1.trackName || 'N/A',
top1Reviews: top1.userRatingCount || 0,
avgTop5Reviews,
};
await new Promise(r => setTimeout(r, 100));
}
}
console.log(JSON.stringify(countryResults, null, 2));
}
analyze();

View File

@@ -0,0 +1,116 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
const ORG_ID = 22309440;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const req = https.request({
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function reqApi(path, token, method = 'GET', body = null) {
return new Promise((resolve) => {
const headers = {
'Authorization': `Bearer ${token}`,
'X-AP-Context': `orgId=${ORG_ID}`,
'User-Agent': 'Mozilla/5.0'
};
if (body) headers['Content-Type'] = 'application/json';
const req = https.request({
hostname: 'api.searchads.apple.com',
port: 443,
path,
method,
headers
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve({ raw: data, status: res.statusCode }); }
});
});
req.on('error', (e) => resolve({ error: e.message }));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function run() {
const secret = generateClientSecret();
const token = (await getAccessToken(secret)).access_token;
// Let's test different paths for keyword suggestions
const paths = [
{ p: '/api/v5/keywords/recommendations', body: { adamId: 6759843546, countryOrRegionCode: 'US' } },
{ p: '/api/v5/recommendations/keywords', body: { adamId: 6759843546, countryOrRegionCode: 'US' } },
{ p: '/api/v5/campaigns/2144248407/adgroups/2149719430/targetingkeywords/find', body: { selector: { conditions: [] } } },
{ p: '/api/v5/search/geo?query=receipt', body: null },
{ p: '/api/v5/keywords/search/recommendations', body: { searchTerms: ['receipt'] } },
{ p: '/api/v5/campaigns/2144248407/keywords/recommendations', body: { adamId: 6759843546 } }
];
for (const item of paths) {
const res = await reqApi(item.p, token, item.body ? 'POST' : 'GET', item.body);
console.log(`\nPath ${item.p}:`, JSON.stringify(res, null, 2).slice(0, 400));
}
}
run();

View File

@@ -0,0 +1,151 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1);
}
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: CLIENT_ID,
aud: "https://appleid.apple.com",
iat: now,
exp: now + 86400 * 180,
iss: TEAM_ID
};
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({
key: PRIVATE_KEY.replace(/\\n/g, '\n'),
dsaEncoding: 'ieee-p1363'
}, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const options = {
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function requestAppleApi(path, token, orgId, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
const headers = {
'Authorization': `Bearer ${token}`,
'User-Agent': 'Mozilla/5.0'
};
if (orgId) {
headers['X-AP-Context'] = `orgId=${orgId}`;
}
if (body) {
headers['Content-Type'] = 'application/json';
}
const options = {
hostname: 'api.searchads.apple.com',
port: 443,
path: path,
method: method,
headers: headers
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
resolve(data);
}
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function main() {
const secret = generateClientSecret();
const tokenRes = await getAccessToken(secret);
const token = tokenRes.access_token;
console.log("Abfrage /api/v5/acls für orgId...");
const acls = await requestAppleApi('/api/v5/acls', token);
console.log("ACLs:", JSON.stringify(acls, null, 2));
const org = acls?.data?.[0];
const orgId = org?.orgId;
console.log("Verwende OrgId:", orgId);
if (orgId) {
// In Search Ads API v5: POST /api/v5/keywords/search
console.log("\nAbfrage Keywords via POST /api/v5/keywords/search ...");
const keywords = ['receipt scanner', 'receipt to excel', 'expense tracker', 'beleg scanner'];
const testKw = await requestAppleApi('/api/v5/keywords/search', token, orgId, 'POST', {
searchTerms: keywords,
countryOrRegion: 'US'
});
console.log("Keyword Search Result (US):", JSON.stringify(testKw, null, 2));
// Check DE
const testKwDE = await requestAppleApi('/api/v5/keywords/search', token, orgId, 'POST', {
searchTerms: ['beleg scanner', 'belege digitalisieren', 'kassenbon scanner', 'haushaltsbuch'],
countryOrRegion: 'DE'
});
console.log("Keyword Search Result (DE):", JSON.stringify(testKwDE, null, 2));
}
}
main();

View File

@@ -0,0 +1,112 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
const ORG_ID = 22309440;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const req = https.request({
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function reqApi(path, token, method = 'GET', body = null) {
return new Promise((resolve) => {
const headers = {
'Authorization': `Bearer ${token}`,
'X-AP-Context': `orgId=${ORG_ID}`,
'User-Agent': 'Mozilla/5.0'
};
if (body) headers['Content-Type'] = 'application/json';
const req = https.request({
hostname: 'api.searchads.apple.com',
port: 443,
path,
method,
headers
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve({ raw: data, status: res.statusCode }); }
});
});
req.on('error', (e) => resolve({ error: e.message }));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function run() {
const secret = generateClientSecret();
const token = (await getAccessToken(secret)).access_token;
console.log("Abfrage Kampagne 2144248407 Adgroups...");
const adgroups = await reqApi('/api/v5/campaigns/2144248407/adgroups', token);
console.log("Adgroups:", JSON.stringify(adgroups, null, 2));
const adgroupId = adgroups?.data?.[0]?.id;
if (adgroupId) {
console.log(`\nAbfrage Keywords für AdGroup ${adgroupId}...`);
const kwRes = await reqApi(`/api/v5/campaigns/2144248407/adgroups/${adgroupId}/targetingkeywords`, token);
console.log("Targeting Keywords:", JSON.stringify(kwRes, null, 2));
}
}
run();

View File

@@ -0,0 +1,21 @@
/**
* Node.js resolve hook used ONLY by scripts/verify_sensitive_paths.mjs when run
* with plain `node` (no bundler, no tsx).
*
* Next ships `next/server.js` but no package.json "exports" map, so bundlers
* resolve `next/server` by extension-guessing while plain Node ESM does not.
* This hook remaps the bare specifier `next/server` -> `next/server.js` for the
* duration of the test process. Not used by the app itself.
*
* Run: node --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs
*/
import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "next/server") {
return nextResolve("next/server.js", context);
}
return nextResolve(specifier, context);
},
});

24
scripts/search_models.mjs Normal file
View File

@@ -0,0 +1,24 @@
import fs from "fs";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
async function searchModels() {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
"Authorization": `Bearer ${apiKey}`,
}
});
const data = await res.json();
const models = data.data || [];
const lunaModels = models.filter(m => m.id.toLowerCase().includes("luna") || m.id.toLowerCase().includes("gpt-5"));
console.log("Found matches for 'luna' or 'gpt-5':", lunaModels.map(m => ({ id: m.id, name: m.name, pricing: m.pricing })));
const visionModels = models.filter(m => m.architecture && m.architecture.modality && m.architecture.modality.includes("image->text"));
console.log(`Total vision models on OpenRouter: ${visionModels.length}`);
}
searchModels().catch(console.error);

View File

@@ -0,0 +1,115 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
const ORG_ID = 22309440;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const req = https.request({
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function reqApi(path, token, method = 'GET', body = null) {
return new Promise((resolve) => {
const headers = {
'Authorization': `Bearer ${token}`,
'X-AP-Context': `orgId=${ORG_ID}`,
'User-Agent': 'Mozilla/5.0'
};
if (body) headers['Content-Type'] = 'application/json';
const req = https.request({
hostname: 'api.searchads.apple.com',
port: 443,
path,
method,
headers
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve({ raw: data, status: res.statusCode }); }
});
});
req.on('error', (e) => resolve({ error: e.message }));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function run() {
const secret = generateClientSecret();
const token = (await getAccessToken(secret)).access_token;
const endpoints = [
{ p: '/api/v5/campaigns/2144248407/adgroups/2149719430/targetingkeywords/recommendations', body: {} },
{ p: '/api/v5/campaigns/2144248407/adgroups/2149719430/keywords/recommendations', body: {} },
{ p: '/api/v5/campaigns/2144248407/keywords/recommendations', body: {} },
{ p: '/api/v5/adgroups/2149719430/targetingkeywords/recommendations', body: {} },
{ p: '/api/v5/reports/campaigns/2144248407/keywords', body: { startTime: '2026-07-01', endTime: '2026-08-18', selector: { orderBy: [{ field: 'impressions', sortOrder: 'DESCENDING' }] } } }
];
for (const ep of endpoints) {
const res = await reqApi(ep.p, token, 'POST', ep.body);
console.log(`\nEndpoint ${ep.p}:`);
console.log(JSON.stringify(res, null, 2).slice(0, 400));
}
}
run();

View File

@@ -0,0 +1,133 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
const ORG_ID = 22309440;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const req = https.request({
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function reqAdsApi(path, token, method = 'POST', body = null) {
return new Promise((resolve) => {
const headers = {
'Authorization': `Bearer ${token}`,
'X-AP-Context': `orgId=${ORG_ID}`,
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0'
};
const req = https.request({
hostname: 'api.ads.apple.com',
port: 443,
path,
method,
headers
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve({ raw: data, status: res.statusCode }); }
});
});
req.on('error', (e) => resolve({ error: e.message }));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function run() {
const secret = generateClientSecret();
const token = (await getAccessToken(secret)).access_token;
console.log("Token obtained. Testing api.ads.apple.com endpoints...");
const endpoints = [
{
p: '/v1/connect/apple-ads/insights/search-term-popularity',
body: {
countryOrRegionCode: 'US',
searchTerms: ['receipt scanner', 'receipt to excel', 'expense tracker', 'invoice scanner']
}
},
{
p: '/v1/connect/apple-ads/suggestions/keywords',
body: {
countryOrRegionCode: 'US',
searchTerm: 'receipt scanner'
}
},
{
p: '/v1/connect/apple-ads/suggestions/phrases',
body: {
countryOrRegionCode: 'US',
searchTerm: 'receipt to excel'
}
}
];
for (const ep of endpoints) {
const res = await reqAdsApi(ep.p, token, 'POST', ep.body);
console.log(`\nEndpoint ${ep.p}:`);
console.log(JSON.stringify(res, null, 2));
}
}
run();

View File

@@ -0,0 +1,112 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
// Read .env.local manually
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1);
}
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
function generateClientSecret() {
if (!PRIVATE_KEY || PRIVATE_KEY.trim() === '') {
throw new Error("APPLE_SEARCH_ADS_PRIVATE_KEY ist nicht in .env.local gesetzt!");
}
const header = {
alg: "ES256",
kid: KEY_ID,
typ: "JWT"
};
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: CLIENT_ID,
aud: "https://appleid.apple.com",
iat: now,
exp: now + 86400 * 180, // 180 Tage
iss: TEAM_ID
};
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const formattedKey = PRIVATE_KEY.replace(/\\n/g, '\n');
const signature = signer.sign({
key: formattedKey,
dsaEncoding: 'ieee-p1363'
}, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const options = {
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Parse error: ${data}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
async function main() {
try {
console.log("1. Generiere Client Secret (JWT)...");
const clientSecret = generateClientSecret();
console.log("Client Secret erfolgreich generiert.");
console.log("2. Fordere Access Token bei Apple OAuth an...");
const tokenData = await getAccessToken(clientSecret);
console.log("Antwort von Apple:", tokenData);
} catch (err) {
console.error("Fehler:", err.message);
}
}
main();

117
scripts/test_endpoints.mjs Normal file
View File

@@ -0,0 +1,117 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
const ORG_ID = 22309440;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const req = https.request({
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function reqApi(path, token, method = 'GET', body = null) {
return new Promise((resolve) => {
const headers = {
'Authorization': `Bearer ${token}`,
'X-AP-Context': `orgId=${ORG_ID}`,
'User-Agent': 'Mozilla/5.0'
};
if (body) headers['Content-Type'] = 'application/json';
const req = https.request({
hostname: 'api.searchads.apple.com',
port: 443,
path,
method,
headers
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve({ raw: data, status: res.statusCode }); }
});
});
req.on('error', (e) => resolve({ error: e.message }));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function run() {
const secret = generateClientSecret();
const token = (await getAccessToken(secret)).access_token;
console.log("Token obtained. Testing endpoints...");
// Test endpoints for keyword search
const endpoints = [
{ path: '/api/v5/search/keywords', method: 'POST', body: { term: 'receipt', countryOrRegion: 'US' } },
{ path: '/api/v5/keywords/targeting-keywords', method: 'POST', body: { searchTerms: ['receipt'], countryOrRegion: 'US' } },
{ path: '/api/v5/campaigns', method: 'GET', body: null },
{ path: '/api/v5/search/apps?query=receipt', method: 'GET', body: null }
];
for (const ep of endpoints) {
const res = await reqApi(ep.path, token, ep.method, ep.body);
console.log(`\nEndpoint ${ep.method} ${ep.path}:`);
console.log(JSON.stringify(res, null, 2).slice(0, 300));
}
}
run();

29
scripts/test_extractor.ts Normal file
View File

@@ -0,0 +1,29 @@
import fs from "fs";
import { extractReceiptData } from "../src/lib/ai/extractor";
const content = fs.readFileSync(".env.local", "utf8");
for (const l of content.split("\n")) {
const trimmed = l.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx !== -1) {
process.env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
}
}
async function test() {
console.log("Model:", process.env.OPENROUTER_MODEL);
console.log("Key prefix:", process.env.OPENROUTER_API_KEY?.substring(0, 10));
// Test demo fallback
const start = Date.now();
try {
const dummyBase64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const res = await extractReceiptData({ base64DataUrl: dummyBase64, fileName: "01_aral_tankbeleg.jpg" });
console.log("Result in " + (Date.now() - start) + "ms:", res.merchant.name, res.totalAmount.value);
} catch (e) {
console.error("Test error:", e);
}
}
test();

View File

@@ -0,0 +1,27 @@
import fs from "fs";
import { extractReceiptData } from "../src/lib/ai/extractor.ts";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
process.env.OPENROUTER_API_KEY = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
async function testExtractorLive() {
console.log("=== TESTING LIVE EXTRACTOR WITH OPENROUTER ===");
const result = await extractReceiptData({
base64DataUrl: dataUrl,
fileName: "random_real_receipt_photo.jpg"
});
console.log("Extracted Merchant:", result.merchant.name);
console.log("Extracted Gross Total:", result.totalAmount.value, result.currency);
console.log("Extracted Date:", result.date.isoDate);
console.log("Extracted Tax Breakdown:", JSON.stringify(result.taxBreakdown));
console.log("Extracted Line Items:", JSON.stringify(result.lineItems));
console.log("Validation:", JSON.stringify(result.validation));
}
testExtractorLive().catch(console.error);

39
scripts/test_gpt5.mjs Normal file
View File

@@ -0,0 +1,39 @@
import fs from "fs";
import { generateObject } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
async function testGpt5Models() {
const models = ["openai/gpt-5-mini", "openai/gpt-5.4-mini", "openai/gpt-4o-mini"];
const openrouter = createOpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
});
for (const m of models) {
console.log(`\nTesting ${m}...`);
try {
const { object } = await generateObject({
model: openrouter(m),
schema: ReceiptExtractionSchema,
messages: [
{ role: "system", content: "Extrahiere den Beleg." },
{ role: "user", content: [{ type: "text", text: "Beleg:" }, { type: "image", image: dataUrl }] },
],
});
console.log(`✓ SUCCESS with ${m}! Merchant: ${object.merchant.name}, Total: ${object.totalAmount.value}`);
} catch (err) {
console.log(`✗ Error with ${m}:`, err.message);
}
}
}
testGpt5Models().catch(console.error);

67
scripts/test_hints.mjs Normal file
View File

@@ -0,0 +1,67 @@
import https from 'https';
const keywords = [
// German Core
'receipt scanner',
'beleg scanner',
'belege digitalisieren',
'rechnung scanner',
'kassenbon scanner',
'quittung scanner',
'spesen app',
'buchhaltung scanner',
'datev',
'datev scanner',
'belegmanager',
'haushaltsbuch',
'ausgaben tracker',
'steuer belege',
// English Core
'receipt to excel',
'receipt scanner to excel',
'expense tracker',
'receipt keeper',
'invoice scanner',
'smart receipts',
'receipts',
'shoeboxed',
'expensify',
'camscanner',
'genius scan'
];
function fetchHints(term, storefront = '143443') { // 143443 = Germany, 143441 = US
return new Promise((resolve) => {
const url = `https://search.itunes.apple.com/WebObjects/MZSearchHints.woa/wa/hints?clientApplication=Software&term=${encodeURIComponent(term)}`;
https.get(url, {
headers: {
'User-Agent': 'AppStore/3.0 (iOS; 16.0)',
'X-Apple-Store-Front': `${storefront}-1,29`
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (e) {
resolve(null);
}
});
}).on('error', () => resolve(null));
});
}
async function test() {
console.log('Testing hints:');
for (const kw of ['beleg', 'kassenbon', 'receipt', 'expense', 'datev', 'spesen', 'rechnung']) {
const hintsDE = await fetchHints(kw, '143443');
console.log(`DE Hints for "${kw}":`, hintsDE?.hints?.map(h => h.term));
const hintsUS = await fetchHints(kw, '143441');
console.log(`US Hints for "${kw}":`, hintsUS?.hints?.map(h => h.term));
}
}
test();

View File

@@ -0,0 +1,59 @@
import fs from "fs";
import { generateObject } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
async function testJsonMode() {
console.log("Testing generateObject with mode: 'json' on OpenRouter...");
const openrouter = createOpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
headers: {
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Receipt Scanner to Excel",
},
});
const { object } = await generateObject({
model: openrouter("openai/gpt-4o-mini"),
schema: ReceiptExtractionSchema,
mode: "json",
messages: [
{
role: "system",
content: `Du bist ein hochpräziser KI-Belegscanner. Extrahiere alle Belegdaten vollständig im vorgegebenen JSON-Format:
- merchant (name, address, taxId, confidence 0-1)
- date (isoDate YYYY-MM-DD, time, confidence 0-1)
- documentType (KASSENBON, RECHNUNG, TANKBELEG, BEWIRTUNGSBELEG, PARKTICKET, SONSTIGES)
- receiptNumber
- currency (EUR)
- totalAmount (value, confidence 0-1)
- netAmount
- taxBreakdown (array mit ratePercent, taxAmount, netAmount)
- lineItems (array mit description, quantity, price, taxRate)
- suggestedCategory (Bewirtung, Reisekosten & Hotel, Tanken & KFZ, Bürobedarf & IT, Verpflegungsmehraufwand, Material & Einkauf, Sonstiges)
- validation (isMathValid, isDuplicateSuspected, needsUserReview, reviewField, reviewReason)`,
},
{
role: "user",
content: [
{ type: "text", text: "Extrahiere diesen Beleg vollständig als JSON:" },
{ type: "image", image: dataUrl },
],
},
],
});
console.log("✓ SUCCESS! Extracted Object:\n", JSON.stringify(object, null, 2));
}
testJsonMode().catch(console.error);

48
scripts/test_luna.mjs Normal file
View File

@@ -0,0 +1,48 @@
import fs from "fs";
import { generateObject } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
async function testLuna() {
console.log("Testing openai/gpt-5.6-luna on OpenRouter...");
const openrouter = createOpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
headers: {
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Receipt Scanner to Excel",
},
});
const { object } = await generateObject({
model: openrouter("openai/gpt-5.6-luna"),
schema: ReceiptExtractionSchema,
messages: [
{
role: "system",
content: "Du bist ein präziser Belegscanner. Extrahiere alle Daten im Schema.",
},
{
role: "user",
content: [
{ type: "text", text: "Extrahiere diesen Beleg vollständig:" },
{ type: "image", image: dataUrl },
],
},
],
});
console.log("✓ SUCCESS WITH GPT-5.6-LUNA!");
console.log("Extracted:", JSON.stringify(object, null, 2));
}
testLuna().catch(console.error);

View File

@@ -0,0 +1,36 @@
import fs from "fs";
import path from "path";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
console.log("Testing OpenRouter with key:", apiKey ? apiKey.substring(0, 15) + "..." : "NONE");
async function testOpenRouter() {
// Test 1: List models or check deepseek/deepseek-v4-flash
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Receipt Scanner",
},
body: JSON.stringify({
model: "deepseek/deepseek-v4-flash",
messages: [
{
role: "user",
content: "Sag einfach 'Hallo, DeepSeek V4 Flash ist bereit!'"
}
]
})
});
console.log("Status:", res.status, res.statusText);
const data = await res.json();
console.log("Response:", JSON.stringify(data, null, 2));
}
testOpenRouter().catch(console.error);

View File

@@ -0,0 +1,116 @@
import crypto from 'crypto';
import https from 'https';
import fs from 'fs';
const envContent = fs.readFileSync('.env.local', 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
let val = match[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
env[match[1].trim()] = val;
}
});
const CLIENT_ID = env.APPLE_SEARCH_ADS_CLIENT_ID;
const TEAM_ID = env.APPLE_SEARCH_ADS_TEAM_ID;
const KEY_ID = env.APPLE_SEARCH_ADS_KEY_ID;
const PRIVATE_KEY = env.APPLE_SEARCH_ADS_PRIVATE_KEY;
const ORG_ID = 22309440;
function generateClientSecret() {
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { sub: CLIENT_ID, aud: "https://appleid.apple.com", iat: now, exp: now + 86400 * 180, iss: TEAM_ID };
const base64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${base64Header}.${base64Payload}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
const signature = signer.sign({ key: PRIVATE_KEY.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363' }, "base64url");
return `${signingInput}.${signature}`;
}
async function getAccessToken(clientSecret) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: clientSecret,
scope: 'searchadsorg'
}).toString();
const req = https.request({
hostname: 'appleid.apple.com',
port: 443,
path: '/auth/oauth2/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
function reqApi(path, token, method = 'GET', body = null) {
return new Promise((resolve) => {
const headers = {
'Authorization': `Bearer ${token}`,
'X-AP-Context': `orgId=${ORG_ID}`,
'User-Agent': 'Mozilla/5.0'
};
if (body) headers['Content-Type'] = 'application/json';
const req = https.request({
hostname: 'api.searchads.apple.com',
port: 443,
path,
method,
headers
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve({ raw: data, status: res.statusCode }); }
});
});
req.on('error', (e) => resolve({ error: e.message }));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function run() {
const secret = generateClientSecret();
const token = (await getAccessToken(secret)).access_token;
console.log("Token obtained. Testing keyword recommendation endpoints...");
const endpoints = [
{ path: '/api/v5/keywords/recommendation', method: 'POST', body: { adamId: 6759843546, countryOrRegionCode: 'US' } },
{ path: '/api/v5/keywords/recommendation', method: 'POST', body: { adamId: 6759843546, countryOrRegionCode: 'DE' } },
{ path: '/api/v5/keywords/recommendation', method: 'POST', body: { searchTerms: ['receipt scanner', 'receipt to excel', 'expense tracker'], countryOrRegionCode: 'US' } },
{ path: '/api/v5/keywords/search/recommendations', method: 'POST', body: { searchTerms: ['receipt scanner'], countryOrRegionCode: 'US' } }
];
for (const ep of endpoints) {
const res = await reqApi(ep.path, token, ep.method, ep.body);
console.log(`\nEndpoint ${ep.method} ${ep.path}:`);
console.log(JSON.stringify(res, null, 2).slice(0, 500));
}
}
run();

View File

@@ -0,0 +1,47 @@
import fs from "fs";
import { generateObject } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { ReceiptExtractionSchema } from "../src/lib/schema/receipt.ts";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
async function testStructuredExtraction() {
console.log("Testing structured object extraction with OpenRouter openai/gpt-4o-mini...");
const openrouter = createOpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
headers: {
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Receipt Scanner to Excel",
},
});
const { object } = await generateObject({
model: openrouter("openai/gpt-4o-mini"),
schema: ReceiptExtractionSchema,
messages: [
{
role: "system",
content: "Du bist ein präziser Belegscanner. Extrahiere alle Daten im vorgegebenen Schema.",
},
{
role: "user",
content: [
{ type: "text", text: "Extrahiere diesen Beleg vollständig:" },
{ type: "image", image: dataUrl },
],
},
],
});
console.log("Extracted Object:", JSON.stringify(object, null, 2));
}
testStructuredExtraction().catch(console.error);

47
scripts/test_vision.mjs Normal file
View File

@@ -0,0 +1,47 @@
import fs from "fs";
import path from "path";
const envContent = fs.readFileSync(".env.local", "utf-8");
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=([^\r\n]+)/);
const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : "";
const demoImgPath = "public/demo/01_aral_tankbeleg_muenchen.jpg";
const imgBase64 = fs.readFileSync(demoImgPath).toString("base64");
const dataUrl = `data:image/jpeg;base64,${imgBase64}`;
async function testVision() {
console.log("Testing image input with deepseek/deepseek-v4-flash on OpenRouter...");
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Receipt Scanner",
},
body: JSON.stringify({
model: "deepseek/deepseek-v4-flash",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Welcher Händler steht auf diesem Beleg und wie lautet der Gesamtbetrag?" },
{
type: "image_url",
image_url: {
url: dataUrl
}
}
]
}
]
})
});
console.log("Status:", res.status, res.statusText);
const data = await res.json();
console.log("Response:", JSON.stringify(data, null, 2));
}
testVision().catch(console.error);

View File

@@ -0,0 +1,263 @@
#!/usr/bin/env node
/**
* Verify the database least-privilege setup (scripts/db-permissions.sql).
*
* Connects twice:
* 1. as the OWNER (DATABASE_URL from env, .env.local or .env) to inspect the
* role attributes and privilege catalog, and
* 2. as the RUNTIME role `receipt_app` (APP_DATABASE_URL if set, otherwise
* derived from DATABASE_URL with user receipt_app and the runtime
* password) to prove the role can do what the app needs — and cannot do
* DDL.
*
* Prints one PASS/FAIL line per check and exits 1 if any check fails.
*
* Usage:
* node scripts/verify-db-permissions.mjs
* APP_DATABASE_URL=... node scripts/verify-db-permissions.mjs
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "pg";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_RUNTIME_PASSWORD = "receipt_app_secure_password";
const RUNTIME_ROLE = "receipt_app";
let passed = 0;
let failed = 0;
function check(name, ok, detail = "") {
if (ok) {
passed++;
console.log(`[PASS] ${name}${detail ? ` - ${detail}` : ""}`);
} else {
failed++;
console.log(`[FAIL] ${name}${detail ? ` - ${detail}` : ""}`);
}
}
/** Minimal .env parser: strips quotes, ignores comments; first key wins. */
function parseEnvFile(filePath) {
const out = {};
let text;
try {
text = readFileSync(filePath, "utf8");
} catch {
return out;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in out)) out[key] = value;
}
return out;
}
// Env precedence: process.env > .env.local > .env.
const fileEnv = { ...parseEnvFile(path.join(ROOT, ".env")), ...parseEnvFile(path.join(ROOT, ".env.local")) };
const ownerUrl = process.env.DATABASE_URL || fileEnv.DATABASE_URL;
if (!ownerUrl) {
console.error("FATAL: DATABASE_URL not found. Set it in the environment, .env.local or .env.");
process.exit(1);
}
const runtimePassword =
process.env.APP_DATABASE_PASSWORD || fileEnv.APP_DATABASE_PASSWORD || DEFAULT_RUNTIME_PASSWORD;
/** Runtime URL: APP_DATABASE_URL when provided, else derived from the owner URL. */
function runtimeUrl() {
const explicit =
process.env.APP_DATABASE_URL || fileEnv.APP_DATABASE_URL;
if (explicit) return explicit;
const u = new URL(ownerUrl);
u.username = RUNTIME_ROLE;
u.password = runtimePassword;
return u.toString();
}
console.log(`Verifying least-privilege setup for role "${RUNTIME_ROLE}" ...\n`);
// ---------------------------------------------------------------------------
// 1) Owner connection: role attributes + privilege catalog
// ---------------------------------------------------------------------------
const owner = new Client({ connectionString: ownerUrl });
await owner.connect();
const roleRes = await owner.query(
`SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin
FROM pg_roles WHERE rolname = $1`,
[RUNTIME_ROLE]
);
if (roleRes.rows.length === 0) {
check(`${RUNTIME_ROLE} exists`, false, "role not found");
console.error("\nAborting: runtime role is missing. Run: node scripts/apply-db-permissions.mjs");
await owner.end();
process.exit(1);
}
const r = roleRes.rows[0];
check(`${RUNTIME_ROLE} exists with LOGIN`, r.rolcanlogin === true);
check(`${RUNTIME_ROLE} is NOT a superuser`, r.rolsuper === false);
check(`${RUNTIME_ROLE} has no CREATEDB`, r.rolcreatedb === false);
check(`${RUNTIME_ROLE} has no CREATEROLE`, r.rolcreaterole === false);
const privRes = await owner.query(
`SELECT has_database_privilege($1, current_database(), 'CONNECT') AS db_connect,
has_database_privilege($1, current_database(), 'TEMP') AS db_temp,
has_schema_privilege($1, 'public', 'USAGE') AS schema_usage,
has_schema_privilege($1, 'public', 'CREATE') AS schema_create`,
[RUNTIME_ROLE]
);
const p = privRes.rows[0];
check(`${RUNTIME_ROLE} has CONNECT on the database`, p.db_connect === true);
check(`${RUNTIME_ROLE} has USAGE on schema public`, p.schema_usage === true);
check(`${RUNTIME_ROLE} has NO CREATE on schema public (no DDL)`, p.schema_create === false);
const publicRes = await owner.query(
`SELECT has_schema_privilege('public', 'public', 'CREATE') AS public_create`
);
check(
"PUBLIC pseudo-role has NO CREATE on schema public (schema hardened)",
publicRes.rows[0].public_create === false
);
// All current public tables: DML granted, DDL (TRUNCATE/REFERENCES) NOT granted.
const tableRes = await owner.query(
`SELECT c.relname,
has_table_privilege($1, c.oid, 'SELECT') AS can_select,
has_table_privilege($1, c.oid, 'INSERT') AS can_insert,
has_table_privilege($1, c.oid, 'UPDATE') AS can_update,
has_table_privilege($1, c.oid, 'DELETE') AS can_delete,
has_table_privilege($1, c.oid, 'TRUNCATE') AS can_truncate,
has_table_privilege($1, c.oid, 'REFERENCES') AS can_references
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY c.relname`,
[RUNTIME_ROLE]
);
const missingDml = tableRes.rows.filter((t) => !(t.can_select && t.can_insert && t.can_update && t.can_delete));
check(
`${RUNTIME_ROLE} has SELECT/INSERT/UPDATE/DELETE on ALL public tables`,
missingDml.length === 0,
`${tableRes.rows.length} table(s)` +
(missingDml.length ? `; missing on: ${missingDml.map((t) => t.relname).join(", ")}` : "")
);
const grantedDdl = tableRes.rows.filter((t) => t.can_truncate || t.can_references);
check(
`${RUNTIME_ROLE} has NO TRUNCATE/REFERENCES on tables (no DDL)`,
grantedDdl.length === 0,
grantedDdl.length ? `granted on: ${grantedDdl.map((t) => t.relname).join(", ")}` : `${tableRes.rows.length} table(s) checked`
);
// All current public sequences: USAGE/SELECT granted, UPDATE (setval) NOT granted.
const seqRes = await owner.query(
`SELECT c.relname,
has_sequence_privilege($1, c.oid, 'USAGE') AS can_usage,
has_sequence_privilege($1, c.oid, 'SELECT') AS can_select,
has_sequence_privilege($1, c.oid, 'UPDATE') AS can_update
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'S'
ORDER BY c.relname`,
[RUNTIME_ROLE]
);
const missingSeq = seqRes.rows.filter((s) => !(s.can_usage && s.can_select));
check(
`${RUNTIME_ROLE} has USAGE/SELECT on ALL public sequences`,
missingSeq.length === 0,
`${seqRes.rows.length} sequence(s)` +
(missingSeq.length ? `; missing on: ${missingSeq.map((s) => s.relname).join(", ")}` : "")
);
const updateSeq = seqRes.rows.filter((s) => s.can_update);
check(
`${RUNTIME_ROLE} has NO UPDATE on sequences (no setval/rewind)`,
updateSeq.length === 0,
updateSeq.length ? `granted on: ${updateSeq.map((s) => s.relname).join(", ")}` : `${seqRes.rows.length} sequence(s) checked`
);
// Default privileges: future objects created by the owner get the same grants.
const defRes = await owner.query(
`SELECT p.defaclobjtype, array_to_string(p.defaclacl, ',') AS acl
FROM pg_default_acl p
JOIN pg_namespace n ON n.oid = p.defaclnamespace
WHERE n.nspname = 'public'
AND pg_get_userbyid(p.defaclrole) = current_user`
);
const aclText = defRes.rows.map((x) => `${x.defaclobjtype}:${x.acl}`).join(" | ");
// Table default ACL is "receipt_app=arwd/<grantor>", sequence is "receipt_app=rU/<grantor>".
check(
"DEFAULT PRIVILEGES grant DML to receipt_app for future tables",
aclText.includes("receipt_app=arwd"),
aclText || "no default ACLs recorded"
);
check(
"DEFAULT PRIVILEGES grant USAGE/SELECT on future sequences",
aclText.includes("receipt_app=rU"),
aclText || "no default ACLs recorded"
);
// Owner must still be able to DDL (migrations/schema init unaffected).
try {
await owner.query("CREATE TABLE public.__receipt_owner_ddl_check__(id integer)");
await owner.query("DROP TABLE public.__receipt_owner_ddl_check__");
check("owner role can still CREATE/DROP tables (migrations unaffected)", true, "CREATE + DROP in public succeeded");
} catch (err) {
check("owner role can still CREATE/DROP tables (migrations unaffected)", false, err.message);
}
// ---------------------------------------------------------------------------
// 2) Runtime connection as receipt_app
// ---------------------------------------------------------------------------
const appUrl = runtimeUrl();
const app = new Client({ connectionString: appUrl });
let connected = false;
try {
await app.connect();
connected = true;
check("receipt_app can connect with its own credentials", true, "(CONNECT granted; URL from APP_DATABASE_URL or derived from DATABASE_URL)");
const me = await app.query("SELECT current_user AS u, current_database() AS db");
check("connected session runs as receipt_app", me.rows[0].u === RUNTIME_ROLE, `current_user=${me.rows[0].u}, db=${me.rows[0].db}`);
try {
const cnt = await app.query("SELECT count(*) AS n FROM users");
check("receipt_app can SELECT from users", true, `count(*) = ${cnt.rows[0].n}`);
} catch (err) {
check("receipt_app can SELECT from users", false, err.message);
}
// DDL attempt: must be denied (SQLSTATE 42501 = insufficient_privilege).
try {
await app.query("CREATE TABLE public.__receipt_app_perm_check__(id integer)");
await app.query("DROP TABLE public.__receipt_app_perm_check__").catch(() => {});
check("receipt_app CANNOT create tables (DDL denied)", false, "CREATE TABLE unexpectedly succeeded");
} catch (err) {
if (err.code === "42501") {
check("receipt_app CANNOT create tables (DDL denied)", true, `SQLSTATE 42501 insufficient_privilege`);
} else {
check("receipt_app CANNOT create tables (DDL denied)", false, `unexpected error: ${err.message}`);
}
}
} catch (err) {
check("receipt_app can connect with its own credentials", false, err.message);
} finally {
if (connected) await app.end().catch(() => {});
}
await owner.end();
console.log(`\nResult: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

50
scripts/verify-styles.js Normal file
View File

@@ -0,0 +1,50 @@
const fs = require('fs');
const path = require('path');
function walk(dir) {
let results = [];
fs.readdirSync(dir).forEach((file) => {
const full = path.join(dir, file);
if (fs.statSync(full).isDirectory()) {
results = results.concat(walk(full));
} else if (/\.(tsx|ts|css)$/.test(file)) {
results.push(full);
}
});
return results;
}
const files = walk('src');
let violations = 0;
files.forEach((f) => {
const lines = fs.readFileSync(f, 'utf8').split('\n');
lines.forEach((l, i) => {
if (/\bshadow-(sm|md|lg|xl|2xl)\b/.test(l)) {
console.log(`SHADOW: ${f}:${i + 1} -> ${l.trim()}`);
violations++;
}
if (/\brounded-(full|lg|md|xl|2xl|3xl)\b/.test(l)) {
console.log(`ROUNDED: ${f}:${i + 1} -> ${l.trim()}`);
violations++;
}
if (/\bbg-gradient\b/.test(l)) {
console.log(`GRADIENT: ${f}:${i + 1} -> ${l.trim()}`);
violations++;
}
if (/box-shadow:/.test(l)) {
console.log(`BOX-SHADOW: ${f}:${i + 1} -> ${l.trim()}`);
violations++;
}
});
});
console.log('--- VERIFICATION RESULT ---');
console.log('TOTAL_VIOLATIONS:', violations);
if (violations === 0) {
console.log('VERIFICATION_PASSED: 0 residual style violations across src/');
process.exit(0);
} else {
console.log('VERIFICATION_FAILED');
process.exit(1);
}

144
scripts/verify_cookies.mjs Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Verification that every auth cookie the app issues carries the hardened
* attributes: HttpOnly + SameSite=Lax (+ Secure in production).
*
* Runs under plain Node — no build step:
* node scripts/verify_cookies.mjs
*
* Three layers:
* 1. SOURCE assertion on the single source of truth — `cookieSecurityOptions()`
* in src/lib/auth/config.ts must declare httpOnly / sameSite / secure /
* path. Every cookie writer below is checked only for *delegating* to
* this helper (or to sessionCookieOptions, which itself delegates to
* it), not for re-declaring the attributes inline — the attributes only
* live in one place, so re-checking them at each call site just goes
* stale the next time a call site is refactored to delegate instead of
* inlining.
* 2. DELEGATION assertions — session.ts, guest.ts and the Google OAuth
* handshake/callback routes must each go through the shared helper, and
* no cookie writer in the auth stack may set attributes without it.
* 3. SERIALIZATION assertion — feed the exact attribute values the helper
* produces through Next's cookie serializer (NextResponse) and confirm
* the resulting Set-Cookie string contains `HttpOnly` and `SameSite=Lax`.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
let failures = 0;
let checks = 0;
function check(name, condition, detail = "") {
checks += 1;
if (condition) {
console.log(` PASS ${name}`);
} else {
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ""}`);
}
}
const read = (rel) => readFileSync(join(root, rel), "utf8");
// ---------------------------------------------------------------------------
// 1. Source-level attribute assertion on the single shared helper
// ---------------------------------------------------------------------------
console.log("\n[1] Core helper — src/lib/auth/config.ts (cookieSecurityOptions)");
{
const src = read("src/lib/auth/config.ts");
const fn = src.slice(src.indexOf("export function cookieSecurityOptions"));
check("cookieSecurityOptions declares httpOnly: true", /\bhttpOnly:\s*true\b/.test(fn));
check("cookieSecurityOptions declares sameSite: \"lax\"", /sameSite:\s*"lax"/.test(fn));
check("cookieSecurityOptions declares secure: isProduction", /\bsecure:\s*isProduction\b/.test(fn));
check("cookieSecurityOptions declares path: \"/\"", /path:\s*"\/"/.test(fn));
}
console.log("\n[2] Session cookie — src/lib/auth/session.ts (sessionCookieOptions)");
{
const src = read("src/lib/auth/session.ts");
check("sessionCookieOptions delegates to cookieSecurityOptions", /export function sessionCookieOptions[\s\S]*?cookieSecurityOptions\(/.test(src));
check("session cookie is set through sessionCookieOptions (single source)", /cookieStore\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src));
check("no other raw session-cookie set without the helper", (src.match(/cookieStore\.set\(/g) || []).length === 1);
}
console.log("\n[3] Guest cookie — src/lib/auth/guest.ts (applyGuestCookie)");
{
const src = read("src/lib/auth/guest.ts");
const block = src.slice(src.indexOf("response.cookies.set"));
check("guest cookie is set via cookieSecurityOptions", /response\.cookies\.set\(\s*GUEST_COOKIE,\s*ctx\.cookieValue,\s*cookieSecurityOptions\(/.test(block));
}
console.log("\n[4] OAuth handshake cookies — src/app/api/auth/google/route.ts");
{
const src = read("src/app/api/auth/google/route.ts");
check("oauth state/verifier cookies are set via cookieSecurityOptions", /cookieSecurityOptions\(\{[^}]*\}\)/.test(src));
check("both handshake cookies reuse the same options object", (src.match(/response\.cookies\.set\((OAUTH_STATE_COOKIE|OAUTH_VERIFIER_COOKIE),\s*\w+,\s*cookieOptions\)/g) || []).length === 2);
}
console.log("\n[5] OAuth session cookie — src/app/api/auth/google/callback/route.ts");
{
const src = read("src/app/api/auth/google/callback/route.ts");
// The callback sets the session cookie through the same shared helper, so the
// attribute source is config.ts (asserted above). Confirm the delegation.
check(
"callback sets the session cookie via sessionCookieOptions",
/response\.cookies\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src)
);
}
console.log("\n[6] No cookie writer in the auth stack bypasses the shared helper");
{
// Any `.cookies.set(` in the auth stack must be immediately followed by a
// call to cookieSecurityOptions(...) or sessionCookieOptions(...) as its
// options argument — never an inline object literal.
const files = [
"src/lib/auth/session.ts",
"src/lib/auth/guest.ts",
"src/app/api/auth/google/route.ts",
"src/app/api/auth/google/callback/route.ts",
];
for (const rel of files) {
const src = read(rel);
const setCalls = src.match(/\.cookies\.set\([^;]*?\)(?=;|\n)/gs) || [];
const bypassing = setCalls.filter((c) => !/(cookieSecurityOptions|sessionCookieOptions|cookieOptions)\s*\(/.test(c) && !c.includes("cookieOptions)"));
check(`${rel}: every .cookies.set() options arg comes from the shared helper`, bypassing.length === 0, bypassing.join(" | "));
}
}
// ---------------------------------------------------------------------------
// 3. Serialization assertion (NextResponse + the exact attribute values)
// ---------------------------------------------------------------------------
console.log("\n[7] Serialized Set-Cookie contains HttpOnly and SameSite=Lax");
{
// Next ships its server entry as CJS; createRequire gives us the same
// resolution plain `require('next/server')` uses.
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const { NextResponse } = require("next/server");
const res = NextResponse.json({ ok: true });
res.cookies.set("sr_session", "test-token", {
httpOnly: true,
sameSite: "lax",
secure: false, // dev — production flips via isProduction
path: "/",
expires: new Date(Date.now() + 3_600_000),
});
const header = res.headers.get("set-cookie") || "";
check("Set-Cookie header present", header.length > 0);
check("contains HttpOnly", /\bHttpOnly\b/i.test(header), header);
check("contains SameSite (case-insensitive)", /SameSite=Lax/i.test(header), header);
check("contains Path=/", /Path=\//i.test(header), header);
check("session cookie is not visible to JS", !/\bHttpOnly=false\b/i.test(header));
}
// ---------------------------------------------------------------------------
console.log(`\n${checks} checks, ${failures} failure(s)`);
if (failures > 0) {
console.error("COOKIE VERIFICATION FAILED");
process.exit(1);
}
console.log("COOKIE VERIFICATION PASSED");

345
scripts/verify_cors.mjs Normal file
View File

@@ -0,0 +1,345 @@
/**
* CORS policy verification harness.
*
* Exercises the pure helpers in src/lib/http/cors.ts with simulated requests
* (real NextRequest/NextResponse instances) and asserts the policy:
* - preflight from an allowed origin -> 204 + correct headers
* - preflight from a disallowed origin -> 403
* - normal request, disallowed origin -> 403
* - no Origin header / allowed origin -> undefined (continue)
* - allowlist normalization (trailing slash, case, default port)
* - never emits `Access-Control-Allow-Origin: *`
*
* Run: node scripts/verify_cors.mjs
* (Node >= 23.6 / 24 runs the imported .ts directly via built-in type stripping;
* no tsx required.)
*/
import assert from "node:assert/strict";
import { NextRequest } from "next/server";
import {
getAllowedOrigins,
isOriginAllowed,
corsHeadersFor,
handleCors,
originComparisonKey,
CORS_ALLOWED_METHODS,
CORS_ERROR_CODE,
} from "../src/lib/http/cors.ts";
let passed = 0;
let failed = 0;
function check(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
failed += 1;
console.error(`FAIL ${name}`);
console.error(String(err instanceof Error ? err.stack : err).replace(/^/gm, " "));
}
}
/** Env that looks like production: app origin + a few extra allowlisted origins. */
const PROD_ENV = {
CORS_ORIGINS: "https://admin.example.com, http://localhost:8080/",
NEXT_PUBLIC_APP_URL: "https://app.example.com",
};
const APP_ORIGIN = "https://app.example.com";
function req(method, headers, url = "https://app.example.com/api/receipts") {
return new NextRequest(url, { method, headers });
}
// ---------------------------------------------------------------------------
console.log("CORS policy verification\n");
console.log("1) Allowlist construction & normalization");
check("getAllowedOrigins returns full origins (echo form), scheme & case preserved", () => {
const list = getAllowedOrigins(PROD_ENV);
assert.ok(list.includes("https://app.example.com"), `NEXT_PUBLIC_APP_URL missing: ${list}`);
assert.ok(list.includes("https://admin.example.com"), `missing admin origin: ${list}`);
assert.ok(list.includes("http://localhost:8080"), `missing localhost origin: ${list}`);
// Trailing slashes are stripped during normalization (spec).
assert.ok(!list.some((o) => o.endsWith("/")), `trailing slash survived: ${list}`);
});
check("allowlist is de-duplicated by comparison key; app origin wins over duplicates", () => {
const dupEnv = {
CORS_ORIGINS: "https://app.example.com, https://APP.EXAMPLE.COM:443",
NEXT_PUBLIC_APP_URL: "https://app.example.com",
};
// All three spellings share the key "app.example.com" -> exactly one entry,
// and it is the canonical NEXT_PUBLIC_APP_URL spelling (added first).
const list = getAllowedOrigins(dupEnv);
assert.deepEqual(list, ["https://app.example.com"], `expected single canonical entry: ${list}`);
const keys = getAllowedOrigins(PROD_ENV).map(originComparisonKey);
assert.equal(new Set(keys).size, keys.length, `duplicate keys: ${keys}`);
});
check("comparison key strips scheme, trailing slash, lowercases host, collapses default port", () => {
assert.equal(originComparisonKey("https://App.Example.com/"), "app.example.com");
assert.equal(originComparisonKey("https://app.example.com:443"), "app.example.com");
assert.equal(originComparisonKey("http://app.example.com:80"), "app.example.com");
assert.equal(originComparisonKey("HTTPS://app.example.com:3000/"), "app.example.com:3000");
assert.equal(originComparisonKey("http://localhost:8080"), "localhost:8080");
});
check("comparison key drops path/query/fragment defensively", () => {
assert.equal(
originComparisonKey("https://app.example.com/some/path?x=1#frag"),
"app.example.com"
);
});
check("comparison key handles IPv6 literals", () => {
assert.equal(originComparisonKey("http://[::1]:3000"), "[::1]:3000");
assert.equal(originComparisonKey("http://[::1]"), "[::1]");
});
// ---------------------------------------------------------------------------
console.log("\n2) isOriginAllowed");
check("no Origin header (null/empty) is allowed (curl, same-origin, server-to-server)", () => {
assert.equal(isOriginAllowed(null, PROD_ENV), true);
assert.equal(isOriginAllowed("", PROD_ENV), true);
});
check("exact allowed origin matches", () => {
assert.equal(isOriginAllowed(APP_ORIGIN, PROD_ENV), true);
assert.equal(isOriginAllowed("https://admin.example.com", PROD_ENV), true);
assert.equal(isOriginAllowed("http://localhost:8080", PROD_ENV), true);
});
check("normalization: trailing slash, mixed case, explicit default port all match", () => {
assert.equal(isOriginAllowed("https://app.example.com/", PROD_ENV), true);
assert.equal(isOriginAllowed("HTTPS://APP.EXAMPLE.COM", PROD_ENV), true);
assert.equal(isOriginAllowed("https://app.example.com:443", PROD_ENV), true);
});
check("disallowed origins are rejected (never *, exact match only)", () => {
assert.equal(isOriginAllowed("https://evil.example.com", PROD_ENV), false);
assert.equal(isOriginAllowed("https://example.com", PROD_ENV), false);
assert.equal(isOriginAllowed("http://localhost:9999", PROD_ENV), false);
assert.equal(isOriginAllowed("null", PROD_ENV), false);
});
check("substring / suffix attacks do not match", () => {
assert.equal(isOriginAllowed("https://app.example.com.evil.com", PROD_ENV), false);
assert.equal(isOriginAllowed("https://app.example.com.evil.com:443", PROD_ENV), false);
assert.equal(isOriginAllowed("https://evilexample.com", PROD_ENV), false);
assert.equal(isOriginAllowed("https://admin.example.com.evil.com", PROD_ENV), false);
});
check("host + port are exact; scheme is ignored for comparison (documented policy)", () => {
// Port is part of the exact match.
assert.equal(isOriginAllowed("https://app.example.com:444", PROD_ENV), false);
assert.equal(isOriginAllowed("http://localhost:8081", PROD_ENV), false);
// The http/https prefix is stripped for comparison (policy), so the same
// host:port under the other scheme is treated as the same origin.
assert.equal(isOriginAllowed("http://app.example.com", PROD_ENV), true);
});
check("empty allowlist => same-origin only (everything with an Origin rejected)", () => {
const empty = { CORS_ORIGINS: "", NEXT_PUBLIC_APP_URL: "" };
assert.equal(isOriginAllowed(null, empty), true);
assert.equal(isOriginAllowed("https://app.example.com", empty), false);
});
// ---------------------------------------------------------------------------
console.log("\n3) corsHeadersFor");
check("allowed origin -> exact echo + Vary: Origin + credentials; never '*'", () => {
const h = corsHeadersFor(APP_ORIGIN, PROD_ENV);
assert.equal(h["Access-Control-Allow-Origin"], APP_ORIGIN);
assert.equal(h["Vary"], "Origin");
assert.equal(h["Access-Control-Allow-Credentials"], "true");
assert.ok(!Object.values(h).includes("*"), "wildcard leaked into headers");
});
check("disallowed / missing origin -> no CORS headers", () => {
assert.deepEqual(corsHeadersFor("https://evil.example.com", PROD_ENV), {});
assert.deepEqual(corsHeadersFor(null, PROD_ENV), {});
assert.deepEqual(corsHeadersFor("", PROD_ENV), {});
});
// ---------------------------------------------------------------------------
console.log("\n4) handleCors — preflight");
check("preflight from allowed origin -> 204 with full header set", () => {
const res = handleCors(
req("OPTIONS", {
origin: APP_ORIGIN,
"access-control-request-method": "POST",
"access-control-request-headers": "content-type, authorization",
}),
PROD_ENV
);
assert.ok(res, "expected a response");
assert.equal(res.status, 204);
assert.equal(res.headers.get("access-control-allow-origin"), APP_ORIGIN);
assert.equal(res.headers.get("vary"), "Origin");
assert.equal(res.headers.get("access-control-allow-credentials"), "true");
assert.equal(res.headers.get("access-control-allow-methods"), CORS_ALLOWED_METHODS);
assert.equal(res.headers.get("access-control-max-age"), "600");
assert.equal(res.headers.get("access-control-allow-headers"), "content-type, authorization");
});
check("preflight echo uses the exact request origin, never a wildcard", () => {
const res = handleCors(
req("OPTIONS", {
origin: "https://admin.example.com",
"access-control-request-method": "GET",
}),
PROD_ENV
);
assert.ok(res);
assert.equal(res.status, 204);
const acao = res.headers.get("access-control-allow-origin");
assert.equal(acao, "https://admin.example.com");
assert.ok(acao && !acao.includes("*"), "Access-Control-Allow-Origin contains '*'");
});
check("preflight from disallowed origin -> 403, no ACAO header", () => {
const res = handleCors(
req("OPTIONS", {
origin: "https://evil.example.com",
"access-control-request-method": "POST",
}),
PROD_ENV
);
assert.ok(res);
assert.equal(res.status, 403);
assert.equal(res.headers.get("access-control-allow-origin"), null, "no ACAO on refusal");
});
check("preflight 403 carries the machine-readable error code", async () => {
const res = handleCors(
req("OPTIONS", {
origin: "https://evil.example.com",
"access-control-request-method": "POST",
}),
PROD_ENV
);
assert.ok(res);
const body = await res.json();
assert.deepEqual(body, { error: CORS_ERROR_CODE });
});
check("Access-Control-Allow-Headers echo is validated (invalid tokens dropped)", () => {
const res = handleCors(
req("OPTIONS", {
origin: APP_ORIGIN,
"access-control-request-method": "PUT",
"access-control-request-headers": "content-type, bad header!, x-foo",
}),
PROD_ENV
);
assert.ok(res);
assert.equal(res.headers.get("access-control-allow-headers"), "content-type, x-foo");
});
check("Access-Control-Allow-Headers echo is bounded (oversized input omitted)", () => {
const res = handleCors(
req("OPTIONS", {
origin: APP_ORIGIN,
"access-control-request-method": "PUT",
"access-control-request-headers": "x-big-" + "a".repeat(5000),
}),
PROD_ENV
);
assert.ok(res);
assert.equal(res.headers.get("access-control-allow-headers"), null);
});
check("Access-Control-Allow-Headers echo is de-duplicated", () => {
const res = handleCors(
req("OPTIONS", {
origin: APP_ORIGIN,
"access-control-request-method": "POST",
"access-control-request-headers": "content-type, content-type, authorization",
}),
PROD_ENV
);
assert.ok(res);
assert.equal(res.headers.get("access-control-allow-headers"), "content-type, authorization");
});
// ---------------------------------------------------------------------------
console.log("\n5) handleCors — non-preflight");
check("normal request from allowed origin -> undefined (continue)", () => {
const res = handleCors(req("GET", { origin: APP_ORIGIN }), PROD_ENV);
assert.equal(res, undefined);
});
check("normal request with no Origin -> undefined (continue)", () => {
const res = handleCors(req("GET", {}), PROD_ENV);
assert.equal(res, undefined);
});
check("normal request from disallowed origin -> 403", () => {
const res = handleCors(req("GET", { origin: "https://evil.example.com" }), PROD_ENV);
assert.ok(res);
assert.equal(res.status, 403);
});
check("bare OPTIONS without Access-Control-Request-Method is not a preflight", () => {
// Disallowed origin on a bare OPTIONS is still refused (cross-origin).
const disallowed = handleCors(req("OPTIONS", { origin: "https://evil.example.com" }), PROD_ENV);
assert.ok(disallowed);
assert.equal(disallowed.status, 403);
// Allowed origin on a bare OPTIONS passes through.
const allowed = handleCors(req("OPTIONS", { origin: APP_ORIGIN }), PROD_ENV);
assert.equal(allowed, undefined);
});
check("POST (credentials path) from allowed origin -> undefined", () => {
const res = handleCors(req("POST", { origin: APP_ORIGIN }), PROD_ENV);
assert.equal(res, undefined);
});
// ---------------------------------------------------------------------------
console.log("\n6) global: no '*' is ever emitted as a header value");
function collectHeaderValues() {
const values = [];
const origins = ["https://app.example.com", "https://admin.example.com", "http://localhost:8080"];
for (const origin of origins) {
const preflight = handleCors(
req("OPTIONS", {
origin,
"access-control-request-method": "GET",
"access-control-request-headers": "content-type, authorization",
}),
PROD_ENV
);
if (preflight) preflight.headers.forEach((v) => values.push(v));
values.push(...Object.values(corsHeadersFor(origin, PROD_ENV)));
}
return values;
}
check("no emitted header value equals or contains '*'", () => {
const values = collectHeaderValues();
assert.ok(values.length > 0, "expected to collect some header values");
for (const v of values) {
assert.ok(!v.includes("*"), `wildcard value emitted: ${JSON.stringify(v)}`);
}
});
check("allowlist itself contains no wildcard entries", () => {
for (const origin of getAllowedOrigins(PROD_ENV)) {
assert.ok(!origin.includes("*"), `wildcard in allowlist: ${origin}`);
}
});
// ---------------------------------------------------------------------------
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
console.error("CORS verification FAILED");
process.exit(1);
}
console.log("CORS verification PASSED");

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Verification for the canonical rate limiter at src/lib/security/rateLimit.ts.
*
* Runs under plain Node (>= 22.6, type-stripping) — no tsx, no build step:
* node scripts/verify_rate_limit.mjs
*
* Covers the three behaviours the security gate cares about:
* 1. A burst over the limit is denied with a positive retryAfter (the routes
* map that to HTTP 429 + Retry-After — see the audit doc).
* 2. Once the window expires, the same key is allowed again.
* 3. Different keys are independent buckets.
* Plus the clientIp header policy (last valid XFF entry wins; spoofed first
* entries ignored; x-real-ip fallback; unknown fallback).
*/
import { rateLimit, clientIp, resetRateLimits } from "../src/lib/security/rateLimit.ts";
let failures = 0;
let checks = 0;
function check(name, condition, detail = "") {
checks += 1;
if (condition) {
console.log(` PASS ${name}`);
} else {
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ""}`);
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// 1. Burst over the limit
// ---------------------------------------------------------------------------
console.log("\n[1] Burst over the limit → denied with retryAfter > 0");
{
resetRateLimits();
const LIMIT = 3;
const WINDOW_MS = 60_000;
const key = "login:ip:203.0.113.9";
const results = [];
for (let i = 0; i < LIMIT + 2; i++) results.push(rateLimit(key, LIMIT, WINDOW_MS));
const allowedCount = results.filter((r) => r.allowed).length;
const blocked = results[LIMIT]; // first denial
check(
"exactly `limit` requests allowed before denial",
allowedCount === LIMIT,
`allowed ${allowedCount}, expected ${LIMIT}`
);
check(
"first over-limit request is denied",
blocked && blocked.allowed === false,
JSON.stringify(blocked)
);
check(
"denied request reports a positive retryAfter (→ Retry-After header)",
blocked && blocked.retryAfter > 0,
`retryAfter=${blocked?.retryAfter}`
);
check(
"every further request stays denied inside the window",
results.slice(LIMIT).every((r) => !r.allowed)
);
check(
"allowed requests report retryAfter 0",
results.slice(0, LIMIT).every((r) => r.retryAfter === 0)
);
}
// ---------------------------------------------------------------------------
// 2. Window expiry → allowed again
// ---------------------------------------------------------------------------
console.log("\n[2] Window expiry resets the bucket");
{
resetRateLimits();
const key = "signup:ip:198.51.100.4";
const WINDOW_MS = 120; // short window so the test does not stall
for (let i = 0; i < 2; i++) rateLimit(key, 1, WINDOW_MS);
const blockedNow = rateLimit(key, 1, WINDOW_MS);
check("denied while inside the window", blockedNow.allowed === false);
await sleep(WINDOW_MS + 30);
const allowedAfter = rateLimit(key, 1, WINDOW_MS);
check("allowed again after the window rolls over", allowedAfter.allowed === true);
}
// ---------------------------------------------------------------------------
// 3. Different keys do not interfere
// ---------------------------------------------------------------------------
console.log("\n[3] Keys are isolated buckets");
{
resetRateLimits();
const keyA = "reset:ip:192.0.2.1";
const keyB = "reset:ip:192.0.2.2";
rateLimit(keyA, 1, 60_000);
rateLimit(keyA, 1, 60_000); // exhaust A
check("key A is denied after its own limit", rateLimit(keyA, 1, 60_000).allowed === false);
check("key B is still allowed", rateLimit(keyB, 1, 60_000).allowed === true);
check(
"route prefix keeps routes apart",
rateLimit("login:ip:192.0.2.1", 1, 60_000).allowed === true
);
}
// ---------------------------------------------------------------------------
// 4. clientIp header policy
// ---------------------------------------------------------------------------
console.log("\n[4] clientIp trusts the LAST valid XFF entry, never the first");
{
const req = (headers) => new Request("http://localhost", { headers });
check(
"takes last valid IP when the client spoofs leading entries",
clientIp(req({ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 198.51.100.9" })) ===
"198.51.100.9",
clientIp(req({ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 198.51.100.9" }))
);
check(
"ignores a spoofed-only chain (no valid IP) and falls back to x-real-ip",
clientIp(req({ "x-forwarded-for": "evil, not-an-ip", "x-real-ip": "10.0.0.5" })) ===
"10.0.0.5"
);
check(
"falls back to x-real-ip when XFF is absent",
clientIp(req({ "x-real-ip": "10.0.0.6" })) === "10.0.0.6"
);
check(
"never returns a spoofed string — 'unknown' when nothing validates",
clientIp(req({ "x-forwarded-for": "spoofed-value" })) === "unknown"
);
check("no headers at all → 'unknown'", clientIp(req({})) === "unknown");
}
// ---------------------------------------------------------------------------
console.log(`\n${checks} checks, ${failures} failure(s)`);
if (failures > 0) {
console.error("RATE-LIMIT VERIFICATION FAILED");
process.exit(1);
}
console.log("RATE-LIMIT VERIFICATION PASSED");

510
scripts/verify_sanitize.mjs Normal file
View File

@@ -0,0 +1,510 @@
#!/usr/bin/env node
/**
* Verification for the "Sanitize before storing" hardening:
* - src/lib/ingest/sanitize.ts (string/number sanitizers + stored-receipt zod schema)
* - src/lib/export/csvGenerator.ts (CSV formula-injection neutralizer)
* - excel / pdf generators (values stay plain strings / literal text)
*
* Runs under plain Node ≥ 23.6 (native TypeScript type-stripping):
* node scripts/verify_sanitize.mjs
*
* Exit code 0 = all assertions passed, 1 = at least one failed.
*/
import { strict as assert } from "node:assert";
import ExcelJS from "exceljs";
import {
SANITIZE_LIMITS,
sanitizeText,
sanitizeMultilineText,
sanitizeUrl,
sanitizeCurrency,
sanitizeReceiptBatch,
sanitizeReceipt,
StoredReceiptSchema,
} from "../src/lib/ingest/sanitize.ts";
import {
generateAccountingCsv,
neutralizeFormulaPrefix,
} from "../src/lib/export/csvGenerator.ts";
import { generateDualSheetExcel } from "../src/lib/export/excelGenerator.ts";
import { generateReceiptPdf } from "../src/lib/export/pdfGenerator.ts";
// ---------------------------------------------------------------------------
// Tiny test runner
// ---------------------------------------------------------------------------
let passed = 0;
let failed = 0;
const failures = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(` PASS ${name}`);
} catch (err) {
failed++;
failures.push({ name, err });
console.error(` FAIL ${name}\n ${err && err.message ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function validReceipt(overrides = {}) {
return {
id: "rcpt_test_123",
imageHash: "abc123def456",
merchant: {
name: "REWE",
address: "Hauptstr 1, 10115 Berlin",
taxId: "DE123456789",
confidence: 0.98,
},
date: { isoDate: "2026-08-15", time: "10:30", confidence: 0.97 },
documentType: "KASSENBON",
receiptNumber: "2026-0815-001",
currency: "EUR",
totalAmount: { value: 12.34, confidence: 0.99 },
netAmount: 10.36,
tipAmount: null,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
lineItems: [
{ description: "Apfel", quantity: 2, price: 12.34, unitPrice: 6.17, taxRate: 19 },
],
suggestedCategory: "Sonstiges",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
issues: null,
userConfirmed: null,
},
rawText: "REWE\nApfel 2x 6,17\nSUMME 12,34",
paymentMethod: "EC_KARTE",
previewUrl: "https://storage.example/rcpt_test_123.jpg",
originalFileName: "bon.jpg",
fileSizeBytes: 12345,
status: "ready",
createdAt: "2026-08-15T10:31:00.000Z",
updatedAt: "2026-08-15T10:31:00.000Z",
...overrides,
};
}
function maliciousReceipt() {
return validReceipt({
merchant: {
name: "<script>alert(1)</script>",
address: "<img src=x onerror=alert(2)>",
taxId: "javascript:alert(3)",
confidence: 1,
},
receiptNumber: '=HYPERLINK("http://evil.example","x")',
suggestedCategory: "@SUM(1+1)",
rawText: '<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007',
lineItems: [
{ description: "+cmd|' /C calc'!A0", quantity: 1, price: 12.34, unitPrice: 12.34, taxRate: 19 },
{ description: "<svg onload=alert(5)>", quantity: 2, price: 0.5, taxRate: null },
],
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "merchant",
reviewReason: "<script>alert(6)</script>",
issues: [{ field: "merchant", severity: "warning", message: "<img onerror=alert(7)>" }],
userConfirmed: null,
},
paymentMethod: "EC_KARTE",
});
}
/** Splits one quoted CSV line (; delimiter) into unquoted cells. */
function splitCsvLine(line) {
const cells = [];
let cur = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuotes = false;
}
} else {
cur += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ";") {
cells.push(cur);
cur = "";
} else {
cur += ch;
}
}
cells.push(cur);
return cells;
}
// ---------------------------------------------------------------------------
// 1. String sanitizer
// ---------------------------------------------------------------------------
console.log("\n[1] String sanitizer");
test("strips script tags from merchant name", () => {
assert.equal(sanitizeText("<script>alert(1)</script>", 200), "alert(1)");
});
test("strips img/svg onerror markup entirely (tag content included)", () => {
// The whole tag — including payload text inside it — is removed.
assert.equal(sanitizeText('<img src=x onerror=alert(1)>', 200), "");
assert.equal(sanitizeText("<svg onload=alert(5)>", 200), "");
});
test("strips comments / doctype / CDATA / processing instructions", () => {
assert.equal(sanitizeText("a<!-- x -->b", 200), "ab");
assert.equal(sanitizeText("a<!DOCTYPE html>b", 200), "ab");
assert.equal(sanitizeText("a<![CDATA[ x ]]>b", 200), "ab");
assert.equal(sanitizeText("a<?php echo 1 ?>b", 200), "ab");
});
test("malformed double-angle markup cannot survive", () => {
assert.equal(sanitizeText("<<script>alert(1)", 200), "alert(1)");
});
test("strips control chars (NUL, BEL, ESC …) and collapses whitespace", () => {
assert.equal(sanitizeText(" Hello\u0000World\u0007!\t\t", 200), "HelloWorld!");
assert.equal(sanitizeText("a\u0000\nb", 200), "a b");
});
test("trims and caps length", () => {
assert.equal(sanitizeText(" x ", 200), "x");
assert.equal(sanitizeText("x".repeat(5000), 200).length, 200);
assert.equal(sanitizeText(12345, 200), ""); // non-string → ""
});
test("multiline OCR sanitizer keeps newlines, strips markup and NUL", () => {
const out = sanitizeMultilineText("<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007", 1000);
assert.equal(out, "Zeile 2\nZeile 3");
assert.ok(!out.includes("<"));
});
test("multiline OCR caps at 50k", () => {
assert.equal(sanitizeMultilineText("y".repeat(100000), 50000).length, 50000);
});
test("sanitizeUrl drops dangerous schemes and caps length", () => {
assert.equal(sanitizeUrl("javascript:alert(1)"), undefined);
assert.equal(sanitizeUrl("vbscript:msgbox(1)"), undefined);
assert.equal(sanitizeUrl("data:text/html,<script>alert(1)</script>"), undefined);
assert.equal(sanitizeUrl(" https://example.com/a.jpg "), "https://example.com/a.jpg");
assert.equal(sanitizeUrl("x".repeat(5000)).length, SANITIZE_LIMITS.previewUrl);
});
test("sanitizeCurrency normalizes and falls back", () => {
assert.equal(sanitizeCurrency(" chf "), "CHF");
assert.equal(sanitizeCurrency("EUR"), "EUR");
assert.equal(sanitizeCurrency(""), "EUR");
assert.equal(sanitizeCurrency(undefined), "EUR");
});
// ---------------------------------------------------------------------------
// 2. Formula-injection neutralizer (CSV export boundary)
// ---------------------------------------------------------------------------
console.log("\n[2] CSV formula-injection neutralizer");
test("prefixes = + - @ tab CR payloads with a single quote", () => {
assert.equal(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")'), "'=HYPERLINK(\"http://evil\",\"x\")");
assert.equal(neutralizeFormulaPrefix("+cmd|' /C calc'!A0"), "'+cmd|' /C calc'!A0");
assert.equal(neutralizeFormulaPrefix("@SUM(1+1)"), "'@SUM(1+1)");
assert.equal(neutralizeFormulaPrefix("-cmd|'/C calc'!A0"), "'-cmd|'/C calc'!A0");
assert.equal(neutralizeFormulaPrefix("\t=1+1"), "'\t=1+1");
assert.equal(neutralizeFormulaPrefix("\r=1+1"), "'\r=1+1");
});
test("leaves plain numbers (incl. negative credit notes) untouched", () => {
assert.equal(neutralizeFormulaPrefix("-5,00"), "-5,00");
assert.equal(neutralizeFormulaPrefix("+1,25"), "+1,25");
assert.equal(neutralizeFormulaPrefix("123"), "123");
assert.equal(neutralizeFormulaPrefix(""), "");
assert.equal(neutralizeFormulaPrefix("'=already-safe"), "'=already-safe");
});
// ---------------------------------------------------------------------------
// 3. Stored-receipt schema: malicious input → sanitized safe output
// ---------------------------------------------------------------------------
console.log("\n[3] Stored-receipt schema (sanitize before store)");
test("malicious receipt is accepted and fully sanitized (no active markup)", () => {
const result = sanitizeReceiptBatch({ receipts: [maliciousReceipt()] });
assert.ok(result.ok, `expected ok, got: ${result.error}`);
const r = result.receipts[0];
assert.equal(r.merchant.name, "alert(1)");
assert.equal(r.merchant.address, null); // whole <img …> tag removed → empty → null
assert.equal(r.receiptNumber, '=HYPERLINK("http://evil.example","x")'); // inert text at rest
assert.equal(r.suggestedCategory, "@SUM(1+1)"); // inert text at rest
assert.equal(r.rawText, "Zeile 2\nZeile 3");
assert.equal(r.lineItems[0].description, "+cmd|' /C calc'!A0");
assert.equal(r.lineItems[1].description, ""); // <svg …> tag removed entirely
assert.equal(r.validation.reviewReason, "alert(6)");
assert.equal(r.validation.issues[0].message, ""); // <img …> tag removed entirely
assert.equal(r.validation.issues[0].severity, "warning");
const serialized = JSON.stringify(r);
assert.ok(!serialized.includes("<"), "no angle bracket may survive in stored data");
assert.ok(!serialized.includes("\u0000"), "no NUL may survive in stored data");
});
test("markup inside date / receiptNumber is stripped, not rejected", () => {
const result = sanitizeReceiptBatch(
validReceipt({ date: { isoDate: "2026-<b>08</b>-15", time: null, confidence: 0.9 } })
);
assert.ok(result.ok);
assert.equal(result.receipts[0].date.isoDate, "2026-08-15");
});
test("legacy German date format is preserved (sanitized), not rejected", () => {
const result = sanitizeReceiptBatch(
validReceipt({ date: { isoDate: "15.08.2026", time: null, confidence: 0.9 } })
);
assert.ok(result.ok);
assert.equal(result.receipts[0].date.isoDate, "15.08.2026");
});
test("oversized strings are truncated per policy", () => {
const result = sanitizeReceiptBatch(
validReceipt({ merchant: { name: "X".repeat(5000), address: null, taxId: null, confidence: 1 } })
);
assert.ok(result.ok);
assert.equal(result.receipts[0].merchant.name.length, SANITIZE_LIMITS.merchantName);
});
// ---------------------------------------------------------------------------
// 4. Numbers
// ---------------------------------------------------------------------------
console.log("\n[4] Numbers");
test("negative amounts are kept (credit notes are a domain case)", () => {
const result = sanitizeReceiptBatch(
validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40 })
);
assert.ok(result.ok);
assert.equal(result.receipts[0].totalAmount.value, -50);
assert.equal(result.receipts[0].netAmount, -40);
});
test("huge amounts are clamped to ±1e10 (fits numeric(12,2))", () => {
const result = sanitizeReceiptBatch(
validReceipt({ totalAmount: { value: 1e12, confidence: 1 } })
);
assert.ok(result.ok);
assert.equal(result.receipts[0].totalAmount.value, SANITIZE_LIMITS.maxAmountAbs);
});
test("tax rate percent is clamped to 0..100", () => {
const result = sanitizeReceiptBatch(
validReceipt({
taxBreakdown: [{ ratePercent: 150, taxAmount: 10, netAmount: 100 }],
})
);
assert.ok(result.ok);
assert.equal(result.receipts[0].taxBreakdown[0].ratePercent, 100);
});
test("NaN / Infinity amounts are REJECTED (structurally invalid)", () => {
const nan = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: NaN, confidence: 1 } }));
assert.ok(!nan.ok);
assert.ok(nan.error.includes("totalAmount.value"), nan.error);
const inf = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: Infinity, confidence: 1 } }));
assert.ok(!inf.ok);
});
test("non-number in a number field is rejected", () => {
const result = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: "12.5", confidence: 1 } }));
assert.ok(!result.ok);
});
// ---------------------------------------------------------------------------
// 5. Currency
// ---------------------------------------------------------------------------
console.log("\n[5] Currency");
test("3-letter ISO currency passes, lowercase is normalized", () => {
const ok = sanitizeReceiptBatch(validReceipt({ currency: "chf" }));
assert.ok(ok.ok);
assert.equal(ok.receipts[0].currency, "CHF");
});
test("missing currency defaults to EUR", () => {
const r = validReceipt();
delete r.currency;
const ok = sanitizeReceiptBatch(r);
assert.ok(ok.ok);
assert.equal(ok.receipts[0].currency, "EUR");
});
test("bad currency (symbol / 2-letter / number) is rejected with 400-style error", () => {
for (const bad of ["€", "US", "euro", 123]) {
const result = sanitizeReceiptBatch(validReceipt({ currency: bad }));
assert.ok(!result.ok, `currency ${JSON.stringify(bad)} should be rejected`);
assert.ok(result.error.includes("currency"), result.error);
}
});
// ---------------------------------------------------------------------------
// 6. Batch shape handling (mirrors POST /api/receipts)
// ---------------------------------------------------------------------------
console.log("\n[6] Batch shapes & limits");
test("accepts array, {receipts}, {receipt} and single-receipt bodies", () => {
const base = validReceipt();
assert.ok(sanitizeReceiptBatch([base]).ok);
assert.ok(sanitizeReceiptBatch({ receipts: [base] }).ok);
assert.ok(sanitizeReceiptBatch({ receipt: base }).ok);
assert.ok(sanitizeReceiptBatch(base).ok);
});
test("rejects empty payloads and >500 receipts with the historic 400 messages", () => {
assert.equal(sanitizeReceiptBatch({}).error, "No receipts provided in payload.");
assert.equal(sanitizeReceiptBatch([]).error, "No receipts provided in payload.");
assert.equal(sanitizeReceiptBatch(Array(501).fill(validReceipt())).error, "Too many receipts in payload.");
});
test("rejects structurally invalid receipts with a clear indexed error", () => {
const broken = validReceipt({ merchant: undefined });
const result = sanitizeReceiptBatch({ receipts: [validReceipt(), broken] });
assert.ok(!result.ok);
assert.ok(result.error.includes("index 1"), result.error);
assert.ok(result.error.includes("merchant"), result.error);
});
test("rejects missing / oversized receipt ids", () => {
assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "" })).ok);
assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "x".repeat(100) })).ok);
});
test("sanitizeReceipt single variant returns null on failure, sanitized data on success", () => {
assert.equal(sanitizeReceipt(validReceipt({ currency: "€" })), null);
const r = sanitizeReceipt(validReceipt({ merchant: { name: "<b>REWE</b>", address: null, taxId: null, confidence: 1 } }));
assert.ok(r !== null);
assert.equal(r.merchant.name, "REWE");
});
test("StoredReceiptSchema keeps passthrough fields (boundingBoxes etc.)", () => {
const r = validReceipt({ boundingBoxes: { merchant: { x: 1, y: 2, width: 3, height: 4 } } });
const parsed = StoredReceiptSchema.safeParse(r);
assert.ok(parsed.success);
assert.deepEqual(parsed.data.boundingBoxes, r.boundingBoxes);
});
// ---------------------------------------------------------------------------
// 7. CSV generator: no cell may start with a formula character
// ---------------------------------------------------------------------------
console.log("\n[7] CSV generator output");
function evilCsvReceipt() {
return validReceipt({
merchant: { name: '=HYPERLINK("http://evil.example","x")', address: null, taxId: null, confidence: 1 },
receiptNumber: "+cmd|' /C calc'!A0",
suggestedCategory: "@SUM(1+1)",
rawText: "<img src=x onerror=alert(1)>\n<b>bold</b>",
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
});
}
test("malicious merchant/receiptNumber/category cells are neutralized in CSV", () => {
const csv = generateAccountingCsv([evilCsvReceipt()], { locale: "de" });
assert.ok(!csv.includes('"=HYPERLINK'), "raw formula must not appear quoted");
assert.ok(csv.includes("'=HYPERLINK"), "neutralized cell must be prefixed with a quote");
assert.ok(csv.includes("'+cmd|"), "DDE payload must be neutralized");
const lines = csv.replace(/^\uFEFF/, "").split("\r\n").filter((l) => l.length > 0);
assert.ok(lines.length >= 2, "header + at least one data row");
for (let i = 1; i < lines.length; i++) {
for (const cell of splitCsvLine(lines[i])) {
assert.ok(
!/^[=+\-@\t\r]/.test(cell),
`CSV cell on line ${i + 1} starts with a formula char: ${JSON.stringify(cell)}`
);
}
}
});
test("negative credit-note amounts still export as plain numbers", () => {
const csv = generateAccountingCsv(
[validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40, currency: "EUR" })],
{ locale: "de" }
);
assert.ok(csv.includes('"-50,00"'), "negative amount stays a number cell");
});
// ---------------------------------------------------------------------------
// 8. Excel generator: user values are written as plain strings, never formulas
// ---------------------------------------------------------------------------
console.log("\n[8] Excel generator round-trip");
test("merchant '=HYPERLINK(...)' is written as a plain STRING cell, not a formula", async () => {
const buffer = await generateDualSheetExcel([evilCsvReceipt()], { locale: "de" });
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buffer);
const sheet = wb.getWorksheet("Belegübersicht");
assert.ok(sheet, "overview sheet exists");
const merchantCell = sheet.getCell(2, 3); // col C = merchant
assert.equal(typeof merchantCell.value, "string");
assert.equal(merchantCell.value, '=HYPERLINK("http://evil.example","x")');
assert.equal(merchantCell.formula, undefined, "cell must not carry a formula");
const itemsSheet = wb.getWorksheet("Einzelpositionen Detail");
assert.ok(itemsSheet, "line-items sheet exists");
const descCell = itemsSheet.getCell(2, 4); // col D = description
assert.equal(typeof descCell.value, "string");
assert.equal(descCell.formula, undefined, "description cell must not carry a formula");
});
// ---------------------------------------------------------------------------
// 9. PDF generator: renders literal text, no formula/HTML interpretation
// ---------------------------------------------------------------------------
console.log("\n[9] PDF generator");
test("PDF with malicious strings still generates a valid PDF document", async () => {
const pdf = await generateReceiptPdf([evilCsvReceipt()], { locale: "de" });
assert.ok(pdf instanceof Uint8Array && pdf.length > 100);
const header = Buffer.from(pdf.slice(0, 5)).toString("latin1");
assert.equal(header, "%PDF-");
});
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
console.error("\nFailed tests:");
for (const f of failures) console.error(` - ${f.name}: ${f.err.message}`);
process.exit(1);
}
process.exit(0);

View File

@@ -0,0 +1,95 @@
/**
* Orchestrator-level verification for Task 1 (Sanitize before storing).
* Tests the real implementation in src/lib/ingest/sanitize.ts (zod schemas +
* sanitizers) and the CSV formula-injection guard in
* src/lib/export/csvGenerator.ts (neutralizeFormulaPrefix).
*
* Run: node --experimental-loader ./scripts/cors-resolve-hook.mjs scripts/verify_sanitize_orchestrator.mjs
*/
import { sanitizeText, sanitizeMultilineText, sanitizeReceiptBatch, sanitizeReceipt, StoredReceiptSchema } from "../src/lib/ingest/sanitize.ts";
import { neutralizeFormulaPrefix } from "../src/lib/export/csvGenerator.ts";
let failed = 0;
let checks = 0;
function expect(cond, label) {
checks++;
if (!cond) { failed++; console.error(" FAIL:", label); }
else console.log(" ok ", label);
}
console.log("[1] sanitizeText strips markup + control chars");
const dirty = `<script>alert(1)</script>Händler <b>GmbH</b>\u0000\u0007`;
const clean = sanitizeText(dirty, 200);
expect(!clean.includes("<") && !clean.includes(">"), "no < > remain");
expect(!clean.includes("\u0000") && !clean.includes("\u0007"), "control chars stripped");
expect(clean.includes("Händler GmbH"), "text preserved");
expect(sanitizeText("a\u00a0b c", 100) === "a b c", "whitespace collapsed");
console.log("[2] multiline OCR keeps line layout, strips markup");
const ocr = "Zeile1\nZeile2<img onerror=alert(1)>\tZeile3";
const ocrClean = sanitizeMultilineText(ocr, 50_000);
expect(ocrClean.includes("\n"), "newlines survive");
expect(!ocrClean.includes("<"), "markup stripped");
expect(ocrClean.includes("Zeile3"), "tab survived");
console.log("[3] length caps");
expect(sanitizeText("x".repeat(500), 64).length === 64, "capped at limit");
console.log("[4] sanitizeReceiptBatch — malicious receipt payload");
const evil = {
id: "r1<script>",
merchant: { name: '=HYPERLINK("http://evil","x")', address: null, taxId: null, confidence: 1 },
date: { isoDate: "2026-08-15", time: null, confidence: 1 },
documentType: "KASSENBON",
receiptNumber: "+cmd|' /C calc'!A0",
currency: "EUR",
totalAmount: { value: 12.5, confidence: 1 },
netAmount: 10.5,
taxBreakdown: [{ ratePercent: 19, taxAmount: 2, netAmount: 10.5 }],
lineItems: [{ description: "<b>Cola</b>", quantity: 2, price: 2.5, unitPrice: 1.25, taxRate: 19 }],
suggestedCategory: "Sonstiges<script>",
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
rawText: "OCR <img onerror=1> line\n2",
paymentMethod: "CASH",
createdAt: "2026-08-15T10:00:00.000Z",
};
const batch = sanitizeReceiptBatch([evil]);
expect(batch.ok === true, "malicious-but-structurally-valid payload accepted (sanitized)");
if (batch.ok && batch.receipts) {
const r = batch.receipts[0];
expect(!r.merchant.name.includes("<") && !r.merchant.name.includes(">"), "merchant markup stripped");
expect(!r.suggestedCategory.includes("<script>"), "category markup stripped");
expect(!r.rawText.includes("<"), "rawText markup stripped");
expect(r.currency === "EUR", "currency normalized");
expect(r.id === "r1", "id sanitized (markup tag removed)");
}
console.log("[5] sanitizeReceiptBatch — structural rejection");
expect(sanitizeReceiptBatch(null).ok === false, "null payload rejected");
expect(sanitizeReceiptBatch({}).ok === false, "empty object rejected");
expect(sanitizeReceiptBatch([]).ok === false, "empty array rejected");
expect(sanitizeReceiptBatch([{ id: 42 }]).ok === false, "non-string id rejected");
expect(sanitizeReceiptBatch([{ id: "x", merchant: { name: 5 } }]).ok === false, "non-string merchant name rejected");
expect(sanitizeReceiptBatch([{ id: "x", currency: "EURO" }]).ok === false, "bad currency rejected");
expect(sanitizeReceiptBatch([{ id: "x", totalAmount: { value: NaN } }]).ok === false, "NaN amount rejected");
const tooMany = Array.from({ length: 501 }, (_, i) => ({ id: `r${i}`, currency: "EUR", totalAmount: { value: 1 } }));
expect(sanitizeReceiptBatch(tooMany).ok === false, "501 receipts rejected");
expect(sanitizeReceiptBatch(tooMany).error.includes("Too many"), "batch-size error message");
console.log("[6] sanitizeReceipt single variant");
expect(sanitizeReceipt(evil) !== null, "valid single receipt sanitized");
expect(sanitizeReceipt({ id: "x" }) === null, "invalid single receipt → null");
console.log("[7] CSV formula injection — neutralizeFormulaPrefix");
expect(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")') === "'=HYPERLINK(\"http://evil\",\"x\")", "= prefix neutralized");
expect(neutralizeFormulaPrefix('+cmd|\' /C calc\'!A0') === "'+cmd|' /C calc'!A0", "+ non-number neutralized");
expect(neutralizeFormulaPrefix('@SUM(1+1)') === "'@SUM(1+1)", "@ neutralized");
expect(neutralizeFormulaPrefix('\t=1') === "'\t=1", "tab neutralized");
expect(neutralizeFormulaPrefix('-5,00') === "-5,00", "negative number literal untouched (credit note)");
expect(neutralizeFormulaPrefix('+1,25') === "+1,25", "positive number literal untouched");
expect(neutralizeFormulaPrefix('-SUM(1+1)') === "'-SUM(1+1)", "- non-number neutralized");
expect(neutralizeFormulaPrefix('Rewe Markt') === "Rewe Markt", "normal text untouched");
console.log(`\n${checks} checks, ${failed} failure(s)`);
if (failed > 0) process.exit(1);
console.log("SANITIZE VERIFICATION PASSED");

View File

@@ -0,0 +1,249 @@
/**
* Verification battery for the sensitive-path / directory-listing hardening
* (src/lib/http/sensitivePaths.ts + its wiring in src/middleware.ts).
*
* Run: node --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs
* (or: npx tsx scripts/verify_sensitive_paths.mjs)
*
* Asserts against the REAL implementation (imported pure function + wrapper),
* not a re-implementation:
* - blocked paths -> isSensitivePath() === true and blockSensitivePath()
* returns a 404 NextResponse with {error:"Not found"}
* - allowed paths -> isSensitivePath() === false and blockSensitivePath()
* returns undefined (request processing continues)
* - encoded bypasses-> blocked by the wrapper's raw-path check
* Exits 0 on success, 1 on any failure.
*/
import { isSensitivePath, blockSensitivePath } from "../src/lib/http/sensitivePaths.ts";
import { NextRequest } from "next/server";
let failures = 0;
let checks = 0;
function expect(condition, label) {
checks++;
if (!condition) {
failures++;
console.error(` FAIL: ${label}`);
}
}
function describe(label, fn) {
console.log(`\n${label}`);
return fn();
}
const BLOCKED_PATHS = [
// dotfiles / dot-directories
"/.env",
"/.env.local",
"/.env.example",
"/.git/config",
"/.gitignore",
"/.dockerignore",
"/.npmrc",
"/.next/BUILD_ID",
"/.next-corrupt-20260815-2345/foo",
"/.next-corrupt-20260815-2350/server.js",
"/api/.env", // dotfile at depth
// non-web-facing directories
"/node_modules/x",
"/node_modules/next/dist/server.js",
"/drizzle/0000_init.sql",
"/scripts/generate_demo_images.mjs",
"/foo/node_modules/x",
// project / build / config files (root + at depth)
"/docker-compose.yml",
"/docker-compose.override.yml",
"/Dockerfile",
"/deep/dir/Dockerfile",
"/build_err.txt",
"/tsconfig.json",
"/tsconfig.tsbuildinfo",
"/next.config.ts",
"/drizzle.config.ts",
"/package.json",
"/package-lock.json",
"/foo/package.json",
// markdown documentation
"/PROJECT.md",
"/ORIGINAL_REQUEST.md",
"/PROMPT_GOAL.md",
"/DESIGN (1).md",
"/AUTH_SETUP_GUIDE.md",
"/STRIPE_SETUP_GUIDE.md",
"/TEST_INFRA.md",
"/receipt_scanner_to_excel_blueprint.md",
"/deep/dir/notes.md",
// sensitive extensions
"/deep/dir/secret.pem",
"/deep/dir/private.key",
"/deep/dir/cert.crt",
"/deep/dir/app.log",
// path traversal / malformed input (defensive)
"/foo/../bar",
"/foo/./bar",
"no-leading-slash",
"",
];
const ALLOWED_PATHS = [
"/",
"/de",
"/en",
"/dashboard",
"/api/receipts",
"/api/receipts/123",
"/api/auth/login",
"/api/auth/callback",
"/showcase/01_synthwave_cyberpunk_sunset.png",
"/showcase/05_watchmaker_cinematic_photo.jpg",
"/app-icon.jpg",
"/app-icon-original.png",
"/demo/01_aral_tankbeleg_muenchen.jpg",
"/favicon.ico",
"/icon.png",
"/apple-icon.png",
"/robots.txt",
"/sitemap.xml",
"/admin",
"/admin/users",
"/admin/waitlist",
"/admin/system",
"/terms",
"/privacy",
"/impressum",
"/datenschutz",
"/agb",
"/_next/static/chunks/app/layout.js", // excluded by matcher anyway; policy must pass it
];
const ENCODED_CASES = [
// [url, expectBlocked, note]
[
"https://example.com/%2eenv",
true,
"encoded leading dot survives URL parsing -> raw %2e check blocks",
],
[
"https://example.com/%252eenv",
true,
"double-encoded dot -> raw %25 check blocks",
],
[
"https://example.com/%2e%2e/%2eenv",
true,
"encoded .. collapses but %2eenv remains -> raw %2e check blocks",
],
[
"https://example.com/foo%5c..%5c.env",
true,
"encoded backslashes survive URL parsing -> raw %5c check blocks",
],
[
"https://example.com/%2e%2e/%2e%2e/etc/passwd",
false,
"URL parser fully normalizes encoded .. -> neutralized upstream (/etc/passwd is not a route)",
],
[
"https://example.com/foo\\bar",
false,
"URL parser normalizes raw backslash to '/' -> neutralized upstream",
],
];
/**
* Wrapper-level subset: every blocked path the wrapper can actually see.
* Paths with literal `.`/`..` segments and non-"/" inputs are pure-level
* concerns — NextRequest URL parsing normalizes them away (or the middleware
* never produces them), so they are asserted separately below.
*/
const WRAPPER_BLOCKED_PATHS = BLOCKED_PATHS.filter(
(p) =>
p.startsWith("/") &&
!p.includes("/../") &&
!p.includes("/./") &&
p !== ".."
);
function mockRequest(url) {
return new NextRequest(url);
}
async function main() {
await describe("Pure predicate - blocked paths (expect isSensitivePath() === true)", () => {
for (const p of BLOCKED_PATHS) {
expect(isSensitivePath(p) === true, `should block ${JSON.stringify(p)}`);
}
});
await describe("Pure predicate - allowed paths (expect isSensitivePath() === false)", () => {
for (const p of ALLOWED_PATHS) {
expect(isSensitivePath(p) === false, `should allow ${JSON.stringify(p)}`);
}
});
await describe("Wrapper - blocked paths return 404 {error:'Not found'}", async () => {
for (const p of WRAPPER_BLOCKED_PATHS) {
const res = blockSensitivePath(mockRequest(`https://example.com${p}`));
expect(res !== undefined, `should return a response for ${JSON.stringify(p)}`);
if (res) {
expect(res.status === 404, `status 404 for ${JSON.stringify(p)} (got ${res.status})`);
const body = await res.json().catch(() => null);
expect(
body && typeof body === "object" && body.error === "Not found",
`body {error:'Not found'} for ${JSON.stringify(p)} (got ${JSON.stringify(body)})`
);
}
}
});
await describe("Wrapper - allowed paths return undefined (processing continues)", () => {
for (const p of ALLOWED_PATHS) {
const res = blockSensitivePath(mockRequest(`https://example.com${p}`));
expect(res === undefined, `should continue for ${JSON.stringify(p)}`);
}
});
await describe("Wrapper - literal traversal segments are neutralized by URL parsing", () => {
// NextRequest uses the WHATWG URL parser, which collapses literal
// `.`/`..` segments and drops non-"/" inputs before middleware code runs.
// The pure predicate still blocks them if they ever appear (defense
// against a proxy that forwards un-normalized paths).
const neutralized = ["/foo/../bar", "/foo/./bar", "no-leading-slash", ""];
for (const p of neutralized) {
expect(isSensitivePath(p) === true, `pure predicate still blocks ${JSON.stringify(p)}`);
const res = blockSensitivePath(mockRequest(`https://example.com${p}`));
expect(
res === undefined,
`wrapper returns undefined for ${JSON.stringify(p)} (URL parser normalized it; router 404s the result)`
);
}
});
await describe("Wrapper - percent-encoded traversal bypasses", () => {
for (const [url, expectBlocked, note] of ENCODED_CASES) {
const req = mockRequest(url);
// informational: show what nextUrl.pathname looked like after URL parsing
console.log(` nextUrl.pathname of ${url} -> ${JSON.stringify(req.nextUrl.pathname)}`);
const res = blockSensitivePath(req);
if (expectBlocked) {
expect(res !== undefined, `should block ${url} (${note})`);
if (res) expect(res.status === 404, `status 404 for ${url}`);
} else {
expect(res === undefined, `neutralized upstream (returns undefined): ${url} (${note})`);
}
}
});
}
main().then(
() => {
console.log(`\n${checks} checks, ${failures} failure(s)`);
if (failures > 0) process.exitCode = 1;
},
(err) => {
console.error("Battery crashed:", err);
process.exitCode = 1;
}
);