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:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] ?? '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tsup.config.ts"]
|
||||
}
|
||||
@@ -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\//],
|
||||
});
|
||||
Reference in New Issue
Block a user