nucleus-core-ts 0.9.716 → 0.9.718

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 (24) hide show
  1. package/dist/index.js +5 -5
  2. package/dist/src/Client/Proxy/authz.d.ts +15 -5
  3. package/dist/src/Client/Proxy/authz.js +8 -5
  4. package/dist/src/Client/Proxy/authz.test.js +29 -2
  5. package/dist/src/Client/Proxy/httpProxy.js +33 -25
  6. package/dist/src/ElysiaPlugin/routes/authorization/index.d.ts +4 -29
  7. package/dist/src/ElysiaPlugin/routes/shared/adminGuard.d.ts +16 -0
  8. package/dist/src/Services/Authorization/ClaimGuard/evaluate.d.ts +36 -0
  9. package/dist/src/Services/Authorization/ClaimGuard/index.d.ts +4 -0
  10. package/dist/src/Services/Authorization/ClaimGuard/resolve.d.ts +37 -0
  11. package/dist/src/Services/Authorization/ClaimGuard/seed.d.ts +16 -0
  12. package/dist/src/Services/Authorization/ClaimGuard/types.d.ts +52 -0
  13. package/dist/src/types.d.ts +13 -17
  14. package/package.json +1 -1
  15. package/dist/src/Services/Authorization/Quota/canonical.test.d.ts +0 -1
  16. package/dist/src/Services/Authorization/Quota/evaluate.d.ts +0 -15
  17. package/dist/src/Services/Authorization/Quota/index.d.ts +0 -5
  18. package/dist/src/Services/Authorization/Quota/quota.test.d.ts +0 -1
  19. package/dist/src/Services/Authorization/Quota/resolve.d.ts +0 -16
  20. package/dist/src/Services/Authorization/Quota/suppress.d.ts +0 -15
  21. package/dist/src/Services/Authorization/Quota/suppress.test.d.ts +0 -1
  22. package/dist/src/Services/Authorization/Quota/types.d.ts +0 -80
  23. package/dist/src/Services/Authorization/Quota/windows.d.ts +0 -44
  24. /package/dist/src/{ElysiaPlugin/routes/authorization/consume.test.d.ts → Services/Authorization/ClaimGuard/claimGuard.test.d.ts} +0 -0
@@ -20,6 +20,9 @@ export interface AccessDecision {
20
20
  decision: 'allow' | 'deny';
21
21
  reason: string;
22
22
  action: string | null;
23
+ /** When a ClaimGuard caused the denial, the guard's action (e.g. `token_spent_today`) so the
24
+ * client can render a human-readable message. */
25
+ guard?: string;
23
26
  }
24
27
  /**
25
28
  * Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
@@ -37,13 +40,20 @@ export declare function evaluateAccess(args: {
37
40
  publicPaths?: string[];
38
41
  godminRole: string;
39
42
  /**
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.
43
+ * Claim actions currently invalidated for this user by a ClaimGuard whose condition holds
44
+ * (see resolveClaimGuards). They are subtracted from the user's claims before the gate check;
45
+ * if the request would have passed on a claim that is invalidated, the decision is `deny`
46
+ * with reason `claim_guard`, naming the firing guard. Absent/empty classic behaviour.
45
47
  */
46
48
  suppressedClaims?: string[];
49
+ /**
50
+ * Guards that fired for this user (from /suppressed-claims), used to NAME the guard in a
51
+ * denial. Each guard's `invalidates` are the claim actions it removed.
52
+ */
53
+ firedGuards?: ReadonlyArray<{
54
+ action: string;
55
+ invalidates: string[];
56
+ }>;
47
57
  }): AccessDecision;
48
58
  /** Fetches + caches a service's route manifest from the IDP. */
49
59
  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, suppressedClaims } = args;
74
+ const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole, suppressedClaims, firedGuards } = args;
75
75
  if (matchesGlob(path, publicPaths)) return {
76
76
  decision: 'allow',
77
77
  reason: 'public',
@@ -102,13 +102,16 @@ function matchesGlob(path, patterns) {
102
102
  reason: 'claim',
103
103
  action
104
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.
105
+ // Denied. If the user actually holds a claim that grants this action but a ClaimGuard
106
+ // invalidated it, surface that distinctly (a live condition, not a missing grant) and name
107
+ // the firing guard so the client can render a human-readable message.
107
108
  if (suppressed && claimSatisfies(claims, action)) {
109
+ const guard = firedGuards?.find((g)=>g.invalidates.some((iv)=>iv === action || action.startsWith(`${iv}.`)))?.action;
108
110
  return {
109
111
  decision: 'deny',
110
- reason: 'quota_exceeded',
111
- action
112
+ reason: 'claim_guard',
113
+ action,
114
+ guard
112
115
  };
113
116
  }
114
117
  return {
@@ -186,7 +186,33 @@ 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', ()=>{
189
+ it('denies with reason claim_guard, naming the firing guard, when a held claim is invalidated', ()=>{
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
+ firedGuards: [
201
+ {
202
+ action: 'token_spent_today',
203
+ invalidates: [
204
+ 'agent.templates.list'
205
+ ]
206
+ }
207
+ ]
208
+ });
209
+ expect(d).toMatchObject({
210
+ decision: 'deny',
211
+ reason: 'claim_guard',
212
+ guard: 'token_spent_today'
213
+ });
214
+ });
215
+ it('claim_guard denial still works (guard undefined) when firedGuards is not supplied', ()=>{
190
216
  const d = evaluateAccess({
191
217
  ...base,
192
218
  method: 'GET',
@@ -200,8 +226,9 @@ describe('evaluateAccess', ()=>{
200
226
  });
201
227
  expect(d).toMatchObject({
202
228
  decision: 'deny',
203
- reason: 'quota_exceeded'
229
+ reason: 'claim_guard'
204
230
  });
231
+ expect(d.guard).toBeUndefined();
205
232
  });
206
233
  it('still allows when a NON-suppressed claim also satisfies the action', ()=>{
207
234
  // broad claim grants the action and is not in the suppressed set → allowed
@@ -1,24 +1,29 @@
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 [];
3
+ async function fetchSuppressedClaims(url, cookieHeader, logger) {
4
+ const empty = {
5
+ suppressedClaims: [],
6
+ firedGuards: []
7
+ };
8
+ if (!cookieHeader) return empty;
10
9
  try {
11
10
  const res = await fetch(url, {
12
11
  headers: {
13
12
  cookie: cookieHeader
14
13
  }
15
14
  });
16
- if (!res.ok) return [];
15
+ if (!res.ok) return empty;
17
16
  const body = await res.json();
18
- return Array.isArray(body?.data?.suppressedClaims) ? body.data.suppressedClaims : [];
17
+ return {
18
+ suppressedClaims: Array.isArray(body?.data?.suppressedClaims) ? body.data.suppressedClaims : [],
19
+ firedGuards: Array.isArray(body?.data?.firedGuards) ? body.data.firedGuards.map((g)=>({
20
+ action: String(g?.action ?? ''),
21
+ invalidates: Array.isArray(g?.invalidates) ? g.invalidates : []
22
+ })) : []
23
+ };
19
24
  } catch (err) {
20
25
  logger.warn(`[authz] suppressed-claims fetch failed (fail-open): ${err instanceof Error ? err.message : String(err)}`);
21
- return [];
26
+ return empty;
22
27
  }
23
28
  }
24
29
  const pendingRefreshes = new Map();
@@ -198,11 +203,11 @@ export function createHttpProxyHandler(config) {
198
203
  } else {
199
204
  claims = Array.isArray(payload?.claims) ? payload.claims : [];
200
205
  }
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;
206
+ // Generic ClaimGuard enforcement (opt-in): subtract the caller's guard-invalidated claims.
207
+ // Skipped for godmin (bypasses by role) and anonymous callers (nothing to invalidate).
208
+ let suppression;
204
209
  if (az.quotaSuppression?.url && payload !== null && !roles.includes(az.godminRole ?? 'godmin')) {
205
- suppressedClaims = await fetchSuppressedClaims(az.quotaSuppression.url, req.headers.get('cookie'), logger);
210
+ suppression = await fetchSuppressedClaims(az.quotaSuppression.url, req.headers.get('cookie'), logger);
206
211
  }
207
212
  const decision = evaluateAccess({
208
213
  manifest,
@@ -214,24 +219,27 @@ export function createHttpProxyHandler(config) {
214
219
  onUnmapped: az.onUnmapped ?? 'deny',
215
220
  publicPaths: az.publicPaths,
216
221
  godminRole: az.godminRole ?? 'godmin',
217
- suppressedClaims
222
+ suppressedClaims: suppression?.suppressedClaims,
223
+ firedGuards: suppression?.firedGuards
218
224
  });
219
225
  if (decision.decision === 'allow') return null;
220
- const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
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) {
226
+ const detail = `${req.method} ${path} (${decision.reason}${decision.guard ? ` guard=${decision.guard}` : ''}${decision.action ? ` need ${decision.action}` : ''})`;
227
+ // A ClaimGuard block is a HARD, live condition: enforce it even while the general gate is
228
+ // auditing. Every other would-block (missing-claim, unmapped) still only logs in audit mode.
229
+ const isGuard = decision.reason === 'claim_guard';
230
+ if ((az.mode ?? 'enforce') === 'audit' && !isGuard) {
225
231
  logger.warn(`[authz audit] WOULD block ${detail}`);
226
232
  return null;
227
233
  }
228
- if (isQuota) {
229
- logger.warn(`[authz] 429 quota ${detail}`);
234
+ if (isGuard) {
235
+ logger.warn(`[authz] 429 claim_guard ${detail}`);
230
236
  return new Response(JSON.stringify({
231
237
  success: false,
232
- error: 'quota_exceeded',
233
- reason: 'quota_exceeded',
234
- message: 'Quota exceeded',
238
+ error: 'claim_guard',
239
+ reason: 'claim_guard',
240
+ // Name the firing guard so the client can render a human-readable message.
241
+ guard: decision.guard ?? null,
242
+ message: decision.guard ? `Blocked by guard: ${decision.guard}` : 'Blocked by a usage guard',
235
243
  action: decision.action
236
244
  }), {
237
245
  status: 429,
@@ -11,36 +11,11 @@ export interface AuthorizationRoutesConfig {
11
11
  token?: string;
12
12
  };
13
13
  /**
14
- * Reads integer usage counters (missing 0) for the quota endpoint. Wired to
15
- * RedisManager.readCounters when Redis is configured; when absent, `/authorization/quotas`
16
- * still returns effective LIMITS but omits live usage.
14
+ * Reads one raw value from the shared state store (the app writes ClaimGuard keys there).
15
+ * Wired to RedisManager.read (Dapr state) when configured; when absent, guards can't be
16
+ * evaluated so NOTHING is suppressed (fail-open, never over-block).
17
17
  */
18
- readCounters?: (keys: string[]) => Promise<number[]>;
19
- /**
20
- * Increments usage counters for the quota `/consume` (metering) endpoint. Wired to
21
- * RedisManager.incrementCounter; when absent, `/consume` returns 503. Each item carries an
22
- * optional TTL (rolling daily buckets self-expire; the `total` counter is permanent).
23
- */
24
- incrementCounters?: (items: Array<{
25
- key: string;
26
- delta: number;
27
- ttlSeconds?: number;
28
- }>) => Promise<Array<{
29
- key: string;
30
- value: number;
31
- }>>;
32
- /**
33
- * Prefix for canonical (metric-keyed) usage counters written by `/consume` and read by a
34
- * quota that omits `keyTemplate`. Default `'quota'`. Must match the spender's prefix so
35
- * metering keys and read keys agree across the deployment.
36
- */
37
- canonicalPrefix?: string;
38
- /**
39
- * Optional allowlist of metric names `/consume` will record. When set, any metric NOT in
40
- * the list is ignored (bounds Redis key cardinality against arbitrary metric names).
41
- * Undefined = meter every metric present in the usage payload (default, fully generic).
42
- */
43
- allowedMetrics?: string[];
18
+ readState?: (key: string) => Promise<unknown>;
44
19
  }
45
20
  /**
46
21
  * Endpoint-discovery admin + manifest routes.
@@ -20,6 +20,22 @@ export interface AdminGuardDeps {
20
20
  * fallback) — e.g. the payment plugin.
21
21
  */
22
22
  export declare function hasGodminRole(request: Request): boolean;
23
+ /**
24
+ * Authoritative godmin check via the DB `user_roles`→`roles` join.
25
+ *
26
+ * Unlike the header-only {@link hasGodminRole}, this does not depend on the
27
+ * `x-user-roles` header being fresh. The header is populated from the JWT
28
+ * `roles` claim, which can lag the DB after a token refresh — a header-only
29
+ * gate would then lock a REAL godmin out of RBAC management (assigning,
30
+ * patching, or removing role_claims) with a spurious 403, even though the DB
31
+ * still records the godmin role. Use as a fallback when the header check fails.
32
+ *
33
+ * Best-effort: returns false (never throws) when the DB/tables are missing or
34
+ * the query fails, so it can only ever GRANT access to a genuine godmin — never
35
+ * deny a real request on infrastructure hiccups beyond the header path already
36
+ * having failed.
37
+ */
38
+ export declare function userHasGodminRoleInDb(db: NodePgDatabase | null | undefined, schemaTables: Record<string, unknown>, userId: string | null | undefined, logger: Logger): Promise<boolean>;
23
39
  /**
24
40
  * Returns true when the request is made by a godmin user.
25
41
  *
@@ -0,0 +1,36 @@
1
+ import { type ClaimGuardCatalogEntry, type GuardCondition, type GuardPolicy, type GuardValueType } from './types';
2
+ /** The claim action a catalog entry seeds: the `name` up to the first `{`, trailing `:` stripped. */
3
+ export declare function guardActionFromName(name: string): string;
4
+ /**
5
+ * Parse one config `claimGuards` entry into the claim action + the `policy` jsonb to store on the
6
+ * seeded claim. Returns null for a malformed entry (missing name or unknown value type).
7
+ */
8
+ export declare function parseGuardCatalogEntry(entry: unknown): {
9
+ action: string;
10
+ policy: GuardPolicy;
11
+ } | null;
12
+ /** Parse a claim's `policy` jsonb into a GuardPolicy, or null if it isn't a guard. */
13
+ export declare function parseGuardPolicy(raw: unknown): GuardPolicy | null;
14
+ /** Substitute `{userId}` / `{tenant}` in a key template. Tenant defaults to `default`. */
15
+ export declare function resolveGuardKey(keyTemplate: string, ctx: {
16
+ userId: string;
17
+ tenant?: string;
18
+ }): string;
19
+ /**
20
+ * Parse the assignment condition (JSON stored in `role_claims.scope`) for a guard-claim of the
21
+ * given value type. Validates the operator is allowed for the type and the threshold fits the
22
+ * type. Returns null when the scope isn't a valid guard condition (so a plain scope string on a
23
+ * non-guard claim is simply ignored by the guard resolver).
24
+ */
25
+ export declare function parseGuardCondition(scope: unknown, valueType: GuardValueType): GuardCondition | null;
26
+ /**
27
+ * Evaluate a guard: does the condition HOLD for the current state value? `true` ⇒ invalidate the
28
+ * condition's claims. Absent/unparseable state ⇒ `false` (fail-open — never invalidate on missing
29
+ * data). Comparison is done in the declared type.
30
+ */
31
+ export declare function guardConditionHolds(rawValue: unknown, valueType: GuardValueType, condition: GuardCondition): boolean;
32
+ /** Convenience: parse a config `claimGuards` array into catalog entries (invalid entries dropped). */
33
+ export declare function parseClaimGuardCatalog(entries: readonly ClaimGuardCatalogEntry[] | undefined): Array<{
34
+ action: string;
35
+ policy: GuardPolicy;
36
+ }>;
@@ -0,0 +1,4 @@
1
+ export { guardActionFromName, guardConditionHolds, parseClaimGuardCatalog, parseGuardCatalogEntry, parseGuardCondition, parseGuardPolicy, resolveGuardKey, } from './evaluate';
2
+ export { type GuardResolution, resolveClaimGuards } from './resolve';
3
+ export { seedClaimGuards } from './seed';
4
+ export { type ClaimGuardCatalogEntry, type FiredGuard, type GuardCondition, type GuardOp, type GuardPolicy, type GuardValueType, OPS_BY_TYPE, type StateReader, } from './types';
@@ -0,0 +1,37 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import type { FiredGuard, GuardOp, GuardValueType, StateReader } from './types';
3
+ /** One fully-evaluated guard assignment for a user — powers both enforcement and display. */
4
+ export interface EvaluatedGuard {
5
+ /** Guard claim action, e.g. `token_spent_today`. */
6
+ action: string;
7
+ /** Resolved state key that was read. */
8
+ key: string;
9
+ valueType: GuardValueType;
10
+ op: GuardOp;
11
+ /** The threshold from the assignment condition. */
12
+ threshold: number | boolean | string;
13
+ /** Current value read from state (null when absent — fail-open). */
14
+ current: number | boolean | string | null;
15
+ /** Claim actions this guard invalidates when it holds. */
16
+ invalidates: string[];
17
+ /** Whether the condition currently holds (⇒ the claims are invalidated). */
18
+ holds: boolean;
19
+ }
20
+ export interface GuardResolution {
21
+ /** Every evaluated guard assignment (for the display endpoint). */
22
+ guards: EvaluatedGuard[];
23
+ /** Claim actions to remove from the user's effective claims right now (union over holding guards). */
24
+ suppressedClaims: string[];
25
+ /** The guards that fired (for a denial that names the guard). */
26
+ firedGuards: FiredGuard[];
27
+ }
28
+ /**
29
+ * Resolve which of a user's claims are invalidated RIGHT NOW by ClaimGuards. For every
30
+ * guard-claim the user holds (via any active role), read its state key from the shared store,
31
+ * evaluate the assignment's condition, and if it holds, invalidate that assignment's claim list.
32
+ * Reads whole small authz tables and filters in-memory, mirroring the manifest/quota resolvers.
33
+ * The state reader is injected so this stays testable and store-agnostic.
34
+ */
35
+ export declare function resolveClaimGuards(db: NodePgDatabase, schemaTables: Record<string, unknown>, userId: string, read: StateReader, ctx?: {
36
+ tenant?: string;
37
+ }): Promise<GuardResolution>;
@@ -0,0 +1,16 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import type { Logger } from 'src/Services/Logger';
3
+ import type { ClaimGuardCatalogEntry } from './types';
4
+ interface SeedGuardResult {
5
+ created: number;
6
+ updated: number;
7
+ deactivated: number;
8
+ }
9
+ /**
10
+ * Seed a claim per config `claimGuards` entry (action = name up to the first `{`, with the guard
11
+ * `policy` jsonb `{kind:'guard',keyTemplate,valueType}`). Idempotent + reconciling: an entry
12
+ * whose policy drifted is updated; a guard-claim no longer in config is deactivated (never
13
+ * deleted, mirroring endpoint-discovery reconcile). Non-guard claims are left untouched.
14
+ */
15
+ export declare function seedClaimGuards(db: NodePgDatabase, schemaTables: Record<string, unknown>, claimGuards: readonly ClaimGuardCatalogEntry[] | undefined, logger?: Logger): Promise<SeedGuardResult>;
16
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ClaimGuard — a generic, config-driven claim-invalidation primitive. A project defines a
3
+ * catalog of comparable keys (config `authorization.claimGuards`); godmin attaches a condition
4
+ * to a guard-claim and a list of claims to invalidate when the condition holds; at runtime
5
+ * nucleus reads the key from the shared Redis/Dapr state, evaluates the condition, and removes
6
+ * the listed claims from the user's effective set. nucleus names no metric/endpoint/window —
7
+ * quota is just one instantiation (see docs/specs/2026-07-04-claim-guards-design.md).
8
+ */
9
+ /** The value type of a guard key, declared in config; drives parsing + which operators are valid. */
10
+ export type GuardValueType = 'int' | 'boolean' | 'string';
11
+ export type GuardOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne';
12
+ /** Which operators are valid for each declared value type (int is ordered; bool/string are equality only). */
13
+ export declare const OPS_BY_TYPE: Record<GuardValueType, readonly GuardOp[]>;
14
+ /**
15
+ * The policy stored on a guard CLAIM (seeded from a config `claimGuards` entry). `keyTemplate`
16
+ * is the full config `name` (e.g. `token_spent_today:{userId}`); `{userId}`/`{tenant}` are
17
+ * resolved per request. `valueType` flows from config so nothing is hand-typed per claim.
18
+ */
19
+ export interface GuardPolicy {
20
+ kind: 'guard';
21
+ keyTemplate: string;
22
+ valueType: GuardValueType;
23
+ }
24
+ /**
25
+ * The condition stored on a role_claim ASSIGNMENT (as JSON in `role_claims.scope`). Set when a
26
+ * guard-claim is attached to a role, e.g. `token_spent_today ?gt: 100000` + selected claims.
27
+ * `invalidates` are the claim actions removed from the user's effective claims when the
28
+ * condition HOLDS — regardless of which role granted them.
29
+ */
30
+ export interface GuardCondition {
31
+ op: GuardOp;
32
+ value: number | boolean | string;
33
+ invalidates: string[];
34
+ }
35
+ /** One catalog entry as it appears in config `authorization.claimGuards`. */
36
+ export interface ClaimGuardCatalogEntry {
37
+ /** Full key template, e.g. `token_spent_today:{userId}` or a global `example_other_key`. */
38
+ name: string;
39
+ /** Declared value type. Accepts the string form used in config. */
40
+ value: GuardValueType;
41
+ }
42
+ /** Reads a raw value from the shared state store (Dapr/Redis). Injected so the resolver stays testable. */
43
+ export type StateReader = (key: string) => Promise<unknown>;
44
+ /** A guard that fired for a user this request — surfaced so a denial can name the guard. */
45
+ export interface FiredGuard {
46
+ /** The guard claim action, e.g. `token_spent_today`. */
47
+ action: string;
48
+ /** The resolved key that was read. */
49
+ key: string;
50
+ /** The claim actions this guard invalidated. */
51
+ invalidates: string[];
52
+ }
@@ -750,24 +750,20 @@ export interface NucleusConfigOptions {
750
750
  token?: string;
751
751
  };
752
752
  /**
753
- * Usage-quota metering settings. Metering is decoupled from quota existence: the
754
- * `/authorization/quotas/consume` endpoint records usage into canonical
755
- * `<prefix>:{tenant}:{userId}:<metric>:{date|total}` counters for EVERY reported metric,
756
- * so usage accumulates even when a user holds no quota claim, and a quota assigned later
757
- * (which omits `keyTemplate`) reads those same counters.
753
+ * ClaimGuards — generic, config-driven claim invalidation. Each entry defines a comparable
754
+ * key in the shared Redis/Dapr state and seeds a claim (action = the name up to the first
755
+ * `{`). At assignment time godmin attaches a condition to the guard-claim and selects the
756
+ * claims to invalidate when it holds (`role_claims.scope` JSON `{op,value,invalidates}`); at
757
+ * runtime nucleus reads the key, evaluates the condition, and drops the listed claims. The
758
+ * APP writes the key values. nucleus names no metric/endpoint/window — in Vorion this
759
+ * surfaces as a quota. See docs/specs/2026-07-04-claim-guards-design.md.
758
760
  */
759
- quota?: {
760
- /**
761
- * Prefix for the canonical usage counters. Default `'quota'`. Must match the prefix the
762
- * spender meters under so read keys and metering keys agree.
763
- */
764
- canonicalPrefix?: string;
765
- /**
766
- * Optional allowlist of metric names `/consume` will record. When set, metrics not in
767
- * the list are ignored (bounds Redis key cardinality). Undefined = meter every metric.
768
- */
769
- allowedMetrics?: string[];
770
- };
761
+ claimGuards?: Array<{
762
+ /** Key template, e.g. `token_spent_today:{userId}` or a global `feature_flag_x`. */
763
+ name: string;
764
+ /** Declared value type: drives parsing, valid operators, and threshold parsing. */
765
+ value: 'int' | 'boolean' | 'string';
766
+ }>;
771
767
  };
772
768
  audit?: {
773
769
  enabled?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.716",
3
+ "version": "0.9.718",
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",
@@ -1,15 +0,0 @@
1
- import type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaPolicy } from './types';
2
- /** Parse+validate a raw `claims.policy` jsonb value into a QuotaPolicy, or null if it isn't one. */
3
- export declare function parseQuotaPolicy(raw: unknown): QuotaPolicy | null;
4
- /**
5
- * Combine the per-role limits a user holds for one quota claim into a single effective limit.
6
- * `max` (default) = tier model (best role wins); `min` = strictest; `sum` = additive.
7
- * Non-numeric scopes are ignored. Returns null when no numeric limit is present.
8
- */
9
- export declare function aggregateLimit(scopes: readonly string[], mode: QuotaAggregate): number | null;
10
- /**
11
- * Read the current usage for a quota policy and compare it to the effective limit. Pure:
12
- * the counter reader is injected. Enforcement decision is the caller's — for a spend of
13
- * cost C, a HARD cap allows it iff `current + C <= limit` (lte) / `< limit` (lt).
14
- */
15
- export declare function evaluateQuota(policy: QuotaPolicy, ctx: QuotaContext, limit: number, read: CounterReader): Promise<QuotaEvaluation>;
@@ -1,5 +0,0 @@
1
- export { aggregateLimit, evaluateQuota, parseQuotaPolicy } from './evaluate';
2
- export { type ResolvedQuota, resolveUserQuotas } from './resolve';
3
- export { resolveSuppressedClaims } from './suppress';
4
- export type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaOp, QuotaPolicy, QuotaWindow, } from './types';
5
- export { bucketDatesForWindow, buildCanonicalIncrementKeys, buildIncrementKeys, buildQuotaKeys, canonicalKeyTemplate, DEFAULT_CANONICAL_PREFIX, sanitizeMetric, } from './windows';
@@ -1 +0,0 @@
1
- export {};
@@ -1,16 +0,0 @@
1
- import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
- import type { QuotaPolicy } from './types';
3
- export interface ResolvedQuota {
4
- action: string;
5
- policy: QuotaPolicy;
6
- /** effective limit after aggregating the per-role scopes for this claim */
7
- limit: number;
8
- }
9
- /**
10
- * Resolve a user's effective quota limits. For every QUOTA claim (`claims.policy.kind==='quota'`)
11
- * granted through any of the user's roles, aggregate the per-role limits — each carried in that
12
- * assignment's `role_claims.scope` — using the policy's `aggregate` rule (default `max`, the tier
13
- * model). Active rows only. Reads whole small authz tables and filters in-memory, mirroring the
14
- * existing manifest resolver (no dynamic-column WHERE).
15
- */
16
- export declare function resolveUserQuotas(db: NodePgDatabase, schemaTables: Record<string, unknown>, userId: string): Promise<ResolvedQuota[]>;
@@ -1,15 +0,0 @@
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[]>;
@@ -1,80 +0,0 @@
1
- /**
2
- * Generic usage-quota primitive. A quota is expressed as a CLAIM whose `policy` jsonb
3
- * column carries this shape; the per-role LIMIT lives in the `role_claims.scope` of each
4
- * assignment (so the same claim can be granted at different limits to different roles, and
5
- * a user's effective limit is an aggregation across their roles). The counter itself is
6
- * maintained by whoever does the spending (e.g. the LLM service) under the key described
7
- * here — nucleus only READS it to gate. Deliberately not spend-specific: `metric` is
8
- * free-form, so the same engine covers tokens, dollars, request counts, storage, etc.
9
- */
10
- export type QuotaOp = 'lte' | 'lt';
11
- /** How a user's per-role limits combine when they hold the claim via multiple roles. */
12
- export type QuotaAggregate = 'max' | 'min' | 'sum';
13
- export type QuotaWindow = {
14
- type: 'rolling';
15
- days: number;
16
- } | {
17
- type: 'calendar_day';
18
- } | {
19
- type: 'total';
20
- };
21
- export interface QuotaPolicy {
22
- kind: 'quota';
23
- source: 'redis';
24
- /** Free-form metric label, e.g. 'tokens' | 'cost_usd' | 'requests'. */
25
- metric: string;
26
- window: QuotaWindow;
27
- /**
28
- * Redis counter key template. Placeholders: `{userId}`, `{tenant}`, `{date}`.
29
- * Bucketed windows (rolling / calendar_day) substitute `{date}` (yyyy-mm-dd, UTC) once
30
- * per bucket. `total` uses the template as-is (must not contain `{date}`).
31
- * e.g. `usage:{tenant}:{userId}:tokens:{date}` or `usage:{tenant}:{userId}:tokens:total`.
32
- *
33
- * OPTIONAL: omit it to INHERIT the canonical per-metric template
34
- * `<prefix>:{tenant}:{userId}:<metric>:{date}` (see canonicalKeyTemplate). Omitting is
35
- * preferred for new quotas — it makes the keys this quota READS identical to the keys the
36
- * `/consume` metering endpoint WRITES for the same metric, so a quota assigned mid-window
37
- * already reflects the usage recorded before it existed.
38
- */
39
- keyTemplate?: string;
40
- /** Comparison for a HARD cap. `lte`: usage may reach the limit; `lt`: must stay below. Default `lte`. */
41
- op?: QuotaOp;
42
- /** Multi-role limit resolution. Default `max` (tier model: the most generous role wins). */
43
- aggregate?: QuotaAggregate;
44
- /** Optional display unit, e.g. 'tokens' | 'usd'. */
45
- unit?: string;
46
- /**
47
- * Claim actions this quota SUPPRESSES while it is exceeded. When a user is over this
48
- * quota, these claim actions are removed from their effective claim set for the rest of
49
- * the window, so any capability guarded by them is denied through the normal claim-gate.
50
- * This is what makes enforcement generic: nucleus never names an endpoint or a metric —
51
- * a project decides what a quota gates purely by listing claim actions here (data, not
52
- * code). Absent/empty = a metering-only quota (reported for status, gates nothing).
53
- */
54
- gates?: string[];
55
- }
56
- export interface QuotaContext {
57
- userId: string;
58
- tenant?: string;
59
- now: Date;
60
- /**
61
- * Prefix used to derive the canonical counter template when a policy omits `keyTemplate`.
62
- * Must match the prefix the spender meters under (default `'quota'`). Set from
63
- * `authorization.quota.canonicalPrefix` so read keys == metering keys across the deployment.
64
- */
65
- canonicalPrefix?: string;
66
- }
67
- export interface QuotaEvaluation {
68
- metric: string;
69
- window: QuotaWindow;
70
- limit: number;
71
- current: number;
72
- /** limit − current; negative when already over. */
73
- remaining: number;
74
- /** true when the cap is already reached (no room for another unit under `op`). */
75
- over: boolean;
76
- /** the concrete counter keys that were summed (for debugging / the status endpoint). */
77
- keys: string[];
78
- }
79
- /** Reads integer counter values for a set of keys (missing → 0). Injected so the evaluator stays pure. */
80
- export type CounterReader = (keys: string[]) => Promise<number[]>;