Kauan builds the @nestm/* family of NestJS 12 libraries (standard-schema, better-auth). The next library is a granular, AWS-IAM-like permission system: policy-based (Allow/Deny, default-deny, deny-overrides), generic across projects, with ORM drivers, and adoptable by /Users/kauan/Projects/concepta/station (NestJS 12 + Fastify + Drizzle/Postgres FORCE-RLS), which today has a deliberate but closed-enum permission system (ADR-0004) whose framework-free core lives in packages/platform/src/authorization.ts.
Recon (live-verified 2026-07-30) showed no maintained JS library implements IAM policy documents, and no NestJS module exists for Cedar/Cerbos at all. The genuine ecosystem gaps this library fills: policy-based NestJS authz with DB-backed runtime-editable policies and ORM row-filtering (unserved for TypeORM by anyone; barely for Drizzle).
- Engine: wrap Cedar —
@cedar-policy/cedar-wasm(nodejs build). AWS's own formally-verified policy language:permit/forbid, default deny, forbid-overrides-permit, conditions, hierarchies, schema validation. - Monorepo
nestm-dev/permissions, split packages (pnpm workspace + changesets fixed mode, pre-modealpha):@nestm/permissions-core— framework-free Cedar engine wrapper (zero NestJS deps)@nestm/permissions— the NestJS 12 module@nestm/permissions-typeorm,@nestm/permissions-drizzle— storage drivers + query-filter compilers
- v1 scope: core + Nest module + in-memory store + three-state query-plan API (ALWAYS_ALLOW / ALWAYS_DENY / CONDITIONAL) + both ORM drivers + station migration plan.
These bind the implementation; full evidence in the design docs (see References):
- ESM named imports work natively from
@cedar-policy/cedar-wasm/nodejs(nested"type":"commonjs"+ cjs-module-lexer). WASM loads synchronously; noawait init(). Must bedeps.neverBundlein tsdown (uses__dirname/require('fs')). statefulIsAuthorizedis 14.6× faster thanisAuthorized(0.136 vs 1.98 ms/op at 40 policies) → preparse caching is mandatory.isAuthorizedPartial(2.28 ms/op) cannot use the preparse cache (nopreparsedPolicySetIdfield) → a JS-side plan LRU is a requirement, not an optimization.- Entity graph size dominates latency (500 irrelevant entities → 20×) → pass principal + ancestors + resource only.
- No WASM unregister API; distinct preparse ids leak (+126 MB / 2000 ids) → policy-set ids must be stable per scope, never version-suffixed; re-preparsing the same id frees the old (+6 MB); eviction = overwrite with an empty policy set (verified to free).
- Partial-eval residuals: three-state comes directly from
decision: 'allow'|'deny'|null; unknowns appear as{"unknown":[…]}ext-calls insideconditions;>/>=arrive negation-normalised;likepatterns arrive tokenised (carry tokens into the AST — never re-serialise; that's the%/_injection trap). - An errored policy gets a
{"Value":false}residual — an erroredforbidsilently disappears. Sharpest security edge:plan()throwsErroredPolicyErrorby default whenerrored[]is non-empty. - Template links are honoured by partial eval → Cedar templates + links are the "role grant" primitive for both check and plan.
PermissionsEngine<V>class (PermissionsEngine.create()async factory):check/checkMany/plan/warm/invalidate/validatePolicies/stats/dispose. Generics thread a vocabularyVsoActionOf<V>is a string-literal union andResourceTypeFor<V, A>makes a mismatched action/resource pair a compile error.- Typed vocabulary builder
defineVocabulary({ namespace, entities, actions })— one source, two outputs: CedarSchemaJson(fed topreparseSchema) + TS unions. Cedar action ids are arbitrary strings, so station's existingnoun:verbpermission keys map 1:1 with no rename. Action groups supported in v1 (Cedar has no action wildcards;memberOfgroups are therun:*idiom — cheap now, expensive to retrofit). PolicyStoreSPI:load(scope) → PolicyBundle{version, policies, links},currentVersion,save/delete/linkTemplate/unlinkTemplate, optionalwatch. Policies persist asPolicyJson(jsonb-queryable;policyToTextfor admin UIs).MemoryPolicyStoreships in core.- Multi-tenant scoping:
PolicyScopeIdper tenant (e.g.org:<uuid>); WASM id =`${instanceId}:${scope}`stable forever; JS LRU (default 256 scopes) with evict-by-empty-overwrite; single-flight cold loads; version-stale check viacurrentVersion. Global + tenant policies are composed at load time by the store (bundle = global ∪ scope), so per-check cost stays one call; a global change invalidates'*'. EntityProviderSPI + typedentity()builder that rejects attributes absent from the vocabulary (catches the errored-forbid trap at construction time). Two-tier entity cache (per-request map + optional process LRU).- Query plan — the security-critical part:
- Neutral
PlanNodeAST (and/or/not/cmp/in/contains/like[tokens]/exists/isEmpty/isType/inHierarchy), typedPlanValuevariants (drivers must bind parameters, never interpolate). - Compile pipeline: partition residuals by effect → normalise (constant folding, negation pushdown, unknown detection) → translate per the pushdown table → assemble
OR(permits) AND NOT(OR(forbids))→ simplify. - Fail-closed contract: untranslatable subterm in a permit would widen the row set → unsafe → throw (
unsupportedResidual: 'error'is the default) or attach an explicit O(n)postFilter(opt-in, capped at 500 rows). Untranslatable subterm in a forbid widens the forbid → result is a subset → safe, recorded as aPlanApproximation{direction:'restrictive'}. Direction is one tested function of(effect, polarity)— a sign error here is a CVE. evaluatePlanNodereference interpreter exported from@nestm/permissions-core/testingso ORM drivers differential-test their SQL against the same oracle. (Family divergence: this package family allows a./testingsubpath.)
- Neutral
cedar-wasmis a harddependencypinned exactly4.12.0during alpha (residual shapes are experimental/unversioned); a non-blocking CI canary tracks@latest.CedarBindinginterface + memoised lazyloadCedar()keeps 4.1 MiB WASM out of the module graph until engine creation (drivers import types + AST walker only).- Error taxonomy (
PermissionsError+codeunion) carrying CedarDetailedError[](enables editor squiggles in an admin UI);onDecisionaudit hook (synchronous, try/catch-wrapped, context redacted by default). - Deferred to v1.1: reverse planning (
principal: null— "who can act on this row"; verified to work).
- Follows
better-authfor every family divergence:verbatimModuleSyntax:false, tsdown (never esbuild — CI grepsdesign:paramtypesin dist),.tsimport extensions, tabs/width-100 prettier, real.oxlintrc.json. - Module:
ConfigurableModuleBuilder1:1 withbetter-auth.module-definition.ts—forRoot/forRootAsync(createPermissionsOptionsfactory),setExtras({isGlobal:true, disableGlobalGuard:false})auto-registeringAPP_GUARD → PermissionsGuard; Symbol tokens (PERMISSIONS_MODULE_OPTIONS,AUTHORIZATION_ENGINE,POLICY_STORE,PRINCIPAL_RESOLVER). Two-phase init: engine factory (no I/O) +PolicySetManager(OnModuleInitload,reload(), poll interval, explicit'unloaded'|'ready'|'stale'state machine). Guard callsassertReady()→ 503, never fail-open.forFeature({ entityProviders })withDiscoveryService-based@EntityProviderclass discovery (per-feature entity-graph knowledge). @RequirePermission(action, resource?, options?)withResourceRefunion:param(withparseAsStandard Schema codec — guards run before pipes, so params are validated in-guard; station's Zod branded schemas drop straight in),literal,resolver, andunspecified(triggers the query plan; ALWAYS_DENY → 403, else plan stashed on the request). Typed actions via declaration-mergedPermissionsTypeRegistry(falls back tostringunaugmented; both branches type-tested).- 404-vs-403 (station ADR-0014): per-route
scopepre-check (non-membership → 404 with constant body; member-lacking-permission → 403),onDeny: 'forbidden'|'not-found',hooks.onDeniedreturning an Error overrides everything. E2E asserts byte-identical 404 bodies for unknown-org vs non-member-probe. - Boot-time route audit (generalised from station):
routeAudit.mode 'off'|'warn'|'error'(default off),additionalMetadataKeysas the station-migration escape hatch so both decorator families count during cutover. - Singleton
PermissionsService(never request-scoped);@CurrentPrincipal/@CurrentAuthorization/@QueryPlanparam decorators readingrequest[AUTHORIZATION_STATE];RequestAuthorization.planFor()for imperative planning. - Auth interop without coupling:
RequestPrincipalResolver({ property, map })readsrequest.session/user/identity— recipes for@nestm/better-auth, plain JWT, and station. Auth guard must be registered before the permissions guard (documented; station already relies onAPP_GUARDorder). - Transport scope v1: http + graphql; ws/rpc get correct exception types but
paramrefs throw a configuration error there (documented limitation). - Barrel re-exports types only from core (
QueryPlan,EntityUid,PolicyStore…); engine construction stays core-only.
- Workspace
packages/* + examples/*; root solution-styletsc -b; changesets fixed across all four (one physical copy of core — two copies = two WASM instances = sporadic "unknown policy set"; core also registersSymbol.for('@nestm/permissions-core/instance')onglobalThisand errors on duplicate load). Core is a plaindependency(workspace:^) of the other three — not a peer. - "Core stays Nest-free" enforced twice:
scripts/assert-core-framework-free.mjs(static grep) + CI job installing the packed core tarball into a bare dir with zero@nestjs/*and importing it. - CI:
check/ per-packagebuild(publint --strict + attw esm-only +design:paramtypesgrep on the Nest package) /testmatrix node 22,24 × express,fastify /test-driverswithservices: postgres:16-alpine/core-framework-free/ non-blockingcanary(@nestjs/*@nextandcedar-wasm@latest— load-bearing given experimental partial eval). - Release: standard-schema's hardened
scripts/publish.mjsadapted for multi-package (assertFixedVersions, tag reconciliation loop). npm Trusted Publishing OIDC; first alpha published manually per package then bound vianpm trust. - License/author: BSD-3-Clause © nestm (user-confirmed — matches better-auth; standard-schema retrofits later).
Core SPI deltas (accepted, must land in core before drivers start):
- D1
watch()owns freshness — the engine never callscurrentVersion()on the check path (a DB round-trip per check destroys the 0.136 ms number). - D2
load(scope)returns the effective bundle (global''∪ scope);PolicyChangeEventmay carryscope:'*'; composite versiong<n>:s<m>; global/scope policy-id collisions throw. - D3
defineVocabularymust NOT eagerly load WASM (validation moves toengine.create()) — station'spackages/webtransitively reachespackages/platform; 4.1 MiB bundle regression otherwise. - D4 Ship
CompositePolicyStorein core ('instance'→ memory store,'*'→ DB store). - D5 Export pure AST utilities from a
@nestm/permissions-core/plansubpath (walkPlanNode,likeTokensToPattern,planValueKindOf) — duplicated LIKE-escaping across drivers is how you get a%-injection bug in exactly one of them. - D6
TemplateLinkRecord.valuestolerates a missing?resource(runtime-checked against the template's declared slots). - D7
evaluatePlanNode'sHierarchyResolveris required whenever the tree containsinHierarchy, and throws when unresolvable. - Nest delta:
PrincipalResolver.resolve()can return{kind:'not-in-scope'}distinct fromnull— that's what maps station's "authenticated but not a member" onto the 404 path without a Cedar decision. - Entity-cache invalidation is tied to
PolicyChangeEventper scope (stale ancestors → stale plan → over-share); asserted in the store conformance suite.
Storage schema (both drivers, tablePrefix default permission_): permission_policies (static + templates in one table; cedar_json jsonb canonical + denormalised cedar_text for admin UIs; partial index (scope) WHERE enabled; GIN on cedar_json), permission_policy_links (slot values as columns — indexed "revoke everything for member X"; PK (scope, link_id)), permission_scope_versions (monotonic counter bumped in the same transaction as every write). Scope column is consumer-supplied via ScopeColumnOptions (name, toScope/fromScope, supportsGlobalScope:false for NOT-NULL tenant columns like station's) — that's what makes the tables droppable into an RLS regime with no NULL-tenant escape hatch. Optional permission_principal_groups behind a flag (default off). Invalidation v1: synchronous local event after commit + one-query monotonic-version snapshot poll (default 5 s, O(all scopes) returned); LISTEN/NOTIFY opt-in on a dedicated non-pooled connection.
Drivers: TypeORM uses EntitySchema factories, not decorated classes (no experimentalDecorators requirement, runtime-renameable tables, sidesteps design:paramtypes fragility); three-tier migrations story (register entities + migration:generate / buildPermissionsMigration() raw statements for hand-appending GRANT+RLS / PermissionsInitialMigration factory). Peer typeorm ^1.1.0 only (user-confirmed; 0.3.x support added later if demand shows). Drizzle exports a createPermissionsSchema factory — results must be spread into top-level named consts (verified: drizzle-kit's prepareFromExports only sees top-level PgTables; nesting silently yields an empty migration — README in bold); extraTableConfig is the RLS seam (pgPolicy/FKs/indexes pass through); permissionsPostgresPolicyStatements() returns the GRANT/RLS strings to hand-append. Both ship NestJS wiring as a ./nestjs subpath with optional @nestjs/* peers.
Query-filter compilers: planToSql(plan, mapping): SQL / planToBrackets(plan, mapping): Brackets are total functions — ALWAYS_ALLOW → TRUE, ALWAYS_DENY → FALSE; no API can return "nothing" (absent WHERE = every row). Mapping DSL: attributes (scalar/entity/array/jsonPath) + hierarchy (self/column/closure/recursive); TypeORM resolves property paths through EntityMetadata.findColumnWithPropertyPathStrict (typo = hard error; identifiers via qb.escape, never user input). Key correctness pins: Cedar in is reflexive (self mapping compiles to id = $p — wrong = silent over-block); LIKE patterns bound with ESCAPE parameter, compiler throws on case-insensitive collations (Cedar is case-sensitive; citext silently over-matches); SQL NULL three-valued logic is uniformly restrictive under OR(permits) AND NOT(OR(forbids)) — never wrap in COALESCE(…, true), never NOT IN a nullable subquery; bigint binds as string + ::bigint cast; TypeORM parameter names collision-proofed. Every unmapped/unsupported node throws a typed PlanCompilationError — "there is no configuration in which an uncompilable node becomes TRUE". Vendor @ucast/sql under references/ as prior art; do not depend on it.
Driver testing: three-way differential (brute-force check() vs evaluatePlanNode vs real Postgres 16 rows — set equality) with fast-check; LIKE fuzz vs Cedar's own decisions; fail-closed table; shared store conformance suite exported from core ./testing run against all three stores; RLS harness reproducing station's FORCE-RLS shape; injection corpus. CI services: postgres:16-alpine + repo compose.yaml (testcontainers rejected — heavy dep, diverges from station's blackbox pattern).
- Vocabulary:
stationVocabularyinpackages/platform/src/authorization/vocabulary.ts— all 25 permission keys +organization:createbecome Cedar action ids verbatim (the closed Zod enum in@station/contractsstays the source of truth; CI assertsActionOf<vocab> ≡ PermissionKey ∪ InstancePermissionKey). Entity hierarchyOrganization ← Project ← {Repository, Board, Run, Secret} ← …means a project-scoped grant transitively reaches Runs/Gates/Artifacts — an intended behaviour change on a security boundary: shadow mode will surface it as a divergence class that must be reviewed and accepted with a test, not dismissed. - Roles → templates, grants → links: one Cedar template per
rolesrow (@id("role:<id>"),permit(principal == ?principal, action in […bundle…], resource in ?resource));role_grants.idreused aslink_id(idempotent backfill, revoke = delete same key).role_grantsstays the source of truth;permission_policy_linksis a same-transaction projection — preserves the whole Role/RoleGrant OpenAPI contract, the generated web client, and the advisory-lock "last administrator" protection. Composite FKlinks(org,link_id) → role_grants(org,id) ON DELETE CASCADEmakes dangerous-direction drift structurally impossible (+ CI drift script). Requires addingUNIQUE(organization_id, id)onrole_grants. Operators: no table change — separate'instance'scope on aMemoryPolicyStore(D4) with one code-owned policy overGroup::"operators", membership via anEntityProviderreading the existingoperatorstable. - Guard swap: mapping table for all five scope kinds (
organization→param ref,project→param ref,any→unspecified(query plan),membership→unspecified+ batched checks,instance→literal). 404-vs-403 preserved exactly: the 404 is a principal-resolution outcome (not-in-scopewhen no Member row), not a Cedar denial;hooks.onDeniedreturns station's constant-detailNotFoundException.parseAs: organizationIdSchema(Zod 4 is Standard Schema) preserves guard-before-pipes validation with the same error pointers. Dual-decorated routes + both audits during cutover (additionalMetadataKeys: [ROUTE_PERMISSION]); Better AuthorganizationHooks(non-guard path, ADR-0016) →permissionsService.check(). - New tables under RLS:
0004_cedar-policy-store.sqlsketched in full (FORCE RLS + isolation policies + GRANTs, following0003_audit-trail.sql). The one honest RLS conflict: the invalidation poller queriespermission_scope_versionswith no org context — user-approved: no RLS on that table (it holds only a change counter, no org data; station_app already knows every org id it serves) — record the carve-out and its rationale explicitly in ADR-0019 + SECURITY.md during S1. PermissionReach→ query plan:listProjectsusesplanToSql(plan, projectResourceMapping)(org-wide residual →organization_id = $o, project grants →id IN-style ORs — identical semantics, RLS still the second wall);listOrganizationsgoes from N transactions to onewithIdentityContextquery + N in-memory ~0.14 ms checks.- Phasing with rollback: Phase 0 additive (tables + projection writes + backfill; reversible by dropping 3 tables) → Phase 1 shadow (
STATION_AUTHZ_ENGINE=shadow, legacy decides, Cedar compares, divergence counter; exit gate = zero divergences on full blackbox suite + staging soak) → Phase 2 cutover route-family-by-route-family → Phase 3 query plans → Phase 4 delete legacy (platform evaluation fns, guard, decorator, audit;roles/role_grants/contract stay). Rollback = flip the env flag any time before Phase 4. Must-keep-passing: all 9authorization.blackbox.test.tscases, tenancy + invitations blackbox, route-audit test,pnpm contracts:generateboots,git diff --exit-codeon the generated client.
Phases gate on each other; core check-path first, plan compiler before drivers.
- Repo scaffold (~2 d): workspace, shared configs, changesets fixed+pre, publish scripts + specs, CI/release workflows, four package skeletons building green.
- Core check path (~7 d): CedarBinding/loader/uid → vocabulary builder + type tests → PolicyStore SPI + MemoryPolicyStore + codec → policy-set cache (stable ids, evict-by-empty, single-flight) → EntityProvider +
entity()builder →check/checkMany+ errors +onDecision+stats. Shippable check-only milestone; unblocks the Nest package. - Nest module (~12 d, overlaps phase 2 after the contract stub lands): tokens/module-definition → providers +
PolicySetManager→ decorators → resolvers →@EntityProviderdiscovery +forFeature→PermissionsGuard(12-step flow; highest-risk file) →PermissionsService+ param decorators → route audit → barrel + exports test → unit + e2e (both adapters) → README + example app. - Query-plan milestone (~8 d): PlanNode AST + normaliser + pushdown table + direction analysis +
compile-residuals→evaluatePlanNode+./testingentry → property-based soundness suite (set-equality vs brute force; fail-closed mutations), LIKE fuzz, direction matrix → Cedar corpus conformance runner → benchmarks. Gates both drivers. - Drivers (~16.5 d): core deltas D1–D7 + shared store-conformance suite first → Drizzle first (simpler; shakes out AST↔SQL semantics): schema factory + drizzle-kit round-trip test → store + watcher → compiler → differential/fuzz/RLS/injection suites →
./nestjsmodule → then the TypeORM mirror (EntitySchema factory, migrations tiers,EntityMetadatacolumn resolution, parameter collision-proofing) → CItest-driverswiring. - Station migration (~13.5 d, phased S1–S10 with shadow mode and env-flag rollback): ADR-0019 + SECURITY.md → vocabulary + CI enum-equivalence test →
0004migration +role_grantscomposite unique → transactional projection writes + backfill/drift scripts → engine module (CompositePolicyStore, principal resolver, entity provider) → shadow service + divergence metric → guard swap (dual decorators, dual audits) → query plans for tenancy lists → Better Auth hooks → delete legacy + rewritepermission.guard.test.ts.
Settled design calls (from the three slices' open questions — recorded so implementation doesn't relitigate): all core deltas D1–D7 + the not-in-scope principal-resolver delta accepted; ./testing and ./plan subpaths allowed as a family divergence for this repo; cedar-wasm exact-pinned as a hard dependency; unsupportedResidual defaults to 'error'; action groups in v1, reverse planning deferred to v1.1; global+tenant bundle composition at load time; drivers ship ./nestjs subpaths with optional peers; CI service containers + compose over testcontainers; GET /organizations keeps the batched per-org loop; routeAudit default 'off'; Nest barrel re-exports core types only; transports v1 = http + graphql; minimal example app now, station-derived one after migration; TypeORM driver documents Node >=22.13 (typeorm@1.1.0's engine floor).
pnpm -r lint/format/typecheck/build; per-packagepublint --strict+ attw;design:paramtypesgrep; core-framework-free black-box import; e2e matrix express+fastify on node 22+24.- Security invariants as tests: no-authz-decorator route → 403; audit
mode:'error'refuses boot; unready policy store → 503 everywhere; byte-identical 404 bodies; plan property-tests assert set-equality against brute-forcecheck()and that non-pushdown-able policies throw rather than plan; LIKE fuzz against Cedar's own decisions; direction-analysis exhaustive matrix. - Drivers: three-way differential set-equality (check / interpreter / real Postgres), LIKE fuzz vs Cedar, no-input-produces-absent-WHERE property test, store conformance suite across all three stores, RLS harness, injection corpus.
- Station (per phase):
db:generateproduces an empty diff after0004; unit + blackbox suites green;contracts:generateboots (route audit);git diff --exit-codeon generated client; projection drift script = 0; shadow-mode divergence counter = 0 before cutover; explicit new test for the transitive-inreach change; poller-kill staleness ≤5 s and never widens access.
isAuthorizedPartialexperimental, residual shapes unversioned → exact-pin cedar-wasm, corpus + property suites as dependency-bump tripwire;check()never depends on partial eval (a regression degrades filtering, never enforcement).- Unsound plan compiler = wrong rows → fail-closed default, single tested direction function, set-equality property tests, shipped reference interpreter for driver differentials.
- Errored
forbiddisappearing →ErroredPolicyErrorby default, typed entity builder,validateOnLoad. - WASM memory monotonicity → stable ids, evict-by-empty,
maxScopessizing table in README. - NestJS major-version churn → stable
^12.0.0peers plus the non-blocking@nestjs/*@nextcanary. - Uncompilable plan node silently becoming
TRUEin a driver → total compile functions, typed throws for every unmapped case, differential suites;ALWAYS_DENYcompiles to literalFALSE. - Cedar's transitive
inwidens project grants beyond station's literal param matching → intended, but treated as a security-boundary change: shadow-mode divergence class, reviewed + tested explicitly. - Projection drift
role_grants↔permission_policy_links→ same-transaction writes + compositeON DELETE CASCADEFK + CI drift check. permission_scope_versionsRLS carve-out weakens station's "every org-scoped table is RLS-forced" invariant → user-approved; must be documented in ADR-0019 + SECURITY.md with the cache-coherence-channel rationale.
- Recon reports + designs:
/private/tmp/claude-501/-Users-kauan-Projects-nestm/e37497dc-4876-466e-8b62-3348b9de7490/scratchpad/{nestmLibs,station,iamEngines,tsEcosystem,design-core,design-nest,design-drivers}.md(+.risks.md/.questions.mdcompanions) - Durable copies: workflow journals under
/Users/kauan/.claude/projects/-Users-kauan-Projects-nestm/e37497dc-4876-466e-8b62-3348b9de7490/subagents/workflows/{wf_466bb71e-ef3,wf_b4b94f77-8fb}/journal.jsonl - First implementation task: copy the design docs into the new repo as
docs/design/*.mdso they survive this session.