nucleus-core-ts 0.9.708 → 0.9.710
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/dist/client.js +1 -1
- package/dist/index.js +3 -3
- package/dist/src/Client/Proxy/httpProxy.d.ts +11 -1
- package/dist/src/Client/Proxy/httpProxy.js +2 -2
- package/dist/src/Client/Proxy/server.js +1 -1
- package/dist/src/Client/Proxy/types.d.ts +10 -0
- package/dist/src/Client/Proxy/wsProxy.d.ts +1 -1
- package/dist/src/Client/Proxy/wsProxy.js +41 -5
- package/dist/src/ElysiaPlugin/routes/authorization/index.d.ts +6 -0
- package/dist/src/Managers/Redis/index.d.ts +6 -0
- package/dist/src/Services/Authorization/Quota/evaluate.d.ts +15 -0
- package/dist/src/Services/Authorization/Quota/index.d.ts +4 -0
- package/dist/src/Services/Authorization/Quota/quota.test.d.ts +1 -0
- package/dist/src/Services/Authorization/Quota/resolve.d.ts +16 -0
- package/dist/src/Services/Authorization/Quota/types.d.ts +59 -0
- package/dist/src/Services/Authorization/Quota/windows.d.ts +11 -0
- package/dist/src/Services/Authorization/SeedRunner/index.d.ts +6 -0
- package/package.json +1 -1
- package/src/system.tables.json +4 -0
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import type { HttpProxyConfig } from './types';
|
|
1
|
+
import type { HttpProxyConfig, TokenRefreshConfig } from './types';
|
|
2
|
+
interface RefreshResult {
|
|
3
|
+
accessToken: string;
|
|
4
|
+
setCookieHeaders: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function deduplicatedRefresh(refreshConfig: TokenRefreshConfig, refreshToken: string, logger: {
|
|
7
|
+
info: (...args: unknown[]) => void;
|
|
8
|
+
warn: (...args: unknown[]) => void;
|
|
9
|
+
error: (...args: unknown[]) => void;
|
|
10
|
+
}, originalCookieHeader?: string | null): Promise<RefreshResult | null>;
|
|
2
11
|
export declare function createHttpProxyHandler(config: HttpProxyConfig): {
|
|
3
12
|
handle(req: Request, clientIp?: string): Promise<Response | null>;
|
|
4
13
|
};
|
|
14
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { evaluateAccess, ManifestCache,
|
|
1
|
+
import { evaluateAccess, ManifestCache, RoleClaimsCache, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
|
|
2
2
|
import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
3
|
const pendingRefreshes = new Map();
|
|
4
4
|
function extractAccessTokenFromSetCookie(setCookieHeaders, accessCookieName) {
|
|
@@ -89,7 +89,7 @@ async function performTokenRefresh(refreshConfig, refreshToken, logger, original
|
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
function deduplicatedRefresh(refreshConfig, refreshToken, logger, originalCookieHeader) {
|
|
92
|
+
export function deduplicatedRefresh(refreshConfig, refreshToken, logger, originalCookieHeader) {
|
|
93
93
|
const dedupeKey = refreshToken.slice(-16);
|
|
94
94
|
const existing = pendingRefreshes.get(dedupeKey);
|
|
95
95
|
if (existing) {
|
|
@@ -39,7 +39,7 @@ export async function createProxyServer(config) {
|
|
|
39
39
|
async fetch (req, server) {
|
|
40
40
|
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
41
41
|
if (wsHandler) {
|
|
42
|
-
const result = wsHandler.upgrade(req, server);
|
|
42
|
+
const result = await wsHandler.upgrade(req, server);
|
|
43
43
|
if (result === true) {
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
@@ -8,6 +8,16 @@ export interface WsProxyTarget {
|
|
|
8
8
|
queryParam?: string;
|
|
9
9
|
headerName?: string;
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Proactively refresh the access token before injecting it into the handshake.
|
|
13
|
+
* A WS handshake bearer MUST be the short-lived access token (the backend verifies
|
|
14
|
+
* it with the access-token secret) — a `refresh_token`/`session_token` fallback is
|
|
15
|
+
* signed with a DIFFERENT secret and always fails verification, producing a 4001
|
|
16
|
+
* reconnect storm once the access-token cookie ages out (e.g. after 30m idle on a
|
|
17
|
+
* sibling subdomain). When set, if the access cookie is missing/expiring and a
|
|
18
|
+
* refresh cookie is present, exchange it for a fresh access token and inject THAT.
|
|
19
|
+
*/
|
|
20
|
+
tokenRefresh?: TokenRefreshConfig;
|
|
11
21
|
headers?: Record<string, string>;
|
|
12
22
|
changeOrigin?: boolean;
|
|
13
23
|
secure?: boolean;
|
|
@@ -5,7 +5,7 @@ export declare function createWsProxyHandler(config: WsProxyConfig): {
|
|
|
5
5
|
upgrade: (req: Request, options: {
|
|
6
6
|
data: WsProxyState;
|
|
7
7
|
}) => boolean;
|
|
8
|
-
}): boolean | Response
|
|
8
|
+
}): Promise<boolean | Response>;
|
|
9
9
|
websocket: {
|
|
10
10
|
open(ws: ServerWebSocket<WsProxyState>): void;
|
|
11
11
|
message(ws: ServerWebSocket<WsProxyState>, message: string | Buffer): void;
|
|
@@ -1,4 +1,21 @@
|
|
|
1
|
+
import { deduplicatedRefresh } from './httpProxy';
|
|
1
2
|
import { addQueryParam, createProxyLogger, httpToWs, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
|
+
/**
|
|
4
|
+
* True when an access token is absent or within `skewMs` of expiring. Decodes the JWT
|
|
5
|
+
* exp WITHOUT verifying (verification happens at the backend) — this only decides whether
|
|
6
|
+
* to pre-emptively refresh before the WS handshake.
|
|
7
|
+
*/ function accessTokenExpiringSoon(token, skewMs = 60000) {
|
|
8
|
+
if (!token) return true;
|
|
9
|
+
try {
|
|
10
|
+
const part = token.split('.')[1];
|
|
11
|
+
if (!part) return true;
|
|
12
|
+
const json = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
13
|
+
if (typeof json.exp !== 'number') return false;
|
|
14
|
+
return json.exp * 1000 - Date.now() < skewMs;
|
|
15
|
+
} catch {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
2
19
|
export function createWsProxyHandler(config) {
|
|
3
20
|
const logger = createProxyLogger('WS Proxy', config.debug ?? false);
|
|
4
21
|
const pingIntervalMs = config.pingIntervalMs ?? 0;
|
|
@@ -10,7 +27,7 @@ export function createWsProxyHandler(config) {
|
|
|
10
27
|
}
|
|
11
28
|
return null;
|
|
12
29
|
}
|
|
13
|
-
function buildBackendUrl(path, target, cookies, query) {
|
|
30
|
+
async function buildBackendUrl(path, target, cookies, query) {
|
|
14
31
|
let baseUrl = target.url.replace(/\/$/, '');
|
|
15
32
|
if (baseUrl.startsWith('http')) {
|
|
16
33
|
baseUrl = httpToWs(baseUrl);
|
|
@@ -40,6 +57,25 @@ export function createWsProxyHandler(config) {
|
|
|
40
57
|
} else {
|
|
41
58
|
let token = cookies[target.injectTokenFromCookie.cookieName];
|
|
42
59
|
let tokenSource = target.injectTokenFromCookie.cookieName;
|
|
60
|
+
// A WS handshake bearer MUST be the short-lived access token. If it's missing or
|
|
61
|
+
// about to expire and a refresh is configured, exchange the refresh cookie for a
|
|
62
|
+
// FRESH access token and inject that — NEVER fall through to a refresh/session
|
|
63
|
+
// cookie, which is signed with a different secret and guarantees a 4001 storm.
|
|
64
|
+
const refreshConfig = target.tokenRefresh;
|
|
65
|
+
const refreshToken = cookies[refreshConfig?.refreshCookieName ?? 'refresh_token'];
|
|
66
|
+
if (refreshConfig?.enabled && refreshToken && accessTokenExpiringSoon(token)) {
|
|
67
|
+
const cookieHeader = Object.entries(cookies).map(([k, v])=>`${k}=${v}`).join('; ');
|
|
68
|
+
const refreshed = await deduplicatedRefresh(refreshConfig, refreshToken, logger, cookieHeader);
|
|
69
|
+
if (refreshed?.accessToken) {
|
|
70
|
+
token = refreshed.accessToken;
|
|
71
|
+
tokenSource = 'refreshed access_token';
|
|
72
|
+
refreshConfig.onRefreshSuccess?.(path);
|
|
73
|
+
} else {
|
|
74
|
+
refreshConfig.onRefreshFailure?.(path, 'WS pre-handshake refresh failed');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Legacy fallback — reached only when refresh is NOT configured. Prefer configuring
|
|
78
|
+
// tokenRefresh: a refresh/session cookie here is not a valid handshake bearer.
|
|
43
79
|
if (!token && target.injectTokenFromCookie.fallbackCookieNames) {
|
|
44
80
|
for (const fallback of target.injectTokenFromCookie.fallbackCookieNames){
|
|
45
81
|
if (cookies[fallback]) {
|
|
@@ -60,7 +96,7 @@ export function createWsProxyHandler(config) {
|
|
|
60
96
|
return url;
|
|
61
97
|
}
|
|
62
98
|
return {
|
|
63
|
-
upgrade (req, server) {
|
|
99
|
+
async upgrade (req, server) {
|
|
64
100
|
const url = new URL(req.url);
|
|
65
101
|
const path = url.pathname;
|
|
66
102
|
if (config.allowHmr !== false && path.startsWith('/_next/webpack-hmr')) {
|
|
@@ -78,7 +114,7 @@ export function createWsProxyHandler(config) {
|
|
|
78
114
|
return false;
|
|
79
115
|
}
|
|
80
116
|
const cookies = parseCookies(req.headers.get('cookie'));
|
|
81
|
-
const backendUrl = buildBackendUrl(path, target, cookies, url.searchParams);
|
|
117
|
+
const backendUrl = await buildBackendUrl(path, target, cookies, url.searchParams);
|
|
82
118
|
logger.info(`Upgrading: ${path} -> ${backendUrl}`);
|
|
83
119
|
// Extract original request headers to forward to backend
|
|
84
120
|
const originalHeaders = {};
|
|
@@ -224,8 +260,8 @@ export function startWsProxyServer(config) {
|
|
|
224
260
|
const server = Bun.serve({
|
|
225
261
|
port,
|
|
226
262
|
hostname,
|
|
227
|
-
fetch (req, server) {
|
|
228
|
-
const result = handler.upgrade(req, server);
|
|
263
|
+
async fetch (req, server) {
|
|
264
|
+
const result = await handler.upgrade(req, server);
|
|
229
265
|
if (result === true) {
|
|
230
266
|
return undefined;
|
|
231
267
|
}
|
|
@@ -10,6 +10,12 @@ export interface AuthorizationRoutesConfig {
|
|
|
10
10
|
discovery: EndpointDiscoveryConfig & {
|
|
11
11
|
token?: string;
|
|
12
12
|
};
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
readCounters?: (keys: string[]) => Promise<number[]>;
|
|
13
19
|
}
|
|
14
20
|
/**
|
|
15
21
|
* Endpoint-discovery admin + manifest routes.
|
|
@@ -27,6 +27,12 @@ export declare class RedisManager {
|
|
|
27
27
|
update<T>(key: string, value: T, preserveTtl?: boolean): Promise<RedisResult<'OK'>>;
|
|
28
28
|
remove(key: string): Promise<RedisResult<number>>;
|
|
29
29
|
exists(key: string): Promise<RedisResult<boolean>>;
|
|
30
|
+
/**
|
|
31
|
+
* Read a batch of integer counter keys (missing / non-numeric → 0). Works in both
|
|
32
|
+
* direct and Dapr modes via the state-store abstraction. Used by the quota evaluator to
|
|
33
|
+
* gate usage; the counters themselves are maintained (INCR'd) by the spender.
|
|
34
|
+
*/
|
|
35
|
+
readCounters(keys: string[]): Promise<number[]>;
|
|
30
36
|
/**
|
|
31
37
|
* Returns the underlying ioredis client when running in direct mode.
|
|
32
38
|
* Returns null in Dapr mode — raw Redis commands (INFO, KEYS, …) are not
|
|
@@ -0,0 +1,15 @@
|
|
|
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>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { aggregateLimit, evaluateQuota, parseQuotaPolicy } from './evaluate';
|
|
2
|
+
export { type ResolvedQuota, resolveUserQuotas } from './resolve';
|
|
3
|
+
export type { CounterReader, QuotaAggregate, QuotaContext, QuotaEvaluation, QuotaOp, QuotaPolicy, QuotaWindow, } from './types';
|
|
4
|
+
export { bucketDatesForWindow, buildQuotaKeys } from './windows';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
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[]>;
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
keyTemplate: string;
|
|
34
|
+
/** Comparison for a HARD cap. `lte`: usage may reach the limit; `lt`: must stay below. Default `lte`. */
|
|
35
|
+
op?: QuotaOp;
|
|
36
|
+
/** Multi-role limit resolution. Default `max` (tier model: the most generous role wins). */
|
|
37
|
+
aggregate?: QuotaAggregate;
|
|
38
|
+
/** Optional display unit, e.g. 'tokens' | 'usd'. */
|
|
39
|
+
unit?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface QuotaContext {
|
|
42
|
+
userId: string;
|
|
43
|
+
tenant?: string;
|
|
44
|
+
now: Date;
|
|
45
|
+
}
|
|
46
|
+
export interface QuotaEvaluation {
|
|
47
|
+
metric: string;
|
|
48
|
+
window: QuotaWindow;
|
|
49
|
+
limit: number;
|
|
50
|
+
current: number;
|
|
51
|
+
/** limit − current; negative when already over. */
|
|
52
|
+
remaining: number;
|
|
53
|
+
/** true when the cap is already reached (no room for another unit under `op`). */
|
|
54
|
+
over: boolean;
|
|
55
|
+
/** the concrete counter keys that were summed (for debugging / the status endpoint). */
|
|
56
|
+
keys: string[];
|
|
57
|
+
}
|
|
58
|
+
/** Reads integer counter values for a set of keys (missing → 0). Injected so the evaluator stays pure. */
|
|
59
|
+
export type CounterReader = (keys: string[]) => Promise<number[]>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { QuotaContext, QuotaPolicy, QuotaWindow } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The yyyy-mm-dd daily buckets a window spans, most-recent first, or `null` for `total`
|
|
4
|
+
* (which is a single cumulative counter with no date bucket). Rolling windows include today.
|
|
5
|
+
*/
|
|
6
|
+
export declare function bucketDatesForWindow(window: QuotaWindow, now: Date): string[] | null;
|
|
7
|
+
/**
|
|
8
|
+
* Concrete Redis counter keys for a policy + context. Substitutes `{userId}`/`{tenant}`,
|
|
9
|
+
* then `{date}` per bucket (or none for `total`). The spender maintains these; nucleus reads them.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildQuotaKeys(policy: QuotaPolicy, ctx: QuotaContext): string[];
|
|
@@ -10,6 +10,12 @@ type SeedConfig = {
|
|
|
10
10
|
path: string;
|
|
11
11
|
method: string;
|
|
12
12
|
description?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional generic policy dictionary (jsonb) — e.g. a QUOTA policy
|
|
15
|
+
* (`{ kind:'quota', metric, window, keyTemplate, ... }`). Reconciled on
|
|
16
|
+
* drift like `scope`, so editing a seeded quota claim's policy takes effect.
|
|
17
|
+
*/
|
|
18
|
+
policy?: Record<string, unknown> | null;
|
|
13
19
|
}>;
|
|
14
20
|
roleClaimAssignments?: Array<{
|
|
15
21
|
role: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.710",
|
|
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",
|