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" }- Server looks up user by username (case-insensitive)
- Verifies PIN against bcrypt hash stored in
users.pin_hash - Loads all permissions for the user (from all roles, across all stores)
- 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" }- Fetches all active users
- Compares PIN against each user's bcrypt hash (necessary because bcrypt hashes cannot be reversed)
- On match, loads permissions scoped to the specified store
- Returns JWT with
storeIdandterminalIdembedded 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
{
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).
{
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:
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):
export async function productRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// All routes below require authentication
}Per-route preHandler (for mixed auth requirements):
app.get('/me', { preHandler: [app.authenticate] }, handler);The requirePermission Middleware
After authentication, specific routes check permissions:
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:
// 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:
- Extracts
userIdfrom the JWT payload - Queries
user_store_rolesto find the user's role IDs - Queries
role_permissionsjoined withpermissionsto get permission codes - Checks if any required permission matches (with wildcard support)
- 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:
| Module | Permissions |
|---|---|
pos | sell, refund, void, discount, discount.override_max, price.override, park_cart |
inventory | view, adjust, count |
transfers | view, create, send, receive, approve |
reports | x_report, z_report, zz_report, view_local, view_global, product_performance, sales_by_rep, profit_margin, cashier_performance, discount_analysis |
products | view, create, edit, delete |
users | view, create, edit, manage_roles |
stores | view, create, edit, provision |
sales_reps | view, create, edit, delete |
purchase_orders | view, create, edit, approve, receive, delete |
worksheets | view, create, submit, approve, apply, delete |
store_prices | view, manage |
suppliers | view, create, edit, delete |
taxes | view, manage |
tenders | view, manage |
specials | view, manage |
customers | view, create, edit |
registers | view, open, close |
roles | view, 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)
| Role | Key Permissions |
|---|---|
| Administrator | All permissions |
| Cashier | pos.sell, pos.discount, reports.x_report, inventory.view |
| Store Manager | All POS + inventory + transfers + local reports |
Client-Side Authorization
Vue Router Guards
The router checks permissions in a beforeEach guard:
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:
{
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:
- Persists the token to
localStorage - Restores the session on page load via
init()(calls/auth/me) - Sets the token on the backend adapter for subsequent API calls
- Provides
hasPermission(code)andhasAnyPermission(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:
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:
| Username | PIN | Role |
|---|---|---|
admin | 1234 | Administrator |
cashier1 | 1234 | Cashier |
manager1 | 1234 | Store Manager |