endpoint-permissions-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,11 +7,13 @@ Framework-agnostic endpoint authorization for TypeScript and JavaScript: an in-m
7
7
 
8
8
  ## Features
9
9
 
10
- - Permissions identified as `[role]::[module]::[name]` strings that your application persists and passes back per request.
11
- - Per-method (`find`, `update`, `create`, `remove`) definitions with allowed fields, or `'*'` for every field.
10
+ - Permissions identified as `[role]::[module]::[name]` strings that your application persists and passes back per request; the request itself carries only the role, the module and the method.
11
+ - Per-method (`find`, `update`, `create`, `remove`) definitions with allowed fields, nested paths (`'unicorn.name'`), per-segment wildcards (`'unicorn.*'`), or `'*'` for every field.
12
+ - One method-agnostic request shape: every method sends `data` and `context`, and receives `{ data }`.
12
13
  - One-hop grants: holders of one permission receive a declared subset of actions on another.
13
14
  - Hooks at module, name and role scope, awaited together with `Promise.allSettled`.
14
15
  - `validate()` never throws: it always returns `{ result, errors }`.
16
+ - The keys of `data` are checked against the permission: denied by default, or cropped with `context.set('cropper', true)`.
15
17
  - Read-only permission views (`permissions.named`, `permissions.forUser`) for admin screens.
16
18
  - `pkit generate` CLI that turns your role catalog into a TypeScript declaration so unknown roles do not compile.
17
19
  - ESM, CommonJS and `.d.ts` output; runs on Node 20 or newer and on Bun.
@@ -42,48 +44,51 @@ pkit.seal();
42
44
 
43
45
  const validation = await pkit.validate({
44
46
  action: 'inventory.items',
45
- name: 'all',
46
47
  method: 'find',
47
48
  role: 'staff',
48
49
  permissions: ['staff::inventory.items::all'],
49
- select: ['id', 'name', 'cost'],
50
+ data: { id: 1, name: 'Wrench' },
50
51
  });
51
52
 
52
53
  console.log(validation.result);
53
54
  console.log(validation.errors);
54
55
  ```
55
56
 
56
- `validation.result` is `['id', 'name']`: the requested selection trimmed to the fields allowed for `staff::inventory.items::all`. `validation.errors` is an empty frozen array. `role` and `permissions` come from your session and your stored assignments, never from the request body or query string.
57
+ `validation.result` is `{ data: { id: 1, name: 'Wrench' } }` and `validation.errors` is an empty frozen array: `id` and `name` are allowed for `staff::inventory.items::all`. Sending `cost` instead would return `result: null` and one `PROPERTIES_NOT_ALLOWED` error listing it. The same shape serves `update`, `create` and `remove`. `role` and `permissions` come from your session and your stored assignments, never from the request body or query string. The request does not name a permission name: `all` is resolved from the assignment that starts with `staff::inventory.items::`.
57
58
 
58
59
  Until you run `pkit generate`, TypeScript only knows the role `general`; the role names above compile once the generated declaration file is part of your program. See [CLI](#cli).
59
60
 
60
61
  ## Concepts
61
62
 
62
63
  - **Role catalog.** `pkit.context.set('roles', [...])` declares every role before the first registration. Without that call the catalog contains only `general`. `general` is an ordinary role: it must be used explicitly with `.role('general')`, it is not added to a declared catalog, and it does not back other roles. There is no implicit role anywhere: `registerActions`, role hooks, `validate()` and `permissions.forUser()` all require an explicit role.
63
- - **Identifier.** A permission is `[role]::[module]::[name]`, for example `staff::marketing.portals::update-only`. The module is a dot-joined path built with chained `.module(segment)` calls, the name labels a set of actions on that module, and the role is part of the key so stored rows can be audited.
64
+ - **Identifier.** A permission is `[role]::[module]::[name]`, for example `staff::marketing.portals::update-only`. The module is a dot-joined path built with chained `.module(segment)` calls, the name labels a set of actions on that module, and the role is part of the key so stored rows can be audited. A request carries `role`, `action` and `method`; the name is resolved from the assignment that starts with `role::module::`, so the same role can hold `all`, `read-only` or `only-related` on one module without the route knowing which one the caller was given.
64
65
  - **Definition vs assignment vs grant.** A definition (`.name('all').role('admin').registerActions(...)`) declares what `admin::marketing.portals::all` allows; it assigns nothing. An assignment is the identifier stored among a user's permissions by your application. A grant (`.name('all').grantTo('admin::marketing.dashboard::all').registerActions(...)`) gives holders of the dashboard permission the declared actions on portals. A role alone authorizes nothing.
65
66
  - **One-hop grants.** Access received through a grant never counts as an assignment that activates another grant. Cycles between different names are allowed; only self-reference is rejected.
66
- - **Direct assignment precedence.** If the user holds `role::module::name` directly, that definition decides completely, even when a grant to the same target would be wider. Only when there is no direct assignment are the applicable grants combined, and their fields are unioned.
67
+ - **Data properties.** The keys of `data` are the fields the request touches, walked structurally and compared path by path with the permission. A declared property matches the exact leaf path: `'unicorn'` allows `unicorn` only when it is an empty object or array, and `'unicorn.name'` is what allows `{ unicorn: { name: 'x' } }`. Each `*` stands for exactly one segment and never the first one, so `'unicorn.*'` allows `unicorn.name` but not `unicorn.treasures.id`, and `'*.name'` is rejected at registration. Array elements share their container's path, so `treasures[0].id` is compared as `treasures.id` and `tags: ['a', 'b']` as `tags`; an object key is always a path segment, so `{ treasures: { '0': { id: 1 } } }` is compared as `treasures.0.id`, not `treasures.id`. No key is exempt: `constructor`, `prototype` and `__proto__` are compared and filtered like any other field.
68
+ - **Deny or crop.** With the default `cropper: false` any path outside the permission denies the request, and `fields` lists the index-free paths. With `pkit.context.set('cropper', true)` nothing is denied: the allowed part of `data` is copied out, arrays are compacted and the containers the crop emptied are pruned. Containers that arrived empty and are allowed stay. The `data` object you pass is never mutated.
69
+ - **Direct assignment precedence.** If the user holds a name of the requested module directly, that definition decides completely, even when a grant to the same target would be wider. Only when no name of the module is assigned are the applicable grants combined, and their fields are unioned.
70
+ - **One name per module.** A user holds at most one name of a given `role::module`, and at most one name of a module is reachable by grant. Two of either is `AMBIGUOUS_PERMISSION`: the request names no name, so the library denies instead of choosing between a wider and a narrower variant.
67
71
 
68
72
  ## Error contract
69
73
 
70
- `validate()` never throws. It returns `{ result, errors }`: on success `errors` is an empty frozen array and `result` is the effective selection for `find` or the same `data` object for writes; on any failure `result` is `null` and `errors` is a frozen array of `{ code, message }` objects.
74
+ `validate()` never throws. It returns `{ result, errors }`: on success `errors` is an empty frozen array and `result` is `{ data }`, the request data unchanged, or cropped to the allowed keys when `cropper` is on; on any failure `result` is `null` and `errors` is a frozen array of `{ code, message }` objects.
71
75
 
72
76
  | Code | Meaning |
73
77
  | --- | --- |
74
78
  | `NOT_SEALED` | `validate()` was called before `seal()` |
75
- | `INVALID_INPUT` | Missing `action`, `name`, `role` or `permissions`; malformed identifier; `select` outside `find`; invalid shape of `data`, `context` or `select` |
79
+ | `INVALID_INPUT` | Missing `action`, `role` or `permissions`; malformed identifier; invalid shape of `data` or `context` |
76
80
  | `UNKNOWN_ROLE` | Role outside the catalog |
77
81
  | `UNKNOWN_ACTION` | Module not registered |
78
- | `UNKNOWN_PERMISSION` | Name does not exist, or an assigned identifier is not assignable |
82
+ | `UNKNOWN_PERMISSION` | An assigned identifier is not assignable |
83
+ | `AMBIGUOUS_PERMISSION` | Two names of one `role::module` are assigned, or two names of one module are reachable by grant |
79
84
  | `PERMISSION_ROLE_MISMATCH` | An assigned identifier carries a role different from the authenticated one |
80
- | `PERMISSION_NOT_ASSIGNED` | No direct assignment and no active grant for the target |
85
+ | `PERMISSION_NOT_ASSIGNED` | No assignment under `role::module::` and no active grant for the module |
81
86
  | `METHOD_DISABLED` | Method absent or disabled on the assigned definition, or not granted by any applicable grant |
82
- | `PROPERTIES_NOT_ALLOWED` | Write keys outside the allowed fields; `fields` lists the rejected keys |
87
+ | `PROPERTIES_NOT_ALLOWED` | Paths of `data` outside the allowed fields; `fields` lists them without array indexes. Never raised with `cropper` on |
83
88
  | `HOOK_ERROR` | One entry per failed hook; `cause` keeps the thrown value |
84
- | `VALIDATION_ERROR` | Unexpected failure in another phase; `cause` keeps the original error |
89
+ | `VALIDATION_ERROR` | Unexpected failure in another phase; `cause` keeps the original error. Also raised when `data` nests deeper than 1000 levels, or when it contains a cycle and `cropper` is on |
85
90
 
86
- Configuration, registration, `seal()` and the permission views throw a native `PkitError` with a `code` property (`ROLE_NOT_DECLARED`, `DUPLICATE_REGISTRATION`, `INVALID_DEFINITION`, `SEALED`, `NOT_SEALED`, `UNKNOWN_ROLE`, `INVALID_INPUT`, `PERMISSION_ROLE_MISMATCH`, `UNKNOWN_PERMISSION`). Those are programming errors raised at startup, not authorization results. The application translates every code to its transport (HTTP status, GraphQL error, RPC failure) and decides what to expose and what to log.
91
+ Configuration, registration, `seal()` and the permission views throw a native `PkitError` with a `code` property (`ROLE_NOT_DECLARED`, `DUPLICATE_REGISTRATION`, `INVALID_DEFINITION`, `SEALED`, `NOT_SEALED`, `UNKNOWN_ROLE`, `INVALID_INPUT`, `PERMISSION_ROLE_MISMATCH`, `UNKNOWN_PERMISSION`, `AMBIGUOUS_PERMISSION`). Those are programming errors raised at startup, not authorization results. The application translates every code to its transport (HTTP status, GraphQL error, RPC failure) and decides what to expose and what to log.
87
92
 
88
93
  ## CLI
89
94
 
@@ -124,14 +129,16 @@ The registry lives in `globalThis[Symbol.for('endpoint-permissions-kit')]`, so t
124
129
  | `src/index.ts` | Composes `pkit`, named exports and the published types |
125
130
  | `src/types.ts` | Public contracts: roles, methods, actions, grants, hooks, inputs, results, errors and views; published as `./types` |
126
131
  | `src/constants.ts` | Available methods, the `general` role, the global hook owner marker and the field wildcard |
127
- | `src/errors.ts` | Creates `PkitError` exceptions with a `code` |
128
- | `src/state.ts` | Creates and returns the shared registry stored on `globalThis` |
129
- | `src/context.ts` | Declares and reads the role catalog |
132
+ | `src/errors.ts` | Creates `PkitError` exceptions with a `code` and renders untrusted values for their messages |
133
+ | `src/state.ts` | Creates and returns the shared registry stored on `globalThis`, and guards its open/sealed lifecycle |
134
+ | `src/context.ts` | Declares and reads the role catalog and the `cropper` flag |
135
+ | `src/identifiers.ts` | The `[role]::[module]::[name]` format: builds identifiers, parses them and checks each segment |
136
+ | `src/definitions.ts` | Reads a `registerActions` literal into the frozen definition the registry stores; grant restrictions |
130
137
  | `src/registry.ts` | Module, name, role and grant builders; registers actions, grants and hooks |
131
- | `src/resolve.ts` | Access resolution shared by `validate` and `permissions.forUser` |
138
+ | `src/resolve.ts` | Identity checks and access resolution, shared by `validate` and `permissions.forUser` |
132
139
  | `src/seal.ts` | Checks cross-references and orphan hooks, then materializes frozen views; idempotent |
133
140
  | `src/permissions.ts` | `permissions.named` and `permissions.forUser` |
134
- | `src/validators.ts` | Reusable validation rules for objects, strings, catalog, roles, definitions, grants, hooks and requests |
141
+ | `src/properties.ts` | Walks `data`, checks each path against the permission and denies the disallowed ones, or crops them when `cropper` is on |
135
142
  | `src/validate.ts` | Runs request validation and hooks; sole owner of the `{ result, errors }` format |
136
143
  | `src/cli/generate.ts` | Resolves the config, reads the catalog from the child process, renders and writes the declaration; `main` handles the CLI |
137
144
  | `src/cli/child.ts` | Imports the config and prints the role catalog on stdout |
@@ -145,7 +152,7 @@ The registry lives in `globalThis[Symbol.for('endpoint-permissions-kit')]`, so t
145
152
 
146
153
  - No framework adapters: you call `validate()` from your own handlers or middleware.
147
154
  - No database access: your application loads assignments from a trusted source and passes them in.
148
- - No route discovery: the route decides which module, name and method it protects.
155
+ - No route discovery: the route decides which module and method it protects.
149
156
  - No session management: `role` and `permissions` come from your authenticated session.
150
157
  - No hot reload: changes to a sealed registry require restarting the process.
151
158
 
package/bin/pkit.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../dist/esm/cli/generate.js';
2
+ import generator from '../dist/esm/cli/generate.js';
3
3
 
4
- await main(process.argv.slice(2));
4
+ await generator.main(process.argv.slice(2));