Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
# 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:
1. **`createPermissions()` factory + `PermissionsInstance` interface** (97 L upstream `presets/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 single `PresetOptions` arg.
2. **`permissionEvents` singleton event bus** (57 L upstream `permission-events.ts`) — a process-wide `EventBusLike` from `@suaveplan/events` keyed by `"permission:checked" | "permission:granted" | "permission:denied" | "role:assigned" | "role:revoked"`. Genesis already exports the `PermissionEventMap` type but never wires a singleton — managers either accept an optional event bus (which most callers leave undefined) or emit nothing.
3. **`deepGet(obj, path)` and `deepHas(obj, path)` utilities** (47 L upstream `utils/deep-get.ts`) — typed dot-path getters used by `DefaultConditionMatcher` to read `user.profile.role` or similar nested context fields. The genesis condition matcher currently only accepts top-level keys, blocking realistic conditions.
4. **`matchPattern(input, pattern)` and `patternToRegex(pattern)` utilities** (54 L upstream `utils/pattern-matching.ts`) — glob-style matching (`*`, `manage`, prefix `foo*`, suffix `*foo`) compiled to RegExp. Used by `DefaultFieldMatcher` and by `Ability.can(action, subject)` for wildcard action / subject matching, which the genesis `WILDCARD_ACTION` constant already presupposes but does not implement.
## What changes
- ADDED: `presets/default-preset/default-preset.ts` exporting `createPermissions<TAction, TSubject, TConditions>(options?: PresetOptions<...>): PermissionsInstance<TAction, TSubject, TConditions>`. The factory wires `MemoryAdapter`, `AbilityBuilder`, `DefaultConditionMatcher`, `DefaultFieldMatcher`, `DefaultSubjectDetector`, builds the `Ability`, and returns all six instances in a frozen result object. Every default is overridable via `PresetOptions`.
- ADDED: `presets/default-preset/default-preset.types.ts` exporting `PresetOptions<TAction, TSubject, TConditions>` (overrides for each of the six injected dependencies) and `PermissionsInstance<TAction, TSubject, TConditions>` (the result shape).
- ADDED: `events/permission-events.ts` `permissionEvents` exported singleton — a module-scope `EventBusLike` instance keyed by the existing `PermissionEventMap`. Managers and the ability builder SHALL emit through it when no caller-supplied bus is passed. The existing `PermissionEventMap` type stays exported as before.
- ADDED: `utils/deep-get/deep-get.ts` exporting `deepGet(obj: unknown, path: string): unknown` and `deepHas(obj: unknown, path: string): boolean`. Supports dot-notation (`user.profile.role`) and array-index segments (`tags.0` resolves `obj.tags[0]`). Returns `undefined` from `deepGet` (and `false` from `deepHas`) when any segment is missing rather than throwing.
- ADDED: `utils/pattern-match/pattern-match.ts` exporting `matchPattern(input: string, pattern: string): boolean` and `patternToRegex(pattern: string): RegExp`. Patterns: `*` and `"manage"` match everything, `foo*` matches by prefix, `*foo` matches by suffix, otherwise exact-equality. The compiled `RegExp` is cached by pattern string in a module-scope `Map<string, RegExp>` so repeated lookups (the hot path inside `Ability.can`) do not re-compile.
- MODIFIED: `conditions/condition-matcher/condition-matcher.ts` — `DefaultConditionMatcher.match(conditions, context)` SHALL use `deepGet(context, key)` to resolve dot-notation keys (currently it only resolves top-level keys via `context[key]`).
- MODIFIED: `conditions/field-matcher/field-matcher.ts` — `DefaultFieldMatcher.match(pattern, field)` SHALL delegate to `matchPattern(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 to `matchPattern(input, ruleAction)` so registered wildcard actions (e.g. `read*`, `*:archive`) match. Currently the `WILDCARD_ACTION` constant exists but `Ability.can` does not honor it.
- MODIFIED: `package.json` adds subpath exports `./presets/default-preset/default-preset`, `./events/permission-events`, `./utils/deep-get/deep-get`, `./utils/pattern-match/pattern-match`.
- MODIFIED: `index.ts` re-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 intercepts `GraphQLResolveInfo`. Genesis `permissions` is universal-tier and cannot depend on `graphql`; if we want this it belongs in a new `@suaveplan/permissions-graphql` browser-tier package.
- **CASL pure-class subject system** — upstream supports passing a class constructor as a subject (`subject: ArticleClass`) and resolves the subject string via `subject.modelName`. Genesis intentionally chose typed-pattern matching (string subjects only) to avoid pulling reflection at runtime; documented in the genesis `permissions` README. Out of scope here.
- **Async condition matchers** — upstream's `DefaultConditionMatcher.match` is synchronous; some downstream callers want an async variant for "is the user a member of this org?" lookups. That requires an `AsyncConditionMatcher` interface and an `Ability.canAsync` companion — meaningfully larger surface change, out of scope.
- **Reactive bindings** — React hooks (`usePermission`, `usePermissionRule`) live in the browser-tier `@suaveplan/react-permissions` package (which already exists as a separate "enterprise-react-permissions" change). Not a `permissions` core concern.
- **Pattern-match anchor configurability** — upstream's `patternToRegex` always anchors with `^` and `$`. Some callers want unanchored "contains" semantics for log-style matching. We keep the upstream anchored semantics; unanchored variants belong in a `permissions-search` follow-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 independent
- `packages/auth/permissions/src/presets/default-preset/default-preset.spec-conformance.test.ts` — generics flow through (`expectTypeOf`), result is frozen, options validated
- `packages/auth/permissions/src/events/permission-events.test.ts` — listener registration, event payload shapes, manager auto-emits when no bus is passed
- `packages/auth/permissions/src/events/permission-events.spec-conformance.test.ts` — singleton invariant (multiple imports return the same instance), `EventBusLike` contract pinned
- `packages/auth/permissions/src/utils/deep-get/deep-get.test.ts` — dot-path resolution, array-index resolution, missing-segment returns undefined, null-safe traversal
- `packages/auth/permissions/src/utils/deep-get/deep-get.spec-conformance.test.ts` — `deepHas` agrees with `deepGet !== undefined` for every fixture
- `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts` — exact match, `*` wildcard, `manage` wildcard, prefix, suffix, both-side wildcard, escaped special characters
- `packages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts` — RegExp cache reuse for repeated patterns; cache eviction at 1024 entries
- `packages/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*` matches `read: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/permissions` returns only `@suaveplan/permissions` itself. No internal consumer exists today (verified by `grep -rl "@suaveplan/permissions" /home/ubuntu/web/genesis/packages/ --include="package.json" | grep -v node_modules` returning 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_modules` returning only `packages/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.

View File

@@ -0,0 +1,216 @@
<!-- markdownlint-disable MD024 -->
# 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<TAction, TSubject, TConditions>(options?: PresetOptions<TAction, TSubject, TConditions>): PermissionsInstance<TAction, TSubject, TConditions>` 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<TAction extends string, TSubject extends string, TConditions extends Record<string, unknown>>` as a readonly interface with fields typed against the same generics, so callers can destructure type-safely. The package SHALL also define `PresetOptions<TAction, TSubject, TConditions>` 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<PermissionEventMap>` 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<PermissionEventMap>"`
---
### 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<string, RegExp>` 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,132 @@
# Tasks — Permissions completion
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `../suaveplan/packages/universal/permissions/src/presets/default-preset/default-preset.ts` and `default-preset.types.ts` end-to-end
- [ ] 1.2 Read `../suaveplan/packages/universal/permissions/src/permission-events.ts` end-to-end
- [ ] 1.3 Read `../suaveplan/packages/universal/permissions/src/utils/deep-get.ts` and `utils/pattern-matching.ts` end-to-end
- [ ] 1.4 Read genesis `packages/auth/permissions/src/{core,conditions,adapters,events,presets}` to confirm what exists
- [ ] 1.5 Read `openspec/AGENTS.md` Sections 13 (spec-conformance) and 14 (source mapping)
- [ ] 1.6 Draft `proposal.md` with explicit ADDED / MODIFIED lists and a `## Source mapping` table citing upstream line ranges
- [ ] 1.7 Draft `specs/permissions/spec.md` with an `## ADDED Requirements` section per gap and `## MODIFIED Requirements` for the three behavior extensions
- [ ] 1.8 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
- [ ] 1.9 `openspec change validate permissions-completion` exits with no errors
## 2. `createPermissions` factory + `PermissionsInstance`
- [ ] 2.1 Create `packages/auth/permissions/src/presets/default-preset/default-preset.types.ts` with `PresetOptions<TAction, TSubject, TConditions>` (overrides for `conditionMatcher`, `fieldMatcher`, `subjectDetector`, `adapter`, `builder`)
- [ ] 2.2 Add `PermissionsInstance<TAction, TSubject, TConditions>` interface with readonly fields `ability`, `builder`, `adapter`, `conditionMatcher`, `fieldMatcher`, `subjectDetector`
- [ ] 2.3 Port `createPermissions` from upstream `default-preset.ts` L60-L97 into `packages/auth/permissions/src/presets/default-preset/default-preset.ts`. Preserve generic parameters (`TAction extends string = DefaultAction`, etc.)
- [ ] 2.4 Wire `MemoryAdapter`, `AbilityBuilder`, `DefaultConditionMatcher`, `DefaultFieldMatcher`, `DefaultSubjectDetector` defaults; honour every override from `PresetOptions`
- [ ] 2.5 `Object.freeze` the returned `PermissionsInstance` so callers can't mutate the matcher slot post-creation
- [ ] 2.6 Add subpath export `./presets/default-preset/default-preset` in `package.json`
- [ ] 2.7 Re-export `createPermissions`, `PermissionsInstance`, `PresetOptions` from `src/index.ts`
- [ ] 2.8 Sibling `default-preset.md` (≥200 words, basic + advanced examples)
## 3. `permissionEvents` singleton
- [ ] 3.1 Modify `packages/auth/permissions/src/events/permission-events.ts` to export a module-scope `permissionEvents` singleton typed as `EventBusLike<PermissionEventMap>` from `@suaveplan/events`
- [ ] 3.2 Use the genesis-canonical `createEventBus({ name: "permissions" })` factory (not the upstream custom `EventEmitter` class)
- [ ] 3.3 Update `core/permission-manager/permission-manager.ts` to default to `permissionEvents` when no bus is passed via constructor options
- [ ] 3.4 Update `core/role-manager/role-manager.ts` likewise
- [ ] 3.5 Update `core/policy-engine/policy-engine.ts` to emit `permission:checked` through the singleton
- [ ] 3.6 Add subpath export `./events/permission-events` in `package.json`
- [ ] 3.7 Re-export `permissionEvents` from `src/index.ts`
- [ ] 3.8 Sibling `permission-events.md` updated to document the singleton (≥200 words, basic listener + advanced filter-by-userId examples)
## 4. `deepGet` / `deepHas` utilities
- [ ] 4.1 Create `packages/auth/permissions/src/utils/deep-get/deep-get.ts` with `deepGet(obj, path)` and `deepHas(obj, path)`
- [ ] 4.2 Support dot-notation segments (`user.profile.role`)
- [ ] 4.3 Support numeric array-index segments (`tags.0` resolves `obj.tags[0]`); detected by `/^\d+$/.test(segment)`
- [ ] 4.4 Return `undefined` from `deepGet` (and `false` from `deepHas`) when any segment is missing or `current` is `null` / `undefined` / non-object
- [ ] 4.5 Update `conditions/condition-matcher/condition-matcher.ts` `DefaultConditionMatcher.match(conditions, context)` to use `deepGet(context, key)` instead of `context[key as keyof typeof context]`
- [ ] 4.6 Add subpath export `./utils/deep-get/deep-get` in `package.json`
- [ ] 4.7 Re-export `deepGet`, `deepHas` from `src/index.ts`
- [ ] 4.8 Sibling `deep-get.md` (≥200 words, basic + advanced array-index example)
## 5. `matchPattern` / `patternToRegex` utilities
- [ ] 5.1 Create `packages/auth/permissions/src/utils/pattern-match/pattern-match.ts` with `matchPattern(input, pattern)` and `patternToRegex(pattern)`
- [ ] 5.2 Patterns: `*` and `"manage"` match everything; `foo*` is prefix match; `*foo` is suffix match; `foo*bar` is prefix + suffix; otherwise exact equality
- [ ] 5.3 Escape regex special characters in non-wildcard portions before compiling: `.`, `+`, `?`, `^`, `$`, `{`, `}`, `(`, `)`, `|`, `[`, `]`, `\`
- [ ] 5.4 Cache compiled `RegExp` in a module-scope `Map<string, RegExp>` keyed by pattern string
- [ ] 5.5 LRU-evict at 1024 entries (insertion-order eviction via `Map.keys().next().value`)
- [ ] 5.6 Update `conditions/field-matcher/field-matcher.ts` `DefaultFieldMatcher.match(pattern, field)` to delegate to `matchPattern(field, pattern)` (keeping the existing exact-match fast path before pattern parse)
- [ ] 5.7 Update `core/ability/ability.ts` `Ability.can` action / subject lookup to delegate to `matchPattern` so registered wildcard rules (e.g. `can("read*", "Article")`) are honoured
- [ ] 5.8 Add subpath export `./utils/pattern-match/pattern-match` in `package.json`
- [ ] 5.9 Re-export `matchPattern`, `patternToRegex` from `src/index.ts`
- [ ] 5.10 Sibling `pattern-match.md` (≥200 words, basic + advanced example showing both prefix and suffix wildcards)
## 6. Tests
- [ ] 6.1 `presets/default-preset/default-preset.test.ts` — destructure-and-use; matcher overrides honoured; adapter override honoured; multiple instances are independent (changing one's matcher does not affect the other)
- [ ] 6.2 `presets/default-preset/default-preset.spec-conformance.test.ts` — `expectTypeOf` checks generics flow through; result is `Object.isFrozen`; `PresetOptions` accepts every documented override
- [ ] 6.3 `events/permission-events.test.ts` — listener registration; payload shapes for all 5 event names; manager auto-emits `permission:checked` and `permission:granted` when no bus passed
- [ ] 6.4 `events/permission-events.spec-conformance.test.ts` — re-importing the module returns the same `permissionEvents` instance; `EventBusLike<PermissionEventMap>` contract pinned (`expectTypeOf<typeof permissionEvents>().toMatchTypeOf<EventBusLike<PermissionEventMap>>()`)
- [ ] 6.5 `utils/deep-get/deep-get.test.ts` — dot-path resolution at depth 3; array-index resolution; missing segment returns `undefined`; null-safe traversal; `deepHas` reports correctly
- [ ] 6.6 `utils/deep-get/deep-get.spec-conformance.test.ts` — `deepHas(obj, p) === (deepGet(obj, p) !== undefined)` for every test fixture
- [ ] 6.7 `utils/pattern-match/pattern-match.test.ts` — exact match; `*` wildcard; `manage` wildcard; `foo*` prefix; `*foo` suffix; `foo*bar` both-side; special-char input must not be matched accidentally (e.g. `.` in input)
- [ ] 6.8 `utils/pattern-match/pattern-match.spec-conformance.test.ts` — RegExp cache: same pattern returns same RegExp instance; cache evicts at 1024 entries (insertion-order)
- [ ] 6.9 `conditions/condition-matcher/condition-matcher.test.ts` — extend with dot-path keys (`user.profile.role` resolves through `deepGet`)
- [ ] 6.10 `conditions/field-matcher/field-matcher.test.ts` — extend with wildcard-pattern matching (`field*` matches `fieldName`; `*Name` matches `firstName`)
- [ ] 6.11 `core/ability/ability.test.ts` — extend with wildcard-action matching (`can("read*", "Article")` matches `ability.can("read", "Article")` and `ability.can("readMeta", "Article")`)
- [ ] 6.12 Every `it(...)` in 6.1–6.11 carries `{ timeout: 5000 }`
## 7. Documentation
- [ ] 7.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 7.2 Update `packages/auth/permissions/README.md` to link the four new co-located `.md` files under `## Modules`
- [ ] 7.3 Update `CLAUDE.md` package listing for `@suaveplan/permissions` to reflect the four new exports
## 8. Pre-archive verification (the 5-gate triple)
- [ ] 8.1 `bun run check` — Biome 0/0/0 across the whole monorepo
- [ ] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/permissions` — green
- [ ] 8.3 `bunx turbo test:coverage --filter=@suaveplan/permissions` — 100/100/100/100
- [ ] 8.4 `bun scripts/openspec-conformance.ts @suaveplan/permissions` — exit 0, no `error` findings
- [ ] 8.5 `bunx turbo build --filter=@suaveplan/permissions` — green
## 9. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 9.1 Generics threaded; `expectTypeOf` assertions on `PermissionsInstance<TAction, TSubject, TConditions>` and `PresetOptions<...>`
- [ ] 9.2 Zod schemas wired at every declared boundary — `PresetOptions` validated at `createPermissions` entry; `permissionEvents` payloads typed against `PermissionEventMap`
- [ ] 9.3 Every existing error code in `errors/permission-error.ts` still thrown by at least one production path (no orphans introduced by the refactor)
- [ ] 9.4 Strict-mode behaviors tested both on and off (e.g. `Ability.can` strict-action mode vs. wildcard mode)
- [ ] 9.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`: `permissions.checks_total`, `permissions.grants_total`, `permissions.denies_total`, `permissions.event_emit_count`
- [ ] 9.6 Lifecycle dedup tested where claimed: re-importing `events/permission-events.ts` returns the same singleton; `patternToRegex` cache reuse
- [ ] 9.7 No retroactive tick — every box above reflects work done in this change
## 10. Archive
- [ ] 10.1 Fold `specs/permissions/spec.md` into `openspec/specs/permissions/spec.md` (creating the cap file — no `permissions` cap exists in `openspec/specs` today)
- [ ] 10.2 Move `openspec/changes/permissions-completion/` → `openspec/archive/<yyyy-mm-dd>-permissions-completion/`
- [ ] 10.3 Refresh `MANIFEST.md` (no package set or dep changes expected, but rerun for safety)
- [ ] 10.4 Update `STATUS.md` with the rolled-up gap closure for the four enumerated features
## Audit Note (2026-05-02)
Audit verdict: **DEFERRED — 0/74 tasks evidenced in code**.
Evidence reviewed:
- `src/index.ts` re-exports the existing surface (Ability, AbilityBuilder, PermissionManager, RoleManager, PolicyEngine, MemoryAdapter, role presets) but does NOT export `createPermissions`, `permissionEvents`, `deepGet`, `deepHas`, `matchPattern`, or `patternToRegex`.
- `src/utils/` does not exist; no `pattern-match/` or `deep-get/` directories.
- `src/presets/` contains only `role-presets/`; no `default-preset/` (and therefore no `createPermissions` factory + frozen `PermissionsInstance`).
- `events/permission-events.ts` exports a type only (`PermissionEventMap`), not the `permissionEvents` singleton wired through `@suaveplan/events`.
Missing pieces:
1. `presets/default-preset/default-preset.ts` (`createPermissions` factory returning a frozen `PermissionsInstance`).
2. `events/permission-events.ts` runtime export of the singleton `EventBusLike<PermissionEventMap>`, plus auto-wiring through PermissionManager / RoleManager / PolicyEngine.
3. `utils/deep-get/` and `utils/pattern-match/` (with cache eviction at 1024 entries) and the integrations in `condition-matcher`, `field-matcher`, and `Ability.can` wildcard handling.
Decision: leave change open. The four feature blocks (factory, events singleton, deep-get, pattern-match) are independent additions, each with its own tests, docs, and OTel instruments — not a tick-and-archive job.