14 KiB
Permissions completion — default-preset factory, event singleton, deep-path utils, pattern-match utils
Package: @suaveplan/permissions
Status: PROPOSED
Type: feature
Why
The 2026-05-02 audit of packages/auth/permissions/src against ../suaveplan/packages/universal/permissions/src identified four confirmed feature gaps. The genesis permissions package has the heavy machinery (managers, cache, middleware, policy engine, role/ability builders, condition / field / subject matchers) but is missing four small ergonomic helpers that upstream callers rely on: a createPermissions() factory that wires the defaults together, a singleton permissionEvents event bus, deepGet / deepHas for condition-matching against nested input shapes, and matchPattern / patternToRegex for wildcard-style action matching.
Without these helpers, every consumer of @suaveplan/permissions has to hand-roll the same boilerplate (instantiate MemoryAdapter, AbilityBuilder, DefaultConditionMatcher, DefaultFieldMatcher, DefaultSubjectDetector; wire the matchers into builder.build; then track and bind the result). Worse, every consumer that wants to listen for permission:granted / permission:denied events has to create its own EventEmitter and pass it through the manager constructor, which leads to two un-shared event buses on every realistic deployment. Both gaps are small but high-friction; closing them in one change avoids four sequential micro-changes and lets the upstream test fixtures port verbatim.
The four gaps:
createPermissions()factory +PermissionsInstanceinterface (97 L upstreampresets/default-preset/default-preset.ts) — returns a single bag of{ ability, builder, adapter, conditionMatcher, fieldMatcher, subjectDetector }so consumers can destructure rather than instantiating six classes by hand. Every default is overridable through a singlePresetOptionsarg.permissionEventssingleton event bus (57 L upstreampermission-events.ts) — a process-wideEventBusLikefrom@suaveplan/eventskeyed by"permission:checked" | "permission:granted" | "permission:denied" | "role:assigned" | "role:revoked". Genesis already exports thePermissionEventMaptype but never wires a singleton — managers either accept an optional event bus (which most callers leave undefined) or emit nothing.deepGet(obj, path)anddeepHas(obj, path)utilities (47 L upstreamutils/deep-get.ts) — typed dot-path getters used byDefaultConditionMatcherto readuser.profile.roleor similar nested context fields. The genesis condition matcher currently only accepts top-level keys, blocking realistic conditions.matchPattern(input, pattern)andpatternToRegex(pattern)utilities (54 L upstreamutils/pattern-matching.ts) — glob-style matching (*,manage, prefixfoo*, suffix*foo) compiled to RegExp. Used byDefaultFieldMatcherand byAbility.can(action, subject)for wildcard action / subject matching, which the genesisWILDCARD_ACTIONconstant already presupposes but does not implement.
What changes
- ADDED:
presets/default-preset/default-preset.tsexportingcreatePermissions<TAction, TSubject, TConditions>(options?: PresetOptions<...>): PermissionsInstance<TAction, TSubject, TConditions>. The factory wiresMemoryAdapter,AbilityBuilder,DefaultConditionMatcher,DefaultFieldMatcher,DefaultSubjectDetector, builds theAbility, and returns all six instances in a frozen result object. Every default is overridable viaPresetOptions. - ADDED:
presets/default-preset/default-preset.types.tsexportingPresetOptions<TAction, TSubject, TConditions>(overrides for each of the six injected dependencies) andPermissionsInstance<TAction, TSubject, TConditions>(the result shape). - ADDED:
events/permission-events.tspermissionEventsexported singleton — a module-scopeEventBusLikeinstance keyed by the existingPermissionEventMap. Managers and the ability builder SHALL emit through it when no caller-supplied bus is passed. The existingPermissionEventMaptype stays exported as before. - ADDED:
utils/deep-get/deep-get.tsexportingdeepGet(obj: unknown, path: string): unknownanddeepHas(obj: unknown, path: string): boolean. Supports dot-notation (user.profile.role) and array-index segments (tags.0resolvesobj.tags[0]). ReturnsundefinedfromdeepGet(andfalsefromdeepHas) when any segment is missing rather than throwing. - ADDED:
utils/pattern-match/pattern-match.tsexportingmatchPattern(input: string, pattern: string): booleanandpatternToRegex(pattern: string): RegExp. Patterns:*and"manage"match everything,foo*matches by prefix,*foomatches by suffix, otherwise exact-equality. The compiledRegExpis cached by pattern string in a module-scopeMap<string, RegExp>so repeated lookups (the hot path insideAbility.can) do not re-compile. - MODIFIED:
conditions/condition-matcher/condition-matcher.ts—DefaultConditionMatcher.match(conditions, context)SHALL usedeepGet(context, key)to resolve dot-notation keys (currently it only resolves top-level keys viacontext[key]). - MODIFIED:
conditions/field-matcher/field-matcher.ts—DefaultFieldMatcher.match(pattern, field)SHALL delegate tomatchPattern(field, pattern)(currently it only supports exact equality and a hard-coded*wildcard). - MODIFIED:
core/ability/ability.ts—Ability.can(action, subject?, field?)action / subject lookup SHALL delegate tomatchPattern(input, ruleAction)so registered wildcard actions (e.g.read*,*:archive) match. Currently theWILDCARD_ACTIONconstant exists butAbility.candoes not honor it. - MODIFIED:
package.jsonadds subpath exports./presets/default-preset/default-preset,./events/permission-events,./utils/deep-get/deep-get,./utils/pattern-match/pattern-match. - MODIFIED:
index.tsre-exports the four new public symbols (createPermissions,permissionEvents,deepGet,deepHas,matchPattern,patternToRegex,PermissionsInstance,PresetOptions).
Out of scope
- GraphQL field-level permissions middleware — separate change. Upstream has a
middleware/graphql-permissions.ts(~180 L) that interceptsGraphQLResolveInfo. Genesispermissionsis universal-tier and cannot depend ongraphql; if we want this it belongs in a new@suaveplan/permissions-graphqlbrowser-tier package. - CASL pure-class subject system — upstream supports passing a class constructor as a subject (
subject: ArticleClass) and resolves the subject string viasubject.modelName. Genesis intentionally chose typed-pattern matching (string subjects only) to avoid pulling reflection at runtime; documented in the genesispermissionsREADME. Out of scope here. - Async condition matchers — upstream's
DefaultConditionMatcher.matchis synchronous; some downstream callers want an async variant for "is the user a member of this org?" lookups. That requires anAsyncConditionMatcherinterface and anAbility.canAsynccompanion — meaningfully larger surface change, out of scope. - Reactive bindings — React hooks (
usePermission,usePermissionRule) live in the browser-tier@suaveplan/react-permissionspackage (which already exists as a separate "enterprise-react-permissions" change). Not apermissionscore concern. - Pattern-match anchor configurability — upstream's
patternToRegexalways anchors with^and$. Some callers want unanchored "contains" semantics for log-style matching. We keep the upstream anchored semantics; unanchored variants belong in apermissions-searchfollow-up.
Source mapping
Genesis enterprise/permissions.md is a generic enterprise-quality template (no per-feature requirements), so source mapping cites the upstream package source directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
createPermissions factory |
packages/universal/permissions/src/presets/default-preset/default-preset.ts |
L60-L97 | Verbatim port |
PermissionsInstance interface |
packages/universal/permissions/src/presets/default-preset/default-preset.ts |
L23-L40 | Verbatim |
PresetOptions interface |
packages/universal/permissions/src/presets/default-preset/default-preset.types.ts |
L1-L26 | Verbatim |
permissionEvents singleton |
packages/universal/permissions/src/permission-events.ts |
L11-L57 | Verbatim port; replace upstream EventEmitter (custom class) with EventBusLike from @suaveplan/events to match genesis-wide bus contract |
deepGet |
packages/universal/permissions/src/utils/deep-get.ts |
L21-L36 | Verbatim |
deepHas |
packages/universal/permissions/src/utils/deep-get.ts |
L45-L47 | Verbatim |
matchPattern |
packages/universal/permissions/src/utils/pattern-matching.ts |
L20-L30 | Verbatim |
patternToRegex |
packages/universal/permissions/src/utils/pattern-matching.ts |
L41-L44 | Verbatim port; ADD module-scope Map<string, RegExp> cache (genesis-only optimisation; upstream re-compiles every call) |
Requirements with no source line
| Requirement | Justification |
|---|---|
permissionEvents typed against @suaveplan/types::EventBusLike (rather than upstream's custom EventEmitter class) |
Genesis-wide event-bus contract policy: every cross-cutting bus uses EventBusLike so callers can swap in their own bus. Upstream uses a self-defined EventEmitter; that breaks consumer composition. |
RegExp cache in patternToRegex |
Genesis-wide hot-path optimisation policy. Ability.can is called on every check; pattern RegExp compilation is the hottest unbounded allocation. Bounding the cache (LRU at 1024 entries, eviction on insertion) is genesis-original. |
deepGet array-index support (tags.0) |
Upstream supports only object-key segments; conditions like firstTag === user.tags[0] could not be written. Adding numeric segments is a small extension that opens the realistic use case without breaking existing callers. |
Ability.can action wildcard via matchPattern |
Upstream Ability.can does honor manage and * wildcards via inline checks. Genesis Ability.can already imports WILDCARD_ACTION but does not actually use it; this change makes the wildcard resolution match the upstream behavior — wired through the new matchPattern util. |
Verification approach
Each spec delta requirement carries a **Verified by:** line pointing at concrete it(...) titles. The test files that will exist after this change ships:
packages/auth/permissions/src/presets/default-preset/default-preset.test.ts— destructure-and-use, matcher overrides, adapter override, multiple instances are independentpackages/auth/permissions/src/presets/default-preset/default-preset.spec-conformance.test.ts— generics flow through (expectTypeOf), result is frozen, options validatedpackages/auth/permissions/src/events/permission-events.test.ts— listener registration, event payload shapes, manager auto-emits when no bus is passedpackages/auth/permissions/src/events/permission-events.spec-conformance.test.ts— singleton invariant (multiple imports return the same instance),EventBusLikecontract pinnedpackages/auth/permissions/src/utils/deep-get/deep-get.test.ts— dot-path resolution, array-index resolution, missing-segment returns undefined, null-safe traversalpackages/auth/permissions/src/utils/deep-get/deep-get.spec-conformance.test.ts—deepHasagrees withdeepGet !== undefinedfor every fixturepackages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts— exact match,*wildcard,managewildcard, prefix, suffix, both-side wildcard, escaped special characterspackages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts— RegExp cache reuse for repeated patterns; cache eviction at 1024 entriespackages/auth/permissions/src/conditions/condition-matcher/condition-matcher.test.ts— extended with dot-path keys (user.profile.role)packages/auth/permissions/src/conditions/field-matcher/field-matcher.test.ts— extended with wildcard-pattern matching (field*,*field)packages/auth/permissions/src/core/ability/ability.test.ts— extended with wildcard-action matching (read*matchesread:article)
Migration impact
This change is mostly additive — it adds four new public symbols and three small behavior changes inside existing implementations.
The three MODIFIED items (in condition-matcher, field-matcher, ability) are behavior-compatible extensions: every existing test continues to pass because the new behaviors only activate when the input contains a . (dot-path) or * (wildcard pattern) — neither of which existing tests exercise as a string literal. The full sweep is part of §9 of tasks.md.
Affected packages:
- None outside
@suaveplan/permissions—bunx turbo ls --filter=...@suaveplan/permissionsreturns only@suaveplan/permissionsitself. No internal consumer exists today (verified bygrep -rl "@suaveplan/permissions" /home/ubuntu/web/genesis/packages/ --include="package.json" | grep -v node_modulesreturning only the package itself).
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/permissions to enumerate. For each dependent, check one box:
- (No dependents in genesis today — verified by
grep -rl "@suaveplan/permissions" /home/ubuntu/web/genesis/packages/ --include="package.json" | grep -v node_modulesreturning onlypackages/auth/permissions/package.json.)
When the upstream @suaveplan/auth migrates into genesis, it will consume createPermissions and permissionEvents. The four exports are stable from day one of this change so auth's migration is a non-issue.