endpoint-permissions-kit 0.1.0 → 0.2.1
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 +28 -20
- package/bin/pkit.mjs +2 -2
- package/dist/cjs/index.cjs +744 -474
- package/dist/cjs/index.cjs.map +16 -14
- package/dist/esm/cli/child.js +329 -352
- package/dist/esm/cli/child.js.map +11 -11
- package/dist/esm/cli/generate.js +78 -420
- package/dist/esm/cli/generate.js.map +5 -9
- package/dist/esm/index.js +746 -476
- package/dist/esm/index.js.map +16 -14
- package/dist/types/cli/generate.d.ts +6 -4
- package/dist/types/cli/protocol.d.ts +10 -7
- package/dist/types/constants.d.ts +9 -6
- package/dist/types/context.d.ts +5 -6
- package/dist/types/definitions.d.ts +15 -0
- package/dist/types/errors.d.ts +12 -11
- package/dist/types/identifiers.d.ts +21 -0
- package/dist/types/index.d.ts +16 -17
- package/dist/types/permissions.d.ts +10 -5
- package/dist/types/properties.d.ts +6 -0
- package/dist/types/registry.d.ts +4 -1
- package/dist/types/resolve.d.ts +50 -6
- package/dist/types/seal.d.ts +8 -1
- package/dist/types/state.d.ts +11 -9
- package/dist/types/types.d.ts +26 -34
- package/dist/types/validate.d.ts +5 -3
- package/package.json +1 -1
- package/dist/types/validators.d.ts +0 -53
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,52 @@ 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
|
-
|
|
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 `
|
|
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
|
-
- **
|
|
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
|
+
- **Reserved fields.** `pkit.context.set('reservedFields', ['id', 'meta.version'])` adds paths that every permission with a property list allows, in both modes: they are never denied and always kept by the crop. The list starts empty, replaces the previous one on each call, is validated once with the rules of declared properties, and is frozen by `seal()`. Matching is exact, like any declared property.
|
|
70
|
+
- **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.
|
|
71
|
+
- **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
72
|
|
|
68
73
|
## Error contract
|
|
69
74
|
|
|
70
|
-
`validate()` never throws. It returns `{ result, errors }`: on success `errors` is an empty frozen array and `result` is the
|
|
75
|
+
`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
76
|
|
|
72
77
|
| Code | Meaning |
|
|
73
78
|
| --- | --- |
|
|
74
79
|
| `NOT_SEALED` | `validate()` was called before `seal()` |
|
|
75
|
-
| `INVALID_INPUT` | Missing `action`, `
|
|
80
|
+
| `INVALID_INPUT` | Missing `action`, `role` or `permissions`; malformed identifier; invalid shape of `data` or `context` |
|
|
76
81
|
| `UNKNOWN_ROLE` | Role outside the catalog |
|
|
77
82
|
| `UNKNOWN_ACTION` | Module not registered |
|
|
78
|
-
| `UNKNOWN_PERMISSION` |
|
|
83
|
+
| `UNKNOWN_PERMISSION` | An assigned identifier is not assignable |
|
|
84
|
+
| `AMBIGUOUS_PERMISSION` | Two names of one `role::module` are assigned, or two names of one module are reachable by grant |
|
|
79
85
|
| `PERMISSION_ROLE_MISMATCH` | An assigned identifier carries a role different from the authenticated one |
|
|
80
|
-
| `PERMISSION_NOT_ASSIGNED` | No
|
|
86
|
+
| `PERMISSION_NOT_ASSIGNED` | No assignment under `role::module::` and no active grant for the module |
|
|
81
87
|
| `METHOD_DISABLED` | Method absent or disabled on the assigned definition, or not granted by any applicable grant |
|
|
82
|
-
| `PROPERTIES_NOT_ALLOWED` |
|
|
88
|
+
| `PROPERTIES_NOT_ALLOWED` | Paths of `data` outside the allowed fields; `fields` lists them without array indexes. Never raised with `cropper` on |
|
|
83
89
|
| `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 |
|
|
90
|
+
| `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
91
|
|
|
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.
|
|
92
|
+
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
93
|
|
|
88
94
|
## CLI
|
|
89
95
|
|
|
@@ -124,14 +130,16 @@ The registry lives in `globalThis[Symbol.for('endpoint-permissions-kit')]`, so t
|
|
|
124
130
|
| `src/index.ts` | Composes `pkit`, named exports and the published types |
|
|
125
131
|
| `src/types.ts` | Public contracts: roles, methods, actions, grants, hooks, inputs, results, errors and views; published as `./types` |
|
|
126
132
|
| `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 |
|
|
133
|
+
| `src/errors.ts` | Creates `PkitError` exceptions with a `code` and renders untrusted values for their messages |
|
|
134
|
+
| `src/state.ts` | Creates and returns the shared registry stored on `globalThis`, and guards its open/sealed lifecycle |
|
|
135
|
+
| `src/context.ts` | Declares and reads the role catalog, the `cropper` flag and the `reservedFields` paths |
|
|
136
|
+
| `src/identifiers.ts` | The `[role]::[module]::[name]` format: builds identifiers, parses them and checks each segment |
|
|
137
|
+
| `src/definitions.ts` | Reads a `registerActions` literal into the frozen definition the registry stores; grant restrictions |
|
|
130
138
|
| `src/registry.ts` | Module, name, role and grant builders; registers actions, grants and hooks |
|
|
131
|
-
| `src/resolve.ts` |
|
|
139
|
+
| `src/resolve.ts` | Identity checks and access resolution, shared by `validate` and `permissions.forUser` |
|
|
132
140
|
| `src/seal.ts` | Checks cross-references and orphan hooks, then materializes frozen views; idempotent |
|
|
133
141
|
| `src/permissions.ts` | `permissions.named` and `permissions.forUser` |
|
|
134
|
-
| `src/
|
|
142
|
+
| `src/properties.ts` | Walks `data`, checks each path against the permission and denies the disallowed ones, or crops them when `cropper` is on |
|
|
135
143
|
| `src/validate.ts` | Runs request validation and hooks; sole owner of the `{ result, errors }` format |
|
|
136
144
|
| `src/cli/generate.ts` | Resolves the config, reads the catalog from the child process, renders and writes the declaration; `main` handles the CLI |
|
|
137
145
|
| `src/cli/child.ts` | Imports the config and prints the role catalog on stdout |
|
|
@@ -145,7 +153,7 @@ The registry lives in `globalThis[Symbol.for('endpoint-permissions-kit')]`, so t
|
|
|
145
153
|
|
|
146
154
|
- No framework adapters: you call `validate()` from your own handlers or middleware.
|
|
147
155
|
- No database access: your application loads assignments from a trusted source and passes them in.
|
|
148
|
-
- No route discovery: the route decides which module
|
|
156
|
+
- No route discovery: the route decides which module and method it protects.
|
|
149
157
|
- No session management: `role` and `permissions` come from your authenticated session.
|
|
150
158
|
- No hot reload: changes to a sealed registry require restarting the process.
|
|
151
159
|
|
package/bin/pkit.mjs
CHANGED