The contract every module-* package implements and every app composes. An app
lists its modules once; routes, navigation, middleware, REST endpoints,
GraphQL schema and the seed registry all derive from that list. Adding the tenth
module is the same one-line edit as adding the second.
Runtime dependencies: none at the root entry point. react is type-only and
optional there; Nest modules are carried as opaque values, so this package never
imports Nest. The React bindings for the status channel are a separate
@kwtech/module-kit/react entry point, so importing composeFeatures from a
Nest app still pulls in no renderer.
Nest already does this: a module's controllers and resolver providers are
registered when the app imports it. module-kit only removes the per-module
boilerplate around that.
// apps/web-server/src/modules.ts
export const SERVER_MODULES = [
permissionsServerModule({
imports: [DbModule],
prismaProvider: { provide: PERMISSIONS_PRISMA, useExisting: PrismaService },
resolvePrincipal: (req) => {
const r = req as { user?: { id: string }; orgId?: string; workspaceId?: string };
return r.user ? { userId: r.user.id, organizationId: r.orgId, workspaceId: r.workspaceId } : undefined;
},
}),
// usersServerModule({ ... }),
];
// apps/web-server/src/app.module.ts
@Module({
imports: [
...serverModuleImports(SERVER_MODULES),
RouterModule.register(serverRoutePrefixes(SERVER_MODULES)),
],
})
export class AppModule {}That single list gets you, per module and with no further wiring:
- REST routes registered — and picked up by
@nestjs/swagger, so they land inopenapi.jsonand the frontend's generated REST types for free. - GraphQL resolvers joined into the code-first schema. No SDL, no stitching.
- Guards and services available for injection.
- Feature contributions for the seed task:
composeFeatures(SERVER_MODULES).
Next.js cannot do this natively. The App Router discovers routes from the
filesystem under app/; there is no plugin API, and a package cannot inject a
route. What a module can own is everything except the file: the page component,
its path, its title, its required feature and its nav entry — all declared as
data in WebModuleDescriptor.
// apps/web-app/src/modules.ts
export const WEB_MODULES = [permissionsWebModule /*, usersWebModule */];
export const ROUTES = composeRoutes(WEB_MODULES);Navigation and route protection then genuinely are automatic, and derive from the same declaration — which is what stops a menu linking somewhere the guard refuses:
const nav = composeNav(WEB_MODULES, grants?.features); // filtered, grouped, sorted
const route = matchRoute(ROUTES, request.nextUrl.pathname); // in proxy.ts
if (route?.feature && !held.includes(route.feature)) return forbid();Three ways to get the pages themselves rendered:
| How | Cost | |
|---|---|---|
| A. Catch-all (start here) | one app/(modules)/[[...slug]]/page.tsx resolving matchRoute(ROUTES, …) |
truly zero per-route work; gives up per-route metadata, generateStaticParams, segment config and nested layouts — everything under it is one segment |
| B. Thin re-export | one file per route: export { RolesPage as default } from '@kwtech/module-permissions/react' |
a real Next route with full static analysis; one 1-line file per route, written by hand |
| C. Generated stubs | a routes:sync script writes B's files from ROUTES |
B's fidelity with A's ergonomics; costs a generation step in the build and generated files in the tree |
Recommendation: A while routes are few and uniform, C once they multiply or need per-route metadata. B only for the handful of routes that want hand-written segment config. Note that A, B and C differ only in how the file gets there — the module, the descriptor and every helper above are identical in all three, so switching later is a mechanical change and never a module rewrite.
Export two descriptors and nothing else changes:
export const usersServerModule = (opts) => ({ key: 'users', nestModule: UsersModule.forRoot(opts), features: [...] });
export const usersWebModule: WebModuleDescriptor = {
key: 'users',
features: [...],
// Where this module's group sits in the drawer. See below.
navGroups: [{ group: 'People', order: 30 }],
routes: [
{ path: '/users', title: 'Users', component: UsersPage, feature: 'members:manage',
nav: { group: 'People', order: 10, icon: 'users' } },
],
};The app adds it to WEB_MODULES and stops. Routes, navigation, group placement,
middleware protection and the seed registry all follow.
Duplicate route paths and duplicate feature keys throw at composition time, not at first request: two modules quietly owning one path is exactly the failure this package exists to catch.
A route may set body: 'fill' to get the space between the header and the
drawer with only a thin gutter, and no page scrolling: the page's h-full is
exactly what is left, and its own panes scroll. The Apps page uses it. Leave it
unset ('padded') for an ordinary page, which scrolls in the shell.
composeNav can only sort group NAMES, alphabetically, so group placement used
to be a hand-written array in the app — which meant adopting a module was a
one-line edit plus a second edit nobody would think of, and forgetting it
silently dropped that module's group to the bottom of the drawer.
navGroups moves that onto the descriptor:
const groups = composeNavGroups([...WEB_MODULES, { key: 'app', navGroups: [{ group: 'Overview', order: 10 }] }]);
entries.sort((a, b) => navGroupRank(groups, a.group) - navGroupRank(groups, b.group) || …);Two rules, and both are deliberate:
- The lowest order wins for a shared group. Duplicate routes and feature
keys throw, because two modules owning one of those is a bug. A group is the
opposite —
Administrationis meant to collect entries from several modules — so two modules naming a position for it is normal. Taking the minimum makes the result independent of the order modules are listed in, which is the property that matters:[a, b]and[b, a]must produce the same drawer. - An unplaced group sorts last, not first. A new module whose group nobody positioned appears at the bottom, which is visible and harmless. Ranking it first would put an unknown module's pages above the dashboard on the day it was installed.
Space the numbers — 10, 50, 90 — so a group can be inserted between two others without renumbering anything.
A module says what it contributes and roughly where. It never says which
chrome draws it: module-auth places Account at 90, and this app lifts that
group out of the drawer entirely and renders it in the header's account menu.
The drawer answers "where can I go". A header tool answers "what can I use here without leaving": a control the app draws in its header, left of the account menu. Chat's inbox is the first; the next tool is a module change, not an app edit.
headerTools: [{ key: 'chat', label: 'Chat', order: 10, feature: CHAT_FEATURE.read, component: ChatHeaderToolSlot }],componenttakes no props, likenav.badge: the app's header is a server component and only a client component reference crosses into the client. It draws its own trigger and whatever that opens.featureis checked at APP level. The header belongs to no organization or workspace. Omit it only for a tool every signed-in person may use.ordersorts left to right; space them (10, 20) like group orders.composeHeaderTools(modules, heldFeatures)filters and sorts, and throws on a duplicatekey. Pass[]when the grants are unknown:undefinedmeans "do not filter".
A module should reach a place from the header OR the drawer, not both — two
doors to one place is how somebody learns to wonder which one is real. Chat
takes placement: 'header' | 'drawer' for exactly that.
A sub-app (the queue, and every app after it) is not in the drawer. It runs
on the workspace's Apps page (module-app-hub), in tabs or side by side in a
grid, and a module declares it:
apps: [{ key: 'queue', label: 'Queue', icon: 'megaphone', feature: QUEUE_FEATURE.read, order: 10, component: QueueApp }],componentgets{ organizationId, workspaceId }(AppProps) and must work in a box of any size: lay out with container queries (@container,@xl:), notsm:/lg:, because a grid cell is narrow on a wide screen.- It fills its box and pads itself. The frame adds no padding, and a
full-page reading column (
mx-auto max-w-*) wastes a wide tab: drop it when embedded (QueuePage'sfill). Root ath-full w-full; the frame scrolls. - It must not navigate. A link away closes every app on the page, so move
between your own screens with state (
QueueAppopens settings in place). featureis REQUIRED and workspace level: the page offers only apps the viewer holds. The app's API still authorises every request.keyis saved in people's layouts. Never rename one.composeApps(modules)collects them inorderand throws on a duplicatekey. The web app hands the result toappHubWebModule({ apps }), so the Apps page never imports a sub-app.
A module declares three kinds of thing that a different module resolves. All three shapes live here, for one reason: every module declares them, only one module answers them, and the resolver may not import its contributors.
| Declaration | Composed by | Answers |
|---|---|---|
FeatureContribution |
composeFeatures |
may this be done |
LimitContribution |
composeLimits |
how many of these may exist |
DefaultContribution |
composeDefaults |
what happens when nobody said |
Each is a looser shape than the spec the resolver narrows it to — kind,
moment, countedOver and bindings.surface are plain strings — because this
package must not own the resolving module's vocabulary. The narrow happens once,
in the app, where a bad value is a boot failure.
⚠ An undeclared one does not exist. The resolver reads the composed registry, so a row in its table for a key nobody declared is ignored, and the screen that sets it has no control to offer. Setting the value in the database changes nothing, with no error to explain why.
Work a module does on a schedule rather than on a request. Same split:
every module may declare one, and only module-jobs runs them.
// packages/module-task/src/processes.ts — pure data, in the module's root
export const TASK_PROCESS_REGISTRY: readonly ProcessDeclaration[] = [{
key: 'task.due_today', module: 'task',
label: 'Task due reminders', description: '…',
serves: TASK_FEATURE.read, // or null, WRITTEN: serves no workspace
defaultSchedule: { kind: 'daily', times: ['08:00'], weekdays: [0, 1, 2, 3, 4, 5, 6] },
scheduleLimits: { kinds: ['daily', 'interval'], minEveryMinutes: 15 },
maxRunSeconds: 120, maxItemsPerRun: 500, tooLateAfterMinutes: 600,
}];
// server-module.ts — joined to the class that runs it
processes: [{ ...declaration, handler: TaskDueTodayProcess }],ProcessDeclaration |
the data: key, what it serves, default schedule, and its limits. Every limit is required |
ProcessContribution |
a declaration plus handler, the module's @Injectable() class implementing ProcessHandler |
composeProcesses |
every module's, for the runner and the seed task. Throws on a duplicate key or an incomplete declaration |
ProcessRunContext |
what a run is handed: now, the schedule in force, maxItems, tooLateAfterMinutes, signal, and workspaces(cursor, limit) |
processOccurrence |
whether a process's time has come in ONE workspace, and for which day: none, due, too_late |
effectiveProcessSchedule |
the admin's schedule if it still fits the declared limits, otherwise the default |
Two schedule kinds: interval (every N minutes) and daily (at set times, on
chosen weekdays, in each workspace's own time). A daily process is not
run once a day: no instant is 8:00 everywhere, so it is swept every
minEveryMinutes and takes the workspaces whose time has come.
The developer of a process answers for its design
(.claude/rules/modules.md, "Background processes"): it sweeps, is idempotent,
works in batches inside maxItems, stops when signal aborts, and assumes
nothing about when it runs. It reaches only the workspaces context.workspaces
hands it, which are those whose organization's plan includes the feature it
serves.
The defaults screen groups by moment — somebody arrives asking "what happens
when a group is created", not "which of these point at a role" — so a default
needs a section to appear in, and DefaultMomentContribution is that section:
defaults: CHAT_DEFAULT_REGISTRY, // the decisions
defaultMoments: CHAT_DEFAULT_MOMENT_REGISTRY, // the headings they appear undercomposeDefaultMoments resolves these the way composeNavGroups resolves nav
groups, and for the same reason — a moment is a shared namespace, and two
modules may have a default at one:
- The lowest order wins, so the result does not depend on module order.
- An undeclared moment sorts last, and its section renders unnamed rather than not at all. That is the important half: a module that declared a default and forgot the heading gets a visible section titled with the raw moment key, not a setting that silently never appears on the only screen it can be set from.
A route path may carry :params, and whatever renders the route hands them to
the component as ModuleRouteProps.params:
{ path: '/admin/features/:featureId/edit', title: 'Edit feature', component: FeatureEditPage }export function FeatureEditPage({ params }: ModuleRouteProps) {
const id = params?.featureId; // already URL-decoded
}Resolve with matchRouteWithParams(routes, pathname), which returns
{ route, params }. matchRoute is the same thing without the params, for
callers that only need to know which route a path belongs to.
Literal segments outrank dynamic ones, whatever order the routes are
declared in. /admin/features/new/manual beats /admin/features/:featureId/edit
because a literal segment scores higher than a :param at every position — the
alternative is a route that works until somebody reorders WEB_MODULES.
A :param matches exactly ONE non-empty segment, so a binding cannot swallow a
deeper path: /admin/features/:featureId does not match
/admin/features/new/manual. When nothing matches exactly, the pre-existing
prefix behaviour still applies — a route may own everything beneath it, longest
prefix winning.
Encode a value that can contain a colon, which every feature key does:
`/admin/features/${encodeURIComponent(key)}/edit` // -> billing%3Amanage@kwtech/module-permissions ships FeatureGate and usePermissions, and they
are the right tools — for that module. Every other module is forbidden from
importing it, so a module wanting to hide its own control had no way to ask the
question: it could gate a ROUTE through the descriptor, but not a button inside
its own page.
The contract lives here; the answer is supplied by whoever resolves permissions.
import { useHoldsFeature, useHoldsAllFeatures, useHeldFeatures } from '@kwtech/module-kit/react';
const canEdit = useHoldsFeature('account:profile_write');
return canEdit ? <SaveButton /> : <p>Your profile is managed for you.</p>;PermissionsProvider mounts FeatureAccessProvider internally, so an app that
already renders it gets this for free and there is exactly one source of the
list. An app with no permission model at all sees an empty list rather than a
crash — a missing provider HIDES controls, it never reveals them.
Not a security boundary. Hiding a control hides an affordance, not an endpoint; the request is authorised again at the API.
@RequireScope belongs to @kwtech/module-permissions and @Public to
@kwtech/module-auth, and neither may be imported by another module. The
metadata KEYS live here instead, so a module declares both with its own
SetMetadata:
import { declareScope, PUBLIC_SURFACE_METADATA, REQUIRED_SCOPE_METADATA } from '@kwtech/module-kit';
import { SetMetadata } from '@nestjs/common';
@SetMetadata(REQUIRED_SCOPE_METADATA, declareScope('workspace'))
@Query(() => QueueBoard)
queueBoard(@Args('organizationId') organizationId: string, @Args('workspaceId') workspaceId: string) {}
@SetMetadata(PUBLIC_SURFACE_METADATA, 'a TV in a waiting room has no session')
@Subscription(() => QueueDisplayEvent)
queueDisplay() {}FeatureGuard and JwtAuthGuard read these exact constants, and the
enforcers' own REQUIRED_SCOPE and IS_PUBLIC are the same strings.
⚠ A module below app level MUST declare its scope on every GraphQL resolver. A resolver has no path, so with no declaration the guard resolves app level, where no organization- or workspace-level key participates — and every one of the module's keys grants nothing, to everybody, with no error. Give the module a test that fails on an undeclared resolver.
⚠ The public reason must be a non-empty string. The authentication guard treats any truthy value as public, which is also why the two keys must never share a value.
Mark it with CREDENTIAL_SURFACE_METADATA and a reason, beside the public
marker. The host points its tightest rate limit at every handler carrying it —
in web-server, CredentialThrottlerGuard's credential bucket, the one
sign-in uses. A module may not depend on the throttler, so it declares the fact
and the app applies the policy.
A screen nobody signs in to — a queue board on a TV — can still hold a live
subscription. The host app lets a module admit such a socket on the module's
own credential (in web-server, the admitAnonymous argument of
graphqlOptions). What the module's hook returns is kept on the socket's
request under ANONYMOUS_ADMISSION_KEY, and the module's public resolver
reads it back:
import { anonymousAdmission, PUBLIC_SURFACE_METADATA } from '@kwtech/module-kit';
@SetMetadata(PUBLIC_SURFACE_METADATA, 'a TV in a waiting room has no session')
@Subscription(() => QueueDisplayEvent)
queueDisplay(@Context('req') req: unknown) {
const admission = anonymousAdmission<{ sessionId: string }>(req);
// ...
}⚠ An admission is not an identity. The socket carries no principal, so every operation on it that is not marked public is refused "Not signed in". That absence is the entire safety argument; never treat an admission as a principal.
⚠ Reading an admission is not checking it. It was valid at the handshake. Re-check it on every publish, because the socket's twelve-hour maximum lifetime is only a backstop.
The app opens ONE connection (createRealtimeConnection from
@kwtech/module-kit/realtime) and puts it in RealtimeProvider. Every module
subscribes through useRealtime(); none opens its own.
createRealtimeConnection({ wsUrl, retryForever: true });retryForeverkeeps retrying withreconnectDelay(1 s doubling to 15 s, with jitter). ⚠ Without itgraphql-wsstops after five attempts — about thirty seconds of outage — and nothing in the tab is live again until a reload, with nothing on screen to say so. The web app sets it since 2026-09-25. A refusal (4403: signed out) is never retried.- A ping with no pong within
REALTIME_PONG_TIMEOUT_MSterminates the socket so the retry takes over. Without it a half-open socket (a laptop that slept) reportslivewhile delivering nothing. reconnectNow()skips the rest of the backoff. The connection calls it itself on the browser'sonlineevent and when a tab becomes visible, and a screen can offer it as "Retry now".useRealtimeStatus()from@kwtech/module-kit/reactreturns{ status, since }:idle | connecting | live | reconnecting | refused, and when it began. There is no "down": how longreconnectingmay last before a screen calls it a problem is that screen's decision (the notification bell waits 3 s, then 30 s).nullmeans there is no socket to ask about.nextRealtimeStatusis the pure transition rule, tested inrealtime.test.ts.
The three members are optional on RealtimeConnection, so a hand-written
connection (a test's fake) still fits; a consumer that finds none treats the
connection as live.
"Today" in a workspace is the workspace's day, not the server's (UTC) and
not the viewer's browser's. Each workspace keeps an IANA zone
(perm_workspace.timeZone, default DEFAULT_TIME_ZONE = Asia/Manila), so
everyone in it sees the same today (PLAN §13, 2026-09-29).
- Root (
src/time-zone.ts):isValidTimeZone,zonedDayKey,zonedMonthKey,zonedHour,zonedStartOfDay,nextDayKey. Built onIntl, with no date library. Every "which day is this instant" goes through these, so a report and a board never disagree. - React: the app shell wraps pages in
WorkspaceTimeZoneProviderwith the selected workspace's zone, and a module readsuseWorkspaceTimeZone(). ⚠ Outside a provider it returns the browser's own zone, which is honest. Guessing a workspace's zone would not be. - Server: a module cannot read
perm_workspace, so it declares a port (the POS'sPOS_WORKSPACE_TIME_ZONE) and the app answers it. When it is unbound, fall back toDEFAULT_TIME_ZONE, never to UTC.
The global status bar's vocabulary lives here for the same reason
FeatureContribution does: every module has something to say, and exactly one
component renders it.
Putting the channel in a feature module — module-auth was the obvious
candidate, since it owns the session — would force every other module to import
that one just to publish a sentence, which is the cycle the module rules exist
to prevent.
The root entry point carries the vocabulary and the store, with no React:
import { selectPrimaryStatus, type StatusMessage } from '@kwtech/module-kit';@kwtech/module-kit/react carries the bindings:
import { PublishStatus, StatusProvider, useStatusChannel } from '@kwtech/module-kit/react';A page publishes declaratively, and it retracts on unmount:
<PublishStatus level="warning" text="Read-only: your plan has expired" source="billing" />or imperatively, from an event handler:
const status = useStatusChannel();
status.publish({ id: 'import', level: 'error', text: 'Import failed.' });<PublishStatus> renders nothing, so a server component can render it —
which is the only way a server-rendered page contributes to the bar.
Where the bar is, what it looks like, and when transient messages are cleared.
A module says what is true; it does not decide where that appears. An app that
never mounts a StatusProvider still runs every module unchanged — publishing
falls through to an inert store rather than throwing, because adopting a module
must not be conditional on adopting the status bar.
sticky: true survives navigation and cannot be dismissed. It is for a
condition — a server that is unreachable is not a property of the route you
happen to be on, and letting someone close it would hide a fact that is still
true. Everything else is news: cleared when the route changes, dismissible while
it is up.