nucleus-core-ts 0.9.702 → 0.9.704

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.
@@ -25,23 +25,36 @@ export function createWsProxyHandler(config) {
25
25
  url = `${url}?${queryString}`;
26
26
  }
27
27
  if (target.injectTokenFromCookie) {
28
- let token = cookies[target.injectTokenFromCookie.cookieName];
29
- let tokenSource = target.injectTokenFromCookie.cookieName;
30
- if (!token && target.injectTokenFromCookie.fallbackCookieNames) {
31
- for (const fallback of target.injectTokenFromCookie.fallbackCookieNames){
32
- if (cookies[fallback]) {
33
- token = cookies[fallback];
34
- tokenSource = `${fallback} (fallback)`;
35
- break;
28
+ const paramName = target.injectTokenFromCookie.queryParam ?? 'token';
29
+ // PRECEDENCE: respect a token the client already supplied in the URL.
30
+ // The frontend fetches/refreshes a FRESH access_token server-side and puts
31
+ // it in `?token=`. The browser ALSO auto-sends the spanning HttpOnly
32
+ // `access_token` COOKIE on the WS handshake — but on a sibling subdomain
33
+ // (e.g. acta./ace.) that cookie is STALE/expired (only www activity
34
+ // refreshes it), and a fallback `session_token` isn't a valid bearer for
35
+ // the backend. Injecting the cookie therefore OVERRODE the fresh client
36
+ // token → backend closed the WS with "Not authenticated". Only inject from
37
+ // the cookie when the client supplied no token.
38
+ if (query.get(paramName)) {
39
+ logger.info(`[WS:auth] Using client-supplied ${paramName} for ${path}`);
40
+ } else {
41
+ let token = cookies[target.injectTokenFromCookie.cookieName];
42
+ let tokenSource = target.injectTokenFromCookie.cookieName;
43
+ if (!token && target.injectTokenFromCookie.fallbackCookieNames) {
44
+ for (const fallback of target.injectTokenFromCookie.fallbackCookieNames){
45
+ if (cookies[fallback]) {
46
+ token = cookies[fallback];
47
+ tokenSource = `${fallback} (fallback)`;
48
+ break;
49
+ }
36
50
  }
37
51
  }
38
- }
39
- if (token) {
40
- const paramName = target.injectTokenFromCookie.queryParam ?? 'token';
41
- url = addQueryParam(url, paramName, token);
42
- logger.info(`[WS:auth] Token injected from ${tokenSource} for ${path}`);
43
- } else {
44
- logger.warn(`[WS:auth] No token found for ${path} — tried: ${target.injectTokenFromCookie.cookieName}${target.injectTokenFromCookie.fallbackCookieNames ? `, ${target.injectTokenFromCookie.fallbackCookieNames.join(', ')}` : ''}`);
52
+ if (token) {
53
+ url = addQueryParam(url, paramName, token);
54
+ logger.info(`[WS:auth] Token injected from ${tokenSource} for ${path}`);
55
+ } else {
56
+ logger.warn(`[WS:auth] No token found for ${path} tried: ${target.injectTokenFromCookie.cookieName}${target.injectTokenFromCookie.fallbackCookieNames ? `, ${target.injectTokenFromCookie.fallbackCookieNames.join(', ')}` : ''}`);
57
+ }
45
58
  }
46
59
  }
47
60
  return url;
@@ -2,6 +2,9 @@ export declare const RegisterBodySchema: import("@sinclair/typebox").TObject<{
2
2
  email: import("@sinclair/typebox").TString;
3
3
  password: import("@sinclair/typebox").TString;
4
4
  confirmPassword: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
5
+ firstName: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
6
+ lastName: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
+ createProfile: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
5
8
  }>;
6
9
  export declare const RegisterResponseSchema: import("@sinclair/typebox").TObject<{
7
10
  success: import("@sinclair/typebox").TBoolean;
@@ -114,6 +114,11 @@ export interface RegisterConfig {
114
114
  isPublic: boolean;
115
115
  enabled: boolean;
116
116
  emailVerification?: EmailVerificationConfig;
117
+ /** Show first/last name fields on the register form (FE) — informational here. */
118
+ showFirstName?: boolean;
119
+ showLastName?: boolean;
120
+ /** Persist a profile row (firstName/lastName) when a user self-registers. */
121
+ createProfileOnRegister?: boolean;
117
122
  }
118
123
  export interface LogoutConfig {
119
124
  route?: string;
@@ -1,6 +1,7 @@
1
+ import { type SQL } from 'drizzle-orm';
1
2
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
3
  import type { Logger } from '../../Logger';
3
- import { type AuthorizationCheckResult, type HttpMethod, type ParsedScope, type ResolvedScope } from '../types';
4
+ import { type AuthorizationCheckResult, type HttpMethod, type ParsedScope, type ParsedScopeWithSelf, type ResolvedScope } from '../types';
4
5
  interface CheckAuthorizationParams {
5
6
  userId: string;
6
7
  method: HttpMethod;
@@ -26,6 +27,22 @@ interface CheckAuthorizationParams {
26
27
  requireClaimSpecificity?: boolean;
27
28
  }
28
29
  export declare function parseScope(scope: string | null): ParsedScope;
30
+ /**
31
+ * Resolves a parsed scope's `self:<field>` refs against the caller's user row.
32
+ *
33
+ * SECURITY (fail-closed): a `self:` ref that CANNOT be resolved — because the
34
+ * user row is missing (getUserData returned undefined / threw) or the named
35
+ * field is absent from it — must NOT be silently dropped. Dropping it would
36
+ * leave the scope key out of `scopeFilters` entirely, and downstream a by-id
37
+ * GET/PATCH/DELETE would then run UNSCOPED — i.e. touch ANY row by id (IDOR).
38
+ * Such keys are reported in `unresolved` so the caller can DENY the request.
39
+ * Static (non-`self:`) scope values always resolve and are never reported.
40
+ * Exported for unit tests.
41
+ */
42
+ export declare function resolveScopeWithSelf(parsedScope: ParsedScopeWithSelf, userData: Record<string, unknown> | undefined, logger: Logger): {
43
+ resolved: ResolvedScope;
44
+ unresolved: string[];
45
+ };
29
46
  /**
30
47
  * Claim patterns are dot-separated `method.entity[.field | .with.relation]`. A
31
48
  * shorter claim is a prefix grant of longer patterns (`get.users` grants
@@ -60,5 +77,29 @@ interface CheckAuthorizationViaIDPParams {
60
77
  export declare function checkAuthorizationViaIDP(params: CheckAuthorizationViaIDPParams): Promise<AuthorizationCheckResult>;
61
78
  export declare function filterResponseFields<T extends Record<string, unknown>>(data: T | T[], allowedFields?: string[]): Partial<T> | Partial<T>[];
62
79
  export declare function filterResponseRelations<T extends Record<string, unknown>>(data: T | T[], allowedRelations?: string[]): T | T[];
80
+ export interface ScopeConditions {
81
+ /** WHERE predicates to AND onto the query (one per scope entry). */
82
+ conditions: SQL[];
83
+ /**
84
+ * Scope fields that did NOT resolve to a column on the target entity. A
85
+ * non-empty list means at least one always-false predicate was emitted so
86
+ * the query fails closed; callers should log this loudly — it signals a
87
+ * misconfigured or stale scope.
88
+ */
89
+ unresolvedFields: string[];
90
+ }
91
+ /**
92
+ * Translate resolved scope filters into Drizzle WHERE predicates, FAILING
93
+ * CLOSED on any scope field whose column is absent from the target entity.
94
+ *
95
+ * SECURITY: a scope like `created_by=self:id` restricts a query to rows the
96
+ * caller owns. If the named column does not exist on the entity (a renamed or
97
+ * removed column, or a typo'd / stale scope), silently dropping the predicate
98
+ * would run the query UNSCOPED — returning every row across tenants (read) or
99
+ * letting a scoped write touch any row (IDOR). Instead we emit an always-false
100
+ * predicate so the query matches ZERO rows, and report the offending field so
101
+ * the caller can log/deny. An unenforceable scope is NEVER dropped.
102
+ */
103
+ export declare function buildScopeConditions(scopeFilters: Record<string, unknown> | undefined, resolveColumn: (field: string) => unknown): ScopeConditions;
63
104
  export declare function applyScopeFilters(scopeFilters: ResolvedScope | undefined): Record<string, unknown> | undefined;
64
105
  export {};
@@ -24,6 +24,8 @@ type SeedResult = {
24
24
  claimsExisting: number;
25
25
  assignmentsCreated: number;
26
26
  assignmentsExisting: number;
27
+ /** Existing assignments whose scope drifted from config and was reconciled. */
28
+ assignmentsUpdated: number;
27
29
  };
28
30
  /**
29
31
  * Seeds custom roles, claims, and role-claim assignments from config.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.702",
3
+ "version": "0.9.704",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
- "author": "Hidayet Can \u00d6zcan <hidayetcan@gmail.com>",
5
+ "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "keywords": [
8
8
  "backend",