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.
- package/dist/index.js +12 -12
- package/dist/src/Client/Proxy/wsProxy.js +28 -15
- package/dist/src/ElysiaPlugin/routes/auth/register/types.d.ts +3 -0
- package/dist/src/ElysiaPlugin/routes/auth/types.d.ts +5 -0
- package/dist/src/Services/Authorization/Middleware/index.d.ts +42 -1
- package/dist/src/Services/Authorization/Middleware/scopeFailClosed.test.d.ts +1 -0
- package/dist/src/Services/Authorization/Middleware/scopeSelfFailClosed.test.d.ts +1 -0
- package/dist/src/Services/Authorization/SeedRunner/index.d.ts +2 -0
- package/dist/src/Services/Authorization/SeedRunner/scopeReconcile.test.d.ts +1 -0
- package/package.json +2 -2
|
@@ -25,23 +25,36 @@ export function createWsProxyHandler(config) {
|
|
|
25
25
|
url = `${url}?${queryString}`;
|
|
26
26
|
}
|
|
27
27
|
if (target.injectTokenFromCookie) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.704",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
|
-
"author": "Hidayet Can
|
|
5
|
+
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"backend",
|