Skip to content

Authentication And Authorization

Wavemap uses username/password authentication with JWT-backed sessions and role-derived permissions.

This page is the durable developer reference for auth architecture and implementation patterns. Historical rollout notes, test proof, and unresolved browser-runtime issues live in working-notes/AUTH_AND_AUTHORIZATION.md and working-notes/AUTH_PUSH_ROADMAP.md.

  • The backend is authoritative for every protected API request and mutation.
  • Roles are persisted; permissions are derived from roles at runtime.
  • Permission checks should use permission codes, not hard-coded role allowlists, whenever the shared maps can express the rule.
  • Access tokens are short-lived and kept in client memory.
  • Refresh tokens are longer-lived, stored in an HttpOnly cookie, and rotated by the backend.
  • Frontend auth state is TanStack Query state plus a small in-memory token module, not React context.
  • Frontend route guards and conditional rendering improve navigation and UX, but they do not replace backend enforcement.
AreaPrimary Source
Role and permission codespackages/api-contracts/src/constants/auth.ts
User and auth DTOspackages/api-contracts/src/types/auth.ts
Role-to-permission mappackages/api-contracts/src/utils/rolesPermissionsMap.ts
Backend route mapapps/wavemap-back-end/src/utils/routesPermissionsMap.ts
Backend auth middlewareapps/wavemap-back-end/src/middleware/isAuthenticated.ts, getCurrentUser.ts, isAuthorized.ts
Backend entity helpersapps/wavemap-back-end/src/utils/entityAuthorization.ts
Backend auth handlersapps/wavemap-back-end/src/handlers/auth/
Frontend auth API layerapps/wavemap-front-end/src/api/axios/auth.ts
Frontend auth hooksapps/wavemap-front-end/src/api/queries/auth/, apps/wavemap-front-end/src/api/mutations/auth/
Frontend route accessapps/wavemap-front-end/src/utils/auth/routeAccess.ts
Frontend permission UIapps/wavemap-front-end/src/utils/auth/permissions.ts, entityAuthorization.ts, RequiresPermission.tsx

Auth changes usually cross the package/app boundary, so start with the contract that downstream code should share:

  1. Put role codes, permission codes, auth DTOs, and frontend route constants in @wavemap/api-contracts.
  2. Derive role capability from rolesPermissionsMap; do not copy role allowlists into app code.
  3. Protect backend routes through routesPermissionsMap and the auth middleware chain.
  4. Wire frontend API functions and query/mutation hooks against the shared DTOs.
  5. Add frontend route-access rules only for pages that require a session or page-level permission redirect.
  6. Gate public-page controls with permission utilities instead of making the whole page private.
  7. Prove the change at the lowest useful test layer before adding browser coverage.

The backend owns authority. The frontend owns navigation, intent preservation, and conditional affordances. Shared contracts keep the two sides from inventing separate names for the same capability.

Access tokens are short-lived JWTs returned by login and refresh responses. The frontend stores them through tokenOps.ts, a thin wrapper over the JWTManager singleton. They are sent to the API as bearer tokens and are not persisted in localStorage or sessionStorage.

Refresh tokens are stored in an HttpOnly cookie. The backend rotates the refresh cookie when the refresh endpoint is used and issues a fresh access token. Refresh-token revocation is driven by the user’s refresh-token version, which lets the backend invalidate older refresh tokens without storing every issued token.

The frontend source of truth for user identity is useCurrentUser():

  • It uses the stable query key exported as QUERY_KEY__CURRENT_USER.
  • Its query function calls refreshAccessToken() to attempt session restore from the refresh-token cookie.
  • On success, it stores the returned access token through setAccessToken() and returns the authenticated user DTO.
  • On failure, it returns null; an unauthenticated user is a settled state, not a thrown query error.
  • It uses staleTime: Number.POSITIVE_INFINITY and retry: false.

Login and logout mutate this same query cache. useLogin() stores the access token and sets the current user. useLogout() optimistically clears the access token and current-user cache, rolls back on failure, and broadcasts logout to other tabs.

Protected backend handlers use the auth middleware chain:

isAuthenticated -> getCurrentUser -> isAuthorized

isAuthenticated reads the bearer token from the Authorization header and places it in Hono context. getCurrentUser verifies the access token, loads the current user from the database, and puts the flattened role list into request context. generateAuthorizationMiddleware(routesPermissionsMap, rolesPermissionsMap) derives permissions from the user’s roles and rejects requests missing the required permissions for the matched route.

When adding or changing a protected backend route:

  1. Add or reuse permission codes in @wavemap/api-contracts.
  2. Make sure the correct roles receive those permissions in rolesPermissionsMap.
  3. Add the route’s required permissions to routesPermissionsMap.
  4. Apply isAuthenticated, getCurrentUser, and isAuthorized to protected handler arrays.
  5. Keep public read routes public when no session is required.

An empty permission list means “no additional permission requirement.” It does not by itself decide whether a route requires a session; the handler middleware chain decides that.

Entity mutation handlers may also call backend authorization helpers after they have resolved the concrete entity rows:

  • assertCanMutateEntity(...)
  • assertCanManageEntityMedia(...)
  • assertCanAssociateEntity(...)

Today these helpers delegate to the same role-derived permission map used by route authorization. They are intentionally lightweight stubs, not a second ownership model. Their purpose is to give handlers a stable place to pass actor and entity references now, so future ownership, organization, profile-control, or transfer rules can be added without rewriting each handler signature.

Use them when a handler has a meaningful entity reference available. Keep route-level middleware in place as the first authorization gate.

Roles are database assignments. Permissions are code-level capabilities derived from the shared role map.

RoleIntended Scope
userStandard authenticated user access.
adminContent creation and update workflows for artists, events, series, and venues.
rootUser management, deletion, admin control, and refresh-token revocation.
dev-debugDevelopment-only role with root-equivalent permissions.

Permission codes are kebab-case verb/resource strings such as read-artist, create-event, update-user, and revoke-refresh-token. Relationship and media permissions should stay separate from profile/content editing permissions when the action manages an association or asset collection instead of the entity’s scalar fields.

Artists are the current worked example for this grammar:

  • update-artist-profile controls artist scalar/profile fields and profile links.
  • manage-artist-media controls artist media upload, deletion, ordering, and synchronization.
  • manage-artist-event-relationships controls artist-event association routes.
  • delete-artist controls artist deletion.

Keep role names and permission codes aligned with @wavemap/api-contracts; do not copy older uppercase, underscore, or legacy broad spellings from historical notes.

Frontend auth calls are plain axios functions in src/api/axios/auth.ts, with request and response types from @wavemap/api-contracts.

Current auth functions include:

  • adminRegistration(args)
  • registration(args)
  • login(args)
  • logout()
  • refreshAccessToken()
  • requestPasswordReset(args)
  • resetPassword(args)
  • changePassword(args)
  • verifyAccount(args)
  • resendAccountVerification(args)

Each user-initiated auth action gets a dedicated mutation hook under src/api/mutations/auth/. This keeps auth actions consistent with the rest of the frontend API layer. The hooks should own cache side effects instead of pushing them into page components.

Use these cache rules:

  • Login sets the access token and currentUser.
  • Logout clears the access token and currentUser; preserve rollback behavior for failed logout.
  • Registration and admin registration do not create a logged-in session.
  • Password reset, account verification, resend verification, and change-password do not rewrite currentUser unless the response contract changes to require it.

The axios response interceptor handles expired-token retry by calling refreshAccessToken() and retrying the original request. Non-refreshable 401s are left to the calling view so auth pages can render local error states. Forbidden responses are treated as authorization failures and redirect through the app error path.

Frontend route protection is deliberately split across middleware and client hooks:

LayerCan VerifyCannot VerifyResponsibility
Backend middlewareSession validity and DB-backed permissionsAuthoritative API enforcement.
Next.js middlewareRefresh-token cookie presencePermissions, roles, token validityHard redirect for unauthenticated full-page loads.
useRequireAuth()Current user and derived permissions after hydrationBackend mutation authorityClient-side route guard and permission redirect.
Permission hooks/componentsCurrent user and derived permissions after hydrationBackend mutation authorityConditional controls, nav items, and page sections.

routeAccess.ts is the centralized frontend route-access config. Each rule names a route constant, declares requiresAuth: true, and may include required permission codes. Dynamic route templates are normalized to static prefixes for middleware matching.

The Next.js authGuard consumes GUARDED_ROUTE_PREFIXES from routeAccess.ts. It only answers “does this request need a session cookie?” If a guarded route is requested without the refresh-token cookie, the middleware redirects to localized login and preserves the original path in a redirect query parameter.

Page components that require auth should also call useRequireAuth():

const { isAuthorized } = useRequireAuth()
if (!isAuthorized) return null

This covers client-side navigations where middleware may not run and handles permission-based redirects to /access-denied.

Routes that are public but contain permission-gated controls should not be added to ROUTE_ACCESS_RULES. Gate the specific controls instead.

Frontend route access is a UX and navigation contract, not the backend permission system.

Keep these rules aligned:

  • A protected frontend page should have a ROUTE_ACCESS_RULES entry and call useRequireAuth() in the page component or route-owned inner component.
  • A route that only contains a protected button, link, or panel should stay public and use <RequiresPermission> or useCurrentUserPermissions().
  • Dynamic frontend routes should use shared route constants so routeAccess.ts can normalize route templates to guarded prefixes for middleware.
  • Middleware should only answer “does this route need a session cookie?” It cannot decide whether a user still exists, whether the refresh token is valid, or whether the user has the right permissions.
  • Permission redirects belong in useRequireAuth() because that hook can read the current user after hydration.
  • Backend API protection must still be configured even when the only visible frontend entry point is permission-gated.

For example, a public artist details page can show profile edit controls only to users with update-artist-profile, media recovery controls only to users with manage-artist-media, and delete controls only to users with delete-artist. The page itself should stay public, while the artist mutation API routes remain backend-protected.

Use useCurrentUserPermissions() for imperative checks:

const { hasPermission } = useCurrentUserPermissions()
if (hasPermission(PERMISSION_CODE__UPDATE_ARTIST_PROFILE)) {
// enable update affordance
}

Use <RequiresPermission> for JSX gating:

<RequiresPermission permission={PERMISSION_CODE__UPDATE_ARTIST_PROFILE}>
<EditArtistButton />
</RequiresPermission>

The wrapper renders children when the user holds the permission and fallback otherwise. Its default fallback is null.

Use the entity-shaped helpers from useCurrentUserPermissions() when a control maps to a concrete entity action:

const { canManageEntityMedia } = useCurrentUserPermissions()
const canShowMediaTools = canManageEntityMedia({
requiredPermission: PERMISSION_CODE__MANAGE_ARTIST_MEDIA,
entity: { entityType: "artist", publicId: artistPublicId },
})

These helpers currently delegate to the same role-derived permission checks as hasPermission(...). They are not an authorization boundary and should not be trusted by the backend. Their value is shape: UI code can pass the actor’s intended operation and entity reference now, leaving room for future owner/controller-aware affordances without pushing that logic into every component.

Prefer permission checks over role checks. For example, an “Add Artist” control should check create-artist; it should not manually ask whether the user is an admin or root.

Standard registration and invited admin registration are separate frontend flows with separate request contracts.

  • Standard registration uses TRegistrationArgs and does not require a registration code.
  • Admin registration uses TAdminRegistrationArgs and requires a registration code.
  • Admin registration links may prefill the code from URL params, but the field stays editable.
  • Registration requests carry a best-effort locale from the browser/app context; the backend normalizes it for the new user’s initial defaultLanguage and account verification email.
  • A completed registration does not create a session; users still verify their account before login.

Account verification, resend verification, password reset, and admin invitation emails are backend email flows. Their templates live in the backend email folder, while request and response DTOs live in @wavemap/api-contracts.

Admin and root capabilities are intentionally modeled as permissions, not as UI assumptions about role names.

Current boundaries:

  • admin can create and update content surfaces such as artists, events, event series, and venues.
  • admin can currently update artist profiles, manage artist media, and manage artist-event relationships; those remain separate permissions even though the same role receives them today.
  • root owns user-management and admin-control actions such as updating users, deleting users, enabling/disabling admin privileges, enabling/disabling user accounts, sending admin invitations, and revoking refresh tokens.
  • dev-debug is a development-only root-equivalent role. It should not become a product-facing role or admin UX label.
  • Admin registration is invite-oriented. The admin-registration route uses a registration code, but it still creates a normal verified-account path and does not create a logged-in session.
  • Role and permission display, editing, auditing, and localization should wait for concrete admin/permissions UI surfaces. Do not turn internal permission codes into public copy before the product surface exists.

When a future admin page appears, gate page access with the narrowest permission that matches the page’s job and gate individual destructive controls separately when needed.

When adding a protected feature, work from the authority outward:

  1. Model any new permission or route contract in @wavemap/api-contracts.
  2. Update rolesPermissionsMap only when role capability changes.
  3. Update backend routesPermissionsMap and handler middleware for protected API routes.
  4. Add or update typed frontend axios functions and mutation/query hooks.
  5. Add a routeAccess.ts rule for protected frontend pages.
  6. Call useRequireAuth() inside protected page components.
  7. Gate individual public-page controls with <RequiresPermission> or useCurrentUserPermissions().
  8. Use entity-shaped frontend helpers for entity-specific controls that may later depend on ownership or profile control.
  9. Add targeted backend request tests, frontend hook/component tests, and route-guard tests for the changed behavior.

Choose the smallest layer that proves the auth risk:

  • Use backend mocked-request tests for route wiring, request validation, API envelopes, unauthenticated responses, and unauthorized responses.
  • Use DB-backed backend tests when token rotation, refresh-token revocation, password hashing, role joins, invitation codes, or persisted account state matters.
  • Use front-end integration tests for auth pages, query/mutation cache effects, route-access rules, permission utilities, and protected page guards.
  • Use Playwright only for browser-specific confidence such as redirect preservation, full-page middleware behavior, focus/navigation behavior after login, or high-value auth journeys.

The current smoke posture keeps broad auth lifecycle Playwright coverage out of the default gate while browser-safe API paths, built-runtime timing, and selector stability continue to mature. See Testing for the browser-runtime contract and smoke-lane posture.

Keep this page focused on durable architecture and implementation conventions.

Keep working notes for:

  • Historical auth-push sequencing and proof history.
  • Open Playwright runtime hardening work.
  • Browser-safe API path and selector-stability notes.
  • Product surfaces that are still placeholders, such as broader profile-editing and MFA flows.
  • Run-specific evidence, logs, and exploratory notes.