Skip to content

Latest commit

 

History

History
524 lines (411 loc) · 23.9 KB

README.md

File metadata and controls

524 lines (411 loc) · 23.9 KB

@kwtech/module-kit

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.

Server — automatic, natively

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 in openapi.json and 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).

Web — the honest version

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.

Adding a module

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.

Navigation groups

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 — Administration is 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.

Header tools

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 }],
  • component takes no props, like nav.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.
  • feature is checked at APP level. The header belongs to no organization or workspace. Omit it only for a tool every signed-in person may use.
  • order sorts left to right; space them (10, 20) like group orders.
  • composeHeaderTools(modules, heldFeatures) filters and sorts, and throws on a duplicate key. Pass [] when the grants are unknown: undefined means "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.

Sub-apps: the workspace's Apps page

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 }],
  • component gets { organizationId, workspaceId } (AppProps) and must work in a box of any size: lay out with container queries (@container, @xl:), not sm: / 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's fill). Root at h-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 (QueueApp opens settings in place).
  • feature is REQUIRED and workspace level: the page offers only apps the viewer holds. The app's API still authorises every request.
  • key is saved in people's layouts. Never rename one.
  • composeApps(modules) collects them in order and throws on a duplicate key. The web app hands the result to appHubWebModule({ apps }), so the Apps page never imports a sub-app.

Three declarations: features, limits, defaults

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.

A fourth: background processes

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.

Defaults also need their MOMENT declared

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 under

composeDefaultMoments 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.

Dynamic route segments

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

Feature access, for modules that are not the enforcer

@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.

Declaring scope and public surfaces, for modules that are not the enforcer

@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.

A surface where somebody guesses a secret

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 public surface a socket reaches with no session

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.

Realtime: one socket per tab, and whether it is live

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 });
  • retryForever keeps retrying with reconnectDelay (1 s doubling to 15 s, with jitter). ⚠ Without it graphql-ws stops 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_MS terminates the socket so the retry takes over. Without it a half-open socket (a laptop that slept) reports live while delivering nothing.
  • reconnectNow() skips the rest of the backoff. The connection calls it itself on the browser's online event and when a tab becomes visible, and a screen can offer it as "Retry now".
  • useRealtimeStatus() from @kwtech/module-kit/react returns { status, since }: idle | connecting | live | reconnecting | refused, and when it began. There is no "down": how long reconnecting may last before a screen calls it a problem is that screen's decision (the notification bell waits 3 s, then 30 s). null means there is no socket to ask about. nextRealtimeStatus is the pure transition rule, tested in realtime.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.

A workspace's calendar

"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 on Intl, 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 WorkspaceTimeZoneProvider with the selected workspace's zone, and a module reads useWorkspaceTimeZone(). ⚠ 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's POS_WORKSPACE_TIME_ZONE) and the app answers it. When it is unbound, fall back to DEFAULT_TIME_ZONE, never to UTC.

The status channel

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.

What the app owns

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 versus transient

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.