@astram/guard — the enforcement gate + NestJS adapters
The enforcement gate + thin NestJS integration for the Astram framework — the
shippable glue a consumer app installs (it runs on / integrates with NestJS). Per
ADR-0023
this is framework code, owned + locked, not consumer-editable: it lives in the
consumer's node_modules, and upgrades flow through npm update. It depends on
@astram/core (the framework-agnostic kernel) and PEER-depends on the
host's NestJS runtime.
What's in here
| Piece | File | Purpose |
|---|---|---|
ShieldModule | src/module/shield.module.ts | The one registration seam — forRoot() / forRootAsync(). Consumers import it once; the framework owns composition. |
| Global enforcement guard | src/enforcement/global-enforcement.guard.ts | The deny-by-default APP_GUARD (ADR-0002) every route traverses. |
| Classification decorators | src/enforcement/classification.decorator.ts | @Public() / @Classified() — the consumer authors these on controllers. |
| Core seam | src/enforcement/core-seam.ts | Type-only import of @astram/core's DecisionAuthority (ADR-0003) + the fail-closed stub. |
| Boot-pool assertion | src/boot/boot-sequence.ts | ADR-0018 connection-pool budget check at boot (fail-closed). |
| App config | src/config/app-config.ts | Port / environment / ADR-0018 pool budget (env loader, pure). |
| Request principal | src/request-auth/request-principal-als.ts | Read-only currentRequestPrincipal(): the current request's { tenantId, userId } (below). |
The registration seam (ADR-0023 §4)
A consumer app wires Astram with a single import in its root module — it never edits the framework:
import { Module } from '@nestjs/common';
import { ShieldModule } from '@astram/guard';
@Module({
imports: [
ShieldModule.forRoot(), // fail-closed stub authority; env-loaded config
// ShieldModule.forRoot({ decisionAuthority, config }) to supply the real Core authority
],
controllers: [/* the consumer's feature controllers */],
})
export class AppModule {}
forRoot() binds:
GlobalEnforcementGuardasAPP_GUARD→ the global, structurally-unavoidable, deny-by-default enforcement point (ADR-0002).@Public()is the only opt-out; an unclassified route is denied (403).- The resolved
AppConfig(underAPP_CONFIG) + theBootSequencehook, which runs the ADR-0018 pool-budget assertion at boot (aborts on violation). - The Core decision authority (under
SHIELD_DECISION_AUTHORITY) — the real@astram/coreauthority when supplied viaforRoot({ decisionAuthority }), else the fail-closed stub (deny-by-default), so a mis-wiring cannot open a door.
forRootAsync({ useFactory, inject, imports }) is the DI-computed variant (a stub
in this layer): the factory resolves the same options at startup; the guard/boot
wiring is identical.
Request principal (currentRequestPrincipal)
import { currentRequestPrincipal } from '@astram/guard';
const p = currentRequestPrincipal(); // Readonly<{ tenantId: string; userId: string }> | undefined
Framework code that runs inside a request but has no req in hand (for example @astram/agent's
createAuditedFetch) reads the caller's tenant and principal id here. Spec:
docs/features/capability-outbound-audit/SPEC.md v6 §5 G.
- Read-only. A framework middleware (
EnforcementModule, every route) opens a module-privateAsyncLocalStoragewith an empty holder per request. The global enforcement guard is its only writer: once, on the allow path, after authentication, from the principal it resolved from the session or API key (never from the body, headers or query). The value is frozen. The storage, the middleware and the writer are not exported, so app code cannot enter the scope or overwrite it. undefinedoutside a request (a scheduled job), on a@Public()route, for a request the guard refused, and for a request that did not pass the middleware.userIdis the principal id: a user or a service account. The value does not say which kind (GAPS #46); look the id up if you need it.- Work that resumes outside the request's async context (for example some stream consumers) reads
undefined, never another request's value (an interleaved four-request test holds this). Read it in the request's own call chain; a callback run by shared code (a listener on an app-wide emitter) runs in the context of whoever triggered it, so do not read it there. - Start background work at boot (
onApplicationBootstrap), never lazily from inside a request. Async resources created inside a request (a timer, a promise chain, a queue drain a handler starts on first use) keep that request's context, including its principal or its lack of one. - Routes excluded from
app.setGlobalPrefix(prefix, { exclude })pass the middleware too (an e2e test holds this). @astram/guard/internal(framework use only) also exports a read-onlyinsideRequest():trueinside a request that passed the middleware, with or without a principal.@astram/agent's audited fetch uses it to refuse calls from@Public()and refused requests.- Express only.
currentRequestPrincipalis proven on@nestjs/platform-express; Fastify is unsupported until it has its own e2e test. - Nest routes only. The holder is opened by a Nest middleware, so a handler mounted directly on
the HTTP adapter (outside Nest routing) runs "outside a request":
insideRequest()isfalseand@astram/agent'screateAuditedFetchtakes the scheduled-job branch there (the app must then supply a server-held tenant). Keep connector code in Nest handlers or boot-started jobs. - In memory only: no database or Redis read is added to the request path (Shield #12).
File-share port token (SHIELD_FILE_SHARE, GAPS #57)
src/module/shield-tokens.ts declares the port so guard never imports identity (ADR-0003);
@astram/identity binds it per request. Spec docs/features/file-share-port/SPEC.md v1, ADR-0040
addendum (2026-11-03).
- Exports:
SHIELD_FILE_SHARE(aSymbol, unbound without identity),FileSharePort(shareWithRole,unshare,revokeForParent,listForParent,reconcileFile; no actor, tenant, owner or company argument),FileShareParent({ typeKey, id }),FileShareResult({ created, effective, missing }),FileShareError,FILE_SHARE_ERROR_CODES,FileShareErrorCode. FileShareError(code)takes only the code, so its message never carries an id, file name or table:file_share_invalid400Invalid file share request.,file_share_not_found404Not found.,file_share_denied403Not permitted.,file_share_unavailable503File sharing is unavailable.; an unknown code becomesfile_share_unavailable.- Migration
1710000000126-AddRecordShareSource.cjsadds the source columns (rs_source_type_key,rs_source_record_uuid,rs_source_operation,''for owner shares),ck_rs_source_complete,uq_rs_grant_source(replacesuq_rs_grant) andidx_rs_source;downrefuses while a port row exists. RecordShareWriter: ownerunsharedeletes owner-made rows only and refuses a tuple that matches only port rows withShareNotPermittedError(SHARE_MANAGED_BY_LINKED_RECORD)(This share is managed by a linked record.); list rows (RecordShareRow) carry the source fields;listSharesForAuthorizedRecordlists a record's shares without the owner bound, for a caller that has already proven authority withdecide()(identity's stored-file admin listing).
Peer dependencies (ADR-0023 §2)
@astram/guard peer-depends on the host runtime so the consumer owns exactly
one copy (a single reflect-metadata singleton / DI injector):
@nestjs/common ^11 · @nestjs/core ^11 · reflect-metadata ^0.2 · rxjs ^7
These are also devDependencies here so the package compiles + tests locally.
@astram/core is a real (workspace) dependency.
Commands
pnpm --filter @astram/guard build # tsc -p tsconfig.build.json (emits dist + .d.ts)
pnpm --filter @astram/guard lint # eslint
pnpm --filter @astram/guard test # vitest run (guard unit + boot-pool + forRoot e2e)
Notes
- CommonJS locally (
"type": "commonjs") for the NestJS decorator +reflect-metadataDI model. The repo root and@astram/corestay ESM/NodeNext; the override is scoped to this package (seetsconfig.json). - The core seam imports the REAL
@astram/corebarrel (type-only) — Core'sexportsmap exposes only".", so subpath imports fail Node resolution. This package's tsconfig clears the basepathsso@astram/coreresolves through the node_modules workspace symlink to Core's builtdist.