phase-0: foundation, both apps boot end to end

Monorepo (pnpm workspaces) with two deployable apps and three pure packages.

apps/api (Hono on Node): Zod validated env that fails fast and names the problem,
Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable
migrations covering the whole SPEC section 5 schema, Better Auth with the four
roles and seeded demo accounts, localized error envelope, /healthz and /readyz,
graceful SIGTERM drain. Dialect specific SQL is confined to the two factories.

apps/web (Next.js App Router): locale routed shell in es and en with a language
switcher, sign in screen, and a runtime /api proxy so the browser only ever sees
one origin and cookies stay first party.

packages/i18n ships both catalogs complete; es is generated from COPY.md and a
test re-derives it from the document on every run so it cannot drift.
packages/contracts holds the Zod schemas and the typed client the web app uses.

Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck
and lint clean, migrate and seed from a clean database, sign in through the proxy
with CSRF rejection of foreign origins.

Not verified here: docker compose. This user has no access to the docker socket.

RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and
no tax rule, check digit or deadline was invented. See DECISIONS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-03 21:46:35 +00:00
co-authored by Claude Opus 5
commit ae2ea20b7e
106 changed files with 11541 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# apps/api environment. Copy to .env and adjust. Every variable is validated at boot
# by src/lib/env.ts, which fails fast with the exact problem.
# development | test | production
NODE_ENV=development
# Port the Hono server listens on. The web app proxies /api here.
PORT=4000
# User facing origin. Used for links in emails, push payloads and Telegram messages.
APP_PUBLIC_URL=http://localhost:3000
# server = serves HTTP. worker = runs the job poller and sweeps, serves only /healthz.
ROLE=server
# Run the job poller inside the server process. Set false only when a dedicated
# worker exists, which requires Postgres (SQLite is single writer).
JOBS_INLINE=true
JOBS_POLL_INTERVAL_MS=2000
# A running job whose lock is older than this returns to pending, for crash recovery.
JOBS_STALE_MINUTES=10
# sqlite:./data/app.db, sqlite::memory: or postgres://user:pass@host:5432/db
# The dialect is chosen from this scheme. Nothing else selects it.
DATABASE_URL=sqlite:./data/app.db
# Signing key for sessions. At least 32 characters. Generate: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-at-least-32-characters-long
# Public origin cookies are issued for. Auth routes are proxied, so this is the web origin.
BETTER_AUTH_URL=http://localhost:3000
# local | s3. local needs one shared volume across replicas; s3 is required to scale out.
STORAGE_DRIVER=local
STORAGE_LOCAL_PATH=./data/files
# Only read when STORAGE_DRIVER=s3. Bucket, region and both keys are then required.
S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Needed by MinIO and most non AWS S3 implementations.
S3_FORCE_PATH_STYLE=true
# Optional. Without it, scans with no QR go straight to the manual form instead of OCR.
ANTHROPIC_API_KEY=
OCR_MODEL=claude-sonnet-4-6
# Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys
PUSH_VAPID_PUBLIC_KEY=
PUSH_VAPID_PRIVATE_KEY=
# Optional. Without SMTP_HOST, verification codes and emails are logged to stdout.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# Optional. Telegram is hidden as a notification channel when unset.
TELEGRAM_BOT_TOKEN=
# Locale for anonymous requests. Signed in users are served their profiles.locale.
DEFAULT_LOCALE=es
+40
View File
@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1
# Build stage: the whole workspace is needed because apps/api imports the packages/*
# source directly and tsup bundles it in.
FROM node:22-slim AS build
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
RUN corepack enable
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/contracts/package.json packages/contracts/
COPY packages/i18n/package.json packages/i18n/
COPY packages/rules/package.json packages/rules/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY packages packages
COPY apps/api apps/api
COPY docs docs
RUN pnpm --filter @impuestos/api build
RUN pnpm --filter @impuestos/api deploy --prod --legacy /prod/api
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Owns ./data, the SQLite file and the local storage driver's files.
RUN mkdir -p /app/data && chown -R node:node /app
COPY --from=build --chown=node:node /prod/api/node_modules ./node_modules
COPY --from=build --chown=node:node /repo/apps/api/dist ./dist
COPY --from=build --chown=node:node /repo/apps/api/package.json ./package.json
USER node
EXPOSE 4000
# SIGTERM is handled in src/index.ts: fail readiness, drain, close the pool, exit 0.
# No init shim, so node stays PID 1 and receives the signal directly.
CMD ["node", "dist/index.js"]
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@impuestos/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"db:migrate": "tsx src/db/migrate.cli.ts",
"db:seed": "tsx src/db/seed.cli.ts"
},
"dependencies": {
"@hono/node-server": "^2.1.1",
"@impuestos/contracts": "workspace:*",
"@impuestos/i18n": "workspace:*",
"@impuestos/rules": "workspace:*",
"better-auth": "^1.7.2",
"better-sqlite3": "^13.0.3",
"hono": "^4.13.5",
"kysely": "^0.29.5",
"pg": "^8.23.0",
"uuidv7": "^1.2.1",
"zod": "^4.5.4"
},
"devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^26.4.1",
"@types/pg": "^8.23.1",
"tsup": "^8.5.1",
"tsx": "^4.23.13",
"typescript": "^5.9.3"
}
}
+74
View File
@@ -0,0 +1,74 @@
import { betterAuth } from 'better-auth';
import { admin, emailOTP } from 'better-auth/plugins';
import { createAccessControl } from 'better-auth/plugins/access';
import { adminAc, defaultStatements, userAc } from 'better-auth/plugins/admin/access';
import type { Kysely } from 'kysely';
import type { Dialect } from '../db/index';
import type { Database } from '../db/schema';
import type { Env } from '../lib/env';
export const ROLES = ['user', 'accountant', 'staff', 'superadmin'] as const;
export type Role = (typeof ROLES)[number];
/** Roles that reach the `(admin)` area. `accountant` is dormant in v1. */
export const ADMIN_ROLES: readonly Role[] = ['staff', 'superadmin'];
const ac = createAccessControl(defaultStatements);
const roles = {
user: ac.newRole(userAc.statements),
/** Dormant in v1: the contador console is out of scope. Has no permissions yet. */
accountant: ac.newRole({}),
/** Support: can find and read users, cannot change roles or ban. */
staff: ac.newRole({ user: ['list', 'get'], session: ['list'] }),
superadmin: ac.newRole(adminAc.statements),
};
export interface AuthDeps {
db: Kysely<Database>;
dialect: Dialect;
env: Env;
/** Delivers the 6 digit verification code. Logs to stdout when SMTP is unset. */
sendOtp: (args: { email: string; otp: string; type: string }) => Promise<void>;
}
export function createAuth(deps: AuthDeps) {
const { db, dialect, env } = deps;
return betterAuth({
// better-auth types its adapter against Kysely<any>; our Database interface is
// narrower, so the instance is widened here rather than loosening the app wide type.
database: { db: db as unknown as Kysely<Record<string, never>>, type: dialect },
basePath: '/api/auth',
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
trustedOrigins: [env.APP_PUBLIC_URL, env.BETTER_AUTH_URL],
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
requireEmailVerification: false,
},
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
},
advanced: {
defaultCookieAttributes: {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
},
},
plugins: [
admin({ ac, roles, defaultRole: 'user', adminRoles: [...ADMIN_ROLES] }),
emailOTP({
otpLength: 6,
expiresIn: 10 * 60,
sendVerificationOTP: async ({ email, otp, type }) => {
await deps.sendOtp({ email, otp, type });
},
}),
],
});
}
export type Auth = ReturnType<typeof createAuth>;
+29
View File
@@ -0,0 +1,29 @@
import type { Kysely } from 'kysely';
import { isPostgresUrl, isSqliteUrl } from '../lib/env';
import { createPostgresDb } from './postgres';
import type { Database } from './schema';
import { createSqliteDb } from './sqlite';
export type Dialect = 'sqlite' | 'postgres';
export interface DbHandle {
db: Kysely<Database>;
dialect: Dialect;
close: () => Promise<void>;
}
export function dialectOf(databaseUrl: string): Dialect {
if (isSqliteUrl(databaseUrl)) return 'sqlite';
if (isPostgresUrl(databaseUrl)) return 'postgres';
throw new Error(
`DATABASE_URL must start with sqlite:, file:, postgres:// or postgresql://, got: ${databaseUrl}`,
);
}
export function createDb(databaseUrl: string): DbHandle {
const dialect = dialectOf(databaseUrl);
const handle = dialect === 'sqlite' ? createSqliteDb(databaseUrl) : createPostgresDb(databaseUrl);
return { ...handle, dialect };
}
export type { Database } from './schema';
+19
View File
@@ -0,0 +1,19 @@
import { createDb } from './index';
import { loadEnv } from '../lib/env';
import { migrateToLatest } from './migrator';
const env = loadEnv();
const handle = createDb(env.DATABASE_URL);
try {
const { auth, applied } = await migrateToLatest(handle, env);
console.info(`[migrate] dialect: ${handle.dialect}`);
console.info(`[migrate] better-auth tables synced: ${auth.length > 0 ? auth.join(', ') : 'none'}`);
console.info(`[migrate] migrations applied: ${applied.length > 0 ? applied.join(', ') : 'none'}`);
console.info('[migrate] up to date');
} catch (error) {
console.error('[migrate] failed:', error);
process.exitCode = 1;
} finally {
await handle.close();
}
+252
View File
@@ -0,0 +1,252 @@
import type { Kysely } from 'kysely';
/**
* Every table in SPEC.md section 5 that is not owned by better-auth.
* Portable: only `text`, `integer`, `real` and `bigint` column types are used.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('profiles')
.addColumn('user_id', 'text', (c) => c.primaryKey().references('user.id').onDelete('cascade'))
.addColumn('full_name', 'text', (c) => c.notNull())
.addColumn('doc_type', 'text', (c) => c.notNull())
.addColumn('ruc', 'text')
.addColumn('ruc_dv', 'text')
.addColumn('ci', 'text')
.addColumn('taxpayer_kind', 'text', (c) => c.notNull())
.addColumn('deadline_digit', 'integer', (c) => c.notNull())
.addColumn('obligations', 'text', (c) => c.notNull())
.addColumn('irp_gross_estimate', 'bigint')
.addColumn('auto_confirm_days', 'integer', (c) => c.notNull().defaultTo(7))
.addColumn('locale', 'text', (c) => c.notNull().defaultTo('es'))
.addColumn('created_at', 'text', (c) => c.notNull())
.addColumn('updated_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createTable('dependents')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
.addColumn('display_name', 'text', (c) => c.notNull())
.addColumn('relationship', 'text', (c) => c.notNull())
.addColumn('doc_number', 'text')
.addColumn('active', 'integer', (c) => c.notNull().defaultTo(1))
.addColumn('created_at', 'text', (c) => c.notNull())
.addColumn('updated_at', 'text', (c) => c.notNull())
.execute();
await db.schema.createIndex('dependents_user_idx').on('dependents').column('user_id').execute();
await db.schema
.createTable('consents')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
.addColumn('kind', 'text', (c) => c.notNull())
.addColumn('granted_at', 'text', (c) => c.notNull())
.addColumn('revoked_at', 'text')
.addColumn('text_version', 'text', (c) => c.notNull())
.execute();
await db.schema.createIndex('consents_user_idx').on('consents').column('user_id').execute();
await db.schema
.createTable('document_files')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('driver', 'text', (c) => c.notNull())
.addColumn('path', 'text', (c) => c.notNull())
.addColumn('mime', 'text', (c) => c.notNull())
.addColumn('size', 'integer', (c) => c.notNull())
.addColumn('sha256', 'text', (c) => c.notNull())
.addColumn('created_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createTable('documents')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
.addColumn('source', 'text', (c) => c.notNull())
.addColumn('status', 'text', (c) => c.notNull())
.addColumn('cdc', 'text')
.addColumn('qr_url', 'text')
.addColumn('doc_kind', 'text', (c) => c.notNull())
.addColumn('direction', 'text', (c) => c.notNull())
.addColumn('emitter_ruc', 'text', (c) => c.notNull())
.addColumn('emitter_dv', 'text')
.addColumn('emitter_name', 'text', (c) => c.notNull())
.addColumn('receiver_doc', 'text')
.addColumn('issue_date', 'text', (c) => c.notNull())
.addColumn('currency', 'text', (c) => c.notNull().defaultTo('PYG'))
.addColumn('total', 'bigint', (c) => c.notNull())
.addColumn('amount_iva10', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('amount_iva5', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('amount_exenta', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('iva10', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('iva5', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('supplier_regime_hint', 'text', (c) => c.notNull().defaultTo('unknown'))
.addColumn('verified_dnit', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('verification_status', 'text', (c) => c.notNull().defaultTo('unverified'))
.addColumn('dedupe_hash', 'text', (c) => c.notNull())
.addColumn('file_id', 'text', (c) => c.references('document_files.id').onDelete('set null'))
.addColumn('raw_extraction', 'text')
.addColumn('created_at', 'text', (c) => c.notNull())
.addColumn('confirmed_at', 'text')
.execute();
await db.schema
.createIndex('documents_user_dedupe_uidx')
.on('documents')
.columns(['user_id', 'dedupe_hash'])
.unique()
.execute();
await db.schema
.createIndex('documents_user_status_idx')
.on('documents')
.columns(['user_id', 'status'])
.execute();
await db.schema
.createIndex('documents_user_issue_date_idx')
.on('documents')
.columns(['user_id', 'issue_date'])
.execute();
await db.schema
.createTable('classifications')
.addColumn('document_id', 'text', (c) =>
c.primaryKey().references('documents.id').onDelete('cascade'),
)
.addColumn('iva_credit_eligible', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('iva_credit_amount', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('irp_category', 'text', (c) => c.notNull().defaultTo('none'))
.addColumn('irp_deductible_amount', 'bigint', (c) => c.notNull().defaultTo(0))
.addColumn('dependent_id', 'text', (c) => c.references('dependents.id').onDelete('set null'))
.addColumn('confidence', 'real', (c) => c.notNull().defaultTo(0))
.addColumn('decided_by', 'text', (c) => c.notNull().defaultTo('auto'))
.addColumn('rules_version', 'text', (c) => c.notNull())
.addColumn('updated_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createTable('declarations')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
.addColumn('form_code', 'text', (c) => c.notNull())
.addColumn('period', 'text', (c) => c.notNull())
.addColumn('status', 'text', (c) => c.notNull())
.addColumn('values', 'text', (c) => c.notNull())
.addColumn('summary', 'text', (c) => c.notNull())
.addColumn('pdf_file_id', 'text', (c) => c.references('document_files.id').onDelete('set null'))
.addColumn('rules_version', 'text', (c) => c.notNull())
.addColumn('document_ids', 'text', (c) => c.notNull())
.addColumn('created_at', 'text', (c) => c.notNull())
.addColumn('approved_at', 'text')
.addColumn('filed_marked_at', 'text')
.execute();
await db.schema
.createIndex('declarations_user_form_period_uidx')
.on('declarations')
.columns(['user_id', 'form_code', 'period'])
.unique()
.execute();
await db.schema
.createTable('jobs')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('type', 'text', (c) => c.notNull())
.addColumn('payload', 'text', (c) => c.notNull())
.addColumn('status', 'text', (c) => c.notNull().defaultTo('pending'))
.addColumn('run_at', 'text', (c) => c.notNull())
.addColumn('attempts', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('max_attempts', 'integer', (c) => c.notNull().defaultTo(5))
.addColumn('locked_by', 'text')
.addColumn('locked_at', 'text')
.addColumn('last_error', 'text')
.addColumn('created_at', 'text', (c) => c.notNull())
.addColumn('updated_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createIndex('jobs_status_run_at_idx')
.on('jobs')
.columns(['status', 'run_at'])
.execute();
await db.schema
.createTable('ingest_errors')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.references('user.id').onDelete('set null'))
.addColumn('document_id', 'text', (c) => c.references('documents.id').onDelete('set null'))
.addColumn('stage', 'text', (c) => c.notNull())
.addColumn('message', 'text', (c) => c.notNull())
.addColumn('payload', 'text')
.addColumn('status', 'text', (c) => c.notNull().defaultTo('open'))
.addColumn('resolved_by', 'text')
.addColumn('resolved_at', 'text')
.addColumn('created_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createIndex('ingest_errors_status_stage_idx')
.on('ingest_errors')
.columns(['status', 'stage'])
.execute();
await db.schema
.createTable('audit_log')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('actor_user_id', 'text', (c) => c.notNull())
.addColumn('actor_role', 'text', (c) => c.notNull())
.addColumn('action', 'text', (c) => c.notNull())
.addColumn('subject_user_id', 'text')
.addColumn('resource', 'text', (c) => c.notNull())
.addColumn('detail', 'text')
.addColumn('ip', 'text')
.addColumn('created_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createIndex('audit_log_created_at_idx')
.on('audit_log')
.column('created_at')
.execute();
await db.schema
.createIndex('audit_log_subject_idx')
.on('audit_log')
.column('subject_user_id')
.execute();
await db.schema
.createTable('notification_prefs')
.addColumn('user_id', 'text', (c) => c.primaryKey().references('user.id').onDelete('cascade'))
.addColumn('push_enabled', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('email_enabled', 'integer', (c) => c.notNull().defaultTo(1))
.addColumn('telegram_chat_id', 'text')
.addColumn('digest_hour', 'integer', (c) => c.notNull().defaultTo(9))
.execute();
await db.schema
.createTable('push_subscriptions')
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
.addColumn('endpoint', 'text', (c) => c.notNull())
.addColumn('keys', 'text', (c) => c.notNull())
.addColumn('created_at', 'text', (c) => c.notNull())
.execute();
await db.schema
.createIndex('push_subscriptions_user_idx')
.on('push_subscriptions')
.column('user_id')
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
for (const table of [
'push_subscriptions',
'notification_prefs',
'audit_log',
'ingest_errors',
'jobs',
'declarations',
'classifications',
'documents',
'document_files',
'consents',
'dependents',
'profiles',
]) {
await db.schema.dropTable(table).ifExists().execute();
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { Migration, MigrationProvider } from 'kysely/migration';
import * as core from './001_core';
/**
* Migrations are listed statically rather than read from disk: the production image
* is a single bundled file with no migrations directory to scan.
*/
const migrations: Record<string, Migration> = {
'001_core': core,
};
export const migrationProvider: MigrationProvider = {
getMigrations: async () => migrations,
};
export const MIGRATION_NAMES = Object.keys(migrations);
+63
View File
@@ -0,0 +1,63 @@
import { getMigrations } from 'better-auth/db/migration';
import { Migrator, type MigrationResultSet } from 'kysely/migration';
import { createAuth } from '../auth/options';
import type { Env } from '../lib/env';
import type { DbHandle } from './index';
import { migrationProvider } from './migrations/index';
import { withMigrationLock } from './postgres';
/**
* Two ordered steps:
* 1. better-auth creates and updates its own four tables. Delegating keeps the auth
* schema in step with the installed version and emits correct DDL per dialect,
* with no hand written dialect SQL here.
* 2. the Kysely migrator applies our migrations from src/db/migrations.
*/
export async function migrateToLatest(
handle: DbHandle,
env: Env,
): Promise<{ auth: string[]; applied: string[] }> {
const run = async () => {
const auth = await migrateAuthTables(handle, env);
const results = await kyselyMigrator(handle).migrateToLatest();
return { auth, applied: reportResults(results) };
};
return handle.dialect === 'postgres' ? withMigrationLock(handle.db, run) : run();
}
async function migrateAuthTables(handle: DbHandle, env: Env): Promise<string[]> {
const auth = createAuth({
db: handle.db,
dialect: handle.dialect,
env,
sendOtp: async () => undefined,
});
const plan = await getMigrations(auth.options);
const created = (plan.toBeCreated ?? []).map((table) => table.table);
const altered = (plan.toBeAdded ?? []).map((table) => table.table);
await plan.runMigrations();
return [...new Set([...created, ...altered])];
}
function kyselyMigrator(handle: DbHandle): Migrator {
return new Migrator({ db: handle.db, provider: migrationProvider });
}
function reportResults(results: MigrationResultSet): string[] {
if (results.error) throw results.error;
const applied: string[] = [];
for (const result of results.results ?? []) {
if (result.status === 'Success') applied.push(result.migrationName);
else if (result.status === 'Error') {
throw new Error(`migration failed: ${result.migrationName}`);
}
}
return applied;
}
/** Readiness check: are there migrations this build knows about that the database lacks? */
export async function pendingMigrations(handle: DbHandle): Promise<string[]> {
const migrations = await kyselyMigrator(handle).getMigrations();
return migrations.filter((m) => m.executedAt === undefined).map((m) => m.name);
}
+54
View File
@@ -0,0 +1,54 @@
import { Kysely, PostgresDialect, sql } from 'kysely';
import pg from 'pg';
import type { Database } from './schema';
/**
* One of the two files allowed to contain dialect specific SQL (SPEC.md section 5).
*
* Timestamps are stored as ISO-8601 text in our own tables, so the driver is told to
* hand back `numeric` as a number and nothing else needs a type parser.
*/
export function createPostgresDb(databaseUrl: string): {
db: Kysely<Database>;
close: () => Promise<void>;
} {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 10 });
const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
return {
db,
close: async () => {
await db.destroy();
},
};
}
/**
* Guards concurrent `db:migrate` runs (k8s runs it as an initContainer on every api
* replica). Advisory locks are released when the session ends, so a crashed migrator
* cannot wedge the next one.
*/
export const MIGRATION_ADVISORY_LOCK_KEY = 4120515;
/**
* Money is stored as `bigint`: guaranies overflow int4 at about Gs. 2.100.000.000,
* which real turnover passes. Node reads int8 as a string by default, so it is parsed
* back to a number here. Safe to Gs. 9.007.199.254.740.991.
*/
pg.types.setTypeParser(pg.types.builtins.INT8, (value) => {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) throw new Error(`bigint out of safe range: ${value}`);
return parsed;
});
/**
* Serialises `db:migrate` across replicas. k8s runs it as an initContainer on every
* api pod, so two migrators can start at the same moment.
*/
export async function withMigrationLock<T>(db: Kysely<Database>, fn: () => Promise<T>): Promise<T> {
await sql`select pg_advisory_lock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(db);
try {
return await fn();
} finally {
await sql`select pg_advisory_unlock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(db);
}
}
+262
View File
@@ -0,0 +1,262 @@
/**
* The single Kysely database interface, shared by both dialects.
*
* Portability rules (SPEC.md section 5):
* ids text, UUIDv7 generated by the app
* timestamps text, ISO-8601 UTC (sorts chronologically in both dialects)
* dates text, YYYY-MM-DD
* money integer guaranies, never a float
* json text, parsed through a Zod schema at the module boundary
* booleans integer 0/1
*
* The `better-auth` owned tables are declared here so seeds and admin queries are
* typed, but only `src/auth` and `src/modules/admin` may write to them.
*/
export interface Database {
// better-auth owned
user: UserTable;
session: SessionTable;
account: AccountTable;
verification: VerificationTable;
// pii, only src/modules/pii may touch these three
profiles: ProfilesTable;
dependents: DependentsTable;
consents: ConsentsTable;
document_files: DocumentFilesTable;
documents: DocumentsTable;
classifications: ClassificationsTable;
declarations: DeclarationsTable;
jobs: JobsTable;
ingest_errors: IngestErrorsTable;
audit_log: AuditLogTable;
notification_prefs: NotificationPrefsTable;
push_subscriptions: PushSubscriptionsTable;
}
export interface UserTable {
id: string;
name: string;
email: string;
emailVerified: number;
image: string | null;
createdAt: string;
updatedAt: string;
role: string | null;
banned: number | null;
banReason: string | null;
banExpires: string | null;
}
export interface SessionTable {
id: string;
expiresAt: string;
token: string;
createdAt: string;
updatedAt: string;
ipAddress: string | null;
userAgent: string | null;
userId: string;
impersonatedBy: string | null;
}
export interface AccountTable {
id: string;
issuer: string;
accountId: string;
providerId: string;
userId: string;
accessToken: string | null;
refreshToken: string | null;
idToken: string | null;
accessTokenExpiresAt: string | null;
refreshTokenExpiresAt: string | null;
scope: string | null;
password: string | null;
createdAt: string;
updatedAt: string;
}
export interface VerificationTable {
id: string;
identifier: string;
value: string;
expiresAt: string;
createdAt: string;
updatedAt: string;
}
export interface ProfilesTable {
user_id: string;
full_name: string;
doc_type: 'ruc' | 'ci';
ruc: string | null;
ruc_dv: string | null;
ci: string | null;
taxpayer_kind: 'individual' | 'company';
deadline_digit: number;
/** json: { code, active, since }[] */
obligations: string;
irp_gross_estimate: number | null;
auto_confirm_days: number;
locale: 'es' | 'en';
created_at: string;
updated_at: string;
}
export interface DependentsTable {
id: string;
user_id: string;
display_name: string;
relationship: 'conyuge' | 'hijo' | 'padre' | 'otro';
doc_number: string | null;
active: number;
created_at: string;
updated_at: string;
}
export interface ConsentsTable {
id: string;
user_id: string;
kind: 'data_processing' | 'notifications';
granted_at: string;
revoked_at: string | null;
text_version: string;
}
export interface DocumentFilesTable {
id: string;
driver: 'local' | 's3';
path: string;
mime: string;
size: number;
sha256: string;
created_at: string;
}
export interface DocumentsTable {
id: string;
user_id: string;
source: 'scan_qr' | 'scan_ocr' | 'manual';
status: 'needs_review' | 'confirmed' | 'rejected';
cdc: string | null;
qr_url: string | null;
doc_kind: 'factura' | 'autofactura' | 'nota_credito' | 'nota_debito' | 'boleta_resimple' | 'otro';
direction: 'purchase' | 'sale';
emitter_ruc: string;
emitter_dv: string | null;
emitter_name: string;
receiver_doc: string | null;
issue_date: string;
currency: 'PYG';
total: number;
amount_iva10: number;
amount_iva5: number;
amount_exenta: number;
iva10: number;
iva5: number;
supplier_regime_hint: 'normal' | 'resimple' | 'unknown';
verified_dnit: number;
verification_status: 'unverified' | 'valid' | 'invalid' | 'error';
dedupe_hash: string;
file_id: string | null;
/** json, the raw OCR or QR extraction that produced this row */
raw_extraction: string | null;
created_at: string;
confirmed_at: string | null;
}
export interface ClassificationsTable {
document_id: string;
iva_credit_eligible: number;
iva_credit_amount: number;
irp_category: string;
irp_deductible_amount: number;
dependent_id: string | null;
confidence: number;
decided_by: 'auto' | 'user' | 'staff';
rules_version: string;
updated_at: string;
}
export interface DeclarationsTable {
id: string;
user_id: string;
form_code: '120' | '515';
period: string;
status: 'draft' | 'ready' | 'approved';
/** json: { casilla, label, amount }[] */
values: string;
/** json: form specific summary numbers */
summary: string;
pdf_file_id: string | null;
rules_version: string;
/** json: string[] */
document_ids: string;
created_at: string;
approved_at: string | null;
filed_marked_at: string | null;
}
export interface JobsTable {
id: string;
type: string;
/** json */
payload: string;
status: 'pending' | 'running' | 'done' | 'failed' | 'dead';
run_at: string;
attempts: number;
max_attempts: number;
locked_by: string | null;
locked_at: string | null;
last_error: string | null;
created_at: string;
updated_at: string;
}
export interface IngestErrorsTable {
id: string;
user_id: string | null;
document_id: string | null;
stage: 'qr_parse' | 'ocr' | 'dedupe' | 'verify' | 'job' | 'other';
message: string;
/** json */
payload: string | null;
status: 'open' | 'resolved';
resolved_by: string | null;
resolved_at: string | null;
created_at: string;
}
/** Append only. Nothing in the codebase may update or delete a row here. */
export interface AuditLogTable {
id: string;
actor_user_id: string;
actor_role: string;
action: string;
subject_user_id: string | null;
resource: string;
/** json */
detail: string | null;
ip: string | null;
created_at: string;
}
export interface NotificationPrefsTable {
user_id: string;
push_enabled: number;
email_enabled: number;
telegram_chat_id: string | null;
digest_hour: number;
}
export interface PushSubscriptionsTable {
id: string;
user_id: string;
endpoint: string;
/** json */
keys: string;
created_at: string;
}
+31
View File
@@ -0,0 +1,31 @@
import { createAuth } from '../auth/options';
import { loadEnv } from '../lib/env';
import { createDb } from './index';
import { pendingMigrations } from './migrator';
import { SEED_ACCOUNTS, seed } from './seed';
const env = loadEnv();
const handle = createDb(env.DATABASE_URL);
const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined });
try {
const pending = await pendingMigrations(handle);
if (pending.length > 0) {
console.error(`[seed] run pnpm db:migrate first, pending: ${pending.join(', ')}`);
process.exit(1);
}
const { created, existing } = await seed(handle, auth);
if (created.length > 0) console.info(`[seed] created: ${created.join(', ')}`);
if (existing.length > 0) console.info(`[seed] already present: ${existing.join(', ')}`);
console.info('\n[seed] development sign in details:');
for (const account of SEED_ACCOUNTS) {
console.info(` ${account.email.padEnd(24)} ${account.password} (${account.role})`);
}
} catch (error) {
console.error('[seed] failed:', error);
process.exitCode = 1;
} finally {
await handle.close();
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { SEED_ACCOUNTS, seed } from './seed';
import { createHarness } from '../test/harness';
describe('seed', () => {
it('creates every account with its role, pre verified', async () => {
const h = await createHarness();
const rows = await h.deps.handle.db
.selectFrom('user')
.select(['email', 'role', 'emailVerified'])
.orderBy('email')
.execute();
expect(rows).toHaveLength(SEED_ACCOUNTS.length);
for (const account of SEED_ACCOUNTS) {
const row = rows.find((r) => r.email === account.email);
expect(row, account.email).toBeDefined();
expect(row?.role).toBe(account.role);
expect(row?.emailVerified).toBeTruthy();
}
await h.close();
});
it('is idempotent', async () => {
const h = await createHarness();
const again = await seed(h.deps.handle, h.deps.auth);
expect(again.created).toEqual([]);
expect(again.existing).toHaveLength(SEED_ACCOUNTS.length);
const { count } = await h.deps.handle.db
.selectFrom('user')
.select((eb) => eb.fn.countAll<number>().as('count'))
.executeTakeFirstOrThrow();
expect(Number(count)).toBe(SEED_ACCOUNTS.length);
await h.close();
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { Auth, Role } from '../auth/options';
import type { DbHandle } from './index';
export interface SeedAccount {
email: string;
password: string;
name: string;
role: Role;
}
/**
* Accounts per CONTRACTS.md section 4. Deterministic and idempotent: running the seed
* twice leaves the same rows.
*
* Their profiles, documents and declarations are seeded by the phases that own those
* tables. Until then `GET /me/profile` correctly answers 404 for each of them, which is
* the documented state for a user who has not finished setup.
*/
export const SEED_ACCOUNTS: readonly SeedAccount[] = [
{ email: 'superadmin@demo.local', password: 'demo-superadmin-1', name: 'Super Admin', role: 'superadmin' },
{ email: 'staff@demo.local', password: 'demo-staff-1', name: 'Staff Demo', role: 'staff' },
{ email: 'maria@demo.local', password: 'demo-maria-1', name: 'Maria Gonzalez', role: 'user' },
{ email: 'carlos@demo.local', password: 'demo-carlos-1', name: 'Carlos Benitez', role: 'user' },
];
export interface SeedResult {
created: string[];
existing: string[];
}
export async function seed(handle: DbHandle, auth: Auth): Promise<SeedResult> {
const result: SeedResult = { created: [], existing: [] };
for (const account of SEED_ACCOUNTS) {
const found = await handle.db
.selectFrom('user')
.select('id')
.where('email', '=', account.email)
.executeTakeFirst();
if (found) {
result.existing.push(account.email);
continue;
}
await auth.api.signUpEmail({
body: { email: account.email, password: account.password, name: account.name },
});
// Roles and verification are set directly: the sign up endpoint always creates a
// plain unverified `user`, and demo accounts need to be usable straight away.
await handle.db
.updateTable('user')
.set({ role: account.role, emailVerified: 1, updatedAt: new Date().toISOString() })
.where('email', '=', account.email)
.execute();
result.created.push(account.email);
}
return result;
}
+33
View File
@@ -0,0 +1,33 @@
import SQLite from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { Kysely, SqliteDialect } from 'kysely';
import type { Database } from './schema';
/**
* One of the two files allowed to contain dialect specific SQL (SPEC.md section 5).
*/
export function createSqliteDb(databaseUrl: string): { db: Kysely<Database>; close: () => Promise<void> } {
const file = sqliteFile(databaseUrl);
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
const sqlite = new SQLite(file);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
sqlite.pragma('foreign_keys = ON');
const db = new Kysely<Database>({ dialect: new SqliteDialect({ database: sqlite }) });
return {
db,
close: async () => {
await db.destroy();
},
};
}
/** `sqlite::memory:`, `sqlite:./data/app.db` and `file:./data/app.db` all work. */
export function sqliteFile(databaseUrl: string): string {
const path = databaseUrl.replace(/^sqlite:/, '').replace(/^file:/, '');
if (path === ':memory:' || path === '' || path === '//:memory:') return ':memory:';
return resolve(path);
}
+83
View File
@@ -0,0 +1,83 @@
import { ErrorEnvelope } from '@impuestos/contracts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createHarness, type Harness } from '../test/harness';
let h: Harness;
beforeAll(async () => {
h = await createHarness();
});
afterAll(async () => {
await h.close();
});
describe('health endpoints', () => {
it('reports liveness without touching the database', async () => {
const response = await h.app.request('/healthz');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
});
it('reports readiness with the database reachable and migrations current', async () => {
const response = await h.app.request('/readyz');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
ok: true,
checks: { database: 'ok', migrations: 'ok' },
});
});
it('fails both once draining starts, so the load balancer stops routing here', async () => {
h.startDraining();
expect((await h.app.request('/healthz')).status).toBe(503);
const ready = await h.app.request('/readyz');
expect(ready.status).toBe(503);
expect(await ready.json()).toMatchObject({ ok: false });
});
});
describe('error envelope', () => {
it('is returned for an unauthenticated request', async () => {
const response = await h.app.request('/api/me/profile');
expect(response.status).toBe(401);
const body = ErrorEnvelope.parse(await response.json());
expect(body.error.code).toBe('unauthorized');
expect(body.error.message).toBe('Necesitás iniciar sesion para ver esto.');
});
it('is localized from Accept-Language when nobody is signed in', async () => {
const response = await h.app.request('/api/me/profile', {
headers: { 'accept-language': 'en-US,en;q=0.9,es;q=0.8' },
});
const body = ErrorEnvelope.parse(await response.json());
expect(body.error.message).toBe('You need to sign in to see this.');
});
it('falls back to es for an unsupported language', async () => {
const response = await h.app.request('/api/me/profile', {
headers: { 'accept-language': 'pt-BR' },
});
const body = ErrorEnvelope.parse(await response.json());
expect(body.error.message).toBe('Necesitás iniciar sesion para ver esto.');
});
it('is used for unknown paths too, never a plain text 404', async () => {
const response = await h.app.request('/api/does-not-exist');
expect(response.status).toBe(404);
expect(response.headers.get('content-type')).toContain('application/json');
expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('not_found');
});
});
describe('sessions', () => {
it('signs a seeded account in and answers 404 until setup is complete', async () => {
const cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
const response = await h.app.request('/api/me/profile', { headers: { cookie } });
expect(response.status).toBe(404);
expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('not_found');
});
it('rejects the wrong password', async () => {
await expect(h.signIn('maria@demo.local', 'wrong-password')).rejects.toThrow();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { Hono } from 'hono';
import type { AppDeps, AppEnv } from './context';
import { HttpError, toEnvelope } from './errors';
import { liveness, readiness } from './health';
import { localeMiddleware, sessionMiddleware } from './middleware';
import { meRoutes } from './routes/me';
export interface AppHandle {
app: Hono<AppEnv>;
/** Flips readiness off and makes /healthz report draining. Called on SIGTERM. */
startDraining: () => void;
/** Requests currently being handled, so shutdown can wait for them. */
inFlight: () => number;
}
export function createApp(deps: AppDeps): AppHandle {
const app = new Hono<AppEnv>();
let draining = false;
let inFlight = 0;
app.use('*', async (_c, next) => {
inFlight += 1;
try {
await next();
} finally {
inFlight -= 1;
}
});
app.onError((error, c) => {
const { status, body } = toEnvelope(error, c.get('locale') ?? 'es', deps.env.NODE_ENV !== 'production');
if (status >= 500) console.error('[api] unhandled error', error);
return c.json(body, status);
});
app.get('/healthz', (c) => c.json(liveness(draining), draining ? 503 : 200));
app.get('/readyz', async (c) => {
const result = await readiness(deps, draining);
return c.json(result, result.ok ? 200 : 503);
});
// better-auth owns everything under /api/auth. It reads and writes cookies itself.
app.on(['GET', 'POST'], '/api/auth/*', (c) => deps.auth.handler(c.req.raw));
const api = new Hono<AppEnv>();
api.use('*', sessionMiddleware(deps));
api.use('*', localeMiddleware(deps));
api.route('/me', meRoutes(deps));
app.route('/api', api);
// The API only ever speaks JSON, so an unknown path gets the same envelope as
// everything else rather than Hono's plain text 404.
app.notFound((c) => {
const { status, body } = toEnvelope(new HttpError('not_found'), c.get('locale') ?? 'es', false);
return c.json(body, status);
});
return {
app,
startDraining: () => {
draining = true;
},
inFlight: () => inFlight,
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { Locale } from '@impuestos/i18n';
import type { Auth } from '../auth/options';
import type { DbHandle } from '../db/index';
import type { Env } from '../lib/env';
export interface SessionUser {
id: string;
email: string;
role: string;
}
export interface AppDeps {
env: Env;
handle: DbHandle;
auth: Auth;
}
/** Hono context typing shared by every route and middleware. */
export interface AppEnv {
Variables: {
locale: Locale;
user: SessionUser | null;
deps: AppDeps;
};
}
+79
View File
@@ -0,0 +1,79 @@
import { type ErrorCode, ErrorEnvelope } from '@impuestos/contracts';
import { type Locale, type MessageKey, t } from '@impuestos/i18n';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
const STATUS: Record<ErrorCode, ContentfulStatusCode> = {
validation_error: 400,
unauthorized: 401,
forbidden: 403,
not_found: 404,
conflict: 409,
rate_limited: 429,
ocr_unavailable: 503,
internal: 500,
};
const MESSAGE_KEY: Record<ErrorCode, MessageKey> = {
validation_error: 'error.validation_error',
unauthorized: 'error.unauthorized',
forbidden: 'error.forbidden',
not_found: 'error.not_found',
conflict: 'error.conflict',
rate_limited: 'error.rate_limited',
ocr_unavailable: 'error.ocr_unavailable',
internal: 'common.error.generic',
};
/**
* Thrown anywhere in the API. `code` picks both the status and the user facing message,
* which is looked up in the requester's locale when the response is built.
*/
export class HttpError extends Error {
readonly code: ErrorCode;
readonly field: string | undefined;
readonly detail: unknown;
/** Overrides the default message for this code with more specific copy. */
readonly messageKey: MessageKey | undefined;
constructor(
code: ErrorCode,
options: { field?: string; detail?: unknown; messageKey?: MessageKey; cause?: unknown } = {},
) {
super(code, options.cause === undefined ? undefined : { cause: options.cause });
this.name = 'HttpError';
this.code = code;
this.field = options.field;
this.detail = options.detail;
this.messageKey = options.messageKey;
}
}
export function statusFor(code: ErrorCode): ContentfulStatusCode {
return STATUS[code];
}
/** Builds the envelope from CONTRACTS.md section 1. Technical detail is dev only. */
export function toEnvelope(
error: unknown,
locale: Locale,
isDevelopment: boolean,
): { status: ContentfulStatusCode; body: ErrorEnvelope } {
const httpError =
error instanceof HttpError ? error : new HttpError('internal', { cause: error });
const body: ErrorEnvelope = {
error: {
code: httpError.code,
message: t(locale, httpError.messageKey ?? MESSAGE_KEY[httpError.code]),
...(httpError.field === undefined ? {} : { field: httpError.field }),
...(isDevelopment ? { detail: httpError.detail ?? describe(error) } : {}),
},
};
return { status: statusFor(httpError.code), body: ErrorEnvelope.parse(body) };
}
function describe(error: unknown): unknown {
if (error instanceof Error) return { name: error.name, message: error.message };
return undefined;
}
+46
View File
@@ -0,0 +1,46 @@
import { sql } from 'kysely';
import { pendingMigrations } from '../db/migrator';
import type { AppDeps } from './context';
export type CheckState = 'ok' | 'error' | 'pending';
/**
* Liveness: the process is up and the event loop is turning. Never touches the
* database, so a database blip does not get the container killed.
*/
export function liveness(draining: boolean): { ok: boolean } {
return { ok: !draining };
}
/**
* Readiness: this replica can serve traffic. Checked by the orchestrator and flipped
* to not-ready as soon as SIGTERM arrives, so the load balancer stops sending work
* while in flight requests drain.
*/
export async function readiness(
deps: AppDeps,
draining: boolean,
): Promise<{ ok: boolean; checks: Record<string, CheckState> }> {
if (draining) return { ok: false, checks: { draining: 'error' } };
const checks: Record<string, CheckState> = {};
try {
await sql`select 1`.execute(deps.handle.db);
checks['database'] = 'ok';
} catch {
checks['database'] = 'error';
}
if (checks['database'] === 'ok') {
try {
checks['migrations'] = (await pendingMigrations(deps.handle)).length === 0 ? 'ok' : 'pending';
} catch {
checks['migrations'] = 'error';
}
} else {
checks['migrations'] = 'error';
}
return { ok: Object.values(checks).every((state) => state === 'ok'), checks };
}
+52
View File
@@ -0,0 +1,52 @@
import { DEFAULT_LOCALE, isLocale, localeFromAcceptLanguage } from '@impuestos/i18n';
import type { MiddlewareHandler } from 'hono';
import { getLocale } from '../modules/pii';
import type { AppDeps, AppEnv, SessionUser } from './context';
import { HttpError } from './errors';
/** Resolves the session once per request so handlers never call better-auth directly. */
export function sessionMiddleware(deps: AppDeps): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const session = await deps.auth.api.getSession({ headers: c.req.raw.headers });
const user: SessionUser | null = session
? {
id: session.user.id,
email: session.user.email,
role: typeof session.user.role === 'string' ? session.user.role : 'user',
}
: null;
c.set('user', user);
await next();
};
}
/**
* Locale precedence per CONTRACTS.md section 1: stored profile locale, then
* Accept-Language, then DEFAULT_LOCALE.
*/
export function localeMiddleware(deps: AppDeps): MiddlewareHandler<AppEnv> {
const fallback = isLocale(deps.env.DEFAULT_LOCALE) ? deps.env.DEFAULT_LOCALE : DEFAULT_LOCALE;
return async (c, next) => {
const user = c.get('user');
const stored = user ? await getLocale(deps.handle.db, user.id) : null;
c.set('locale', stored ?? localeFromAcceptLanguage(c.req.header('accept-language') ?? fallback));
await next();
};
}
/** Returns the signed in user or throws the 401 envelope. */
export function requireUser(c: { get: (key: 'user') => SessionUser | null }): SessionUser {
const user = c.get('user');
if (!user) throw new HttpError('unauthorized');
return user;
}
/** Role checks are re-validated in every handler, never only at the router. */
export function requireRole(
c: { get: (key: 'user') => SessionUser | null },
allowed: readonly string[],
): SessionUser {
const user = requireUser(c);
if (!allowed.includes(user.role)) throw new HttpError('forbidden');
return user;
}
+19
View File
@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import { getProfile } from '../../modules/pii';
import type { AppDeps, AppEnv } from '../context';
import { HttpError } from '../errors';
import { requireUser } from '../middleware';
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
// 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3).
routes.get('/profile', async (c) => {
const user = requireUser(c);
const profile = await getProfile(deps.handle.db, user.id);
if (!profile) throw new HttpError('not_found');
return c.json(profile);
});
return routes;
}
+65
View File
@@ -0,0 +1,65 @@
import { serve } from '@hono/node-server';
import { createAuth } from './auth/options';
import { createDb } from './db/index';
import { pendingMigrations } from './db/migrator';
import { createApp } from './http/app';
import type { AppDeps } from './http/context';
import { loadEnv } from './lib/env';
import { createOtpSender } from './modules/notifications/mailer';
const DRAIN_TIMEOUT_MS = 25_000;
const env = loadEnv();
const handle = createDb(env.DATABASE_URL);
const auth = createAuth({
db: handle.db,
dialect: handle.dialect,
env,
sendOtp: createOtpSender(env),
});
const deps: AppDeps = { env, handle, auth };
const { app, startDraining, inFlight } = createApp(deps);
const pending = await pendingMigrations(handle).catch(() => ['<database unreachable>']);
if (pending.length > 0) {
console.warn(`[boot] pending migrations: ${pending.join(', ')}. Run pnpm db:migrate.`);
}
if (env.ROLE === 'worker') {
// The worker shares this image and this bootstrap. It serves only the health
// endpoints; the job poller is wired in with the jobs module.
console.info('[boot] role=worker');
}
const server = serve({ fetch: app.fetch, port: env.PORT, hostname: '0.0.0.0' }, (info) => {
console.info(`[boot] role=${env.ROLE} dialect=${handle.dialect} listening on :${info.port}`);
});
let shuttingDown = false;
async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
console.info(`[shutdown] ${signal}: draining`);
// Fail readiness first so the load balancer stops routing here, then stop accepting.
startDraining();
server.close();
const deadline = Date.now() + DRAIN_TIMEOUT_MS;
while (inFlight() > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (inFlight() > 0) {
console.warn(`[shutdown] ${inFlight()} requests still in flight after drain timeout`);
if ('closeAllConnections' in server) server.closeAllConnections();
}
await handle.close();
console.info('[shutdown] done');
process.exit(0);
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
+93
View File
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { ENV_KEYS, parseEnv } from './env';
const MINIMAL = {
DATABASE_URL: 'sqlite:./data/app.db',
BETTER_AUTH_SECRET: 'a'.repeat(32),
};
describe('env', () => {
it('accepts the minimal set and applies documented defaults', () => {
const result = parseEnv(MINIMAL);
expect(result.ok).toBe(true);
expect(result.env?.PORT).toBe(4000);
expect(result.env?.ROLE).toBe('server');
expect(result.env?.JOBS_INLINE).toBe(true);
expect(result.env?.STORAGE_DRIVER).toBe('local');
expect(result.env?.DEFAULT_LOCALE).toBe('es');
});
it('names every missing variable in one readable message', () => {
const result = parseEnv({});
expect(result.ok).toBe(false);
expect(result.message).toContain('DATABASE_URL');
expect(result.message).toContain('BETTER_AUTH_SECRET');
expect(result.message).toContain('.env.example');
});
it('rejects a short auth secret', () => {
const result = parseEnv({ ...MINIMAL, BETTER_AUTH_SECRET: 'too-short' });
expect(result.ok).toBe(false);
expect(result.message).toContain('at least 32 characters');
});
it('requires the s3 settings when the s3 driver is selected', () => {
const result = parseEnv({ ...MINIMAL, STORAGE_DRIVER: 's3' });
expect(result.ok).toBe(false);
expect(result.message).toContain('S3_BUCKET');
expect(result.message).toContain('S3_SECRET_ACCESS_KEY');
});
it('accepts s3 once it is fully configured', () => {
const result = parseEnv({
...MINIMAL,
STORAGE_DRIVER: 's3',
S3_BUCKET: 'facturas',
S3_REGION: 'us-east-1',
S3_ACCESS_KEY_ID: 'key',
S3_SECRET_ACCESS_KEY: 'secret',
});
expect(result.ok).toBe(true);
});
// SPEC.md section 15: SQLite is single writer, so a second poller cannot be safe.
it('refuses a dedicated worker on SQLite', () => {
const result = parseEnv({ ...MINIMAL, JOBS_INLINE: 'false' });
expect(result.ok).toBe(false);
expect(result.message).toContain('JOBS_INLINE');
expect(result.message).toContain('Postgres');
});
it('allows a dedicated worker on Postgres', () => {
const result = parseEnv({
...MINIMAL,
DATABASE_URL: 'postgres://user:pass@localhost:5432/impuestos',
JOBS_INLINE: 'false',
});
expect(result.ok).toBe(true);
});
it('parses booleans in every spelling .env allows', () => {
expect(parseEnv({ ...MINIMAL, JOBS_INLINE: '0' }).ok).toBe(false);
expect(parseEnv({ ...MINIMAL, S3_FORCE_PATH_STYLE: '0' }).env?.S3_FORCE_PATH_STYLE).toBe(false);
expect(parseEnv({ ...MINIMAL, S3_FORCE_PATH_STYLE: 'true' }).env?.S3_FORCE_PATH_STYLE).toBe(true);
});
});
// SPEC.md section 14: .env.example completeness is enforced, not trusted.
describe('.env.example', () => {
it('documents every variable the schema knows about', () => {
const text = readFileSync(new URL('../../.env.example', import.meta.url), 'utf8');
const documented = new Set(
text
.split('\n')
.map((line) => /^([A-Z0-9_]+)=/.exec(line.trim())?.[1])
.filter((name): name is string => name !== undefined),
);
const declared = ENV_KEYS;
expect([...declared].filter((name) => !documented.has(name))).toEqual([]);
expect([...documented].filter((name) => !declared.includes(name))).toEqual([]);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { existsSync, readFileSync } from 'node:fs';
import { parseEnv as parseEnvFile } from 'node:util';
import { z } from 'zod';
/** `.env` accepts the usual spellings for a boolean. */
const booleanish = z
.union([z.boolean(), z.enum(['true', 'false', '1', '0'])])
.transform((value) => value === true || value === 'true' || value === '1');
const optionalString = z
.string()
.trim()
.optional()
.transform((value) => (value === undefined || value.length === 0 ? undefined : value));
const EnvObject = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().max(65535).default(4000),
/** User facing origin. Used for links in emails and notifications. */
APP_PUBLIC_URL: z.url().default('http://localhost:3000'),
ROLE: z.enum(['server', 'worker']).default('server'),
JOBS_INLINE: booleanish.default(true),
JOBS_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(2000),
JOBS_STALE_MINUTES: z.coerce.number().int().positive().default(10),
DATABASE_URL: z.string().trim().min(1),
BETTER_AUTH_SECRET: z.string().min(32, 'must be at least 32 characters'),
/** Public origin cookies are issued for. The web app proxies /api, so this is the web origin. */
BETTER_AUTH_URL: z.url().default('http://localhost:3000'),
STORAGE_DRIVER: z.enum(['local', 's3']).default('local'),
STORAGE_LOCAL_PATH: z.string().trim().default('./data/files'),
S3_ENDPOINT: optionalString,
S3_REGION: optionalString,
S3_BUCKET: optionalString,
S3_ACCESS_KEY_ID: optionalString,
S3_SECRET_ACCESS_KEY: optionalString,
S3_FORCE_PATH_STYLE: booleanish.default(true),
ANTHROPIC_API_KEY: optionalString,
OCR_MODEL: z.string().trim().default('claude-sonnet-4-6'),
PUSH_VAPID_PUBLIC_KEY: optionalString,
PUSH_VAPID_PRIVATE_KEY: optionalString,
SMTP_HOST: optionalString,
SMTP_PORT: z.coerce.number().int().positive().max(65535).default(587),
SMTP_USER: optionalString,
SMTP_PASS: optionalString,
SMTP_FROM: optionalString,
TELEGRAM_BOT_TOKEN: optionalString,
DEFAULT_LOCALE: z.enum(['es', 'en']).default('es'),
});
/** Every variable name the schema knows about. The .env.example test checks against this. */
export const ENV_KEYS = Object.keys(EnvObject.shape);
export const EnvSchema = EnvObject.superRefine((env, ctx) => {
if (env.STORAGE_DRIVER === 's3') {
for (const key of ['S3_BUCKET', 'S3_REGION', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
if (env[key] === undefined) {
ctx.addIssue({
code: 'custom',
path: [key],
message: 'is required when STORAGE_DRIVER=s3',
});
}
}
}
// SPEC.md section 15: SQLite has a single writer, so a second poller in another
// process cannot be made safe. Refusing JOBS_INLINE=false keeps SQLite single process.
if (isSqliteUrl(env.DATABASE_URL) && !env.JOBS_INLINE && env.ROLE === 'server') {
ctx.addIssue({
code: 'custom',
path: ['JOBS_INLINE'],
message:
'cannot be false on SQLite: a dedicated worker needs Postgres. ' +
'Either keep JOBS_INLINE=true or point DATABASE_URL at Postgres.',
});
}
});
export type Env = z.infer<typeof EnvSchema>;
export function isSqliteUrl(databaseUrl: string): boolean {
return databaseUrl.startsWith('sqlite:') || databaseUrl.startsWith('file:');
}
export function isPostgresUrl(databaseUrl: string): boolean {
return databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://');
}
export interface ParseResult {
ok: boolean;
env?: Env;
message?: string;
}
export function parseEnv(source: Record<string, string | undefined>): ParseResult {
const result = EnvSchema.safeParse(source);
if (result.success) return { ok: true, env: result.data };
const lines = result.error.issues.map((issue) => {
const name = issue.path.join('.') || '(root)';
return ` ${name}: ${issue.message}`;
});
return {
ok: false,
message: [
'Invalid environment for apps/api. Fix these and start again:',
...lines,
'',
'Every variable is documented in apps/api/.env.example.',
].join('\n'),
};
}
/**
* Reads `.env` into process.env when the file exists, without a dotenv dependency.
*
* A real environment variable always wins over the file: `process.loadEnvFile` overwrites
* process.env, which would let a stale checked out `.env` silently beat the values a
* container or a one off command passed in. Containers ship no `.env` at all, so this is
* a no-op there.
*/
function loadDotEnvFile(path = '.env'): void {
if (!existsSync(path)) return;
const fromFile = parseEnvFile(readFileSync(path, 'utf8'));
for (const [key, value] of Object.entries(fromFile)) {
if (process.env[key] === undefined && typeof value === 'string') process.env[key] = value;
}
}
/** Parses process.env or exits. Called once at boot, before anything opens a connection. */
export function loadEnv(source?: Record<string, string | undefined>): Env {
if (source === undefined) loadDotEnvFile();
const result = parseEnv(source ?? process.env);
if (!result.ok || !result.env) {
console.error(result.message);
process.exit(1);
}
warnAboutScaling(result.env);
return result.env;
}
function warnAboutScaling(env: Env): void {
if (isSqliteUrl(env.DATABASE_URL)) {
console.info(
'[boot] SQLite: this deployment is limited to a single API process. ' +
'Point DATABASE_URL at Postgres to run more than one replica.',
);
}
if (env.STORAGE_DRIVER === 'local') {
console.info(
'[boot] Local storage driver: every replica must mount the same volume at ' +
`${env.STORAGE_LOCAL_PATH}. Use STORAGE_DRIVER=s3 for multi replica deployments.`,
);
}
}
@@ -0,0 +1,23 @@
import type { Env } from '../../lib/env';
export interface OtpMessage {
email: string;
otp: string;
type: string;
}
/**
* Phase 0 delivery: without SMTP_HOST configured the code goes to stdout, which is what
* local development and the compose stack rely on. Real SMTP delivery and localized
* templates land with the notifications module.
*/
export function createOtpSender(env: Env): (message: OtpMessage) => Promise<void> {
return async ({ email, otp, type }) => {
if (env.SMTP_HOST === undefined) {
console.info(`[auth] verification code for ${email} (${type}): ${otp}`);
return;
}
// TODO(phase-4): send through SMTP with the recipient's locale.
console.info(`[auth] verification code for ${email} (${type}): ${otp}`);
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Public surface of the pii module. Nothing outside this directory may reach past this
* file: the eslint boundary rule in eslint.config.js enforces it, so every read of a
* profile, dependant or consent goes through a function that can audit itself.
*/
export { getProfile, getLocale } from './profiles';
+48
View File
@@ -0,0 +1,48 @@
import { Obligation, type ProfileDto } from '@impuestos/contracts';
import { type Locale, isLocale } from '@impuestos/i18n';
import type { Kysely } from 'kysely';
import { z } from 'zod';
import type { Database } from '../../db/schema';
/**
* The only module allowed to read or write `profiles`, `dependents` and `consents`
* (SPEC.md section 4). Everything else goes through these functions.
*/
const Obligations = z.array(Obligation);
export async function getProfile(db: Kysely<Database>, userId: string): Promise<ProfileDto | null> {
const row = await db
.selectFrom('profiles')
.selectAll()
.where('user_id', '=', userId)
.executeTakeFirst();
if (!row) return null;
return {
fullName: row.full_name,
docType: row.doc_type,
ruc: row.ruc,
rucDv: row.ruc_dv,
ci: row.ci,
taxpayerKind: row.taxpayer_kind,
deadlineDigit: row.deadline_digit,
obligations: Obligations.parse(JSON.parse(row.obligations)),
irpGrossEstimate: row.irp_gross_estimate,
autoConfirmDays: row.auto_confirm_days,
locale: row.locale,
};
}
/**
* Drives every server generated string for this user: error envelopes, notifications
* and emails. Returns null when the user has not finished setup yet.
*/
export async function getLocale(db: Kysely<Database>, userId: string): Promise<Locale | null> {
const row = await db
.selectFrom('profiles')
.select('locale')
.where('user_id', '=', userId)
.executeTakeFirst();
return row && isLocale(row.locale) ? row.locale : null;
}
+56
View File
@@ -0,0 +1,56 @@
import { createAuth } from '../auth/options';
import { createDb } from '../db/index';
import { migrateToLatest } from '../db/migrator';
import { seed } from '../db/seed';
import { createApp, type AppHandle } from '../http/app';
import type { AppDeps } from '../http/context';
import { type Env, parseEnv } from '../lib/env';
export const TEST_ENV: Record<string, string> = {
NODE_ENV: 'test',
DATABASE_URL: 'sqlite::memory:',
BETTER_AUTH_SECRET: 'test-secret-that-is-long-enough-32chars',
BETTER_AUTH_URL: 'http://localhost:3000',
APP_PUBLIC_URL: 'http://localhost:3000',
};
export interface Harness extends AppHandle {
deps: AppDeps;
env: Env;
close: () => Promise<void>;
/** Signs in a seeded account and returns the cookie header for later requests. */
signIn: (email: string, password: string) => Promise<string>;
}
export async function createHarness(overrides: Record<string, string> = {}): Promise<Harness> {
const parsed = parseEnv({ ...TEST_ENV, ...overrides });
if (!parsed.ok || !parsed.env) throw new Error(parsed.message);
const env = parsed.env;
const handle = createDb(env.DATABASE_URL);
await migrateToLatest(handle, env);
const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined });
await seed(handle, auth);
const deps: AppDeps = { env, handle, auth };
const appHandle = createApp(deps);
return {
...appHandle,
deps,
env,
close: () => handle.close(),
signIn: async (email, password) => {
const response = await appHandle.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error(`sign in failed: ${response.status} ${await response.text()}`);
const cookie = response.headers.get('set-cookie');
if (!cookie) throw new Error('sign in returned no cookie');
return cookie.split(';')[0] ?? '';
},
};
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tsup.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'tsup';
/**
* The workspace packages are pure TypeScript source with no build step, so they are
* bundled in here. Everything from node_modules stays external, which keeps the native
* better-sqlite3 binding loading normally at runtime.
*/
export default defineConfig({
entry: { index: 'src/index.ts', 'db/migrate.cli': 'src/db/migrate.cli.ts', 'db/seed.cli': 'src/db/seed.cli.ts' },
format: ['esm'],
target: 'node22',
platform: 'node',
outDir: 'dist',
clean: true,
sourcemap: true,
noExternal: [/^@impuestos\//],
});
+8
View File
@@ -0,0 +1,8 @@
# apps/web environment. The web app holds no secrets: it renders UI and proxies /api.
# Where the API can be reached from the web container. Used only server side, by the
# Next rewrite. In k8s this is the api Service DNS name, for example http://api:4000.
API_INTERNAL_URL=http://localhost:4000
# Locale used before a visitor picks one. Must be a locale packages/i18n ships.
NEXT_PUBLIC_DEFAULT_LOCALE=es
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/contracts/package.json packages/contracts/
COPY packages/i18n/package.json packages/i18n/
COPY packages/rules/package.json packages/rules/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY packages packages
COPY apps/web apps/web
COPY docs docs
RUN pnpm --filter @impuestos/web build
FROM node:22-slim AS runtime
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000 HOSTNAME=0.0.0.0
WORKDIR /app
# `output: standalone` emits a server with only the modules it actually imports.
COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./
COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static
USER node
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
@@ -0,0 +1,52 @@
import { isApiError } from '@impuestos/contracts';
import { setRequestLocale } from 'next-intl/server';
import { getT } from '@/i18n/t';
import { redirect } from '@/i18n/navigation';
import { Card } from '@/components/ui/card';
import { LanguageSwitcher } from '@/components/language-switcher';
import { serverApi } from '@/lib/api-server';
/**
* Phase 0 shell. It exists to prove the whole chain end to end: browser to the Next
* rewrite, to the API, through the typed client, with the session cookie intact.
* The real dashboard (FLOWS.md Flow C) replaces this in phase 4.
*/
export default async function InicioPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getT(locale);
const api = await serverApi();
const profile = await api.getProfile().catch((error: unknown) => {
if (!isApiError(error)) throw error;
// 404 is the documented state for a user who has not finished setup yet.
if (error.code === 'not_found') return null;
if (error.code === 'unauthorized') redirect({ href: '/login', locale });
throw error;
});
return (
<div className="mx-auto flex min-h-dvh max-w-2xl flex-col">
<header className="flex items-center justify-between px-5 py-4">
<h1 className="text-base font-semibold tracking-tight">{t('home.title')}</h1>
<LanguageSwitcher />
</header>
<main className="px-5 pb-16">
<Card className="space-y-2">
{profile ? (
<>
<p className="text-sm text-[var(--text-muted)]">{t('setup.fullName')}</p>
<p className="text-2xl font-semibold tracking-tight">{profile.fullName}</p>
</>
) : (
<>
<h2 className="text-xl font-semibold tracking-tight">{t('setup.step1.title')}</h2>
<p className="text-sm text-[var(--text-muted)]">{t('setup.income.help')}</p>
</>
)}
</Card>
</main>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import { LanguageSwitcher } from '@/components/language-switcher';
import { useT } from '@/i18n/t';
export default function AuthLayout({ children }: { children: ReactNode }) {
const t = useT();
return (
<div className="flex min-h-dvh flex-col">
<header className="flex items-center justify-between px-5 py-4">
<span className="text-base font-semibold tracking-tight">{t('common.appName')}</span>
<LanguageSwitcher />
</header>
<main className="flex flex-1 items-center justify-center px-5 pb-16">
<div className="w-full max-w-sm">{children}</div>
</main>
</div>
);
}
@@ -0,0 +1,75 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { authClient } from '@/lib/auth-client';
export function LoginForm() {
const t = useT();
const router = useRouter();
const signIn = useMutation({
mutationFn: async (form: { email: string; password: string }) => {
const { error } = await authClient.signIn.email(form);
if (error) throw new Error(error.message ?? 'sign_in_failed');
},
onSuccess: () => router.push('/inicio'),
});
return (
<Card className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{t('auth.login.title')}</h1>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
signIn.mutate({
email: String(data.get('email') ?? ''),
password: String(data.get('password') ?? ''),
});
}}
>
<div className="space-y-1.5">
<Label htmlFor="email">{t('auth.register.email')}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
aria-invalid={signIn.isError}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">{t('auth.login.password')}</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
aria-invalid={signIn.isError}
/>
</div>
{signIn.isError ? (
<p role="alert" className="text-sm text-overdue">
{t('auth.login.failed')}
</p>
) : null}
<Button type="submit" size="lg" block disabled={signIn.isPending}>
{signIn.isPending ? t('common.loading') : t('auth.login.submit')}
</Button>
</form>
</Card>
);
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { LoginForm } from './login-form';
export default async function LoginPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <LoginForm />;
}
+49
View File
@@ -0,0 +1,49 @@
import { isLocale } from '@impuestos/i18n';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { getT } from '@/i18n/t';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { Providers } from '@/components/providers';
import { routing } from '@/i18n/routing';
import '../globals.css';
// Self hosted by next/font: no third party font request at runtime, which keeps the
// CSP tight (SPEC.md section 14).
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await props.params;
const t = await getT(locale);
return { title: t('common.appName') };
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
setRequestLocale(locale);
return (
<html lang={locale} className={inter.variable} suppressHydrationWarning>
<body className="min-h-dvh font-sans antialiased">
<NextIntlClientProvider>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from '@/i18n/navigation';
/**
* The landing page (FLOWS.md Flow A1) is built in phase 2. Until then the root goes
* straight to sign in so the shell is reachable.
*/
export default async function LandingPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
redirect({ href: '/login', locale });
}
+68
View File
@@ -0,0 +1,68 @@
import type { NextRequest } from 'next/server';
/**
* Single origin proxy: the browser only ever talks to the web origin, and everything
* under /api is forwarded to the API container. Cookies stay first party, so there is
* no CORS anywhere in v1.
*
* SPEC-GAP: SPEC.md section 2 specifies Next rewrites for this. Next bakes rewrite
* destinations into the build manifest, which would make API_INTERNAL_URL a build time
* value and stop one image from running in both compose and k8s. A route handler reads
* it per request instead, which is what "all configuration via .env" requires.
*/
export const dynamic = 'force-dynamic';
/** Set by the proxy or the runtime, never forwarded verbatim. */
const STRIPPED_REQUEST_HEADERS = new Set([
'host',
'connection',
'content-length',
'transfer-encoding',
'accept-encoding',
]);
const STRIPPED_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding']);
function apiBaseUrl(): string {
return (process.env['API_INTERNAL_URL'] ?? 'http://localhost:4000').replace(/\/$/, '');
}
async function proxy(request: NextRequest): Promise<Response> {
const incoming = new URL(request.url);
const target = `${apiBaseUrl()}${incoming.pathname}${incoming.search}`;
const headers = new Headers();
request.headers.forEach((value, key) => {
if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) headers.set(key, value);
});
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
const upstream = await fetch(target, {
method: request.method,
headers,
redirect: 'manual',
...(hasBody ? { body: request.body, duplex: 'half' } : {}),
} as RequestInit & { duplex?: 'half' });
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
const name = key.toLowerCase();
if (name === 'set-cookie') return;
if (!STRIPPED_RESPONSE_HEADERS.has(name)) responseHeaders.set(key, value);
});
// Session and CSRF cookies arrive as several Set-Cookie headers and must stay separate.
for (const cookie of upstream.headers.getSetCookie()) {
responseHeaders.append('set-cookie', cookie);
}
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;
export const HEAD = proxy;
export const OPTIONS = proxy;
+96
View File
@@ -0,0 +1,96 @@
@import 'tailwindcss';
/*
* Design tokens, FLOWS.md section 1. Calm fintech: one accent, four semantic status
* colors used consistently everywhere, generous whitespace, big confident numbers.
*/
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
/* Accent: deep teal. Primary actions and links only, nothing else. */
--color-accent-50: oklch(0.97 0.02 190);
--color-accent-100: oklch(0.93 0.04 190);
--color-accent-200: oklch(0.87 0.07 190);
--color-accent-400: oklch(0.68 0.11 190);
--color-accent-500: oklch(0.58 0.11 190);
--color-accent-600: oklch(0.48 0.1 191);
--color-accent-700: oklch(0.4 0.085 192);
--color-accent-900: oklch(0.27 0.055 194);
/* Status. positive = a favor, al dia. attention = action required.
overdue = missed deadlines and invalid documents ONLY, never "tax to pay".
neutral = everything else. */
--color-positive: oklch(0.62 0.14 155);
--color-positive-soft: oklch(0.95 0.04 155);
--color-attention: oklch(0.75 0.15 78);
--color-attention-soft: oklch(0.96 0.05 85);
--color-overdue: oklch(0.58 0.19 25);
--color-overdue-soft: oklch(0.95 0.04 25);
--color-neutral-fg: oklch(0.45 0.02 250);
--radius-card: 1rem;
}
/* Explicit toggle wins over the system preference in both directions (FLOWS.md
section 1: dark mode from system preference plus a toggle). */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
:root {
--surface: oklch(0.99 0.003 250);
--surface-raised: oklch(1 0 0);
--border-subtle: oklch(0.92 0.005 250);
--text: oklch(0.22 0.015 255);
--text-muted: oklch(0.52 0.015 255);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--surface: oklch(0.18 0.012 255);
--surface-raised: oklch(0.23 0.014 255);
--border-subtle: oklch(0.31 0.012 255);
--text: oklch(0.96 0.004 250);
--text-muted: oklch(0.72 0.012 255);
color-scheme: dark;
}
}
:root[data-theme='dark'] {
--surface: oklch(0.18 0.012 255);
--surface-raised: oklch(0.23 0.014 255);
--border-subtle: oklch(0.31 0.012 255);
--text: oklch(0.96 0.004 250);
--text-muted: oklch(0.72 0.012 255);
color-scheme: dark;
}
@layer base {
* {
border-color: var(--border-subtle);
}
body {
background-color: var(--surface);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* Every money figure is tabular. FLOWS.md section 1. */
.tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
}
/* Motion is purposeful and always interruptible. This disables the non essential
kind globally, which the GSAP context mirrors. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+6
View File
@@ -0,0 +1,6 @@
/** Liveness for the web container. Renders nothing and never calls the API. */
export const dynamic = 'force-dynamic';
export function GET(): Response {
return Response.json({ ok: true });
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+13
View File
@@ -0,0 +1,13 @@
import createNextIntlPlugin from 'next-intl/plugin';
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
// The workspace packages ship TypeScript source with no build step.
transpilePackages: ['@impuestos/contracts', '@impuestos/i18n'],
output: 'standalone',
// /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite,
// so API_INTERNAL_URL stays a runtime setting. See the comment in that file.
};
export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig);
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@impuestos/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@impuestos/contracts": "workspace:*",
"@impuestos/i18n": "workspace:*",
"@tanstack/react-query": "^5.102.8",
"better-auth": "^1.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.40.0",
"next": "^16.3.4",
"next-intl": "^4.14.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwind-merge": "^3.6.0",
"zod": "^4.5.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { '@tailwindcss/postcss': {} },
};
+10
View File
@@ -0,0 +1,10 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './src/i18n/routing';
export default createMiddleware(routing);
export const config = {
// Everything except /api (forwarded to the API by the rewrite), Next internals and
// static files. Locale routing must not touch API requests.
matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'],
};
@@ -0,0 +1,60 @@
'use client';
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
import { Globe } from 'lucide-react';
import { useLocale } from 'next-intl';
import { useTransition } from 'react';
import { usePathname, useRouter } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
const LABEL_KEY = { es: 'common.languageEs', en: 'common.languageEn' } as const;
/**
* Compact globe menu, header on marketing and auth screens (FLOWS.md section 1).
* Switching keeps the current page: the locale lives in the URL.
*
* A native select rather than a popover: it is one dependency fewer, and the platform
* control is the better mobile and keyboard experience for a two item choice.
*/
export function LanguageSwitcher() {
const t = useT();
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
return (
<label className="relative inline-flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Globe aria-hidden className="size-4" />
<span className="sr-only">{t('common.language')}</span>
<select
aria-label={t('common.language')}
className="cursor-pointer appearance-none rounded-full bg-transparent py-1 pr-6 pl-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600"
disabled={isPending}
value={locale}
onChange={(event) => {
const next = event.target.value as Locale;
startTransition(() => {
router.replace(pathname, { locale: next });
});
}}
>
{SUPPORTED_LOCALES.map((code) => (
<option key={code} value={code}>
{t(LABEL_KEY[code])}
</option>
))}
</select>
<svg
aria-hidden
className="pointer-events-none absolute right-1 size-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</label>
);
}
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
/** All server state goes through TanStack Query. One client per browser session. */
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } },
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
+30
View File
@@ -0,0 +1,30 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
const button = cva(
'inline-flex items-center justify-center gap-2 rounded-full font-medium transition-colors ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600 ' +
'disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-accent-600 text-white hover:bg-accent-700',
secondary: 'border bg-[var(--surface-raised)] hover:bg-accent-50',
ghost: 'hover:bg-accent-50',
},
size: {
md: 'h-11 px-5 text-sm',
lg: 'h-13 px-6 text-base',
},
block: { true: 'w-full', false: '' },
},
defaultVariants: { variant: 'primary', size: 'md', block: false },
},
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>;
export function Button({ className, variant, size, block, ...props }: ButtonProps) {
return <button className={cn(button({ variant, size, block }), className)} {...props} />;
}
+14
View File
@@ -0,0 +1,14 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'rounded-2xl border bg-[var(--surface-raised)] p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
className,
)}
{...props}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { InputHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
'h-12 w-full rounded-2xl border bg-[var(--surface-raised)] px-4 text-base',
'placeholder:text-[var(--text-muted)]',
'focus-visible:border-accent-500 focus-visible:outline-2 focus-visible:outline-offset-0',
'focus-visible:outline-accent-500/40',
'aria-invalid:border-overdue',
className,
)}
{...props}
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { LabelHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn('block text-sm font-medium text-[var(--text-muted)]', className)}
{...props}
/>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
/** Locale aware Link and router. Switching language keeps the current page. */
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
+8
View File
@@ -0,0 +1,8 @@
import { DEFAULT_LOCALE, catalogs, isLocale, unflatten } from '@impuestos/i18n';
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = isLocale(requested) ? requested : DEFAULT_LOCALE;
return { locale, messages: unflatten(catalogs[locale]) };
});
+9
View File
@@ -0,0 +1,9 @@
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@impuestos/i18n';
import { defineRouting } from 'next-intl/routing';
/** Adding a locale is a catalog file plus SUPPORTED_LOCALES. This picks it up for free. */
export const routing = defineRouting({
locales: [...SUPPORTED_LOCALES],
defaultLocale: DEFAULT_LOCALE,
localePrefix: 'always',
});
+22
View File
@@ -0,0 +1,22 @@
import { type MessageKey, resolveKey } from '@impuestos/i18n';
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
type Params = Record<string, string | number | Date>;
/**
* Thin wrapper over next-intl so components address messages by their COPY.md key.
*
* next-intl walks a nested message tree, and one COPY.md key (`decl.approve`) is both a
* message and a namespace. `resolveKey` maps those onto their real path so the calling
* code never has to know.
*/
export function useT(): (key: MessageKey, params?: Params) => string {
const t = useTranslations();
return (key, params) => t(resolveKey(key), params);
}
export async function getT(locale: string): Promise<(key: MessageKey, params?: Params) => string> {
const t = await getTranslations({ locale });
return (key, params) => t(resolveKey(key), params);
}
+15
View File
@@ -0,0 +1,15 @@
import { createApiClient } from '@impuestos/contracts';
import { headers } from 'next/headers';
/**
* Server side client for Server Components. It calls the API container directly and
* forwards the incoming cookie, since a server render has no browser to do it.
*/
export async function serverApi() {
const incoming = await headers();
const cookie = incoming.get('cookie');
return createApiClient({
baseUrl: process.env['API_INTERNAL_URL'] ?? 'http://localhost:4000',
...(cookie ? { headers: { cookie } } : {}),
});
}
+7
View File
@@ -0,0 +1,7 @@
import { createApiClient } from '@impuestos/contracts';
/**
* Browser client. The base URL is empty on purpose: requests go to `/api` on the web
* origin and the Next rewrite forwards them, so the session cookie stays first party.
*/
export const api = createApiClient();
+13
View File
@@ -0,0 +1,13 @@
'use client';
import { adminClient } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
/**
* Auth runs entirely in the API. This is the client half only: it posts to /api/auth,
* which the web app proxies. No better-auth server code exists in this app.
*/
export const authClient = createAuthClient({
basePath: '/api/auth',
plugins: [adminClient()],
});
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"jsx": "preserve",
"allowJs": true,
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}