Architecture Decision Records (ADR)

All architectural decisions and rationale

Architecture Decision Record — ADR-013

Architecture Decision Record — ADR-013

Project: Drop ADR Number: ADR-013 Title: Neonomics as Open Banking Aggregator for AISP/PISP Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Proposed Reviewers: Alem Bašić (CEO), John (AI Director)

Document History

Version Date Author Changes
0.1 2026-02-23 Petter Graff Initial draft

ADR Numbering Scheme

Convention: ADR-{NNN}-{short-slug}.md — e.g., ADR-013-neonomics-open-banking-aggregator.md Store in: docs/architecture/adr/


1. Context

1.1 Situation

Drop is a PSD2 pass-through payment application (ADR-003) that requires AISP (read bank balances) and PISP (initiate payments from user's bank) capabilities to function in production. The current MVP uses mock AISP/PISP — the NEXT_PUBLIC_SERVICE_MODE=mock flag returns simulated balances and payment confirmations without contacting real banks.

To go live (Phase 2), Drop must connect to the actual Open Banking APIs of Norwegian and Nordic banks (DNB, SpareBank 1, Nordea, Handelsbanken, etc.) using the Berlin Group NextGenPSD2 standard. Two strategic approaches exist: direct ASPSP integration (per-bank) or aggregator integration (single API covering all banks).

The previous planned provider, Swan (a BaaS provider), has been deprecated and removed from the codebase — it was not aligned with the PSD2 pass-through model (Swan offered embedded banking, not TPP-style AISP/PISP).

1.2 Forces & Constraints

Technical forces:

Business forces:

Compliance & regulatory:

Existing decisions that constrain this:

1.3 Problem Statement

We need to decide: Which Open Banking connectivity strategy should Drop adopt for Phase 2 production AISP/PISP — direct per-ASPSP integration or a single Open Banking aggregator?


2. Decision

We will: Use Neonomics as the primary Open Banking aggregator for both AISP (balance reads) and PISP (payment initiation) in the Norwegian and Nordic market.

Rationale (summary): Neonomics provides a single REST API endpoint covering 90%+ of Norwegian banks under the Berlin Group NextGenPSD2 standard, handles eIDAS certificate management and bank onboarding, and is a Norwegian company with strong local regulatory relationships — reducing Drop's time-to-market from 6-9 months (direct per-bank) to 6-8 weeks (aggregator contract + integration).


3. Alternatives Considered

Option A: Neonomics Aggregator ← Selected

Description: Contract with Neonomics (Norwegian Open Banking aggregator, Oslo HQ) for a single HTTPS REST API covering Norwegian + Nordic banks. Neonomics holds its own PISP/AISP registration with Finanstilsynet and eIDAS certificates — Drop operates as an agent or technology partner under Neonomics' regulatory umbrella initially.

Pros:

Cons:

Cost/Effort: Contract negotiation 2-4 weeks; technical integration 2-4 weeks — total 6-8 weeks Risk: MEDIUM — Neonomics is a funded startup (not a Tier-1 bank); business continuity risk mitigated by Tink as backup


Option B: Direct Per-ASPSP Integration

Description: Integrate directly with each Norwegian bank's PSD2 developer portal: DNB API, SpareBank 1 Open Banking, Nordea Open Banking, etc. Each bank has its own onboarding process, bilateral agreement, and API specifics.

Pros:

Cons:

Cost/Effort: 6-12 months engineering time + bank agreements + cert procurement Risk: HIGH — Timeline risk is critical; direct integration too slow for Phase 2 target

Why not chosen: Timeline incompatible with Phase 2 launch target; Direct integration is the long-term (Phase 3+) optimization once scale justifies the margin savings.


Option C: Tink (Visa) Aggregator

Description: Use Tink (acquired by Visa in 2022), the largest European Open Banking aggregator with 6,000+ banks across the EU.

Pros:

Cons:

Cost/Effort: Similar to Neonomics — 6-8 weeks integration Why not chosen: Neonomics preferred for Phase 2 Norwegian launch due to local regulatory relationships and Norwegian bank specialization. Tink retained as Backup/Phase 3 (EU expansion) option.


Comparison Matrix

Criterion Weight Option A: Neonomics (Selected) Option B: Direct ASPSP Option C: Tink
Time-to-market 5 5 1 4
Norwegian bank coverage 5 5 3 4
Per-transaction cost (3yr) 3 3 5 3
GDPR compliance complexity 3 4 5 4
Regulatory / license path 4 5 2 4
Vendor stability 3 3 5 5
Engineering effort 4 5 1 4
Weighted Total 131 77 112

Score: 1 (poor) to 5 (excellent)


4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Neutral / Secondary Effects

4.4 Technical Debt Created


5. Compliance Impact

Regulation Impact Notes
GDPR MEDIUM AISP balance data and PISP payment data transit Neonomics — GDPR DPA required; Neonomics is EU/EEA entity
PSD2 (Betalingstjenesteloven) HIGH Neonomics holds PISP/AISP registration; Drop operates as agent/technology partner until own license obtained
AML (Hvitvaskingsloven) LOW Transaction monitoring remains Drop's responsibility regardless of aggregator
DORA MEDIUM Neonomics is a critical third-party ICT provider — must be documented in Drop's ICT risk management framework

Data residency implications: Neonomics processes data in Norway/EEA — no cross-border transfer to non-adequate countries.


6. Performance Impact

Metric Before (mock) After (Neonomics production) Source
AISP balance read latency ~10ms (mock) ~200-500ms (Neonomics + ASPSP) Neonomics SLA + Berlin Group typical
PISP initiation latency ~10ms (mock) ~300-800ms (Neonomics + ASPSP) Neonomics SLA
PISP SCA redirect latency N/A (mock) User-dependent (BankID at ASPSP) External
Availability N/A (mock) 99.5% SLA (Neonomics) Neonomics commercial SLA

Performance testing plan: Load test AISP balance reads with 100 concurrent users against Neonomics sandbox before production launch.


7. Migration / Implementation Notes

7.1 Migration Plan

Phase 2a (Weeks 1-2): Contract + setup
  - [ ] Sign Neonomics commercial contract
  - [ ] Sign GDPR DPA
  - [ ] Obtain Neonomics sandbox API credentials
  - [ ] Create lib/openbanking/neonomics.ts client module

Phase 2b (Weeks 3-4): AISP integration (balance reads)
  - [ ] Implement AISP consent flow (POST /v1/consents)
  - [ ] Implement balance read (GET /v1/accounts/{id}/balances)
  - [ ] Update bank_accounts table (add consent_id, consent_expires_at columns)
  - [ ] Integration test against Neonomics sandbox with DNB test account

Phase 2c (Weeks 5-6): PISP integration (payment initiation)
  - [ ] Implement PISP payment initiation (POST /v1/payments/sepa-credit-transfers)
  - [ ] Implement SCA redirect flow + payment status polling
  - [ ] Add payment webhook receiver for async status updates
  - [ ] Integration test: full remittance flow against Neonomics sandbox

Phase 2d (Weeks 7-8): Production readiness
  - [ ] Switch NEXT_PUBLIC_SERVICE_MODE from 'mock' to 'production'
  - [ ] Production credentials (Neonomics production API key) in AWS Secrets Manager
  - [ ] Remove deprecated mock-swan.ts
  - [ ] Monitoring: Sentry alerts for Neonomics API errors
  - [ ] Circuit breaker: 3 failures in 60s → 60s cooldown

7.2 Rollback Strategy

Can we roll back? YES — Feature flag NEXT_PUBLIC_SERVICE_MODE reverts to mock mode instantly

Rollback steps:

  1. Set NEXT_PUBLIC_SERVICE_MODE=mock in AWS Secrets Manager
  2. Restart App Runner instances — rollback complete in < 5 minutes
  3. Users see cached balance; payments queue for retry when production mode re-enabled

7.3 Feature Flags

Flag Purpose Default
NEXT_PUBLIC_SERVICE_MODE mock = simulated AISP/PISP; production = Neonomics live mock
OPEN_BANKING_PROVIDER Future: switch between Neonomics and Tink neonomics

ADR Relationship Notes
ADR-003 Prerequisite PSD2 pass-through model requires AISP/PISP provider
ADR-005 Constrained by Single monolith means single aggregator integration point
ADR-007 Related BankID used for Drop login SCA; ASPSP-side SCA (for PISP) is separate via Neonomics scaRedirect

9. Review Date

Next review: 2026-08-23 (6 months post-decision) or when Neonomics pricing changes by > 50%

Review trigger conditions:

Superseded by:(fill in if this ADR is later superseded)


Approval

Role Name Date Signature
Author Petter Graff 2026-02-23
Tech Lead John (AI Director)
Security (if compliance impact)
CEO Alem Bašić

ADR-001: Consolidate Backends

ADR-001: Consolidate to Single Backend

Status: Accepted Date: 2026-02-12 Deciders: John (AI Director), Alem (CEO) Category: Architecture


Context

The Drop codebase contained two competing middleware implementations creating confusion about which was authoritative:

  1. lib/middleware.ts -- A simple, monolithic file used by all 24 API routes. Provided cookie-based JWT auth (requireAuth, requireMerchant), basic rate limiting via SQLite-backed rate_limits table, IP extraction from X-Forwarded-For, and standardized JSON error responses (jsonError).

  2. lib/middleware/ directory -- A more robust, modular implementation with auth-middleware.ts (Bearer token auth, in-memory rate limiting with X-RateLimit-* headers), error-handler.ts (typed AppError class with predefined error constructors), and validation.ts (comprehensive input validation: phone, amount, IBAN, PIN, email, currency, sanitization). This directory was completely unused by any route.

Additionally, FontelePay (an earlier project iteration) resided at src/fontelepay/ with its own .git/, node_modules/, and independent codebase, blurring project boundaries.

The SOURCE-STATUS.md analysis confirmed that Drop source code had never been properly committed to version control. A clean rebuild using build artifacts as specification was the recommended path.

graph LR
    subgraph before["Before (Dual Middleware)"]
        routes["24 API Routes"] --> mw1["lib/middleware.ts<br/>(cookie JWT, basic rate limit)"]
        unused["UNUSED"] --> mw2["lib/middleware/<br/>(Bearer JWT, typed errors, validation)"]
    end

    subgraph after["After (Consolidated)"]
        routes2["24 API Routes"] --> mw3["lib/middleware.ts<br/>(cookie JWT, persistent rate limit,<br/>CSRF, session revocation)"]
        routes2 --> val["lib/middleware/validation.ts<br/>(input validation, sanitization)"]
    end

    before -->|"ADR-001"| after

Decision

  1. Consolidate middleware into a single implementation, incorporating the best parts of both:

    • Cookie-based JWT auth from lib/middleware.ts (matches current route expectations)
    • Typed errors and input validation from lib/middleware/ directory
    • Persistent rate limiting via rate_limits SQLite table (replacing in-memory Map)
  2. Remove duplicate code after consolidation to eliminate confusion about which implementation is authoritative.

  3. Separate FontelePay from Drop's src/ directory (see ADR-002).

  4. Remove dead code including any orphaned backend variants and unused files.

Consequences

Positive

Negative

Risks

References

ADR-002: Separate FonTelePay

ADR-002: Separate FontelePay from Drop Repository

Status: Accepted Date: 2026-02-12 Deciders: John (AI Director) Category: Architecture


Context

FontelePay is an earlier iteration of the payment concept that predates the Drop rebrand. It resided at src/fontelepay/ inside the Drop product directory with:

This created several problems:

Problem Impact
Project confusion Developers could not distinguish "Drop source" from "FontelePay source"
Git conflicts Nested .git/ directory caused submodule-like behavior without proper configuration
Build interference FontelePay's node_modules/ could interfere with Drop's build process
Size bloat FontelePay's 527 dependency packages inflated the Drop directory
graph TB
    subgraph before["Before: Nested Repository"]
        drop_dir["Drop/"]
        drop_dir --> src["src/"]
        src --> drop_app["drop-app/ (Drop)"]
        src --> fp["fontelepay/ (FontelePay)"]
        fp --> fp_git[".git/ (separate repo)"]
        fp --> fp_nm["node_modules/ (527 pkgs)"]
        fp --> fp_claude[".claude/"]
        fp --> fp_rnd["rnd/ (market research)"]
    end

    subgraph after["After: Clean Separation"]
        products["ALAI/products/"]
        products --> drop_clean["Drop/<br/>Clean codebase"]
        products --> fp_separate["FontelePay/<br/>Preserved independently"]
    end

    before -->|"ADR-002"| after

Decision

Move FontelePay out of the Drop directory to its own location:

  1. Move src/fontelepay/ to ~/ALAI/products/FontelePay/ (or archive)
  2. Preserve FontelePay's git history by keeping its .git/ intact during move
  3. Keep only a reference document in Drop pointing to FontelePay's new location
  4. Copy relevant research documents (particularly rnd/mobilebank-research/) to Drop's rnd/ directory if directly relevant

Consequences

Positive

Negative

Risks

References

ADR-003: PSD2 Pass-Through Model

ADR-003: Adopt PSD2 Pass-through Model (No Wallet)

Status: Accepted Date: 2026-02-12 Deciders: Alem (CEO), John (AI Director) Category: Architecture


Context

The original Drop codebase implemented a wallet model where:

This wallet model had significant regulatory implications under Norwegian law:

Aspect Wallet Model (EMI) Pass-through Model (PISP/AISP)
License type E-money Institution (EMI) PISP/AISP registration
Norwegian law Finansforetaksloven Betalingstjenesteloven
Initial capital 350,000 EUR 20,000-50,000 EUR
Timeline to license 12-18 months 6-12 months
Fund safeguarding Required (segregated accounts or insurance) Not needed
PCI-DSS scope Full (card data stored) Minimal (no card data)

The alternative PSD2 pass-through model positions Drop as a Payment Initiation Service Provider (PISP) and Account Information Service Provider (AISP) where Drop never holds customer funds.

graph LR
    subgraph wallet["Wallet Model (Rejected)"]
        user1["User"] -->|"Top-up"| drop_wallet["Drop Wallet<br/>(holds funds)"]
        drop_wallet -->|"Pay"| merchant1["Merchant"]
        drop_wallet -->|"Send"| receiver1["Receiver"]
    end

    subgraph passthrough["Pass-through Model (Adopted)"]
        user2["User"] -->|"PISP: Initiate payment"| bank["User's Bank<br/>(holds funds)"]
        bank -->|"Execute transfer"| merchant2["Merchant"]
        bank -->|"Execute transfer"| receiver2["Receiver"]
        drop_pt["Drop<br/>(orchestrator)"] -.->|"AISP: Read balance"| bank
        drop_pt -.->|"PISP: Initiate"| bank
    end

    classDef rejected fill:#FFCDD2,stroke:#C62828
    classDef adopted fill:#C8E6C9,stroke:#2E7D32

    class user1,drop_wallet,merchant1,receiver1 rejected
    class user2,bank,merchant2,receiver2,drop_pt adopted

Decision

Drop adopts the PSD2 pass-through model. Specifically:

  1. No wallet: Remove all local balance, top-up, and fund-holding functionality
  2. AISP for balance: User sees their bank account balance via Open Banking API (read-only). The bank_accounts.balance field stores a cached AISP read -- not a Drop-held balance
  3. PISP for payments: Remittance and QR payments are initiated from the user's own bank account via Open Banking payment initiation with SCA
  4. No card storage: Cards feature gated behind feature flags (all default false); future card issuance via PCI-compliant partner only
  5. BankID for SCA: Strong Customer Authentication via Norwegian BankID replaces email+password for all financial operations

Code Impact

Feature Wallet Model (removed) Pass-through Model (current)
Balance Local balance column in users table bank_accounts.balance = cached AISP read from bank
Top-up /api/users/top-up endpoint Removed -- no top-up needed
Remittance Deduct from local balance POST /api/transactions/remittance triggers PISP
QR Payment Deduct from local balance POST /api/transactions/qr-payment triggers PISP
Cards Stored locally (PAN, CVV in DB) Feature-flagged; future partner integration (token-only)
Auth Email + password (single factor) BankID OIDC for SCA
Transaction Local DB update only Local record + bank payment confirmation
sequenceDiagram
    participant User
    participant Drop
    participant BankID
    participant Bank
    participant Recipient

    Note over User,Recipient: PSD2 Pass-through Remittance Flow
    User->>Drop: Initiate remittance (amount, recipient)
    Drop->>Drop: Fee disclosure (0.5%)
    Drop->>User: Show total cost + exchange rate
    User->>Drop: Confirm payment
    Drop->>BankID: SCA challenge (amount + payee)
    BankID->>User: Authenticate (BankID app)
    User->>BankID: Approve
    BankID->>Drop: SCA confirmed
    Drop->>Bank: PISP: Initiate payment
    Bank->>Bank: Debit user account
    Bank->>Drop: Payment status: processing
    Drop->>Drop: Record transaction (status: processing)
    Bank->>Recipient: Transfer funds (SEPA/SWIFT)
    Bank->>Drop: Payment status: completed
    Drop->>Drop: Update transaction (status: completed)
    Drop->>User: Notification: transfer complete

Consequences

Positive

Negative

Risks

References

ADR-004: JWT HTTPOnly Cookies

ADR-004: JWT Storage in httpOnly Cookies

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Security


Context

Drop is a financial application handling payment initiation, bank account data, and personal information. Secure token storage is critical -- token theft enables full account takeover including payment initiation from the victim's bank account.

The two primary options for JWT storage in browser-based SPAs are:

Option XSS Risk CSRF Risk Implementation Complexity
localStorage HIGH -- any XSS payload can read tokens None Low
httpOnly cookie None -- JavaScript cannot access Medium -- requires CSRF protection Medium

Given that Drop processes financial data and operates under PSD2, XSS-based token theft would be catastrophic -- an attacker could initiate payments from a user's bank account. CSRF is a more constrained attack vector with well-understood mitigations.

The mobile app (Expo SDK 54) uses Bearer tokens stored in AsyncStorage since cookies are not practical for native apps, but the attack surface is fundamentally different (no XSS in native context).

Decision

Store JWTs in httpOnly cookies for the web application. Use Bearer tokens for the mobile API.

Property Value Rationale
httpOnly true Prevents JavaScript access, eliminates XSS token theft
secure true (production) HTTPS-only transport
sameSite "Lax" CSRF defense (allows BankID redirect back)
maxAge 604,800 (7d) Session lifetime
path "/" Full application scope

Implementation note: The actual implementation uses maxAge=604800 (7d) and SameSite=Lax (changed from the originally specified strict/24h to support BankID OIDC redirect flows).

CSRF protection layers:

  1. sameSite: "Lax" -- browser refuses to send cookie on cross-origin POST requests
  2. Origin header validation against allowed origins whitelist (app.ts:23-30 CORS middleware)
  3. CSRF token generation available (generateCsrfToken()) for additional protection
graph TB
    subgraph localStorage["localStorage (Rejected)"]
        xss["XSS Attack"] -->|"document.cookie<br/>or localStorage.getItem()"| steal["Token Stolen"]
        steal --> takeover["Account Takeover<br/>+ Payment Initiation"]
    end

    subgraph httpOnly["httpOnly Cookie (Adopted)"]
        xss2["XSS Attack"] -->|"Cannot access<br/>httpOnly cookie"| blocked["BLOCKED"]
        csrf["CSRF Attack"] -->|"Cross-origin request"| samesite["sameSite: strict<br/>BLOCKED by browser"]
    end

    classDef danger fill:#FFCDD2,stroke:#C62828
    classDef safe fill:#C8E6C9,stroke:#2E7D32

    class xss,steal,takeover danger
    class blocked,samesite safe

Consequences

Positive

Negative

Risks

References

ADR-005: Monolith First

ADR-005: Monolith-First Architecture

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Architecture


Context

Drop needs to go from concept to demo-ready product as quickly as possible. The team is small (1 human CEO + AI agents), and the initial scope is well-defined: 10 screens, ~24 API endpoints, single SQLite database.

Three architecture patterns were considered:

Pattern Deployment Complexity Dev Speed Scaling Team Size Fit
Microservices High (orchestration, service mesh, API gateway) Slow (interface contracts, distributed testing) Excellent Large teams
Modular monolith Low (single deploy unit) Fast (shared code, simple debugging) Good (extract later) Small teams
Serverless functions Medium (cold starts, state management) Medium Good (per-function) Any

The current scope (7 core pages + API routes) does not justify the operational overhead of microservices. The codebase is ~24 API route files and ~19 database tables -- well within what a single deployment can handle.

graph TB
    subgraph monolith["Drop Monolith (Current)"]
        nextjs["Next.js 15<br/>App Router"]
        nextjs --> pages["Frontend Pages<br/>(10 screens, React 19)"]
        nextjs --> api["API Routes<br/>(24 endpoints)"]
        api --> db["SQLite / PostgreSQL<br/>(19 tables)"]
    end

    subgraph future["Future Extraction (If Needed)"]
        web["Web Frontend<br/>(Next.js)"]
        mobile_api["Mobile API<br/>(Hono v4)"]
        payment_svc["Payment Service<br/>(PISP orchestration)"]
        banking_svc["Banking Service<br/>(AISP integration)"]
        shared_db["PostgreSQL<br/>(shared or per-service)"]
    end

    monolith -->|"Extract when:<br/>- Team grows to 5+ devs<br/>- 10K+ concurrent users<br/>- Independent deploy needed"| future

    classDef current fill:#C8E6C9,stroke:#2E7D32
    classDef future_style fill:#E3F2FD,stroke:#1565C0

    class nextjs,pages,api,db current
    class web,mobile_api,payment_svc,banking_svc,shared_db future_style

Decision

Start as a modular monolith. Extract microservices only when scaling demands it.

The monolith is structured with clear module boundaries to make future extraction feasible:

Module Responsibility Files
auth/ Authentication (BankID OIDC, JWT, sessions) api/auth/*, lib/auth.ts
transactions/ Remittance and QR payment processing api/transactions/*
merchants/ Merchant registration and dashboard api/merchants/*
cards/ Card management (feature-flagged) api/cards/*
compliance/ GDPR, AML, complaints api/consents/*, api/complaints/*, api/user/*
lib/ Shared: middleware, DB, validation, feature flags lib/*

Extraction triggers (when to consider splitting):

Consequences

Positive

Negative

Risks

References

ADR-006: SQLite to PostgreSQL

ADR-006: SQLite for Development, PostgreSQL for Production

Status: SUPERSEDED by ADR-014: PostgreSQL-Only Architecture (2026-03-03) Date: 2026-02-21 Deciders: John (AI Director) Category: Database


Context

Drop requires a database strategy that supports rapid local development while being production-ready for a financial application. The key tension is between developer velocity (zero-config local setup) and production reliability (concurrent writes, ACID transactions, managed backups).

Database Local Setup Concurrent Writes Managed Services Financial Compliance
SQLite Zero config, file-based Poor (single writer) None Not suitable for production
PostgreSQL Docker required Excellent (MVCC) AWS RDS, Aurora Industry standard for fintech
MySQL Docker required Good AWS RDS, Aurora Common but less feature-rich

Drop's data model includes 19 tables with foreign keys, transactions requiring atomicity (balance deduction + transaction record), and compliance tables needing reliable concurrent access for audit logging. SQLite handles development workloads but cannot support concurrent writes from multiple App Runner instances.

Decision

Use SQLite (better-sqlite3) for development and PostgreSQL for production, with a dual-driver abstraction layer.

Driver detection is automatic based on environment:

const USE_PG = !!process.env.DATABASE_URL;

When DATABASE_URL is set (production), PostgreSQL is used. Otherwise, SQLite with WAL mode is used.

graph LR
    subgraph dev["Development"]
        app_dev["Drop App"] --> dal["Database Access Layer<br/>(db.ts)"]
        dal --> sqlite["SQLite<br/>(better-sqlite3)<br/>./data/drop.db"]
    end

    subgraph prod["Production"]
        app_prod["Drop App (x N)"] --> dal2["Database Access Layer<br/>(db.ts)"]
        dal2 --> pg["PostgreSQL<br/>(AWS RDS)<br/>DATABASE_URL"]
    end

    dal -.->|"Same API:<br/>query(), getOne(),<br/>run(), transaction()"| dal2

See ADR-010: Dual Database Driver for the abstraction layer details.

Consequences

Positive

Negative

Risks

References

ADR-007: BankID OIDC Auth

ADR-007: BankID as Sole Authentication Provider

Status: Accepted Date: 2026-02-21 Deciders: Alem (CEO), John (AI Director) Category: Security


Context

Drop is a financial application operating under PSD2 in Norway. PSD2 mandates Strong Customer Authentication (SCA) for payment initiation and account access. SCA requires two of three factors: knowledge (something you know), possession (something you have), and inherence (something you are).

Authentication options considered:

Option SCA Compliant KYC Built-in Norwegian Coverage Implementation
BankID Yes (possession + knowledge/biometric) Yes (national ID verified) ~4.5M users (90%+ adult pop.) OIDC standard
Vipps Login Partial (depends on config) Partial (phone-verified) ~4.3M users OIDC standard
Email + Password No (single factor) No Universal Simple
Email + OTP Partial (possession) No Universal Medium

BankID provides the strongest combination: SCA compliance (BankID app = possession, PIN = knowledge, biometric = inherence), built-in identity verification (national ID / fodselsnummer), and near-universal adoption in Norway. Using BankID as the sole auth provider eliminates the need for a separate KYC step -- identity is verified at login.

The original Drop codebase used email + password authentication, which is inadequate for PSD2 compliance and provides no identity verification.

Decision

Use BankID OIDC as the sole authentication provider for Drop. Remove email/password login.

Authentication architecture:

Platform Flow Token Storage Token Lifetime
Web (Next.js BFF) BankID OIDC redirect flow httpOnly cookie (drop_token) 7 days
Mobile (Expo) BankID OIDC with deep link callback AsyncStorage (Bearer token) 7 days

User creation is automatic on first BankID login:

  1. Parse pid (fodselsnummer, 11 digits) from BankID ID token
  2. Hash pid with SHA-256 for storage (national_id_hash column)
  3. Check for existing user by national_id_hash
  4. If new: create user with kyc_status = 'approved', kyc_method = 'bankid'
  5. Verify age >= 18 from pid birthdate encoding
sequenceDiagram
    participant User
    participant Drop as Drop (BFF)
    participant BankID as BankID OIDC

    User->>Drop: GET /api/auth/bankid
    Drop->>Drop: Generate state + nonce
    Drop->>Drop: Set bankid_state cookie
    Drop->>User: Redirect URL to BankID

    User->>BankID: Authenticate (app/code device)
    Note over User,BankID: SCA: possession (device) +<br/>knowledge (PIN) or inherence (biometric)
    BankID->>User: Redirect to callback with code

    User->>Drop: GET /api/auth/bankid/callback?code=&state=
    Drop->>Drop: Verify state vs cookie
    Drop->>BankID: Exchange code for tokens
    BankID->>Drop: ID token + access token
    Drop->>Drop: Verify ID token (JWKS)
    Drop->>Drop: Extract pid, verify age >= 18
    Drop->>Drop: Find or create user
    Drop->>Drop: Create session, set JWT cookie
    Drop->>User: 302 Redirect to /dashboard

Deprecated endpoints (return 410 Gone):

Consequences

Positive

Negative

Risks

References

ADR-008: Hono API Framework

ADR-008: Hono v4 for Mobile API

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Backend


Context

Drop has two client platforms with different API needs:

Platform Auth Pattern Token Storage API Style Deployment
Web (Next.js) Cookie-based JWT via BFF httpOnly cookie Next.js API Routes (collocated) Vercel / App Runner
Mobile (Expo) Bearer token AsyncStorage REST API (separate process) App Runner

Next.js API Routes work well for the web BFF pattern (server-side rendering + API in one deployment), but mobile needs a lightweight, standalone REST API with Bearer token authentication and mobile-specific concerns (deep link callbacks, longer token lifetimes).

Frameworks considered for the mobile API:

Framework Performance TypeScript Edge Compatible Bundle Size Ecosystem
Hono v4 Excellent (minimal overhead) First-class Yes (Workers, Deno, Bun) ~14KB Growing fast
Express 5 Good (mature) Requires @types No (Node-only) ~200KB Massive
Fastify 5 Excellent (schema validation) Good (built-in types) No (Node-only) ~300KB Large
Elysia Excellent (Bun-native) First-class Bun only ~20KB Small

Hono was selected for its TypeScript-first design, minimal overhead, and edge compatibility. The mobile API runs as a separate Hono server on App Runner alongside the Next.js web app.

Decision

Use Hono v4 for the mobile REST API. Keep Next.js API Routes for the web BFF.

graph TB
    subgraph clients["Client Platforms"]
        web["Web Browser<br/>(Next.js SSR + CSR)"]
        mobile["Mobile App<br/>(Expo SDK 54)"]
    end

    subgraph backend["Backend Services"]
        nextjs_api["Next.js BFF<br/>API Routes (/api/*)<br/>Cookie auth, SSR"]
        hono_api["Hono v4 API<br/>REST (/v1/*)<br/>Bearer auth, mobile-optimized"]
    end

    subgraph shared["Shared Layer"]
        db["Database Access (db.ts)"]
        bankid["BankID OIDC (bankid.ts)"]
        validation["Validation (validation.ts)"]
    end

    web --> nextjs_api
    mobile --> hono_api
    nextjs_api --> db
    nextjs_api --> bankid
    hono_api --> db
    hono_api --> bankid
    nextjs_api --> validation
    hono_api --> validation

Both APIs share the same database access layer, BankID integration, and validation utilities. The difference is in auth pattern and deployment:

Aspect Next.js API Routes Hono v4 API
Base path /api/ /v1/
Auth Cookie JWT (httpOnly) Bearer token (Authorization header)
Token lifetime 7 days 7 days
BankID callback HTTP redirect to /dashboard JSON response with token
Rate limiting SQLite-backed (persistent) Database-backed (SQLite rate_limits table, persistent)
Deployment Vercel or App Runner App Runner (standalone)

Consequences

Positive

Negative

Risks

References

ADR-009: Feature Flag System

ADR-009: Custom Feature Flag System

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Backend


Context

Drop needs feature flags for several reasons:

  1. Gradual rollout: Cards feature requires a card issuing partner before activation -- must be gated
  2. Kill switches: Ability to disable features instantly in production if compliance or operational issues arise
  3. Development: Feature-in-progress code can be merged to main without being user-visible
  4. A/B testing: Future capability for comparing payment flows

Feature flag approaches considered:

Approach Cost Complexity Server+Client Targeting Audit
Custom (env vars) Free Low Yes (NEXT_PUBLIC_) No Via deploy history
LaunchDarkly $10/seat/mo Medium Yes Yes (per-user) Yes
Unleash (self-hosted) Free (OSS) High (infra) Yes Yes Yes
ConfigCat Free tier Low Yes Yes Yes

At Drop's current stage (pre-launch, no production users), a custom system based on environment variables provides exactly what is needed: server+client flag availability, zero operational overhead, and type-safe TypeScript integration. User-level targeting is not needed until there are users to target.

Decision

Implement a custom feature flag system using NEXT_PUBLIC_FF_* environment variables, with type-safe TypeScript wrappers for server and client access.

Architecture:

graph TB
    subgraph env["Environment Variables"]
        vars["NEXT_PUBLIC_FF_VIRTUAL_CARDS=false<br/>NEXT_PUBLIC_FF_PHYSICAL_CARDS=false<br/>NEXT_PUBLIC_FF_NOTIFICATIONS=true<br/>..."]
    end

    subgraph server["Server-Side (API Routes)"]
        isEnabled["isEnabled('virtualCards')"]
        featureGate["featureGate('physicalCards')"]
        getAllFlags["getAllFlags()"]
    end

    subgraph client["Client-Side (React)"]
        useFlag["useFeatureFlag('notifications')"]
        useFlags["useFeatureFlags()"]
    end

    vars -->|"Build-time inline<br/>(NEXT_PUBLIC_ prefix)"| server
    vars -->|"Build-time inline<br/>(NEXT_PUBLIC_ prefix)"| client

    featureGate -->|"Returns 404<br/>if disabled"| api_route["API Route<br/>(e.g., POST /api/cards/[id]/physical)"]

Flag registry (feature-flags.ts:27-36):

Flag Env Var Default Purpose
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 Card detail view
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 Spending limit controls
notifications NEXT_PUBLIC_FF_NOTIFICATIONS true Push notifications
merchantDashboard NEXT_PUBLIC_FF_MERCHANT_DASHBOARD true Merchant dashboard

Server-side API route protection via featureGate():

const gate = featureGate("physicalCards");
if (gate) return gate;  // Returns 404: "Feature not available"

Client-side conditional rendering via useFeatureFlag():

const cardsEnabled = useFeatureFlag("virtualCards");
if (!cardsEnabled) return null;

Consequences

Positive

Negative

Risks

References

ADR-010: Dual Database Driver

ADR-010: Dual Database Driver Abstraction

Status: SUPERSEDED by ADR-014: PostgreSQL-Only Architecture (2026-03-03) Date: 2026-02-21 Deciders: John (AI Director) Category: Database


Context

Per ADR-006, Drop uses SQLite for development and PostgreSQL for production. This creates a challenge: the application code must work correctly against both database engines, which have different SQL dialects, parameter binding, and transaction semantics.

The naive approach -- maintaining two separate codebases or using an ORM -- has drawbacks:

Approach Type Safety SQL Control Performance Complexity
Raw SQL per driver Low (string SQL) Full Optimal High (2x code)
ORM (Prisma/Drizzle) High Limited Good (with overhead) Medium
Thin abstraction layer Medium Full Optimal Low

An ORM would add a dependency, a build step (Prisma generate), and limit SQL flexibility for complex compliance queries. A thin abstraction layer provides the best balance: same SQL syntax where possible, automatic translation where not.

Decision

Implement a thin database abstraction layer (db.ts) that exposes a unified API and transparently converts SQL between SQLite and PostgreSQL dialects.

Driver detection at startup:

const USE_PG = !!process.env.DATABASE_URL;
graph TB
    subgraph app["Application Code"]
        routes["API Routes"]
        routes -->|"query(), getOne(),<br/>run(), transaction()"| dal["Database Access Layer<br/>(db.ts)"]
    end

    subgraph dal_internals["Abstraction Layer Internals"]
        dal --> detect{"DATABASE_URL<br/>set?"}
        detect -->|"Yes"| pg_driver["PostgreSQL Driver<br/>(pg pool)"]
        detect -->|"No"| sqlite_driver["SQLite Driver<br/>(better-sqlite3)"]

        dal --> convert["SQL Converter"]
        convert -->|"? → $1,$2..."| pg_driver
        convert -->|"datetime('now') →<br/>CURRENT_TIMESTAMP"| pg_driver
    end

    subgraph databases["Databases"]
        pg_driver --> pg["PostgreSQL<br/>(production)"]
        sqlite_driver --> sqlite["SQLite<br/>(development)"]
    end

Unified API

Function Signature Purpose
query<T> (sql, params?) -> Promise<T[]> SELECT, returns array of rows
getOne<T> (sql, params?) -> Promise<T | null> SELECT, returns first row or null
run (sql, params?) -> Promise<{changes}> INSERT/UPDATE/DELETE
runIgnore (sql, params?) -> Promise<{changes}> INSERT OR IGNORE / ON CONFLICT DO NOTHING
runUpsert (sql, conflictCol, updateCols, params?) -> Promise<{changes}> INSERT OR REPLACE / ON CONFLICT DO UPDATE
transaction<T> (fn) -> Promise<T> Atomic transaction wrapper
initDb () -> Promise<void> Schema creation + seed data
getDriver () -> "pg" | "sqlite" Current driver type

SQL Translation Rules (db.ts:50-59)

SQLite Syntax PostgreSQL Equivalent Handled By
? placeholders $1, $2, $3, ... Automatic in query()/run()
INSERT OR IGNORE INTO INSERT INTO ... ON CONFLICT DO NOTHING runIgnore()
INSERT OR REPLACE INTO INSERT INTO ... ON CONFLICT (col) DO UPDATE SET runUpsert()
datetime('now') CURRENT_TIMESTAMP Automatic in SQL string
INTEGER AUTOINCREMENT SERIAL Schema initialization
TEXT dates TIMESTAMPTZ Schema initialization

Consequences

Positive

Negative

Risks

References

ADR-011: Expo Mobile Framework

ADR-011: Expo SDK 54 for Mobile App

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Mobile


Context

Drop requires a mobile app for iOS and Android. The mobile app is the primary interface for remittance and QR payments -- users scan QR codes with their phone camera and approve payments via BankID on the same device.

Mobile framework options considered:

Framework Cross-Platform Code Sharing (Web) OTA Updates Camera/QR BankID Integration Dev Experience
Expo SDK 54 iOS + Android High (React shared) Yes (EAS Update) expo-camera expo-web-browser Excellent
React Native (bare) iOS + Android High (React shared) Manual react-native-camera Custom deep links Good
Flutter iOS + Android None (Dart vs TS) No native OTA camera plugin Custom deep links Good
Native (Swift/Kotlin) Separate codebases None App Store only Native APIs Native SDKs Platform-specific

Key factors in the decision:

  1. Code sharing: Drop's web app uses React 19. Expo enables sharing React components, hooks, types, and business logic between web and mobile.
  2. BankID flow: Mobile BankID authentication requires opening a secure browser (expo-web-browser) and handling deep link callbacks (drop://auth/callback). Expo provides both natively.
  3. QR scanning: Core feature requires camera access. expo-camera provides this with barcode scanning built in.
  4. OTA updates: Financial apps need rapid hotfix deployment. Expo Application Services (EAS) provides over-the-air JavaScript bundle updates without App Store review.
  5. Team capacity: AI-driven development team benefits from a single language (TypeScript) across all platforms.

Decision

Use Expo SDK 54 with managed workflow for the Drop mobile app.

graph TB
    subgraph mobile["Mobile App (Expo SDK 54)"]
        screens["Screens<br/>(10 screens matching web)"]
        hooks["Shared Hooks<br/>(useAuth, useTransactions)"]
        types["Shared TypeScript Types"]

        screens --> camera["expo-camera<br/>(QR scanning)"]
        screens --> browser["expo-web-browser<br/>(BankID auth)"]
        screens --> notif["expo-notifications<br/>(push alerts)"]
        screens --> storage["AsyncStorage<br/>(Bearer token)"]
        screens --> linking["expo-linking<br/>(deep links: drop://)"]
    end

    subgraph web["Web App (Next.js 15)"]
        web_screens["Screens<br/>(10 screens)"]
        web_hooks["Shared Hooks"]
        web_types["Shared TypeScript Types"]
    end

    subgraph backend["Backend"]
        hono["Hono v4 API<br/>(/v1/* - Bearer auth)"]
        nextjs["Next.js BFF<br/>(/api/* - Cookie auth)"]
    end

    hooks -.->|"Shared React code"| web_hooks
    types -.->|"Shared types"| web_types
    mobile --> hono
    web --> nextjs

    classDef expo fill:#E3F2FD,stroke:#1565C0
    classDef web_style fill:#C8E6C9,stroke:#2E7D32
    classDef backend_style fill:#FFF3E0,stroke:#E65100

    class screens,hooks,types,camera,browser,notif,storage,linking expo
    class web_screens,web_hooks,web_types web_style
    class hono,nextjs backend_style

Key Expo Modules Used

Module Purpose Drop Feature
expo-camera Camera access + barcode scanning QR payment scanning
expo-web-browser Secure in-app browser BankID OIDC authentication
expo-notifications Push notification handling Transaction alerts, payment receipts
expo-linking Deep link handling (drop://) BankID callback, notification deep links
@react-native-async-storage Persistent key-value store Bearer token storage
expo-secure-store Encrypted storage Sensitive data (future biometric)
expo-local-authentication Biometric auth App unlock (Phase 2)

Mobile-Specific Auth Flow

The mobile BankID flow differs from web:

  1. GET /v1/auth/bankid/initiate?platform=mobile returns { redirectUrl, state }
  2. Open BankID in expo-web-browser (secure, isolated browser)
  3. BankID redirects to drop://auth/callback?code=&state=
  4. expo-linking catches the deep link
  5. POST /v1/auth/bankid/callback exchanges code for Bearer token
  6. Token stored in AsyncStorage (7-day lifetime)

Consequences

Positive

Negative

Risks

References

ADR-012: AWS App Runner Deploy

ADR-012: AWS App Runner for Deployment

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Infrastructure


Context

Drop needs a deployment target for its backend services (Next.js BFF and Hono mobile API). The deployment platform must support Docker containers, auto-scaling, HTTPS termination, and be cost-effective at low initial traffic with the ability to scale.

Deployment options considered:

Platform Container Support Auto-scaling Min Cost Operational Overhead Cold Start
AWS App Runner Yes (ECR/source) Automatic ~$5/mo (min instances) Very low Warm (min instance)
AWS ECS/Fargate Yes (ECR) Manual config (target tracking) ~$10/mo (Fargate) Medium (task defs, services, ALB) Warm
AWS Lambda Yes (container image) Automatic (per-request) ~$0 (free tier) Low Cold start problem
Vercel No (serverless functions) Automatic Free tier Very low Cold start for API
Railway Yes (Dockerfile) Automatic ~$5/mo Very low Warm
Fly.io Yes (Dockerfile) Automatic ~$5/mo Low Warm

Key factors:

  1. WebSocket/long connections: App Runner supports them; Lambda does not (29s timeout)
  2. PostgreSQL connectivity: App Runner runs in VPC, can connect to RDS; Lambda requires NAT gateway ($32/mo)
  3. Operational simplicity: App Runner is "push container, get HTTPS endpoint" -- no load balancer, target group, or service mesh to configure
  4. Cost at scale: App Runner pricing is straightforward (vCPU-hour + memory-hour); ECS/Fargate pricing is similar but with more configuration
  5. AWS ecosystem: PostgreSQL on RDS, secrets in Secrets Manager, logs in CloudWatch -- all in same account

Vercel was used for the landing page (static) and is excellent for Next.js, but its serverless function model is not ideal for the Hono API or long-running database connections.

Decision

Use AWS App Runner for backend deployment. Keep Vercel for the landing page (static site).

graph TB
    subgraph edge["Edge Layer"]
        cf["Cloudflare<br/>DNS + CDN + WAF + DDoS"]
    end

    subgraph aws["AWS (eu-north-1)"]
        subgraph apprunner["App Runner"]
            nextjs["Next.js BFF<br/>Web app + API routes<br/>(1-10 instances)"]
            hono["Hono API<br/>Mobile REST API<br/>(1-10 instances)"]
        end

        subgraph data["Data Layer"]
            rds["RDS PostgreSQL<br/>(production DB)"]
            secrets["Secrets Manager<br/>(JWT_SECRET, BANKID creds)"]
        end

        subgraph monitoring["Monitoring"]
            cw["CloudWatch<br/>(logs, metrics)"]
        end
    end

    subgraph vercel["Vercel"]
        landing["Landing Page<br/>getdrop.no<br/>(static)"]
    end

    cf --> nextjs
    cf --> hono
    cf --> landing
    nextjs --> rds
    hono --> rds
    nextjs --> secrets
    hono --> secrets
    nextjs --> cw
    hono --> cw

    classDef edge_style fill:#FFF3E0,stroke:#E65100
    classDef aws_style fill:#E3F2FD,stroke:#1565C0
    classDef vercel_style fill:#F3E5F5,stroke:#6A1B9A

    class cf edge_style
    class nextjs,hono,rds,secrets,cw aws_style
    class landing vercel_style

App Runner Configuration

Setting Value Rationale
Region eu-north-1 (Stockholm) Closest AWS region to Norway; GDPR data residency
Source ECR (Docker image) Pushed by GitHub Actions CI/CD
CPU 1 vCPU Sufficient for current load
Memory 2 GB Room for Node.js heap + DB connections
Min instances 1 Eliminates cold start; ~$5/mo baseline
Max instances 10 Auto-scales based on concurrent requests
Port 3000 (Next.js), 3001 (Hono) Default Node.js ports
Health check GET /api/health (also available at /v1/health) Returns DB connectivity status
Auto deploy Yes (on ECR push) CI/CD pushes new image, App Runner deploys

Deployment Pipeline

graph LR
    push["git push"] --> gha["GitHub Actions"]
    gha --> build["Docker build<br/>+ TypeScript check<br/>+ Lint + Test"]
    build --> ecr["Push to ECR"]
    ecr --> apprunner["App Runner<br/>auto-deploy"]
    apprunner --> health["Health check<br/>GET /api/health"]
    health -->|"Healthy"| live["Live traffic"]
    health -->|"Unhealthy"| rollback["Auto-rollback<br/>to previous version"]

Consequences

Positive

Negative

Risks

References

ADR Overview

Architecture Decision Records (ADRs)

Project: Drop -- Fintech Payment App Last updated: 2026-03-03 Maintainer: Standards Architect


What are ADRs?

Architecture Decision Records capture significant technical decisions made during Drop's development. Each ADR documents the context, the decision itself, and its consequences -- providing a historical record of why the system is built the way it is.

ADRs are immutable once accepted. If a decision is reversed, the original ADR is marked Superseded and a new ADR is created referencing it.


ADR Index

ADR Title Status Date Category
ADR-001 Consolidate to Single Backend Accepted 2026-02-12 Architecture
ADR-002 Separate FontelePay from Drop Repository Accepted 2026-02-12 Architecture
ADR-003 Adopt PSD2 Pass-through Model (No Wallet) Accepted 2026-02-12 Architecture
ADR-004 JWT Storage in httpOnly Cookies Accepted 2026-02-21 Security
ADR-005 Monolith-First Architecture Accepted 2026-02-21 Architecture
ADR-006 SQLite for Dev, PostgreSQL for Production Superseded by ADR-014 2026-02-21 Database
ADR-007 BankID as Sole Authentication Provider Accepted 2026-02-21 Security
ADR-008 Hono v4 for Mobile API Accepted 2026-02-21 Backend
ADR-009 Custom Feature Flag System Accepted 2026-02-21 Backend
ADR-010 Dual Database Driver Abstraction Superseded by ADR-014 2026-02-21 Database
ADR-011 Expo SDK 54 for Mobile App Accepted 2026-02-21 Mobile
ADR-012 AWS App Runner for Deployment Accepted 2026-02-21 Infrastructure
ADR-014 PostgreSQL-Only Architecture (Drizzle ORM) Accepted 2026-02-26 Database

ADR Lifecycle

stateDiagram-v2
    [*] --> Proposed : Author drafts ADR
    Proposed --> Accepted : Team reviews and approves
    Proposed --> Rejected : Team rejects proposal
    Accepted --> Deprecated : No longer relevant
    Accepted --> Superseded : New ADR replaces this one
    Rejected --> [*]
    Deprecated --> [*]
    Superseded --> [*]

ADR Template

Use this template when proposing a new ADR. Save as ADR-NNN-short-title.md in this directory.

# ADR-NNN: Title

**Status:** Proposed | Accepted | Deprecated | Superseded by [ADR-XXX](ADR-XXX-title.md)
**Date:** YYYY-MM-DD
**Deciders:** [Names and roles]
**Category:** Architecture | Security | Database | Backend | Frontend | Mobile | Infrastructure

---

## Context

What is the issue that we are seeing that motivates this decision?
Include technical background, constraints, and forces at play.

## Decision

What is the change that we are proposing and/or doing?
State the decision clearly and concisely.

## Consequences

### Positive
- List benefits of this decision

### Negative
- List drawbacks and trade-offs

### Risks
- List risks and their mitigations

## References

- Link to related ADRs, documents, or external resources
- Link to implementation PRs or tasks

Guidelines for Proposing ADRs

  1. When to write an ADR: Any decision that affects the system architecture, technology choices, security model, or data model and would be hard to reverse.

  2. Scope: One ADR per decision. If a decision has multiple parts, consider splitting into separate ADRs.

  3. Numbering: Use sequential three-digit numbers (001, 002, ...). Never reuse numbers.

  4. Review process: Draft as Proposed, share with the team. Once approved by the AI Director (John) and/or CEO (Alem), change status to Accepted.

  5. Superseding: When reversing a decision, create a new ADR and update the old one's status to Superseded by [ADR-XXX]. Never delete old ADRs.

  6. Context matters: Future readers need to understand why the decision was made. Include constraints, alternatives considered, and the reasoning.


Cross-References