Backend

API reference, database, authentication, services, middleware

API & Data

API & Data

API Reference

Drop Backend API Reference

Auto-generated from source code analysis. All file references are relative to src/drop-app/src/.

Overview

Drop uses Next.js App Router API routes (app/api/). All responses use a consistent JSON envelope:

{ "data": { ... } }                    // Success
{ "error": "code", "message": "...", "details": [...] }  // Error

Authentication is via httpOnly cookie (drop_token) containing a signed JWT (HS256, 24h expiry).

Pass-Through Model: Drop uses a PSD2 pass-through model — it NEVER holds customer money. There is no wallet, no balance, no top-up. User funds remain in their bank account at all times. Drop uses:

The bank_accounts.balance field stores the last AISP-read balance from the user's real bank (cached for display) — NOT a Drop-held balance.


Authentication

POST /api/auth/register

Create a new user account.

Field Source
File app/api/auth/register/route.ts
Auth None
Rate Limit 10 req/min per IP

Request Body:

Field Type Required Validation
email string Yes RFC-like regex, unique
password string Yes Min 8 chars, must contain letters + digits
firstName string Yes validateName() — 1-100 chars, at least one letter, no HTML/script
lastName string Yes Same as firstName
phone string No International format +XXXXXXXXXXXX (8-15 digits)
dateOfBirth string Yes ISO date string, must be >= 18 years old

Success Response (201):

{
  "data": {
    "id": "usr_...",
    "email": "user@example.com",
    "firstName": "...",
    "lastName": "...",
    "dateOfBirth": "...",
    "kycStatus": "pending",
    "createdAt": "2026-..."
  }
}

Error Responses:

Status Code Condition
400 bad_request Invalid JSON body
409 conflict Email already registered
422 validation_error Field validation failures (returned in details array)
429 rate_limited Too many requests

POST /api/auth/login

Authenticate with email and password.

Field Source
File app/api/auth/login/route.ts
Auth None
Rate Limit 10 req/min per IP

Request Body:

Field Type Required
email string Yes
password string Yes

Success Response (200):

{
  "data": {
    "id": "usr_...",
    "email": "...",
    "firstName": "...",
    "lastName": "...",
    "kycStatus": "approved"
  }
}

Error Responses:

Status Code Condition
400 bad_request Missing email or password
401 unauthorized Invalid credentials
429 rate_limited Too many requests

GET /api/auth/me

Get current authenticated user with bank accounts.

Field Source
File app/api/auth/me/route.ts
Auth Required (cookie)

Success Response (200):

{
  "data": {
    "id": "usr_...",
    "email": "...",
    "firstName": "...",
    "lastName": "...",
    "totalBalance": 58030.0,
    "bankAccounts": [
      {
        "id": "ba_1",
        "bankName": "DNB",
        "accountNumber": "1234.56.78901",
        "balance": 45230.0,
        "currency": "NOK",
        "isPrimary": true
      }
    ],
    "kycStatus": "approved",
    "createdAt": "..."
  }
}

POST /api/auth/logout

Logout and revoke all sessions.

Field Source
File app/api/auth/logout/route.ts
Auth Required (cookie)

Calls revokeAllSessions() to invalidate all session records, then clears the auth cookie.

Success Response (200):

{ "message": "Logged out" }

POST /api/auth/refresh

Refresh the authentication token (issue new JWT, create new session record).

Field Source
File app/api/auth/refresh/route.ts
Auth Required (cookie)

Success Response (200):

{
  "data": {
    "userId": "usr_...",
    "email": "...",
    "role": "user"
  }
}

Transactions

GET /api/transactions

List user's transactions with pagination and filtering.

Field Source
File app/api/transactions/route.ts
Auth Required

Query Parameters:

Param Type Default Notes
page int 1 Min 1
limit int 20 Min 1, Max 50
type string - remittance or qr_payment
status string - processing, completed, or failed

Success Response (200):

{
  "data": [
    {
      "id": "tx_rem_1",
      "type": "remittance",
      "status": "completed",
      "amount": -2000,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "..."
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 3 }
}

Note: amount is negated in the response (always shown as outgoing).


GET /api/transactions/[id]

Get single transaction details with exchange rate info.

Field Source
File app/api/transactions/[id]/route.ts
Auth Required

Success Response (200):

{
  "data": {
    "id": "tx_rem_1",
    "type": "remittance",
    "status": "completed",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "total": 2010,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "Serbia",
    "createdAt": "...",
    "completedAt": "..."
  }
}

GET /api/transactions/summary

Get transaction summary statistics (all-time and this month).

Field Source
File app/api/transactions/summary/route.ts
Auth Required

Success Response (200):

{
  "data": {
    "allTime": {
      "totalCount": 3,
      "totalSent": 5000,
      "totalPaid": 129,
      "remittanceCount": 2,
      "qrPaymentCount": 1
    },
    "thisMonth": { "..." }
  }
}

POST /api/transactions/remittance

Create a remittance (international money transfer).

Field Source
File app/api/transactions/remittance/route.ts
Auth Required
Rate Limit 10 req/min per IP
KYC Must be approved

Request Body:

Field Type Required Validation
recipientId string Yes Must belong to user
amount number Yes 100-50,000 NOK, max 2 decimal places
currency string No Defaults to NOK
bankAccountId string No Defaults to primary bank account

Business Logic:

  1. Verify recipient belongs to user
  2. Look up exchange rate for recipient's currency
  3. Verify bank account exists and has sufficient balance
  4. Fee: 0.5% of amount
  5. Debit bank account (atomic transaction)
  6. Create transaction record with status processing

Success Response (201):

{
  "data": {
    "id": "tx_rem_...",
    "type": "remittance",
    "status": "processing",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "feePercent": 0.5,
    "total": 2010,
    "recipientName": "...",
    "recipientCountry": "Serbia",
    "fromAccount": "DNB",
    "eta": "1-2 business days",
    "createdAt": "..."
  }
}

Error Responses:

Status Code Condition
400 bad_request Missing/invalid fields
400 no_bank_account No linked bank account
402 insufficient_balance Bank account balance too low
403 kyc_required KYC not approved
404 not_found Recipient not found
422 validation_error Unsupported currency corridor

POST /api/transactions/qr-payment

Create a QR payment to a merchant.

Field Source
File app/api/transactions/qr-payment/route.ts
Auth Required
Rate Limit 10 req/min per IP

Request Body:

Field Type Required Validation
merchantId string Yes Must exist
amount number Yes 1-100,000 NOK, max 2 decimal places

Business Logic:

  1. Verify merchant exists
  2. Get user's primary bank account
  3. Fee: 1% of amount
  4. Debit bank account (atomic transaction)
  5. Create transaction with status completed (instant)

Success Response (201):

{
  "data": {
    "id": "tx_qr_...",
    "type": "qr_payment",
    "status": "completed",
    "amount": 129,
    "currency": "NOK",
    "fee": 1.29,
    "feePercent": 1,
    "merchantName": "Ahmetov Kebab",
    "merchantId": "mer_1",
    "fromAccount": "DNB",
    "createdAt": "..."
  }
}

Recipients

GET /api/recipients

List user's recipients with pagination.

Field Source
File app/api/recipients/route.ts
Auth Required

Query Parameters: page (default 1), limit (default 20, max 50)

Bank account numbers are masked in response (e.g., *****5678).

Supported Countries: RS (Serbia), BA (Bosnia), PL (Poland), PK (Pakistan), TR (Turkey)


POST /api/recipients

Add a new recipient.

Field Source
File app/api/recipients/route.ts
Auth Required

Request Body:

Field Type Required Validation
name string Yes validateName()
country string Yes Must be in supported list
currency string Yes -
bankAccount string Yes -
bankName string No Sanitized to 200 chars

DELETE /api/recipients/[id]

Delete a recipient.

Field Source
File app/api/recipients/[id]/route.ts
Auth Required

Returns 204 No Content on success. Returns 404 if recipient not found or not owned by user.


Cards (FUTURE — feature-flagged, all flags default to false)

Note: The entire Cards section is a FUTURE feature, gated behind feature flags. All card-related feature flags default to false. These endpoints exist in code but return 404 when flags are disabled. Cards require a card issuing partner (e.g., Stripe Issuing) before activation.

GET /api/cards

List user's cards (excludes cancelled).

Field Source
File app/api/cards/route.ts
Auth Required

POST /api/cards

Create a new card (virtual or physical).

Field Source
File app/api/cards/route.ts
Auth Required

Request Body:

Field Type Required Notes
type string No virtual (default) or physical

GET /api/cards/[id]

Get card details. Card number is masked (---- ---- ---- XXXX), CVV is hidden (---).

Field Source
File app/api/cards/[id]/route.ts
Auth Required

PCI-DSS compliant: never exposes full card number or CVV.


PATCH /api/cards/[id]

Freeze or unfreeze a card.

Field Source
File app/api/cards/[id]/route.ts
Auth Required

Request Body: { "status": "active" | "frozen" }


DELETE /api/cards/[id]

Cancel a card (soft delete — sets status to cancelled).

Field Source
File app/api/cards/[id]/route.ts
Auth Required

POST /api/cards/[id]/physical

Order physical version of a virtual card.

Field Source
File app/api/cards/[id]/physical/route.ts
Auth Required
Feature Flag physicalCards (returns 404 if disabled)

Request Body: { "address": "..." } (min 10 chars)


POST /api/cards/[id]/pin

Set PIN for a card.

Field Source
File app/api/cards/[id]/pin/route.ts
Auth Required
Feature Flag cardPin (returns 404 if disabled)

Request Body: { "pin": "1234" } (exactly 4 digits)

PIN is hashed with bcrypt before storage.


GET /api/cards/[id]/limits

Get spending limits for a card.

Field Source
File app/api/cards/[id]/limits/route.ts
Auth Required
Feature Flag spendingLimits (returns 404 if disabled)

PUT /api/cards/[id]/limits

Set a spending limit for a card.

Field Source
File app/api/cards/[id]/limits/route.ts
Auth Required
Feature Flag spendingLimits (returns 404 if disabled)

Request Body:

Field Type Required Validation
limitType string Yes daily, weekly, monthly, or transaction
amount number Yes Must be positive

Replaces any existing limit of the same type.


Exchange Rates

See also: Currency Rates for provider source, cache/freshness behavior, fallback, and circuit-breaker verification.

GET /api/rates

Get all persisted exchange rates from NOK. This endpoint reads exchange_rates; it does not call the external FX providers directly.

Field Source
File apps/drop-api/src/routes/rates.ts
Auth None
Rate Limit 120 req/min per IP

Success Response (200):

{
  "data": {
    "baseCurrency": "NOK",
    "rates": { "RSD": 10.17, "BAM": 0.17, "PLN": 0.374, "PKR": 26.5, "TRY": 3.39, "EUR": 0.087 },
    "updatedAt": "...",
    "stale": false
  }
}

GET /api/rates/:currency

Get one persisted NOK → target-currency rate.

Field Source
File apps/drop-api/src/routes/rates.ts
Auth None
Rate Limit 120 req/min per IP

Response includes fee: 0.005 (0.5% remittance fee) and stale freshness status.


Notifications

GET /api/notifications

List all notifications for user.

Field Source
File app/api/notifications/route.ts
Auth Required
Feature Flag notifications (default: enabled)

PATCH /api/notifications

Mark notifications as read.

Field Source
File app/api/notifications/route.ts
Auth Required
Feature Flag notifications

Request Body: { "notificationIds": ["noti_..."] }


Settings

GET /api/settings

Get user settings (creates defaults if none exist).

Field Source
File app/api/settings/route.ts
Auth Required

Defaults: currency=NOK, language=nb, pushEnabled=true, emailEnabled=true


PATCH /api/settings

Update user settings.

Field Source
File app/api/settings/route.ts
Auth Required

Request Body (all optional):

Field Type Validation
currency string Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
language string Whitelist: nb, en, bs, sq
pushEnabled boolean -
emailEnabled boolean -

Merchants

POST /api/merchants/register

Register as a merchant.

Field Source
File app/api/merchants/register/route.ts
Auth Required

Request Body:

Field Type Required Validation
businessName string Yes validateName()
orgNumber string Yes Exactly 9 digits, unique
address string No Sanitized to 300 chars
bankAccount string Yes Payout account

Upgrades user role to merchant. Returns a QR code URI (drop://pay/{merchantId}).


GET /api/merchants/dashboard

Get merchant dashboard stats.

Field Source
File app/api/merchants/dashboard/route.ts
Auth Required (merchant role)

Query Parameters: periodtoday (default), week, month

Returns: revenue, transactionCount, fees, netRevenue, nextPayout, payoutTime.


GET /api/merchants/qr

Get merchant QR code data.

Field Source
File app/api/merchants/qr/route.ts
Auth Required (merchant role)

Returns: merchantId, businessName, qrValue (drop://pay/{id}), address.


GET /api/merchants/transactions

List merchant's QR payment transactions with pagination.

Field Source
File app/api/merchants/transactions/route.ts
Auth Required (merchant role)

Query Parameters: page, limit

Customer names are partially anonymized (first name + last initial).


GDPR & Compliance

GET /api/user/data-export

Export all user data (GDPR right to data portability).

Field Source
File app/api/user/data-export/route.ts
Auth Required

Creates a data_access_request record with type export and status completed.

Success Response (200):

{
  "data": {
    "user": {
      "id": "usr_...",
      "email": "...",
      "first_name": "...",
      "last_name": "...",
      "phone": "+47...",
      "date_of_birth": "1995-03-15",
      "kyc_status": "approved",
      "role": "user",
      "created_at": "..."
    },
    "transactions": [ {...}, {...} ],
    "recipients": [ {...}, {...} ],
    "bankAccounts": [ {...} ],
    "settings": { "currency": "NOK", "language": "nb", ... },
    "consents": [ {...}, {...} ]
  },
  "exportedAt": "2026-02-17T..."
}

DELETE /api/user/account

Request account deletion (GDPR right to erasure).

Field Source
File app/api/user/account/route.ts
Auth Required

Behavior:

Success Response (200):

{
  "message": "Account scheduled for deletion",
  "retentionNote": "Data retained for 5 years per AML requirements"
}

GET /api/consents

List user's GDPR consents.

Field Source
File app/api/consents/route.ts
Auth Required

Success Response (200):

{
  "data": [
    {
      "id": "con_...",
      "user_id": "usr_...",
      "consent_type": "terms",
      "granted": 1,
      "granted_at": "2026-02-17T...",
      "withdrawn_at": null,
      "ip_address": "192.0.2.1"
    }
  ]
}

POST /api/consents

Grant or withdraw a consent.

Field Source
File app/api/consents/route.ts
Auth Required

Request Body:

Field Type Required Validation
consentType string Yes Must be one of: terms, privacy, marketing, cookies_analytics, cookies_marketing
granted boolean Yes true to grant, false to withdraw

Behavior:

Success Response (200 for update, 201 for new):

{
  "data": {
    "id": "con_...",
    "consent_type": "marketing",
    "granted": 1,
    "granted_at": "2026-02-17T...",
    "withdrawn_at": null,
    "ip_address": "192.0.2.1"
  }
}

Error Responses:

Status Code Condition
400 bad_request Invalid consent type or missing fields

GET /api/complaints

List user's complaints.

Field Source
File app/api/complaints/route.ts
Auth Required

Query Parameters:

Param Type Default Notes
page int 1 Pagination page number
limit int 10 Items per page, max 100

Success Response (200):

{
  "data": [
    {
      "id": "cmp_...",
      "category": "transaction",
      "subject": "Transaction delayed",
      "description": "My remittance to Serbia is delayed...",
      "status": "received",
      "resolution": null,
      "created_at": "2026-02-17T...",
      "resolved_at": null
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 3,
    "totalPages": 1
  }
}

POST /api/complaints

Submit a complaint (Finansavtaleloven §3-53 compliance).

Field Source
File app/api/complaints/route.ts
Auth Required

Request Body:

Field Type Required Validation
category string Yes Must be one of: transaction, service, fees, privacy, technical, other
subject string Yes Max 200 chars, sanitized
description string Yes Max 2000 chars, sanitized

Success Response (201):

{
  "data": {
    "id": "cmp_...",
    "category": "fees",
    "subject": "High transfer fee",
    "description": "...",
    "status": "received",
    "created_at": "2026-02-17T..."
  },
  "commitmentNote": "We will review and respond to your complaint within 15 business days per Finansavtaleloven §3-53"
}

Error Responses:

Status Code Condition
400 bad_request Invalid category or empty fields

POST /api/transactions/disclosure

Get full transaction fee and exchange rate disclosure before initiating payment.

Field Source
File app/api/transactions/disclosure/route.ts
Auth Required

Request Body:

Field Type Required Notes
type string Yes remittance or qr_payment
amount number Yes Must be positive
currency string No Defaults to NOK
recipientId string Conditional Required for remittance

Success Response (200):

{
  "amount": 2000,
  "fee": 10,
  "feePercentage": 0.5,
  "exchangeRate": 10.17,
  "receiveAmount": 20340,
  "receiveCurrency": "RSD",
  "estimatedDelivery": "1-2 business days",
  "totalCost": 2010
}

Fee Calculation:

Delivery Time:


GET /api/transactions/[id]/receipt

Get transaction receipt with full details.

Field Source
File app/api/transactions/[id]/receipt/route.ts
Auth Required

Success Response (200):

{
  "data": {
    "transactionId": "tx_rem_1",
    "date": "2026-02-17T...",
    "type": "remittance",
    "amount": 2000,
    "currency": "NOK",
    "fee": 10,
    "exchangeRate": 10.17,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "recipient": {
      "name": "Mama Jasmina",
      "country": "RS"
    },
    "reference": "tx_rem_1",
    "status": "completed",
    "estimatedCompletion": null,
    "completedAt": "2026-02-17T..."
  }
}

Error Responses:

Status Code Condition
404 not_found Transaction not found or not owned by user

Health Check

GET /api/health

System health check (no auth required).

Field Source
File app/api/health/route.ts
Auth None

Success Response (200):

{
  "status": "ok",
  "version": "0.1.0",
  "uptime": 3600,
  "db": "connected",
  "dbLatencyMs": 1,
  "timestamp": "..."
}

Returns 503 with status: "error" if database is unreachable.

API & Data

Authentication

Drop Authentication System

Sources: src/drop-app/src/app/api/auth/bankid/, src/drop-api/src/lib/bankid.ts, src/drop-api/src/routes/auth.ts

Overview

Drop uses BankID OIDC as the sole authentication method. Email/password login has been removed to comply with PSD2/SCA requirements.

Phase 2 (planned)


Authentication Flow

BankID Login (Web)

Browser                     Next.js BFF                   BankID OIDC
  |                            |                              |
  |  GET /api/auth/bankid      |                              |
  |--------------------------->|                              |
  |                            |  1. Rate limit check         |
  |                            |  2. Generate state + nonce   |
  |                            |  3. Set bankid_state cookie  |
  |  { redirectUrl }           |                              |
  |<---------------------------|                              |
  |                            |                              |
  |  Browser redirects to BankID authorize URL                |
  |---------------------------------------------------------->|
  |                            |                              |
  |  User authenticates with BankID                           |
  |                            |                              |
  |  BankID redirects to /api/auth/bankid/callback?code=&state=
  |<----------------------------------------------------------|
  |                            |                              |
  |  GET /callback?code&state  |                              |
  |--------------------------->|                              |
  |                            |  4. Verify state vs cookie   |
  |                            |  5. Exchange code for tokens |
  |                            |----------------------------->|
  |                            |  { id_token, access_token }  |
  |                            |<-----------------------------|
  |                            |  6. Verify ID token (JWKS)   |
  |                            |  7. Parse pid, verify age    |
  |                            |  8. Find/create user         |
  |                            |  9. Create session + cookie  |
  |  302 /dashboard            |                              |
  |<---------------------------|                              |

BankID Login (Mobile)

Mobile App                  Hono API                      BankID OIDC
  |                            |                              |
  |  GET /v1/auth/bankid/initiate?platform=mobile             |
  |--------------------------->|                              |
  |  { redirectUrl, state }    |                              |
  |<---------------------------|                              |
  |                            |                              |
  |  Open BankID in secure browser (expo-web-browser)         |
  |---------------------------------------------------------->|
  |                            |                              |
  |  User authenticates with BankID                           |
  |                            |                              |
  |  Redirect to drop://auth/callback?code=&state=            |
  |<----------------------------------------------------------|
  |                            |                              |
  |  POST /v1/auth/bankid/callback                            |
  |  { code, state, platform }                                |
  |--------------------------->|                              |
  |                            |  1. Exchange code for tokens |
  |                            |----------------------------->|
  |                            |  { id_token }                |
  |                            |<-----------------------------|
  |                            |  2. Verify ID token (JWKS)   |
  |                            |  3. Parse pid, verify age    |
  |                            |  4. Find/create user         |
  |                            |  5. Create session           |
  |  { token, data }           |                              |
  |<---------------------------|                              |
  |                            |                              |
  |  Store token in AsyncStorage                              |

User Creation

BankID login automatically creates user accounts:

  1. Parse pid from BankID ID token (Norwegian national ID, 11 digits)
  2. Hash pid with SHA-256 for storage (national_id_hash column)
  3. Check existing user by national_id_hash
  4. If new: Create user with:
    • kyc_status = 'approved' (BankID = verified identity)
    • kyc_method = 'bankid'
    • auth_provider = 'bankid'
    • password_hash = 'EIDONLY' (sentinel — no password auth)
  5. Age check: Must be >= 18 (parsed from pid birthdate)

JWT Structure

Payload

interface JwtPayload {
  userId: string;   // e.g., "usr_a1b2c3d4e5f6g7h8"
  email: string;    // e.g., "usr_xxx@bankid.drop.local"
  role: string;     // "user" or "merchant"
}

Claims

Claim Value
exp Current time + 24h (web) / 7d (mobile)
iat Current time
iss drop-api (Hono) / none (Next.js)
aud drop (Hono) / none (Next.js)

Session Revocation

  1. On login: sessions record created with SHA-256 hash of JWT
  2. On each request: Verify session not revoked + not expired
  3. On logout: All user sessions marked revoked = 1

CSRF Protection


Rate Limiting

Endpoint Limit
BankID initiate 10/min per IP
BankID callback 10/min per IP
Auth me/logout/refresh No additional limit (auth required)

Authorization

Role-Based Access

Two roles: user and merchant.

Route Auth Role
GET /auth/bankid/initiate None -
POST /auth/bankid/callback None -
GET /auth/me Required Any
POST /auth/logout Required Any
POST /auth/refresh Required Any
POST /merchants/register Required Any (upgrades to merchant)
GET /merchants/dashboard Required Merchant

Deprecated Endpoints

These endpoints return 410 Gone:

Endpoint Replacement
POST /auth/login BankID OIDC flow
POST /auth/register Automatic via BankID login
POST /auth/verify-otp Not needed (BankID replaces OTP)

Environment Variables

Required (Production)

BANKID_CLIENT_ID          # BankID OIDC client ID
BANKID_CLIENT_SECRET      # BankID OIDC client secret
BANKID_CALLBACK_URL       # Web callback URL (e.g., https://getdrop.no/api/auth/bankid/callback)
BANKID_CALLBACK_URL_MOBILE # Mobile deep link (e.g., drop://auth/callback)
JWT_SECRET                # JWT signing secret (min 32 chars)

Optional

BANKID_AUTHORIZE_URL      # Default: BankID prod authorize endpoint
BANKID_TOKEN_URL          # Default: BankID prod token endpoint
BANKID_JWKS_URL           # Default: BankID prod JWKS endpoint
BANKID_ISSUER             # Default: BankID prod issuer
BANKID_MOCK=true          # Dev mode: mock OIDC flow (no real BankID needed)
JWT_ALGORITHM             # "HS256" (default) or "RS256"
JWT_EXPIRY                # Default: "24h"

Merchant Flow

Merchants use the same BankID login as regular users. After logging in:

  1. Navigate to merchant registration
  2. Fill in business details (business name, org number, bank account)
  3. POST /merchants/register with auth token
  4. User role upgraded from user to merchant
  5. Merchant dashboard becomes accessible

Services & Middleware

Services & Middleware

Services

Drop External Services

Source: src/drop-app/src/lib/services/

Overview

Drop uses a PSD2 pass-through model — it never holds customer money. AISP reads bank balances via Open Banking, PISP initiates payments from the user's own bank account.

Drop integrates with external service providers. Each service has a different readiness level — see status tags below.

For backend FX-rate sourcing, see Currency Rates. Current drop-api source is configured for Norges Bank primary rates with ECB fallback; /api/rates reads the persisted exchange_rates table rather than calling providers directly.

Legend: [PRODUCTION] = real SDK, production-ready. [MOCK/DEV] = mock only, NOT connected to real APIs. [PLANNED] = future roadmap. [DEPRECATED] = no longer the chosen provider.

Service mode is controlled by NEXT_PUBLIC_SERVICE_MODE env var (default: mock).

Source: services/index.ts:21-30

export const config = {
  mode: (process.env.NEXT_PUBLIC_SERVICE_MODE || 'mock') as 'mock' | 'production',
  endpoints: {
    sumsub: process.env.SUMSUB_API_URL || 'https://api.sumsub.com',
  },
}

Note on Swan: Swan was previously listed as the Open Banking provider but has been deprecated. The pass-through PSD2 model will use a different AISP/PISP provider (TBD).

Note on Stripe: Card issuing is a future feature gated behind feature flags. No Stripe SDK is integrated — only a mock file exists.

Note on Vipps/Nets: Sometimes mentioned in business discussions but have ZERO code in the codebase.


Swan — Open Banking / PSD2 Provider [DEPRECATED]

⚠️ DEPRECATED: Swan is no longer the planned Open Banking provider. Mock code remains but will be removed.

File: services/mock-swan.ts Production docs: https://docs.swan.io/ Status: DEPRECATED mock — no production integration, no contract, no API keys.

Interfaces

Interface Description
SwanAccount Bank account with IBAN, BIC, balance, status
SwanTransaction SEPA credit/debit with status tracking

Functions

Function Signature Description
createAccount (userId) → SwanAccount Create new bank account with IBAN
getAccount (accountId) → SwanAccount | null Retrieve account details
getBalance (accountId) → {available, pending} Get balance breakdown
initiateTransfer ({fromAccountId, toIban, amount, ...}) → SwanTransaction Initiate SEPA credit transfer
simulateIncoming ({toAccountId, amount, fromIban}) → SwanTransaction Simulate incoming transfer
getTransactions (accountId, limit?) → SwanTransaction[] List recent transactions
onWebhook (callback) → void Register webhook listener

Mock Behavior

Account Statuses

Opened | Closing | Closed

Transaction Types

SepaCredit | SepaDebit | CardTransaction

Transaction Statuses

Pending | Booked | Rejected


Stripe — Card Issuing [MOCK/DEV]

⚠️ MOCK ONLY: No Stripe SDK installed. Mock file for UI development only.

File: services/mock-stripe.ts Production docs: https://stripe.com/docs/issuing Status: Mock implementation only — no real Stripe API calls, no SDK, no API keys.

Interfaces

Interface Description
StripeCard Card with type, brand, status, spending limits
StripeCardDetails Full card number, CVC, expiry (virtual only)
StripeAuthorization Card authorization with merchant info

Functions

Function Signature Description
createVirtualCard ({cardholderName, spendingLimit?}) → StripeCard Issue virtual Visa card
orderPhysicalCard ({cardholderName, shippingAddress}) → StripeCard Order physical card
getCardDetails (cardId) → StripeCardDetails Get full card details (virtual only)
setCardStatus (cardId, active) → StripeCard Freeze/unfreeze card
updateSpendingLimit (cardId, limit) → StripeCard Update spending limit
getCards () → StripeCard[] List all cards
simulateAuthorization ({cardId, amount, merchant}) → StripeAuthorization Simulate card purchase
getAuthorizations (cardId?) → StripeAuthorization[] List authorizations

Mock Behavior

Card Statuses

active | inactive | canceled | pending

Authorization Statuses

pending | approved | declined


Sumsub — KYC/Identity Verification [PRODUCTION]

✅ PRODUCTION-READY: Sumsub is the only external service with real production API integration.

File: services/mock-sumsub.ts Production docs: https://docs.sumsub.com/ Status: Production-ready — real API calls, WebSDK integration, webhook handling.

Interfaces

Interface Description
SumsubApplicant KYC applicant with review status
SumsubDocument Identity document (passport, ID card, etc.)
SumsubVerificationResult Verification outcome with per-check breakdown

Functions

Function Signature Description
createApplicant ({externalUserId, email?, phone?}) → SumsubApplicant Create KYC applicant
getAccessToken (applicantId) → {token, expiresAt} Get WebSDK token (30min)
submitDocument (applicantId, document) → void Submit ID document
submitSelfie (applicantId, selfieData) → void Submit selfie for liveness
getApplicantStatus (applicantId) → SumsubApplicant Check applicant status
getVerificationResult (applicantId) → SumsubVerificationResult Get verification details
forceApprove (applicantId) → void Force approve (testing only)
onWebhook (callback) → void Register webhook listener

Mock Behavior

Applicant Statuses

init | pending | queued | completed | onHold

Review Answers

GREEN (approved) | RED (rejected) | RETRY

Verification Checks

Check Description
documentAuthenticity Document is genuine
livenessCheck Selfie is a real person
facematch Selfie matches document photo
sanctionsCheck Not on sanctions lists
pepCheck Not a politically exposed person

Document Types

PASSPORT | ID_CARD | DRIVERS | RESIDENCE_PERMIT


Service Initialization

Source: services/index.ts:36-48

// Call on app startup
await initializeServices()

// Reset all mocks (testing)
resetMockServices()

Service Status Summary

Service Status Description
Sumsub [PRODUCTION] Real API integration, WebSDK, webhook handling — READY
FX rates [IMPLEMENTED/RISK] drop-api uses Norges Bank primary + ECB fallback for cron refresh; see verified parser/conversion risk in Currency Rates
Stripe [MOCK/DEV] Mock file only for UI development — NO SDK, NO API keys
Swan [DEPRECATED] No longer the planned Open Banking provider — mock will be removed
Vipps [PLANNED] Future consideration — ZERO code currently
Nets [PLANNED] Future consideration — ZERO code currently

Important Notes

  1. Sumsub is the ONLY production-ready service — all others are mocks or deprecated.
  2. Console warnings are emitted on module load for mock services to make usage visible.
  3. Mock state uses localStorage in browser, in-memory on server — resets on server restart.
  4. Production API endpoints are configurable via environment variables.
  5. The current backend API routes do NOT call these service modules directly — they use the database layer (db.ts) for all operations. The services are available for future integration when real providers are connected.
Services & Middleware

Middleware Design Document

Middleware Design Document

Project: Drop Version: 0.1.0 Date: 2026-02-23 Author: Platform Architect (AI) Status: In Review Reviewers: Alem Bašić (CEO)

Document History

Version Date Author Changes
0.1 2026-02-23 Platform Architect (AI) Initial draft from source code analysis

1. Overview

Drop has two middleware layers:

  1. src/lib/middleware.ts — The active middleware used by all API routes. Provides requireAuth, requireMerchant, rateLimit, getClientIp, jsonError, CSRF protection, and session revocation.

  2. src/lib/middleware/ — A modular middleware library with auth-middleware.ts (Bearer token for mobile), error-handler.ts (AppError class), and validation.ts (input sanitization functions).

Both layers are used in production. Routes import from @/lib/middleware (auth, rate limiting) and @/lib/middleware/validation (input validation).


2. Active Middleware (lib/middleware.ts)

2.1 requireAuth(request?)

Source: middleware.ts:42–80

Authenticates the current request via cookie-based JWT.

Returns: { user: User, error: null } | { user: null, error: NextResponse }

Steps:

  1. CSRF origin check — if Origin header present, must match allowed origins (NEXT_PUBLIC_APP_URL, http://localhost:3000, http://localhost:3001)
  2. Cookie extraction — reads drop_token from request cookies
  3. JWT verification — validates HS256 signature and expiry using jose library
  4. User lookup — loads user from users table by userId from JWT payload
  5. Session revocation check — verifies at least one non-revoked session exists for this user

Usage:

const { user, error } = await requireAuth(request);
if (error) return error;  // Returns NextResponse with JSON error
// user is guaranteed non-null here

Error responses:


2.2 requireMerchant(request?)

Source: middleware.ts:101–108

Extends requireAuth with a merchant role check.

const { user, error } = await requireMerchant(request);
if (error) return error;  // 401 if not authenticated, 403 if not merchant

Returns 403 forbidden if user exists but role !== 'merchant'.

Applied to: GET /api/merchants/dashboard, GET /api/merchants/qr, GET /api/merchants/transactions


2.3 rateLimit(ip, limit, windowMs?)

Source: middleware.ts:7–31

Persistent IP-based rate limiter using the rate_limits database table.

Parameter Default Description
ip Client IP address
limit Max requests per window
windowMs 60,000ms Window size in milliseconds

Returns: booleantrue if request is allowed, false if rate limited.

Implementation:

Rate limit table schema:

CREATE TABLE rate_limits (
  key TEXT PRIMARY KEY,      -- IP address
  count INTEGER DEFAULT 1,
  expires_at INTEGER         -- Unix timestamp (ms)
);

Usage:

const ip = getClientIp(request);
if (!(await rateLimit(ip, 10))) {  // 10 req/min
  return jsonError("rate_limited", "Too many requests", 429);
}

Applied limits:

Endpoint Limit Window
/api/auth/bankid/initiate 10/min 60s
/api/auth/bankid/callback 10/min 60s
/api/auth/register (deprecated) 10/min 60s
/api/auth/login (deprecated) 10/min 60s
/api/transactions/remittance 10/min 60s
/api/transactions/qr-payment 10/min 60s
/api/rates 120/min 60s
/api/rates/[currency] 120/min 60s

2.4 getClientIp(request)

Source: middleware.ts:33–35

Extracts the client's real IP address from the x-forwarded-for header (first IP in the chain — the originating client). Falls back to '127.0.0.1' if header not present.

Note: When behind App Runner (AWS managed proxy), x-forwarded-for is set automatically with the real client IP.


2.5 jsonError(error, message, status, details?)

Source: middleware.ts:37–39

Creates a standardized JSON error NextResponse.

return jsonError("validation_error", "Validation failed", 422, ["Email required"]);
// Response body: { "error": "validation_error", "message": "Validation failed", "details": ["Email required"] }

2.6 revokeAllSessions(userId)

Source: middleware.ts:83–85

Sets revoked=1 on all sessions for a user. Called by POST /api/auth/logout.

UPDATE sessions SET revoked = 1 WHERE user_id = $1;

2.7 generateCsrfToken() / validateCsrf(request, token)

Source: middleware.ts:88–99

CSRF token generation (32 random bytes hex-encoded) and validation via x-csrf-token header.

Status: Implemented but not actively required on any route. CSRF protection is handled via:


3. Middleware Library (lib/middleware/)

3.1 Error Handler (middleware/error-handler.ts)

AppError class:

class AppError extends Error {
  constructor(
    public code: string,
    message: string,
    public status: number = 500,
    public details?: unknown
  ) {}
}

Predefined error constructors:

Constructor Code HTTP Status
Errors.unauthorized(msg?) UNAUTHORIZED 401
Errors.forbidden(msg?) FORBIDDEN 403
Errors.notFound(resource) NOT_FOUND 404
Errors.badRequest(msg, details?) BAD_REQUEST 400
Errors.conflict(msg) CONFLICT 409
Errors.tooManyRequests(msg?) RATE_LIMIT_EXCEEDED 429
Errors.internal(msg?) INTERNAL_ERROR 500

Error response format:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Amount must be between 100 and 50000 NOK",
    "details": "validation_error"
  }
}

Production masking: createErrorResponse() masks internal error messages in production — only returns "An unexpected error occurred" for 500 errors.


3.2 Auth Middleware (middleware/auth-middleware.ts)

Alternative auth middleware for mobile clients using Bearer token pattern.

requireAuth(request):

In-memory rate limiter (for Bearer token routes):

getClientIP(request): Checks X-Forwarded-ForX-Real-IP → falls back to 'unknown'.


3.3 Validation (middleware/validation.ts)

Input validation functions — no external dependencies, all custom implementations.

Function Description Rules
validatePhone(phone) International phone Starts with +, 8–15 digits
validateAmount(amount) Positive monetary amount > 0, max 2 decimal places
validateIBAN(iban) European IBAN Country code + alphanumeric, mod-97 checksum
validatePIN(pin) Card PIN Exactly 4 digits
validateEmail(email) Email address Basic x@y.z pattern
validateCurrency(currency) ISO 4217 code Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
validateDateISO(date) ISO 8601 date Parseable by Date.parse()
validateName(name) Name field 1–100 chars, at least one letter, XSS-safe
validateLanguage(lang) Language code Whitelist: nb, en, bs, sq
sanitizeText(text, maxLength?) Text sanitization Strips HTML tags + control chars, trims, enforces max length (default 500)
validate(condition, msg) Assert helper Throws AppError (400) if false
required(value, name) Required field check Throws AppError (400) if null/undefined

Security notes:


4. Security Headers (Next.js Config)

Applied to all responses via next.config.ts:

Header Production Value Development Value Purpose
Content-Security-Policy default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none' Adds 'unsafe-eval' + 'unsafe-inline' for HMR XSS protection
X-Frame-Options DENY DENY Clickjacking prevention
X-Content-Type-Options nosniff nosniff MIME sniffing prevention
Referrer-Policy strict-origin-when-cross-origin Same Referrer leakage prevention
Permissions-Policy camera=(self), microphone=(), geolocation=(self) Same Feature restriction
Strict-Transport-Security max-age=63072000; includeSubDomains; preload Same Force HTTPS (2-year HSTS)

5. Middleware Usage Matrix

Route Rate Limit requireAuth requireMerchant Feature Flag Validation Functions
GET /api/auth/bankid 10/min No No No
GET /api/auth/bankid/callback 10/min No No No state cookie
GET /api/auth/me No Yes No No
POST /api/auth/logout No Yes No No
POST /api/auth/refresh No Yes No No
GET /api/transactions No Yes No No
POST /api/transactions/remittance 10/min Yes No No validateAmount
POST /api/transactions/qr-payment 10/min Yes No No validateAmount
GET /api/rates 120/min No No No
POST /api/recipients No Yes No No validateName, country whitelist
POST /api/merchants/register No Yes No No validateName, orgNumber
GET /api/merchants/dashboard No Yes Yes No period whitelist
GET /api/notifications No Yes No notifications
PATCH /api/notifications No Yes No notifications ID format, max 100
PATCH /api/settings No Yes No No currency/language whitelist
POST /api/cards/[id]/physical No Yes No physicalCards address min 10 chars
POST /api/cards/[id]/pin No Yes No cardPin validatePIN
GET/PUT /api/cards/[id]/limits No Yes No spendingLimits limitType whitelist

6. Error Spike Detection

Implemented in src/lib/alerts.ts as a middleware-adjacent concern:

Limitation: Error counter is in-memory only — resets on application restart. Redis-backed counter planned for v1.0.



Approval

Role Name Date Signature
Author Platform Architect (AI) 2026-02-23
Reviewer
Approver Alem Bašić
Services & Middleware

Feature Flags

Drop Feature Flags

Sources: src/drop-app/src/lib/feature-flags.ts, src/drop-app/src/lib/features.ts

Feature Flag System

Source: feature-flags.ts

Architecture

Feature flags are controlled via environment variables with the pattern:

NEXT_PUBLIC_FF_<SCREAMING_SNAKE_CASE>=true|false

The NEXT_PUBLIC_ prefix ensures flags are available on both server and client (inlined at build time by Next.js).

Conversion example: physicalCardsNEXT_PUBLIC_FF_PHYSICAL_CARDS

Source: feature-flags.ts:42-45


Available Flags

Flag Name Env Var Default Description
virtualCards NEXT_PUBLIC_FF_VIRTUAL_CARDS false Virtual card issuance
physicalCards NEXT_PUBLIC_FF_PHYSICAL_CARDS false Physical card ordering
cardDetails NEXT_PUBLIC_FF_CARD_DETAILS false View full card details
cardFreeze NEXT_PUBLIC_FF_CARD_FREEZE false Card freeze/unfreeze
cardPin NEXT_PUBLIC_FF_CARD_PIN false Card PIN management
spendingLimits NEXT_PUBLIC_FF_SPENDING_LIMITS false Card spending limits
notifications NEXT_PUBLIC_FF_NOTIFICATIONS true Push notifications
merchantDashboard NEXT_PUBLIC_FF_MERCHANT_DASHBOARD true Merchant dashboard

Source: feature-flags.ts:27-36


Server-Side API

Function Return Type Description
isEnabled(flag) boolean Check if a flag is enabled
getAllFlags() FeatureFlags Get all flags with current values
featureGate(flag) NextResponse | null API middleware: returns 404 response if disabled, null if enabled

featureGate usage in routes:

// In any route handler:
const gate = featureGate("physicalCards");
if (gate) return gate;  // Returns 404 with "Feature not available"

Source: feature-flags.ts:80-88

Routes using featureGate:

Route Flag
POST /api/cards/[id]/physical physicalCards
POST /api/cards/[id]/pin cardPin
GET /api/cards/[id]/limits spendingLimits
PUT /api/cards/[id]/limits spendingLimits
GET /api/notifications notifications
PATCH /api/notifications notifications

Client-Side API

Function Return Type Description
useFeatureFlag(flag) boolean React hook for a single flag
useFeatureFlags() FeatureFlags React hook for all flags

These work because NEXT_PUBLIC_* env vars are inlined at build time — no server roundtrip needed.

Source: feature-flags.ts:94-114


Feature Tracking System

Source: features.ts

A separate system for tracking implementation progress of Drop features. Not runtime flags — this is a development tracking tool.

Feature Interface

interface Feature {
  id: string;              // e.g., "auth-001"
  category: string;        // e.g., "Authentication"
  name: string;            // e.g., "User Registration"
  description: string;
  status: "pending" | "in_progress" | "passing" | "failing";
  priority: number;        // 1 = highest
  dependencies: string[];  // IDs of prerequisite features
  acceptanceCriteria: string[];
  implementedAt?: string;  // ISO date
  testedAt?: string;       // ISO date
}

Feature Categories and Status

Category Total Passing Pending Notes
Authentication 4 3 1 (Biometric Login)
KYC 1 1 0
Banking 6 5 1 bank-006 (Top-up via Card) is FUTURE — incompatible with pass-through model
Cards 4 4 0 FUTURE — all card features are gated behind feature flags (default: false)
Notifications 1 0 1 (Push Notifications)

All Features

ID Name Status Priority Dependencies Notes
auth-001 User Registration passing 1 -
auth-002 PIN Login passing 1 auth-001
auth-003 Logout passing 2 auth-002
auth-004 Biometric Login pending 3 auth-002
kyc-001 Identity Verification passing 1 auth-001
bank-001 IBAN Generation passing 1 kyc-001
bank-002 Balance Display passing 1 bank-001 AISP read-only
bank-003 Send Money passing 1 bank-002 PISP from user's bank
bank-004 Receive Money passing 1 bank-001
bank-005 Transaction History passing 2 bank-003, bank-004
bank-006 Top-up via Card passing 2 bank-001 FUTURE — no wallet in pass-through model
card-001 Virtual Card Issuance passing 1 kyc-001 FUTURE — feature-flagged
card-002 Card Freeze/Unfreeze passing 2 card-001 FUTURE — feature-flagged
card-003 Card Transactions passing 1 card-001 FUTURE — feature-flagged
card-004 Physical Card Order passing 3 card-001 FUTURE — feature-flagged
notif-001 Push Notifications pending 3 auth-001

Helper Functions

Function Description
getFeaturesByStatus(status) Filter features by status
getFeaturesByCategory(category) Filter features by category
getFeatureStats() Get counts: total, passing, pending, inProgress, failing, percentComplete
getReadyFeatures() Features whose dependencies are all passing
printFeatureReport() Formatted text report

Source: features.ts:284-357


Environment Variable Summary

Variable Purpose Default
NEXT_PUBLIC_FF_VIRTUAL_CARDS Enable virtual cards false
NEXT_PUBLIC_FF_PHYSICAL_CARDS Enable physical cards false
NEXT_PUBLIC_FF_CARD_DETAILS Enable card detail view false
NEXT_PUBLIC_FF_CARD_FREEZE Enable card freeze false
NEXT_PUBLIC_FF_CARD_PIN Enable card PIN false
NEXT_PUBLIC_FF_SPENDING_LIMITS Enable spending limits false
NEXT_PUBLIC_FF_NOTIFICATIONS Enable notifications true
NEXT_PUBLIC_FF_MERCHANT_DASHBOARD Enable merchant dashboard true
NEXT_PUBLIC_SERVICE_MODE mock or production mock
DATABASE_URL PostgreSQL 16 connection string Required (no SQLite fallback)
JWT_SECRET JWT signing secret dev-only fallback
NEXT_PUBLIC_APP_URL App URL for CSRF -
SEED_DEMO Enable demo data in staging -
Services & Middleware

Middleware

Drop Middleware

Sources: src/drop-app/src/lib/middleware.ts, src/drop-app/src/lib/middleware/

Overview

Drop has two middleware layers:

  1. lib/middleware.ts — The active middleware used by all API routes. Provides requireAuth, requireMerchant, rateLimit, getClientIp, jsonError, CSRF, and session revocation.

  2. lib/middleware/ directory — A modular middleware library with auth-middleware.ts, error-handler.ts, and validation.ts. Exported via barrel file middleware/index.ts.

The API routes import from both: @/lib/middleware (auth, rate limiting) and @/lib/middleware/validation (input validation).


Active Middleware (middleware.ts)

requireAuth(request?)

Source: middleware.ts:42-80

Authenticates the current request via cookie-based JWT. Returns { user, error }.

Steps:

  1. CSRF origin check — If Origin header present, must match allowed origins
  2. Cookie extraction — Reads drop_token from cookies
  3. JWT verification — Validates signature and expiry
  4. User lookup — Loads user from users table
  5. Session revocation check — Verifies at least one non-revoked session exists

Allowed origins: NEXT_PUBLIC_APP_URL, http://localhost:3000, http://localhost:3001

const { user, error } = await requireAuth(request);
if (error) return error;  // Returns NextResponse with error JSON

requireMerchant(request?)

Source: middleware.ts:101-108

Extends requireAuth with a role check: user must have role === 'merchant'. Returns 403 if not.

const { user, error } = await requireMerchant(request);
if (error) return error;

rateLimit(ip, limit, windowMs?)

Source: middleware.ts:7-31

Persistent IP-based rate limiter using the rate_limits database table.

if (!(await rateLimit(ip, 10))) {           // 10 requests per 60s window
  return jsonError("rate_limited", "Too many requests", 429);
}

getClientIp(request)

Source: middleware.ts:33-35

Extracts client IP from x-forwarded-for header (first IP in chain), falls back to 127.0.0.1.


jsonError(error, message, status, details?)

Source: middleware.ts:37-39

Creates a standardized JSON error response.

return jsonError("validation_error", "Validation failed", 422, ["Email required"]);
// → { "error": "validation_error", "message": "Validation failed", "details": ["Email required"] }

revokeAllSessions(userId)

Source: middleware.ts:83-85

Sets revoked=1 on all sessions for a user. Called during logout.


generateCsrfToken() / validateCsrf(request, token)

Source: middleware.ts:88-99

CSRF token generation (32 random bytes hex-encoded) and validation via x-csrf-token header. Available but not actively required on any route.


Middleware Library (middleware/)

Error Handler

Source: middleware/error-handler.ts

AppError class:

class AppError extends Error {
  constructor(code: string, message: string, status: number = 500, details?: unknown)
}

Predefined error constructors (Errors.*):

Constructor Code Status
Errors.unauthorized(msg?) UNAUTHORIZED 401
Errors.forbidden(msg?) FORBIDDEN 403
Errors.notFound(resource) NOT_FOUND 404
Errors.badRequest(msg, details?) BAD_REQUEST 400
Errors.conflict(msg) CONFLICT 409
Errors.tooManyRequests(msg?) RATE_LIMIT_EXCEEDED 429
Errors.internal(msg?) INTERNAL_ERROR 500

Error response format:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "...",
    "details": "..."
  }
}

createErrorResponse(error) handles AppError, standard Error, and unknown errors. In development, includes original error messages; in production, masks internal errors.


Auth Middleware

Source: middleware/auth-middleware.ts

Alternative auth middleware using Bearer token pattern (vs. cookie pattern in middleware.ts).

requireAuth(request) — Extracts JWT from Authorization: Bearer <token> header, verifies, returns userId.

In-memory rate limiter with:

getClientIP(request) — Checks X-Forwarded-For, then X-Real-IP, then falls back to 'unknown'.


Validation

Source: middleware/validation.ts

Input validation functions (no external dependencies):

Function Description Rules
validatePhone(phone) International phone format Starts with +, 8-15 digits
validateAmount(amount) Positive number > 0, max 2 decimal places
validateIBAN(iban) European IBAN format Country code + digits + alphanumeric, mod-97 checksum
validatePIN(pin) Card PIN Exactly 4 digits
validateEmail(email) Email address Basic x@y.z pattern
validateCurrency(currency) ISO 4217 code Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
validateDateISO(date) ISO 8601 date Parseable by Date.parse()
validateName(name) Name field 1-100 chars, at least one letter, no script/HTML injection
validateLanguage(lang) Language code Whitelist: nb, en, bs, sq
sanitizeText(text, maxLength?) Text sanitization Strips HTML tags, control chars, trims, enforces max length (default 500)
validate(condition, msg) Assert helper Throws AppError (400) if false
required(value, name) Required check Throws AppError (400) if null/undefined

Security notes:


Middleware Usage by Route

Route Rate Limit Auth Merchant Feature Flag Validation
POST /auth/register 10/min - - - email, name, phone, age
POST /auth/login 10/min - - - -
GET /auth/me - Yes - - -
POST /auth/logout - Yes - - -
POST /auth/refresh - Yes - - -
GET /transactions - Yes - - -
POST /transactions/remittance 10/min Yes - - amount range, decimal
POST /transactions/qr-payment 10/min Yes - - amount range, decimal
GET /rates 120/min - - - -
GET /rates/[currency] 120/min - - - -
POST /cards/[id]/physical - Yes - physicalCards address min 10 chars
POST /cards/[id]/pin - Yes - cardPin 4-digit PIN
GET /cards/[id]/limits - Yes - spendingLimits -
PUT /cards/[id]/limits - Yes - spendingLimits limitType whitelist
GET /notifications - Yes - notifications -
PATCH /notifications - Yes - notifications ID format, max 100
PATCH /settings - Yes - - currency/language whitelist
POST /recipients - Yes - - name, country whitelist
POST /merchants/register - Yes - - orgNumber 9 digits
GET /merchants/dashboard - Yes Merchant - period whitelist
GET /merchants/qr - Yes Merchant - -
GET /merchants/transactions - Yes Merchant - -

Backend Architecture Document

Backend Architecture Document

Project: Drop Version: 0.1.0 Date: 2026-02-23 Author: Platform Architect (AI) Status: In Review Reviewers: Alem Bašić (CEO)

Document History

Version Date Author Changes
0.1 2026-02-23 Platform Architect (AI) Initial draft from source code analysis

1. Architecture Pattern

Pattern: Modular Monolith — Next.js App Router with co-located API routes

Pattern Considered Pros Cons Decision
Monolith (Next.js all-in-one) Simple deploy, single codebase, lowest latency Scaling bottleneck, frontend/backend coupled Selected
Modular Monolith Module isolation within single deploy Extra abstraction overhead for small team Partially adopted (lib/ modules)
Microservices Independent scaling per service Operational complexity, too expensive for MVP Rejected

Rationale: Drop is a two-person MVP (Alem + AI). The Next.js App Router pattern co-locates API routes (app/api/) with the frontend, enabling full-stack development in a single TypeScript codebase with zero additional runtime complexity. The src/lib/ directory provides module isolation (db, middleware, services, features) without microservice overhead. App Runner handles scaling concerns at the infrastructure level.

Pass-Through Model (Critical Architecture Constraint): Drop NEVER holds customer money. All payments are pass-through via PSD2:


2. Technology Stack

Layer Technology Version Notes
Runtime Node.js 22 (Alpine) LTS, Dockerfile base image
Framework Next.js (App Router) 16.1.6 Standalone output for Docker
Frontend React 19.2.3
Language TypeScript ^5 Strict mode
Database (production) PostgreSQL (via pg) 16 (RDS) pg ^8.18.0
Database (MVP/staging) SQLite (via better-sqlite3) ^12.6.2 Auto-detected when no DATABASE_URL
Auth JWT (via jose) ^6.1.3 HS256, httpOnly cookie
Password hashing bcryptjs ^3.0.3 Legacy — BankID replaces email/password
Identity (eID) BankID OIDC Norwegian eID Mandatory for all users
KYC Sumsub WebSDK + API Production-ready Only connected external service
Open Banking TBD (Swan deprecated) AISP/PISP provider selection pending
Styling Tailwind CSS ^4
UI Components Radix UI ^1.4.3 Accessible, unstyled primitives
Icons Lucide React ^0.563.0
Theme next-themes ^0.4.6 Dark/light mode
Toasts Sonner ^2.0.7
Testing (unit) Vitest ^4.0.18
Testing (E2E) Playwright ^1.58.2
Linting ESLint ^9

3. Application Structure

src/drop-app/
├── src/
│   ├── app/                    # Next.js App Router
│   │   ├── api/                # API route handlers (REST endpoints)
│   │   │   ├── auth/           # BankID OIDC + session management
│   │   │   │   ├── bankid/     # initiate + callback endpoints
│   │   │   │   ├── me/         # current user + bank accounts
│   │   │   │   ├── logout/     # session revocation
│   │   │   │   └── refresh/    # token refresh
│   │   │   ├── transactions/   # remittance, qr-payment, history, disclosure, receipt
│   │   │   ├── recipients/     # recipient CRUD
│   │   │   ├── rates/          # exchange rates (public)
│   │   │   ├── merchants/      # merchant registration + dashboard
│   │   │   ├── notifications/  # push notification management
│   │   │   ├── settings/       # user preferences
│   │   │   ├── consents/       # GDPR consent management
│   │   │   ├── complaints/     # Finansavtaleloven §3-53 complaints
│   │   │   ├── cards/          # [FUTURE] feature-flagged card management
│   │   │   ├── user/           # GDPR data export + account deletion
│   │   │   └── health/         # health check endpoint
│   │   └── (frontend pages)    # Next.js pages
│   └── lib/                    # Shared application library
│       ├── db.ts               # Database abstraction (PostgreSQL + SQLite)
│       ├── middleware.ts        # Auth, rate limiting, CSRF, session revocation
│       ├── middleware/          # Modular middleware library
│       │   ├── auth-middleware.ts  # Bearer token auth (mobile)
│       │   ├── error-handler.ts   # AppError class + error response formatting
│       │   └── validation.ts      # Input validation functions
│       ├── alerts.ts           # Slack alerting + error spike detection
│       ├── secrets.ts          # Pluggable secrets provider (env / Doppler / AWS SM)
│       ├── feature-flags.ts    # Environment-variable-based feature flags
│       ├── features.ts         # Feature tracking system (dev tool)
│       └── services/           # External service integrations
│           ├── index.ts        # Service initialization
│           ├── mock-sumsub.ts  # Sumsub KYC (production-ready)
│           ├── mock-swan.ts    # Swan Open Banking (DEPRECATED)
│           └── mock-stripe.ts  # Stripe Issuing (mock only, FUTURE)
├── tests/                      # Test suite
│   ├── setup.ts                # Vitest setup (NODE_ENV=test, in-memory DB)
│   ├── *.test.ts               # Unit + integration tests
│   └── e2e/                    # Playwright E2E tests
│       ├── user-flows.spec.ts
│       ├── full-flows.spec.ts
│       └── input-chaos.spec.ts
└── scripts/
    ├── backup.sh               # SQLite backup script
    └── qa-report.js            # QA metrics generator

4. Database Layer

4.1 Dual-Database Architecture

Drop auto-detects the database driver at startup:

Source: src/lib/db.ts

4.2 Key Database Tables

Table Purpose Notes
users User accounts, KYC status, BankID linkage kyc_status: pending/approved/rejected; national_id_hash: SHA-256 of BankID pid
sessions JWT session tracking + revocation SHA-256 hash of JWT, revoked flag
bank_accounts Linked bank accounts (AISP data) balance = last AISP read (NOT Drop-held funds)
transactions All payments (remittance + QR) type: remittance/qr_payment; status: processing/completed/failed
recipients Saved international recipients Bank account masked in API responses
merchants Merchant profiles, QR data org_number unique (9 digits, Norwegian)
notifications User notifications Feature-flagged
rate_limits IP-based rate limiting (persistent) key: IP address, window-based counter
audit_log Security + compliance audit trail action, resource_type, resource_id, details
aml_alerts AML/financial crime alerts status: open/closed/filed
str_reports Suspicious Transaction Reports Filed with Finanstilsynet
consents GDPR consent records consent_type: terms/privacy/marketing/cookies_analytics/cookies_marketing
data_access_requests GDPR export/erasure requests type: export/erasure
complaints User complaints (Finansavtaleloven §3-53) 15-business-day response SLA
exchange_rates NOK → destination currency rates Updated externally
feature_flags Runtime feature flag overrides Complement to env-var flags
cards [FUTURE] Virtual/physical cards Feature-flagged, all flags default false

4.3 Data Auto-Detection (db.ts)

// Auto-detects driver based on DATABASE_URL env var
const driver = process.env.DATABASE_URL ? 'pg' : 'sqlite';

5. Authentication Architecture

5.1 BankID OIDC Flow (Primary Auth)

Auth method: Norwegian BankID — mandatory for all users. Email/password auth deprecated (returns 410 Gone).

Token: JWT (HS256), stored in drop_token httpOnly cookie (web) or Authorization Bearer header (mobile).

Token lifetime: 24h (web), 7d (mobile)

BankID Web Flow:

  1. GET /api/auth/bankid → generate state + nonce, set bankid_state cookie, return redirect URL
  2. User authenticates with BankID at provider
  3. GET /api/auth/bankid/callback?code=&state= → verify state, exchange code for tokens, verify JWKS signature, parse pid, hash pid → SHA-256, find/create user, issue JWT cookie

User creation on first BankID login:

Age verification: pid encodes date of birth — must be >= 18 years old.

5.2 Session Management

Login  → Create session record (SHA-256 of JWT) in sessions table
Request → requireAuth() checks: cookie present + JWT valid + session not revoked
Logout → revokeAllSessions(userId) — sets revoked=1 on all user sessions

5.3 CSRF Protection


6. Middleware Stack

Middleware Function Applied To
requireAuth() CSRF check → cookie extraction → JWT verify → user lookup → session revocation check All protected routes
requireMerchant() requireAuth() + role check (role === 'merchant') Merchant-only routes
rateLimit(ip, limit) Persistent IP-based counter via rate_limits DB table, 60s window Auth endpoints (10/min), public rates (120/min)
getClientIp() Extract IP from x-forwarded-for All rate-limited routes
jsonError() Standardized JSON error response All routes
featureGate(flag) Returns 404 if feature flag disabled Cards, spending limits, notifications
Input validation validateEmail, validatePhone, validateAmount, validateName, sanitizeText, etc. All mutation endpoints
Error handler AppError class with predefined constructors All routes via createErrorResponse()

7. API Design Principles

  1. Consistent response envelope:

    • Success: { "data": { ... } } or { "data": [...], "pagination": { ... } }
    • Error: { "error": "code", "message": "...", "details": [...] }
  2. No wallet model: Drop never holds funds. bank_accounts.balance is AISP-read cache only.

  3. KYC gate: Remittance requires kyc_status === 'approved' — enforced in route handler.

  4. Atomic transactions: Balance deduction and transaction creation in a single DB transaction.

  5. Data masking: Bank account numbers masked in responses (*****5678), card numbers PCI-masked.

  6. GDPR by design: Data export, account deletion (soft delete), consent records all implemented.

  7. Compliance-first: STR reports, AML alerts, audit log, complaint system (Finansavtaleloven §3-53), PITR retention (5 years per hvitvaskingsloven).


8. Security Architecture

Control Implementation
Auth BankID OIDC (Norwegian eID) — mandatory
Session tokens httpOnly, secure, sameSite=strict JWT cookies
Rate limiting Persistent DB-backed per-IP (10/min auth, 120/min public)
Input validation Custom validators (no external dep) — email, phone, amount, IBAN, name (XSS-resistant)
SQL injection Parameterized queries via pg / better-sqlite3
XSS CSP headers (strict production — no unsafe-eval) + HTML sanitization in sanitizeText()
CSRF Origin header validation + BankID state parameter
Secrets AWS Secrets Manager / Fly.io secrets — never in code or .env
Error masking createErrorResponse() masks internal errors in production
Password hashing bcryptjs (legacy users)
Card data PCI-masked (never expose full card number or CVV)
Audit trail audit_log table — all sensitive actions logged
AML aml_alerts + str_reports tables — compliance framework

9. External Service Integrations

Service Status Purpose
Sumsub PRODUCTION (only connected external service) KYC/identity verification — WebSDK + webhook
BankID OIDC PRODUCTION Norwegian eID authentication
Open Banking (AISP/PISP) TBD — provider selection pending Bank balance read + payment initiation
Swan Open Banking DEPRECATED Was planned, no longer selected
Stripe Issuing MOCK (future) Card issuance — no SDK, no API keys
Slack PRODUCTION Operational alerting via webhook
BetterStack PRODUCTION External uptime monitoring


Approval

Role Name Date Signature
Author Platform Architect (AI) 2026-02-23
Reviewer
Approver Alem Bašić

Service Design Document — Payment Service

Service Design Document — Payment Service

Project: Drop Service: Payment Service (Remittance + QR Payments) Version: 0.1.0 Date: 2026-02-23 Author: Platform Architect (AI) Status: In Review Reviewers: Alem Bašić (CEO)

Document History

Version Date Author Changes
0.1 2026-02-23 Platform Architect (AI) Initial draft from source code analysis

1. Service Overview

The Payment Service is Drop's core business logic module, responsible for:

  1. Remittance — international money transfers from Norway to 5 corridors (Serbia, Bosnia, Poland, Pakistan, Turkey)
  2. QR Payments — instant in-store payments to registered merchants

Drop uses a PSD2 pass-through model — it never holds customer money. Payments are PISP-initiated directly from the user's bank account. The service orchestrates: balance verification → fee calculation → atomic debit + transaction record creation.

Source files:


2. Domain Model

2.1 Core Entities

User
  ├── has many BankAccount (AISP-read from user's real bank)
  ├── has many Recipient (saved international recipients)
  ├── has many Transaction
  └── has one Merchant (optional — if registered as merchant)

Transaction
  ├── type: "remittance" | "qr_payment"
  ├── status: "processing" | "completed" | "failed"
  ├── sendAmount + sendCurrency (NOK)
  ├── receiveAmount + receiveCurrency (destination)
  ├── exchangeRate
  ├── fee (NOK)
  └── links to: Recipient (remittance) OR Merchant (QR)

BankAccount
  ├── balance (AISP-read cache — NOT Drop-held funds)
  ├── isPrimary
  └── bankName, accountNumber, currency

2.2 Supported Currency Corridors

Destination Currency Exchange Rate (illustrative) Fee
Serbia RSD 11.7 NOK/RSD 0.5%
Bosnia BAM 1.04 NOK/BAM 0.5%
Poland PLN 0.41 NOK/PLN 0.5%
Pakistan PKR 26.8 NOK/PKR 0.5%
Turkey TRY 3.45 NOK/TRY 0.5%

QR Payments: NOK only, fee 1%, instant settlement.


3. Remittance Service Design

3.1 Flow

POST /api/transactions/remittance
│
├── 1. requireAuth() — verify JWT cookie + session not revoked
├── 2. Rate limit check (10/min per IP)
├── 3. KYC gate — verify user.kyc_status === 'approved'
├── 4. Validate request body
│      ├── amount: 100–50,000 NOK, max 2 decimal places
│      └── recipientId: must belong to current user
├── 5. Load recipient → extract currency (e.g., RSD)
├── 6. Look up exchange rate for currency
├── 7. Calculate fee (0.5% of amount, rounded to 2 decimals)
├── 8. Load bank account (bankAccountId or primary)
├── 9. Verify balance >= (amount + fee)
├── 10. ATOMIC DATABASE TRANSACTION:
│      ├── Debit bank_accounts.balance by (amount + fee)
│      └── INSERT transaction record (status: 'processing')
└── 11. Return 201 with transaction details

3.2 Fee Calculation

const fee = Math.round(amount * 0.005 * 100) / 100;  // 0.5%, 2 decimal places
const total = amount + fee;
const receiveAmount = Math.round(amount * exchangeRate * 100) / 100;

3.3 ETA Logic

Recipient country ETA
EEA countries "1-2 business days"
Non-EEA countries "2-4 business days"

Note: Serbia, Bosnia are non-EEA. Poland is EEA. Pakistan, Turkey are non-EEA.

3.4 Transaction Status Flow

processing → completed (when PISP provider confirms settlement)
processing → failed (on PISP rejection or bank rejection)

Current implementation: Status starts as processing. Settlement tracking (webhooks from PISP provider) is pending — requires Open Banking provider integration.


4. QR Payment Service Design

4.1 Flow

POST /api/transactions/qr-payment
│
├── 1. requireAuth() — verify JWT + session
├── 2. Rate limit check (10/min per IP)
├── 3. Validate request body
│      ├── merchantId: must exist
│      └── amount: 1–100,000 NOK, max 2 decimal places
├── 4. Load merchant
├── 5. Get user's primary bank account
├── 6. Calculate fee (1% of amount)
├── 7. Verify balance >= (amount + fee)
├── 8. ATOMIC DATABASE TRANSACTION:
│      ├── Debit bank_accounts.balance by (amount + fee)
│      └── INSERT transaction record (status: 'completed')
└── 9. Return 201 with transaction details

4.2 Fee Calculation

const fee = Math.round(amount * 0.01 * 100) / 100;  // 1%, 2 decimal places

4.3 QR Code Format

Merchant QR codes encode: drop://pay/{merchantId}

The mobile app scans this URI, extracts merchantId, and pre-fills the QR payment form.


5. Pre-Payment Disclosure

Endpoint: POST /api/transactions/disclosure

The disclosure endpoint provides full fee transparency BEFORE a payment is initiated, complying with Finansavtaleloven requirements (users must see costs before confirming).

Response includes:


6. Database Operations

6.1 Atomic Transaction Pattern

All payment operations use database transactions to ensure atomicity:

BEGIN;
  UPDATE bank_accounts
    SET balance = balance - $1
    WHERE id = $2 AND user_id = $3 AND balance >= $1;

  INSERT INTO transactions (id, user_id, type, status, send_amount, ...)
    VALUES ($1, $2, 'remittance', 'processing', ...);
COMMIT;

If either operation fails, the entire transaction rolls back — preventing partial state (debit without record, or record without debit).

6.2 Balance Check

Balance is checked atomically in the UPDATE statement (WHERE balance >= required_amount). If the UPDATE affects 0 rows, the transaction fails with insufficient_balance error.

6.3 Key Queries

-- Get transaction with exchange rate detail
SELECT t.*, r.name as recipient_name, r.country as recipient_country,
       er.rate as exchange_rate
FROM transactions t
  LEFT JOIN recipients r ON t.recipient_id = r.id
  LEFT JOIN exchange_rates er ON er.currency = r.currency
WHERE t.id = $1 AND t.user_id = $2;

7. Validation Rules

Field Validation Rule
amount (remittance) validateAmount() 100–50,000 NOK, max 2 decimal places
amount (QR payment) validateAmount() 1–100,000 NOK, max 2 decimal places
recipientId ownership check Must exist in recipients table for current user
merchantId existence check Must exist in merchants table
bankAccountId ownership check Must exist in bank_accounts for current user

8. Error Handling

Error HTTP Status Code Trigger
Missing required fields 400 bad_request null/undefined required field
No bank account 400 no_bank_account User has no linked bank account
Insufficient balance 402 insufficient_balance balance < (amount + fee)
KYC not approved 403 kyc_required kyc_status !== 'approved'
Recipient not found 404 not_found Recipient doesn't belong to user
Unsupported currency 422 validation_error No exchange rate for currency
Rate limited 429 rate_limited > 10 req/min per IP

9. Audit Trail

Every transaction creates an audit log entry:

INSERT INTO audit_log (action, user_id, resource_type, resource_id, details)
VALUES ('transaction_created', $userId, 'transaction', $txId, $detailsJson);

AML monitoring: aml_alerts table is checked for high-value transactions (> NOK 100,000 equivalent per day, per regulatory requirements).


10. Future: Open Banking Integration (PISP)

Current implementation: balance is tracked in Drop's own database (bank_accounts.balance), debited atomically.

Target architecture (requires Open Banking provider):

  1. Initiate PISP payment at provider API
  2. User's actual bank account is debited (not Drop's DB record)
  3. Provider webhook confirms settlement
  4. Drop updates transaction status from processingcompleted/failed

AISP balance refresh: balance in bank_accounts should be refreshed via AISP API on each login or dashboard load.



Approval

Role Name Date Signature
Author Platform Architect (AI) 2026-02-23
Reviewer
Approver Alem Bašić

Middleware Design

Middleware Design Document

Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Middleware Pipeline Overview

sequenceDiagram
    participant Client
    participant CORS as 1. CORS
    participant Security as 2. Security Headers
    participant RequestID as 3. Request ID
    participant RateLimit as 4. Rate Limiter
    participant Logger as 5. Request Logger
    participant Auth as 6. Authentication
    participant Authz as 7. Authorization (RBAC)
    participant Validate as 8. Validation
    participant AuditLog as 9. Audit Logger
    participant Handler as Route Handler

    Client->>CORS: HTTP Request
    CORS->>Security: (CORS headers set)
    Security->>RequestID: (Security headers set)
    RequestID->>RateLimit: (X-Request-ID injected)
    RateLimit->>Logger: (Rate check passed)
    Logger->>Auth: (Request logged)
    Auth->>Authz: (JWT validated, user attached)
    Authz->>Validate: (Permissions verified)
    Validate->>AuditLog: (Input validated & sanitized)
    AuditLog->>Handler: (Audit record written)
    Handler-->>Client: Response

Framework: {{NestJS / Express / Fastify / Hono}} Execution order is strict — changing order may break security guarantees.


2. Request Lifecycle

2.1 CORS Middleware

Library: {{cors / @fastify/cors}}

Configuration:

// config/cors.config.ts
export const corsConfig = {
  origin: (origin: string, callback: Function) => {
    const allowedOrigins = [
      'https://app.{{domain.com}}',
      'https://admin.{{domain.com}}',
      ...(process.env.NODE_ENV !== 'production'
        ? ['http://localhost:3000', 'http://localhost:3001']
        : []),
    ];

    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Request-ID', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'],
  credentials: true,
  maxAge: 86400, // 24h preflight cache
};

Performance impact: < 0.1ms per request (header injection only)


2.2 Security Headers Middleware

Library: helmet

app.use(helmet({
  // Content Security Policy
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https://cdn.{{domain.com}}"],
      connectSrc: ["'self'", "https://api.{{domain.com}}"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
    },
  },
  // HTTP Strict Transport Security
  hsts: {
    maxAge: 31536000,       // 1 year
    includeSubDomains: true,
    preload: true,
  },
  // Other headers
  referrerPolicy: { policy: 'same-origin' },
  frameguard: { action: 'deny' },
  noSniff: true,            // X-Content-Type-Options: nosniff
  xssFilter: true,          // X-XSS-Protection (legacy browsers)
  hidePoweredBy: true,      // Remove X-Powered-By
}));

Headers set:

Header Value Purpose
Strict-Transport-Security max-age=31536000; includeSubDomains; preload Force HTTPS
Content-Security-Policy (see above) XSS prevention
X-Frame-Options DENY Clickjacking prevention
X-Content-Type-Options nosniff MIME sniffing prevention
Referrer-Policy same-origin Referrer privacy

Performance impact: < 0.2ms per request


2.3 Request ID Middleware

Purpose: Correlate logs across services for distributed tracing.

// middleware/request-id.middleware.ts
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers['x-request-id'] as string
    || `req_${ulid()}`;

  req.requestId = requestId;
  res.setHeader('X-Request-ID', requestId);

  // Bind to AsyncLocalStorage for log correlation
  requestContext.run({ requestId }, next);
}

Format: req_{ulid} — e.g., req_01HX7M2K5N3P4Q5R6S7T8V9W0


2.4 Authentication Middleware

Strategy: JWT Bearer token validation

// guards/jwt.guard.ts
@Injectable()
export class JwtGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractToken(request);

    if (!token) throw new UnauthorizedException('No token provided');

    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: this.configService.get('JWT_SECRET'),
        algorithms: ['HS256'],
        clockTolerance: 10, // 10 second clock skew tolerance
      });

      // Attach to request for downstream use
      request.user = {
        id: payload.sub,
        email: payload.email,
        role: payload.role,
      };

      return true;
    } catch (error) {
      if (error instanceof TokenExpiredError) {
        throw new UnauthorizedException('TOKEN_EXPIRED');
      }
      throw new UnauthorizedException('INVALID_TOKEN');
    }
  }

  private extractToken(request: Request): string | null {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : null;
  }
}

Performance impact: ~2-5ms (crypto operation + optional DB lookup for token revocation)

Token revocation check: {{Check Redis blocklist on each request | Check on logout only | Use short TTL — no revocation check}}


2.5 Authorization Middleware (RBAC/ABAC)

Model: {{RBAC (Role-Based) | ABAC (Attribute-Based) | Hybrid}}

// decorators/roles.decorator.ts
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
export const RequirePermission = (permission: string) =>
  SetMetadata('permission', permission);

// guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get<Role[]>('roles', context.getHandler());
    const requiredPermission = this.reflector.get<string>('permission', context.getHandler());

    if (!requiredRoles && !requiredPermission) return true; // Public route

    const { user } = context.switchToHttp().getRequest();

    if (requiredRoles && !requiredRoles.includes(user.role)) {
      throw new ForbiddenException(`Requires role: ${requiredRoles.join(' or ')}`);
    }

    if (requiredPermission && !this.hasPermission(user, requiredPermission)) {
      throw new ForbiddenException(`Requires permission: ${requiredPermission}`);
    }

    return true;
  }
}

// Usage on controller
@Get('users')
@Roles(Role.ADMIN)
@RequirePermission('users:read')
async listUsers() { ... }

Role hierarchy:

admin > manager > user > viewer > public
Role Capabilities
admin Full access
manager Read/write own org resources
user Read/write own resources
viewer Read-only

2.6 Validation Middleware

Library: class-validator + class-transformer OR zod

// Global validation pipe (NestJS)
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // Strip unknown properties
  forbidNonWhitelisted: true,  // Throw if unknown properties present
  transform: true,              // Auto-transform to DTO types
  transformOptions: {
    enableImplicitConversion: true,
  },
}));

Sanitization rules:

Validation error format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "field": "email", "message": "must be an email" },
      { "field": "name", "message": "must be longer than 2 characters" }
    ]
  }
}

Performance impact: < 1ms for typical DTOs


2.7 Rate Limiting Middleware

Library: {{@nestjs/throttler | express-rate-limit | rate-limiter-flexible}} Storage: {{Redis}} (shared across all replicas)

Algorithms:

Algorithm Library Best For
Fixed window express-rate-limit Simple, low overhead
Sliding window rate-limiter-flexible Accurate, no burst at window edge
Token bucket rate-limiter-flexible Bursty traffic patterns

Selected algorithm: {{Sliding window}}

Configuration:

const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl',
  points: 1000,         // Number of points
  duration: 60,         // Per 60 seconds
  blockDuration: 60,    // Block for 60s after exceeded
});

// Per-route overrides
const loginLimiter = new RateLimiterRedis({
  points: 5,
  duration: 900,        // 15 minutes
  blockDuration: 900,
});

Key strategy: {{IP address | User ID (if authenticated) | IP + User ID}}

Performance impact: ~1-2ms (Redis round-trip)


2.8 Audit Logging Middleware

// interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, path, user, requestId } = request;

    // Only audit mutating operations
    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
      this.auditService.log({
        requestId,
        userId: user?.id,
        method,
        path,
        body: this.sanitizeBody(request.body), // Strip PII
        timestamp: new Date().toISOString(),
      });
    }

    return next.handle();
  }

  private sanitizeBody(body: Record<string, unknown>) {
    const REDACTED_FIELDS = ['password', 'token', 'creditCard', 'ssn'];
    return Object.fromEntries(
      Object.entries(body).map(([key, value]) =>
        REDACTED_FIELDS.includes(key) ? [key, '[REDACTED]'] : [key, value]
      )
    );
  }
}

What IS logged:

What is NEVER logged:

Audit log retention: {{1 year}} (compliance requirement: {{GDPR / SOC2 / internal}})


2.9 Error Handling Middleware

// filters/global-exception.filter.ts
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = 500;
    let code = 'INTERNAL_ERROR';
    let message = 'An unexpected error occurred';
    let details: unknown[] = [];

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse() as any;
      code = exceptionResponse.code ?? 'HTTP_ERROR';
      message = exceptionResponse.message ?? exception.message;
      details = exceptionResponse.details ?? [];
    }

    // Log 5xx errors (not 4xx — those are client errors)
    if (status >= 500) {
      this.logger.error('Unhandled exception', { exception, requestId: request.requestId });
      this.sentryService.captureException(exception);
    }

    response.status(status).json({
      error: {
        code,
        message,
        details,
        requestId: request.requestId,
        timestamp: new Date().toISOString(),
      },
    });
  }
}

3. Custom Middleware Development Guide

Template for new middleware:

// middleware/{{name}}.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class {{Name}}Middleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    // 1. Extract needed data from request
    // 2. Perform validation/enrichment/logging
    // 3. Set data on request object if needed
    // 4. Call next() or throw HttpException

    next();
  }
}

Requirements for new middleware:


4. Middleware Ordering & Dependencies

Request → [1] → [2] → [3] → [4] → [5] → [6] → [7] → [8] → [9] → Handler

[1] CORS          — No dependencies
[2] Security      — No dependencies
[3] Request ID    — Must be before Logger (Logger reads requestId)
[4] Rate Limiter  — Must be after Request ID (uses requestId for key)
[5] Logger        — Must be after Request ID
[6] Auth          — Must be after Logger (Logger should log auth failures)
[7] Authorization — MUST be after Auth (requires user on request)
[8] Validation    — MUST be after Auth (DTOs may reference user context)
[9] Audit Logger  — MUST be after Auth (logs user ID)

NEVER reorder middleware without reviewing this dependency chain.


5. Performance Impact Per Middleware

Middleware Avg Latency Added P99 Latency Added Notes
CORS 0.05ms 0.1ms Header injection only
Security Headers (Helmet) 0.1ms 0.2ms Header injection only
Request ID 0.1ms 0.2ms ID generation
Rate Limiter 1.5ms 5ms Redis round-trip
Request Logger 0.5ms 1ms Async log write
Authentication (JWT) 3ms 8ms Crypto + optional Redis
Authorization 0.5ms 1ms In-memory role check
Validation 0.8ms 2ms Schema parsing
Audit Logger 0.5ms 1ms Async DB write
Total ~7ms ~18ms Middleware overhead

Target: middleware overhead < 10ms P50, < 25ms P99.


6. Testing Strategy for Middleware

// Example unit test for Auth middleware
describe('JwtGuard', () => {
  it('should attach user to request on valid token', async () => {
    const token = generateTestToken({ sub: 'usr_123', role: 'user' });
    const mockRequest = { headers: { authorization: `Bearer ${token}` } };
    const result = await guard.canActivate(createMockContext(mockRequest));
    expect(result).toBe(true);
    expect(mockRequest.user).toMatchObject({ id: 'usr_123', role: 'user' });
  });

  it('should throw UnauthorizedException on expired token', async () => {
    const expiredToken = generateExpiredToken();
    const mockRequest = { headers: { authorization: `Bearer ${expiredToken}` } };
    await expect(guard.canActivate(createMockContext(mockRequest)))
      .rejects.toThrow('TOKEN_EXPIRED');
  });
});

Test coverage requirements:


7. Configuration Options Per Middleware

Middleware Environment Variable Default Description
CORS CORS_ORIGINS localhost:3000 Comma-separated allowed origins
Rate Limit RATE_LIMIT_POINTS 1000 Requests per window
Rate Limit RATE_LIMIT_DURATION 60 Window size in seconds
Rate Limit (auth) AUTH_RATE_LIMIT_POINTS 5 Login attempts per window
Audit Log AUDIT_LOG_RETENTION_DAYS 365 How long to keep audit records
Request Body MAX_REQUEST_BODY_SIZE 1mb Max request body size

Approval

Role Name Date Signature
Author
Backend Lead
Security Lead
Tech Lead

External Services Integration

External Services Integration

Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Integration Inventory

Service Category Criticality SLA Owner Team Status
{{Stripe}} Payments Critical 99.99% {{Backend}} {{Active}}
{{SendGrid}} Email delivery High 99.95% {{Backend}} {{Active}}
{{Twilio}} SMS Medium 99.95% {{Backend}} {{Active}}
{{AWS S3}} File storage High 99.99% {{Infrastructure}} {{Active}}
{{Sentry}} Error tracking Low {{DevOps}} {{Active}}
{{Google Maps API}} Geocoding Medium 99.9% {{Backend}} {{Active}}
{{NAME}} {{Category}} {{Critical/High/Medium/Low}} {{X.XX%}} {{Team}} {{Status}}

Criticality definitions:


2. Per-Service Integration


2.1 Stripe (Payments)

Property Value
Purpose Payment processing, subscription billing
API docs https://stripe.com/docs/api
Auth method Secret key (Bearer token)
Credentials Vault: stripe/secret-key-{{env}}
Webhook secret Vault: stripe/webhook-secret-{{env}}
SDK stripe npm package v14.x
API version 2023-10-16 (pinned)

Key endpoints used:

Operation Stripe API Notes
Create customer POST /v1/customers On user registration
Create payment intent POST /v1/payment_intents Checkout flow
Confirm payment POST /v1/payment_intents/:id/confirm After client confirms
Create subscription POST /v1/subscriptions Subscription plans
Cancel subscription DELETE /v1/subscriptions/:id User-initiated cancel

Request example:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 1000, // in cents
  currency: 'nok',
  customer: customer.stripeId,
  metadata: { orderId, userId },
  automatic_payment_methods: { enabled: true },
});

Error handling:

try {
  await stripe.paymentIntents.create(params);
} catch (err) {
  if (err instanceof Stripe.errors.StripeCardError) {
    throw new PaymentDeclinedException(err.message);
  }
  if (err instanceof Stripe.errors.StripeRateLimitError) {
    throw new ServiceTemporarilyUnavailableException('Payment service rate limited');
  }
  // Log unexpected errors to Sentry
  this.sentry.captureException(err);
  throw new PaymentServiceException('Unexpected payment error');
}

Retry policy: Stripe SDK handles retries on network errors automatically. Business-level failures (card declined) are NOT retried.

Circuit breaker: {{Yes — breaker trips after 5 consecutive failures, opens for 30s}}

Fallback: No fallback for payments — fail clearly with user-facing error message.

Webhooks consumed:

Event Handler Action
payment_intent.succeeded PaymentSucceededHandler Mark order paid
payment_intent.payment_failed PaymentFailedHandler Notify user, release hold
customer.subscription.deleted SubscriptionCancelledHandler Downgrade user

Rate limits: 100 read requests/s, 100 write requests/s per secret key. Cost: Per transaction (see Finance: Stripe billing dashboard).


2.2 SendGrid (Email)

Property Value
Purpose Transactional email delivery
API docs https://docs.sendgrid.com/api-reference
Auth method API key (Authorization: Bearer)
Credentials Vault: sendgrid/api-key-{{env}}
SDK @sendgrid/mail npm package v8.x
From email {{noreply@domain.com}} (verified sender)

Key operations:

Operation Template Trigger
Welcome email d-XXXX User registration
Password reset d-XXXX Forgot password flow
Order confirmation d-XXXX Order placed
Invoice d-XXXX Invoice generated

Request example:

await sgMail.send({
  to: user.email,
  from: { email: 'noreply@domain.com', name: '{{APP_NAME}}' },
  templateId: 'd-XXXXXXXXXXXXXXXXXXXXXX',
  dynamicTemplateData: {
    firstName: user.name.split(' ')[0],
    orderNumber: order.number,
    orderTotal: formatCurrency(order.total),
  },
});

Error handling:

try {
  await sgMail.send(message);
} catch (err) {
  if (err.code === 429) {
    // Queue for retry
    await this.emailQueue.add('retry_email', message, { delay: 60000 });
  } else {
    this.logger.error('SendGrid error', { code: err.code, message: err.message });
    // Don't throw — email failure is non-critical for most flows
  }
}

Retry policy: 3 retries via BullMQ queue with 60s, 300s, 900s backoff. Fallback: {{Postmark as backup SMTP | Log and alert team — no fallback}} Rate limits: 100 emails/s on Pro plan.


2.3 AWS S3 (File Storage)

Property Value
Purpose User file uploads, generated reports, media
Auth method IAM Role (EC2/ECS) or AWS Access Key
Credentials IAM role (preferred) / Vault: aws/s3-access-key-{{env}}
SDK @aws-sdk/client-s3 v3.x
Buckets See table below

Bucket configuration:

Bucket Access Lifecycle Purpose
{{company}}-uploads-{{env}} Private 90 day expiry for tmp User uploads
{{company}}-exports-{{env}} Private 7 day expiry Generated exports/reports
{{company}}-public-{{env}} Public (CDN) None Marketing assets, public images

Pre-signed URL pattern:

const command = new PutObjectCommand({
  Bucket: process.env.S3_UPLOADS_BUCKET,
  Key: `${userId}/${ulid()}.${extension}`,
  ContentType: mimeType,
  ContentLength: fileSize,
});

const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 });

Retry policy: AWS SDK retries with exponential backoff by default (max 3 retries). Circuit breaker: Breaker trips after 10 consecutive failures. Fallback: {{Cloudflare R2 as fallback storage | Abort upload with user error}}


2.4 {{SERVICE_NAME}}

Property Value
Purpose {{PURPOSE}}
API docs {{URL}}
Auth method {{API Key / OAuth2 / Basic Auth}}
Credentials Vault: {{vault/path}}
SDK {{package@version or "Direct HTTP"}}

Key endpoints used:

Operation Endpoint Notes
{{Operation}} {{Method}} {{/path}} {{Notes}}

Request example:

// TODO: Add representative request example

Error handling:

// TODO: Define error handling strategy

Retry policy: {{Exponential backoff: 1s, 2s, 4s, max 3 retries}} Circuit breaker: {{Yes/No — threshold: X failures in Y seconds}} Fallback / degradation: {{Define fallback behavior}} Rate limits: {{X requests per Y}} Cost: {{Pricing model reference}} Monitoring: {{Alert name and dashboard link}}


3. SDK vs Direct API Call Decisions

Service Approach Rationale
Stripe SDK SDK handles retry logic, type safety, webhook verification
SendGrid SDK SDK simplifies template rendering, attachment handling
AWS S3 SDK v3 Modular SDK reduces bundle size; handles signing
{{Service}} Direct HTTP No official SDK, lightweight wrapper sufficient
{{Service}} SDK {{Reason}}

Wrapper pattern — all integrations are wrapped in a service class:

// services/stripe.service.ts — abstraction over Stripe SDK
@Injectable()
export class StripeService {
  // Exposes only operations the app actually needs
  // Hides Stripe-specific implementation details
  // Makes testing easier (injectable, mockable)
  async createPaymentIntent(amount: number, currency: string): Promise<PaymentIntent> { ... }
}

4. Mock / Stub Strategy for Development & Testing

Environment Strategy
Unit tests Jest manual mocks (__mocks__/stripe.ts)
Integration tests Nock HTTP interceptors OR test-mode credentials
Local development Test API keys (Stripe test mode, SendGrid sandbox)
E2E / staging Live test-mode credentials — real API calls to sandbox
Production Live production credentials

Mock setup example:

// __mocks__/@sendgrid/mail.ts
const sendMock = jest.fn().mockResolvedValue([{ statusCode: 202 }]);
export default { send: sendMock, setApiKey: jest.fn() };

// In tests
import sgMail from '@sendgrid/mail';
expect(sgMail.send).toHaveBeenCalledWith(expect.objectContaining({
  templateId: 'd-XXXXX',
}));

Test mode credentials location: .env.test (gitignored) — see onboarding guide.


5. Vendor Lock-In Assessment

Service Lock-in Level Switching Cost Migration Complexity
Stripe Medium High (webhook events, customer IDs) {{2-4 weeks}}
SendGrid Low Low (standard SMTP + template export) {{1-2 days}}
AWS S3 Medium Medium (URL changes, S3-compatible APIs) {{1 week}}

Mitigation strategy: All integrations wrapped in service classes with defined interfaces. Swapping provider = rewrite service class, not application logic.


6. Migration Plan (Switching Providers)

Stripe → Alternative Payment Provider

Trigger conditions: Pricing increase > 30%, reliability < 99.9%, compliance issues.

Migration steps:

  1. Select alternative (Adyen, Braintree, etc.) and obtain test credentials
  2. Implement new PaymentService adapter behind feature flag
  3. Test in staging with full payment flow
  4. Migrate new customers to new provider
  5. Migrate existing subscription customers (requires customer consent in some jurisdictions)
  6. Deprecate Stripe integration (keep webhooks active until all subscriptions migrated)

Data to migrate: Customer IDs (map old → new), subscription IDs.


Approval

Role Name Date Signature
Author
Backend Lead
Finance / Legal (payment integrations)
Security Reviewer

Event Schema Documentation

Event Schema Documentation

Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Event-Driven Architecture Overview

graph LR
    subgraph "Publishers"
        UserService["user-service"]
        OrderService["order-service"]
        PaymentService["payment-service"]
    end

    subgraph "Message Broker"
        Broker["{{Kafka / RabbitMQ / AWS SQS + SNS}}"]
    end

    subgraph "Consumers"
        NotifService["notification-service"]
        AnalyticsService["analytics-service"]
        SearchService["search-service"]
        AuditService["audit-service"]
    end

    UserService -->|user.* events| Broker
    OrderService -->|order.* events| Broker
    PaymentService -->|payment.* events| Broker

    Broker -->|filtered| NotifService
    Broker -->|all events| AnalyticsService
    Broker -->|entity events| SearchService
    Broker -->|all events| AuditService

Event-driven use cases in this system:


2. Message Broker Configuration

Broker: {{Apache Kafka | RabbitMQ | AWS SQS/SNS | NATS | Upstash Kafka}} Version: {{3.x}} Hosting: {{Confluent Cloud / self-hosted / AWS MSK}}

Topic / Queue Naming Convention

{{DOMAIN}}.{{ENTITY}}.{{ACTION}}

Examples:
  user.user.created
  order.order.status_changed
  payment.invoice.generated
  notification.email.sent

Pattern rules:

Topic Configuration

Topic Partitions Replication Retention Compaction
user.user.* 6 3 7 days No
order.order.* 12 3 30 days No
payment.invoice.* 6 3 90 days No
*.*.deleted 6 3 30 days Log compaction

3. Event Naming Conventions

Component Rule Examples
Full event type {domain}.{entity}.{action} user.user.created
Domain Lowercase, matches service prefix user, order, payment
Entity Singular noun, lowercase with underscores user, order_item, invoice
Action Past-tense verb, lowercase with underscores created, updated, status_changed, payment_failed

Do NOT use:


4. Event Envelope Format (CloudEvents 1.0)

{
  "specversion": "1.0",
  "type": "{{DOMAIN}}.{{ENTITY}}.{{ACTION}}",
  "source": "{{SERVICE_NAME}}",
  "id": "evt_01HX7M2K5N3P4Q5R6S7T8V9W0",
  "time": "2024-01-15T10:30:00.000Z",
  "datacontenttype": "application/json",
  "subject": "{{optional: entity ID}}",
  "data": {
    "{{field}}": "{{value}}"
  }
}

Envelope fields:

Field Type Required Description
specversion string Yes Always "1.0"
type string Yes Event type (see naming convention)
source string Yes Emitting service name
id string Yes Unique event ID (ULID format)
time string Yes ISO 8601 timestamp (UTC)
datacontenttype string Yes Always "application/json"
subject string No Primary entity ID (for routing)
data object Yes Domain-specific event payload

TypeScript interface:

interface CloudEvent<T = Record<string, unknown>> {
  specversion: '1.0';
  type: string;
  source: string;
  id: string;
  time: string;
  datacontenttype: 'application/json';
  subject?: string;
  data: T;
}

5. Per-Event Documentation


5.1 User Service Events

user.user.created

Published when a new user account is created.

Property Value
Publisher user-service
Consumers notification-service, analytics-service, audit-service
Topic user.user.created
Ordering guarantee Per user ID (partitioned by subject)
Idempotency key id (event ID) — consumers must deduplicate
Retry behavior Consumer retries up to 5x before DLQ

Payload schema (JSON Schema):

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["userId", "email", "name", "role", "createdAt"],
  "properties": {
    "userId": { "type": "string", "format": "uuid" },
    "email": { "type": "string", "format": "email" },
    "name": { "type": "string", "minLength": 1 },
    "role": { "type": "string", "enum": ["admin", "user", "viewer"] },
    "createdAt": { "type": "string", "format": "date-time" }
  }
}

Example event:

{
  "specversion": "1.0",
  "type": "user.user.created",
  "source": "user-service",
  "id": "evt_01HX7M2K5N3P4Q5R6S7T8V9W0",
  "time": "2024-01-15T10:30:00.000Z",
  "datacontenttype": "application/json",
  "subject": "usr_01HX7...",
  "data": {
    "userId": "usr_01HX7...",
    "email": "newuser@example.com",
    "name": "Jane Doe",
    "role": "user",
    "createdAt": "2024-01-15T10:30:00.000Z"
  }
}

user.user.updated

Published when user profile data changes.

Property Value
Publisher user-service
Consumers search-service, notification-service, analytics-service
Topic user.user.updated
Ordering guarantee Per user ID
Idempotency key id (event ID)

Payload schema:

{
  "type": "object",
  "required": ["userId", "updatedFields", "updatedAt"],
  "properties": {
    "userId": { "type": "string" },
    "updatedFields": {
      "type": "array",
      "items": { "type": "string" },
      "description": "List of field names that changed"
    },
    "before": { "type": "object", "description": "Previous values (only changed fields)" },
    "after": { "type": "object", "description": "New values (only changed fields)" },
    "updatedAt": { "type": "string", "format": "date-time" }
  }
}

Example event:

{
  "specversion": "1.0",
  "type": "user.user.updated",
  "source": "user-service",
  "id": "evt_01HX8...",
  "time": "2024-01-16T08:00:00.000Z",
  "datacontenttype": "application/json",
  "subject": "usr_01HX7...",
  "data": {
    "userId": "usr_01HX7...",
    "updatedFields": ["name"],
    "before": { "name": "Jane Doe" },
    "after": { "name": "Jane Smith" },
    "updatedAt": "2024-01-16T08:00:00.000Z"
  }
}

user.user.deleted

Published when a user account is soft-deleted.

Property Value
Publisher user-service
Consumers order-service, notification-service, analytics-service
Payload { userId, deletedAt, reason }
Ordering guarantee Per user ID

Example event:

{
  "specversion": "1.0",
  "type": "user.user.deleted",
  "source": "user-service",
  "id": "evt_01HX9...",
  "time": "2024-01-17T12:00:00.000Z",
  "datacontenttype": "application/json",
  "subject": "usr_01HX7...",
  "data": {
    "userId": "usr_01HX7...",
    "deletedAt": "2024-01-17T12:00:00.000Z",
    "reason": "user_requested"
  }
}

5.2 Order Service Events

order.order.created

Property Value
Publisher order-service
Consumers payment-service, notification-service, inventory-service
Topic order.order.created
Ordering guarantee Per order ID

Payload schema:

{
  "type": "object",
  "required": ["orderId", "userId", "items", "total", "currency", "createdAt"],
  "properties": {
    "orderId": { "type": "string" },
    "userId": { "type": "string" },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "productId": { "type": "string" },
          "quantity": { "type": "integer" },
          "unitPrice": { "type": "number" }
        }
      }
    },
    "total": { "type": "number" },
    "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
    "createdAt": { "type": "string", "format": "date-time" }
  }
}

5.3 {{DOMAIN}} Service Events

{{domain}}.{{entity}}.{{action}}

Property Value
Publisher {{service-name}}
Consumers {{consumer-a, consumer-b}}
Topic {{domain.entity.action}}
Ordering guarantee `{{Per entity ID
Idempotency key {{id}}

Payload schema: TODO: Define JSON Schema

Example event: TODO: Add example


6. Dead Letter Queue Handling

DLQ naming: {{topic}}.dlq — e.g., user.user.created.dlq

DLQ workflow:

Event Published
    ↓
Consumer processes
    ↓ Fails
Retry (exp. backoff: 1s, 2s, 4s, 8s, 16s — max 5 retries)
    ↓ All retries exhausted
Move to DLQ
    ↓
Alert fires: PagerDuty P3
    ↓
On-call investigates
    ↓
Option A: Fix consumer bug → replay from DLQ
Option B: Skip message (data was invalid) → log + discard

DLQ message format:

{
  "originalEvent": { /* original CloudEvent */ },
  "failureReason": "Consumer threw: Cannot read property 'id' of undefined",
  "attemptCount": 5,
  "firstFailedAt": "2024-01-15T10:30:00Z",
  "lastFailedAt": "2024-01-15T10:32:00Z",
  "consumerGroup": "notification-service-consumer"
}

DLQ retention: 14 days. DLQ alert threshold: > 10 messages in DLQ within 5 minutes.


7. Event Versioning Strategy

Strategy: Backward-compatible field addition + major version in event type.

Rules:

  1. Adding optional fields: allowed without version bump
  2. Removing fields: NOT allowed (use deprecation first, remove after all consumers updated)
  3. Changing field types: NOT allowed (breaking change)
  4. Adding required fields: requires version bump
  5. Major breaking change: new event type user.user.created.v2

Deprecation process:

1. Mark field as deprecated in schema docs
2. Notify all consumer teams
3. Wait 2 sprint cycles (4 weeks minimum)
4. Remove field from schema
5. Update documentation

Schema registry: {{Confluent Schema Registry | AWS Glue Schema Registry | Manual docs}} Validation: Consumer validates incoming events against pinned schema version.


8. Event Replay Capability

Replay supported: {{Yes — Kafka log retention | No — events are ephemeral}}

Replay scenarios:

Replay procedure:

  1. Identify topic and time range to replay
  2. Coordinate with all consumer teams (replay may cause duplicate side effects)
  3. Ensure consumers are idempotent before replay
  4. Set consumer offset to target timestamp: kafka-consumer-groups --reset-offsets --to-datetime
  5. Restart consumer with temporary consumer group to avoid affecting production offset
  6. Verify replayed state is correct
  7. Switch production consumer to new state

Retention periods by topic: See topic configuration table in Section 2.


9. Monitoring & Observability

Metric Alert Threshold Severity Channel
Consumer lag (per topic) > 10,000 messages P2 Slack #alerts
DLQ depth > 10 messages / 5min P3 Slack #alerts
Producer error rate > 1% / 5min P1 PagerDuty
Consumer error rate > 5% / 5min P2 PagerDuty
Event processing latency P99 > 5 seconds P3 Slack #alerts

Dashboard: {{https://monitoring.domain.com/dashboards/events}} Distributed tracing: All events carry traceparent header (OpenTelemetry W3C Trace Context).


10. Testing Event-Driven Flows

Unit Tests

// Test producer: verify event shape
it('should publish user.created event with correct schema', async () => {
  await userService.create(createUserDto);

  expect(eventBus.publish).toHaveBeenCalledWith(
    expect.objectContaining({
      type: 'user.user.created',
      source: 'user-service',
      data: expect.objectContaining({
        userId: expect.any(String),
        email: createUserDto.email,
      }),
    })
  );
});

// Test consumer: verify handler idempotency
it('should not send welcome email twice for duplicate event', async () => {
  const event = buildUserCreatedEvent();
  await handler.handle(event);
  await handler.handle(event); // duplicate
  expect(emailService.send).toHaveBeenCalledTimes(1);
});

Integration Tests

// Use real broker in integration tests (testcontainers)
const kafka = await new KafkaContainer('confluentinc/cp-kafka:7.5.0').start();

E2E Tests

Test full event chain: API action → event published → consumer processes → side effect visible.

POST /users → poll for welcome email (SendGrid sandbox) → assert received within 5s

Approval

Role Name Date Signature
Author
Backend Lead
Platform / Infrastructure Lead
Architect

Backend Architecture

Backend Architecture Document

Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Architecture Pattern

Pattern: {{Modular Monolith | Microservices | Monolith | Event-Driven Microservices}}

Pattern Considered Pros Cons Decision
Monolith Simple deploy, low latency Scaling bottleneck, team coupling {{Selected/Rejected}}
Modular Monolith Organized, single deploy, module isolation Shared DB risk {{Selected/Rejected}}
Microservices Independent scaling, team autonomy Operational complexity {{Selected/Rejected}}

Rationale:

TODO: 3-5 sentences explaining the decision considering team size, scale requirements, and operational maturity.


2. Technology Stack

Layer Technology Version Notes
Runtime {{Node.js}} {{20.x LTS}}
Framework {{NestJS / Express / Fastify / Hono}} {{10.x}}
ORM {{Prisma / TypeORM / Drizzle}} {{5.x}}
Primary DB {{PostgreSQL}} {{16.x}} Managed: {{RDS / Supabase}}
Cache {{Redis}} {{7.x}} Managed: {{ElastiCache / Upstash}}
Queue {{BullMQ / SQS / RabbitMQ}} {{5.x}}
Search {{Elasticsearch / MeiliSearch / Typesense}} {{8.x}} Optional
File storage {{AWS S3 / Cloudflare R2}} API
Auth {{Custom JWT / Auth0 / Supabase Auth}}
Logging {{Pino / Winston}} {{8.x}} {{Datadog / Loki}}
APM {{Datadog / Sentry / Elastic APM}}
API docs {{Swagger / OpenAPI 3.1}} {{3.1}}

3. Project Structure

src/
├── modules/                # Feature modules (NestJS) / route handlers (Express)
│   ├── users/
│   │   ├── users.module.ts
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   ├── users.repository.ts
│   │   ├── dto/
│   │   │   ├── create-user.dto.ts
│   │   │   └── update-user.dto.ts
│   │   └── entities/
│   │       └── user.entity.ts
│   ├── auth/
│   ├── notifications/
│   └── {{FEATURE}}/
├── common/
│   ├── decorators/         # Custom decorators
│   ├── filters/            # Exception filters
│   ├── guards/             # Auth / role guards
│   ├── interceptors/       # Logging, transform interceptors
│   ├── pipes/              # Validation pipes
│   └── middleware/         # Request middleware
├── database/
│   ├── migrations/
│   └── seeds/
├── config/
│   ├── app.config.ts
│   ├── database.config.ts
│   └── redis.config.ts
├── jobs/                   # Background job definitions
└── main.ts                 # Application entry point

test/
├── unit/
├── integration/
└── e2e/

4. Request Processing Pipeline

sequenceDiagram
    participant Client
    participant Gateway as API Gateway / LB
    participant Middleware as Middleware Stack
    participant Guard as Guards
    participant Pipe as Validation Pipe
    participant Controller
    participant Service
    participant Repository
    participant DB as Database

    Client->>Gateway: HTTP Request
    Gateway->>Middleware: Forward (with tracing headers)
    Middleware->>Middleware: Request ID, CORS, Security Headers, Rate Limit
    Middleware->>Guard: Authenticated request
    Guard->>Guard: JWT verification, Role check
    Guard->>Pipe: Authorized request
    Pipe->>Pipe: Schema validation (Zod/class-validator)
    Pipe->>Controller: Validated DTO
    Controller->>Service: Business method call
    Service->>Repository: Data access call
    Repository->>DB: Query
    DB-->>Repository: Result
    Repository-->>Service: Domain entity
    Service-->>Controller: Response data
    Controller-->>Client: HTTP Response (transformed)

5. Middleware Stack Configuration

Execution order (applied left-to-right):

Order Middleware Purpose Global?
1 helmet Security headers (CSP, HSTS, etc.) Yes
2 cors CORS policy enforcement Yes
3 request-id Inject X-Request-ID header Yes
4 compression gzip response compression Yes
5 body-parser Parse JSON/urlencoded bodies Yes
6 rate-limiter IP-based rate limiting (Redis) Yes
7 request-logger Structured request logging Yes
8 Route-specific middleware Auth, validation per route No

Security headers configured via Helmet:

app.use(helmet({
  contentSecurityPolicy: { /* ... */ },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'same-origin' },
}));

6. Authentication & Authorization Flow

flowchart TD
    Login["POST /auth/login\n{email, password}"] --> ValidateCreds["Validate credentials\n(bcrypt compare)"]
    ValidateCreds -->|Invalid| Reject["401 Unauthorized"]
    ValidateCreds -->|Valid| IssuePair["Issue token pair\naccess (15m) + refresh (30d)"]
    IssuePair --> StoreRefresh["Store refresh token\n(hashed, Redis or DB)"]
    IssuePair --> ReturnTokens["Return tokens to client"]

    AuthReq["Authenticated Request"] --> ExtractJWT["Extract Bearer token"]
    ExtractJWT --> VerifyJWT["Verify signature + expiry"]
    VerifyJWT -->|Invalid/Expired| RefreshFlow["POST /auth/refresh"]
    VerifyJWT -->|Valid| CheckRoles["Role/permission check"]
    CheckRoles -->|Unauthorized| Forbidden["403 Forbidden"]
    CheckRoles -->|Authorized| Handler["Route Handler"]

    RefreshFlow --> VerifyRefresh["Verify refresh token\n(hash match, not revoked)"]
    VerifyRefresh -->|Invalid| Logout["Force logout → 401"]
    VerifyRefresh -->|Valid| RotateToken["Rotate tokens\n(old token revoked)"]

RBAC / ABAC:


7. Database Access Patterns

Pattern: {{Repository Pattern}}

// Repository interface — decouples business logic from storage
interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findMany(filters: UserFilters): Promise<PaginatedResult<User>>;
  create(data: CreateUserData): Promise<User>;
  update(id: string, data: UpdateUserData): Promise<User>;
  delete(id: string): Promise<void>;
}

Query performance rules:


8. Caching Architecture

graph LR
    Request --> L1["L1: In-Memory\n(node-cache)"]
    L1 -->|Cache miss| L2["L2: Redis\n(shared, distributed)"]
    L2 -->|Cache miss| DB["Database"]

    DB --> L2
    L2 --> L1
    L1 --> Response
Layer Technology TTL What's Cached
L1 (in-process) node-cache / Map 30 sec Config, feature flags
L2 (distributed) Redis Per resource User sessions, API responses
L3 (CDN edge) Cloudflare / CloudFront Per route Public API responses

Cache-aside pattern (L2):

async function getCachedUser(id: string): Promise<User> {
  const cacheKey = `user:${id}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await userRepository.findById(id);
  await redis.setex(cacheKey, 300, JSON.stringify(user)); // 5 min TTL
  return user;
}

Cache invalidation strategy:


9. Background Job Processing

Library: {{BullMQ | Agenda | pg-boss}} Queue storage: {{Redis | PostgreSQL}}

Queue Job Types Concurrency Retry Policy
emails Welcome, reset password, notifications 10 3 retries, exp. backoff
uploads Image processing, file conversion 5 2 retries
sync External API sync, data aggregation 3 5 retries
reports PDF generation, exports 2 1 retry

Job schema:

interface EmailJob {
  type: 'welcome' | 'password_reset' | 'notification';
  to: string;
  templateId: string;
  data: Record<string, unknown>;
}

Monitoring: {{Bull Board | BullMQ Metrics API}} — admin UI at {{/admin/queues}}


10. File Storage & Media Handling

Storage provider: {{AWS S3 | Cloudflare R2 | MinIO}} Bucket naming: {{company-project-env}} (e.g., alai-app-production)

Upload flow:

  1. Client requests pre-signed URL from API (POST /uploads/presigned)
  2. API validates file type, size, generates pre-signed URL (expiry: 15 min)
  3. Client uploads directly to storage (bypasses API server)
  4. Client notifies API of upload completion (POST /uploads/confirm)
  5. API validates file exists, creates database record, triggers processing job

File size limits:

Type Max Size
Profile images 5 MB
Documents 25 MB
Videos 500 MB

11. Logging & Observability

Logger: {{Pino}} — structured JSON logs Log aggregation: {{Datadog / Loki / CloudWatch}}

Log levels policy:

Level When to use
error Exceptions, failures requiring attention
warn Unexpected but handled situations
info Significant business events (user created, order placed)
debug Detailed diagnostic info — dev/staging only
trace Verbose request tracing — never in production

Always log:

Never log:


12. Configuration Management

Pattern: Typed config module with validation on startup

// config/app.config.ts
const schema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().default(4000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  // ...
});

export const config = schema.parse(process.env);
// App fails FAST at startup if any required variable is missing/invalid

Secrets management: {{HashiCorp Vault | AWS Secrets Manager | Doppler}} NO secrets in environment files committed to git.


13. Health Check Endpoints

Endpoint Type Checks
GET /health/live Liveness Process is running
GET /health/ready Readiness DB connected, Redis connected, app ready
GET /health/startup Startup Migrations run, config valid

Readiness check response:

{
  "status": "ok",
  "checks": {
    "database": { "status": "ok", "latency": 3 },
    "redis": { "status": "ok", "latency": 1 },
    "queue": { "status": "ok", "pendingJobs": 12 }
  },
  "version": "1.2.3",
  "uptime": 3600
}

14. Architecture Diagram

graph TB
    subgraph "Clients"
        Web["Web App"]
        Mobile["Mobile App"]
    end

    subgraph "Infrastructure"
        LB["Load Balancer\n(Nginx / ALB)"]
        API["API Server\n({{FRAMEWORK}})"]
        Workers["Background Workers\n(BullMQ)"]
    end

    subgraph "Data"
        DB["PostgreSQL\n(Primary + Replicas)"]
        Cache["Redis\n(Cache + Queue)"]
        Storage["Object Storage\n(S3 / R2)"]
    end

    subgraph "Observability"
        Logs["Log Aggregation\n(Datadog / Loki)"]
        APM["APM / Tracing"]
    end

    Web --> LB
    Mobile --> LB
    LB --> API
    API --> DB
    API --> Cache
    API --> Storage
    API --> Cache
    Workers --> Cache
    Workers --> DB
    API --> Logs
    Workers --> Logs
    API --> APM

Approval

Role Name Date Signature
Author
Backend Lead
Tech Lead / Architect
Security Reviewer

Service Design

Service Design Document

Project: {{PROJECT_NAME}} Service: {{SERVICE_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Service Overview

Property Value
Service name {{service-name}}
Bounded context {{Domain / bounded context name}}
Repository {{https://github.com/org/service-name}}
Owner team {{Team Name}}
On-call {{PagerDuty rotation / team contact}}
Runbook {{https://wiki.domain.com/runbooks/service-name}}
Tech stack {{Node.js 20 + NestJS + PostgreSQL + Redis}}

Purpose:

TODO: 2-3 sentences. What does this service do? What business capability does it own? What is explicitly OUT of scope?

Bounded context: This service owns the {{DOMAIN}} domain. It is the single source of truth for {{entities owned}}. Other services must NOT directly access this service's database — they must call its API or subscribe to its events.


2. Service Responsibility & Ownership

This service IS responsible for:

This service is NOT responsible for:

Data ownership:


3. Interface Definition

3.1 REST API Endpoints

Method Path Description Auth
GET /{{service}}/health Health check None
GET /{{service}}/{{resource}} List {{resources}} Bearer JWT
GET /{{service}}/{{resource}}/:id Get by ID Bearer JWT
POST /{{service}}/{{resource}} Create Bearer JWT
PATCH /{{service}}/{{resource}}/:id Update Bearer JWT
DELETE /{{service}}/{{resource}}/:id Delete Bearer JWT

Internal endpoints (service-to-service only, no external access):

Method Path Description Auth
GET /internal/{{resource}}/:id Bulk lookup by IDs Service API key

Full API reference: See api-reference.md or {{OpenAPI URL}}


3.2 gRPC Service Definition (if applicable)

// proto/{{service_name}}.proto
syntax = "proto3";

package {{service_name}};

service {{ServiceName}}Service {
  rpc Get{{Resource}} (Get{{Resource}}Request) returns ({{Resource}});
  rpc List{{Resources}} (List{{Resources}}Request) returns (List{{Resources}}Response);
  rpc Create{{Resource}} (Create{{Resource}}Request) returns ({{Resource}});
}

message {{Resource}} {
  string id = 1;
  string name = 2;
  string created_at = 3;
}

message Get{{Resource}}Request {
  string id = 1;
}

TODO: Remove or populate gRPC section based on actual communication protocol.


3.3 Events Published

Event Type Trigger Topic / Queue Consumer(s)
{{domain}}.{{entity}}.created Entity created {{topic-name}} {{service-a, service-b}}
{{domain}}.{{entity}}.updated Entity updated {{topic-name}} {{service-a}}
{{domain}}.{{entity}}.deleted Soft delete {{topic-name}} {{service-b}}

Example published event:

{
  "specversion": "1.0",
  "type": "{{domain}}.{{entity}}.created",
  "source": "{{service-name}}",
  "id": "evt_01HX7...",
  "time": "2024-01-15T10:30:00Z",
  "datacontenttype": "application/json",
  "data": {
    "id": "{{UUID}}",
    "{{field}}": "{{value}}"
  }
}

Full event schemas: See event-schema-documentation.md


3.4 Events Consumed

Event Type Source Service Handler Action
{{domain}}.{{entity}}.created {{source-service}} {{Action this service takes}}
{{domain}}.{{entity}}.deleted {{source-service}} {{Action — e.g., cascade delete}}

Consumer group: {{service-name}}-consumer Idempotency: All handlers are idempotent (duplicate events produce same result).


4. Database

4.1 Technology & Rationale

Property Value
Database {{PostgreSQL 16}}
ORM {{Prisma 5}}
Rationale {{Why this DB was chosen}}
Hosting {{AWS RDS / Supabase / Self-hosted}}
Replication {{1 primary + 2 read replicas}}
Backup {{Daily snapshot + WAL archiving}}
Encryption At rest and in transit

4.2 Schema Overview

erDiagram
    USERS {
        uuid id PK
        string email UK
        string name
        string role
        string status
        timestamp created_at
        timestamp updated_at
        timestamp deleted_at
    }

    USER_PROFILES {
        uuid id PK
        uuid user_id FK
        string avatar_url
        string bio
        jsonb settings
        timestamp updated_at
    }

    USER_SESSIONS {
        uuid id PK
        uuid user_id FK
        string refresh_token_hash
        string ip_address
        timestamp expires_at
        timestamp created_at
    }

    USERS ||--o| USER_PROFILES : has
    USERS ||--o{ USER_SESSIONS : has

TODO: Update schema to reflect actual tables. Add missing tables.


4.3 Data Ownership Boundaries

Cross-service data pattern:

Service A needs user name:
  → GET /users/:id via HTTP (NOT direct DB query)
  → Or subscribe to user.updated events and cache locally

5. Dependencies

5.1 Upstream Services (Services This Depends On)

Service Purpose Criticality Fallback
{{auth-service}} JWT validation Critical Cache valid tokens 5 min
{{notification-service}} Send emails Non-critical Queue for retry
{{{{EXTERNAL_API}}}} {{Purpose}} {{Critical/Non-critical}} {{Fallback strategy}}

5.2 Downstream Services (Services That Depend On This)

Service How it uses this service Impact if this service is down
{{order-service}} Validate user exists before creating order Cannot create orders
{{notification-service}} Resolve user email for delivery Cannot send user notifications

5.3 External APIs & Third-Party

Service Purpose Rate Limit Credentials
{{SendGrid}} Transactional email 100 req/s Vault: sendgrid/api-key
{{Stripe}} Payment processing Vault: stripe/secret-key

5.4 Dependency Diagram

graph LR
    ThisService["{{service-name}}"]

    subgraph "Upstream (depends on)"
        AuthService["auth-service"]
        ExternalAPI["external-api"]
    end

    subgraph "Downstream (depended on by)"
        OrderService["order-service"]
        NotifService["notification-service"]
    end

    AuthService --> ThisService
    ExternalAPI --> ThisService
    ThisService --> OrderService
    ThisService --> NotifService

6. Deployment Configuration

Property Dev Staging Production
Replicas 1 2 {{min: 3, max: 10}}
CPU request 100m 250m 500m
CPU limit 500m 1000m 2000m
Memory request 128Mi 256Mi 512Mi
Memory limit 512Mi 1Gi 2Gi
Port 4000 4000 4000

Kubernetes manifest location: {{k8s/{{service-name}}/}} Helm chart: {{charts/{{service-name}}/}} Docker image: {{registry.domain.com/service-name}}


7. Scaling Strategy

Dimension Strategy Trigger
Horizontal (replicas) HPA: CPU > 70% OR RPS > 1000 Automatic
Vertical (resources) VPA recommendations reviewed monthly Manual
Database Read replicas for SELECT queries Manual
Cache Redis Cluster when > 10GB RAM Manual

Stateless confirmation: This service stores NO session state in memory — safe to scale horizontally.


8. Health Check & Readiness Probes

livenessProbe:
  httpGet:
    path: /health/live
    port: 4000
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: 4000
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3

startupProbe:
  httpGet:
    path: /health/startup
    port: 4000
  failureThreshold: 30
  periodSeconds: 10

9. SLA Commitments

Metric Target Measurement Window
Availability 99.9% (8.7h downtime/year) Rolling 30 days
P50 response time < 50ms 1 hour
P95 response time < 200ms 1 hour
P99 response time < 500ms 1 hour
Error rate (5xx) < 0.1% 1 hour

SLA breach escalation: Alert → PagerDuty {{on-call rotation}} → Incident declared at SLA breach risk.


10. Monitoring & Alerting Rules

Metric Threshold Alert Severity Channel
Error rate (5xx) > 1% for 5 min P1 PagerDuty
P99 latency > 1s for 5 min P2 Slack #alerts
CPU utilization > 85% for 10 min P3 Slack #alerts
Memory utilization > 80% P3 Slack #alerts
DB connection pool > 80% P2 PagerDuty
Queue depth > 10,000 items P2 Slack #alerts

Dashboard: {{https://monitoring.domain.com/dashboards/service-name}}


11. Runbook Reference

Runbook location: {{https://wiki.domain.com/runbooks/{{service-name}}}}

Quick reference for common incidents:

Incident Initial Response
High error rate Check logs → identify error pattern → scale up if OOM
High latency Check DB slow query log → check Redis hit rate → check upstream dependency
Pod crash loop Check OOMKilled → check logs → check health probe thresholds
DB connection exhaustion Check pool config → check idle connections → force disconnect

Approval

Role Name Date Signature
Author
Service Owner
Architect
SRE Lead

Database Schema

Drop Database Schema

Source: src/shared/db/schema.ts (Drizzle ORM schema — single source of truth for all environments)

Overview

Drop uses PostgreSQL 16 as the sole database engine in all environments (development, CI, staging, production). Database access is via Drizzle ORM. There is no SQLite dependency and no dual-driver abstraction.

See ADR-014 for the full rationale.

Total tables: 19 (12 core + 7 compliance)


Tables

users

Primary user accounts.

Column Type Constraints Default
id TEXT PRIMARY KEY -
email TEXT UNIQUE NOT NULL -
password_hash TEXT NOT NULL -
first_name TEXT NOT NULL -
last_name TEXT NOT NULL -
phone TEXT - NULL
date_of_birth TEXT - NULL
kyc_status TEXT CHECK('pending','approved','rejected') 'pending'
role TEXT CHECK('user','merchant') 'user'
created_at TEXT - CURRENT_TIMESTAMP

ID format: usr_ + 16 hex chars (generated by randomId("usr") in utils-server.ts:4)

Password hashing: bcrypt with 12 rounds (utils-server.ts:8-11)


recipients

Saved remittance recipients per user.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
name TEXT NOT NULL -
country TEXT NOT NULL -
currency TEXT NOT NULL -
bank_account TEXT NOT NULL -
bank_name TEXT - NULL
created_at TEXT - CURRENT_TIMESTAMP

Supported countries: RS, BA, PL, PK, TR (enforced at API level, not DB level)

Index: idx_recipients_user on user_id


merchants

Registered merchant profiles for QR payments.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
business_name TEXT NOT NULL -
org_number TEXT UNIQUE NOT NULL -
address TEXT - NULL
bank_account TEXT NOT NULL -
fee_rate REAL - 0.01
status TEXT - 'active'
created_at TEXT - CURRENT_TIMESTAMP

Index: idx_merchants_org on org_number


transactions

All financial transactions (remittances and QR payments).

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
type TEXT NOT NULL, CHECK('remittance','qr_payment') -
status TEXT CHECK('processing','completed','failed') 'processing'
amount REAL NOT NULL -
currency TEXT - 'NOK'
fee REAL - 0
recipient_id TEXT FK → recipients(id) NULL
merchant_id TEXT FK → merchants(id) NULL
send_amount REAL - NULL
send_currency TEXT - NULL
receive_amount REAL - NULL
receive_currency TEXT - NULL
exchange_rate REAL - NULL
created_at TEXT - CURRENT_TIMESTAMP
completed_at TEXT - NULL

Indexes: idx_transactions_user on user_id, idx_transactions_merchant on merchant_id

Notes:


exchange_rates

Currency exchange rates (from NOK).

Column Type Constraints Default
id SERIAL PRIMARY KEY AUTOINCREMENT
from_currency TEXT - 'NOK'
to_currency TEXT NOT NULL -
rate REAL NOT NULL -
updated_at TEXT - CURRENT_TIMESTAMP

Seed data (apps/drop-api/src/lib/db.ts):

Corridor Rate
NOK → RSD 10.17
NOK → BAM 0.17
NOK → PLN 0.374
NOK → PKR 26.5
NOK → TRY 3.39
NOK → EUR 0.087

Live/provider refresh details are documented in Currency Rates.


bank_accounts

Linked bank accounts (AISP — Open Banking read in production, mock balances in dev).

Pass-through model: Drop NEVER holds customer money. The balance column stores the last AISP-read balance from the user's real bank account — it is a cached read-only value, not a Drop-held balance. In dev/demo mode, mock balances are seeded for testing.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
bank_name TEXT NOT NULL -
account_number TEXT NOT NULL -
iban TEXT - NULL
balance REAL - 0
currency TEXT - 'NOK'
is_primary INTEGER - 0
connected_at TEXT - CURRENT_TIMESTAMP

Index: idx_bank_accounts_user on user_id


cards (FUTURE — feature-flagged)

Note: Cards are a FUTURE feature, gated behind feature flags (all default to false). This table exists in the schema but is not actively used until a card issuing partner is integrated.

Virtual and physical payment cards.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
type TEXT CHECK('virtual','physical') 'virtual'
last_four TEXT NOT NULL -
token_ref TEXT - NULL
expiry TEXT NOT NULL -
status TEXT CHECK('active','frozen','cancelled') 'active'
shipping_address TEXT - NULL
created_at TEXT - CURRENT_TIMESTAMP
pin_hash TEXT - NULL (added via runtime migration in cards/[id]/pin/route.ts:51-53)

Index: idx_cards_user on user_id


sessions

JWT session tracking for revocation support.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
token_hash TEXT NOT NULL -
created_at TEXT - CURRENT_TIMESTAMP
expires_at TEXT NOT NULL -
revoked INTEGER - 0

Indexes: idx_sessions_user on user_id, idx_sessions_token on token_hash

Token hash: SHA-256 of the JWT string (auth.ts:59)


notifications

In-app notifications.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
type TEXT NOT NULL -
title TEXT NOT NULL -
body TEXT NOT NULL -
read INTEGER - 0
created_at TEXT - CURRENT_TIMESTAMP

Index: idx_notifications_user on user_id


settings

Per-user preferences.

Column Type Constraints Default
user_id TEXT PRIMARY KEY, FK → users(id) -
currency TEXT - 'NOK'
language TEXT - 'nb'
push_enabled INTEGER - 1
email_enabled INTEGER - 1
updated_at TEXT - CURRENT_TIMESTAMP

spending_limits (FUTURE — feature-flagged)

Note: Tied to the cards feature. Only active when card feature flags are enabled.

Card spending limits.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
card_id TEXT FK → cards(id) NULL
limit_type TEXT NOT NULL -
amount REAL NOT NULL -
currency TEXT - 'NOK'
created_at TEXT - CURRENT_TIMESTAMP

Indexes: idx_spending_limits_user on user_id, idx_spending_limits_card on card_id

Limit types (API-enforced): daily, weekly, monthly, transaction


rate_limits

Persistent rate limiting store.

Column Type Constraints Default
key TEXT PRIMARY KEY -
count INTEGER NOT NULL -
reset_at INTEGER NOT NULL -

Used by middleware.ts:rateLimit() for IP-based rate limiting. Expired entries are cleaned on each call (middleware.ts:11).


Compliance & GDPR Tables

Added: 2026-02-16 (compliance infrastructure)

These tables support Drop's compliance requirements for Norwegian financial services regulation, GDPR, and AML/KYC requirements per hvitvaskingsloven.

audit_log

User action audit trail for compliance and security monitoring.

Column Type Constraints Default
id TEXT PRIMARY KEY -
timestamp TEXT - CURRENT_TIMESTAMP
user_id TEXT FK → users(id) NULL
action TEXT NOT NULL -
resource_type TEXT - NULL
resource_id TEXT - NULL
details TEXT - NULL
ip_address TEXT - NULL
user_agent TEXT - NULL

Indexes: idx_audit_log_user on user_id, idx_audit_log_timestamp on timestamp, idx_audit_log_action on action

Purpose: Tracks all significant user actions (login, transaction, settings change, etc.) for audit purposes.


aml_alerts

AML (Anti-Money Laundering) transaction monitoring alerts.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
alert_type TEXT NOT NULL -
severity TEXT NOT NULL, CHECK('low','medium','high','critical') -
transaction_id TEXT FK → transactions(id) NULL
details TEXT - NULL
status TEXT CHECK('open','investigating','resolved','escalated','filed') 'open'
reviewed_by TEXT - NULL
reviewed_at TEXT - NULL
created_at TEXT - CURRENT_TIMESTAMP

Indexes: idx_aml_alerts_user on user_id, idx_aml_alerts_status on status

Purpose: Records suspicious transaction patterns flagged by AML monitoring rules (e.g., structuring, velocity, high-risk corridors).


str_reports

STR (Suspicious Transaction Reports) filed with financial authorities.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
alert_id TEXT FK → aml_alerts(id) NULL
report_type TEXT NOT NULL -
status TEXT CHECK('draft','submitted','acknowledged') 'draft'
filed_at TEXT - NULL
reference_number TEXT - NULL
details TEXT - NULL
created_at TEXT - CURRENT_TIMESTAMP

Purpose: Tracks STRs filed with Økokrim/EFE (Norwegian financial intelligence unit) per hvitvaskingsloven requirements.


screening_results

Results from sanctions/PEP (Politically Exposed Persons) screening.

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
screening_type TEXT NOT NULL, CHECK('pep','sanctions','adverse_media') -
provider TEXT - NULL
result TEXT NOT NULL, CHECK('clear','match','potential_match','error') -
match_details TEXT - NULL
screened_at TEXT - CURRENT_TIMESTAMP

Indexes: idx_screening_user on user_id

Purpose: Stores results from automated screening against PEP lists, sanctions lists (OFAC, UN, EU), and adverse media databases.


consents

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
consent_type TEXT NOT NULL -
granted INTEGER NOT NULL 1
granted_at TEXT - CURRENT_TIMESTAMP
withdrawn_at TEXT - NULL
ip_address TEXT - NULL

Purpose: Tracks when users grant or withdraw consent for different types of data processing, with IP address as proof of consent action.


data_access_requests

GDPR data access/erasure/rectification requests (Art. 15-17).

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
request_type TEXT NOT NULL, CHECK('export','erasure','rectification','restriction') -
status TEXT CHECK('pending','processing','completed','rejected') 'pending'
requested_at TEXT - CURRENT_TIMESTAMP
completed_at TEXT - NULL
download_url TEXT - NULL
notes TEXT - NULL

Indexes: idx_data_requests_user on user_id

Purpose: Tracks GDPR data subject access requests. export requests generate full data export, erasure triggers account deletion.


complaints

Customer complaints per Finansavtaleloven §3-53 (15-day response requirement).

Column Type Constraints Default
id TEXT PRIMARY KEY -
user_id TEXT NOT NULL, FK → users(id) -
category TEXT NOT NULL -
subject TEXT NOT NULL -
description TEXT NOT NULL -
status TEXT CHECK('received','investigating','resolved','escalated') 'received'
resolution TEXT - NULL
created_at TEXT - CURRENT_TIMESTAMP
resolved_at TEXT - NULL

Indexes: idx_complaints_user on user_id, idx_complaints_status on status

Categories (API-enforced): transaction, service, fees, privacy, technical, other

Purpose: Formal complaint logging system to ensure compliance with Norwegian financial services law requiring 15 business day response time.


Database Access Layer

Source: db.ts

Data Access Layer

The database access layer is Drizzle ORM (src/shared/db/schema.ts). The old db.ts dual-driver abstraction has been removed (see ADR-014).

Use Drizzle query builder or the sql template tag for raw queries:

import { db } from '@drop/shared/db'
import { users } from '@drop/shared/db/schema'
import { eq } from 'drizzle-orm'

// Type-safe query
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1)

// Raw SQL escape hatch (PostgreSQL syntax, $1 params not needed — Drizzle handles binding)
import { sql } from 'drizzle-orm'
const result = await db.execute(sql`SELECT id FROM users WHERE email = ${email}`)

Migrations are managed by drizzle-kit:

cd src/shared && npx drizzle-kit generate  # Generate migration file
cd src/shared && npx drizzle-kit push      # Push schema to dev database
make db-push                               # Shortcut (from repo root)

Seed Data

When exchange_rates table is empty, seedData() (db.ts:530) populates:

Error Codes Catalog

Bilko Error Codes Catalog

Project: Bilko Version: 1.0 Date: 2026-02-24 Status: Specification Applies to: apps/api/ — all modules


Overview

All Bilko API errors follow a consistent JSON structure. Every error response includes:

Error code ranges by module:

Range Module
BILKO-1xxx Authentication
BILKO-2xxx Organizations
BILKO-3xxx Invoices
BILKO-4xxx Expenses
BILKO-5xxx Banking
BILKO-6xxx Reports
BILKO-7xxx Contacts
BILKO-8xxx Settings & Accounts
BILKO-9xxx General / Cross-cutting

Error Response Schema

All error responses use this structure:

interface ErrorResponse {
  error: {
    code: string                          // e.g., "BILKO-1001"
    message: string                       // Human-readable, localized
    details?: Record<string, string[]>    // Field-level validation errors (422 only)
    requestId?: string                    // Trace ID for support (production)
  }
}

Examples:

// Authentication error
{
  "error": {
    "code": "BILKO-1001",
    "message": "Pogrešan email ili lozinka.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

// Validation error (422) with field details
{
  "error": {
    "code": "BILKO-9003",
    "message": "Validacija nije uspjela.",
    "details": {
      "email": ["Email adresa nije ispravna."],
      "password": ["Lozinka mora imati najmanje 8 znakova.", "Lozinka mora sadržavati barem jedan broj."]
    },
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

HTTP Status Codes

HTTP Code Meaning When Used
200 OK Successful read or update GET, PUT, PATCH
201 Created Resource successfully created POST (creates new record)
204 No Content Successful deletion DELETE, POST /logout
400 Bad Request Request is well-formed but semantically invalid Business rule violations (not validation)
401 Unauthorized Missing, expired, or invalid authentication No token, expired JWT, wrong credentials
403 Forbidden Authenticated but insufficient permissions Role lacks access to endpoint or action
404 Not Found Resource does not exist within organization scope Record not found or belongs to different org
409 Conflict Resource already exists Duplicate email, duplicate invoice number
413 Payload Too Large Upload exceeds size limit File uploads over 10MB (receipt) or 5MB (CSV)
422 Unprocessable Entity Zod schema validation failed Invalid field types, missing required fields
429 Too Many Requests Rate limit exceeded Auth: 5/min; writes: 10–50/min; reads: 100/min
500 Internal Server Error Unexpected server-side error Unhandled exception, DB error
503 Service Unavailable External dependency unavailable SendGrid, ECB API, Cloudflare R2 down

Retry Guidance

Error Code HTTP Retry? Strategy
BILKO-1003 401 Yes Refresh access token via POST /auth/refresh, then retry
BILKO-9005 429 Yes Wait until Retry-After header value (seconds), then retry
BILKO-9006 500 Yes Exponential backoff: 1s, 2s, 4s — max 3 retries
BILKO-9007 503 Yes Exponential backoff: 2s, 5s, 10s — max 3 retries
All 4xx except above No Fix request before retrying — these are client errors
BILKO-1001 401 No Wrong credentials — do not retry automatically
BILKO-3011 500 Yes SendGrid transient failure — retry once after 5s

Retry-After header: Always present on 429 responses. Value is seconds to wait.


Module: Authentication (1xxx)

BILKO-1001 — Invalid Credentials

Field Value
HTTP 401
Trigger POST /auth/login — email not found or password does not match bcrypt hash
Retry No
{
  "error": {
    "code": "BILKO-1001",
    "message": "Pogrešan email ili lozinka."
  }
}

BILKO-1002 — Account Disabled

Field Value
HTTP 403
Trigger POST /auth/login — user has isActive = false
Retry No — contact support
{
  "error": {
    "code": "BILKO-1002",
    "message": "Vaš nalog je deaktiviran. Kontaktirajte podršku."
  }
}

BILKO-1003 — Access Token Expired

Field Value
HTTP 401
Trigger Any authenticated request — JWT exp claim is in the past
Retry Yes — refresh token first via POST /auth/refresh, then retry original request
{
  "error": {
    "code": "BILKO-1003",
    "message": "Sesija je istekla. Osvježite token."
  }
}

BILKO-1004 — Invalid Token

Field Value
HTTP 401
Trigger Any authenticated request — JWT signature invalid or malformed
Retry No — re-authenticate
{
  "error": {
    "code": "BILKO-1004",
    "message": "Token nije ispravan. Molimo prijavite se ponovo."
  }
}

BILKO-1005 — No Authentication Token

Field Value
HTTP 401
Trigger Any authenticated endpoint called without Authorization: Bearer <token> header
Retry No — add token
{
  "error": {
    "code": "BILKO-1005",
    "message": "Autentifikacija je obavezna."
  }
}

BILKO-1006 — Refresh Token Invalid or Expired

Field Value
HTTP 401
Trigger POST /auth/refresh — cookie missing, token blacklisted, or expired
Retry No — force full re-login
{
  "error": {
    "code": "BILKO-1006",
    "message": "Sesija je istekla. Molimo prijavite se ponovo."
  }
}

BILKO-1007 — Auth Rate Limit Exceeded

Field Value
HTTP 429
Trigger POST /auth/login or POST /auth/register — 5+ requests in 60 seconds from same IP
Retry Yes — after Retry-After header value (900 seconds / 15 min lockout)
{
  "error": {
    "code": "BILKO-1007",
    "message": "Previše pokušaja prijave. Pokušajte ponovo za 15 minuta."
  }
}

Response headers:

Retry-After: 900
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1740399600

BILKO-1008 — Email Already Registered

Field Value
HTTP 409
Trigger POST /auth/register — email already exists in users table
Retry No — use different email or reset password
{
  "error": {
    "code": "BILKO-1008",
    "message": "Email adresa je već registrirana."
  }
}

BILKO-1009 — Weak Password

Field Value
HTTP 422
Trigger POST /auth/register or PUT /auth/password — password fails strength requirements
Retry No — fix password
{
  "error": {
    "code": "BILKO-1009",
    "message": "Lozinka ne zadovoljava sigurnosne zahtjeve.",
    "details": {
      "password": [
        "Lozinka mora imati najmanje 8 znakova.",
        "Lozinka mora sadržavati barem jedno veliko slovo.",
        "Lozinka mora sadržavati barem jedan broj."
      ]
    }
  }
}

BILKO-1010 — Two-Factor Authentication Required

Field Value
HTTP 403
Trigger POST /auth/login — user has twoFactorEnabled = true, 2FA code not provided
Retry No — submit TOTP code via POST /auth/verify-2fa
{
  "error": {
    "code": "BILKO-1010",
    "message": "Potrebna je dvofaktorska autentifikacija.",
    "details": {
      "requiresTwoFactor": ["true"]
    }
  }
}

BILKO-1011 — Invalid 2FA Code

Field Value
HTTP 401
Trigger POST /auth/verify-2fa — TOTP code incorrect or expired (outside 30s window)
Retry No — request new code from authenticator app
{
  "error": {
    "code": "BILKO-1011",
    "message": "Kod za verifikaciju nije ispravan ili je istekao."
  }
}

BILKO-1012 — Invalid Invite Token

Field Value
HTTP 401
Trigger User follows invite link after it has expired (7 days) or already been used
Retry No — request new invitation
{
  "error": {
    "code": "BILKO-1012",
    "message": "Pozivnica je nevažeća ili je istekla."
  }
}

Module: Organizations (2xxx)

BILKO-2001 — Organization Not Found

Field Value
HTTP 404
Trigger Organization ID in JWT does not exist in database (account deleted while token still valid)
{
  "error": {
    "code": "BILKO-2001",
    "message": "Organizacija nije pronađena."
  }
}

BILKO-2002 — Invalid Currency Code

Field Value
HTTP 422
Trigger PUT /organizationbaseCurrency is not one of EUR, RSD, BAM, HRK
{
  "error": {
    "code": "BILKO-2002",
    "message": "Neispravna valuta. Podržane valute: EUR, RSD, BAM, HRK.",
    "details": {
      "baseCurrency": ["Vrijednost mora biti jedna od: EUR, RSD, BAM, HRK."]
    }
  }
}

BILKO-2003 — Invalid Language Code

Field Value
HTTP 422
Trigger PUT /organizationlanguage is not one of sr, bs, hr
{
  "error": {
    "code": "BILKO-2003",
    "message": "Neispravni jezički kod. Podržani jezici: sr, bs, hr.",
    "details": {
      "language": ["Vrijednost mora biti jedna od: sr, bs, hr."]
    }
  }
}

BILKO-2004 — Cannot Change Base Currency

Field Value
HTTP 400
Trigger PUT /organization — attempting to change baseCurrency when transactions already exist
{
  "error": {
    "code": "BILKO-2004",
    "message": "Osnovna valuta ne može se promijeniti jer već postoje finansijske transakcije."
  }
}

BILKO-2005 — User Not Found in Organization

Field Value
HTTP 404
Trigger PUT /users/:id/role or DELETE /users/:id — user UUID not in caller's organization
{
  "error": {
    "code": "BILKO-2005",
    "message": "Korisnik nije pronađen u ovoj organizaciji."
  }
}

BILKO-2006 — Cannot Modify Owner

Field Value
HTTP 403
Trigger PUT /users/:id/role or DELETE /users/:id — target user is owner
{
  "error": {
    "code": "BILKO-2006",
    "message": "Nije moguće promijeniti ili ukloniti vlasnika organizacije."
  }
}

BILKO-2007 — Cannot Remove Self

Field Value
HTTP 403
Trigger DELETE /users/:id — user attempts to delete their own account
{
  "error": {
    "code": "BILKO-2007",
    "message": "Ne možete ukloniti vlastiti korisnički račun."
  }
}

BILKO-2008 — Cannot Invite Existing Member

Field Value
HTTP 409
Trigger POST /users/invite — email already belongs to a user in this organization
{
  "error": {
    "code": "BILKO-2008",
    "message": "Korisnik s ovim emailom je već član organizacije."
  }
}

Module: Invoices (3xxx)

BILKO-3001 — Invoice Not Found

Field Value
HTTP 404
Trigger GET/PUT/PATCH/DELETE /invoices/:id — ID not found in caller's organization
{
  "error": {
    "code": "BILKO-3001",
    "message": "Faktura nije pronađena."
  }
}

BILKO-3002 — Customer Not Found

Field Value
HTTP 404
Trigger POST /invoicescustomerId does not exist in organization's contacts
{
  "error": {
    "code": "BILKO-3002",
    "message": "Klijent nije pronađen."
  }
}

BILKO-3003 — Invoice Not in Draft Status

Field Value
HTTP 400
Trigger PUT /invoices/:id — attempting to edit an invoice that is not in draft status
{
  "error": {
    "code": "BILKO-3003",
    "message": "Faktura se može mijenjati samo u statusu 'nacrt'.",
    "details": {
      "status": ["Trenutni status: sent. Samo nacrti se mogu uređivati."]
    }
  }
}

BILKO-3004 — Invalid Invoice Status Transition

Field Value
HTTP 400
Trigger PATCH /invoices/:id/status — action is not valid for current invoice status

Valid transitions:

{
  "error": {
    "code": "BILKO-3004",
    "message": "Nevažeća promjena statusa fakture.",
    "details": {
      "action": ["Akcija 'mark-paid' nije dozvoljena za status 'draft'."]
    }
  }
}

BILKO-3005 — Customer Has No Email

Field Value
HTTP 400
Trigger POST /invoices/:id/send — customer contact has no email field set
{
  "error": {
    "code": "BILKO-3005",
    "message": "Klijent nema email adresu. Dodajte email u kontakt podatke."
  }
}

BILKO-3006 — Invoice Items Required

Field Value
HTTP 422
Trigger POST /invoicesitems array is empty
{
  "error": {
    "code": "BILKO-3006",
    "message": "Faktura mora imati najmanje jednu stavku.",
    "details": {
      "items": ["Polje items ne može biti prazno."]
    }
  }
}

BILKO-3007 — Negative or Zero Amount

Field Value
HTTP 422
Trigger POST /invoices or POST /invoices/:id/sendunitPrice or quantity is ≤ 0
{
  "error": {
    "code": "BILKO-3007",
    "message": "Iznosi moraju biti veći od nule.",
    "details": {
      "items[0].unitPrice": ["Cijena mora biti pozitivan broj."]
    }
  }
}

BILKO-3008 — Invalid Tax Rate

Field Value
HTTP 422
Trigger POST /invoicestaxRate is negative or exceeds 100
{
  "error": {
    "code": "BILKO-3008",
    "message": "Stopa poreza mora biti između 0 i 100.",
    "details": {
      "items[0].taxRate": ["Vrijednost mora biti između 0 i 100."]
    }
  }
}

BILKO-3009 — Due Date Before Invoice Date

Field Value
HTTP 422
Trigger POST /invoicesdueDate is before invoiceDate
{
  "error": {
    "code": "BILKO-3009",
    "message": "Datum dospijeća ne može biti prije datuma fakture.",
    "details": {
      "dueDate": ["Datum dospijeća mora biti isti ili kasniji od datuma fakture."]
    }
  }
}

BILKO-3010 — Invoice PDF Not Available

Field Value
HTTP 404
Trigger GET /invoices/:id/pdf — PDF has not yet been generated (invoice still in draft)
{
  "error": {
    "code": "BILKO-3010",
    "message": "PDF faktura nije dostupan. Faktura mora biti poslana da bi se generirao PDF."
  }
}

BILKO-3011 — Invoice Email Delivery Failed

Field Value
HTTP 500
Trigger POST /invoices/:id/send — SendGrid API returned error
Retry Yes — once, after 5 seconds
{
  "error": {
    "code": "BILKO-3011",
    "message": "Slanje emaila nije uspjelo. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

Module: Expenses (4xxx)

BILKO-4001 — Expense Not Found

Field Value
HTTP 404
Trigger GET/PUT/PATCH/DELETE /expenses/:id — ID not in caller's organization
{
  "error": {
    "code": "BILKO-4001",
    "message": "Troškak nije pronađen."
  }
}

BILKO-4002 — Expense Not in Pending Status

Field Value
HTTP 400
Trigger PUT /expenses/:id or DELETE /expenses/:id — expense is not in pending status
{
  "error": {
    "code": "BILKO-4002",
    "message": "Troškak se može mijenjati ili brisati samo u statusu 'na čekanju'.",
    "details": {
      "status": ["Trenutni status: approved."]
    }
  }
}

BILKO-4003 — Expense Already Processed

Field Value
HTTP 400
Trigger PATCH /expenses/:id/approve — expense is already approved, paid, or rejected
{
  "error": {
    "code": "BILKO-4003",
    "message": "Troškak je već obrađen i ne može se odobriti ponovo.",
    "details": {
      "status": ["Trenutni status: approved."]
    }
  }
}

BILKO-4004 — Vendor Not Found

Field Value
HTTP 404
Trigger POST /expensesvendorId not found in organization's contacts
{
  "error": {
    "code": "BILKO-4004",
    "message": "Dobavljač nije pronađen."
  }
}

BILKO-4005 — Receipt File Too Large

Field Value
HTTP 413
Trigger POST /expenses with receiptFile — file exceeds 10MB
{
  "error": {
    "code": "BILKO-4005",
    "message": "Fajl je prevelik. Maksimalna veličina je 10 MB."
  }
}

BILKO-4006 — Invalid Receipt File Type

Field Value
HTTP 422
Trigger POST /expenses with receiptFile — file is not PDF, PNG, or JPG
{
  "error": {
    "code": "BILKO-4006",
    "message": "Neispravni tip fajla. Dozvoljeni formati: PDF, PNG, JPG.",
    "details": {
      "receiptFile": ["Tip fajla 'docx' nije dozvoljen."]
    }
  }
}

BILKO-4007 — Receipt Upload Failed

Field Value
HTTP 500
Trigger Cloudflare R2 upload failed after ClamAV scan passed
Retry Yes — exponential backoff
{
  "error": {
    "code": "BILKO-4007",
    "message": "Upload računa nije uspio. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

BILKO-4008 — File Rejected (Virus Detected)

Field Value
HTTP 422
Trigger ClamAV scan detected malware in uploaded file
Retry No
{
  "error": {
    "code": "BILKO-4008",
    "message": "Fajl nije prihvaćen zbog sigurnosnih razloga."
  }
}

Module: Banking (5xxx)

BILKO-5001 — Bank Account Not Found

Field Value
HTTP 404
Trigger GET/POST /bank-accounts/:id/* — ID not found in caller's organization
{
  "error": {
    "code": "BILKO-5001",
    "message": "Bankovni račun nije pronađen."
  }
}

BILKO-5002 — GL Account Must Be Asset Type

Field Value
HTTP 422
Trigger POST /bank-accounts — referenced accountId is not an Asset account type
{
  "error": {
    "code": "BILKO-5002",
    "message": "Konto za bankovni račun mora biti tipa 'Imovina'.",
    "details": {
      "accountId": ["Odabrani konto je tipa 'Rashodi'. Odaberite konto tipa 'Imovina'."]
    }
  }
}

BILKO-5003 — GL Account Not Found

Field Value
HTTP 404
Trigger POST /bank-accountsaccountId does not exist in organization
{
  "error": {
    "code": "BILKO-5003",
    "message": "Konto nije pronađen."
  }
}

BILKO-5004 — Bank Transaction Not Found

Field Value
HTTP 404
Trigger POST /bank-accounts/:id/reconcilebankTransactionId not found
{
  "error": {
    "code": "BILKO-5004",
    "message": "Bankovna transakcija nije pronađena."
  }
}

BILKO-5005 — Transaction Already Reconciled

Field Value
HTTP 400
Trigger POST /bank-accounts/:id/reconcile — either transaction is already reconciled = true
{
  "error": {
    "code": "BILKO-5005",
    "message": "Transakcija je već usklađena."
  }
}

BILKO-5006 — Invalid CSV Format

Field Value
HTTP 422
Trigger POST /bank-accounts/:id/import — CSV missing required columns or malformed
{
  "error": {
    "code": "BILKO-5006",
    "message": "Nevažeći format CSV fajla.",
    "details": {
      "file": ["Obavezne kolone: Date, Description, Amount, Reference."]
    }
  }
}

BILKO-5007 — CSV File Too Large

Field Value
HTTP 413
Trigger POST /bank-accounts/:id/import — CSV file exceeds 5MB
{
  "error": {
    "code": "BILKO-5007",
    "message": "CSV fajl je prevelik. Maksimalna veličina je 5 MB."
  }
}

BILKO-5008 — Invalid CSV Date Format

Field Value
HTTP 422
Trigger POST /bank-accounts/:id/import — date column values not parseable as ISO 8601
{
  "error": {
    "code": "BILKO-5008",
    "message": "Nevažeći format datuma u CSV fajlu.",
    "details": {
      "file": ["Datumi moraju biti u formatu YYYY-MM-DD (npr. 2026-02-24)."]
    }
  }
}

Module: Reports (6xxx)

BILKO-6001 — From Date Required

Field Value
HTTP 422
Trigger GET /reports/profit-loss, /cash-flow, /vatfrom query parameter missing
{
  "error": {
    "code": "BILKO-6001",
    "message": "Početni datum je obavezan.",
    "details": {
      "from": ["Parametar 'from' je obavezan."]
    }
  }
}

BILKO-6002 — To Date Required

Field Value
HTTP 422
Trigger GET /reports/profit-loss, /cash-flow, /vatto query parameter missing
{
  "error": {
    "code": "BILKO-6002",
    "message": "Krajnji datum je obavezan.",
    "details": {
      "to": ["Parametar 'to' je obavezan."]
    }
  }
}

BILKO-6003 — Invalid Date Range

Field Value
HTTP 422
Trigger Any report endpoint — from date is after to date
{
  "error": {
    "code": "BILKO-6003",
    "message": "Nevažeći raspon datuma. Početni datum mora biti prije krajnjeg.",
    "details": {
      "from": ["Mora biti prije 'to' datuma."]
    }
  }
}

BILKO-6004 — Trial Balance Not Balanced

Field Value
HTTP 500
Trigger GET /reports/trial-balance — internal data integrity check failed (debit ≠ credit totals)
Retry No — requires manual investigation
{
  "error": {
    "code": "BILKO-6004",
    "message": "Greška integriteta podataka: probni bilans nije uravnotežen. Kontaktirajte podršku.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

Module: Contacts (7xxx)

BILKO-7001 — Contact Not Found

Field Value
HTTP 404
Trigger GET/PUT/DELETE /contacts/:id — ID not found in caller's organization
{
  "error": {
    "code": "BILKO-7001",
    "message": "Kontakt nije pronađen."
  }
}

BILKO-7002 — Contact Has Active Invoices

Field Value
HTTP 400
Trigger DELETE /contacts/:id — contact has invoices with status other than cancelled
{
  "error": {
    "code": "BILKO-7002",
    "message": "Kontakt ne može biti deaktiviran jer ima aktivne fakture."
  }
}

BILKO-7003 — Contact Has Active Expenses

Field Value
HTTP 400
Trigger DELETE /contacts/:id — contact has expenses with status other than rejected
{
  "error": {
    "code": "BILKO-7003",
    "message": "Kontakt ne može biti deaktiviran jer ima aktivne troškove."
  }
}

BILKO-7004 — Invalid ISO Country Code

Field Value
HTTP 422
Trigger POST/PUT /contactscountry is not a valid ISO 3166-1 alpha-2 code
{
  "error": {
    "code": "BILKO-7004",
    "message": "Nevažeći kod države.",
    "details": {
      "country": ["Mora biti ISO 3166-1 alpha-2 kod (npr. 'RS', 'BA', 'HR')."]
    }
  }
}

BILKO-7005 — Invalid Payment Terms

Field Value
HTTP 422
Trigger POST/PUT /contactspaymentTerms is negative or not an integer
{
  "error": {
    "code": "BILKO-7005",
    "message": "Rok plaćanja mora biti pozitivan cijeli broj (dani).",
    "details": {
      "paymentTerms": ["Mora biti pozitivni cijeli broj."]
    }
  }
}

Module: Settings & Accounts (8xxx)

BILKO-8001 — Account Not Found

Field Value
HTTP 404
Trigger PUT /accounts/:id — account UUID not found in caller's organization
{
  "error": {
    "code": "BILKO-8001",
    "message": "Konto nije pronađen."
  }
}

BILKO-8002 — Account Code Already Exists

Field Value
HTTP 409
Trigger POST /accountscode is not unique within the organization
{
  "error": {
    "code": "BILKO-8002",
    "message": "Konto sa ovim kodom već postoji.",
    "details": {
      "code": ["Kod '1200' je već u upotrebi."]
    }
  }
}

BILKO-8003 — Invalid Account Type

Field Value
HTTP 422
Trigger POST /accountsaccountTypeId is not 1–5
{
  "error": {
    "code": "BILKO-8003",
    "message": "Nevažeći tip konta.",
    "details": {
      "accountTypeId": ["Mora biti između 1 (Imovina) i 5 (Rashodi)."]
    }
  }
}

BILKO-8004 — Cannot Deactivate Account with Transactions

Field Value
HTTP 400
Trigger PUT /accounts/:id — setting isActive = false on account that has transactions
{
  "error": {
    "code": "BILKO-8004",
    "message": "Konto se ne može deaktivirati jer ima postojeće transakcije."
  }
}

BILKO-8005 — Parent Account Not Found

Field Value
HTTP 404
Trigger POST /accountsparentAccountId not found in organization
{
  "error": {
    "code": "BILKO-8005",
    "message": "Nadređeni konto nije pronađen."
  }
}

BILKO-8006 — Invalid VAT Rate

Field Value
HTTP 422
Trigger PUT /settings/tax-rates — any rate value is negative or exceeds 100
{
  "error": {
    "code": "BILKO-8006",
    "message": "Stopa PDV-a mora biti između 0 i 100.",
    "details": {
      "defaultVATRate": ["Vrijednost mora biti između 0 i 100."]
    }
  }
}

General / Cross-cutting (9xxx)

BILKO-9001 — Insufficient Permissions

Field Value
HTTP 403
Trigger Any endpoint — user's role not in allowedRoles for that endpoint
{
  "error": {
    "code": "BILKO-9001",
    "message": "Nemate dovoljna prava za ovu akciju.",
    "details": {
      "required": ["owner", "admin"],
      "current": ["accountant"]
    }
  }
}

BILKO-9002 — Resource Not Found

Field Value
HTTP 404
Trigger Generic fallback when a specific BILKO-Xxxx code doesn't apply
{
  "error": {
    "code": "BILKO-9002",
    "message": "Traženi resurs nije pronađen."
  }
}

BILKO-9003 — Validation Failed

Field Value
HTTP 422
Trigger Generic Zod schema validation failure when no specific BILKO-Xxxx code applies
{
  "error": {
    "code": "BILKO-9003",
    "message": "Validacija zahtjeva nije uspjela.",
    "details": {
      "fieldName": ["Opis greške validacije."]
    }
  }
}

BILKO-9004 — Internal Server Error

Field Value
HTTP 500
Trigger Unhandled exception; logged to Sentry with full stack trace
Retry Yes — exponential backoff
{
  "error": {
    "code": "BILKO-9004",
    "message": "Došlo je do greške na serveru. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

BILKO-9005 — Rate Limit Exceeded

Field Value
HTTP 429
Trigger General API rate limit exceeded (100 req/min for reads, 10–50 for writes)
Retry Yes — after Retry-After header
{
  "error": {
    "code": "BILKO-9005",
    "message": "Previše zahtjeva. Usporite."
  }
}

BILKO-9006 — Database Error

Field Value
HTTP 500
Trigger Prisma throws unexpected DB error (connection lost, constraint violation from race condition)
Retry Yes — exponential backoff
{
  "error": {
    "code": "BILKO-9006",
    "message": "Greška baze podataka. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

BILKO-9007 — External Service Unavailable

Field Value
HTTP 503
Trigger SendGrid, Cloudflare R2, or ECB API is unreachable after internal retries exhausted
Retry Yes — exponential backoff; 503 responses include Retry-After
{
  "error": {
    "code": "BILKO-9007",
    "message": "Vanjska usluga trenutno nije dostupna. Pokušajte ponovo za nekoliko minuta.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

BILKO-9008 — Invalid Pagination Parameters

Field Value
HTTP 400
Trigger Any list endpoint — page < 1, perPage > 100, or non-integer values
{
  "error": {
    "code": "BILKO-9008",
    "message": "Nevažeći parametri paginacije.",
    "details": {
      "perPage": ["Maksimalna vrijednost je 100."],
      "page": ["Mora biti pozitivan cijeli broj."]
    }
  }
}

i18n Error Messages

All error message fields are returned in the organization's configured language (sr, bs, hr, en). Codes and details keys are always in English to enable programmatic handling.

Message localization table (selected codes):

Code SR (Serbian) BS (Bosnian) HR (Croatian) EN (English)
BILKO-1001 Pogrešan email ili lozinka. Pogrešan email ili lozinka. Pogrešan email ili lozinka. Invalid email or password.
BILKO-1003 Sesija je istekla. Osvježite token. Sesija je istekla. Osvježite token. Sesija je istekla. Osvježite token. Session expired. Please refresh your token.
BILKO-1005 Autentifikacija je obavezna. Autentifikacija je obavezna. Autentifikacija je obavezna. Authentication is required.
BILKO-1007 Previše pokušaja prijave. Pokušajte za 15 min. Previše pokušaja prijave. Pokušajte za 15 min. Previše pokušaja prijave. Pokušajte za 15 min. Too many login attempts. Try again in 15 minutes.
BILKO-1008 Email adresa je već registrirana. Email adresa je već registrirana. Email adresa je već registrirana. Email address is already registered.
BILKO-3001 Faktura nije pronađena. Faktura nije pronađena. Faktura nije pronađena. Invoice not found.
BILKO-4001 Troškak nije pronađen. Troškak nije pronađen. Trošak nije pronađen. Expense not found.
BILKO-7001 Kontakt nije pronađen. Kontakt nije pronađen. Kontakt nije pronađen. Contact not found.
BILKO-9001 Nemate dovoljna prava za ovu akciju. Nemate dovoljna prava za ovu akciju. Nemate dovoljna prava za ovu akciju. You do not have permission to perform this action.
BILKO-9003 Validacija zahtjeva nije uspjela. Validacija zahtjeva nije uspjela. Provjera zahtjeva nije uspjela. Request validation failed.
BILKO-9004 Došlo je do greške na serveru. Došlo je do greške na serveru. Došlo je do pogreške na poslužitelju. An internal server error occurred.
BILKO-9005 Previše zahtjeva. Usporite. Previše zahtjeva. Usporite. Previše zahtjeva. Usporite. Too many requests. Please slow down.

Implementation: Error messages are stored in /src/lib/i18n/errors/ with one file per language (sr.json, bs.json, hr.json, en.json). The message is selected using the organization's language field from the JWT orgId lookup.

// src/lib/i18n/errors/en.json (excerpt)
{
  "BILKO-1001": "Invalid email or password.",
  "BILKO-1003": "Session expired. Please refresh your token.",
  "BILKO-9001": "You do not have permission to perform this action."
}
// src/lib/error-formatter.ts
function formatError(code: string, orgLanguage: string, details?: Record<string, string[]>) {
  const messages = require(`./i18n/errors/${orgLanguage}.json`)
  return {
    error: {
      code,
      message: messages[code] ?? messages['BILKO-9004'],
      ...(details && { details }),
    }
  }
}

Quick Reference — All Error Codes

Code HTTP Module Short Description
BILKO-1001 401 Auth Invalid credentials
BILKO-1002 403 Auth Account disabled
BILKO-1003 401 Auth Access token expired
BILKO-1004 401 Auth Invalid token
BILKO-1005 401 Auth No token provided
BILKO-1006 401 Auth Refresh token invalid/expired
BILKO-1007 429 Auth Auth rate limit exceeded
BILKO-1008 409 Auth Email already registered
BILKO-1009 422 Auth Weak password
BILKO-1010 403 Auth 2FA required
BILKO-1011 401 Auth Invalid 2FA code
BILKO-1012 401 Auth Invalid invite token
BILKO-2001 404 Org Organization not found
BILKO-2002 422 Org Invalid currency code
BILKO-2003 422 Org Invalid language code
BILKO-2004 400 Org Cannot change base currency
BILKO-2005 404 Org User not found in org
BILKO-2006 403 Org Cannot modify owner
BILKO-2007 403 Org Cannot remove self
BILKO-2008 409 Org User already a member
BILKO-3001 404 Invoices Invoice not found
BILKO-3002 404 Invoices Customer not found
BILKO-3003 400 Invoices Invoice not in draft
BILKO-3004 400 Invoices Invalid status transition
BILKO-3005 400 Invoices Customer has no email
BILKO-3006 422 Invoices Items array empty
BILKO-3007 422 Invoices Negative/zero amount
BILKO-3008 422 Invoices Invalid tax rate
BILKO-3009 422 Invoices Due date before invoice date
BILKO-3010 404 Invoices PDF not available
BILKO-3011 500 Invoices Email delivery failed
BILKO-4001 404 Expenses Expense not found
BILKO-4002 400 Expenses Expense not pending
BILKO-4003 400 Expenses Expense already processed
BILKO-4004 404 Expenses Vendor not found
BILKO-4005 413 Expenses Receipt file too large
BILKO-4006 422 Expenses Invalid file type
BILKO-4007 500 Expenses Upload failed
BILKO-4008 422 Expenses Virus detected in file
BILKO-5001 404 Banking Bank account not found
BILKO-5002 422 Banking GL account must be Asset type
BILKO-5003 404 Banking GL account not found
BILKO-5004 404 Banking Bank transaction not found
BILKO-5005 400 Banking Transaction already reconciled
BILKO-5006 422 Banking Invalid CSV format
BILKO-5007 413 Banking CSV file too large
BILKO-5008 422 Banking Invalid CSV date format
BILKO-6001 422 Reports From date required
BILKO-6002 422 Reports To date required
BILKO-6003 422 Reports Invalid date range
BILKO-6004 500 Reports Trial balance not balanced
BILKO-7001 404 Contacts Contact not found
BILKO-7002 400 Contacts Contact has active invoices
BILKO-7003 400 Contacts Contact has active expenses
BILKO-7004 422 Contacts Invalid country code
BILKO-7005 422 Contacts Invalid payment terms
BILKO-8001 404 Accounts Account not found
BILKO-8002 409 Accounts Account code already exists
BILKO-8003 422 Accounts Invalid account type
BILKO-8004 400 Accounts Cannot deactivate account with transactions
BILKO-8005 404 Accounts Parent account not found
BILKO-8006 422 Settings Invalid VAT rate
BILKO-9001 403 General Insufficient permissions
BILKO-9002 404 General Resource not found
BILKO-9003 422 General Validation failed
BILKO-9004 500 General Internal server error
BILKO-9005 429 General Rate limit exceeded
BILKO-9006 500 General Database error
BILKO-9007 503 General External service unavailable
BILKO-9008 400 General Invalid pagination parameters

End of Error Codes Catalog

Roles and Permissions

Bilko Roles and Permissions

Project: Bilko Version: 1.0 Date: 2026-02-24 Status: Specification Applies to: apps/api/authGuard + roleGuard middleware, organizationScope


Overview

Bilko uses Role-Based Access Control (RBAC) with four fixed roles. Roles are assigned per user within an organization. A user belongs to exactly one organization and has exactly one role within it.

Roles defined in Prisma schema (packages/database/prisma/schema.prisma):

enum UserRole {
  owner
  admin
  accountant
  viewer
}

Roles are embedded in the JWT access token claim role and enforced on every request via the roleGuard middleware. No additional DB lookup is required for authorization at runtime.


Role Definitions

owner

The organization creator. There is exactly one owner per organization. The owner role is assigned automatically on registration and cannot be granted via invitation.

Capabilities:

Restrictions:


admin

A trusted operator with near-full access. Assigned by the owner via invitation or role change.

Capabilities:

Restrictions:


accountant

A bookkeeping operator who can perform all financial data entry but cannot administer the organization or its users.

Capabilities:

Restrictions:


viewer

Read-only access to all financial data within the organization. Suitable for external accountants, auditors, or stakeholders who need visibility without write access.

Capabilities:

Restrictions:


Permission Inheritance Model

Permissions do not inherit hierarchically. Each role has a discrete, fixed set of permissions. However, higher roles consistently include all permissions of lower roles:

viewer  ⊂  accountant  ⊂  admin  ⊂  owner

This means:

The single exception is owner-exclusive operations (role assignment, organization deletion) which are not part of admin's scope.


Endpoint Access Matrix

Full access matrix for all 50 API endpoints. = access granted. = 403 BILKO-9001 returned.

Authentication (no role required)

Endpoint owner admin accountant viewer Notes
POST /auth/register Public — no auth required
POST /auth/login Public — no auth required
POST /auth/refresh Cookie-based — no role required
POST /auth/logout Any authenticated user
GET /auth/me Any authenticated user

Organization

Endpoint owner admin accountant viewer Notes
GET /organization All roles
PUT /organization Settings change: owner + admin

Users

Endpoint owner admin accountant viewer Notes
GET /users User list: owner + admin only
POST /users/invite admin can invite up to admin role
PUT /users/:id/role Role changes: owner only
DELETE /users/:id User removal: owner only

Contacts

Endpoint owner admin accountant viewer Notes
GET /contacts All roles
POST /contacts Create: owner + admin + accountant
GET /contacts/:id All roles
PUT /contacts/:id Edit: owner + admin + accountant
DELETE /contacts/:id Soft-delete: owner + admin

Invoices

Endpoint owner admin accountant viewer Notes
GET /invoices All roles
POST /invoices Create: owner + admin + accountant
GET /invoices/:id All roles
PUT /invoices/:id Edit (draft only): owner + admin + accountant
PATCH /invoices/:id/status Status change: owner + admin + accountant
GET /invoices/:id/pdf PDF download: all roles
POST /invoices/:id/send Email send: owner + admin + accountant

Expenses

Endpoint owner admin accountant viewer Notes
GET /expenses All roles
POST /expenses Create: owner + admin + accountant
GET /expenses/:id All roles
PUT /expenses/:id Edit (pending only): owner + admin + accountant
PATCH /expenses/:id/approve Approve: owner + admin only
DELETE /expenses/:id Delete (pending only): owner + admin

Bank Accounts

Endpoint owner admin accountant viewer Notes
GET /bank-accounts All roles
POST /bank-accounts Create: owner + admin
GET /bank-accounts/:id/transactions All roles
POST /bank-accounts/:id/import CSV import: owner + admin + accountant
POST /bank-accounts/:id/reconcile Reconcile: owner + admin + accountant

Reports

Endpoint owner admin accountant viewer Notes
GET /reports/dashboard All roles
GET /reports/profit-loss All roles
GET /reports/balance-sheet All roles
GET /reports/cash-flow All roles
GET /reports/vat All roles
GET /reports/trial-balance All roles

Chart of Accounts

Endpoint owner admin accountant viewer Notes
GET /accounts All roles
POST /accounts Create: owner + admin
PUT /accounts/:id Edit/deactivate: owner + admin

Transactions (General Ledger)

Endpoint owner admin accountant viewer Notes
GET /transactions All roles
POST /transactions Manual journal entry: owner + admin + accountant

Settings

Endpoint owner admin accountant viewer Notes
GET /settings/tax-rates All roles
PUT /settings/tax-rates Update: owner + admin

Currencies

Endpoint owner admin accountant viewer Notes
GET /currencies All roles
GET /exchange-rates All roles

UI Element Visibility Per Role

Frontend elements are conditionally rendered based on the user's role (available in Zustand store from /auth/me). This is a display-only optimization — the API enforces permissions independently.

Navigation Sidebar

Nav Item owner admin accountant viewer
Dashboard
Invoices
Expenses
Banking
Reports
Chart of Accounts ✅ (read-only)
Contacts ✅ (read-only)
Settings
Users & Teams

Action Buttons

Action owner admin accountant viewer
"New Invoice" button Hidden
"Send Invoice" button Hidden
"Mark as Paid" button Hidden
"Cancel Invoice" button Hidden
"New Expense" button Hidden
"Approve Expense" button Hidden Hidden
"Delete Expense" button Hidden Hidden
"New Contact" button Hidden
"Edit Contact" button Hidden
"Delete Contact" button Hidden Hidden
"Invite User" button Hidden Hidden
"Change Role" dropdown Hidden Hidden Hidden
"Remove User" button Hidden Hidden Hidden
"Add Bank Account" button Hidden Hidden
"Import CSV" button Hidden
"Reconcile" button Hidden
"New Account" (CoA) button Hidden Hidden
"Deactivate Account" button Hidden Hidden
"Manual Journal Entry" Hidden
"Update Settings" button Hidden Hidden

Settings Page Sections

Section owner admin accountant viewer
Organization Info (editable)
Tax Rates (editable)
Users List
Invite User form
Change User Role
Remove User
Delete Organization

Accountant and viewer roles do not have access to the Settings page — the nav item is hidden and direct URL access returns 403 BILKO-9001.


Data Scope Rules — Organization-Level Multi-tenancy

Every authenticated user has their organizationId embedded in the JWT access token payload:

interface AccessTokenPayload {
  sub: string       // User ID
  email: string
  role: UserRole
  orgId: string     // Organization ID — always present
  iat: number
  exp: number
}

organizationScope Middleware

The organizationScope middleware runs after authGuard and roleGuard on all data-access endpoints. It attaches req.organizationId from the JWT and enforces that every DB query is scoped to that organization.

// src/middleware/organization.middleware.ts
function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
  if (!req.user?.organizationId) {
    return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
  }
  req.organizationId = req.user.organizationId
  next()
}

Mandatory Query Scoping

Every Prisma query on organization-owned resources must include where: { organizationId: req.organizationId }. This prevents cross-organization data leakage even if a user somehow obtains a valid JWT with a different orgId.

// Example: invoice fetch — always org-scoped
const invoice = await prisma.invoice.findFirst({
  where: {
    id: req.params.id,
    organizationId: req.organizationId,   // MANDATORY — never omit
  }
})

if (!invoice) {
  throw new NotFoundError('BILKO-3001')   // Returns 404 — same as if not found
}

Returning 404 (not 403) when a resource exists in a different organization is intentional — it prevents enumeration of cross-org record IDs.

Data Isolation Guarantees

Scenario Behavior
User accesses own org's invoice 200 — returns invoice
User accesses invoice from another org 404 BILKO-3001 — treated as not found
User's JWT has invalid orgId 404 BILKO-2001 — organization not found
Deleted organization's records Cascade delete (defined in Prisma schema)
User removed from org but JWT still valid First request returns 404 BILKO-2001

All 15 database models with organizationId field enforce this scoping:

Global models (not org-scoped, shared across all organizations):


Role Assignment and Invitation Flow

Registration — Owner Assignment

When an organization is registered, the first (and only) owner is created:

POST /auth/register
  → Create Organization
  → Create User (role = 'owner')
  → Seed Chart of Accounts (country-based defaults)
  → Return JWT pair

The owner role cannot be granted by invitation. The POST /users/invite endpoint explicitly rejects role: 'owner' with 422 BILKO-9003.


Invitation Flow

An owner or admin can invite new users with roles admin, accountant, or viewer.

POST /users/invite
  { email, fullName, role: 'admin' | 'accountant' | 'viewer' }
  → Validate role (cannot be 'owner')
  → Create user record (passwordHash = null, isActive = false)
  → Generate one-time invite token (JWT, expires in 7 days)
  → Send invite email via SendGrid
  → Return { user, inviteLink }

The invitee clicks the link:

GET /auth/accept-invite?token=<JWT>
  → Verify token signature + expiry
  → Prompt user to set password (frontend form)

POST /auth/accept-invite
  { token, password }
  → Hash password
  → Set user.isActive = true
  → Invalidate invite token
  → Return JWT pair (auto-login)

Invite constraints:


Role Change Flow

Only the owner can change another user's role:

PUT /users/:id/role
  { role: 'admin' | 'accountant' | 'viewer' }
  → Validate: caller must be 'owner'
  → Validate: target is not owner (BILKO-2006)
  → Validate: target is not caller (BILKO-2007)
  → Update user.role in DB
  → Log to LoggedAction
  → Return updated user

Behavior after role change:


User Removal Flow

Only the owner can remove users:

DELETE /users/:id
  → Validate: caller must be 'owner'
  → Validate: target is not owner (BILKO-2006)
  → Validate: target is not caller (BILKO-2007)
  → Set user.isActive = false (soft delete — preserves audit trail)
  → Add all user's refresh tokens to blacklist
  → Log to LoggedAction
  → Return 204

User data (invoices created, expenses entered) is retained for audit trail purposes. The users table record remains with isActive = false. The user cannot log in after removal.


Permission Flow Diagram

flowchart TD
    REQUEST["API Request\nGET/POST/PUT/PATCH/DELETE /api/v1/*"] --> HELMET["Helmet\nSecurity headers"]
    HELMET --> CORS["CORS\nOrigin validation"]
    CORS --> RL["Rate Limiter\nper-IP / per-user"]
    RL -->|"429 BILKO-9005"| R429["429 Too Many Requests"]
    RL --> LOGGER["Morgan Logger\nHTTP access log"]
    LOGGER --> AUTH_GUARD["authGuard\nExtract Bearer token"]

    AUTH_GUARD -->|"No Authorization header"| R401A["401 BILKO-1005\nNo token"]
    AUTH_GUARD -->|"Token expired"| R401B["401 BILKO-1003\nToken expired"]
    AUTH_GUARD -->|"Invalid signature"| R401C["401 BILKO-1004\nInvalid token"]
    AUTH_GUARD -->|"Valid JWT"| EXTRACT["Extract claims\nsub, email, role, orgId"]

    EXTRACT --> ROLE_GUARD["roleGuard(allowedRoles)\nCheck role membership"]
    ROLE_GUARD -->|"Role not in allowedRoles"| R403["403 BILKO-9001\nInsufficient permissions"]
    ROLE_GUARD -->|"Role authorized"| ORG_SCOPE["organizationScope\nAttach req.organizationId"]

    ORG_SCOPE --> VALIDATE["Zod Validation\nschema.parse(req.body)"]
    VALIDATE -->|"Schema errors"| R422["422 BILKO-9003\nValidation failed"]
    VALIDATE -->|"Valid body"| HANDLER["Route Handler\n(module controller)"]

    HANDLER --> DB_QUERY["Prisma Query\nWHERE organizationId = req.organizationId"]
    DB_QUERY -->|"Record not in org"| R404["404 BILKO-Xxxx\nNot found"]
    DB_QUERY -->|"Business rule violation"| R400["400 BILKO-Xxxx\nBad request"]
    DB_QUERY -->|"DB error"| R500["500 BILKO-9006\nDatabase error"]
    DB_QUERY -->|"Success"| AUDIT["LoggedAction INSERT\nAppend-only audit trail"]
    AUDIT --> RESPONSE["200/201/204\nSuccess Response"]

    style R401A fill:#dc2626,color:#fff
    style R401B fill:#dc2626,color:#fff
    style R401C fill:#dc2626,color:#fff
    style R403 fill:#ea580c,color:#fff
    style R404 fill:#ca8a04,color:#fff
    style R400 fill:#ca8a04,color:#fff
    style R422 fill:#ca8a04,color:#fff
    style R429 fill:#ca8a04,color:#fff
    style R500 fill:#7c3aed,color:#fff
    style RESPONSE fill:#16a34a,color:#fff
    style DB_QUERY fill:#336791,color:#fff
    style AUDIT fill:#dc2626,color:#fff

Role Assignment Diagram

flowchart LR
    subgraph "Registration"
        REG["POST /auth/register"] --> OWNER["owner\n(auto-assigned)"]
    end

    subgraph "Invitation — by owner or admin"
        INVITE["POST /users/invite\nrole: admin | accountant | viewer"] --> PENDING["Pending User\n(isActive = false)"]
        PENDING -->|"Accepts invite within 7 days"| ACTIVE["Active User\n(assigned role)"]
        PENDING -->|"Invite expires"| EXPIRED["BILKO-1012\nInvalid invite"]
    end

    subgraph "Role Changes — by owner only"
        OWNER -->|"PUT /users/:id/role"| CHANGE["Role updated\nEffective on next JWT refresh"]
    end

    subgraph "User Removal — by owner only"
        OWNER -->|"DELETE /users/:id"| SOFT_DEL["isActive = false\nRefresh tokens invalidated"]
    end

    style OWNER fill:#00E5A0,color:#000
    style ACTIVE fill:#16a34a,color:#fff
    style SOFT_DEL fill:#dc2626,color:#fff
    style EXPIRED fill:#ca8a04,color:#fff

Middleware Implementation Reference

// src/middleware/auth.middleware.ts

type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'

// Step 1: Verify JWT and attach user to request
async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
  }

  const token = authHeader.substring(7)

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload
    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
      organizationId: payload.orgId,
    }
    next()
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: { code: 'BILKO-1003', message: 'Session expired. Please refresh your token.' } })
    }
    return res.status(401).json({ error: { code: 'BILKO-1004', message: 'Invalid token. Please log in again.' } })
  }
}

// Step 2: Check role authorization
function roleGuard(allowedRoles: UserRole[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: {
          code: 'BILKO-9001',
          message: 'You do not have permission to perform this action.',
          details: {
            required: allowedRoles,
            current: [req.user.role],
          },
        },
      })
    }

    next()
  }
}

// Step 3: Apply organization scope
function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
  req.organizationId = req.user!.organizationId
  next()
}

// Convenience: all authenticated roles
const allRoles: UserRole[] = ['owner', 'admin', 'accountant', 'viewer']

// Usage in routes:
router.post('/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  organizationScope,
  validate(CreateInvoiceSchema),
  invoicesController.create
)

router.get('/invoices',
  authGuard,
  roleGuard(allRoles),
  organizationScope,
  invoicesController.list
)

router.patch('/expenses/:id/approve',
  authGuard,
  roleGuard(['owner', 'admin']),
  organizationScope,
  expensesController.approve
)

Summary Table

Capability owner admin accountant viewer
Invoices
View invoices
Create / edit invoice
Send invoice
Mark invoice paid / cancel
Expenses
View expenses
Create / edit expense
Approve expense
Delete expense
Contacts
View contacts
Create / edit contact
Deactivate contact
Banking
View bank accounts & transactions
Create bank account
Import CSV / reconcile
GL Transactions
View transactions
Create manual journal entry
Reports
View all reports
Chart of Accounts
View accounts
Create / edit / deactivate accounts
Settings
View tax rates
Update tax rates
Update org details
Users
View user list
Invite user
Change user role
Remove user
Organization
Delete organization

End of Roles and Permissions Documentation

Account Lockout (Brute-Force zaštita) — A12

Bilko — Account Lockout (Brute-Force zaštita) — A12 / MC #104687

Status: implementirano 2026-07-03, commit 13b3a729 na feat/task-104687 (Wave 1 — čeka merge u dev) Vlasnik: CodeCraft backend · Proveo PASS (nezavisna verifikacija svih 5 AC)

Ponašanje

Implementacija

Sloj Šta
Migracija V108__account_lockout.sqlusers.failed_login_attempts INT NOT NULL DEFAULT 0, users.locked_until TIMESTAMPTZ NULL (aditivno) + drop/recreate 3 bilko_auth.* SECURITY DEFINER lookup funkcije (RETURNS TABLE promjena; OWNER/GRANT identični V31/V66 originalima — diffano)
Servis AuthService.kt — lock-check prije verifyPassword; brojač = jedan atomski parametrizovani UPDATE (nema izgubljenih inkremenata pod konkurencijom); adminUnlock(orgId, userId)
HTTP LockedExceptionBILKO-AUTH-005 (423) u StatusPages.kt; ruta unlock u UserManagementRoutes.kt; dokumentovano u ERROR-CODES.md + openapi.yaml
Testovi AuthServiceLockoutTest.kt — 5 scenarija (6. pokušaj s tačnom šifrom → 423; reset na uspjeh; cooldown expiry; admin unlock; cross-org 404). Izvršeno 5/5 + regresija AuthServiceTest 31/31, skipped=0 (XML ground truth)

Bitno: javna login ruta je 410

POST /api/v1/auth/login vraća 410 Gone platformski (WP4/MC #103144 — Bilko je Entra-only, password login penzionisan). Lockout je time defense-in-depth: javna brute-force površina je ZATVORENA (Proveo live probe: 7×410 na api.bilko.cloud), a lockout se automatski primjenjuje ako se ruta ikad reaktivira. Odluka: rutu NE reaktivirati radi live-423 demonstracije.

Poznati follow-up

Evidence

Ops napomene (otkriveno tokom taska)

  1. Gitleaks pre-commit je gađao ../../.gitleaks.toml — rušio SVAKI commit iz nested .claude/worktrees/* (fail-closed "Secrets detected" bez skena). Fixano u .husky/pre-commit (commit 13b3a729): config je tracked na repo root-u → --show-toplevel/.gitleaks.toml.
  2. Testcontainers silent-skip: ~/.testcontainers.properties je pokazivao na legacy docker.raw.sock koji Ryuk ne može bind-mountati na aktuelnom Docker Desktopu → SVI integration testovi tiho SKIPPED uz "BUILD SUCCESSFUL". Fix: unix:///var/run/docker.sock + ryuk.disabled=true. Lekcija: uvijek čitati tests=/skipped= iz testsuite XML-a, ne vjerovati zelenom buildu.

KPO Export RBAC Fix — MC #105326

KPO Export RBAC Fix — MC #105326

Finding

Proveo adversarial verify on MC #105321 (2026-07-12) found that GET /reports/kpo/export/pdf and GET /reports/kpo/export/xlsx in ReportRoutes.kt were missing the requirePermission(principal, "report:export") guard that every other export route (KIR, KPR, VAT, P&L, balance-sheet, accountant CSV package) already has. Git diff confirmed the gap pre-dated the T1b diff — it was a pre-existing asymmetry, not a regression.

Fix

Added if (requirePermission(principal, "report:export")) return@get to both KPO export routes in apps/api/src/main/kotlin/no/alai/bilko/routes/ReportRoutes.kt, matching the exact pattern used at the other 11 export-route call sites in the same file (13 total after the fix).

Tests

Added 4 integration tests to apps/api/src/test/kotlin/no/alai/bilko/routes/ReportRoutesHttpIntegrationTest.kt:

Verified: ./gradlew integrationTest --tests "no.alai.bilko.routes.ReportRoutesHttpIntegrationTest" --rerun → BUILD SUCCESSFUL, 0 failures / 0 errors.

P2P Peer Verify

Proveo independent pre-verifier: PASS. mesh-thr-d41602c0-65fa-4d4c-bca4-8fe770f11704 / mesh-msg-e3c3869f-f160-4e65-a9d0-8ba27f000686. Transcript: /tmp/alai/p2p-pairing-evidence/mc105326-transcript.md.

Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook — MC #105742

Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook

MC: #105671 (dizajn) + #105741 (Task A) + #105742 (Task B) + #105744 (Finverge BA verifikacija) + #105747 (RS redizajn, otvoreno) Status (2026-07-15): Task A merged (PR 164, b1417252, azdo/main). Task B PR 165 open (6b9ebe5c), NOT merged — Proveo P2P adversarial verify još u toku (mesh thread otvoren, guard-rail auto-BLOCKED nije pravi verdikt). HR-07/RS-07 katalog i dalje enabled: false.


1. Šta feature radi

Payment-event obaveza je Bilko-ova podsjetnik logika koja se okida na stvarnu isplatu plaće, ne na kalendarski datum kao postojeći mjesečni podsjetnici (npr. MIP-1023, Obrazac 1002).

Tok (HR, implementirano):

POST /payroll/organizations/{orgId}/payslip-runs/mark-paid
  { periodYear, periodMonth, paymentDate }
        │  (V123: payslips.payment_date, SERIALIZABLE transakcija)
        ▼
PayrollService.markPayrollRunPaid()
        │  postavlja payment_date na SVE payslip redove org/period
        │  (ista transakcija, samo na write-grani, nikad na idempotent no-op)
        ▼
PaymentEventObligationService.onPayrollRunPaid(orgId, periodYear, periodMonth, paymentDate)
        │  HR org → čita HR-07 (JOPPD) iz kataloga SAMO za title/form metadata
        │  due_date = resolveNextBusinessDay(paymentDate)
        │  RS/BA org → no-op (vidi §2b, §2c)
        ▼
upsert ComplianceDeadlines  (keyed: org, year, deadlineType, period)
        │  korekcija datuma → UPDATE istog reda, ne duplikat
        ▼
notifyIfNeeded()  — JEDNA in-app + email notifikacija na kreiranje, VAN transakcije
        (bez T-7/T-1 kadence — nema lead-time koncepta, rok = dan isplate)

resolveNextBusinessDay(date) — čista funkcija, semantika je roll-forward SAMO ako datum pada na neradni dan, ne "uvijek sljedeći radni dan":

Ovo je vikend-only MVP (bez praznik-tabele — vidi §2d).


2. Scope odluke s razlozima (ključno za budućnost)

(a) BA_FED (FBiH) i BA_RS isključeni iz payment-event tipa — Finverge verifikacija #105744, verdikt NE

MIP-1023 (FBiH) i Obrazac 1002 (RS-BiH) su fiksni mjesečni agregatni izvještaji, ne payment-event:

Oba entiteta su konfirmisana sa 7+ (FBiH) i 6+ (RS-BiH) nezavisnih izvora (FEB, paragraf.ba, advokat-prnjavorac.com, fineks.ba, poreskaupravars.org, vladars.rs). Već su ispravno modelovani u postojećem fiksno-mjesečnom katalogu (PR 157, #105641, CEO-odobreno #105640) — Task B ih namjerno NE dira. Ne dodavati BA_FED/BA_RS u payment-event tip triger.

(b) RS (Srbija) isključena iz Task B builda — red-zone FAIL, #105742 redzone-tax-verdict.md

Red-zone porezna verifikacija (persona porez-hr-105742, web-verified 5+ nezavisnih izvora: porezionline.rs, aktivasistem.com, paragraf.rs, purs.gov.rs) je dala FAIL verdikt na originalni HR+RS dizajn:

Posljedica za build: Task B je implementiran HR-only. RS org u onPayrollRunPaid() je dokumentovan no-op — nema deadline red, nema notifikaciju, log linija referencira #105747. RS-07 katalog entry ostaje netaknut (enabled: false).

(c) HR-07/RS-07 katalog enabled: false do praznik-tabele (#105743, Lexicon gate)

ObligationEngine.generateDeadlines() i dalje ne emituje HR-07/RS-07 (potvrđeno testom ObligationEngineUnaffectedTest — 3/3 PASS, uključujući provjeru protiv REALNOG učitanog kataloga). Payment-event insertion je potpuno odvojen kod-put od kalendarskog generatora — katalog služi samo kao metadata SSOT (title/form), ne kao evaluator za ovaj tip roka. enabled: true čeka:

  1. Praznik-tabela (#105743, MVP je trenutno vikend-only)
  2. Lexicon sign-off na requires_expert_validation konvenciju (isti obrazac kao postojeći RS-07 flag)

(d) Vikend-only MVP — praznici su Task C (odvojen, ne blokira A/B)

Dizajn (#105671 §4) eksplicitno je priznao ovaj gap kao MVP-known-limitation, ne skriveni nedostatak: puni praznik-kalendar zahtijeva holidays referentnu tabelu po jurisdikciji/godini + Lexicon/tax-expert sign-off (Zakon o blagdanima HR vs Zakon o praznicima RS se razlikuju). Cost/risk ako se ship-a bez praznika: pogrešan rok kad je isplata 1-2 radna dana prije praznika (npr. dan prije Uskrsnog ponedjeljka — vikend-only logika kaže "sljedeći dan" = sam praznik, što je netačno). Task C nosi ovu tabelu odvojeno, ne blokira A/B ship.

Bitna napomena (red-zone stavka 1): AC1 pseudokod (Mon-Fri passthrough, Sat/Sun roll-forward) je ispravan izvor istine za JOPPD pravilo. Prozna formulacija u originalnom dizajn-sažetku ("next business day after payment date") je dvosmislena i može zavesti na pogrešnu "uvijek +1" implementaciju — kod treba pratiti AC1, ne prozni opis.


3. Tehnika

SERIALIZABLE transakcija (Proveo P2P race nalaz + fix)

Proveo adversarial P2P review na #105741 (proveo-angie-105741, verdikt PASS s jednim MEDIUM nalazom) je našao da markPayrollRunPaid() radi read-then-conditionally-write pod default TRANSACTION_READ_COMMITTED izolacijom, bez SELECT...FOR UPDATE i bez SERIALIZABLE. Konkretan race: dva konkurentna mark-paid poziva za isti org+period sa RAZLIČITIM datumima mogu oba pročitati pre-write stanje, oba računati changed=true, i kasniji UPDATE tiho pobjeđuje (lost update) — "gubitnik" dobija 200/changed=true iako je njegov upis pregažen.

Fix (commit 3aa0ece2): markPayrollRunPaid() sada koristi

orgTransaction(
    organizationId = organizationId,
    transactionIsolation = java.sql.Connection.TRANSACTION_SERIALIZABLE,
) { ... }

1:1 kopija postojećeg repo presedana za isti read-then-derive-then-write oblik (ExpenseService.kt:267-269, InvoiceService.kt:661-664 — broj-generacija). Bez retry-on-serialization-failure wrappera (repo-wide grep potvrdio: nijedan presedan ga nema).

Novi konkurentni test (2 real threads, CountDownLatch, isti pattern kao FiscalDeviceSequenceTest): provjerava da nakon dva istovremena poziva sa različitim datumima svi payslip redovi u run-u nose ISTI konačni payment_date — nema split-state. 7/7 PASS, ponovljeno 3x, 0 flake.

Upsert keying

ComplianceDeadlines upsert je keyed na (organizationId, year, deadlineType, period) — korekcija datuma (npr. payroll admin ispravi pogrešnu isplatu) ažurira POSTOJEĆI red umjesto da duplira. Potvrđeno testom "correction updates the existing deadline row instead of duplicating" (assertion na isti deadline id kroz korekciju).

Notifikacija van transakcije

notifyIfNeeded() se zove IZ route handler-a, NAKON što dbQuery{} vrati rezultat — izvan markPayrollRunPaid()-ove SERIALIZABLE transakcije. Fires samo ako result.obligationResult nije null (tj. nikad na idempotent no-op granu).

RS/BA no-op s testovima

onPayrollRunPaid() grana na country != HR → no-op, pokriveno testovima "RS org mark-paid does not create any compliance deadline" i "does not fire any notification". Isti no-op put pokriva i BA_FED/BA_RS (§2a).

Migracija

Task A: V123__payslip_payment_date.sqlALTER TABLE payslips ADD COLUMN IF NOT EXISTS payment_date DATE NULL + indeks na (organization_id, period_year, period_month, payment_date). Flyway-replay test (V121 lekcija): migracija privremeno uklonjena → test PADA (3/3 FAILED), vraćena → PASS — dokazuje da test stvarno testira migraciju, ne samo Kotlin model.

Task B: Nema nove migracije — ComplianceDeadlines (V9) je već imala sve potrebne kolone, potvrđeno čitanjem postojeće Exposed definicije prije pisanja koda.

PR / commit trag

Task Repo PR Commit Status
A (#105741) Bilko PR 164 d7d27d0c + fix 3aa0ece2 merged (b1417252)
B (#105742) Bilko PR 165 6b9ebe5c open, adversarial verify u toku

4. Runbook / gotchas

Kako se aktivira za prave klijente

  1. #105743 — sagraditi holidays referentnu tabelu (jurisdikcija, datum, naziv), seed za HR + RS za tekuću + narednu godinu.
  2. resolveNextBusinessDay() proširiti da konsultuje tabelu kad postoji za org-ovu jurisdikciju, fallback na vikend-only ako nema podataka za tu godinu (isti defanzivni pattern kao OrgComplianceProfile.default()).
  3. Lexicon sign-off zabilježen po requires_expert_validation konvenciji — TEK ONDA flip enabled: true na HR-07 (i RS-07 nakon #105747 redizajna, vidi ispod).
  4. Bez ovog koraka HR-07/RS-07 ostaju enabled: false u katalogu — payment-event insertion put radi nezavisno od tog flaga (flag utiče samo na ObligationEngine-ov kalendarski generator, ne na PaymentEventObligationService), ali production-ready gate je i dalje ovaj sign-off.

RS (Srbija) redizajn — #105747, otvoreno

RS-07 (PPP-PD) NE smije se implementirati po istom "mark-paid → deadline poslije → jedna notifikacija" šablonu kao HR-07. Minimalne opcije za redizajn (iz red-zone verdikta):

Ne kopirati HR-07 resolveNextBusinessDay(paymentDate) šablon direktno na RS-07 bez ove korekcije.

PAYROLL-PHASE2-PLAN.md §2b — zastario opis

Repo dokument docs/regulatory/PAYROLL-PHASE2-PLAN.md §2b sadrži netačan/zastario opis PPP-PD roka ("Due by the 15th of the month following payment") koji je u sukobu i sa web-verified pravilom i sa RS-07 katalog tipom (PER_PAYMENT_EVENT). Isti tekst identifikovan identičan u više worktree kopija (angie-105568, codecraft-105355, codecraft-105193/4, codecraft-105192, proveo-105276, codecraft-105687). Fix pripada #105747 — sinhronizovati sve kopije nakon RS redizajna, ne prije.

Prije mc.js ready/done na #105742


Izvori (evidence)

Bilko — Financial Audit Trail & Retention Architecture

Bilko — Financial Audit Trail & Retention Architecture

Parent: MC #105872 · Plan: ~/.claude/plans/greedy-greeting-flame.md (CEO-approved 2026-07-17) · Design team: Petter Graff (lead), Martin Kleppmann (event-log semantics), Bruce Momjian (Postgres)

CEO mandate (2026-07-17): Bilko is serious accounting software — every mutation of a financial record must leave a trace ("if someone deletes a file and claims we lost their data, we must be able to prove what happened"). Files themselves do not have to be kept once their legal retention window expires, but the trace of what happened must survive forever.

Status at time of writing (2026-07-17)

Phase A (foundation) is merged to azdo/main via Azure DevOps PR #178 (commit c44ee7c9, squash-merge of the full A2–A8 + C2 chain, 24 files, 4707 insertions) — genuinely a two-parent merge commit (git cat-file -p c44ee7c9 shows parents 3f144e82/ce7ee96f), consistent with a real PR merge, though this specific instance doesn't carry azdo's usual auto-generated "Merge pull request N from branch into main" message text that its neighboring commits do (those PRs were completed via REST rather than the azdo web UI the same evening, per team lead). Three sibling PRs from the same wave: #179 (Lexicon legal docs, commit a71a0b15), #180 (D1/D2 demo-country fix, commit 3f144e82), #181 (B4 storage consolidation, commit ce64b93b). PR numbers/attribution here are as reported by the team lead; this session could not independently query the azdo PR REST endpoint (az repos pr show fails with a local keychain error, -25308 Can't fetch password from system, unrelated to the PAT itself — see ~/.claude/projects/-Users-makinja/memory/technical_azdo_git_auth_paths_sp_not_in_org_2026-07-17.md, PAT was rotated and confirmed working earlier the same day). The merge-commit hashes and their file contents are independently tool-verified (git log/git show --stat against azdo/main). This page documents what is live in main today and flags explicitly what is still open.

Item Status
A1 — RS/BA/HR retention law verification Done (MC #105873)
A2/A3 — financial_audit_log + document_retention_manifest schema Merged main (V124, V125), PR #178
A4/A5 — capture trigger, pilot + 6-table fan-out Merged main (V126, V128), PR #178
A6 — actor/request-id context plumbing Merged main (3 adversarial rounds, see below), PR #178
A7 — backfill of existing rows Merged main (V129), PR #178
A8 — activity feed + entity timeline read API Merged main (V130), PR #178; web UI panel not built, flagged to Vizu
C1 — deleted_by/deletion_reason columns Merged main (V127), PR #178
C2 — InvoiceService.deleteInvoice USER_DELETE snapshot Merged main, PR #178
B4 — Storage: R2/GCS → Azure Blob SDK, deleteBlob() Merged main (ce64b93b), PR #181
B1 — retention citation fixes + per-type override table MC #105880, in progress — not yet in code
B5 — upload path writes document_retention_manifest row MC #105884, in progress
B2 — purge worker, dry-run mode MC #105881 created, blocked — red-zone (Momjian+Graff), waiting on A3+B1
B3 — purge worker, live mode MC #105882 created, blocked — waiting on B2 + ≥1 clean dry-run sprint on stage + explicit CEO sign-off before first live activation
D1/D2 — demo-country display bug (parallel workstream, same wave, not part of audit trail scope) Fixed and merged (3f144e82), PR #180; documented separately, referenced here only for context

Do not treat B1/B2/B3/B5 as done. MC #105881/#105882 exist but are deliberately blocked, not started — the purge worker does not run anywhere today; nothing purges blobs or applies retention. This page's "purge runbook" section below describes the design, not a currently operable procedure.

Why a third audit table

Two audit mechanisms already existed before this program:

Neither covers ordinary financial-entity mutations (invoices, expenses, contacts, transactions). Before this program, InvoiceService.deleteInvoice was a hard delete with no audit trail at all (InvoiceService.kt:1004-1018 pre-change) — the exact gap the CEO mandate calls out.

Kleppmann's arbitration: build a third table, not a consolidation of V1/V51 and not per-domain tables. It carries org_id + country_code from day one (unlike V1), so ADR-017 Phase 2B-style partitioning/tenant-query patterns work immediately.

Schema

financial_audit_log (V124, apps/api/src/main/resources/db/migration/V124__financial_audit_log.sql)

Partitioned, append-only audit trail for financial-entity mutations and read-sensitive actions.

event_id       BIGINT GENERATED ALWAYS AS IDENTITY
org_id         UUID NOT NULL REFERENCES organizations(id)
country_code   VARCHAR(10) NOT NULL      -- denormalized, indexed, NOT the partition key
entity_type    VARCHAR(32)
entity_id      UUID
action         VARCHAR(24) NOT NULL      -- INSERT | UPDATE | DELETE | EXPORT | DOWNLOAD |
                                          -- RESTORE | USER_DELETE | RETENTION_PURGE
actor_user_id  UUID REFERENCES users(id) ON DELETE SET NULL
actor_label    TEXT
before_data    JSONB
after_data     JSONB
changed_fields TEXT[]
reason         TEXT
request_id     TEXT
client_ip      INET
is_backfilled  BOOLEAN NOT NULL DEFAULT false
occurred_at    TIMESTAMPTZ(6) NOT NULL DEFAULT now()

PRIMARY KEY (event_id, occurred_at)   -- occurred_at required in PK: it's the partition key

Design decisions (Momjian + Kleppmann arbitration, all tool-verified against the live schema, not assumed):

document_retention_manifest (V125, generalizes the existing V78 hr_einvoice_archive WORM pattern to all uploaded files)

manifest_id       BIGSERIAL PRIMARY KEY
org_id            UUID NOT NULL REFERENCES organizations(id)
entity_type       VARCHAR(32)
entity_id         UUID
original_filename TEXT NOT NULL
content_type      TEXT
file_size         BIGINT
sha256_hex        CHAR(64) NOT NULL
storage_backend   TEXT
storage_path      TEXT
uploaded_by       UUID REFERENCES users(id) ON DELETE SET NULL
uploaded_at       TIMESTAMPTZ(6) NOT NULL DEFAULT now()
blob_purged_at    TIMESTAMPTZ(6)
blob_purge_reason TEXT
purged_by_job     TEXT

This table is permanent by design — it has no retain_until because it never expires. It is the direct, literal answer to the CEO scenario: a row here proves a file existed, who uploaded it, its exact SHA-256, and — once the blob itself is purged — exactly when, why (RETENTION_PURGE vs USER_DELETE, matching financial_audit_log.action), and by which job run. bilko_app has INSERT+SELECT only; there is no append-only trigger here (unlike financial_audit_log) because a legitimate, narrow future UPDATE path exists — the not-yet-built purge worker marking blob_purged_at — and that grant is deliberately deferred to the Phase B2/B3 migration that creates the bilko_purge_worker role, since granting to a role that doesn't exist yet is a Flyway failure.

Capture mechanism (V126 pilot on invoices, V128 fan-out to 6 more tables)

Arbitration point 1 (Graff + Momjian, overruling a pure application-level design): capture is a generic AFTER DB trigger (capture_financial_audit(), to_jsonb(OLD/NEW), keyed off TG_TABLE_NAME), synchronous and transactional — not pg_notify/async, which would create a durability gap. This was chosen specifically because this same repo already proved app-level capture gets forgotten: deleteInvoice never called logged_actions before this program. Covers invoices, expenses, contacts, transactions, bank_accounts, bank_transactions, invoice_items.

Read-sensitive actions the trigger structurally cannot see — export, download, restore — are Kleppmann's second arbitration point: those are application-level events written into the same table from the service layer, since a DB trigger only fires on SELECT... it never fires at all, it can't be the mechanism for read events.

C1/C2 — soft-delete and hard-delete audit coverage

A8 — read API

GET /orgs/{id}/activity (org-wide feed) and GET /{entity}/{id}/timeline (single-entity history), both paginated, both using the composite indexes created in V124. Gated by a new activity:read permission (V130), seeded only for owner/admin — not viewer, not accountant — because before_data/after_data carry full row snapshots including PII (e.g. a contact's OIB/JMBG-equivalent tax ID). Both endpoints filter directly on financial_audit_log.org_id = principal.organizationId rather than the existing ResourceAccessFilter.requireOrgOwnership helper, because that helper's table coverage predates this program and is missing 3 of the 7 audited tables. Cross-org access returns a 404 for the org feed and an empty list (not a 403/404) for the entity timeline, specifically to avoid leaking entity-existence across tenants via response-code side channel. The web UI panel for this API was explicitly scoped out — flagged to Vizu as a separate task rather than built as a rushed stub, per the implementing agent's own assessment that building it to the existing app's quality bar is realistically over an hour of frontend work.

A6 saga — three adversarial rounds, two real bugs caught

A6 (SET LOCAL app.current_user_id / app.current_request_id, MC #105877) is the most important lesson from this program for anyone touching Ktor plugin install-order in this codebase. It went through three rounds of Momjian build → Parisa Tabriz (Securion) adversarial verify before landing.

Round 1 finding (ThreadLocal vs. coroutine dispatcher hop): the original implementation used a plain ThreadLocal to carry actor context. dbQuery{} dispatches onto Dispatchers.IO, which is a thread pool — a ThreadLocal set on the request-handling thread is simply not visible on whatever IO-pool thread the query actually executes on. Result: actor_user_id silently NULL for essentially all real requests. Fixed by switching to a kotlinx.coroutines ThreadContextElement, which is designed to survive exactly this kind of dispatcher hop.

Round 2 finding (install-site / phase-ordering — the more interesting one): even with the coroutine-context mechanism now correct in isolation, it was still not effective end-to-end, because installOrgScopePlugin() was installed via intercept(ApplicationPhase.Plugins) at the Application.module() level — which runs before Ktor's routing tree even dispatches into the authenticate("bilko-jwt") { } block, i.e. before BilkoPrincipal is resolved. So call.principal<BilkoPrincipal>() inside the interceptor was always null in production, authenticated request or not.

This is the same class of bug as MC #104962, which had already found and fixed the identical mistake for the sibling TrialGatePlugin: install at Application level and the plugin sees a permanently-null principal, no matter how correct its internal logic is. TrialGatePlugin's own doc comment says this explicitly — but installOrgScopePlugin() had not been given the same fix.

The org_id side of the same mechanism was not affected, and understanding why is the actual transferable lesson: orgTransaction(organizationId: String, ...) takes organizationId as an explicit function parameter, sourced by every real call site via effectiveOrgId(principal) called inside the route handler (which does run after auth). So org_id never depended on the broken plugin's principal read at all — currentOrgIdThreadLocal turned out to be dead code in production (zero call sites). actor_id had no equivalent parameter — its only source was the broken plugin — so only the actor side silently failed.

Fix: installOrgScopePlugin() was changed from an Application extension to a Route extension, intercepting ApplicationPhase.Call (not Plugins), called as the literal first statement inside authenticate("bilko-jwt") { } in Routing.kt — before install(TrialGatePlugin) and all route registrations. TrialGatePlugin couldn't be copied verbatim as a pattern because it uses the on(AuthenticationChecked) hook, which is a plain synchronous callback with no proceed() — fine for a check-and-throw, but unable to keep a coroutine context element alive forward into a later route handler's dbQuery call, which is exactly what A6 needs.

Round 3's regression test (OrgScopeActorContextHttpIntegrationTest.kt) is itself a lesson worth keeping: it deliberately goes through the real HTTP client, the real JWT verifier, and the real routing tree — not the withActorContextForTest seam that round 2's test used, which structurally could not have caught this bug because it never calls installOrgScopePlugin()'s actual install path at all.

Lesson for future Ktor plugin work in this codebase: if a plugin needs call.principal<T>() to be non-null, verify empirically (not by inspection) which phase it actually observes the principal at — ApplicationPhase.Plugins at the Application level is provably too early for anything gated by authenticate(...), regardless of where in the source file the intercept() call is textually nested. This has now bitten two different plugins (TrialGatePlugin in #104962, OrgScopeSessionVariable here) with the same root cause.

Retention law — verified findings (MC #105873, primary sources)

Jurisdiction Books (journal/ledger) Auxiliary books Financial statements e-invoice (fiscalized) Payroll records
Croatia (HR) at least 11 years (ZOR NN 78/2015 čl. 10(2)) at least 11 years permanent* (pin at B1) 6 years (Zakon o fiskalizaciji NN 89/2025 čl. 35) ≥6 years / analytics permanent
Serbia (RS) 10 years (Zakon o računovodstvu, "Sl. glasnik RS" 73/2019 čl. 28) 5 years 20 years n/a (SEF rules separate — pin at B1) permanent
BiH — Federation (BA_FED) at least 11 years (Zakon o računovodstvu i reviziji FBiH, "Sl. novine FBiH" 15/2021 čl. 49) pin at B1 pin at B1 n/a pin at B1
BiH — Republika Srpska entity (BA_RS) at least 10 years ("Sl. glasnik RS" 115/2025, article number TBD — not yet pinned from primary text) at least 5 years permanent n/a pin at B1

Two compliance findings from this verification are not yet fixed in code — they are B1's job, not done:

The BA_RS article number in the new 115/2025 law could not be pinned from available sources (target site had a TLS failure); the 10/5/permanent figures are confirmed via secondary sources, but B1's implementer must pin the exact article from the official Sl. glasnik RS 115/25 text before it is cited in any docs or code comment — this is a repeat of the same "don't propagate an unverified article number" discipline that caught the RS 62/2013→73/2019 gap in the first place.

GDPR — erasure vs. retention

Position (plan arbitration point 7, not yet formalized in Privacy Policy/DPIA — that's Lexicon's MC #105277, linked, separate from this page): under GDPR Art. 17(3)(b), the right to erasure does not apply where processing is necessary for compliance with a legal obligation — here, the accounting-law retention requirements in the table above. A financial audit snapshot (financial_audit_log.before_data/after_data, or a document_retention_manifest row) is not deleted on a user erasure request during the applicable retention window. The only erasure-adjacent action available is pseudonymizing actor_label/user-identifying fields after the retention window closes, and that is explicitly scoped as: every erasure request goes through Securion/legal review, never a self-serve deletion path, and never automatic.

Purge worker — design (not yet built; B2/B3 are open tasks)

This section is a design skeleton for the not-yet-implemented RetentionPurgeWorker, so it is documented in the same place as the schema it operates on. Nothing described below exists in the running system today.

Relationship to ADR-017 Phase 2B

ADR-017 Phase 2B originally proposed country-code-list partitioning. It was never implemented — Flyway's actual HEAD was V116 at the start of this program (V36/V37 slots referenced in the old ADR discussion were long since consumed by unrelated migrations), so this program's financial_audit_log design is effectively a green-field implementation of "partition a big multi-tenant table," not a migration of an existing partitioned table. Momjian's rebuttal, adopted here: partition by occurred_at (RANGE, monthly) with country_code as a plain indexed column, not a partition key — purge is fundamentally a time predicate, and a country-list partition scheme would need to be extended by hand every time a new market is added, for no purge-performance benefit.

Testing discipline (applies to every migration in this program)

Every migration listed above has a companion V1xxMigrationTest.kt (Testcontainers postgres:16) that does a full V1..V1xx replay, not a fresh-schema shortcut — this is the direct lesson from the V121 incident (Exposed's SchemaUtils.create()-based test schemas do not see CHECK constraints introduced by a migration; the only way to prove a constraint is real is to replay the actual migration chain and show that removing it makes the negative test fail). Each migration test includes negative tests (UPDATE/DELETE on the audit tables must throw, for both bilko_app and bilko_admin), an RLS cross-org test, and — where relevant — a partition-existence/partition-drop test.

Evidence index

Task Evidence
A1 retention law verification ~/system/evidence/105873/a1-retention-verification.md
A6 build (3 rounds) ~/system/evidence/105875/momjian-review.md
A6 adversarial verify (Securion, 3 rounds) ~/system/evidence/105877/parisa-verify.md, ~/system/evidence/105877/verdict.md
A7 backfill ~/system/evidence/105878/verdict.md
A8 activity/timeline API ~/system/evidence/105879/verdict.md
B4 storage consolidation ~/system/evidence/105883/verdict.md
C1 soft-delete columns ~/system/evidence/105885/verdict.md
C2 delete snapshot ~/system/evidence/105886/verdict.md
D1 demo-country bug root cause ~/system/evidence/105874/d1-rootcause.md
D2 demo-country bug fix + E2E ~/system/evidence/105887/d2-verdict.md

Open follow-ups

ADR outline (for a future repo-side ADR, not written to the repo by this task)

Proposed title: ADR-0XX — Financial Audit Trail: append-only partitioned log, not a consolidation of V1/V51

  1. Context: CEO mandate, gaps in V1/V51 coverage, deleteInvoice hard-delete-with-no-trail example.
  2. Decision: third table (financial_audit_log), RANGE-partitioned by occurred_at monthly, country_code denormalized not partition key; dual append-only enforcement (REVOKE + trigger); hybrid capture (DB trigger for mutations, application-level for read-sensitive actions); permanent document_retention_manifest for file-level trace, decoupled from blob lifecycle.
  3. Alternatives considered and rejected: consolidating into V1 or V51 (Kleppmann — wrong semantics, V1 has no tenant context); async/pg_notify capture (durability gap); country-code partitioning per original ADR-017 Phase 2B sketch (Momjian — no purge-performance benefit, extra operational burden per new market); hash-chained tamper-evidence (deferred, not rejected — revisit only on explicit regulatory/enterprise requirement).
  4. Consequences: purge becomes a partition-drop operation once B2/B3 land; every future table needing financial audit coverage follows the V126/V128 trigger-fan-out pattern; A6's install-site lesson should be called out as a standing Ktor-plugin gotcha for this codebase.
  5. Status: Phase A implemented and merged; Phase B (retention enforcement) open.

Bilko — Retention entity_type mapping (V133, MC #105965)

Retention entity_type mapping (V133)

MC: #105965 (parent #105872 audit-trail program) · Autor: Bruce Momjian (CodeCraft) · Verdict: PASS (FORGE MLX peer-verify PASS) · Grana: azdo feat/105965-entity-type-mapping (nije u main)

Problem

document_retention_manifest.entity_type je čuvao IMENA TABELA ('expenses','inbox_items' iz B5), a entity_type_retention_policy (B1) je seedovana PRAVNIM KATEGORIJAMA ('isprave','eracun'). Nisu se mapirali → RetentionPurgeWorker fail-safe skip → dry-run nalazi ~0 kandidata. Worker je (linije 53-68, 336-342) eksplicitno imenovao ovaj gap kao MC #105965.

Rješenje — dedicated lookup tabela

entity_type_mapping(jurisdiction, table_entity_type, legal_entity_type) (V133). Odluka: zasebna tabela, NE kolona na policy tabeli.

Testovi (V121 standard, Testcontainers postgres:16)

21/21 zeleno: V133MigrationTest (5: seed exactness, UNIQUE, CHECK, composite-FK rejection, grants) + 16 RetentionPurgeWorkerTest uklj. candidatesFound > 0 na realnom seedu + companion "ukloni mapping red → isti kandidat nestane" (doslovni V121 'dokaz = ukloni → test pada'). V131/V132 regresija re-run zelena.

Odnos prema purge lancu

Ovo je PREP: odblokira #105881 (B2 dry-run). B3 live purge (#105882) ostaje CEO-sign-off gated — nijedan živi DELETE nije pokrenut. Evidence: ~/system/evidence/105965/verdict.md.

Bilko — RetentionPurgeWorker DRY-RUN (B2, MC #105881)

RetentionPurgeWorker DRY-RUN (B2)

MC: #105881 (parent #105872) · Autor: Bruce Momjian (CodeCraft) + Graff red-zone review · Verdict: PASS (FORGE MLX peer-verify PASS) · Grana: azdo (rebase tip 844fc11c, NIJE main-merge dok red-zone gate ne prođe)

Šta radi

Dry-run purge worker: čita document_retention_manifest + entity_type_mapping (V133) + entity_type_retention_policy + legal_retention_years, LOGUJE kandidate za purge, NIŠTA NE BRIŠE. Sada kad je #105965 mapping integrisan, dry-run NALAZI kandidate (prije: fail-safe skip → 0).

Dokaz

39/39 Testcontainers (postgres:16) zeleno end-to-end na merge-anoj grani: V131(9)+V132(9)+V133(5)+RetentionPurgeWorkerTest(16), uklj. candidatesFound > 0 asertaciju + V121 companion "ukloni mapping → test pada". Evidence: ~/system/evidence/105881/verdict-b2-finalization-2026-07-19.md.

Cron mehanizam

RetentionPurgeCronRoutes.kt — POST /api/v1/internal/retention/purge-dry-run, PURGE_CRON_SECRET header auth, fail-closed 503, mounted u Routing.kt (isti pattern kao InvoiceCronRoutes/ComplianceCronRoutes). INERTAN dok se ne provisionira PURGE_CRON_SECRET + eksterni trigger. Odluka: app-cron fallback; pg_cron (Azure Flexible) optimizacija odgođena do #105964 RBAC.

🔴 Red-zone invarijante (7, svaka grep-verifikovana, ne pretpostavljena)

  1. DRY_RUN hard-coded true, nema override. 2. Nula DELETE/UPDATE/DROP dodano #105965 integracijom (samo read-only SELECT lookup). 3. purge_expired_partition() i dalje bez ijednog caller-a, bez novog EXECUTE granta. 4. bilko_purge_worker write grant nepromijenjen/uzak. 5. mapping-miss fallback matchuje samo human-authored policy redove (companion test). 6. cron ruta fail-closed/inert. 7. entity_type_mapping + entity_type_retention_policy read-only kroz worker (svaki ref = .selectAll()). Graff review: ~/system/evidence/105881/graff-review.md.

Odnos prema B3

B3 live purge (#105882) = jedina preostala CEO-sign-off tačka cijelog programa. B2 ne dodaje nijedan EXECUTE/DELETE/DROP grant (grep-potvrđeno). Živi purge se ne može desiti dok CEO eksplicitno ne odobri B3 + ne provisionira live flag + PURGE_CRON_SECRET.

Bilko — Support agent rola + RLS temelj (Faza A, MC #106050/#106051)

Support agent rola + RLS (Support MVP Faza A)

MC: #106050 (A1) + #106051 (A2), parent #106049 · Autor: Bruce Momjian · Red-zone verify: Parisa Tabriz (Securion) PASS · Grana: azdo feat/106051-support-agent-rbac (c4c8b4f4, NIJE main dok se A4/B ne ožiče)

Šta

DB temelj za support osoblje: nova rola support_agent koja vidi tikete SVIH klijenata ali NIŠTA finansijsko.

Sigurnost (Parisa adversarial verify, 6/6 drži)

  1. Finance leak: nema — is_support_agent se ne pojavljuje ni u jednoj finance/audit policy kroz 134 migracije; support konekcija na te tabele vezana istim org_isolation policy-ma. 2. GUC scope: is_support_agent samo u V134, samo na support_tickets policy-ma. 3. Cross-org: samo support_tickets otvoren, ništa drugo. 4. Permission over-grant: resolve() = exact-equality lookup, bez wildcard/prefix; mehanički ne može vratiti više od 2 ključa. 5. Column-scoped grant potvrđen. 6. RLS nigdje globalno ugašen; nema GUC-injection primitiva. Evidence: ~/system/evidence/106051/parisa-adversarial-verify.md.

⚠️ Bitno za sljedeću fazu (A4/B)

Aplikacijski sloj JOŠ ne ožičuje rolu — RLS politike su trenutno nedostižan dead code (siguran failure mode; rute i dalje idu preko app.is_platform_admin). A4 (requireSupportAgent() guard) + B (rute koje SET LOCAL app.is_support_agent) su prvi kod koji stварno pali novi GUC → traže vlastiti adversarial pass, ne voze na ovom verdiktu (Parisa flag).

Bilko — Support ticket reply + GUC aktivacija (Faza B, MC #106054-106057)

Support ticket reply + GUC aktivacija (Faza B)

MC: #106054 (B1) #106055 (B2) #106056 (B3) #106057 (B4) + #106071 (gap-fix), parent #106049 · Build: Momjian (B1+gap) + CodeCraft (B2-B4) · Red-zone verify: Parisa Tabriz GUC-execution adversarial PASS · Grana: azdo feat/106051-support-agent-rbac (5c06350a, NIJE main)

Šta

Support agent sada može ODGOVORITI korisniku na tiket (async), i tu se PRVI put aktivira app.is_support_agent prekidač (dosad dead-code).

🔴 Sigurnost (Parisa GUC-execution adversarial, sve drži)

  1. Pool leakage: nema — Database.kt isAutoCommit=false (SET LOCAL transaction-scoped); svaki request svježa Exposed transakcija; pool-test (Hikari pool=1, druga tx čita NULL, 3×) + Parisa nezavisno potvrdila handler shape. 2. Ekskluzivnost: B4 sealed-class exhaustive when = compile-time garancija da se nikad ne postave oba prekidača; grep — nijedan drugi fajl ne referencira is_support_agent. 3. #106071 stварno zatvoren na 5c06350a (3 call-site exact-match reject, ne tiketiran). 4. B3 ne pali GUC, app-layer filter daje pravi 404. 5. B4 detail/PATCH byte-for-byte nepromijenjeni. Evidence: ~/system/evidence/106055/parisa-b-guc-adversarial-verify.md.

Otvoreni tech-debt (van scope-a, evidentiran)

#106070 StatusPages 403-body · #106072 org-name unique · #106075 V73 ::uuid 500 · Jackson-vs-kotlinx serialization mehanizam (D1 docs presuda).

Ostaje

C (Vizu support konzola UI) → D (docs) → E (Angie E2E uživo).

Bilko — Support sistem — arhitektura (MC #106061)

Parent: MC #106049 (Support MVP) · Ovaj task: MC #106061 (D1) · Agent: Skillforge · Datum: 2026-07-20

Ova stranica postoji da se izbjegne zabuna od 2026-07-13 ("dva backena") — Bilko ima tri odvojena support mehanizma plus jedan odgođen. Nijedan se ne preklapa niti dijeli kod s drugim. Ako trebate promijeniti support ponašanje, prvo utvrdite u KOJI od ova tri/četiri komada spada promjena.

1. Mapa sistema

flowchart TB
    subgraph customer["Customer-facing"]
        CW["ChatWidget.tsx<br/>apps/web/components/chatbot/"]
        CBS["ChatbotService.kt<br/>/chatbot/message endpoint<br/>KANONSKI AI customer backend"]
        CC["ComplianceClassifier.kt<br/>floor/compliance guard (#105476)<br/>fail-closed na compliance pitanja"]
        CW -->|"POST /chatbot/message"| CBS
        CBS --> CC
    end

    subgraph staff["Staff-only (interno)"]
        BSA["bilko-support-answer.js<br/>ZASEBAN staff-copilot daemon<br/>NIJE customer-facing, NE dijeli kod s ChatbotService"]
    end

    subgraph human["Human support (OVAJ MVP, MC #106049)"]
        SA["support_agent rola<br/>RBAC: support_ticket:read / support_ticket:reply"]
        RLS["RLS cross-org izuzetak<br/>app.is_support_agent GUC<br/>SAMO support_tickets + support_ticket_messages"]
        REPLY["Async ticket reply<br/>NotificationService polling<br/>support_ticket_messages thread"]
        SA --> RLS
        RLS --> REPLY
    end

    subgraph future["Faza 3 (ODGOĐENO)"]
        RT["Real-time live chat<br/>AI->čovjek handoff<br/>build in-house (Ktor WS+Redis) vs Chatwoot/Crisp<br/>trigger: realan volumen support zahtjeva"]
    end

    CC -.->|"nikad ne dijeli backend"| SA
    BSA -.->|"nikad ne dijeli backend"| SA
    REPLY -.->|"kad dođe red"| RT

2. AI chat — customer widget (KANONSKI, ne dira ovaj MVP)

3. AI staff triage — zaseban daemon

4. Human support — NOVO u ovom MVP-u (MC #106049)

Ovo je jedini dio arhitekture koji je STVARNO nov u ovom talasu. Detalji u:

5. Faza 3 (odgođeno) — Real-time live chat + AI→čovjek handoff

6. Linkovi

Bilko — Kako support agent radi (runbook, MC #106063)

Parent: MC #106049 (Support MVP) · Ovaj task: MC #106063 (E2) · Agent: Skillforge · Datum: 2026-07-20

Vidi također: Faza A (RBAC+RLS temelj), Faza B (ticket reply + GUC aktivacija), i "Support sistem — arhitektura" (D1) za mapu svih support mehanizama.

1. Kako se support agent provisionira

1a. Zatvorena rupa — #106071 generic-invite reject

Kad su InviteService.VALID_ROLES i UserProvisioningService.VALID_ROLES prošireni da uključe support_agent (da bi gornji dedicated endpoint mogao pozvati isti servisni sloj), TRI POSTOJEĆA generička route-a (gated samo sa users:manage, dakle bilo koji admin/owner SVOJE org-e) su tiho počela prihvatati role="support_agent" i preko sebe:

Fix: nova funkcija rejectSupportAgentRoleInGenericFlow(role) u RbacHelper.kt — baca ForbiddenException("SUPPORT_AGENT_ROLE_FORBIDDEN_HERE...") na sva tri generička route-a PRIJE nego zahtjev stigne do servisnog sloja. VALID_ROLES ostaju široki (jer dedicated endpoint legitimno zove istu createInvite() funkciju) — reject je na route sloju, ne na servisnom, upravo na ta tri mjesta koja je Parisa našla, ne paralelna allowlist koja bi mogla driftati.

2. Jackson vs kotlinx.serialization — razriješena neslaganja (Momjian vs Parisa)

Zadatak je tražio da se PROČITA stvarni kod prije nego što se prepiše bilo čija tvrdnja. Pročitano: apps/api/src/main/kotlin/no/alai/bilko/plugins/Serialization.kt i SupportAgentProvisioningRoutes.kt na branch-u feat/106051-support-agent-rbac (commit 5c06350a).

Nalaz: Bruce Momjian je bio u pravu — mehanizam je Jackson's FAIL_ON_UNKNOWN_PROPERTIES (default true, nikad eksplicitno postavljen niti isključen u kodu), NE kotlinx.serialization ignoreUnknownKeys.

Dokaz iz koda:

// Serialization.kt
fun Application.configureSerialization() {
    install(ContentNegotiation) {
        jackson {
            disable(SerializationFeature.INDENT_OUTPUT)
            registerModule(JavaTimeModule())
            disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            findAndRegisterModules()
        }
    }
}

val bilkoJson = Json {
    ignoreUnknownKeys = true   // <- postoji, ali NIJE registrovan u ContentNegotiation
    ...
}

Zaključak za tim: kad god vidite @Serializable na DTO-u u ovom repou, to NE garantuje da će kotlinx pravila (npr. ignoreUnknownKeys) važiti na HTTP granici — treba provjeriti da li je taj DTO stvarno deserijalizovan preko ContentNegotiation-a (Jackson, ovaj repo) ili preko direktnog poziva na bilkoJson.decodeFromString<T>() (kotlinx, druga upotreba u istom fajlu).

3. Šta support agent VIDI

4. Šta support agent NE VIDI

5. RBAC permission-key referenca

Permission keyZnačenjeFormat
support_ticket:readČitanje/listanje tiketa preko svih organizacijacolon-format (resource:verb)
support_ticket:replyOdgovor / triage tiketa (status + resolution_note) preko svih organizacijacolon-format (resource:verb)

Zašto colon, ne dot: dispatch je originalno tražio support.tickets.read/support.tickets.reply (dot-notation). V67-ova permission_key_format CHECK constraint (key ~ '^[a-z_]+:[a-z_]+$') zahtijeva tačno jednu dvotačku, bez tačaka — svaki postojeći ključ u katalogu (npr. invoice:read, expense:create) već poštuje taj format. Umjesto proširenja constraint-a za jednu feature-preferencu imena, korišteno je support_ticket:read/support_ticket:reply (singular resource, isti obrazac).

6. RLS cross-org izuzetak — zašto i kako ograničen

Zašto postoji izuzetak uopšte: support osoblje MORA vidjeti tikete preko svih organizacija da bi triage funkcionisao — jedan support agent opslužuje sve klijente, ne samo jednu org. Standardni Bilko RLS model (org_id-scoped) bi u tom slučaju blokirao support agenta da vidi ijedan tiket van svoje (interne) org-e.

Kako je ograničen (ključna crvena-zona odluka, Momjian, MC #106051):

7. Otvoreni tech-debt

8. Merge status (na dan pisanja ove stranice)

Branch feat/106051-support-agent-rbac (azdo), tip 5c06350a. NIJE merge-ovan u azdo/main. Faza A (Parisa adversarial verify) i Faza B (GUC-execution adversarial pass) oboje još čekaju Parisa Tabriz-ov nezavisni pregled prije merge-a — to je eksplicitni merge-gate koji su i Momjian i CodeCraft ostavili otvorenim u svojim verdiktima (vidi ~/system/evidence/106051/verdict.md i ~/system/evidence/106055/verdict.md).