POS V2 — Research Hub

Architecture decisions backed by industry research

Research carried over from V1 + new V2-specific research
Last updated: March 16, 2026
8
Research Topics
17+
Sources Cited
6
Completed
2
Planned

Core Framework Architecture

The Question

What framework and structure should be the foundation of POS V2? Build custom, use an existing framework, or hybrid?

Frameworks Evaluated

9 frameworks and approaches were researched. Each evaluated for module system, performance, offline support, and POS fit.

01 — Selected

Fastify

Plugin system, lifecycle hooks, decorators. The fastest mainstream Node.js framework.

80K req/s • MIT • 33K stars
SELECTED — Best balance of speed, maturity, flexibility
02

NestJS

DI modules, guards/interceptors. Enterprise-grade but heavy abstraction layer.

45K req/s (Fastify adapter) • MIT • 70K stars
Good but overkill for POS
03

Feathers.js

Services + hooks pattern, native real-time, offline-first package available.

Real-time native • MIT • 15K stars
Strong contender — best offline story
04

Medusa.js

E-commerce modules, subscriber pattern. No multi-DB support, no offline capability.

E-commerce focused • MIT
NOT for POS — no multi-DB, no offline
05

Hono

Edge-first ultralight framework. Blazing fast but no offline, no state management.

100K req/s • Edge-native
NOT for POS — no offline, no state
06

Elysia

Bun-based, extremely fast. Too immature, Bun dependency is risky for production.

Bun-only • Early stage
Too immature — Bun dependency risky
07

AdonisJS

Laravel-style, batteries included. Full MVC with ORM, auth, mail built in.

Laravel for Node • Active community
Over-engineered for POS
08

tRPC

End-to-end type safety for APIs. Not a framework — a complementary API layer tool.

API layer only • TypeScript-first
Complementary tool, not a framework
09

Custom from Scratch

Build everything ourselves. Months of engineering for HTTP, routing, plugins, lifecycle.

Months of work • High risk
Not worth it — Fastify provides 90%

Comparison Matrix

Scores out of 10. Green (8-10) = excellent, Yellow (5-7) = adequate, Red (1-4) = poor fit.

Criteria Fastify NestJS Feathers Medusa Hono Elysia
Module System 8 9 7 8 6 6
Hooks / Events 8 9 9 7 6 6
Multi-Database 8 8 8 4 7 7
Real-Time 6 7 9 6 4 5
Offline-First 5 4 9 3 2 2
Performance 10 7 7 7 10 10
Maturity 9 9 6 8 7 5
POS Fit 9 6 8 3 3 3

Why Fastify

Decision

Fastify is the core framework for POS V2

Hybrid approach: Fastify provides the HTTP layer, plugin system, and lifecycle hooks. We build the module structure, rule engine, sync engine, and adapter layer on top.

80K req/s — fastest mainstream Node.js framework
Plugin system with encapsulation, hooks, decorators
Lightweight — perfect for store servers on minimal hardware
Already proven in V1 — no learning curve
MIT license, 33K stars, active community
We build what’s missing: modules, rules, sync, adapter

What Fastify Doesn’t Have (We Build)

Fastify gives us the engine. These are the systems we engineer on top:

Layer 1

Structured Module System

Each module = routes.ts + rules.ts + sync.ts + service.ts. Self-contained, testable, consistent across HQ and store servers.

Layer 2

Rule Engine

Before/after hooks for every action. Cross-module communication without direct imports. Our @pos/rule-engine package.

Layer 3

Sync Engine

Global version counter, per-entity handlers, transactional outbox with status tracking and idempotency keys.

Layer 4

Event Bus

Cross-module communication for side effects. Decouples modules so inventory doesn’t import sales directly.

Layer 5

Backend Adapter

Web clients use HTTP fetch, Tauri apps use invoke. Same API surface, different transport. @pos/backend-adapter.

POS V2 Architecture Structure

The complete monorepo layout showing where everything lives and how it connects. This is the blueprint.

Monorepo Structure
pos2/
├── docs/
│   ├── planner/          → Planning site
│   └── research/         → Research site (you are here)
├── packages/
│   ├── core/             → Module system, hook pipeline, base interfaces
│   ├── rule-engine/      → Before/after hooks, rule registry
│   ├── sync-engine/      → Global version counter, entity handlers, outbox
│   ├── shared-types/     → TypeScript interfaces for all entities
│   ├── backend-adapter/  → Web (fetch) vs Tauri (invoke)
│   ├── pos-logic/        → Shared Pinia stores, composables
│   ├── tax-engine/       → Tax calculation (basis points)
│   ├── i18n/             → EN + ES translations
│   ├── ui-kit/           → PrimeVue theme + components
│   └── event-bus/        → Client-side event notifications
├── apps/
│   ├── hq-server/        → Fastify, central HQ
│   │   └── src/
│   │       ├── modules/
│   │       │   ├── products/
│   │       │   │   ├── routes.ts    → HTTP endpoints
│   │       │   │   ├── rules.ts     → Business rules (before/after)
│   │       │   │   ├── sync.ts      → Sync handler for products
│   │       │   │   └── service.ts   → Business logic
│   │       │   ├── sales/
│   │       │   ├── inventory/
│   │       │   ├── users/
│   │       │   └── ...
│   │       ├── rules/
│   │       │   └── global.ts        → Rules that apply everywhere
│   │       └── db/
│   │           └── schema/          → Drizzle schema
│   ├── store-server/     → Fastify, local per store
│   │   └── src/
│   │       ├── modules/             → Same pattern as HQ
│   │       ├── rules/
│   │       ├── sync/                → Sync service (pull/push/outbox)
│   │       └── db/
│   ├── web-client/       → Vue 3 PWA
│   ├── pos-terminal/     → Tauri desktop (POS)
│   ├── store-manager/    → Tauri desktop (Store)
│   └── hq-manager/       → Tauri desktop (HQ)
└── tools/
    ├── scripts/          → deploy, setup, validate
    └── docker/           → Docker configs
Module Anatomy — How ONE Module Works
modules/sales/
├── routes.ts      → HTTP endpoints (GET/POST/PUT/DELETE)
│                     Calls service.ts for logic
│                     Uses ruleEngine.emit() for hooks
│
├── rules.ts       → Registered at startupbefore('sale:create', validatePrices)
│                     before('sale:discount', checkPermission)
│                     after('sale:complete', deductInventory)
│
├── sync.ts        → SyncHandler for sales entity
│                     push: send completed sales to HQ
│                     (no pull — sales don’t come from HQ)
│
└── service.ts     → Pure business logic
                      createSale(), voidSale(), applySaleDiscount()
                      No HTTP knowledge, no framework dependency
                      Testable in isolation
Request Flow — How a Request Moves Through the System
  1. HTTP Request arrives at Fastify Router
  2. Auth Hook — JWT verification
  3. Permission Hook — route-level permission check
  4. Route Handler (routes.ts) receives the request
  5. ruleEngine.emit('sale:create', ctx, action)
  6. runBefore hooks: validatePrices (rules.ts)
  7. runBefore hooks: checkPermission (rules.ts)
  8. runBefore hooks: checkInventory (inventory/rules.ts)
  9. All pass → Execute action (service.ts)
  10. runAfter hooks: deductInventory (rules.ts)
  11. runAfter hooks: addToSyncOutbox (sync.ts)
  12. runAfter hooks: emitEvent('sale:completed') via event-bus
  13. HTTP Response returned to client

Sync Flow

Store Server → HQ (Push)
1
Sale created → after hook adds to sync_outbox
2
Sync service polls outbox every N seconds
3
Pushes to HQ via HTTP POST /sync/push
4
HQ processes → updates store_product_dynamic
5
HQ responds success → Store marks outbox entry as ‘sent’
HQ → Store Server (Pull)
1
Product updated → bumps global syncVersion
2
WebSocket notifies connected stores
3
Store receives notification → pulls from HQ
4
Store’s sync handler processes pulled entities
5
Store reports product state back to HQ

How Packages Connect

@pos/core
Defines Module interface, HookPipeline, base types
@pos/rule-engine
Implements before/after hooks using core interfaces
@pos/sync-engine
Implements sync using core Module + rule-engine hooks
hq-server + store-server
Import all three + register their modules

Key Differences from V1

Aspect V1 V2
Module structure routes.ts only routes + rules + sync + service
Rule engine Added later, patched in Built into core from day 1
Sync Monolithic pull-service.ts Per-entity handlers registered by modules
syncVersion Per-row (broken) Global counter per entity type
Outbox No status, no idempotency status + idempotencyKey + sentAt
Validation Frontend + safety net Server-side per-action + safety net
Module communication Direct imports Event-based (rule engine + event bus)
Schema Evolved organically Designed complete before first migration

Existing POS Systems Studied

Three established systems were analyzed to understand how production POS software handles modules, inventory, and multi-store operations.

ERPNext POS

ERPNext

Full ERP with POS module. DocType-based architecture with hooks and events. Taught us the value of a unified document model and server-side business rules.

Lesson: Document-level hooks are powerful but heavyweight. We want hook granularity at the action level, not the document level.
Odoo POS

Odoo

Modular ERP with offline POS client. Uses XML-RPC sync and ORM-level computed fields. Offline JS client syncs when reconnected.

Lesson: Offline-first POS is viable. Their sync conflict resolution (last-write-wins per field) is too simple for inventory — we need operation-based sync.
TEKLA POS

TEKLA

Caribbean-focused multi-store POS. Desktop app with central server sync. Real-world multi-store with tax authority integration.

Lesson: Multi-store sync must handle intermittent connectivity gracefully. Outbox pattern with retry is essential, not optional.
Sources

Rule Engine Architecture

V2 Note

Implemented in V1 — will be built into V2 core from day 1

The before/after hook pattern proved successful in V1. V2 will adopt this as a first-class architectural pattern from the start, with the rule engine integrated at the framework level rather than bolted on.

The Question

How should our POS system handle business rules (price validation, discount limits, permissions) across modules?

// Industry Patterns Compared

We evaluated three industry patterns for implementing business rules in a multi-module TypeScript/Fastify system. Each was assessed for type safety, performance, testability, and cross-module coordination.

Pattern 1: Condition/Action Rules
Define rule with condition() + action()
Engine collects all rules, sorts by priority
Evaluates conditions sequentially
Executes matching actions
Short-circuit on first failure
Pattern 2: Before/After Hooks
Module registers before('event')
Before hooks run: can validate or cancel
Core action executes
After hooks run: side effects, logging
Module registers after('event')
Pattern 3: JSON Rules Engine
Rules defined in JSON (DB or file)
Engine parses conditions + operators
Evaluates against provided "facts"
Returns events for matching rules
Caller handles events
Pattern 1

Condition/Action Rules

Rules defined in TypeScript with a condition function and an action function. Priority-based execution with short-circuit on failure. Used by the ts-rule-engine library pattern.

Used by: Custom enterprise systems, internal rule frameworks
TypeScript typed Requires redeployment Fast execution Rules scattered in code Easy to test No runtime changes
Pattern 2 — Recommended

Before/After Hooks

Lifecycle events where "before" hooks can validate and cancel, "after" hooks handle side effects. Each module registers its own hooks. Inspired by ERPNext, Mongoose, and Fastify's own hook system.

Used by: ERPNext, Odoo, Mongoose ODM, Fastify, WordPress
Clean separation More boilerplate Modular by design Requires redeployment Full type safety Hook ordering matters DI-friendly
Pattern 3

JSON Rules Engine

Rules stored in database or JSON files. Declarative conditions with operators like greaterThan, equal. No recompilation needed to change rules. Used by json-rules-engine npm package.

Used by: Data-driven promotion systems, A/B testing platforms
No redeploy Slower execution Runtime changes Verbose JSON Non-dev editable Harder to type
Proposed Architecture
packages/rule-engine/
Generic framework: before() / after() / emit()
imported by
apps/store-server/rules/
POS rules, inventory rules, transfer rules
apps/hq-server/rules/
Product rules, worksheet rules, sync rules

// How Rules Are Registered

Side-by-side comparison of the ERPNext/Python pattern and our TypeScript equivalent.

ERPNext (Python)
# ERPNext: hooks in each module
doc_events = {
  "Sales Invoice": {
    "before_submit": validate_credit_limit,
    "after_submit": update_stock_ledger,
  },
  "Stock Entry": {
    "before_submit": check_warehouse_capacity,
  }
}

def validate_credit_limit(doc, method):
  if doc.outstanding > doc.credit_limit:
    raise ValidationError("Over limit")
Our TypeScript Equivalent
// packages/rule-engine/src/index.ts
export class RuleEngine {
  before(event: string, handler: HookFn) {}
  after(event: string, handler: HookFn) {}
  async emit(event: string, ctx: Context) {}
}

// apps/hq-server/rules/sales.ts
engine.before('sale:complete', async (ctx) => {
  if (ctx.sale.total > ctx.store.creditLimit)
    throw new ValidationError('Over limit');
});

engine.after('sale:complete', async (ctx) => {
  await updateInventory(ctx.sale.items);
});

// Decision Matrix

FeatureCondition/ActionBefore/After HooksJSON Engine
Type SafetyFull TypeScriptFull TypeScriptJSON only
DB AccessVia DIVia DICustom facts provider
No Redeploy to Change RulesRequires deployRequires deployRuntime changes
PerformanceFast (native code)Fast (native code)Slower (parsing)
Cross-Module RulesSupportedSupportedComplex setup
TestingEasy to unit testEasy to unit testHarder to isolate
Industry Adoption (Retail)ModerateHigh (ERPNext, Odoo)Niche
Learning CurveLowLowMedium
Our Decision

Implemented: Before/After Hooks with Module-Level Registration

Closest to ERPNext/Odoo architecture, the most widely adopted pattern in retail ERP systems. Provides full TypeScript type safety, clean module separation, and aligns with Fastify's own lifecycle hook model. Each module registers its own hooks. Testing is straightforward since each hook is a standalone async function.

// V1 Implementation

The rule engine was built and integrated across both servers in V1:

packages/rule-engine/ — Generic before/after hook framework (26 tests)
store-server/modules/sales/rules.ts — 7 sales rules
store-server/modules/inventory/rules.ts — Inventory adjustment rules
store-server/modules/transfers/rules.ts — Transfer validation rules
hq-server/modules/sales/rules.ts — HQ-side sales rules
Rules registered at server startup in app.ts

Sync Architecture

The Question

Can our sync system be modular like the rule engine? How do enterprise POS systems handle multi-store sync?

// Industry Findings

Enterprise POS systems universally use event-driven architectures with per-entity handlers — not monolithic sync services.

43.2%
Reduction in System Load
<500ms
Propagation Latency
6-12h
Industry Avg (Batch)
4
Enterprise Vendors Studied
  • Microsoft Dynamics 365 — Row version tracking per entity type with global version number. Pull-based with incremental deltas.
  • Oracle Retail — Event-driven integration bus with per-entity handlers. Each retail domain has its own sync module.
  • SAP Retail — CIF uses modular IDocs per entity type. Push-based notifications trigger targeted data downloads.
  • Couchbase Sync Gateway — Global sequences per channel. Monotonic counters guarantee zero missed mutations.

// Three Architecture Patterns

Pattern 1: Monolithic Sync
V1 Current — Broken
Single Service Handles All
One sync service handles every entity type. Per-row syncVersion leads to missed updates. No hooks, no modularity.
sync/pull-service.ts → ALL entities sync/push-service.ts → ALL entities
Pattern 2: Registry-Based Handlers
Recommended
Per-Entity Sync Modules
Each module registers its own SyncHandler. The sync service is just an orchestrator. Extensible and testable.
modules/products/sync.ts → product pull/push modules/sales/sync.ts → sale push modules/inventory/sync.ts → inventory push
Pattern 3: Event-Sourced Sync
Overkill
Event Log + Replay
All changes stored as immutable events. Sync replays events to reconstruct state. Full audit trail but massive complexity.
events/product-changed.ts events/sale-created.ts projections/product-state.ts
Decision

Registry-Based Per-Entity Handlers (Pattern 2)

Matches our existing rule engine pattern, is simple to implement, and follows what Microsoft Dynamics 365, Oracle Retail, and SAP all use internally.

// Global Version Counter

The core fix: replace per-row syncVersion with a global monotonic sequence per entity type. Pattern used by SQL Server Change Tracking, Couchbase Sync Gateway, and Dynamics 365.

Before: Per-Row Versions

Each row increments its own version independently. Gaps are inevitable.

12? 4?? ?8
Gaps cause missed updates. Store watermark jumps past unsynced rows.
After: Global Sequence

All changes get the next number from a single counter. No gaps possible.

123 456 78
Monotonic → nothing is ever missed. Pull "since N" always works.

// Conflict Resolution Strategy

Per-entity ownership determines who wins in a conflict.

EntitySource of TruthStrategyDirection
Products (catalog)HQHQ WinsHQ → Store
Prices / Tax GroupsHQHQ WinsHQ → Store
Users / RolesHQHQ WinsHQ → Store
SalesStoreStore WinsStore → HQ
Inventory CountStoreStore ReportsStore → HQ
CustomersStore creates, HQ mergesLast-Write-WinsBidirectional

// Before/After Hooks for Sync

The rule engine integrates directly with the sync system. Each sync operation can trigger before/after hooks.

Before Hooks (Validation)
before('sync:pull:products', validateVersion)
// Ensure version is valid before applying

before('sync:push:sale', enrichSaleData)
// Add store metadata before pushing
After Hooks (Side Effects)
after('sync:pull:products', reportStockToHQ)
// Push local stock state after pull

after('sync:push:sale', updateSyncStatus)
// Update UI sync indicator

// Transactional Outbox Pattern

Changes written to sync_outbox in the same transaction as data change. Recommended additions:

  • idempotencyKey — UUID per outbox entry prevents duplicate processing on retry.
  • syncVersion column on outbox — links outbox entry to the global version for traceability.

// V1 Bugs Identified

Bug 1 — Critical

Per-row syncVersion misses updates

syncVersion is per-row, auto-incremented. Store watermark advances past unsynced rows permanently.

Bug 2 — Critical

store_product_dynamic never reflects reality

Created on first sync pull with onConflictDoNothing — never updated after. HQ quantity is permanently wrong.

Bug 3 — Fixed

Sync pull overwrites store quantity

Generic upsert overwrote ALL fields including quantity. Fixed to exclude quantity.

Fixed in V1
Bug 4 — Critical

Store never pushes product state

Store pushes events but never pushes actual quantity/price state. HQ has no way to know real inventory.

// Implementation Roadmap

Five-phase rollout. Each phase is independently deployable.

Phase 1

Global Version Counter

Create per-entity-type sequences. Add trigger to stamp sync_version from global sequence. Reset all store watermarks for full re-sync.

Phase 2

Per-Entity Sync Handlers

Create SyncHandler interface with pull(), push(), resolve(). Each module registers its handler. Sync service becomes thin orchestrator.

Phase 3

Before/After Hooks

Wire rule engine hooks into sync lifecycle for validation, enrichment, and automatic side effects.

Phase 4

Product Stock Push

Store pushes full product state to HQ after every sale, adjustment, transfer, or PO receive. HQ never calculates — only records.

Phase 5

Migration + Testing

Deploy updated pull handler. Force full re-sync. Monitor for missed updates. Add sync health dashboard.

Tauri + Web Shared Code

The Question

How to share TypeScript logic between Tauri desktop apps and web browser apps in a monorepo?

// What We Found

  • pnpm workspaces + Turborepo validated by GitButler (50k+ stars), Clash Nyanpasu, and many Tauri projects
  • Shared package pattern is the recommended approach from the Tauri community
  • Backend adapter pattern solves the "invoke duplication problem" — one API interface for both Tauri and web
  • Dependency injection via provide/inject for platform-agnostic stores
Shared Code Architecture
packages/pos-logic/
Shared stores, composables, config, modules
imported by all apps
apps/web-client/
Vue PWA (browser)
apps/pos-terminal/
Tauri POS (desktop)
apps/store-manager/
Tauri Store Mgr
Backend Adapter Pattern
execute(command, args)
Unified API — packages/backend-adapter/
routes to
Web: fetch()
HTTP GET/POST/PUT/DELETE
Tauri: invoke()
IPC to Rust backend

// V1 Implementation

12 shared Pinia stores in pos-logic
3 composables (barcode, theme, version)
Shared config + modules registry
All 4 apps import from @pos/pos-logic
~90 backend adapter commands mapped
Dynamic import via Function() for Tauri
i18n shared (en + es) across all apps
248 web-client tests passing

Feature Roadmap

The Question

What features are we missing compared to industry standard POS systems, and how should we prioritize them?

// V1 System Capabilities

  • Multi-store management (HQ + stores) with sync
  • Product catalog (SKU, barcode, departments, suppliers, specials)
  • Tax system (rates, groups, compound taxes)
  • Sales processing (cart, discounts, multi-payment, price validation)
  • Inventory management (adjustments, cross-store lookup)
  • Inter-store transfers with variance tracking
  • Purchase orders (full lifecycle)
  • User/role/permission system (65+ permissions)
  • Worksheets (price/product/tax changes with approval flow)
  • Customer management, Sales representatives
  • Register sessions (open/close with X/Z/ZZ reports)
  • 13 report types with Chart.js visualizations
  • Dark mode, PWA, i18n (EN+ES), barcode scanner, receipt printing

High Priority

7 features
1 Loyalty Program / Points — 68% of retailers have this
2 Gift Cards — sell, reload, redeem as payment
3 Barcode Label Printing — generate price/barcode labels
4 Dashboard Charts — visual reports with graphs
5 Server-side Pagination — handle large product catalogs
6 Low Stock Alerts — notifications when below minimum
7 Auto Reorder Suggestions — POs based on reorder points

Medium Priority

9 features
8 Layaway / Apartado — partial payments, product hold
9 Returns with original sale reference
10 Employee Time Clock — clock in/out
11 Accounting Integration — QuickBooks/Xero export
12 Email Receipts — digital receipt to customer
13 Gross Margin Report — by product/dept/store
14 Shrinkage Report — inventory loss tracking
15 Sell-Through Rate — sell speed vs received stock
16 Customer Purchase History

Low Priority

4 features
17 Multi-currency support
18 Employee Scheduling
19 Mobile POS optimization
20 Public API for external integrations

// Missing Reports for Store Owners

Gross Margin Report

Margin by product, department, and store. "Where am I making the most money?"

Shrinkage Report

Track inventory loss over time. "How much am I losing?"

Sell-Through Rate

Sales velocity vs stock received. "What sells fast vs what's stuck?"

Customer Retention

Repeat customer rate over time. "Are customers coming back?"

Store Comparison

Same metric side by side across stores. "Which store performs best?"

ABC Analysis

Classify products by revenue contribution. "Which 20% of products drive 80% of revenue?"

// Items Completed in V1 from This Research

Server-side pagination (8 APIs + 8 views)
Chart.js in 4 report views
Security audit (rate limiting, CORS, nginx)
Store config system (16 configs, 5 modules)
User-store-role assignment UI
Store provisioning UI (tokens)
Specials/promotions (CRUD + auto-apply)
POS discount (cart-wide + per-item)
Rule Engine package (@pos/rule-engine)
Dark mode, responsive views, version check
1,128 tests total across all packages

Database Design

The Question

What is the optimal schema design for a multi-store POS system? How do we balance normalization, performance, and auditability?

Research Needed

Research needed on optimal schema design for multi-store POS

  • Normalization vs Denormalization — When to normalize for data integrity vs denormalize for read performance in POS workloads
  • Audit Trails — Patterns for tracking all data changes: trigger-based, application-level, event sourcing, or temporal tables
  • Soft Deletes — Strategy for soft deletes across all entities: deleted_at column, status enum, or separate archive tables
  • Multi-tenant Schema — Schema-per-store vs shared schema with store_id column vs separate databases
  • Index Strategy — Optimal indexes for POS query patterns: product lookup by barcode/SKU, sales by date range, inventory by store
  • Migration Strategy — How to handle schema migrations across HQ + multiple store databases simultaneously

Authentication & Permissions

The Question

How should POS V2 handle authentication and permissions across HQ, Store, and POS apps — each with completely separate permission spaces?

Models Evaluated

Three permission models were evaluated for multi-store retail POS.

Selected

RBAC (Role-Based) ✓

User → Role → Permissions. Simple, scalable, fits retail perfectly (Cashier, Manager, Admin).

Used by: Shopify POS, Lightspeed, most retail systems
Simple mental model
Industry standard
Easy to audit
Fast permission checks
Evaluated

ABAC (Attribute-Based)

User + Resource + Environment attributes = allow/deny. Overkill for POS — business rules handle complex conditions.

Confusion point: Discount limits, time-based rules → those are RULE ENGINE, not auth
Fine-grained control
Over-engineered for POS
Hard to debug
Slow evaluation
Evaluated

PBAC (Policy-Based)

Policy files define access rules. Too complex for our use case — adds indirection without benefit.

Used by: AWS IAM, large enterprise systems
Centralized policies
External policy files
Complex to debug
Wrong abstraction for POS

Auth vs Rule Engine CRITICAL DISTINCTION

The most common mistake in POS permission design: confusing authorization with business rules. These are two completely separate layers.

Auth (RBAC)
// "Can you ATTEMPT this action?"

pos:discountyes/no

¿Puedes INTENTAR esta acción?
Rule Engine
// "Does the action follow RULES?"

¿Exceeds 20%?yes/no

¿La acción cumple las REGLAS?
LayerResponsibilityExample
Auth (RBAC) Can you attempt this action? pos:discount → "Do you have this permission?"
Rule Engine Does the action follow business rules? "Is discount under 20%?" → blocked or allowed
Request Flow: Auth → Rule Engine
1. Auth Check
Has pos:discount? → No → 403 Forbidden
Yes — pass to rule engine
2. Rule Engine
before('sale:discount') → exceeds max? → blocked
All rules pass
3. Action Applied
Discount applied to sale

Scope Separation

Three completely separate permission namespaces. HQ permissions NEVER mix with Store/POS permissions.

HQ Permissions
hq:products.create
hq:reports.view
hq:users.manage
hq:stores.provision
hq:taxes.manage
Store Permissions
store:inventory.adjust
store:transfers.create
store:reports.view
store:employees.manage
store:purchases.create
POS Permissions
pos:sell
pos:refund
pos:discount
pos:void
pos:price_override
A user can have HQ role + Store 1 role + no POS access
A user can be Manager in Store 1 and Cashier in Store 2
HQ permissions NEVER mix with Store/POS permissions
StoreId is explicit in user_roles table

Super Admin

Design Decision

isSuperAdmin flag in users table

Checked FIRST before any permission lookup. Automatically has ALL permissions — no need to assign them. Cannot be accidentally locked out. This is the escape hatch that prevents admin lockout during misconfiguration.

Why Custom RBAC (No Libraries)

Evaluated existing permission libraries. Custom RBAC wins for our use case.

LibraryTypeWhy NOT for us
CASL ABAC Designed for attribute-based. Our model is RBAC. Overkill.
Casbin PBAC Requires external policy files. Complex to debug.
Custom RBAC 200 lines, full control, no deps, easy to debug ✓

If system grows to need ABAC → migrate to CASL later. For now, custom is cleaner.

JWT Design

What's IN the token
userId        // user identifier
username      // display name
isSuperAdmin  // bypass all checks
appContext    // hq | store | pos
storeId       // which store (if store/pos)
What's NOT in the token
permissions   // loaded fresh from DB
                // on every request

roles         // derived from DB lookup

// WHY: When HQ revokes a permission,
// it takes effect IMMEDIATELY.
// No waiting for token expiry.
15min
Access Token TTL
7 days
Refresh Token TTL
httpOnly
Refresh Cookie
Instant
Revocation

Security

Password Hashing

Argon2id

OWASP #1 recommendation for 2026. Replaces bcrypt. Memory-hard, resistant to GPU and ASIC attacks.

Rate Limiting

Dual-Layer Protection

5 attempts per user + 20 per IP per 15 min window. Account lockout after 5 failed attempts (30 min cooldown).

Session Management

Single Session per App

New login revokes previous session for same user + app context. Prevents ghost sessions on shared POS terminals.

Token Security

httpOnly Refresh Cookie

Refresh token stored in httpOnly cookie — not accessible to JavaScript. Prevents XSS token theft.

Audit Trail

Log ALL sensitive actions — immutable (insert only, never update/delete).

Login success and failure attempts
Permission denied events
CRUD operations on sensitive entities
Discounts, voids, refunds, price overrides
userId, action, entityType, entityId, storeId, appContext
Timestamp, IP address, success/failure status

Database Schema

Auth Tables
users
  id, username, passwordHash, isSuperAdmin, isActive

roles
  id, name, scope (hq | store | pos), description

permissions
  id, code, scope, description

user_roles
  userId, roleId, storeId (nullable — null for HQ roles)

role_permissions
  roleId, permissionId

audit_logs
  userId, action, entityType, entityId, storeId,
  appContext, changes (jsonb), ip, timestamp, status

revocation_list
  userId, revokedAt, reason

Real-World POS Systems Studied

Industry Reference

Shopify POS

Custom roles per staff member. PIN login for fast cashier switching. Granular permissions per action (discounts, refunds, reports).

Industry Reference

Lightspeed

3 default roles: Admin, Manager, Cashier. Custom roles available on Enterprise tier. Role-based register access control.

Industry Reference

ERPNext

3-level security model: Role → Document Type → Field level. Deep granularity but complex to configure. Good for ERP, heavy for POS.

Industry Reference

Odoo

User types (Internal/Portal/Public) combined with role permissions. Group-based access with record rules. Mature but monolithic.

Implementation Plan

How it maps to our module structure:

Module Structure
packages/          → nothing auth-specific (custom RBAC is server-only)
apps/hq-server/
  src/
    lib/auth.ts           → hashPassword, verifyPassword, createToken, verifyToken
    lib/permission.ts     → PermissionService (getUserPermissions, checkPermission)
    lib/authorize.ts      → Fastify middleware (validateJWT, loadPermissions, requirePermission)
    modules/auth/routes.ts → login, refresh, logout, revoke
    db/schema/auth.ts     → users, roles, permissions, user_roles, role_permissions, audit_logs

Dependencies

Only 2 external packages. Everything else is custom.

Package

argon2

Password hashing. OWASP-recommended. Memory-hard algorithm resistant to GPU/ASIC attacks.

Package

jose

JWT sign/verify. Modern, maintained, standards-compliant. Universal JavaScript implementation.

Note — Sync Boundary

Permission sync between HQ and Stores is a sync concern

Permission sync between HQ and Stores is covered in the Sync Architecture section. Auth module only handles authentication and authorization — how permissions propagate is a sync concern, not an auth concern.

Offline-First Architecture

The Question

How should POS terminals handle network failures gracefully while ensuring data consistency when connectivity returns?

Research Needed

Research needed on offline-first patterns for POS terminals

  • Local Database Strategy — SQLite vs IndexedDB vs OPFS for offline storage on POS terminals
  • Conflict Resolution — Handling concurrent offline edits: CRDTs, operational transforms, or application-level merge
  • Queue Management — Outbox queue for offline sales: ordering guarantees, retry policies, deduplication
  • Data Freshness — How stale can product prices/inventory be? Configurable staleness thresholds
  • Partial Sync — Syncing only changed data vs full snapshots: delta sync patterns for constrained bandwidth
  • Offline Auth — Authenticating users when HQ is unreachable: cached credentials, time-limited tokens
  • Service Worker Strategy — PWA offline caching: cache-first vs network-first per resource type

Architecture Decision Log

DecisionChoiceAlternatives ConsideredOriginStatus
Business Rules EngineBefore/After Hooks Condition/Action, JSON Rules EngineV1 Done
Shared Code Strategypos-logic package Copy-paste, git submodulesV1 Done
Sync Version TrackingGlobal version counter Per-row version (V1, broken)V1 V2
Sync ArchitectureRegistry-based per-entity handlers Monolithic sync, event sourcingV1 V2
Conflict ResolutionPer-entity ownership (HQ/Store wins) Generic CRDT, last-write-wins for allV1 V2
Backend AbstractionCommand-based adapter Direct fetch(), separate API clientsV1 Done
Money RepresentationCents (integers) Decimals, stringsV1 Done
Tax RatesBasis points (825 = 8.25%) Decimals, percentagesV1 Done
Store ↔ HQ CommunicationWS notifications + HTTP data Pure WS, polling, gRPCV1 Done
Outbox PatternTransactional outbox Direct push, event sourcingV1 Done
Database Schema StrategyTBD Normalized, denormalized, hybridV2 Planned
Authentication ModelCustom RBAC + Argon2 + Jose CASL (ABAC), Casbin (PBAC), bcrypt, sessionsV2 Done
Auth vs Business RulesRBAC for permissions, Rule Engine for business logic ABAC with attribute-based rules, mixed auth+rulesV2 Done
Offline StrategyTBD SQLite, IndexedDB, OPFSV2 Planned

All Sources

// Rule Engine

// Sync Architecture

// Tauri + Shared Code

// Feature Roadmap