endpoint-permissions-kit 0.2.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 CHANGED
@@ -66,6 +66,7 @@ Until you run `pkit generate`, TypeScript only knows the role `general`; the rol
66
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.
67
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
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.
69
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.
70
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.
71
72
 
@@ -131,7 +132,7 @@ The registry lives in `globalThis[Symbol.for('endpoint-permissions-kit')]`, so t
131
132
  | `src/constants.ts` | Available methods, the `general` role, the global hook owner marker and the field wildcard |
132
133
  | `src/errors.ts` | Creates `PkitError` exceptions with a `code` and renders untrusted values for their messages |
133
134
  | `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/context.ts` | Declares and reads the role catalog, the `cropper` flag and the `reservedFields` paths |
135
136
  | `src/identifiers.ts` | The `[role]::[module]::[name]` format: builds identifiers, parses them and checks each segment |
136
137
  | `src/definitions.ts` | Reads a `registerActions` literal into the frozen definition the registry stores; grant restrictions |
137
138
  | `src/registry.ts` | Module, name, role and grant builders; registers actions, grants and hooks |
@@ -120,13 +120,216 @@ function invalidIdentifier(value, code, subject) {
120
120
  }
121
121
  var identifiers_default = identifiers;
122
122
 
123
+ // src/properties.ts
124
+ var PRUNED = Symbol("pruned");
125
+ var MAX_DEPTH = 1000;
126
+ var properties = {
127
+ checkDeclaredPath(property, permissionPath) {
128
+ if (property.includes("[") || property.includes("]")) {
129
+ throw errors_default.create("INVALID_DEFINITION", `${permissionPath}: array indexes are not allowed in properties: "${property}"`);
130
+ }
131
+ const segments = segmentsOf(property);
132
+ for (const segment of segments) {
133
+ if (segment.length === 0)
134
+ throw errors_default.create("INVALID_DEFINITION", `${permissionPath}: empty segment in property path: "${property}"`);
135
+ }
136
+ if (segments[0] === constants_default.ALL_FIELDS) {
137
+ throw errors_default.create("INVALID_DEFINITION", `${permissionPath}: a property path cannot start with "${constants_default.ALL_FIELDS}": "${property}"`);
138
+ }
139
+ },
140
+ resolve(data, allowed, reserved, cropper) {
141
+ if (allowed === constants_default.ALL_FIELDS)
142
+ return data;
143
+ const patterns = [];
144
+ for (const pattern of allowed.concat(reserved))
145
+ patterns.push(segmentsOf(pattern));
146
+ const walk = { patterns, segments: [], ancestors: new Set([data]) };
147
+ if (cropper)
148
+ return cropData(data, walk);
149
+ rejectForbiddenPaths(data, walk);
150
+ return data;
151
+ }
152
+ };
153
+ function segmentsOf(path) {
154
+ const segments = [];
155
+ let segment = "";
156
+ let index = 0;
157
+ while (index < path.length) {
158
+ const character = path[index];
159
+ if (character === "\\") {
160
+ segment += path[index + 1] ?? "";
161
+ index += 2;
162
+ continue;
163
+ }
164
+ if (character === ".") {
165
+ segments.push(segment);
166
+ segment = "";
167
+ index += 1;
168
+ continue;
169
+ }
170
+ segment += character;
171
+ index += 1;
172
+ }
173
+ segments.push(segment);
174
+ return segments;
175
+ }
176
+ function isAllowedPath(segments, patterns) {
177
+ for (const pattern of patterns) {
178
+ if (pattern.length !== segments.length)
179
+ continue;
180
+ let isMatch = true;
181
+ for (let index = 0;index < segments.length; index += 1) {
182
+ const patternSegment = pattern[index];
183
+ if (patternSegment === constants_default.ALL_FIELDS)
184
+ continue;
185
+ if (patternSegment === segments[index])
186
+ continue;
187
+ isMatch = false;
188
+ break;
189
+ }
190
+ if (isMatch)
191
+ return true;
192
+ }
193
+ return false;
194
+ }
195
+ function isPlainObject(value) {
196
+ if (value === null || typeof value !== "object" || Array.isArray(value))
197
+ return false;
198
+ const prototype = Object.getPrototypeOf(value);
199
+ return prototype === Object.prototype || prototype === null;
200
+ }
201
+ function requireDepth(walk) {
202
+ if (walk.ancestors.size < MAX_DEPTH)
203
+ return;
204
+ throw new RangeError(`data is nested deeper than ${MAX_DEPTH} levels`);
205
+ }
206
+ function setField(target, key, value) {
207
+ if (key === "__proto__") {
208
+ Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
209
+ return;
210
+ }
211
+ target[key] = value;
212
+ }
213
+ function cropValue(value, walk) {
214
+ if (Array.isArray(value)) {
215
+ if (value.length === 0)
216
+ return isAllowedPath(walk.segments, walk.patterns) ? [] : PRUNED;
217
+ if (walk.ancestors.has(value))
218
+ throw new RangeError("data contains a circular reference");
219
+ requireDepth(walk);
220
+ walk.ancestors.add(value);
221
+ const items = [];
222
+ for (const item of value) {
223
+ const croppedItem = cropValue(item, walk);
224
+ if (croppedItem === PRUNED)
225
+ continue;
226
+ items.push(croppedItem);
227
+ }
228
+ walk.ancestors.delete(value);
229
+ return items.length === 0 ? PRUNED : items;
230
+ }
231
+ if (isPlainObject(value)) {
232
+ const container = value;
233
+ const keys = Object.keys(container);
234
+ if (keys.length === 0)
235
+ return isAllowedPath(walk.segments, walk.patterns) ? {} : PRUNED;
236
+ if (walk.ancestors.has(container))
237
+ throw new RangeError("data contains a circular reference");
238
+ requireDepth(walk);
239
+ walk.ancestors.add(container);
240
+ const cropped = {};
241
+ let keptCount = 0;
242
+ for (const key of keys) {
243
+ walk.segments.push(key);
244
+ const croppedValue = cropValue(container[key], walk);
245
+ walk.segments.pop();
246
+ if (croppedValue === PRUNED)
247
+ continue;
248
+ setField(cropped, key, croppedValue);
249
+ keptCount += 1;
250
+ }
251
+ walk.ancestors.delete(container);
252
+ return keptCount === 0 ? PRUNED : cropped;
253
+ }
254
+ return isAllowedPath(walk.segments, walk.patterns) ? value : PRUNED;
255
+ }
256
+ function cropData(data, walk) {
257
+ const cropped = {};
258
+ for (const key of Object.keys(data)) {
259
+ walk.segments.push(key);
260
+ const croppedValue = cropValue(data[key], walk);
261
+ walk.segments.pop();
262
+ if (croppedValue === PRUNED)
263
+ continue;
264
+ setField(cropped, key, croppedValue);
265
+ }
266
+ return cropped;
267
+ }
268
+ function collectForbiddenPaths(value, walk, forbidden) {
269
+ if (Array.isArray(value)) {
270
+ if (value.length === 0 || walk.ancestors.has(value)) {
271
+ reportPath(walk, forbidden);
272
+ return;
273
+ }
274
+ requireDepth(walk);
275
+ walk.ancestors.add(value);
276
+ for (const item of value)
277
+ collectForbiddenPaths(item, walk, forbidden);
278
+ walk.ancestors.delete(value);
279
+ return;
280
+ }
281
+ if (isPlainObject(value)) {
282
+ const container = value;
283
+ const keys = Object.keys(container);
284
+ if (keys.length === 0 || walk.ancestors.has(container)) {
285
+ reportPath(walk, forbidden);
286
+ return;
287
+ }
288
+ requireDepth(walk);
289
+ walk.ancestors.add(container);
290
+ for (const key of keys) {
291
+ walk.segments.push(key);
292
+ collectForbiddenPaths(container[key], walk, forbidden);
293
+ walk.segments.pop();
294
+ }
295
+ walk.ancestors.delete(container);
296
+ return;
297
+ }
298
+ reportPath(walk, forbidden);
299
+ }
300
+ function reportPath(walk, forbidden) {
301
+ if (isAllowedPath(walk.segments, walk.patterns))
302
+ return;
303
+ forbidden.add(walk.segments.join("."));
304
+ }
305
+ function rejectForbiddenPaths(data, walk) {
306
+ const forbidden = new Set;
307
+ for (const key of Object.keys(data)) {
308
+ walk.segments.push(key);
309
+ collectForbiddenPaths(data[key], walk, forbidden);
310
+ walk.segments.pop();
311
+ }
312
+ if (forbidden.size === 0)
313
+ return;
314
+ const fields = [...forbidden];
315
+ const error = errors_default.create("PROPERTIES_NOT_ALLOWED", `fields not allowed: ${fields.join(", ")}`);
316
+ throw Object.assign(error, { fields });
317
+ }
318
+ var properties_default = properties;
319
+
123
320
  // src/state.ts
124
321
  var STATE_KEY = Symbol.for("endpoint-permissions-kit");
125
322
  var state = {
126
323
  getOrCreate() {
127
324
  const stateHost = globalThis;
128
325
  if (stateHost[STATE_KEY] === undefined) {
129
- stateHost[STATE_KEY] = { roles: new Set([constants_default.GENERAL_ROLE]), cropper: false, modules: new Map, snapshot: null };
326
+ stateHost[STATE_KEY] = {
327
+ roles: new Set([constants_default.GENERAL_ROLE]),
328
+ cropper: false,
329
+ reservedFields: [],
330
+ modules: new Map,
331
+ snapshot: null
332
+ };
130
333
  }
131
334
  return stateHost[STATE_KEY];
132
335
  },
@@ -155,16 +358,23 @@ var context = Object.freeze({
155
358
  currentState.cropper = value;
156
359
  return;
157
360
  }
361
+ if (contextKey === "reservedFields") {
362
+ currentState.reservedFields = Object.freeze(readReservedFields(value));
363
+ return;
364
+ }
158
365
  currentState.roles = new Set(readRoleCatalog(value, currentState.modules.size));
159
366
  },
160
367
  get(key) {
161
368
  const contextKey = checkKey(key);
162
369
  const currentState = state_default.getOrCreate();
163
- return contextKey === "cropper" ? currentState.cropper : [...currentState.roles];
370
+ if (contextKey === "cropper")
371
+ return currentState.cropper;
372
+ const paths = contextKey === "roles" ? [...currentState.roles] : [...currentState.reservedFields];
373
+ return paths;
164
374
  }
165
375
  });
166
376
  function checkKey(key) {
167
- if (key === "roles" || key === "cropper")
377
+ if (key === "roles" || key === "cropper" || key === "reservedFields")
168
378
  return key;
169
379
  throw errors_default.create("INVALID_DEFINITION", `unknown context key: "${errors_default.describe(key)}"`);
170
380
  }
@@ -186,6 +396,18 @@ function readRoleCatalog(value, registeredModuleCount) {
186
396
  identifiers_default.checkRole(role);
187
397
  return catalog;
188
398
  }
399
+ function readReservedFields(value) {
400
+ if (!Array.isArray(value))
401
+ throw errors_default.create("INVALID_DEFINITION", "reservedFields must be an array of property paths");
402
+ const fields = [];
403
+ for (const field of value) {
404
+ if (typeof field !== "string")
405
+ throw errors_default.create("INVALID_DEFINITION", "reservedFields must be an array of property paths");
406
+ properties_default.checkDeclaredPath(field, "reservedFields");
407
+ fields.push(field);
408
+ }
409
+ return fields;
410
+ }
189
411
  var context_default = context;
190
412
 
191
413
  // src/resolve.ts
@@ -317,202 +539,6 @@ var permissions = Object.freeze({
317
539
  });
318
540
  var permissions_default = permissions;
319
541
 
320
- // src/properties.ts
321
- var PRUNED = Symbol("pruned");
322
- var MAX_DEPTH = 1000;
323
- var properties = {
324
- checkDeclaredPath(property, permissionPath) {
325
- if (property.includes("[") || property.includes("]")) {
326
- throw errors_default.create("INVALID_DEFINITION", `${permissionPath}: array indexes are not allowed in properties: "${property}"`);
327
- }
328
- const segments = segmentsOf(property);
329
- for (const segment of segments) {
330
- if (segment.length === 0)
331
- throw errors_default.create("INVALID_DEFINITION", `${permissionPath}: empty segment in property path: "${property}"`);
332
- }
333
- if (segments[0] === constants_default.ALL_FIELDS) {
334
- throw errors_default.create("INVALID_DEFINITION", `${permissionPath}: a property path cannot start with "${constants_default.ALL_FIELDS}": "${property}"`);
335
- }
336
- },
337
- resolve(data, allowed, cropper) {
338
- if (allowed === constants_default.ALL_FIELDS)
339
- return data;
340
- const patterns = [];
341
- for (const pattern of allowed)
342
- patterns.push(segmentsOf(pattern));
343
- const walk = { patterns, segments: [], ancestors: new Set([data]) };
344
- if (cropper)
345
- return cropData(data, walk);
346
- rejectForbiddenPaths(data, walk);
347
- return data;
348
- }
349
- };
350
- function segmentsOf(path) {
351
- const segments = [];
352
- let segment = "";
353
- let index = 0;
354
- while (index < path.length) {
355
- const character = path[index];
356
- if (character === "\\") {
357
- segment += path[index + 1] ?? "";
358
- index += 2;
359
- continue;
360
- }
361
- if (character === ".") {
362
- segments.push(segment);
363
- segment = "";
364
- index += 1;
365
- continue;
366
- }
367
- segment += character;
368
- index += 1;
369
- }
370
- segments.push(segment);
371
- return segments;
372
- }
373
- function isAllowedPath(segments, patterns) {
374
- for (const pattern of patterns) {
375
- if (pattern.length !== segments.length)
376
- continue;
377
- let isMatch = true;
378
- for (let index = 0;index < segments.length; index += 1) {
379
- const patternSegment = pattern[index];
380
- if (patternSegment === constants_default.ALL_FIELDS)
381
- continue;
382
- if (patternSegment === segments[index])
383
- continue;
384
- isMatch = false;
385
- break;
386
- }
387
- if (isMatch)
388
- return true;
389
- }
390
- return false;
391
- }
392
- function isPlainObject(value) {
393
- if (value === null || typeof value !== "object" || Array.isArray(value))
394
- return false;
395
- const prototype = Object.getPrototypeOf(value);
396
- return prototype === Object.prototype || prototype === null;
397
- }
398
- function requireDepth(walk) {
399
- if (walk.ancestors.size < MAX_DEPTH)
400
- return;
401
- throw new RangeError(`data is nested deeper than ${MAX_DEPTH} levels`);
402
- }
403
- function setField(target, key, value) {
404
- if (key === "__proto__") {
405
- Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
406
- return;
407
- }
408
- target[key] = value;
409
- }
410
- function cropValue(value, walk) {
411
- if (Array.isArray(value)) {
412
- if (value.length === 0)
413
- return isAllowedPath(walk.segments, walk.patterns) ? [] : PRUNED;
414
- if (walk.ancestors.has(value))
415
- throw new RangeError("data contains a circular reference");
416
- requireDepth(walk);
417
- walk.ancestors.add(value);
418
- const items = [];
419
- for (const item of value) {
420
- const croppedItem = cropValue(item, walk);
421
- if (croppedItem === PRUNED)
422
- continue;
423
- items.push(croppedItem);
424
- }
425
- walk.ancestors.delete(value);
426
- return items.length === 0 ? PRUNED : items;
427
- }
428
- if (isPlainObject(value)) {
429
- const container = value;
430
- const keys = Object.keys(container);
431
- if (keys.length === 0)
432
- return isAllowedPath(walk.segments, walk.patterns) ? {} : PRUNED;
433
- if (walk.ancestors.has(container))
434
- throw new RangeError("data contains a circular reference");
435
- requireDepth(walk);
436
- walk.ancestors.add(container);
437
- const cropped = {};
438
- let keptCount = 0;
439
- for (const key of keys) {
440
- walk.segments.push(key);
441
- const croppedValue = cropValue(container[key], walk);
442
- walk.segments.pop();
443
- if (croppedValue === PRUNED)
444
- continue;
445
- setField(cropped, key, croppedValue);
446
- keptCount += 1;
447
- }
448
- walk.ancestors.delete(container);
449
- return keptCount === 0 ? PRUNED : cropped;
450
- }
451
- return isAllowedPath(walk.segments, walk.patterns) ? value : PRUNED;
452
- }
453
- function cropData(data, walk) {
454
- const cropped = {};
455
- for (const key of Object.keys(data)) {
456
- walk.segments.push(key);
457
- const croppedValue = cropValue(data[key], walk);
458
- walk.segments.pop();
459
- if (croppedValue === PRUNED)
460
- continue;
461
- setField(cropped, key, croppedValue);
462
- }
463
- return cropped;
464
- }
465
- function collectForbiddenPaths(value, walk, forbidden) {
466
- if (Array.isArray(value)) {
467
- if (value.length === 0 || walk.ancestors.has(value)) {
468
- reportPath(walk, forbidden);
469
- return;
470
- }
471
- requireDepth(walk);
472
- walk.ancestors.add(value);
473
- for (const item of value)
474
- collectForbiddenPaths(item, walk, forbidden);
475
- walk.ancestors.delete(value);
476
- return;
477
- }
478
- if (isPlainObject(value)) {
479
- const container = value;
480
- const keys = Object.keys(container);
481
- if (keys.length === 0 || walk.ancestors.has(container)) {
482
- reportPath(walk, forbidden);
483
- return;
484
- }
485
- requireDepth(walk);
486
- walk.ancestors.add(container);
487
- for (const key of keys) {
488
- walk.segments.push(key);
489
- collectForbiddenPaths(container[key], walk, forbidden);
490
- walk.segments.pop();
491
- }
492
- walk.ancestors.delete(container);
493
- return;
494
- }
495
- reportPath(walk, forbidden);
496
- }
497
- function reportPath(walk, forbidden) {
498
- if (isAllowedPath(walk.segments, walk.patterns))
499
- return;
500
- forbidden.add(walk.segments.join("."));
501
- }
502
- function rejectForbiddenPaths(data, walk) {
503
- const forbidden = new Set;
504
- for (const key of Object.keys(data)) {
505
- walk.segments.push(key);
506
- collectForbiddenPaths(data[key], walk, forbidden);
507
- walk.segments.pop();
508
- }
509
- if (forbidden.size === 0)
510
- return;
511
- const fields = [...forbidden];
512
- throw Object.assign(errors_default.create("PROPERTIES_NOT_ALLOWED", `fields not allowed: ${fields.join(", ")}`), { fields });
513
- }
514
- var properties_default = properties;
515
-
516
542
  // src/definitions.ts
517
543
  var METHODS = new Set(constants_default.METHODS);
518
544
  var definitions = {
@@ -837,7 +863,8 @@ function prepareRequest(input, currentState) {
837
863
  if (group)
838
864
  hooks.push(...group);
839
865
  }
840
- return { hooks, context, permissions, data: properties_default.resolve(data, access.properties, currentState.cropper) };
866
+ const allowedData = properties_default.resolve(data, access.properties, currentState.reservedFields, currentState.cropper);
867
+ return { hooks, context, permissions, data: allowedData };
841
868
  }
842
869
  function readOptionalObject(value, message) {
843
870
  if (value === undefined)
@@ -876,4 +903,4 @@ var defineModule = registry_default.defineModule;
876
903
  var pkit = Object.freeze({ context: context_default, module: defineModule, seal, permissions: permissions_default, validate });
877
904
  var src_default = pkit;
878
905
 
879
- //# debugId=1BE6B3EEE711E91164756E2164756E21
906
+ //# debugId=276C6E13E676044E64756E2164756E21