nucleus-core-ts 0.9.967 → 0.9.968
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/bin/cli.ts +131 -131
- package/dist/.build-ok +1 -1
- package/dist/client.js +1 -1
- package/dist/dist/index.d.ts +71 -5
- package/dist/index.js +11 -11
- package/dist/src/Client/ApiCaller/types.d.ts +67 -0
- package/dist/src/Client/Proxy/httpProxy.js +70 -70
- package/dist/src/Client/Proxy/wsProxy.js +26 -26
- package/dist/src/Client/PubSub/usePubSub.js +19 -19
- package/dist/src/ElysiaPlugin/routes/auth/admin/lockoutState.d.ts +36 -0
- package/dist/src/ElysiaPlugin/routes/auth/admin/passwordAdmin.d.ts +8 -1
- package/dist/src/ElysiaPlugin/routes/auth/admin/requireGodmin.d.ts +17 -0
- package/dist/src/ElysiaPlugin/routes/auth/admin/userState.d.ts +46 -0
- package/dist/src/Services/Authorization/ClaimsCache/index.d.ts +8 -1
- package/dist/src/Services/Captcha/types.d.ts +8 -1
- package/dist/src/Utils/storedJson.d.ts +29 -0
- package/package.json +1 -1
- package/scripts/build.ts +24 -2
- package/scripts/generate-schema.ts +739 -739
- package/src/system.tables.json +5527 -5527
|
@@ -1027,6 +1027,49 @@ export type AdminHardDeleteUserResponse = {
|
|
|
1027
1027
|
cleanedRelations: string[];
|
|
1028
1028
|
};
|
|
1029
1029
|
};
|
|
1030
|
+
/**
|
|
1031
|
+
* Account-state administration. These three exist because the generic entity
|
|
1032
|
+
* API refuses `is_locked`, `locked_until`, `failed_login_attempts`,
|
|
1033
|
+
* `verified_at` and `email_verified` — a write grant on `users` must not be a
|
|
1034
|
+
* way to self-verify or defeat lockout. A UI that PATCHes `users` with those
|
|
1035
|
+
* fields gets `200 success: true` and changes nothing, so it must call these
|
|
1036
|
+
* instead.
|
|
1037
|
+
*/
|
|
1038
|
+
export type AdminLockUserPayload = {
|
|
1039
|
+
userId: string;
|
|
1040
|
+
/** ISO timestamp for a temporary lock; omit to lock indefinitely. */
|
|
1041
|
+
until?: string;
|
|
1042
|
+
};
|
|
1043
|
+
export type AdminLockUserResponse = {
|
|
1044
|
+
success: boolean;
|
|
1045
|
+
message?: string;
|
|
1046
|
+
data?: {
|
|
1047
|
+
isLocked: boolean;
|
|
1048
|
+
lockedUntil: string | null;
|
|
1049
|
+
};
|
|
1050
|
+
};
|
|
1051
|
+
export type AdminUnlockUserPayload = {
|
|
1052
|
+
userId: string;
|
|
1053
|
+
};
|
|
1054
|
+
export type AdminUnlockUserResponse = {
|
|
1055
|
+
success: boolean;
|
|
1056
|
+
message?: string;
|
|
1057
|
+
data?: {
|
|
1058
|
+
wasLocked: boolean;
|
|
1059
|
+
clearedFailedAttempts: number;
|
|
1060
|
+
};
|
|
1061
|
+
};
|
|
1062
|
+
export type AdminMarkEmailVerifiedPayload = {
|
|
1063
|
+
userId: string;
|
|
1064
|
+
};
|
|
1065
|
+
export type AdminMarkEmailVerifiedResponse = {
|
|
1066
|
+
success: boolean;
|
|
1067
|
+
message?: string;
|
|
1068
|
+
data?: {
|
|
1069
|
+
verifiedAt: string;
|
|
1070
|
+
alreadyVerified: boolean;
|
|
1071
|
+
};
|
|
1072
|
+
};
|
|
1030
1073
|
export type AuthFeatureKey = keyof typeof AUTH_ENDPOINT_CONFIGS;
|
|
1031
1074
|
type AuthEndpointMap = {
|
|
1032
1075
|
login: {
|
|
@@ -1487,6 +1530,30 @@ export declare const AUTH_ENDPOINTS: {
|
|
|
1487
1530
|
_success: AdminHardDeleteUserResponse;
|
|
1488
1531
|
_error: BaseErrorResponse;
|
|
1489
1532
|
};
|
|
1533
|
+
ADMIN_LOCK_USER: {
|
|
1534
|
+
method: "POST";
|
|
1535
|
+
path: string;
|
|
1536
|
+
isPublic: false;
|
|
1537
|
+
_payload: AdminLockUserPayload;
|
|
1538
|
+
_success: AdminLockUserResponse;
|
|
1539
|
+
_error: BaseErrorResponse;
|
|
1540
|
+
};
|
|
1541
|
+
ADMIN_UNLOCK_USER: {
|
|
1542
|
+
method: "POST";
|
|
1543
|
+
path: string;
|
|
1544
|
+
isPublic: false;
|
|
1545
|
+
_payload: AdminUnlockUserPayload;
|
|
1546
|
+
_success: AdminUnlockUserResponse;
|
|
1547
|
+
_error: BaseErrorResponse;
|
|
1548
|
+
};
|
|
1549
|
+
ADMIN_MARK_EMAIL_VERIFIED: {
|
|
1550
|
+
method: "POST";
|
|
1551
|
+
path: string;
|
|
1552
|
+
isPublic: false;
|
|
1553
|
+
_payload: AdminMarkEmailVerifiedPayload;
|
|
1554
|
+
_success: AdminMarkEmailVerifiedResponse;
|
|
1555
|
+
_error: BaseErrorResponse;
|
|
1556
|
+
};
|
|
1490
1557
|
API_KEYS_CREATE: {
|
|
1491
1558
|
method: "POST";
|
|
1492
1559
|
path: string;
|
|
@@ -134,11 +134,11 @@ function appendSetCookieHeaders(responseHeaders, setCookieHeaders) {
|
|
|
134
134
|
responseHeaders.append('Set-Cookie', cookie);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
-
/**
|
|
138
|
-
* Preserve individual Set-Cookie headers when copying via new Headers().
|
|
139
|
-
* new Headers(response.headers) may combine multiple Set-Cookie headers
|
|
140
|
-
* into a single comma-separated value — browsers cannot parse this for
|
|
141
|
-
* Set-Cookie (RFC 6265). This function re-adds them individually.
|
|
137
|
+
/**
|
|
138
|
+
* Preserve individual Set-Cookie headers when copying via new Headers().
|
|
139
|
+
* new Headers(response.headers) may combine multiple Set-Cookie headers
|
|
140
|
+
* into a single comma-separated value — browsers cannot parse this for
|
|
141
|
+
* Set-Cookie (RFC 6265). This function re-adds them individually.
|
|
142
142
|
*/ function preserveSetCookieHeaders(target, source) {
|
|
143
143
|
const setCookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
|
|
144
144
|
if (setCookies.length > 1) {
|
|
@@ -148,10 +148,10 @@ function appendSetCookieHeaders(responseHeaders, setCookieHeaders) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Resolve the user id to inject as x-user-id from a bearer token, or null if it can't be
|
|
153
|
-
* TRUSTED. Fail-closed: returns null unless BOTH a secret and a token are present AND the token
|
|
154
|
-
* passes HS256 signature + expiry verification. Never trusts a bare/forged/unsigned token.
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the user id to inject as x-user-id from a bearer token, or null if it can't be
|
|
153
|
+
* TRUSTED. Fail-closed: returns null unless BOTH a secret and a token are present AND the token
|
|
154
|
+
* passes HS256 signature + expiry verification. Never trusts a bare/forged/unsigned token.
|
|
155
155
|
*/ export function resolveInjectedUserId(token, secret) {
|
|
156
156
|
if (!secret || !token) return null;
|
|
157
157
|
const payload = verifyJwtHS256(token, secret);
|
|
@@ -178,9 +178,9 @@ export function createHttpProxyHandler(config) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
-
/**
|
|
182
|
-
* Claim-gate a request for a target. Returns a 403 Response to block (enforce
|
|
183
|
-
* mode), or null to allow (also null in audit mode, where a would-block is logged).
|
|
181
|
+
/**
|
|
182
|
+
* Claim-gate a request for a target. Returns a 403 Response to block (enforce
|
|
183
|
+
* mode), or null to allow (also null in audit mode, where a would-block is logged).
|
|
184
184
|
*/ async function checkAuthorize(req, target, path) {
|
|
185
185
|
const az = target.authorize;
|
|
186
186
|
if (!az) return null;
|
|
@@ -368,30 +368,30 @@ export function createHttpProxyHandler(config) {
|
|
|
368
368
|
function injectToken(headers, token, headerName) {
|
|
369
369
|
headers.set(headerName, `Bearer ${token}`);
|
|
370
370
|
}
|
|
371
|
-
/**
|
|
372
|
-
* Forward one request upstream, bounded by BOTH clocks that can end it.
|
|
373
|
-
*
|
|
374
|
-
* The timeout below only bounds time-to-first-headers: it is cleared the
|
|
375
|
-
* moment headers arrive, because a streamed body (SSE, a long download, a
|
|
376
|
-
* model's token stream) legitimately takes as long as it takes. That is
|
|
377
|
-
* correct — but on its own it left a streamed request with NO way to end.
|
|
378
|
-
*
|
|
379
|
-
* So when the person closed the tab, the browser's connection went away and
|
|
380
|
-
* ours did not. The upstream request stayed open, holding one of the
|
|
381
|
-
* runtime's outgoing connection slots, forever.
|
|
382
|
-
*
|
|
383
|
-
* That is not a slow leak. Measured on Bun 1.3.14: 256 clients that connect
|
|
384
|
-
* and hang up leave 256 upstream requests open (0 cancelled), and the process
|
|
385
|
-
* can then make no outgoing request at all — every later fetch times out.
|
|
386
|
-
* A single tab reconnecting an EventSource 256 times does it alone.
|
|
387
|
-
*
|
|
388
|
-
* Cancelling the body we hand back (`body.cancel()`) does NOT release the
|
|
389
|
-
* slot; only aborting the upstream fetch does. Measured both ways.
|
|
390
|
-
*
|
|
391
|
-
* Hence `clientSignal`: the client's hangup is wired to the same controller
|
|
392
|
-
* the timeout uses, so whichever ends first ends the upstream request. The
|
|
393
|
-
* listener is left attached deliberately — it belongs to this one request and
|
|
394
|
-
* is collected with it, and aborting an already-finished fetch is a no-op.
|
|
371
|
+
/**
|
|
372
|
+
* Forward one request upstream, bounded by BOTH clocks that can end it.
|
|
373
|
+
*
|
|
374
|
+
* The timeout below only bounds time-to-first-headers: it is cleared the
|
|
375
|
+
* moment headers arrive, because a streamed body (SSE, a long download, a
|
|
376
|
+
* model's token stream) legitimately takes as long as it takes. That is
|
|
377
|
+
* correct — but on its own it left a streamed request with NO way to end.
|
|
378
|
+
*
|
|
379
|
+
* So when the person closed the tab, the browser's connection went away and
|
|
380
|
+
* ours did not. The upstream request stayed open, holding one of the
|
|
381
|
+
* runtime's outgoing connection slots, forever.
|
|
382
|
+
*
|
|
383
|
+
* That is not a slow leak. Measured on Bun 1.3.14: 256 clients that connect
|
|
384
|
+
* and hang up leave 256 upstream requests open (0 cancelled), and the process
|
|
385
|
+
* can then make no outgoing request at all — every later fetch times out.
|
|
386
|
+
* A single tab reconnecting an EventSource 256 times does it alone.
|
|
387
|
+
*
|
|
388
|
+
* Cancelling the body we hand back (`body.cancel()`) does NOT release the
|
|
389
|
+
* slot; only aborting the upstream fetch does. Measured both ways.
|
|
390
|
+
*
|
|
391
|
+
* Hence `clientSignal`: the client's hangup is wired to the same controller
|
|
392
|
+
* the timeout uses, so whichever ends first ends the upstream request. The
|
|
393
|
+
* listener is left attached deliberately — it belongs to this one request and
|
|
394
|
+
* is collected with it, and aborting an already-finished fetch is a no-op.
|
|
395
395
|
*/ async function forwardRequest(finalUrl, method, headers, body, timeout, followRedirects, clientSignal) {
|
|
396
396
|
const controller = new AbortController();
|
|
397
397
|
const timeoutId = setTimeout(()=>controller.abort(), timeout);
|
|
@@ -407,13 +407,13 @@ export function createHttpProxyHandler(config) {
|
|
|
407
407
|
clientGone = true;
|
|
408
408
|
clearTimeout(timeoutId);
|
|
409
409
|
controller.abort();
|
|
410
|
-
/*
|
|
411
|
-
* Logged HERE, not in the catch below, because the common case
|
|
412
|
-
* never reaches the catch: once headers have arrived this function
|
|
413
|
-
* has already returned, and the hangup happens while the body is
|
|
414
|
-
* still streaming. That is exactly the case that used to leak, so
|
|
415
|
-
* it is exactly the case that has to be visible — a cancellation
|
|
416
|
-
* nobody can see is how this went unnoticed for months.
|
|
410
|
+
/*
|
|
411
|
+
* Logged HERE, not in the catch below, because the common case
|
|
412
|
+
* never reaches the catch: once headers have arrived this function
|
|
413
|
+
* has already returned, and the hangup happens while the body is
|
|
414
|
+
* still streaming. That is exactly the case that used to leak, so
|
|
415
|
+
* it is exactly the case that has to be visible — a cancellation
|
|
416
|
+
* nobody can see is how this went unnoticed for months.
|
|
417
417
|
*/ logger.info(`Client disconnected, upstream cancelled: ${finalUrl}`);
|
|
418
418
|
}, {
|
|
419
419
|
once: true
|
|
@@ -519,30 +519,30 @@ export function createHttpProxyHandler(config) {
|
|
|
519
519
|
logger.warn(`[Auth:proxy] NO TOKEN available for ${path} — request will likely fail with 401`);
|
|
520
520
|
}
|
|
521
521
|
const baseTimeout = target.timeout ?? 30000;
|
|
522
|
-
/*
|
|
523
|
-
* A file needs minutes, not thirty seconds.
|
|
524
|
-
*
|
|
525
|
-
* The fixed timeout aborted a 200 MB video long before the last byte, and
|
|
526
|
-
* the browser had no way to tell that from a slow line — the bar simply
|
|
527
|
-
* stopped. The allowance now grows with the declared size.
|
|
522
|
+
/*
|
|
523
|
+
* A file needs minutes, not thirty seconds.
|
|
524
|
+
*
|
|
525
|
+
* The fixed timeout aborted a 200 MB video long before the last byte, and
|
|
526
|
+
* the browser had no way to tell that from a slow line — the bar simply
|
|
527
|
+
* stopped. The allowance now grows with the declared size.
|
|
528
528
|
*/ const timeout = timeoutForBody(req.headers, baseTimeout);
|
|
529
529
|
const followRedirects = target.followRedirects !== false;
|
|
530
530
|
// Buffer body for potential retry (only if 401 retry is enabled)
|
|
531
531
|
let bodyForRetry = null;
|
|
532
532
|
let bodyToSend = req.body;
|
|
533
|
-
/*
|
|
534
|
-
* Only a body small enough to hold.
|
|
535
|
-
*
|
|
536
|
-
* Buffering exists so a 401 can be answered by refreshing the token and
|
|
537
|
-
* sending the same request again — worth it for a form post, fatal for a
|
|
538
|
-
* file. An 800 MB video was read into an ArrayBuffer inside a container
|
|
539
|
-
* limited to 256 MiB: the upload appeared to run, because the browser
|
|
540
|
-
* really was sending bytes, and then died with nothing to show for it.
|
|
541
|
-
*
|
|
542
|
-
* A large body streams straight through and gives up the retry. That is
|
|
543
|
-
* the right way round: an upload that is refused can be started again by
|
|
544
|
-
* the person who chose the file; an upload that never completes cannot be
|
|
545
|
-
* rescued by anybody.
|
|
533
|
+
/*
|
|
534
|
+
* Only a body small enough to hold.
|
|
535
|
+
*
|
|
536
|
+
* Buffering exists so a 401 can be answered by refreshing the token and
|
|
537
|
+
* sending the same request again — worth it for a form post, fatal for a
|
|
538
|
+
* file. An 800 MB video was read into an ArrayBuffer inside a container
|
|
539
|
+
* limited to 256 MiB: the upload appeared to run, because the browser
|
|
540
|
+
* really was sending bytes, and then died with nothing to show for it.
|
|
541
|
+
*
|
|
542
|
+
* A large body streams straight through and gives up the retry. That is
|
|
543
|
+
* the right way round: an upload that is refused can be started again by
|
|
544
|
+
* the person who chose the file; an upload that never completes cannot be
|
|
545
|
+
* rescued by anybody.
|
|
546
546
|
*/ const bufferable = canBufferForRetry(req.headers);
|
|
547
547
|
if (refreshEnabled && (refreshConfig.retryOn401 ?? true) && req.body && bufferable) {
|
|
548
548
|
try {
|
|
@@ -616,14 +616,14 @@ export function createHttpProxyHandler(config) {
|
|
|
616
616
|
} catch (error) {
|
|
617
617
|
const duration = performance.now() - startTime;
|
|
618
618
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
619
|
-
/*
|
|
620
|
-
* The person closed the tab. Nothing failed, and there is nobody left
|
|
621
|
-
* to send a status to — so this is not an error, and calling it one
|
|
622
|
-
* sends whoever reads the log hunting a timeout that never happened.
|
|
623
|
-
*
|
|
624
|
-
* Reported with the elapsed time, because on a streaming path "the
|
|
625
|
-
* client left after 4 minutes" is genuinely useful and "Gateway
|
|
626
|
-
* Timeout" is genuinely misleading.
|
|
619
|
+
/*
|
|
620
|
+
* The person closed the tab. Nothing failed, and there is nobody left
|
|
621
|
+
* to send a status to — so this is not an error, and calling it one
|
|
622
|
+
* sends whoever reads the log hunting a timeout that never happened.
|
|
623
|
+
*
|
|
624
|
+
* Reported with the elapsed time, because on a streaming path "the
|
|
625
|
+
* client left after 4 minutes" is genuinely useful and "Gateway
|
|
626
|
+
* Timeout" is genuinely misleading.
|
|
627
627
|
*/ if (err.name === 'ClientGoneError') {
|
|
628
628
|
logger.info(`Client disconnected after ${duration.toFixed(0)}ms, upstream cancelled: ${path}`);
|
|
629
629
|
return new Response(null, {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { deduplicatedRefresh } from './httpProxy';
|
|
2
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.
|
|
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
7
|
*/ function accessTokenExpiringSoon(token, skewMs = 60000) {
|
|
8
8
|
if (!token) return true;
|
|
9
9
|
try {
|
|
@@ -16,20 +16,20 @@ import { addQueryParam, createProxyLogger, httpToWs, matchPath, parseCookies, re
|
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Keeps the proxy→backend socket alive with a PROTOCOL ping, not a data frame.
|
|
21
|
-
*
|
|
22
|
-
* This used to send `''`. An empty text frame is still application data, so it
|
|
23
|
-
* arrived at the backend's `message` handler, where `JSON.parse('')` throws —
|
|
24
|
-
* and nucleus' own PubSub route logged "Failed to parse client message" once per
|
|
25
|
-
* interval per connected client. On a busy IDP that is the entire debug log, and
|
|
26
|
-
* it names the proxy's own heartbeat as a misbehaving client.
|
|
27
|
-
*
|
|
28
|
-
* `ping()` is a control frame: it never reaches `message`, the peer answers with
|
|
29
|
-
* pong automatically, and it resets the idle timer the same way. Bun's WebSocket
|
|
30
|
-
* client implements it; the JSON heartbeat is the fallback for a runtime that
|
|
31
|
-
* does not, because `{"type":"ping"}` is a frame the PubSub route understands and
|
|
32
|
-
* replies to, rather than one it can only fail to parse.
|
|
19
|
+
/**
|
|
20
|
+
* Keeps the proxy→backend socket alive with a PROTOCOL ping, not a data frame.
|
|
21
|
+
*
|
|
22
|
+
* This used to send `''`. An empty text frame is still application data, so it
|
|
23
|
+
* arrived at the backend's `message` handler, where `JSON.parse('')` throws —
|
|
24
|
+
* and nucleus' own PubSub route logged "Failed to parse client message" once per
|
|
25
|
+
* interval per connected client. On a busy IDP that is the entire debug log, and
|
|
26
|
+
* it names the proxy's own heartbeat as a misbehaving client.
|
|
27
|
+
*
|
|
28
|
+
* `ping()` is a control frame: it never reaches `message`, the peer answers with
|
|
29
|
+
* pong automatically, and it resets the idle timer the same way. Bun's WebSocket
|
|
30
|
+
* client implements it; the JSON heartbeat is the fallback for a runtime that
|
|
31
|
+
* does not, because `{"type":"ping"}` is a frame the PubSub route understands and
|
|
32
|
+
* replies to, rather than one it can only fail to parse.
|
|
33
33
|
*/ function pingBackend(backendWs) {
|
|
34
34
|
const withPing = backendWs;
|
|
35
35
|
if (typeof withPing.ping === 'function') {
|
|
@@ -51,14 +51,14 @@ export function createWsProxyHandler(config) {
|
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
55
|
-
* The backend URL, plus whether this handshake carries a credential at all.
|
|
56
|
-
*
|
|
57
|
-
* The second half is the point. This function already KNEW when it had no
|
|
58
|
-
* token — it logged "No token found" and handed back a URL anyway, and the
|
|
59
|
-
* caller upgraded the socket regardless. That silence is what turns one
|
|
60
|
-
* logged-out browser tab into a permanent reconnect storm; see the refusal in
|
|
61
|
-
* `upgrade()` for the measurements.
|
|
54
|
+
/**
|
|
55
|
+
* The backend URL, plus whether this handshake carries a credential at all.
|
|
56
|
+
*
|
|
57
|
+
* The second half is the point. This function already KNEW when it had no
|
|
58
|
+
* token — it logged "No token found" and handed back a URL anyway, and the
|
|
59
|
+
* caller upgraded the socket regardless. That silence is what turns one
|
|
60
|
+
* logged-out browser tab into a permanent reconnect storm; see the refusal in
|
|
61
|
+
* `upgrade()` for the measurements.
|
|
62
62
|
*/ async function buildBackendUrl(path, target, cookies, query) {
|
|
63
63
|
let baseUrl = target.url.replace(/\/$/, '');
|
|
64
64
|
if (baseUrl.startsWith('http')) {
|
|
@@ -14,13 +14,13 @@ function buildWsUrl(config) {
|
|
|
14
14
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
15
15
|
return `${protocol}//${window.location.host}${path}${query}`;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
* One WebSocket per distinct URL, shared across every `usePubSub` caller via
|
|
19
|
-
* ref-counting. Previously each hook instance opened its own socket, so two
|
|
20
|
-
* components subscribing for the same user (e.g. the header bell and the chat
|
|
21
|
-
* panel) created competing connections — the server de-dupes by user, dropping
|
|
22
|
-
* one and breaking realtime delivery (and the message chime). Sharing a single
|
|
23
|
-
* socket eliminates that and the duplicate events it produced.
|
|
17
|
+
/**
|
|
18
|
+
* One WebSocket per distinct URL, shared across every `usePubSub` caller via
|
|
19
|
+
* ref-counting. Previously each hook instance opened its own socket, so two
|
|
20
|
+
* components subscribing for the same user (e.g. the header bell and the chat
|
|
21
|
+
* panel) created competing connections — the server de-dupes by user, dropping
|
|
22
|
+
* one and breaking realtime delivery (and the message chime). Sharing a single
|
|
23
|
+
* socket eliminates that and the duplicate events it produced.
|
|
24
24
|
*/ const connections = new Map();
|
|
25
25
|
let eventIdCounter = 0;
|
|
26
26
|
function log(conn, ...args) {
|
|
@@ -58,11 +58,11 @@ function startHeartbeat(conn) {
|
|
|
58
58
|
}
|
|
59
59
|
}, conn.options.heartbeatInterval);
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
62
|
-
* How many refused handshakes in a row before the client stops trying.
|
|
63
|
-
*
|
|
64
|
-
* Small on purpose: a refused handshake is a session problem, and the second
|
|
65
|
-
* attempt tells you the first was not a fluke. Anything beyond that is a storm.
|
|
61
|
+
/**
|
|
62
|
+
* How many refused handshakes in a row before the client stops trying.
|
|
63
|
+
*
|
|
64
|
+
* Small on purpose: a refused handshake is a session problem, and the second
|
|
65
|
+
* attempt tells you the first was not a fluke. Anything beyond that is a storm.
|
|
66
66
|
*/ const HANDSHAKE_FAILURE_LIMIT = 3;
|
|
67
67
|
function scheduleReconnect(conn) {
|
|
68
68
|
if (!conn.options.autoReconnect) return;
|
|
@@ -258,13 +258,13 @@ export function usePubSub(config) {
|
|
|
258
258
|
return {
|
|
259
259
|
isConnected: store.connection.status === 'connected',
|
|
260
260
|
isConnecting: store.connection.status === 'connecting' || store.connection.status === 'reconnecting',
|
|
261
|
-
/**
|
|
262
|
-
* The session is over and realtime will not return on its own.
|
|
263
|
-
*
|
|
264
|
-
* Exposed as its own flag so a screen can say so. Without it the page keeps
|
|
265
|
-
* its live appearance and quietly delivers nothing — which is worse than
|
|
266
|
-
* the reconnect storm this replaced, because at least the storm was visible
|
|
267
|
-
* to somebody.
|
|
261
|
+
/**
|
|
262
|
+
* The session is over and realtime will not return on its own.
|
|
263
|
+
*
|
|
264
|
+
* Exposed as its own flag so a screen can say so. Without it the page keeps
|
|
265
|
+
* its live appearance and quietly delivers nothing — which is worse than
|
|
266
|
+
* the reconnect storm this replaced, because at least the storm was visible
|
|
267
|
+
* to somebody.
|
|
268
268
|
*/ sessionExpired: store.connection.status === 'unauthorized',
|
|
269
269
|
clientId: store.connection.clientId,
|
|
270
270
|
subscribedTopics: store.connection.subscribedTopics,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The columns that decide whether an account is allowed to attempt a login.
|
|
3
|
+
*
|
|
4
|
+
* They are written together, always, because clearing a subset is how an
|
|
5
|
+
* account ends up in the state that reads as fine and is not. The login route
|
|
6
|
+
* consults them in two independent places:
|
|
7
|
+
*
|
|
8
|
+
* - `isLocked` / `lockedUntil` refuse the attempt outright, and
|
|
9
|
+
* - `failedLoginAttempts` decides whether the captcha gate applies — a gate that
|
|
10
|
+
* runs BEFORE the password is checked and before the counter can move.
|
|
11
|
+
*
|
|
12
|
+
* So an account released by unsetting `isLocked` alone still cannot log in, and
|
|
13
|
+
* nothing in the database says why: `is_locked` reads false, `failed_login_
|
|
14
|
+
* attempts` reads 1, and the account is stuck on a gate that only a successful
|
|
15
|
+
* login could clear. Seven farplas accounts sat exactly there.
|
|
16
|
+
*/
|
|
17
|
+
export type LockoutState = {
|
|
18
|
+
failedLoginAttempts: number;
|
|
19
|
+
isLocked: boolean;
|
|
20
|
+
lockedUntil: Date | null;
|
|
21
|
+
};
|
|
22
|
+
export declare function clearedLockoutState(): LockoutState;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the login route will let this account reach the password check.
|
|
25
|
+
*
|
|
26
|
+
* `captchaThreshold` is `login.captcha.requireAfterFailures`; pass `undefined`
|
|
27
|
+
* when the captcha is disabled for login. An `undefined` threshold with the
|
|
28
|
+
* captcha ENABLED means "always", which is the login route's own reading of a
|
|
29
|
+
* missing value — a detail worth keeping in one place, since a config that
|
|
30
|
+
* misspells the key gets every account gated rather than none.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isHeldOutOfLogin(user: {
|
|
33
|
+
failedLoginAttempts?: number | null;
|
|
34
|
+
isLocked?: boolean | null;
|
|
35
|
+
lockedUntil?: Date | null;
|
|
36
|
+
}, captchaThreshold: number | undefined, captchaEnabled: boolean, now?: Date): boolean;
|
|
@@ -12,8 +12,15 @@ import type { AuthRouteConfig } from '../types';
|
|
|
12
12
|
* The two endpoints are siblings on purpose. The sweep leaves everybody holding
|
|
13
13
|
* a password nobody knows, which is the point; the single-account one is how an
|
|
14
14
|
* administrator then gets a person who has no email address back to work.
|
|
15
|
+
*
|
|
16
|
+
* Both stay opt-in: a bulk rotation is irreversible, and an installation that
|
|
17
|
+
* never shared an opening password has no use for either. Releasing an account
|
|
18
|
+
* from lockout is deliberately NOT here — it is neither irreversible nor
|
|
19
|
+
* optional, so it lives in `userState.ts` and mounts unconditionally.
|
|
15
20
|
*/
|
|
16
|
-
export declare function createPasswordAdminRoutes(config: AuthRouteConfig
|
|
21
|
+
export declare function createPasswordAdminRoutes(config: AuthRouteConfig, options?: {
|
|
22
|
+
passwordTools?: boolean;
|
|
23
|
+
}): Elysia<"", {
|
|
17
24
|
decorator: {};
|
|
18
25
|
store: {};
|
|
19
26
|
derive: {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AuthRouteConfig } from '../types';
|
|
2
|
+
export type GodminGate = {
|
|
3
|
+
ok: true;
|
|
4
|
+
callerId: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
status: 401 | 403 | 500;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The gate every account-administration route stands behind.
|
|
12
|
+
*
|
|
13
|
+
* It was a closure inside the password module until a second module needed the
|
|
14
|
+
* same check; a copy would have been a second place to forget the deactivation
|
|
15
|
+
* kill-switch below.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createRequireGodmin(config: AuthRouteConfig): (request: Request) => Promise<GodminGate>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Elysia } from 'elysia';
|
|
2
|
+
import type { AuthRouteConfig } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Setting the three account-state flags an administrator actually needs:
|
|
5
|
+
* locked, released, email-verified.
|
|
6
|
+
*
|
|
7
|
+
* These columns are refused by the generic entity CRUD API on purpose — a write
|
|
8
|
+
* grant on `users` must not be a way to self-verify or to defeat lockout. But
|
|
9
|
+
* refusing them there without offering anywhere else left administration UIs
|
|
10
|
+
* PATCHing `users` with `isLocked: false` and getting `200 success: true` back
|
|
11
|
+
* while nothing changed: the fields were dropped silently and `updatedAt` moved,
|
|
12
|
+
* so the request looked like it worked, the audit log recorded a successful
|
|
13
|
+
* PATCH, and the account stayed shut out. Three buttons in the Vorion portal
|
|
14
|
+
* were doing exactly that.
|
|
15
|
+
*
|
|
16
|
+
* So the capability lives here, where it is godmin-gated, audited by name, and
|
|
17
|
+
* answers honestly about what it changed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createUserStateRoutes(config: AuthRouteConfig): Elysia<"", {
|
|
20
|
+
decorator: {};
|
|
21
|
+
store: {};
|
|
22
|
+
derive: {};
|
|
23
|
+
resolve: {};
|
|
24
|
+
}, {
|
|
25
|
+
typebox: {};
|
|
26
|
+
error: {};
|
|
27
|
+
}, {
|
|
28
|
+
schema: {};
|
|
29
|
+
standaloneSchema: {};
|
|
30
|
+
macro: {};
|
|
31
|
+
macroFn: {};
|
|
32
|
+
parser: {};
|
|
33
|
+
response: {};
|
|
34
|
+
}, {}, {
|
|
35
|
+
derive: {};
|
|
36
|
+
resolve: {};
|
|
37
|
+
schema: {};
|
|
38
|
+
standaloneSchema: {};
|
|
39
|
+
response: {};
|
|
40
|
+
}, {
|
|
41
|
+
derive: {};
|
|
42
|
+
resolve: {};
|
|
43
|
+
schema: {};
|
|
44
|
+
standaloneSchema: {};
|
|
45
|
+
response: {};
|
|
46
|
+
}>;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
2
|
import type { Logger } from '../../Logger';
|
|
3
3
|
type RedisAdapter = {
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `unknown`: direct Redis returns the written string, Dapr returns an
|
|
6
|
+
* already-deserialized value (see `Utils/storedJson`). Reading it as `string`
|
|
7
|
+
* and calling `JSON.parse` threw on every Dapr install — and the caller
|
|
8
|
+
* swallows that throw, so `resolve` claims mode silently produced no claims
|
|
9
|
+
* at all rather than failing loudly.
|
|
10
|
+
*/
|
|
11
|
+
get: (key: string) => Promise<unknown>;
|
|
5
12
|
set: (key: string, value: string) => Promise<void>;
|
|
6
13
|
delete: (key: string) => Promise<void>;
|
|
7
14
|
};
|
|
@@ -53,7 +53,14 @@ export interface CaptchaValidateResult {
|
|
|
53
53
|
}
|
|
54
54
|
export interface CaptchaServiceDeps {
|
|
55
55
|
redis: {
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* `unknown`, not `string`, and deliberately so: the direct-Redis store
|
|
58
|
+
* returns the string that was written while the Dapr store returns an
|
|
59
|
+
* already-deserialized object (see `Utils/storedJson`). Typing this as
|
|
60
|
+
* `string` was the lie that let `JSON.parse(stored)` ship and take every
|
|
61
|
+
* captcha-gated login to a 500. Read it through `parseStoredJson`.
|
|
62
|
+
*/
|
|
63
|
+
get: (key: string) => Promise<unknown>;
|
|
57
64
|
set: (key: string, value: string, options?: {
|
|
58
65
|
ex?: number;
|
|
59
66
|
}) => Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read back a value that was written to the state store as JSON.
|
|
3
|
+
*
|
|
4
|
+
* The state store has two implementations and, for the same `create()` call,
|
|
5
|
+
* they hand back two different shapes:
|
|
6
|
+
*
|
|
7
|
+
* - `DirectRedisStore` (`src/Managers/Redis/index.ts`) stringifies on write and
|
|
8
|
+
* parses once on read. A caller that stringified its own object before
|
|
9
|
+
* calling `set` therefore gets its **string** back — the two layers of
|
|
10
|
+
* encoding and decoding cancel out.
|
|
11
|
+
* - `DaprRedisStore` hands the value to the Dapr SDK, whose HTTP client already
|
|
12
|
+
* `JSON.parse`s the response body; `DaprStateClient.get` then parses a second
|
|
13
|
+
* time. The same caller gets a fully deserialized **object** back.
|
|
14
|
+
*
|
|
15
|
+
* So `IStateStore.read` is typed `string` but is only a string half the time.
|
|
16
|
+
* Any consumer that trusted the type and called `JSON.parse(stored)` directly
|
|
17
|
+
* threw `SyntaxError: Unexpected identifier "object"` on every Dapr install —
|
|
18
|
+
* `JSON.parse` stringifies its argument first, and an object stringifies to
|
|
19
|
+
* `"[object Object]"`. That is what took captcha validation, and with it every
|
|
20
|
+
* login that required a captcha, to a 500.
|
|
21
|
+
*
|
|
22
|
+
* `SessionStore` had already grown its own copy of this guard
|
|
23
|
+
* (`deserializeSession`). This is that guard, once, for everyone — so the next
|
|
24
|
+
* consumer does not have to rediscover the asymmetry the hard way.
|
|
25
|
+
*
|
|
26
|
+
* Never throws: a value that cannot be understood is `null`, which every caller
|
|
27
|
+
* already has to handle for the expired / evicted case.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseStoredJson<T>(stored: unknown): T | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.968",
|
|
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",
|
package/scripts/build.ts
CHANGED
|
@@ -377,8 +377,30 @@ async function obfuscateFiles() {
|
|
|
377
377
|
|
|
378
378
|
async function removeSourceMaps() {
|
|
379
379
|
log.info('Removing any source maps...')
|
|
380
|
-
|
|
381
|
-
|
|
380
|
+
// Walked in JS rather than shelled out to `find`: Bun's shell does not carry
|
|
381
|
+
// a `find` builtin, so on Windows this step exited 2 and took the whole build
|
|
382
|
+
// with it — the release could only ever be cut from a machine that happened
|
|
383
|
+
// to have GNU findutils on PATH.
|
|
384
|
+
let removed = 0
|
|
385
|
+
const walk = async (dir: string): Promise<void> => {
|
|
386
|
+
let entries: Awaited<ReturnType<typeof readdir>>
|
|
387
|
+
try {
|
|
388
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
389
|
+
} catch {
|
|
390
|
+
return // the directory is optional; nothing to clean if it was never built
|
|
391
|
+
}
|
|
392
|
+
for (const entry of entries) {
|
|
393
|
+
const full = join(dir, entry.name)
|
|
394
|
+
if (entry.isDirectory()) {
|
|
395
|
+
await walk(full)
|
|
396
|
+
} else if (entry.name.endsWith('.map')) {
|
|
397
|
+
await rm(full, { force: true })
|
|
398
|
+
removed++
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
await walk(config.build.outDir)
|
|
403
|
+
log.success(removed > 0 ? `Source maps removed (${removed})` : 'Source maps removed')
|
|
382
404
|
}
|
|
383
405
|
|
|
384
406
|
async function verifyBuild() {
|