nucleus-core-ts 0.9.712 → 0.9.714

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.
@@ -36,6 +36,14 @@ export declare function evaluateAccess(args: {
36
36
  onUnmapped: 'allow' | 'deny';
37
37
  publicPaths?: string[];
38
38
  godminRole: string;
39
+ /**
40
+ * Claim actions the caller has determined are currently suppressed for this user by an
41
+ * exceeded quota (see resolveSuppressedClaims). They are subtracted from the user's claims
42
+ * before the gate check; if the request would have passed on a claim that is suppressed,
43
+ * the decision is `deny` with reason `quota_exceeded` so a caller can enforce the usage cap
44
+ * even while the general claim-gate runs in audit mode. Absent/empty ⇒ classic behaviour.
45
+ */
46
+ suppressedClaims?: string[];
39
47
  }): AccessDecision;
40
48
  /** Fetches + caches a service's route manifest from the IDP. */
41
49
  export declare class ManifestCache {
@@ -71,7 +71,7 @@ function matchesGlob(path, patterns) {
71
71
  * (from the JWT in embed mode, or via the role-claims manifest in resolve mode), so
72
72
  * this stays mode-agnostic and easy to test.
73
73
  */ export function evaluateAccess(args) {
74
- const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole } = args;
74
+ const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole, suppressedClaims } = args;
75
75
  if (matchesGlob(path, publicPaths)) return {
76
76
  decision: 'allow',
77
77
  reason: 'public',
@@ -95,11 +95,22 @@ function matchesGlob(path, patterns) {
95
95
  reason: 'godmin',
96
96
  action
97
97
  };
98
- if (claimSatisfies(claims, action)) return {
98
+ const suppressed = suppressedClaims && suppressedClaims.length ? new Set(suppressedClaims) : null;
99
+ const effectiveClaims = suppressed ? claims.filter((c)=>!suppressed.has(c)) : claims;
100
+ if (claimSatisfies(effectiveClaims, action)) return {
99
101
  decision: 'allow',
100
102
  reason: 'claim',
101
103
  action
102
104
  };
105
+ // Denied. If the user actually holds a claim that grants this action but a quota suppressed
106
+ // it, surface that distinctly — this is a usage cap, not a missing grant.
107
+ if (suppressed && claimSatisfies(claims, action)) {
108
+ return {
109
+ decision: 'deny',
110
+ reason: 'quota_exceeded',
111
+ action
112
+ };
113
+ }
103
114
  return {
104
115
  decision: 'deny',
105
116
  reason: 'missing-claim',
@@ -186,6 +186,95 @@ describe('evaluateAccess', ()=>{
186
186
  reason: 'missing-claim'
187
187
  });
188
188
  });
189
+ it('denies with reason quota_exceeded when a held claim is suppressed by a quota', ()=>{
190
+ const d = evaluateAccess({
191
+ ...base,
192
+ method: 'GET',
193
+ path: '/api/v1/templates',
194
+ claims: [
195
+ 'agent.templates.list'
196
+ ],
197
+ suppressedClaims: [
198
+ 'agent.templates.list'
199
+ ]
200
+ });
201
+ expect(d).toMatchObject({
202
+ decision: 'deny',
203
+ reason: 'quota_exceeded'
204
+ });
205
+ });
206
+ it('still allows when a NON-suppressed claim also satisfies the action', ()=>{
207
+ // broad claim grants the action and is not in the suppressed set → allowed
208
+ const d = evaluateAccess({
209
+ ...base,
210
+ method: 'GET',
211
+ path: '/api/v1/templates',
212
+ claims: [
213
+ 'agent.templates.list',
214
+ 'agent'
215
+ ],
216
+ suppressedClaims: [
217
+ 'agent.templates.list'
218
+ ]
219
+ });
220
+ expect(d).toMatchObject({
221
+ decision: 'allow',
222
+ reason: 'claim'
223
+ });
224
+ });
225
+ it('suppression never elevates godmin denial (godmin bypasses by role)', ()=>{
226
+ const d = evaluateAccess({
227
+ ...base,
228
+ method: 'GET',
229
+ path: '/api/v1/templates',
230
+ roles: [
231
+ 'godmin'
232
+ ],
233
+ claims: [
234
+ 'agent.templates.list'
235
+ ],
236
+ suppressedClaims: [
237
+ 'agent.templates.list'
238
+ ]
239
+ });
240
+ expect(d).toMatchObject({
241
+ decision: 'allow',
242
+ reason: 'godmin'
243
+ });
244
+ });
245
+ it('a missing claim stays missing-claim (not quota_exceeded) under suppression', ()=>{
246
+ // the user never held the claim; an unrelated suppression must not relabel it
247
+ const d = evaluateAccess({
248
+ ...base,
249
+ method: 'DELETE',
250
+ path: '/api/v1/templates/1',
251
+ claims: [
252
+ 'agent.templates.list'
253
+ ],
254
+ suppressedClaims: [
255
+ 'something.else'
256
+ ]
257
+ });
258
+ expect(d).toMatchObject({
259
+ decision: 'deny',
260
+ reason: 'missing-claim'
261
+ });
262
+ });
263
+ it('empty suppressedClaims is identical to classic behaviour', ()=>{
264
+ const d = evaluateAccess({
265
+ ...base,
266
+ method: 'GET',
267
+ path: '/api/v1/templates',
268
+ claims: [
269
+ 'agent.templates.list'
270
+ ],
271
+ suppressedClaims: []
272
+ });
273
+ expect(d).toMatchObject({
274
+ decision: 'allow',
275
+ reason: 'claim'
276
+ });
277
+ });
189
278
  });
190
279
  describe('resolveClaimsFromRoles (resolve mode)', ()=>{
191
280
  const roleClaims = {
@@ -1,5 +1,26 @@
1
1
  import { evaluateAccess, ManifestCache, RoleClaimsCache, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
2
2
  import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
3
+ /**
4
+ * Ask the IDP which claim actions are currently suppressed for the caller (an exceeded
5
+ * quota that gates them). Uses the caller's OWN cookies — no service secret. Fail-open: any
6
+ * error yields an empty set, so a transient IDP blip never blocks traffic (the cap is
7
+ * re-checked on the next request).
8
+ */ async function fetchSuppressedClaims(url, cookieHeader, logger) {
9
+ if (!cookieHeader) return [];
10
+ try {
11
+ const res = await fetch(url, {
12
+ headers: {
13
+ cookie: cookieHeader
14
+ }
15
+ });
16
+ if (!res.ok) return [];
17
+ const body = await res.json();
18
+ return Array.isArray(body?.data?.suppressedClaims) ? body.data.suppressedClaims : [];
19
+ } catch (err) {
20
+ logger.warn(`[authz] suppressed-claims fetch failed (fail-open): ${err instanceof Error ? err.message : String(err)}`);
21
+ return [];
22
+ }
23
+ }
3
24
  const pendingRefreshes = new Map();
4
25
  function extractAccessTokenFromSetCookie(setCookieHeaders, accessCookieName) {
5
26
  for (const header of setCookieHeaders){
@@ -177,6 +198,12 @@ export function createHttpProxyHandler(config) {
177
198
  } else {
178
199
  claims = Array.isArray(payload?.claims) ? payload.claims : [];
179
200
  }
201
+ // Generic quota enforcement (opt-in): subtract the caller's quota-suppressed claims.
202
+ // Skipped for godmin (bypasses by role) and anonymous callers (nothing to suppress).
203
+ let suppressedClaims;
204
+ if (az.quotaSuppression?.url && payload !== null && !roles.includes(az.godminRole ?? 'godmin')) {
205
+ suppressedClaims = await fetchSuppressedClaims(az.quotaSuppression.url, req.headers.get('cookie'), logger);
206
+ }
180
207
  const decision = evaluateAccess({
181
208
  manifest,
182
209
  method: req.method,
@@ -186,14 +213,34 @@ export function createHttpProxyHandler(config) {
186
213
  claims,
187
214
  onUnmapped: az.onUnmapped ?? 'deny',
188
215
  publicPaths: az.publicPaths,
189
- godminRole: az.godminRole ?? 'godmin'
216
+ godminRole: az.godminRole ?? 'godmin',
217
+ suppressedClaims
190
218
  });
191
219
  if (decision.decision === 'allow') return null;
192
220
  const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
193
- if ((az.mode ?? 'enforce') === 'audit') {
221
+ // A quota breach is a HARD usage cap: enforce it even while the general gate is auditing.
222
+ // Every other would-block (missing-claim, unmapped) still only logs in audit mode.
223
+ const isQuota = decision.reason === 'quota_exceeded';
224
+ if ((az.mode ?? 'enforce') === 'audit' && !isQuota) {
194
225
  logger.warn(`[authz audit] WOULD block ${detail}`);
195
226
  return null;
196
227
  }
228
+ if (isQuota) {
229
+ logger.warn(`[authz] 429 quota ${detail}`);
230
+ return new Response(JSON.stringify({
231
+ success: false,
232
+ error: 'quota_exceeded',
233
+ reason: 'quota_exceeded',
234
+ message: 'Quota exceeded',
235
+ action: decision.action
236
+ }), {
237
+ status: 429,
238
+ headers: {
239
+ 'content-type': 'application/json',
240
+ 'retry-after': '3600'
241
+ }
242
+ });
243
+ }
197
244
  logger.warn(`[authz] 403 ${detail}`);
198
245
  return new Response(JSON.stringify({
199
246
  success: false,
@@ -81,6 +81,21 @@ export interface ProxyAuthorizeConfig {
81
81
  onUnmapped?: 'allow' | 'deny';
82
82
  publicPaths?: string[];
83
83
  godminRole?: string;
84
+ /**
85
+ * OPTIONAL generic quota enforcement. When set, before deciding the proxy asks the IDP
86
+ * which claim actions are currently SUPPRESSED for the caller by an exceeded quota (GETs
87
+ * this URL with the caller's own cookies) and subtracts them from the caller's claims. A
88
+ * request that would have passed on a now-suppressed claim is denied `quota_exceeded` —
89
+ * and that specific denial is enforced EVEN IN `audit` mode, because a usage cap is a hard
90
+ * limit (all other would-blocks still just log while auditing). Fail-open on any fetch
91
+ * error so a transient IDP blip never breaks traffic. Nothing here names an endpoint or a
92
+ * metric: what a quota disables is pure data (the quota's `gates`), so the same wiring
93
+ * serves any project.
94
+ */
95
+ quotaSuppression?: {
96
+ /** GET endpoint returning `{ data: { suppressedClaims: string[] } }` for the caller. */
97
+ url: string;
98
+ };
84
99
  }
85
100
  export interface HttpProxyTarget {
86
101
  url: string;
@@ -1,4 +1,5 @@
1
1
  export { aggregateLimit, evaluateQuota, parseQuotaPolicy } from './evaluate';
2
2
  export { type ResolvedQuota, resolveUserQuotas } from './resolve';
3
+ export { resolveSuppressedClaims } from './suppress';
3
4
  export type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaOp, QuotaPolicy, QuotaWindow, } from './types';
4
5
  export { bucketDatesForWindow, buildIncrementKeys, buildQuotaKeys } from './windows';
@@ -0,0 +1,15 @@
1
+ import type { ResolvedQuota } from './resolve';
2
+ import type { CounterReader, QuotaContext } from './types';
3
+ /**
4
+ * The set of claim actions a user has LOST right now because a quota that gates them is
5
+ * exceeded. This is the one generic hinge of quota enforcement: for every resolved quota
6
+ * that carries `gates`, read its live counter (reader injected — stays pure/testable) and,
7
+ * if it is over the limit, add its gated claim actions to the suppressed set. Callers then
8
+ * subtract this set from the user's effective claims, so the ordinary claim-gate denies the
9
+ * gated capabilities. nucleus never learns what is being metered or which endpoint is hit —
10
+ * a quota decides what it disables purely by the claim actions listed in its policy.
11
+ *
12
+ * Metering-only quotas (no `gates`) never suppress anything; they exist just to be reported.
13
+ * Returns a de-duplicated list (order not significant).
14
+ */
15
+ export declare function resolveSuppressedClaims(quotas: readonly ResolvedQuota[], read: CounterReader, ctx: QuotaContext): Promise<string[]>;
@@ -37,6 +37,15 @@ export interface QuotaPolicy {
37
37
  aggregate?: QuotaAggregate;
38
38
  /** Optional display unit, e.g. 'tokens' | 'usd'. */
39
39
  unit?: string;
40
+ /**
41
+ * Claim actions this quota SUPPRESSES while it is exceeded. When a user is over this
42
+ * quota, these claim actions are removed from their effective claim set for the rest of
43
+ * the window, so any capability guarded by them is denied through the normal claim-gate.
44
+ * This is what makes enforcement generic: nucleus never names an endpoint or a metric —
45
+ * a project decides what a quota gates purely by listing claim actions here (data, not
46
+ * code). Absent/empty = a metering-only quota (reported for status, gates nothing).
47
+ */
48
+ gates?: string[];
40
49
  }
41
50
  export interface QuotaContext {
42
51
  userId: string;
@@ -1,5 +1,12 @@
1
1
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
2
  import type { Logger } from '../../Logger';
3
+ /**
4
+ * Expand claim-assignment patterns into concrete claim actions. A trailing `.*` is a PREFIX
5
+ * grant: it matches the prefix itself plus every action beneath it (`llm.conversations.*` →
6
+ * `llm.conversations.create…`, `…list…`, …). Everything else is taken literally. De-duplicated.
7
+ * Pure so it is unit-testable; the caller supplies the known claim actions.
8
+ */
9
+ export declare function expandClaimPatterns(patterns: string[], allActions: readonly string[]): string[];
3
10
  type SeedConfig = {
4
11
  roles?: Array<{
5
12
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.712",
3
+ "version": "0.9.714",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",