@api-client/core 0.20.1 → 0.20.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@api-client/core",
3
3
  "description": "The API Client's core client library. Works in NodeJS and in a ES enabled browser.",
4
- "version": "0.20.1",
4
+ "version": "0.20.2",
5
5
  "license": "UNLICENSED",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -39,19 +39,20 @@ export interface RuntimeApiModelSchema extends ApiModelSchema {
39
39
  }
40
40
 
41
41
  export interface RuntimeResolvedAction {
42
- entity: ExposedEntity
42
+ exposure: ExposedEntity
43
+ entity: DomainEntity
43
44
  action: Action
44
45
  params: Record<string, string>
45
46
  }
46
47
 
47
48
  export type RuleEvaluator = (rule: AccessRule) => Promise<boolean | undefined> | boolean | undefined
48
49
 
49
- interface PhaseRules {
50
+ export interface PhaseRules {
50
51
  permissionRules: AccessRule[]
51
52
  mandatoryRules: AccessRule[]
52
53
  }
53
54
 
54
- interface ActionRulesCache {
55
+ export interface ActionRulesCache {
55
56
  preFetch: PhaseRules
56
57
  fetch: PhaseRules
57
58
  postFetch: PhaseRules
@@ -281,17 +282,23 @@ export class RuntimeApiModel extends ApiModel {
281
282
 
282
283
  const params = exec(path, matchedRoute)
283
284
 
284
- const entity = this.exposes.get(def.lookup.exposedEntityKey)
285
- if (!entity) {
286
- return undefined
285
+ const exposure = this.exposes.get(def.lookup.exposedEntityKey)
286
+ if (!exposure) {
287
+ throw new Exception('Missing exposure ' + def.lookup.exposedEntityKey, { code: 'API_MODEL_ERROR', status: 500 })
287
288
  }
288
289
 
289
- const action = entity.actions.find((a) => a.kind === def.lookup.actionKind)
290
+ const action = exposure.actions.find((a) => a.kind === def.lookup.actionKind)
290
291
  if (!action) {
291
- return undefined
292
+ throw new Exception('Missing action ' + def.lookup.actionKind, { code: 'API_MODEL_ERROR', status: 500 })
293
+ }
294
+
295
+ const entity = this.domain?.findEntity(exposure.entity.key, exposure.entity.domain)
296
+ if (!entity) {
297
+ throw new Exception('Missing entity ' + exposure.entity.key, { code: 'API_MODEL_ERROR', status: 500 })
292
298
  }
293
299
 
294
300
  return {
301
+ exposure,
295
302
  entity,
296
303
  action,
297
304
  params,
@@ -299,64 +306,15 @@ export class RuntimeApiModel extends ApiModel {
299
306
  }
300
307
 
301
308
  /**
302
- * Evaluates access rules for a given action and phase.
303
- *
304
- * The evaluation process follows two phases per execution phase (PRE_FETCH, FETCH, POST_FETCH):
305
- * 1. Mandatory Phase: All rules marked as `mandatory: true` across all levels must return true.
306
- * If any fail, the request is immediately rejected.
307
- * 2. Permission Phase: Evaluation follows the hierarchy from most specific to most general:
308
- * Action -> Endpoint (ExposedEntity) -> API Model.
309
- * If an explicit allow (true) or deny (false) is hit, evaluation stops and returns the result.
310
- * If no rules match (all return undefined), the request is rejected by default.
311
- *
312
- * @param action The resolved action to evaluate.
313
- * @param evaluator A callback that evaluates a single rule. Should return true (allow),
314
- * false (deny), or undefined (no hit).
315
- * @param phase The execution phase to evaluate.
316
- * @returns A promise that resolves to true if access is granted, false if denied.
309
+ * Retrieves the precomputed and shadowed effective rules for a given action.
317
310
  */
318
- async evaluateAccess(
319
- action: RuntimeResolvedAction,
320
- evaluator: RuleEvaluator,
321
- phase: AccessRuleExecutionPhase
322
- ): Promise<boolean> {
311
+ getEffectiveRules(action: RuntimeResolvedAction): ActionRulesCache {
323
312
  let cachedRules = this.#actionRulesCache.get(action.action)
324
313
  if (!cachedRules) {
325
- // Fallback if somehow action is not cached (e.g. dynamically added after initialization or in tests)
326
- cachedRules = this.#computeEffectiveRules(action.action, action.entity)
314
+ cachedRules = this.#computeEffectiveRules(action.action, action.exposure)
327
315
  this.#actionRulesCache.set(action.action, cachedRules)
328
316
  }
329
-
330
- let rulesForPhase
331
- if (phase === AccessRuleExecutionPhase.POST_FETCH) {
332
- rulesForPhase = cachedRules.postFetch
333
- } else if (phase === AccessRuleExecutionPhase.FETCH) {
334
- rulesForPhase = cachedRules.fetch
335
- } else {
336
- rulesForPhase = cachedRules.preFetch
337
- }
338
-
339
- // Step 1: Mandatory Phase
340
- for (const rule of rulesForPhase.mandatoryRules) {
341
- const result = await evaluator(rule)
342
- if (result !== true) {
343
- return false // Immediately reject if any mandatory rule fails
344
- }
345
- }
346
-
347
- // Step 2-4: Permission Phase
348
- for (const rule of rulesForPhase.permissionRules) {
349
- const result = await evaluator(rule)
350
- if (result === true) {
351
- return true
352
- }
353
- if (result === false) {
354
- return false
355
- }
356
- }
357
-
358
- // Default: no hit
359
- return false
317
+ return cachedRules
360
318
  }
361
319
 
362
320
  override toJSON(): RuntimeApiModelSchema {
@@ -10,11 +10,10 @@ test.group('RuntimeApiModel', () => {
10
10
  test('initializes from schema', ({ assert }) => {
11
11
  const domain = new DataDomain({ info: { version: '1.0.0' } })
12
12
  const model = domain.addModel({ info: { name: 'Test Model' } })
13
- const entity = model.addEntity({ key: 'ent-1', info: { name: 'User' } })
13
+ const entity = model.addEntity({ info: { name: 'User' } })
14
14
 
15
15
  const baseModel = new ApiModel(
16
16
  {
17
- key: 'api-1',
18
17
  info: { name: 'Test API' },
19
18
  },
20
19
  domain
@@ -37,13 +36,15 @@ test.group('RuntimeApiModel', () => {
37
36
 
38
37
  test('resolves path with matchit', ({ assert }) => {
39
38
  const domain = new DataDomain({ info: { version: '1.0.0' } })
39
+ const model = domain.addModel({ info: { name: 'Test Model' } })
40
+ const entity = model.addEntity({ info: { name: 'User' } })
40
41
  const schema = {
41
42
  key: 'api-1',
42
43
  info: { name: 'Test API' },
43
44
  exposes: [
44
45
  {
45
46
  key: 'expose-1',
46
- entity: { key: 'ent-1', type: 'association' },
47
+ entity: { key: entity.key, domain: domain.key },
47
48
  actions: [
48
49
  { kind: 'read', type: 'crud' },
49
50
  { kind: 'list', type: 'crud' },
@@ -92,7 +93,7 @@ test.group('RuntimeApiModel', () => {
92
93
  assert.isUndefined(getResult)
93
94
  }).tags(['@modeling', '@runtime'])
94
95
 
95
- test('returns undefined when entity or action is missing', ({ assert }) => {
96
+ test('throws error when entity or action is missing', ({ assert }) => {
96
97
  const domain = new DataDomain({ info: { version: '1.0.0' } })
97
98
  const schema = {
98
99
  key: 'api-1',
@@ -115,12 +116,14 @@ test.group('RuntimeApiModel', () => {
115
116
  const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
116
117
 
117
118
  // Exposed entity not found
118
- const missingEntityResult = runtimeModel.lookupAction('GET', '/missing-entity')
119
- assert.isUndefined(missingEntityResult)
119
+ assert.throws(() => {
120
+ runtimeModel.lookupAction('GET', '/missing-entity')
121
+ }, 'Missing exposure missing-expose')
120
122
 
121
123
  // Action not found on entity
122
- const missingActionResult = runtimeModel.lookupAction('GET', '/missing-action')
123
- assert.isUndefined(missingActionResult)
124
+ assert.throws(() => {
125
+ runtimeModel.lookupAction('GET', '/missing-action')
126
+ }, 'Missing action read')
124
127
  }).tags(['@modeling', '@runtime'])
125
128
 
126
129
  test('caches user entity and properties based on semantics', ({ assert }) => {
@@ -211,7 +214,7 @@ test.group('RuntimeApiModel', () => {
211
214
  assert.equal(runtimeModel.sessionProperties.size, 0)
212
215
  }).tags(['@modeling', '@runtime'])
213
216
 
214
- test('evaluateAccess - rejects if any mandatory rule fails', async ({ assert }) => {
217
+ test('getEffectiveRules - separates mandatory and permission rules', async ({ assert }) => {
215
218
  const domain = new DataDomain({ info: { version: '1.0.0' } })
216
219
  const baseModel = new ApiModel(
217
220
  { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
@@ -220,40 +223,41 @@ test.group('RuntimeApiModel', () => {
220
223
  const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
221
224
 
222
225
  const mockAction = {
223
- entity: { accessRule: [], parent: undefined },
226
+ exposure: { accessRule: [], parent: undefined },
224
227
  action: { kind: 'read', accessRule: [] },
225
228
  } as any
226
229
 
227
- const evaluator: RuleEvaluator = async (_rule: AccessRule): Promise<boolean> => false // Mandatory rule fails
228
- const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
229
- assert.isFalse(result)
230
+ const rules = runtimeModel.getEffectiveRules(mockAction)
231
+ assert.equal(rules.preFetch.mandatoryRules.length, 1)
232
+ assert.equal(rules.preFetch.mandatoryRules[0].type, 'allowAuthenticated')
233
+ assert.equal(rules.preFetch.permissionRules.length, 0)
230
234
  }).tags(['@modeling', '@runtime', '@authorization'])
231
235
 
232
- test('evaluateAccess - rejects by default if no rules hit', async ({ assert }) => {
236
+ test('getEffectiveRules - defaults to preFetch phase if none specified', async ({ assert }) => {
233
237
  const domain = new DataDomain({ info: { version: '1.0.0' } })
234
238
  const baseModel = new ApiModel({ key: 'api-1' }, domain)
235
239
  const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
236
240
 
237
241
  const mockAction = {
238
- entity: {
242
+ exposure: {
239
243
  accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
240
244
  parent: undefined,
241
245
  },
242
246
  action: { kind: 'read', accessRule: [] },
243
247
  } as any
244
248
 
245
- const evaluator: RuleEvaluator = async (_rule: AccessRule): Promise<undefined> => undefined // No rules match
246
- const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
247
- assert.isFalse(result)
249
+ const rules = runtimeModel.getEffectiveRules(mockAction)
250
+ assert.equal(rules.preFetch.permissionRules.length, 1)
251
+ assert.equal(rules.preFetch.permissionRules[0].type, 'allowPublic')
248
252
  }).tags(['@modeling', '@runtime', '@authorization'])
249
253
 
250
- test('evaluateAccess - evaluates from most specific to most general in permission phase', async ({ assert }) => {
254
+ test('getEffectiveRules - orders from most specific to most general', async ({ assert }) => {
251
255
  const domain = new DataDomain({ info: { version: '1.0.0' } })
252
- const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
256
+ const baseModel = new ApiModel({ accessRule: [{ type: 'allowAuthenticated' }] }, domain)
253
257
  const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
254
258
 
255
259
  const mockAction = {
256
- entity: {
260
+ exposure: {
257
261
  accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
258
262
  parent: undefined,
259
263
  },
@@ -263,77 +267,19 @@ test.group('RuntimeApiModel', () => {
263
267
  },
264
268
  } as any
265
269
 
266
- const evaluatedOrder: string[] = []
267
- const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<undefined> => {
268
- evaluatedOrder.push(rule.type)
269
- return undefined // Continue to next rule
270
- }
271
-
272
- await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
273
- assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic', 'allowAuthenticated'])
274
- }).tags(['@modeling', '@runtime', '@authorization'])
275
-
276
- test('evaluateAccess - short-circuits on explicit allow or deny in permission phase', async ({ assert }) => {
277
- const domain = new DataDomain({ info: { version: '1.0.0' } })
278
- const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
279
- const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
280
-
281
- const mockAction = {
282
- entity: {
283
- accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
284
- parent: undefined,
285
- },
286
- action: {
287
- kind: 'read',
288
- accessRule: [{ type: 'matchEmailDomain', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
289
- },
290
- } as any
291
-
292
- const evaluatedOrder: string[] = []
293
- const evaluator = async (rule: any) => {
294
- evaluatedOrder.push(rule.type)
295
- if (rule.type === 'allowPublic') return true
296
- return undefined
297
- }
298
-
299
- const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
300
- assert.isTrue(result)
301
- // Should not reach the api-rule
302
- assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic'])
303
- }).tags(['@modeling', '@runtime', '@authorization'])
304
-
305
- test('evaluateAccess - passes mandatory phase and uses permission phase result', async ({ assert }) => {
306
- const domain = new DataDomain({ info: { version: '1.0.0' } })
307
- const baseModel = new ApiModel(
308
- { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
309
- domain
270
+ const rules = runtimeModel.getEffectiveRules(mockAction)
271
+ assert.equal(rules.preFetch.permissionRules.length, 3)
272
+ assert.deepEqual(
273
+ rules.preFetch.permissionRules.map((r) => r.type),
274
+ ['matchEmailDomain', 'allowPublic', 'allowAuthenticated']
310
275
  )
311
- const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
312
-
313
- const mockAction = {
314
- entity: {
315
- accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
316
- parent: undefined,
317
- },
318
- action: { kind: 'read', accessRule: [] },
319
- } as any
320
-
321
- const evaluator = async (rule: any) => {
322
- if (rule.mandatory) return true // passes mandatory check
323
- if (rule.type === 'allowPublic') return false // explicitly denies
324
- return undefined
325
- }
326
-
327
- const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
328
- assert.isFalse(result) // Denied by permission rule
329
276
  }).tags(['@modeling', '@runtime', '@authorization'])
330
277
 
331
- test('evaluateAccess - shadows parent rules of the same type', async ({ assert }) => {
278
+ test('getEffectiveRules - shadows parent rules of the same type', async ({ assert }) => {
332
279
  const domain = new DataDomain({ info: { version: '1.0.0' } })
333
280
  const model = domain.addModel({ key: 'mod-1' })
334
281
  const entity = model.addEntity({ key: 'ent-1' })
335
282
 
336
- // Create base model with exposing and actions directly in schema
337
283
  const baseModel = new ApiModel(
338
284
  {
339
285
  key: 'api-1',
@@ -361,19 +307,6 @@ test.group('RuntimeApiModel', () => {
361
307
  domain
362
308
  )
363
309
 
364
- // Hierarchy will be:
365
- // Action: allowAuthenticated (mandatory)
366
- // Entity: allowPublic, matchEmailDomain (mandatory)
367
- // API: allowAuthenticated, matchEmailDomain
368
-
369
- // Effective Rules (with shadowing):
370
- // Action's allowAuthenticated (mandatory) shadows API's allowAuthenticated
371
- // Entity's matchEmailDomain (mandatory) shadows API's matchEmailDomain
372
- // Entity's allowPublic is kept
373
-
374
- // Mandatory: allowAuthenticated, matchEmailDomain
375
- // Permission: allowPublic
376
-
377
310
  const schema: RuntimeApiModelSchema = {
378
311
  ...baseModel.toJSON(),
379
312
  routingMap: {
@@ -384,16 +317,15 @@ test.group('RuntimeApiModel', () => {
384
317
  const runtimeModel = new RuntimeApiModel(schema, domain.toJSON())
385
318
  const mockAction = runtimeModel.lookupAction('GET', '/test')!
386
319
 
387
- const evaluatedOrder: string[] = []
388
- const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<boolean | undefined> => {
389
- evaluatedOrder.push(`${rule.type}${rule.mandatory ? '-mandatory' : ''}`)
390
- if (rule.mandatory) return true
391
- return undefined
392
- }
393
-
394
- await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
320
+ const rules = runtimeModel.getEffectiveRules(mockAction)
395
321
 
396
- // Check that we only evaluate the shadowed rules, exactly once per phase
397
- assert.deepEqual(evaluatedOrder, ['allowAuthenticated-mandatory', 'matchEmailDomain-mandatory', 'allowPublic'])
322
+ assert.deepEqual(
323
+ rules.preFetch.mandatoryRules.map((r) => r.type),
324
+ ['allowAuthenticated', 'matchEmailDomain']
325
+ )
326
+ assert.deepEqual(
327
+ rules.preFetch.permissionRules.map((r) => r.type),
328
+ ['allowPublic']
329
+ )
398
330
  }).tags(['@modeling', '@runtime', '@authorization'])
399
331
  })