# Capability Spec Delta: permissions (permissions-completion) **Package**: `@suaveplan/permissions` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: createPermissions factory wires defaults The package SHALL expose `createPermissions(options?: PresetOptions): PermissionsInstance` from `presets/default-preset/default-preset.ts`. The factory SHALL instantiate `MemoryAdapter`, `AbilityBuilder`, `DefaultConditionMatcher`, `DefaultFieldMatcher`, `DefaultSubjectDetector`, build the `Ability` with all three matchers wired, and return all six instances in a single `Object.freeze`d result. Every default SHALL be overridable through `PresetOptions`. #### Scenario: zero-config returns all six instances - **WHEN** `createPermissions()` is called with no arguments - **THEN** the result has defined `ability`, `builder`, `adapter`, `conditionMatcher`, `fieldMatcher`, `subjectDetector` fields AND `Object.isFrozen(result) === true` #### Scenario: matcher overrides honoured - **WHEN** `createPermissions({ conditionMatcher: customMatcher })` is called - **THEN** `result.conditionMatcher === customMatcher` AND the ability's condition matching delegates through `customMatcher.match` #### Scenario: instances are independent - **WHEN** `createPermissions()` is called twice - **THEN** the two `result.adapter` instances are not the same reference, and adding rules to one does not affect the other **Verified by:** `packages/auth/permissions/src/presets/default-preset/default-preset.test.ts::"zero-config createPermissions returns all six wired instances"` AND `packages/auth/permissions/src/presets/default-preset/default-preset.test.ts::"createPermissions honours conditionMatcher / fieldMatcher / subjectDetector overrides"` AND `packages/auth/permissions/src/presets/default-preset/default-preset.test.ts::"two createPermissions calls return independent adapters"` --- ### Requirement: PermissionsInstance generic surface The package SHALL define `PermissionsInstance>` as a readonly interface with fields typed against the same generics, so callers can destructure type-safely. The package SHALL also define `PresetOptions` carrying optional overrides for `conditionMatcher`, `fieldMatcher`, `subjectDetector`, `adapter`, `builder`. #### Scenario: generics flow through factory - **WHEN** `createPermissions<"read" | "write", "Article">()` is called - **THEN** `expectTypeOf(result.ability.can).parameter(0).toEqualTypeOf<"read" | "write">()` and `expectTypeOf(result.ability.can).parameter(1).toEqualTypeOf<"Article" | undefined>()` **Verified by:** `packages/auth/permissions/src/presets/default-preset/default-preset.spec-conformance.test.ts::"generics flow through PermissionsInstance and PresetOptions"` --- ### Requirement: permissionEvents singleton bus The package SHALL export a module-scope singleton `permissionEvents` from `events/permission-events.ts`, typed as `EventBusLike` from `@suaveplan/events`. Re-importing the module SHALL return the same instance. `PermissionManager`, `RoleManager`, and `PolicyEngine` SHALL emit through this singleton when no caller-supplied bus is passed via constructor options. Event names: `"permission:checked"`, `"permission:granted"`, `"permission:denied"`, `"role:assigned"`, `"role:revoked"`. Payloads MUST match `PermissionEventMap`. #### Scenario: singleton invariant - **WHEN** `permissionEvents` is imported from two different modules in the same process - **THEN** both references point to the same instance (`a === b`) #### Scenario: manager emits via singleton when bus not passed - **WHEN** `new PermissionManager({...without eventBus...})` is constructed and a permission check is performed - **THEN** a `permission:checked` listener registered on `permissionEvents` fires with the documented payload shape #### Scenario: caller-supplied bus overrides singleton - **WHEN** `new PermissionManager({ eventBus: customBus })` is constructed and a permission check is performed - **THEN** a `permission:checked` listener on `customBus` fires AND a listener on `permissionEvents` does NOT fire (no double-emit) **Verified by:** `packages/auth/permissions/src/events/permission-events.test.ts::"permissionEvents is a process-wide singleton across imports"` AND `packages/auth/permissions/src/events/permission-events.test.ts::"PermissionManager emits to singleton when no eventBus passed"` AND `packages/auth/permissions/src/events/permission-events.test.ts::"caller-supplied bus overrides singleton with no double-emit"` AND `packages/auth/permissions/src/events/permission-events.spec-conformance.test.ts::"permissionEvents satisfies EventBusLike"` --- ### Requirement: deepGet / deepHas dot-path utilities The package SHALL expose `deepGet(obj: unknown, path: string): unknown` and `deepHas(obj: unknown, path: string): boolean` from `utils/deep-get/deep-get.ts`. The functions SHALL accept dot-notation paths and SHALL also accept numeric segments that resolve to array indices (e.g. `tags.0` reads `obj.tags[0]`). When any segment is missing or `current` is null / undefined / non-object, `deepGet` SHALL return `undefined` and `deepHas` SHALL return `false`. The functions SHALL NOT throw on any input. #### Scenario: dot-path resolves nested properties - **WHEN** `deepGet({ user: { profile: { role: "admin" } } }, "user.profile.role")` is called - **THEN** the result is `"admin"` #### Scenario: numeric segment resolves array index - **WHEN** `deepGet({ tags: ["a", "b", "c"] }, "tags.1")` is called - **THEN** the result is `"b"` #### Scenario: missing segment returns undefined - **WHEN** `deepGet({ user: { profile: {} } }, "user.profile.role")` is called - **THEN** the result is `undefined` #### Scenario: null-safe traversal - **WHEN** `deepGet(null, "anything")` or `deepGet({}, "user.profile.role")` is called - **THEN** the result is `undefined` (no exception) #### Scenario: deepHas agrees with deepGet - **WHEN** any fixture is queried with `deepHas(obj, path)` - **THEN** the result equals `deepGet(obj, path) !== undefined` **Verified by:** `packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"resolves nested object properties via dot notation"` AND `packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"resolves numeric segments as array indices"` AND `packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"returns undefined for any missing segment"` AND `packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"is null-safe and never throws"` AND `packages/auth/permissions/src/utils/deep-get/deep-get.spec-conformance.test.ts::"deepHas agrees with deepGet for every fixture"` --- ### Requirement: matchPattern / patternToRegex glob utilities The package SHALL expose `matchPattern(input: string, pattern: string): boolean` and `patternToRegex(pattern: string): RegExp` from `utils/pattern-match/pattern-match.ts`. Patterns: `*` and the literal string `"manage"` SHALL match every input; `foo*` SHALL match by prefix; `*foo` SHALL match by suffix; `foo*bar` SHALL match by combined prefix-and-suffix; otherwise exact equality. Special regex characters in non-wildcard portions SHALL be escaped before compilation. The compiled `RegExp` SHALL be cached in a module-scope `Map` keyed by pattern string with insertion-order eviction at 1024 entries. #### Scenario: exact match - **WHEN** `matchPattern("read", "read")` is called - **THEN** the result is `true` #### Scenario: wildcard "*" matches everything - **WHEN** `matchPattern("anything", "*")` is called - **THEN** the result is `true` #### Scenario: "manage" matches everything - **WHEN** `matchPattern("read", "manage")` is called - **THEN** the result is `true` #### Scenario: prefix wildcard - **WHEN** `matchPattern("readArticle", "read*")` is called - **THEN** the result is `true` #### Scenario: suffix wildcard - **WHEN** `matchPattern("getArticle", "*Article")` is called - **THEN** the result is `true` #### Scenario: special regex characters escaped - **WHEN** `matchPattern("a.b", "a.b")` is called (literal dot, not wildcard) - **THEN** the result is `true` AND `matchPattern("axb", "a.b")` returns `false` #### Scenario: RegExp cache reuse - **WHEN** `patternToRegex("read*")` is called twice - **THEN** both calls return the same `RegExp` instance (`a === b`) #### Scenario: cache eviction at 1024 entries - **WHEN** 1025 unique patterns are passed to `patternToRegex` - **THEN** the first inserted pattern is evicted (calling `patternToRegex` for it again returns a new `RegExp` instance, not the original) **Verified by:** `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"matches by exact equality"` AND `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"\"*\" matches every input"` AND `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"\"manage\" matches every input"` AND `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"prefix and suffix wildcards work"` AND `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"escapes special regex characters in literal portions"` AND `packages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts::"reuses cached RegExp for the same pattern"` AND `packages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts::"evicts in insertion order at 1024 entries"` ## MODIFIED Requirements > The three behavior extensions below modify existing genesis surface (no requirement currently exists in `openspec/specs/permissions/spec.md` because no `permissions` cap file has been folded yet — these are quoted in full so reviewers see the new contract clearly). ### Requirement: DefaultConditionMatcher resolves dot-path keys The `DefaultConditionMatcher.match(conditions, context)` method SHALL resolve every condition key through `deepGet(context, key)` rather than `context[key]`. This means a condition like `{ "user.role": { eq: "admin" } }` SHALL resolve to `context.user.role` and compare against `"admin"`. Top-level keys SHALL continue to work via `deepGet` returning the immediate value. #### Scenario: dot-path key resolves nested context - **WHEN** `matcher.match({ "user.role": { eq: "admin" } }, { user: { role: "admin" } })` is called - **THEN** the result is `true` #### Scenario: top-level key still works - **WHEN** `matcher.match({ role: { eq: "admin" } }, { role: "admin" })` is called - **THEN** the result is `true` (regression check) **Verified by:** `packages/auth/permissions/src/conditions/condition-matcher/condition-matcher.test.ts::"resolves dot-path condition keys via deepGet"` AND `packages/auth/permissions/src/conditions/condition-matcher/condition-matcher.test.ts::"top-level condition keys continue to resolve"` --- ### Requirement: DefaultFieldMatcher delegates to matchPattern The `DefaultFieldMatcher.match(pattern, field)` method SHALL delegate to `matchPattern(field, pattern)`, replacing the existing exact-match-plus-hardcoded-`*`-only logic. The fast path of byte-equal `pattern === field` SHALL be preserved for the common case, but every other path SHALL go through `matchPattern`. #### Scenario: prefix wildcard matches - **WHEN** `fieldMatcher.match("field*", "fieldName")` is called - **THEN** the result is `true` #### Scenario: suffix wildcard matches - **WHEN** `fieldMatcher.match("*Name", "firstName")` is called - **THEN** the result is `true` #### Scenario: exact equality fast path preserved - **WHEN** `fieldMatcher.match("title", "title")` is called - **THEN** the result is `true` AND no `RegExp` compilation occurs (verified by spying on `patternToRegex`) **Verified by:** `packages/auth/permissions/src/conditions/field-matcher/field-matcher.test.ts::"matches by prefix and suffix wildcards"` AND `packages/auth/permissions/src/conditions/field-matcher/field-matcher.test.ts::"exact-equality fast path skips RegExp compilation"` --- ### Requirement: Ability.can honors registered wildcard actions The `Ability.can(action, subject?, field?)` method SHALL match a registered rule whose `action` is `*`, `"manage"`, or contains `*` as a wildcard against the supplied `action`. Matching SHALL delegate to `matchPattern(action, rule.action)`. Likewise for `subject` matching against `rule.subject`. #### Scenario: wildcard action rule matches concrete action - **WHEN** a rule `{ action: "read*", subject: "Article" }` is registered AND `ability.can("readMeta", "Article")` is called - **THEN** the result is `true` #### Scenario: "manage" rule matches every action - **WHEN** a rule `{ action: "manage", subject: "Article" }` is registered AND `ability.can("delete", "Article")` is called - **THEN** the result is `true` #### Scenario: subject wildcard matches concrete subject - **WHEN** a rule `{ action: "read", subject: "*" }` is registered AND `ability.can("read", "Article")` is called - **THEN** the result is `true` **Verified by:** `packages/auth/permissions/src/core/ability/ability.test.ts::"can honours registered wildcard action via matchPattern"` AND `packages/auth/permissions/src/core/ability/ability.test.ts::"can honours \"manage\" wildcard action"` AND `packages/auth/permissions/src/core/ability/ability.test.ts::"can honours wildcard subject"`