Skip to content

Authentication & Authorization

Overview

The POS system uses JWT-based authentication with a role-based permission model. Users authenticate with a username + PIN (or password), receive a JWT access token, and include it in subsequent requests. Permissions are enforced both server-side (Fastify hooks) and client-side (Vue Router guards).

Authentication Flow

Standard Login (HQ / Web Client)

POST /api/v1/auth/login
{ "username": "admin", "pin": "1234" }
  1. Server looks up user by username (case-insensitive)
  2. Verifies PIN against bcrypt hash stored in users.pin_hash
  3. Loads all permissions for the user (from all roles, across all stores)
  4. Returns JWT access token (8h expiry) and refresh token (7d expiry)

PIN-Only Login (POS / Store)

POST /api/v1/auth/pin-login
{ "pin": "1234", "storeId": "uuid", "terminalId": "T01" }
  1. Fetches all active users
  2. Compares PIN against each user's bcrypt hash (necessary because bcrypt hashes cannot be reversed)
  3. On match, loads permissions scoped to the specified store
  4. Returns JWT with storeId and terminalId embedded in the payload

The PIN-only login is used by the POS terminal, where cashiers enter only a PIN without a username.

Token Refresh

POST /api/v1/auth/refresh
{ "refreshToken": "eyJ..." }

Returns a new access token without re-authenticating. The refresh token must have type: "refresh" in its payload.

JWT Payload

User Token

typescript
{
  userId: string;      // UUID
  username: string;    // For display
  storeId?: string;    // Present for store/POS logins
  terminalId?: string; // Present for POS logins
}

Store Sync Token

Used for store-to-HQ sync authentication. Generated during store provisioning and stored as an environment variable (SYNC_TOKEN).

typescript
{
  storeId: string;     // UUID
  storeCode: string;   // e.g., "ST01"
  type: "store_sync";  // Distinguishes from user tokens
}

Server-Side Authentication

The authenticate Decorator

Both HQ and Store servers register a Fastify decorator:

typescript
app.decorate('authenticate', async function (request, reply) {
  try {
    await request.jwtVerify();
  } catch (err) {
    reply.status(401).send({ success: false, error: 'Unauthorized' });
  }
});

Route modules apply it as a hook. There are two patterns:

Module-level hook (protects all routes in the module):

typescript
export async function productRoutes(app: FastifyInstance) {
  app.addHook('onRequest', app.authenticate);
  // All routes below require authentication
}

Per-route preHandler (for mixed auth requirements):

typescript
app.get('/me', { preHandler: [app.authenticate] }, handler);

The requirePermission Middleware

After authentication, specific routes check permissions:

typescript
import { requirePermission } from '../../lib/auth-guard.js';

app.get('/', {
  preHandler: [requirePermission('products.view')]
}, handler);

requirePermission accepts one or more permission codes. The user must have at least one of them (OR logic). It supports wildcard patterns:

typescript
// User needs any permission starting with "products."
requirePermission('products.*')

// User needs either products.view OR products.edit
requirePermission('products.view', 'products.edit')

How it works internally:

  1. Extracts userId from the JWT payload
  2. Queries user_store_roles to find the user's role IDs
  3. Queries role_permissions joined with permissions to get permission codes
  4. Checks if any required permission matches (with wildcard support)
  5. Returns 403 if no match

Source: apps/hq-server/src/lib/auth-guard.ts

Permission Model

Structure

User -- user_store_roles --> Role -- role_permissions --> Permission
          (optional store_id)
  • Permissions are atomic capability codes (e.g., products.view)
  • Roles group permissions together (e.g., "Administrator" has all permissions)
  • User-Store-Roles assign roles to users, optionally scoped to a specific store
    • store_id = null: Global role (applies everywhere)
    • store_id = uuid: Role applies only at that store

Permission Codes

Permissions follow the pattern module.action. There are 65+ permissions across these modules:

ModulePermissions
possell, refund, void, discount, discount.override_max, price.override, park_cart
inventoryview, adjust, count
transfersview, create, send, receive, approve
reportsx_report, z_report, zz_report, view_local, view_global, product_performance, sales_by_rep, profit_margin, cashier_performance, discount_analysis
productsview, create, edit, delete
usersview, create, edit, manage_roles
storesview, create, edit, provision
sales_repsview, create, edit, delete
purchase_ordersview, create, edit, approve, receive, delete
worksheetsview, create, submit, approve, apply, delete
store_pricesview, manage
suppliersview, create, edit, delete
taxesview, manage
tendersview, manage
specialsview, manage
customersview, create, edit
registersview, open, close
rolesview, manage

All permissions are registered in a central registry at apps/hq-server/src/lib/permission-registry.ts. This registry is used for database seeding and the admin UI.

Default Roles (Seeded)

RoleKey Permissions
AdministratorAll permissions
Cashierpos.sell, pos.discount, reports.x_report, inventory.view
Store ManagerAll POS + inventory + transfers + local reports

Client-Side Authorization

Vue Router Guards

The router checks permissions in a beforeEach guard:

typescript
router.beforeEach(async (to) => {
  if (to.meta.requiresAuth) {
    const authStore = useAuthStore();

    if (!authStore.isLoggedIn) {
      return { name: 'login' };
    }

    const requiredPermissions = to.meta.permissions;
    if (requiredPermissions?.length > 0) {
      if (!authStore.hasAnyPermission(requiredPermissions)) {
        return { name: 'unauthorized' };
      }
    }
  }
});

Route definitions declare required permissions in meta:

typescript
{
  path: 'products',
  name: 'hq-products',
  component: () => import('./views/hq/ProductsView.vue'),
  meta: { permissions: ['products.view', 'products.*'] },
}

Auth Store (Pinia)

The useAuthStore holds the current user, token, and permissions in memory. It:

  1. Persists the token to localStorage
  2. Restores the session on page load via init() (calls /auth/me)
  3. Sets the token on the backend adapter for subsequent API calls
  4. Provides hasPermission(code) and hasAnyPermission(codes) methods

Sync Token Authentication

Store servers authenticate to HQ using a long-lived JWT sync token. This token has type: "store_sync" and includes the storeId. The sync endpoints validate it separately from user JWT tokens:

typescript
async function authenticateSyncToken(app, request, reply) {
  const token = request.headers.authorization?.slice(7);
  const decoded = app.jwt.verify(token);

  if (decoded.type !== 'store_sync' || !decoded.storeId) {
    reply.status(401).send({ error: 'Invalid sync token type' });
    return null;
  }

  // Verify the store exists and is active
  const store = await db.select().from(stores).where(eq(stores.id, decoded.storeId));
  if (!store || !store.isActive) {
    reply.status(401).send({ error: 'Store not found or inactive' });
    return null;
  }

  return decoded.storeId;
}

Dev Credentials

For development, the seeded users are:

UsernamePINRole
admin1234Administrator
cashier11234Cashier
manager11234Store Manager