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\//],
});