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.
@@ -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): Elysia<"", {
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
- get: (key: string) => Promise<string | null>;
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
- get: (key: string) => Promise<string | null>;
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.967",
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
- await $`find ${config.build.outDir} -name "*.map" -delete`.quiet()
381
- log.success('Source maps removed')
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() {