The bootstrap, one payload per page, access helpers, tenant actions and permission refs.
A page makes one user-scoped database call besides the cached bootstrap, makes no Auth call before it renders, and keeps its private data for at least five minutes so navigation is instant. These notes name the helpers that keep it that way. apps/saas/tests/request-path-guards.test.ts and tests/integration/cross-package/toolchain/request-path-retired-apis.test.ts fail when the old helpers come back.
getBoot(orgSlug) in apps/saas/lib/organizations/boot.ts is the only caller of loadAppBoot. It is one "use cache: private" entry per slug with the default stale of 300 seconds, and returns the profile, the organization list, the portal organizations, the tenant's badges and announcements, and the caller's current PermDock version. An ended session redirects to sign-in without being cached. The shell, the badge islands and the access helpers all read this one entry, so do not add a second organization or profile query to a layout or page.
The bootstrap seeds PermDock with the version stored in the database, not with the token's authz_ver claim. The claim is the version at sign-in, so seeding with it hides a role change made after the token was issued. AppBoot.tokenVersion keeps the claim for comparison only.
A hot page loads its data through one RPC that returns jsonb, such as customers_overview_payload, organization_dashboard_payload or tasks_board_payload. The loader is one private cache with the tags of the tables it reads. Do not nest private caches, do not stack cacheLife inside bsb().cached(), and do not split the payload into several calls again. Inside a private cache, build the request context with sessionRequestContext() from @workspace/core/request-context, never with createRequestContext() or getPermDock().
The quote board reads through list_quote_pipeline, the agenda through agenda_grid_payload, and the workflow progress section on record detail pages through record_lifecycle_source. A page payload does not read with the service role. When a section needs rows that RLS hides from the caller, add a security definer function that checks the caller's access itself, as record_lifecycle_source does with is_org_staff_member and the subject's read permission.
Two pages keep a few reads on purpose. Convert them when they become hot or when their reads change.
/invoices/all makes five user-scoped reads in one parallel wave: customers, templates, accepted quotes, notification levels and the filtered invoice page. A free-text search adds a second invoice read, because it widens the match to customer names. Folding this into SQL would duplicate the list filters, sort and search that the invoice list read already owns./settings streams two sections. The organization form reads the organization and its lifecycle options in one wave, and owners also read the ownership candidates. Data exports make one call. It is a form page that people open rarely, so three small reads cost less than a payload function to maintain.getTenantAccess(orgSlug, permission?) in apps/saas/lib/organizations/payload-access.ts returns the organization, the seeded PermDock instance and can, with no database call of its own. It returns null when the caller is signed out, is not a member, or lacks the permission. Read UI flags with getPermissionFlags(access, [...]).
When a payload loader returns null, end the page with pageAccess(orgSlug, permission) from apps/saas/lib/organizations/page-access.ts. It sends signed-out users to sign-in, answers forbidden for a missing permission, and calls notFound() otherwise. Do not call requireAccess after an empty payload; that resolves access a second time.
readTenantPayloadAccess, getCurrentOrganizationAccess, getShellAccessSnapshot, getOrganizationAccessSnapshot, cacheForSession, getRequestSession, invalidateSession and sessionSupabaseClient are gone. getAppShellAccess remains in a few shell files and may not gain callers.
A tenant Server Action is tenantAction(permission, schema, handler) from apps/saas/lib/organizations/tenant-action.ts, or authorizeTenantAction(organizationId, permission) when the action needs its own shape. Both decide with write intent, so a revoked role takes effect at once, and neither loads the caller's organization list. authorizeTenant and authenticatedServerAction are gone.
After the write, call updateTag for the tags that cover the change. Do not import refresh from next/cache, and do not call router.refresh() on the client after an action whose tags cover what the page shows: the action response already renders the page again, so a refresh fetches it twice.
Import permissions from @workspace/identity/permdock/permissions and pass refs such as permissions.customer.read to tenantAction, getTenantAccess, pageAccess, hasTenantPermission and the service authorize ports. A renamed or removed permission then fails the typecheck instead of denying at runtime. Use permissions.customer.read.key only where a record is keyed by the string, as in the getPermissionFlags result. hasTenantPermissionKey is for keys that only arrive as data, such as the permission of an AI contextual action or capability pack.
Last updated on