@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.
- package/LICENSE +185 -185
- package/NOTICE +16 -16
- package/README.md +974 -947
- package/dist/authorization-details.d.ts +86 -0
- package/dist/authorization-details.d.ts.map +1 -0
- package/dist/authorization-details.js +209 -0
- package/dist/authorization-details.js.map +1 -0
- package/dist/canonical.d.ts +47 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +163 -0
- package/dist/canonical.js.map +1 -0
- package/dist/caps/index.d.ts +19 -0
- package/dist/caps/index.d.ts.map +1 -0
- package/dist/caps/index.js +19 -0
- package/dist/caps/index.js.map +1 -0
- package/dist/caps/limits.d.ts +40 -0
- package/dist/caps/limits.d.ts.map +1 -0
- package/dist/caps/limits.js +109 -0
- package/dist/caps/limits.js.map +1 -0
- package/dist/caps/memory.d.ts +15 -0
- package/dist/caps/memory.d.ts.map +1 -0
- package/dist/caps/memory.js +57 -0
- package/dist/caps/memory.js.map +1 -0
- package/dist/caps/meter.d.ts +107 -0
- package/dist/caps/meter.d.ts.map +1 -0
- package/dist/caps/meter.js +200 -0
- package/dist/caps/meter.js.map +1 -0
- package/dist/caps/postgres.d.ts +64 -0
- package/dist/caps/postgres.d.ts.map +1 -0
- package/dist/caps/postgres.js +221 -0
- package/dist/caps/postgres.js.map +1 -0
- package/dist/caps/redis.d.ts +45 -0
- package/dist/caps/redis.d.ts.map +1 -0
- package/dist/caps/redis.js +173 -0
- package/dist/caps/redis.js.map +1 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +517 -57
- package/dist/client.js.map +1 -1
- package/dist/decisions/action.d.ts +51 -0
- package/dist/decisions/action.d.ts.map +1 -0
- package/dist/decisions/action.js +237 -0
- package/dist/decisions/action.js.map +1 -0
- package/dist/decisions/index.d.ts +11 -0
- package/dist/decisions/index.d.ts.map +1 -0
- package/dist/decisions/index.js +8 -0
- package/dist/decisions/index.js.map +1 -0
- package/dist/decisions/verify.d.ts +103 -0
- package/dist/decisions/verify.d.ts.map +1 -0
- package/dist/decisions/verify.js +277 -0
- package/dist/decisions/verify.js.map +1 -0
- package/dist/denials.d.ts +132 -0
- package/dist/denials.d.ts.map +1 -0
- package/dist/denials.js +129 -0
- package/dist/denials.js.map +1 -0
- package/dist/evidence/build.d.ts +63 -0
- package/dist/evidence/build.d.ts.map +1 -0
- package/dist/evidence/build.js +201 -0
- package/dist/evidence/build.js.map +1 -0
- package/dist/evidence/canonical.d.ts +3 -0
- package/dist/evidence/canonical.d.ts.map +1 -0
- package/dist/evidence/canonical.js +3 -0
- package/dist/evidence/canonical.js.map +1 -0
- package/dist/evidence/checks.d.ts +13 -0
- package/dist/evidence/checks.d.ts.map +1 -0
- package/dist/evidence/checks.js +406 -0
- package/dist/evidence/checks.js.map +1 -0
- package/dist/evidence/document.d.ts +8 -0
- package/dist/evidence/document.d.ts.map +1 -0
- package/dist/evidence/document.js +298 -0
- package/dist/evidence/document.js.map +1 -0
- package/dist/evidence/hashing.d.ts +37 -0
- package/dist/evidence/hashing.d.ts.map +1 -0
- package/dist/evidence/hashing.js +106 -0
- package/dist/evidence/hashing.js.map +1 -0
- package/dist/evidence/index.d.ts +24 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +24 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/result.d.ts +88 -0
- package/dist/evidence/result.d.ts.map +1 -0
- package/dist/evidence/result.js +88 -0
- package/dist/evidence/result.js.map +1 -0
- package/dist/evidence/schema-1.0.d.ts +2 -0
- package/dist/evidence/schema-1.0.d.ts.map +1 -0
- package/dist/evidence/schema-1.0.js +1398 -0
- package/dist/evidence/schema-1.0.js.map +1 -0
- package/dist/evidence/schema.d.ts +13 -0
- package/dist/evidence/schema.d.ts.map +1 -0
- package/dist/evidence/schema.js +264 -0
- package/dist/evidence/schema.js.map +1 -0
- package/dist/evidence/signature.d.ts +23 -0
- package/dist/evidence/signature.d.ts.map +1 -0
- package/dist/evidence/signature.js +136 -0
- package/dist/evidence/signature.js.map +1 -0
- package/dist/evidence/trace.d.ts +23 -0
- package/dist/evidence/trace.d.ts.map +1 -0
- package/dist/evidence/trace.js +69 -0
- package/dist/evidence/trace.js.map +1 -0
- package/dist/evidence/verify.d.ts +29 -0
- package/dist/evidence/verify.d.ts.map +1 -0
- package/dist/evidence/verify.js +163 -0
- package/dist/evidence/verify.js.map +1 -0
- package/dist/http.d.ts +12 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +11 -5
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +178 -9
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +379 -16
- package/dist/manifest.js.map +1 -1
- package/dist/purpose.d.ts +29 -0
- package/dist/purpose.d.ts.map +1 -0
- package/dist/purpose.js +60 -0
- package/dist/purpose.js.map +1 -0
- package/dist/resources/agents.js +3 -3
- package/dist/resources/agents.js.map +1 -1
- package/dist/resources/anomalies.d.ts +3 -1
- package/dist/resources/anomalies.d.ts.map +1 -1
- package/dist/resources/anomalies.js +7 -1
- package/dist/resources/anomalies.js.map +1 -1
- package/dist/resources/audit.js +1 -1
- package/dist/resources/audit.js.map +1 -1
- package/dist/resources/budgets.js +2 -2
- package/dist/resources/budgets.js.map +1 -1
- package/dist/resources/credentials.js +1 -1
- package/dist/resources/credentials.js.map +1 -1
- package/dist/resources/decisions.d.ts +69 -0
- package/dist/resources/decisions.d.ts.map +1 -0
- package/dist/resources/decisions.js +115 -0
- package/dist/resources/decisions.js.map +1 -0
- package/dist/resources/domains.js +2 -2
- package/dist/resources/domains.js.map +1 -1
- package/dist/resources/dpdp.js +4 -4
- package/dist/resources/dpdp.js.map +1 -1
- package/dist/resources/grants.js +2 -2
- package/dist/resources/grants.js.map +1 -1
- package/dist/resources/policies.js +3 -3
- package/dist/resources/policies.js.map +1 -1
- package/dist/resources/scim.js +5 -5
- package/dist/resources/scim.js.map +1 -1
- package/dist/resources/tokens.d.ts.map +1 -1
- package/dist/resources/tokens.js +5 -1
- package/dist/resources/tokens.js.map +1 -1
- package/dist/resources/vault.js +2 -2
- package/dist/resources/vault.js.map +1 -1
- package/dist/resources/webauthn.d.ts +6 -1
- package/dist/resources/webauthn.d.ts.map +1 -1
- package/dist/resources/webauthn.js +5 -1
- package/dist/resources/webauthn.js.map +1 -1
- package/dist/resources/webhooks.js +1 -1
- package/dist/resources/webhooks.js.map +1 -1
- package/dist/revocations/feed.d.ts +61 -0
- package/dist/revocations/feed.d.ts.map +1 -0
- package/dist/revocations/feed.js +282 -0
- package/dist/revocations/feed.js.map +1 -0
- package/dist/revocations/index.d.ts +20 -0
- package/dist/revocations/index.d.ts.map +1 -0
- package/dist/revocations/index.js +19 -0
- package/dist/revocations/index.js.map +1 -0
- package/dist/revocations/set.d.ts +59 -0
- package/dist/revocations/set.d.ts.map +1 -0
- package/dist/revocations/set.js +87 -0
- package/dist/revocations/set.js.map +1 -0
- package/dist/scopes.d.ts +26 -0
- package/dist/scopes.d.ts.map +1 -0
- package/dist/scopes.js +37 -0
- package/dist/scopes.js.map +1 -0
- package/dist/types.d.ts +121 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -1
- package/dist/types.js.map +1 -1
- package/dist/verify.d.ts +31 -2
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +236 -29
- package/dist/verify.js.map +1 -1
- 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 {
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
423
|
+
// 4. Look up tool permission from manifest
|
|
229
424
|
const requiredPermission = manifest.getPermission(tool);
|
|
230
425
|
if (!requiredPermission) {
|
|
231
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
444
|
+
// 6. Check permission hierarchy
|
|
248
445
|
if (!permissionCovers(grantedPermission, requiredPermission)) {
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
//
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 {
|
|
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]
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
}
|