@grantex/sdk 0.6.0 → 0.7.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.
Files changed (181) hide show
  1. package/LICENSE +185 -185
  2. package/NOTICE +16 -16
  3. package/README.md +974 -947
  4. package/dist/authorization-details.d.ts +86 -0
  5. package/dist/authorization-details.d.ts.map +1 -0
  6. package/dist/authorization-details.js +209 -0
  7. package/dist/authorization-details.js.map +1 -0
  8. package/dist/canonical.d.ts +47 -0
  9. package/dist/canonical.d.ts.map +1 -0
  10. package/dist/canonical.js +163 -0
  11. package/dist/canonical.js.map +1 -0
  12. package/dist/caps/index.d.ts +19 -0
  13. package/dist/caps/index.d.ts.map +1 -0
  14. package/dist/caps/index.js +19 -0
  15. package/dist/caps/index.js.map +1 -0
  16. package/dist/caps/limits.d.ts +40 -0
  17. package/dist/caps/limits.d.ts.map +1 -0
  18. package/dist/caps/limits.js +109 -0
  19. package/dist/caps/limits.js.map +1 -0
  20. package/dist/caps/memory.d.ts +15 -0
  21. package/dist/caps/memory.d.ts.map +1 -0
  22. package/dist/caps/memory.js +57 -0
  23. package/dist/caps/memory.js.map +1 -0
  24. package/dist/caps/meter.d.ts +107 -0
  25. package/dist/caps/meter.d.ts.map +1 -0
  26. package/dist/caps/meter.js +200 -0
  27. package/dist/caps/meter.js.map +1 -0
  28. package/dist/caps/postgres.d.ts +64 -0
  29. package/dist/caps/postgres.d.ts.map +1 -0
  30. package/dist/caps/postgres.js +221 -0
  31. package/dist/caps/postgres.js.map +1 -0
  32. package/dist/caps/redis.d.ts +45 -0
  33. package/dist/caps/redis.d.ts.map +1 -0
  34. package/dist/caps/redis.js +173 -0
  35. package/dist/caps/redis.js.map +1 -0
  36. package/dist/client.d.ts +12 -0
  37. package/dist/client.d.ts.map +1 -1
  38. package/dist/client.js +517 -57
  39. package/dist/client.js.map +1 -1
  40. package/dist/decisions/action.d.ts +51 -0
  41. package/dist/decisions/action.d.ts.map +1 -0
  42. package/dist/decisions/action.js +237 -0
  43. package/dist/decisions/action.js.map +1 -0
  44. package/dist/decisions/index.d.ts +11 -0
  45. package/dist/decisions/index.d.ts.map +1 -0
  46. package/dist/decisions/index.js +8 -0
  47. package/dist/decisions/index.js.map +1 -0
  48. package/dist/decisions/verify.d.ts +103 -0
  49. package/dist/decisions/verify.d.ts.map +1 -0
  50. package/dist/decisions/verify.js +277 -0
  51. package/dist/decisions/verify.js.map +1 -0
  52. package/dist/denials.d.ts +132 -0
  53. package/dist/denials.d.ts.map +1 -0
  54. package/dist/denials.js +129 -0
  55. package/dist/denials.js.map +1 -0
  56. package/dist/evidence/build.d.ts +63 -0
  57. package/dist/evidence/build.d.ts.map +1 -0
  58. package/dist/evidence/build.js +201 -0
  59. package/dist/evidence/build.js.map +1 -0
  60. package/dist/evidence/canonical.d.ts +3 -0
  61. package/dist/evidence/canonical.d.ts.map +1 -0
  62. package/dist/evidence/canonical.js +3 -0
  63. package/dist/evidence/canonical.js.map +1 -0
  64. package/dist/evidence/checks.d.ts +13 -0
  65. package/dist/evidence/checks.d.ts.map +1 -0
  66. package/dist/evidence/checks.js +406 -0
  67. package/dist/evidence/checks.js.map +1 -0
  68. package/dist/evidence/document.d.ts +8 -0
  69. package/dist/evidence/document.d.ts.map +1 -0
  70. package/dist/evidence/document.js +298 -0
  71. package/dist/evidence/document.js.map +1 -0
  72. package/dist/evidence/hashing.d.ts +37 -0
  73. package/dist/evidence/hashing.d.ts.map +1 -0
  74. package/dist/evidence/hashing.js +106 -0
  75. package/dist/evidence/hashing.js.map +1 -0
  76. package/dist/evidence/index.d.ts +24 -0
  77. package/dist/evidence/index.d.ts.map +1 -0
  78. package/dist/evidence/index.js +24 -0
  79. package/dist/evidence/index.js.map +1 -0
  80. package/dist/evidence/result.d.ts +88 -0
  81. package/dist/evidence/result.d.ts.map +1 -0
  82. package/dist/evidence/result.js +88 -0
  83. package/dist/evidence/result.js.map +1 -0
  84. package/dist/evidence/schema-1.0.d.ts +2 -0
  85. package/dist/evidence/schema-1.0.d.ts.map +1 -0
  86. package/dist/evidence/schema-1.0.js +1398 -0
  87. package/dist/evidence/schema-1.0.js.map +1 -0
  88. package/dist/evidence/schema.d.ts +13 -0
  89. package/dist/evidence/schema.d.ts.map +1 -0
  90. package/dist/evidence/schema.js +264 -0
  91. package/dist/evidence/schema.js.map +1 -0
  92. package/dist/evidence/signature.d.ts +23 -0
  93. package/dist/evidence/signature.d.ts.map +1 -0
  94. package/dist/evidence/signature.js +136 -0
  95. package/dist/evidence/signature.js.map +1 -0
  96. package/dist/evidence/trace.d.ts +23 -0
  97. package/dist/evidence/trace.d.ts.map +1 -0
  98. package/dist/evidence/trace.js +69 -0
  99. package/dist/evidence/trace.js.map +1 -0
  100. package/dist/evidence/verify.d.ts +29 -0
  101. package/dist/evidence/verify.d.ts.map +1 -0
  102. package/dist/evidence/verify.js +163 -0
  103. package/dist/evidence/verify.js.map +1 -0
  104. package/dist/http.d.ts +12 -1
  105. package/dist/http.d.ts.map +1 -1
  106. package/dist/http.js +11 -5
  107. package/dist/http.js.map +1 -1
  108. package/dist/index.d.ts +16 -3
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +16 -2
  111. package/dist/index.js.map +1 -1
  112. package/dist/manifest.d.ts +178 -9
  113. package/dist/manifest.d.ts.map +1 -1
  114. package/dist/manifest.js +379 -16
  115. package/dist/manifest.js.map +1 -1
  116. package/dist/purpose.d.ts +29 -0
  117. package/dist/purpose.d.ts.map +1 -0
  118. package/dist/purpose.js +60 -0
  119. package/dist/purpose.js.map +1 -0
  120. package/dist/resources/agents.js +3 -3
  121. package/dist/resources/agents.js.map +1 -1
  122. package/dist/resources/anomalies.d.ts +3 -1
  123. package/dist/resources/anomalies.d.ts.map +1 -1
  124. package/dist/resources/anomalies.js +7 -1
  125. package/dist/resources/anomalies.js.map +1 -1
  126. package/dist/resources/audit.js +1 -1
  127. package/dist/resources/audit.js.map +1 -1
  128. package/dist/resources/budgets.js +2 -2
  129. package/dist/resources/budgets.js.map +1 -1
  130. package/dist/resources/credentials.js +1 -1
  131. package/dist/resources/credentials.js.map +1 -1
  132. package/dist/resources/decisions.d.ts +69 -0
  133. package/dist/resources/decisions.d.ts.map +1 -0
  134. package/dist/resources/decisions.js +115 -0
  135. package/dist/resources/decisions.js.map +1 -0
  136. package/dist/resources/domains.js +2 -2
  137. package/dist/resources/domains.js.map +1 -1
  138. package/dist/resources/dpdp.js +4 -4
  139. package/dist/resources/dpdp.js.map +1 -1
  140. package/dist/resources/grants.js +2 -2
  141. package/dist/resources/grants.js.map +1 -1
  142. package/dist/resources/policies.js +3 -3
  143. package/dist/resources/policies.js.map +1 -1
  144. package/dist/resources/scim.js +5 -5
  145. package/dist/resources/scim.js.map +1 -1
  146. package/dist/resources/tokens.d.ts.map +1 -1
  147. package/dist/resources/tokens.js +5 -1
  148. package/dist/resources/tokens.js.map +1 -1
  149. package/dist/resources/vault.js +2 -2
  150. package/dist/resources/vault.js.map +1 -1
  151. package/dist/resources/webauthn.d.ts +6 -1
  152. package/dist/resources/webauthn.d.ts.map +1 -1
  153. package/dist/resources/webauthn.js +5 -1
  154. package/dist/resources/webauthn.js.map +1 -1
  155. package/dist/resources/webhooks.js +1 -1
  156. package/dist/resources/webhooks.js.map +1 -1
  157. package/dist/revocations/feed.d.ts +61 -0
  158. package/dist/revocations/feed.d.ts.map +1 -0
  159. package/dist/revocations/feed.js +282 -0
  160. package/dist/revocations/feed.js.map +1 -0
  161. package/dist/revocations/index.d.ts +20 -0
  162. package/dist/revocations/index.d.ts.map +1 -0
  163. package/dist/revocations/index.js +19 -0
  164. package/dist/revocations/index.js.map +1 -0
  165. package/dist/revocations/set.d.ts +59 -0
  166. package/dist/revocations/set.d.ts.map +1 -0
  167. package/dist/revocations/set.js +87 -0
  168. package/dist/revocations/set.js.map +1 -0
  169. package/dist/scopes.d.ts +26 -0
  170. package/dist/scopes.d.ts.map +1 -0
  171. package/dist/scopes.js +37 -0
  172. package/dist/scopes.js.map +1 -0
  173. package/dist/types.d.ts +121 -4
  174. package/dist/types.d.ts.map +1 -1
  175. package/dist/types.js +0 -1
  176. package/dist/types.js.map +1 -1
  177. package/dist/verify.d.ts +31 -2
  178. package/dist/verify.d.ts.map +1 -1
  179. package/dist/verify.js +236 -29
  180. package/dist/verify.js.map +1 -1
  181. package/package.json +92 -84
package/dist/client.js CHANGED
@@ -21,15 +21,67 @@ import { CredentialsClient } from './resources/credentials.js';
21
21
  import { PassportsClient } from './resources/passports.js';
22
22
  import { DpdpClient } from './resources/dpdp.js';
23
23
  import { CommerceClient } from './resources/commerce.js';
24
- import { ToolManifest, permissionCovers } from './manifest.js';
24
+ import { CapSubReason, DenialReason, RevocationSubReason, ManifestSubReason, PurposeSubReason, TokenSubReason, ToolSubReason, } from './denials.js';
25
+ import { AuthorizationDetailsError, parseDecisionReferences, parseToolsAuthorization, toolsAuthorizationAllows, } from './authorization-details.js';
26
+ import { isKnownPurpose, matchPurpose } from './purpose.js';
27
+ import { CAPS_MODES, CapExceededError, CapsConfigurationError, MeterUnavailableError, } from './caps/meter.js';
28
+ import { MALFORMED_GRANT_CAPS, buildCapLimits } from './caps/limits.js';
29
+ import { ToolManifest, parseManifestJson, permissionCovers } from './manifest.js';
25
30
  import { verifyGrantToken } from './verify.js';
31
+ import { RevocationFeed, isRevocationCheckMode, } from './revocations/index.js';
32
+ import { DecisionSubReason } from './denials.js';
33
+ import { ActionValidationError, computeActionHash, decisionActionFromToolCall, parseDecisionAction } from './decisions/action.js';
34
+ import { DecisionGrantError, verifyDecisionGrants } from './decisions/verify.js';
35
+ import { DecisionsClient } from './resources/decisions.js';
26
36
  const DEFAULT_BASE_URL = 'https://api.grantex.dev';
37
+ function checkDecisionsMode(mode) {
38
+ if (mode !== 'enforce' && mode !== 'warn') {
39
+ throw new Error(`decisionsMode must be one of enforce, warn, not ${JSON.stringify(mode)}`);
40
+ }
41
+ return mode;
42
+ }
43
+ function checkRevocationCheck(mode) {
44
+ if (!isRevocationCheckMode(mode)) {
45
+ throw new Error(`revocationCheck must be offline, online or feed, not ${JSON.stringify(mode)}`);
46
+ }
47
+ return mode;
48
+ }
49
+ function checkCapsMode(mode) {
50
+ if (!CAPS_MODES.includes(mode)) {
51
+ throw new Error(`capsMode must be one of ${CAPS_MODES.join(', ')}, not ${JSON.stringify(mode)}`);
52
+ }
53
+ return mode;
54
+ }
55
+ /**
56
+ * Throw CapExceededError if reserving `limits` now would exceed a cap. A
57
+ * point-in-time check that consumes nothing: a concurrent call can still take
58
+ * the last unit before the reservation is made.
59
+ */
60
+ async function checkCaps(meter, tenantId, limits) {
61
+ for (const usage of await meter.usage(tenantId, limits)) {
62
+ const limit = usage.limit;
63
+ if (limit.units > 0 && (limit.limit === 0 || usage.used + limit.units > limit.limit)) {
64
+ throw new CapExceededError({
65
+ limit: limit.limit, window: limit.window, used: usage.used, requested: limit.units, scope: limit.scope, kind: limit.kind,
66
+ });
67
+ }
68
+ }
69
+ }
27
70
  export class Grantex {
28
71
  #http;
29
72
  #manifests = new Map();
30
73
  #jwksUri;
31
74
  #issuer;
75
+ #legacyClaims;
32
76
  #enforceMode;
77
+ #capsMeter;
78
+ #capsMode;
79
+ #revocationCheck;
80
+ #revocationFeed;
81
+ #revocationFeedOptions;
82
+ #decisionsMode;
83
+ #decisionConsumer;
84
+ #decisionAlgorithms;
33
85
  agents;
34
86
  grants;
35
87
  tokens;
@@ -52,6 +104,7 @@ export class Grantex {
52
104
  passports;
53
105
  dpdp;
54
106
  commerce;
107
+ decisions;
55
108
  get lastRateLimit() {
56
109
  return this.#http.lastRateLimit;
57
110
  }
@@ -92,7 +145,25 @@ export class Grantex {
92
145
  this.commerce = new CommerceClient(this.#http);
93
146
  this.#jwksUri = options.jwksUri ?? `${normalizedBaseUrl}/.well-known/jwks.json`;
94
147
  this.#issuer = options.issuer;
148
+ this.#legacyClaims = options.legacyClaims;
95
149
  this.#enforceMode = options['enforceMode'] ?? 'strict';
150
+ this.#capsMeter = options.capsMeter;
151
+ this.#capsMode = checkCapsMode(options.capsMode ?? 'enforce');
152
+ this.#revocationCheck = checkRevocationCheck(options.revocationCheck ?? 'offline');
153
+ this.#revocationFeedOptions = options.revocationFeed ?? {};
154
+ if (this.#revocationCheck === 'feed')
155
+ this.#feed().start();
156
+ this.#decisionsMode = checkDecisionsMode(options.decisionsMode ?? 'enforce');
157
+ const algorithms = [...(options.decisionAlgorithms ?? ['RS256', 'ES256'])];
158
+ if (algorithms.length === 0 || algorithms.some((a) => a !== 'RS256' && a !== 'ES256')) {
159
+ throw new Error('decisionAlgorithms must be a non-empty subset of RS256, ES256');
160
+ }
161
+ this.#decisionAlgorithms = algorithms;
162
+ this.decisions = new DecisionsClient(this.#http);
163
+ const decisions = this.decisions;
164
+ this.#decisionConsumer = options.decisionConsumer ?? {
165
+ consume: (grants, consumeOptions) => decisions.consume(grants, consumeOptions ?? {}),
166
+ };
96
167
  }
97
168
  /**
98
169
  * Create a new developer account without an API key.
@@ -165,7 +236,10 @@ export class Grantex {
165
236
  const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.json'));
166
237
  for (const file of files) {
167
238
  const content = fs.readFileSync(path.join(dirPath, file), 'utf-8');
168
- const data = JSON.parse(content);
239
+ const data = parseManifestJson(content);
240
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
241
+ throw new Error(`ToolManifest: a manifest must be a JSON object (${file})`);
242
+ }
169
243
  this.loadManifest(ToolManifest.fromJSON(data));
170
244
  }
171
245
  }
@@ -188,8 +262,97 @@ export class Grantex {
188
262
  * if (!result.allowed) throw new Error(result.reason);
189
263
  * ```
190
264
  */
265
+ /**
266
+ * The revocation feed this client uses for `revocationCheck: 'feed'`,
267
+ * started on first use. Call `stopRevocationFeed()` to close it.
268
+ */
269
+ revocationFeed() {
270
+ return this.#feed();
271
+ }
272
+ /** What the revocation feed knows, and whether it is fresh enough to be trusted. */
273
+ revocationFeedState() {
274
+ return this.#revocationFeed?.state();
275
+ }
276
+ /** Stop following the revocation feed and release its connection. */
277
+ async stopRevocationFeed() {
278
+ await this.#revocationFeed?.stop();
279
+ }
280
+ #feed() {
281
+ if (!this.#revocationFeed) {
282
+ this.#revocationFeed = new RevocationFeed(this.#http, this.#revocationFeedOptions);
283
+ }
284
+ return this.#revocationFeed;
285
+ }
286
+ /**
287
+ * Why this grant must not be used, or undefined. `enforce()` calls this
288
+ * before any other check, because a revoked grant is not a question of
289
+ * scopes.
290
+ */
291
+ async #revocationDenial(mode, ref) {
292
+ if (mode === 'feed') {
293
+ const feed = this.#feed();
294
+ if (!await feed.ready()) {
295
+ const unavailable = feed.state().unavailable;
296
+ return unavailable === null
297
+ ? {
298
+ reason: `The revocation feed has not been reachable for more than ${feed.staleAfterMs} ms, `
299
+ + 'so revocations may not be known; denying rather than authorising on stale information.',
300
+ subReason: RevocationSubReason.FEED_STALE,
301
+ }
302
+ : {
303
+ reason: `The revocation feed is not usable (${unavailable}); denying rather than authorising `
304
+ + 'without a way to learn about revocations.',
305
+ subReason: RevocationSubReason.FEED_UNAVAILABLE,
306
+ };
307
+ }
308
+ const match = feed.match(ref);
309
+ if (!match)
310
+ return undefined;
311
+ const subReason = match.kind === 'parent_grant'
312
+ ? RevocationSubReason.PARENT_REVOKED
313
+ : match.action === 'suspended' ? RevocationSubReason.SUSPENDED : RevocationSubReason.REVOKED;
314
+ return { reason: `Grant ${match.id} is ${match.action === 'suspended' ? 'suspended' : 'revoked'}.`, subReason };
315
+ }
316
+ const query = new URLSearchParams();
317
+ if (ref.grantId !== undefined)
318
+ query.set('grantId', ref.grantId);
319
+ if (ref.tokenId !== undefined)
320
+ query.set('jti', ref.tokenId);
321
+ let status;
322
+ try {
323
+ status = await this.#http.get(`/v1/revocations/status?${query.toString()}`);
324
+ }
325
+ catch (err) {
326
+ return {
327
+ reason: `The revocation status of this grant could not be checked (${err instanceof Error ? err.message : String(err)}); `
328
+ + 'denying rather than authorising without it.',
329
+ subReason: RevocationSubReason.STATUS_UNAVAILABLE,
330
+ };
331
+ }
332
+ if (typeof status !== 'object' || status === null || typeof status.revoked !== 'boolean') {
333
+ return {
334
+ reason: 'The revocation status endpoint returned something this client cannot read; '
335
+ + 'denying rather than assuming the grant is live.',
336
+ subReason: RevocationSubReason.STATUS_UNAVAILABLE,
337
+ };
338
+ }
339
+ if (!status.revoked)
340
+ return undefined;
341
+ if (status.status === 'suspended') {
342
+ return { reason: 'This grant is suspended.', subReason: RevocationSubReason.SUSPENDED };
343
+ }
344
+ if (status.status === 'unknown') {
345
+ return {
346
+ reason: 'The auth service does not recognise this grant; denying rather than assuming it is live.',
347
+ subReason: RevocationSubReason.STATUS_UNAVAILABLE,
348
+ };
349
+ }
350
+ return { reason: `This grant is ${status.status}.`, subReason: RevocationSubReason.REVOKED };
351
+ }
191
352
  async enforce(options) {
192
- const { grantToken, connector, tool, amount } = options;
353
+ const { grantToken, connector, tool, amount, caseId, costComponents, reserve = true, capsTenantId } = options;
354
+ const capsMode = options.capsMode === undefined ? this.#capsMode : checkCapsMode(options.capsMode);
355
+ const decisionsMode = options.decisionsMode === undefined ? this.#decisionsMode : checkDecisionsMode(options.decisionsMode);
193
356
  const base = {
194
357
  grantId: '',
195
358
  agentDid: '',
@@ -198,72 +361,332 @@ export class Grantex {
198
361
  connector,
199
362
  tool,
200
363
  };
364
+ let resultPurpose;
365
+ const denied = (reason, reasonCode, subReason, details) => this.#applyEnforceMode({
366
+ ...base,
367
+ allowed: false,
368
+ reason,
369
+ reasonCode,
370
+ ...(subReason !== undefined ? { subReason } : {}),
371
+ ...(details !== undefined ? { details } : {}),
372
+ ...(resultPurpose !== undefined ? { purpose: resultPurpose } : {}),
373
+ });
201
374
  // 1. Verify the token locally using JWKS retrieved from the configured URI
202
375
  let grant;
203
376
  try {
204
377
  grant = await verifyGrantToken(grantToken, {
205
378
  jwksUri: this.#jwksUri,
206
379
  ...(this.#issuer !== undefined ? { issuer: this.#issuer } : {}),
380
+ ...(this.#legacyClaims !== undefined ? { legacyClaims: this.#legacyClaims } : {}),
207
381
  });
208
382
  }
209
383
  catch (err) {
210
- return this.#applyEnforceMode({
211
- ...base,
212
- allowed: false,
213
- reason: `Token verification failed: ${err instanceof Error ? err.message : String(err)}`,
214
- });
384
+ return denied(`Token verification failed: ${err instanceof Error ? err.message : String(err)}`, DenialReason.TOKEN_INVALID);
215
385
  }
216
386
  base.grantId = grant.grantId;
217
387
  base.agentDid = grant.agentDid;
218
388
  base.scopes = grant.scopes;
219
- // 2. Look up manifest for the connector
389
+ // 1b. Revocation. The token verifies offline whether or not the grant
390
+ // still stands, so this is the only place a revocation can be seen.
391
+ const revocationCheck = options.revocationCheck === undefined
392
+ ? this.#revocationCheck
393
+ : checkRevocationCheck(options.revocationCheck);
394
+ if (revocationCheck !== 'offline') {
395
+ const denial = await this.#revocationDenial(revocationCheck, {
396
+ grantId: grant.grantId,
397
+ tokenId: grant.tokenId,
398
+ parentGrantId: grant.parentGrantId,
399
+ });
400
+ if (denial)
401
+ return denied(denial.reason, DenialReason.GRANT_REVOKED, denial.subReason);
402
+ }
403
+ // 2. Read the grant's tools authorization for this connector. A claim that
404
+ // cannot be read unambiguously denies every call.
405
+ let entry;
406
+ let decisionReference;
407
+ try {
408
+ entry = parseToolsAuthorization(grant.authorizationDetails).get(connector);
409
+ decisionReference = parseDecisionReferences(grant.authorizationDetails).get(connector);
410
+ }
411
+ catch (err) {
412
+ if (!(err instanceof AuthorizationDetailsError))
413
+ throw err;
414
+ return denied(`Grant token authorization_details cannot be used: ${err.message}.`, DenialReason.TOKEN_INVALID, TokenSubReason.MALFORMED_AUTHORIZATION_DETAILS);
415
+ }
416
+ const purpose = entry?.purpose;
417
+ resultPurpose = purpose;
418
+ // 3. Look up manifest for the connector
220
419
  const manifest = this.#manifests.get(connector);
221
420
  if (!manifest) {
222
- return this.#applyEnforceMode({
223
- ...base,
224
- allowed: false,
225
- reason: `No manifest loaded for connector '${connector}'. Load a manifest first.`,
226
- });
421
+ return denied(`No manifest loaded for connector '${connector}'. Load a manifest first.`, DenialReason.MANIFEST_UNKNOWN_TOOL, ManifestSubReason.UNKNOWN_CONNECTOR);
227
422
  }
228
- // 3. Look up tool permission from manifest
423
+ // 4. Look up tool permission from manifest
229
424
  const requiredPermission = manifest.getPermission(tool);
230
425
  if (!requiredPermission) {
231
- return this.#applyEnforceMode({
232
- ...base,
233
- allowed: false,
234
- reason: `Unknown tool '${tool}' on connector '${connector}'. Tool not found in manifest.`,
235
- });
426
+ return denied(`Unknown tool '${tool}' on connector '${connector}'. Tool not found in manifest.`, DenialReason.MANIFEST_UNKNOWN_TOOL, ManifestSubReason.UNKNOWN_TOOL);
236
427
  }
237
428
  base.permission = requiredPermission;
238
- // 4. Find the best matching scope for this connector
429
+ let spec;
430
+ try {
431
+ spec = manifest.getToolSpec(tool);
432
+ }
433
+ catch (err) {
434
+ return denied(`Tool '${tool}' on connector '${connector}' has an invalid declaration: ${err instanceof Error ? err.message : String(err)}`, DenialReason.MANIFEST_UNKNOWN_TOOL, ManifestSubReason.INVALID_DECLARATION);
435
+ }
436
+ if (spec === undefined) {
437
+ return denied(`Unknown tool '${tool}' on connector '${connector}'. Tool not found in manifest.`, DenialReason.MANIFEST_UNKNOWN_TOOL, ManifestSubReason.UNKNOWN_TOOL);
438
+ }
439
+ // 5. Find the best matching scope for this connector
239
440
  const grantedPermission = this.#resolveGrantedPermission(grant.scopes, connector);
240
441
  if (!grantedPermission) {
241
- return this.#applyEnforceMode({
242
- ...base,
243
- allowed: false,
244
- reason: `No scope grants access to connector '${connector}'.`,
245
- });
442
+ return denied(`No scope grants access to connector '${connector}'.`, DenialReason.TOOL_NOT_GRANTED);
246
443
  }
247
- // 5. Check permission hierarchy
444
+ // 6. Check permission hierarchy
248
445
  if (!permissionCovers(grantedPermission, requiredPermission)) {
249
- return this.#applyEnforceMode({
250
- ...base,
251
- allowed: false,
252
- reason: `${grantedPermission} scope does not permit ${requiredPermission} operations on ${connector}.`,
253
- });
446
+ return denied(`${grantedPermission} scope does not permit ${requiredPermission} operations on ${connector}.`, DenialReason.PERMISSION_INSUFFICIENT);
447
+ }
448
+ // 7. The grant's tools list, when it has one, must name the tool.
449
+ if (entry !== undefined && !toolsAuthorizationAllows(entry, tool)) {
450
+ return denied(`Grant does not list tool '${tool}' on connector '${connector}'.`, DenialReason.TOOL_NOT_GRANTED, ToolSubReason.NOT_IN_AUTHORIZATION_DETAILS);
451
+ }
452
+ // 8. Purpose. A tool that declares allowed_purposes needs a grant whose
453
+ // purpose is known and matches one of them.
454
+ if (spec.allowedPurposes !== undefined) {
455
+ const allowedPurposes = [...spec.allowedPurposes];
456
+ if (purpose === undefined) {
457
+ return denied(`Tool '${tool}' on ${connector} is restricted to purposes ${allowedPurposes.join(', ')}; the grant carries no purpose.`, DenialReason.PURPOSE_NOT_ALLOWED, PurposeSubReason.MISSING, { allowed_purposes: allowedPurposes });
458
+ }
459
+ if (!isKnownPurpose(purpose)) {
460
+ return denied(`Grant purpose ${JSON.stringify(purpose)} is not in the purpose vocabulary.`, DenialReason.PURPOSE_NOT_ALLOWED, PurposeSubReason.UNKNOWN_PURPOSE, { allowed_purposes: allowedPurposes, purpose });
461
+ }
462
+ if (matchPurpose(allowedPurposes, purpose) === undefined) {
463
+ return denied(`Grant purpose '${purpose}' is not allowed for tool '${tool}' on ${connector}; allowed purposes: ${allowedPurposes.join(', ')}.`, DenialReason.PURPOSE_NOT_ALLOWED, PurposeSubReason.NOT_MATCHED, { allowed_purposes: allowedPurposes, purpose });
464
+ }
465
+ }
466
+ // 9. Decision. A tool that requires a decision, in the manifest or in the
467
+ // grant's decision references, needs decision grants that verify
468
+ // offline for this exact action; they are consumed at the issuer as the
469
+ // last step, after caps are reserved. A decision needs two approvers if
470
+ // either the manifest or the grant says so.
471
+ let decisionSet;
472
+ let wouldDeny;
473
+ if (spec.requiresDecision || decisionReference?.tools.includes(tool)) {
474
+ const fourEyesOn = [...new Set([...spec.fourEyesOn, ...(decisionReference?.fourEyesOn[tool] ?? [])])];
475
+ const requirement = { decision_required: `${connector}:${tool}` };
476
+ let decisionDenial;
477
+ try {
478
+ decisionSet = await this.#verifyDecision(grant, connector, tool, fourEyesOn, spec.decisionFields ?? [], options);
479
+ }
480
+ catch (err) {
481
+ if (!(err instanceof DecisionGrantError))
482
+ throw err;
483
+ decisionDenial = err.subReason === DecisionSubReason.ABSENT
484
+ ? { reason_code: DenialReason.DECISION_REQUIRED, sub_reason: '', reason: `Tool '${tool}' on ${connector} requires a decision grant.`, details: requirement }
485
+ : { reason_code: DenialReason.DECISION_INVALID, sub_reason: err.subReason, reason: `The decision grant for tool '${tool}' on ${connector} is not valid: ${err.message}`, details: requirement };
486
+ }
487
+ if (decisionDenial !== undefined) {
488
+ if (decisionsMode !== 'warn') {
489
+ return denied(decisionDenial.reason, decisionDenial.reason_code, decisionDenial.sub_reason === '' ? undefined : decisionDenial.sub_reason, decisionDenial.details);
490
+ }
491
+ wouldDeny = decisionDenial;
492
+ }
254
493
  }
255
- // 6. Check capped amount if provided
494
+ // 10. Check capped amount if provided
256
495
  if (amount !== undefined) {
496
+ if (typeof amount !== 'number' || !Number.isFinite(amount)) {
497
+ return denied(`Amount must be a finite number to enforce a budget cap on ${connector}.`, DenialReason.CAP_EXCEEDED, CapSubReason.INVALID_AMOUNT);
498
+ }
257
499
  const cap = this.#extractCap(grant.scopes, connector);
500
+ if (cap === 'invalid') {
501
+ return denied(`A capped scope on ${connector} carries a malformed cap; refusing to authorize amount ${amount}.`, DenialReason.CAP_EXCEEDED, CapSubReason.MALFORMED_CAP);
502
+ }
258
503
  if (cap !== undefined && amount > cap) {
259
- return this.#applyEnforceMode({
260
- ...base,
261
- allowed: false,
262
- reason: `Amount ${amount} exceeds budget cap of ${cap} on ${connector}.`,
263
- });
504
+ return denied(`Amount ${amount} exceeds budget cap of ${cap} on ${connector}.`, DenialReason.CAP_EXCEEDED, CapSubReason.AMOUNT_CAP, { limit: cap, amount });
505
+ }
506
+ }
507
+ // 11. Call caps and cost units (declared by the manifest or by the grant).
508
+ // Reserving is the last step, so a denied call never consumes a cap;
509
+ // without a meter the call is denied.
510
+ const grantCaps = entry?.caps;
511
+ const grantCapsApply = grantCaps !== undefined
512
+ && (Object.prototype.hasOwnProperty.call(grantCaps, tool)
513
+ || (spec.costUnits !== undefined && Object.prototype.hasOwnProperty.call(grantCaps, 'cost_units')));
514
+ let reservation;
515
+ let capLimits = [];
516
+ const capsTenant = capsTenantId ?? grant.developerId;
517
+ if ((spec.caps !== undefined || spec.costUnits !== undefined || grantCapsApply) && capsMode !== 'off') {
518
+ let capDenial;
519
+ const meter = this.#capsMeter;
520
+ if (meter === undefined) {
521
+ capDenial = {
522
+ reason_code: DenialReason.CAP_EXCEEDED,
523
+ sub_reason: CapSubReason.METER_UNAVAILABLE,
524
+ reason: `Tool '${tool}' on ${connector} declares caps or cost units and no caps meter is configured.`,
525
+ details: {},
526
+ };
527
+ }
528
+ else {
529
+ try {
530
+ const buildOptions = {
531
+ connector,
532
+ tool,
533
+ spec,
534
+ grantId: grant.grantId,
535
+ ...(grantCaps !== undefined ? { grantCaps } : {}),
536
+ ...(caseId !== undefined ? { caseId } : {}),
537
+ ...(costComponents !== undefined ? { costComponents } : {}),
538
+ };
539
+ capLimits = buildCapLimits(buildOptions);
540
+ }
541
+ catch (err) {
542
+ if (!(err instanceof CapsConfigurationError))
543
+ throw err;
544
+ if (err.subReason === MALFORMED_GRANT_CAPS) {
545
+ // A token problem, not a cap decision: denied in every mode.
546
+ return denied(`Grant token authorization_details cannot be used: ${err.message}.`, DenialReason.TOKEN_INVALID, TokenSubReason.MALFORMED_AUTHORIZATION_DETAILS);
547
+ }
548
+ capDenial = {
549
+ reason_code: DenialReason.CAP_EXCEEDED,
550
+ sub_reason: err.subReason,
551
+ reason: `Cannot meter tool '${tool}' on ${connector}: ${err.message}.`,
552
+ details: {},
553
+ };
554
+ }
555
+ if (capDenial === undefined) {
556
+ try {
557
+ if (reserve) {
558
+ reservation = await meter.reserve(capsTenant, capLimits);
559
+ }
560
+ else {
561
+ await checkCaps(meter, capsTenant, capLimits);
562
+ }
563
+ }
564
+ catch (err) {
565
+ if (err instanceof CapExceededError) {
566
+ capDenial = {
567
+ reason_code: DenialReason.CAP_EXCEEDED,
568
+ sub_reason: CapSubReason.LIMIT_REACHED,
569
+ reason: `${err.message} on ${connector}.${tool}.`,
570
+ details: {
571
+ code: err.code,
572
+ limit: err.limit,
573
+ window: err.window,
574
+ used: err.used,
575
+ requested: err.requested,
576
+ scope: err.scope,
577
+ kind: err.kind,
578
+ },
579
+ };
580
+ }
581
+ else {
582
+ // Any other failure, including an invalid tenant, leaves the call unmetered.
583
+ const message = err instanceof MeterUnavailableError || err instanceof CapsConfigurationError
584
+ ? err.message
585
+ : 'caps meter failed';
586
+ capDenial = {
587
+ reason_code: DenialReason.CAP_EXCEEDED,
588
+ sub_reason: CapSubReason.METER_UNAVAILABLE,
589
+ reason: `Caps meter could not evaluate tool '${tool}' on ${connector}: ${message}.`,
590
+ details: {},
591
+ };
592
+ }
593
+ }
594
+ }
595
+ }
596
+ if (capDenial !== undefined) {
597
+ if (capsMode !== 'warn') {
598
+ return denied(capDenial.reason, capDenial.reason_code, capDenial.sub_reason, capDenial.details);
599
+ }
600
+ wouldDeny ??= capDenial;
601
+ }
602
+ }
603
+ // 12. Consume the decision grants at the issuer. Offline verification
604
+ // alone never allows a call: one grant authorises one call.
605
+ let decision;
606
+ if (decisionSet !== undefined) {
607
+ try {
608
+ decision = await this.#decisionConsumer.consume(decisionSet, grant.grantId ? { grantId: grant.grantId } : {});
609
+ }
610
+ catch (err) {
611
+ const subReason = err instanceof DecisionGrantError ? err.subReason : DecisionSubReason.CONSUME_UNAVAILABLE;
612
+ if (reservation !== undefined && this.#capsMeter !== undefined) {
613
+ await this.#capsMeter.refundUnsent(reservation).catch(() => undefined);
614
+ reservation = undefined;
615
+ }
616
+ const message = `The decision grant for tool '${tool}' on ${connector} was not consumed: ${err instanceof Error ? err.message : 'consumption failed'}`;
617
+ const details = { decision_required: `${connector}:${tool}` };
618
+ if (decisionsMode !== 'warn')
619
+ return denied(message, DenialReason.DECISION_INVALID, subReason, details);
620
+ wouldDeny ??= { reason_code: DenialReason.DECISION_INVALID, sub_reason: subReason, reason: message, details };
264
621
  }
265
622
  }
266
- return { ...base, allowed: true, reason: '' };
623
+ return {
624
+ ...base,
625
+ allowed: true,
626
+ reason: '',
627
+ ...(purpose !== undefined ? { purpose } : {}),
628
+ ...(reservation !== undefined ? { reservation } : {}),
629
+ ...(capLimits.length > 0 ? { capLimits, capsTenantId: capsTenant } : {}),
630
+ ...(wouldDeny !== undefined ? { wouldDeny } : {}),
631
+ ...(decision !== undefined ? { decision } : {}),
632
+ };
633
+ }
634
+ /** Offline checks of the decision grants for one call (see `enforce`). */
635
+ async #verifyDecision(grant, connector, tool, fourEyesOn, decisionFields, options) {
636
+ const tokens = options.decisionGrants;
637
+ if (tokens === undefined || tokens.length === 0) {
638
+ throw new DecisionGrantError(DecisionSubReason.ABSENT, 'no decision grant was presented');
639
+ }
640
+ let given;
641
+ let fromArguments;
642
+ try {
643
+ if (options.decisionAction !== undefined)
644
+ given = parseDecisionAction(options.decisionAction);
645
+ if (options.arguments !== undefined)
646
+ fromArguments = decisionActionFromToolCall(tool, options.arguments, decisionFields);
647
+ }
648
+ catch (err) {
649
+ if (err instanceof ActionValidationError) {
650
+ throw new DecisionGrantError(DecisionSubReason.MALFORMED, `the call's action is invalid: ${err.message}`);
651
+ }
652
+ throw err;
653
+ }
654
+ if (given !== undefined && fromArguments !== undefined && computeActionHash(given) !== computeActionHash(fromArguments)) {
655
+ // The call would do something other than what the caller says it approves.
656
+ throw new DecisionGrantError(DecisionSubReason.ACTION_MISMATCH, 'decisionAction does not match the action derived from the call arguments');
657
+ }
658
+ const action = given ?? fromArguments;
659
+ if (action === undefined) {
660
+ throw new DecisionGrantError(DecisionSubReason.MALFORMED, 'enforce() needs decisionAction or arguments to compare the decision grant with');
661
+ }
662
+ const missingFields = decisionFields.filter((name) => action.extra === undefined || !Object.prototype.hasOwnProperty.call(action.extra, name));
663
+ if (missingFields.length > 0) {
664
+ throw new DecisionGrantError(DecisionSubReason.MALFORMED, `the action does not bind the declared decision fields: ${missingFields.join(', ')}`);
665
+ }
666
+ if (action.action !== tool) {
667
+ throw new DecisionGrantError(DecisionSubReason.ACTION_MISMATCH, 'the decision action names another tool');
668
+ }
669
+ if (typeof options.caseVersion !== 'string' || options.caseVersion.length === 0) {
670
+ throw new DecisionGrantError(DecisionSubReason.MALFORMED, 'enforce() needs caseVersion for a decision');
671
+ }
672
+ return verifyDecisionGrants(tokens, action, options.caseVersion, {
673
+ issuer: this.#decisionIssuer(),
674
+ jwksUri: this.#jwksUri,
675
+ developerId: grant.developerId,
676
+ connector,
677
+ approvalsRequired: fourEyesOn.includes(action.decision) ? 2 : 1,
678
+ algorithms: this.#decisionAlgorithms,
679
+ });
680
+ }
681
+ #decisionIssuer() {
682
+ if (this.#issuer !== undefined)
683
+ return this.#issuer;
684
+ const url = new URL(this.#jwksUri);
685
+ if (url.href.replace(/\/$/, '') === 'https://api.grantex.dev/.well-known/jwks.json')
686
+ return 'https://grantex.dev';
687
+ return url.pathname.endsWith('/.well-known/jwks.json')
688
+ ? `${url.origin}${url.pathname.slice(0, -'/.well-known/jwks.json'.length)}`
689
+ : `${url.origin}${url.pathname.replace(/\/$/, '')}`;
267
690
  }
268
691
  /**
269
692
  * Resolve the highest granted permission level for a connector from scope strings.
@@ -299,16 +722,25 @@ export class Grantex {
299
722
  * Scope format: `tool:{connector}:{permission}:{resource}:capped:{N}`
300
723
  */
301
724
  #extractCap(scopes, connector) {
725
+ // The tightest cap on the connector wins. Returning the first capped scope
726
+ // encountered let `tool:x:read:capped:1000` shadow `tool:x:write:capped:10`
727
+ // purely by ordering, and a malformed cap (`capped:abc` → NaN) disabled the
728
+ // check entirely because `amount > NaN` is always false.
729
+ let cap;
302
730
  for (const scope of scopes) {
303
731
  const parts = scope.split(':');
304
- if (parts[0] === 'tool' && parts[1] === connector) {
305
- const cappedIdx = parts.indexOf('capped');
306
- if (cappedIdx !== -1 && parts[cappedIdx + 1]) {
307
- return Number(parts[cappedIdx + 1]);
308
- }
309
- }
732
+ if ((parts[0] !== 'tool' && parts[0] !== 'agenticorg') || parts[1] !== connector)
733
+ continue;
734
+ const cappedIdx = parts.indexOf('capped');
735
+ if (cappedIdx === -1)
736
+ continue;
737
+ const raw = parts[cappedIdx + 1];
738
+ const value = raw !== undefined && /^\d+(\.\d+)?$/.test(raw) ? Number(raw) : Number.NaN;
739
+ if (!Number.isFinite(value) || value < 0)
740
+ return 'invalid';
741
+ cap = cap === undefined ? value : Math.min(cap, value);
310
742
  }
311
- return undefined;
743
+ return cap;
312
744
  }
313
745
  #applyEnforceMode(result) {
314
746
  if (!result.allowed && this.#enforceMode === 'permissive') {
@@ -342,18 +774,25 @@ export class Grantex {
342
774
  const wrapped = Object.create(tool);
343
775
  wrapped.invoke = async function (...args) {
344
776
  const getToken = () => typeof options.grantToken === 'function' ? options.grantToken() : options.grantToken;
345
- let result = await self.enforce({
346
- grantToken: getToken(),
777
+ const caseId = typeof options.caseId === 'function' ? options.caseId() : options.caseId;
778
+ const costComponents = typeof options.costComponents === 'function' ? options.costComponents() : options.costComponents;
779
+ const decisionGrants = typeof options.decisionGrants === 'function' ? options.decisionGrants() : options.decisionGrants;
780
+ const caseVersion = typeof options.caseVersion === 'function' ? options.caseVersion() : options.caseVersion;
781
+ const input = args[0];
782
+ const callOptions = {
347
783
  connector: options.connector,
348
784
  tool: options.tool,
349
- });
350
- // Retry once with refreshed token if expired and grantToken is a getter
785
+ ...(caseId !== undefined ? { caseId } : {}),
786
+ ...(costComponents !== undefined ? { costComponents } : {}),
787
+ ...(decisionGrants !== undefined ? { decisionGrants } : {}),
788
+ ...(caseVersion !== undefined ? { caseVersion } : {}),
789
+ ...(typeof input === 'object' && input !== null && !Array.isArray(input) ? { arguments: input } : {}),
790
+ };
791
+ let result = await self.enforce({ grantToken: getToken(), ...callOptions });
792
+ // Retry once with refreshed token if expired and grantToken is a getter. An
793
+ // expired token is denied before caps are reserved, so this cannot reserve twice.
351
794
  if (!result.allowed && result.reason.includes('expired') && typeof options.grantToken === 'function') {
352
- result = await self.enforce({
353
- grantToken: getToken(),
354
- connector: options.connector,
355
- tool: options.tool,
356
- });
795
+ result = await self.enforce({ grantToken: getToken(), ...callOptions });
357
796
  }
358
797
  if (!result.allowed) {
359
798
  throw new Error(`Grantex scope denied: ${result.reason}`);
@@ -389,13 +828,34 @@ export class Grantex {
389
828
  jsonFn.call(statusFn.call(response, 401), { error: { code: 'UNAUTHORIZED', message: 'Missing grant token' } });
390
829
  return;
391
830
  }
392
- self.enforce({ grantToken: token, connector, tool })
831
+ const caseId = options.extractCaseId?.(request);
832
+ const costComponents = options.extractCostComponents?.(request);
833
+ const decisionGrants = options.extractDecisionGrants?.(request);
834
+ const callArguments = options.extractArguments?.(request);
835
+ const caseVersion = options.extractCaseVersion?.(request);
836
+ self.enforce({
837
+ grantToken: token,
838
+ connector,
839
+ tool,
840
+ ...(caseId !== undefined ? { caseId } : {}),
841
+ ...(costComponents !== undefined ? { costComponents } : {}),
842
+ ...(decisionGrants !== undefined ? { decisionGrants } : {}),
843
+ ...(callArguments !== undefined ? { arguments: callArguments } : {}),
844
+ ...(caseVersion !== undefined ? { caseVersion } : {}),
845
+ })
393
846
  .then((result) => {
394
847
  if (!result.allowed) {
395
848
  const statusFn = response['status'];
396
849
  const jsonFn = statusFn.call(response, 403)['json'];
397
850
  jsonFn.call(statusFn.call(response, 403), {
398
- error: { code: 'SCOPE_DENIED', message: result.reason, connector, tool },
851
+ error: {
852
+ code: 'SCOPE_DENIED',
853
+ message: result.reason,
854
+ connector,
855
+ tool,
856
+ ...(result.reasonCode !== undefined ? { reason: result.reasonCode } : {}),
857
+ ...(result.subReason !== undefined ? { subReason: result.subReason } : {}),
858
+ },
399
859
  });
400
860
  return;
401
861
  }