Slice 1 — Identity & Audit
The foundational slice. Builds authentication, authorization, session management, and the tamper-evident audit log. Every subsequent slice's mutations write through this slice's audit helper.
Status: Built. Auth + roles + per-staff PINs + audit-event/mutation writes are live across every mutating endpoint via recordMutation(). The remaining slice-1 follow-ons are API-level gating (today the cookie identifies the user; most endpoints don't refuse anonymous callers) and the daily chain-hash verification cron.
Scope
- Staff table + roles + permissions (role defaults + per-staff overrides)
- Sign-in flow (PIN entry, lockout, session cookie)
- Sign-out + idle timeout
- Audit-chain schema (
audit_events+audit_mutations) - Audit helper (
withAudit) used by every mutating endpoint - Daily chain-verification cron
- Admin reset code path (
466687)
Schema
staff— see staff entityroles— Sys Admin, Owner, Sales, Mechanic, Junior, Service Leadrole_permissions— defaults per role × screenstaff_screen_permissions— per-staff overrides; wins over rolescreens— Today, Sales, Customers, Service, Inventory, Trades, Rentals, Orders, Reports, Settingsstaff_sessions— active sessionsauth_attempts— failed sign-in attempts (for rate limit)audit_events— chain-linked;build_versioncolumn added in migration 013 (build versioning)audit_mutations— before/after snapshotsdevice_sessions— Layer-1 (OAuth) device cookies; added migration 015. One row per authenticated browser, 30-day sliding expiry. See ADR-0026.shop_config.authorized_google_emails— JSON allowlist column added migration 015. Seeded with Swicked's day-one emails.idempotency_records— added migration 014 for offline-arch Slice 2staff_messages— added migration 032. Lightweight 1-on-1 DMs between signed-in staff. Text only (1-2000 chars), no attachments, no groups.CHECK (from_staff_id != to_staff_id)and a soft-delete flag (preserved for audit; never hard-deleted).shop_config.idle_lockout_seconds,roles.idle_lockout_seconds,staff.idle_lockout_seconds— added migration 018. Three-tier resolutionCOALESCE(staff, role, shop_config).NULL= inherit;0= never lock;15..3600= seconds. CHECK constraints enforce the range so an Owner can't accidentally pick a 5-second lockout that would interrupt every transaction.shop_settings— added migration 019. Manifest-driven settings registry: the JS side ishelmSettings.registerSection()+helmSettings.registerSetting()inpublic/index.html; settings render in both the search-driven Settings tab and the in-situ section-header popover. Key-value table; mutations viaPUT /api/settings/:keyaudit throughrecordMutationwithaudit_mutations.trigger_source ∈ {settings-tab, in-situ}(two new values widening the existing column's vocabulary — no schema change toaudit_mutationsitself).shop_config.fiscal_year_end_mmdd— added migration 035. MM-DD string, default'12-31'. Drives YTD anchoring for the Sales report endpoint and the YTD pill on the Sales screen. Editable from Settings → Store Info and surfaced in the new-shop orientation questionnaire. Validated server-side as a real calendar date.integration_gbp— added migration 036. Single-row Google Business Profile connection state. See integrations.custom_integrations— added migration 037. Owner-defined third-party integrations with five auth-type templates. See integrations.
Endpoints
Layer 1 (Google OAuth + device sessions) — added 2026-05-13, ADR-0026:
GET /api/auth/google/start— generate state + PKCE code-verifier, redirect to Google's authorization endpointGET /api/auth/google/callback— exchange code → tokens, validate email againstshop_config.authorized_google_emails, mintdevice_sessionsrow +helm_devicecookie, redirect to/POST /api/auth/google/signout— mark currentdevice_sessionsrow revoked, clearhelm_devicecookieGET /api/auth/allowlist— read the authorized-emails list (Sys Admin only)POST /api/auth/allowlist— add an email to the allowlistDELETE /api/auth/allowlist— remove an email
Layer 2 (PIN) — unchanged from earlier:
POST /api/auth/login— PIN entry, returnshelm_sessioncookie (requires validhelm_devicecookie)POST /api/auth/logout— clears sessionGET /api/auth/me— returns the currently-signed-in staff plus the resolvedidle_lockout_seconds(migration 018 —COALESCE(staff, role, shop_config)). Frontend uses this instead of a hard-codedIDLE_MSconstant.POST /api/auth/reset-pin— admin code pathGET /api/staff— list (Settings → Staff visible only)PUT /api/roles/:id— update role-level properties (today:idle_lockout_secondsonly) ✓GET /api/staff/:id— detailPUT /api/staff/:id— editPUT /api/staff/:id/pin— set PINDELETE /api/staff/:id/pin— clear PINPUT /api/staff/:id/screens— set per-staff permission overridesDELETE /api/staff/:id/screens— clear all overrides (reset to role defaults)GET /api/roles— list rolesGET /api/screens— list screensPUT /api/roles/:id/permissions— change role defaults (not built; not needed in v0.1)
UI
Settings → Staff & Permissions:
- List of staff cards
- Per staff: role dropdown, per-screen visibility checkboxes, Select/Deselect All, "Reset to role defaults", PIN management
-
- Add staff button
Sign-in overlay:
- Always present on app load (no Sign In button anywhere)
- Triggered by idle timeout
- 5-digit PIN entry; lockout after 5 wrong attempts
- "I forgot my PIN" → admin reset code modal
Migration from AIM
9 staff records carried forward; AIM permission model not migrated. Initial PIN set by Sys Admin on first sign-in. See migration from AIM.
What's built
- All endpoints listed above ✓
- Sign-in overlay UI ✓
- Idle timeout (60s) ✓
- PIN management (set/clear/reset) ✓
- Per-staff overrides for screen visibility ✓
- Admin reset code (466687) direct sign-in ✓
- Audit helper (
recordMutation) called on every mutating endpoint ✓ — writes bothaudit_events+audit_mutations(before/after JSON, diff) per mutation - Audit recent-feed endpoints (
GET /api/audit/recent,GET /api/audit/events/:id) ✓ POST /api/audit/manual— bridge endpoint for client-only flows (e.g., the Beta Feedback system) to land in the audit chain. Wrapped inwithIdempotency(ADR-0015 implementation, migration 014) so client retries on the same key replay the original response instead of double-writing. ✓GET /api/build-info— current build identity (version,git_sha,built_at,environment); used by the user-menu version pill and by Beta Comments to snapshot the build at click time. See build versioning. ✓- Build version stamped on every audit row — migration 013 added
audit_events.build_version+ filtered index;recordAuditEventpasses the module-levelBUILD_VERSIONconstant on every INSERT (mutations, auth, manual). ✓ - Permissions module v0.5.0 (live 2026-05-23, migration 053 +
public/js/helm-perms.js) ✓ — the substrate has existed since migration 001 (staff → role_id → roles ← role_permissions → permissions) but onlyscreen.*keys were ever populated. This migration ships the full catalog (~95 dot-notation permission keys coveringsales.*,service.*,inventory.*,customers.*,reports.*,sys_admin.*,screen.*), the 6 canonical role grants (sys_admin, owner, manager, mechanic, sales, junior — sys_admin is finally seeded by this migration; everything that referencedADMIN_ROLE_CODESalready included it), and a newstaff_permission_overridestable with override semantics distinct from migration 016'sstaff_screen_permissions(016 = REPLACE: rows-present-means-those-are-the-set; 053 = OVERRIDE: allow/revoke individual perms on top of role defaults, withexpires_atfor time-bound grants andgranted_by_staff_id+reasonfor audit). Idempotent (INSERT OR IGNORE + CREATE TABLE IF NOT EXISTS). Client-side enforcement:helm-perms.js(~190 LOC) fetches/api/me/permissionson boot, exposeswindow.helmCan(perm)+window.helmPermissions+window.helmCurrentRole. DOM walker hides (or disables, perdata-perm-mode="disable") any element withdata-perm="some.perm.key"the user doesn't hold; walker re-runs onhelmPermsRefresh()for dynamic modals. - Staff messages (migration 032, live 2026-05-16) — lightweight 1-on-1 DMs between signed-in staff. Polling-based to keep the Worker side cheap:
- badge poll every 30s (
GET /api/messages/unread-count) - inbox poll every 10s when open (
GET /api/messages/inbox) - thread poll every 5s when open (
GET /api/messages/thread/:other_id) - send:
POST /api/messages { to_staff_id, body }; mark-read:POST /api/messages/mark-read; search:GET /api/messages/search?q=... - sender can soft-delete their own messages (
is_deletedflag preserved for audit; never hard-deleted). ✓
- badge poll every 30s (
What's not yet built
- API-level gating — cookie identifies the user; most endpoints don't refuse anonymous callers. Nav-tab gating in the frontend (per the resolved screen permissions) is the next concrete piece
- Daily chain-hash verification cron — schema captures
prev_chain_hash+chain_hashper event; the cron that walks the chain end-to-end and reports breaks is not wired (no scheduled handlers in the Worker yet) - Action-level permissions — only screen-level today; "can refund within sales" needs the
screen_actionstable - CSRF tokens — session cookie SameSite=Lax handles most, but explicit tokens for state-changing endpoints are TBD
- Rate-limit storage in KV —
auth_attemptstable works but a KV-backed counter would be faster - External anchoring — chain hash is local; posting to an external timestamping service is roadmapped
Acceptance criteria for "slice 1 done"
- Every mutating endpoint writes an audit row ✓
- Daily cron verifies the chain end-to-end and reports breaks
- API-level gating refuses anonymous callers on every endpoint per the resolved screen permissions
- Action-level permissions cover the obvious privileged operations (refunds, deletes of audit-protected entities, settings changes)
- A KV-backed login rate limit is wired
- The Staff & Permissions UI shows audit history per staff (last 30 days)
See also
- Security model
- Audit-everything
- Build versioning — every audit row now stamps the build that produced it
- Staff entity
- Permission entity
- Staff session lifecycle
- ADR-0005: Tamper-chain audit log
- ADR-0013: PBKDF2 PIN hashing
- ADR-0014: Per-staff permission overrides