nucleus-core-ts 0.9.705 → 0.9.707

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.
Files changed (29) hide show
  1. package/dist/index.js +11 -11
  2. package/dist/src/Client/Proxy/authz.d.ts +74 -0
  3. package/dist/src/Client/Proxy/authz.js +202 -0
  4. package/dist/src/Client/Proxy/authz.test.d.ts +1 -0
  5. package/dist/src/Client/Proxy/authz.test.js +235 -0
  6. package/dist/src/Client/Proxy/httpProxy.js +96 -11
  7. package/dist/src/Client/Proxy/types.d.ts +40 -0
  8. package/dist/src/ElysiaPlugin/routes/authorization/discovery.test.d.ts +1 -0
  9. package/dist/src/ElysiaPlugin/routes/authorization/index.d.ts +50 -0
  10. package/dist/src/ElysiaPlugin/routes/domain/hostnames.d.ts +4 -2
  11. package/dist/src/ElysiaPlugin/routes/domain/hostnames.test.d.ts +1 -0
  12. package/dist/src/ElysiaPlugin/routes/payment/marketplace/authz.test.d.ts +1 -0
  13. package/dist/src/ElysiaPlugin/routes/payment/marketplace/types.d.ts +9 -0
  14. package/dist/src/ElysiaPlugin/routes/pubsub/daprAuth.test.d.ts +1 -0
  15. package/dist/src/ElysiaPlugin/routes/pubsub/types.d.ts +8 -0
  16. package/dist/src/ElysiaPlugin/routes/storage/cdn.test.d.ts +1 -0
  17. package/dist/src/ElysiaPlugin/utils.d.ts +5 -0
  18. package/dist/src/Services/Authorization/EndpointDiscovery/discovery.test.d.ts +1 -0
  19. package/dist/src/Services/Authorization/EndpointDiscovery/index.d.ts +68 -0
  20. package/dist/src/Services/Authorization/EndpointDiscovery/seed.d.ts +60 -0
  21. package/dist/src/Services/Authorization/EndpointDiscovery/seed.test.d.ts +1 -0
  22. package/dist/src/Services/Authorization/types.d.ts +6 -0
  23. package/dist/src/Services/OAuth/providers/microsoft.d.ts +17 -0
  24. package/dist/src/Services/OAuth/providers/microsoft.test.d.ts +1 -0
  25. package/dist/src/Services/Tenant/TenantRegistry.d.ts +1 -1
  26. package/dist/src/Services/Tenant/helpers.d.ts +9 -1
  27. package/dist/src/Services/Tenant/isTrustedSource.test.d.ts +1 -0
  28. package/dist/src/types.d.ts +49 -0
  29. package/package.json +113 -113
@@ -34,6 +34,44 @@ export interface TokenRefreshConfig {
34
34
  onRefreshSuccess?: (path: string) => void;
35
35
  onRefreshFailure?: (path: string, error: string) => void;
36
36
  }
37
+ export interface ManifestRoute {
38
+ action: string;
39
+ path: string;
40
+ method: string;
41
+ }
42
+ /**
43
+ * Claim-gate config for a proxy target: enforce the discovered endpoint claims
44
+ * (nucleus endpointDiscovery) at the proxy. Requires the IDP in `jwtClaimsMode: 'embed'`.
45
+ */
46
+ export interface ProxyAuthorizeConfig {
47
+ serviceId: string;
48
+ manifest: {
49
+ /** Route→action manifest URL (GET /authorization/route-manifest). */
50
+ url: string;
51
+ /**
52
+ * Role→claim-actions manifest URL (GET /authorization/role-claims-manifest),
53
+ * REQUIRED when claimsMode is 'resolve' (JWT carries roles, not claims).
54
+ */
55
+ roleClaimsUrl?: string;
56
+ token?: string;
57
+ refreshSec?: number;
58
+ };
59
+ jwt: {
60
+ secret: string;
61
+ cookieName?: string;
62
+ headerName?: string;
63
+ };
64
+ /**
65
+ * How the caller's claims are obtained. 'embed' (default): read from the JWT's
66
+ * `claims`. 'resolve': the JWT carries only `roles`; resolve them to claims via
67
+ * the role-claims manifest (matches the IDP's jwtClaimsMode: 'resolve').
68
+ */
69
+ claimsMode?: 'embed' | 'resolve';
70
+ mode?: 'audit' | 'enforce';
71
+ onUnmapped?: 'allow' | 'deny';
72
+ publicPaths?: string[];
73
+ godminRole?: string;
74
+ }
37
75
  export interface HttpProxyTarget {
38
76
  url: string;
39
77
  paths: string[];
@@ -48,6 +86,8 @@ export interface HttpProxyTarget {
48
86
  cookieName: string;
49
87
  headerName?: string;
50
88
  };
89
+ /** Enforce discovered endpoint claims for this target at the proxy. */
90
+ authorize?: ProxyAuthorizeConfig;
51
91
  tokenRefresh?: TokenRefreshConfig;
52
92
  changeOrigin?: boolean;
53
93
  timeout?: number;
@@ -0,0 +1,50 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import { Elysia } from 'elysia';
3
+ import { type EndpointDiscoveryConfig } from 'src/Services/Authorization/EndpointDiscovery/seed';
4
+ import type { Logger } from 'src/Services/Logger';
5
+ export interface AuthorizationRoutesConfig {
6
+ db: NodePgDatabase;
7
+ schemaTables: Record<string, unknown>;
8
+ logger: Logger;
9
+ /** endpointDiscovery config, plus an optional shared token for the proxy. */
10
+ discovery: EndpointDiscoveryConfig & {
11
+ token?: string;
12
+ };
13
+ }
14
+ /**
15
+ * Endpoint-discovery admin + manifest routes.
16
+ * - `POST /authorization/discover` — godmin re-runs discovery + reconcile.
17
+ * - `GET /authorization/route-manifest` — the templated route→claim table the reverse
18
+ * proxy consumes. Reachable by godmin OR by a caller presenting the configured
19
+ * discovery token (`x-discovery-token`), so the proxy (which has no user session)
20
+ * can fetch it. The token resolves from `endpointDiscovery.token` then the
21
+ * `NUCLEUS_DISCOVERY_TOKEN` env.
22
+ */
23
+ export declare function createAuthorizationDiscoveryRoutes(cfg: AuthorizationRoutesConfig): Elysia<"", {
24
+ decorator: {};
25
+ store: {};
26
+ derive: {};
27
+ resolve: {};
28
+ }, {
29
+ typebox: {};
30
+ error: {};
31
+ }, {
32
+ schema: {};
33
+ standaloneSchema: {};
34
+ macro: {};
35
+ macroFn: {};
36
+ parser: {};
37
+ response: {};
38
+ }, {}, {
39
+ derive: {};
40
+ resolve: {};
41
+ schema: {};
42
+ standaloneSchema: {};
43
+ response: {};
44
+ }, {
45
+ derive: {};
46
+ resolve: {};
47
+ schema: {};
48
+ standaloneSchema: {};
49
+ response: {};
50
+ }>;
@@ -2,8 +2,10 @@ import { Elysia } from 'elysia';
2
2
  import type { DomainRouteConfig } from './types';
3
3
  /**
4
4
  * Custom-hostname lifecycle routes. Management requires an authenticated user
5
- * (x-user-id); deeper ownership/authorization is enforced by the deployment
6
- * via claims. Mounted under config.basePath (default "/domains").
5
+ * (x-user-id). Creation additionally binds the hostname's target schema to the
6
+ * caller's own verified tenant (`x-tenant-schema`) unless the caller is godmin —
7
+ * see the createHostname handler. Mounted under config.basePath (default
8
+ * "/domains").
7
9
  */
8
10
  export declare function createHostnameRoutes(config: DomainRouteConfig): Elysia<"", {
9
11
  decorator: {};
@@ -32,6 +32,15 @@ export declare const isAdmin: (ctx: MarketplaceCtx) => boolean;
32
32
  * not an operator.
33
33
  */
34
34
  export declare const requireAdmin: (ctx: MarketplaceCtx) => string | null;
35
+ /**
36
+ * Authorizes READING an owner's financial ledger (balance, splits, settlement
37
+ * policy, payout requests, disputes). Operators may read any owner. A non-operator
38
+ * may only read their OWN user-scoped ledger — the framework keeps no generic
39
+ * seller/tenant → user mapping, so ownerType 'seller'/'tenant'/'platform' cannot be
40
+ * proven to belong to the caller and is operator-only by default. Without this,
41
+ * any authenticated user could read ANY owner's balances/payouts (financial IDOR).
42
+ */
43
+ export declare const canReadOwnerLedger: (ctx: MarketplaceCtx, ownerType: string | undefined, ownerId: string | undefined | null) => boolean;
35
44
  export declare const failResponse: (ctx: MarketplaceCtx, status: number, message: string) => {
36
45
  success: boolean;
37
46
  message: string;
@@ -70,6 +70,14 @@ export interface PubSubRouteConfig {
70
70
  debounceMs: number;
71
71
  };
72
72
  cleanupIntervalMs: number;
73
+ /**
74
+ * Shared secret Dapr presents (as the `dapr-api-token` header) when it POSTs a
75
+ * delivered message to the subscription endpoint. When set, the endpoint rejects
76
+ * any inbound event whose token does not match — without it, ANY caller who can
77
+ * reach the route could inject realtime events to any user. Resolved from
78
+ * config.pubsub.daprApiToken, then env APP_API_TOKEN / DAPR_API_TOKEN.
79
+ */
80
+ daprApiToken?: string;
73
81
  getLiveMonitoringService?: () => LiveMonitoringService | null;
74
82
  /**
75
83
  * Optional WebSocket handshake authenticator. When provided, every WS
@@ -0,0 +1 @@
1
+ export {};
@@ -71,6 +71,11 @@ export interface ValidationResult {
71
71
  errors: ValidationError[];
72
72
  }
73
73
  export declare function validatePayload(payload: Record<string, unknown>, columns: NucleusColumn[], isPartial?: boolean): ValidationResult;
74
+ /**
75
+ * True when a column name is an auth-secret that must never cross the wire (in a
76
+ * row OR as a distinct-value projection). Accepts camelCase or snake_case.
77
+ */
78
+ export declare function isSensitiveOutputKey(key: string): boolean;
74
79
  /** Strip auth-secret columns from a row (or null) before it crosses the wire. */
75
80
  export declare function redactSensitiveOutput<T>(row: T): T;
76
81
  export declare function sanitizePayload(payload: Record<string, unknown>, columns: NucleusColumn[]): Record<string, unknown>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Endpoint discovery: turn an external (non-nucleus) service's OpenAPI document
3
+ * into nucleus claim specs, and match a concrete request path back to its claim.
4
+ *
5
+ * A discovered claim's `action` is prefixed with the service id
6
+ * (`{serviceId}.{resource}.{op}`) so it never collides with an auto-seeded entity
7
+ * claim (which is method-first: `get.users`) and so all of a service's claims can
8
+ * be found by the `{serviceId}.` prefix during reconcile. `serviceId` therefore MUST
9
+ * NOT be an HTTP-method word (get/post/put/patch/delete/toggle/bulk).
10
+ */
11
+ export interface ClaimSpec {
12
+ action: string;
13
+ path: string;
14
+ method: string;
15
+ description: string;
16
+ }
17
+ /** Minimal slice of an OpenAPI 3.x document we depend on. */
18
+ export interface OpenApiDoc {
19
+ paths?: Record<string, Record<string, {
20
+ tags?: string[];
21
+ operationId?: string;
22
+ summary?: string;
23
+ description?: string;
24
+ } | undefined>>;
25
+ }
26
+ /** Simple glob: exact match, or a trailing `/*` prefix match. */
27
+ export declare function matchesExclude(path: string, patterns: string[] | undefined): boolean;
28
+ export interface ParseOptions {
29
+ /** Path globs to skip (e.g. ['/health', '/api/v1/internal/*']). */
30
+ exclude?: string[];
31
+ }
32
+ /**
33
+ * Convert an OpenAPI document into claim specs. Deterministic and pure.
34
+ * `action = {serviceId}.{tag|resource}.{operationId | method_normalizedPath}` —
35
+ * the fallback op token embeds the path, so actions are unique even without
36
+ * operationIds (method+path is unique within an OpenAPI doc).
37
+ */
38
+ export declare function parseOpenApiToClaims(serviceId: string, doc: OpenApiDoc, opts?: ParseOptions): ClaimSpec[];
39
+ /** True when a concrete request path matches a templated OpenAPI path. */
40
+ export declare function pathMatchesTemplate(template: string, concrete: string): boolean;
41
+ /**
42
+ * Given the claim specs for a service and an incoming (method, concrete-path),
43
+ * return the required claim action, or null when no route matches (the caller
44
+ * decides allow/deny for unmapped routes).
45
+ */
46
+ export declare function matchRouteToAction(specs: ClaimSpec[], method: string, concretePath: string): string | null;
47
+ /** An existing claim row as seen during reconcile. */
48
+ export interface ExistingClaim {
49
+ action: string;
50
+ isActive: boolean;
51
+ path: string;
52
+ method: string;
53
+ description: string;
54
+ }
55
+ export interface ClaimReconcilePlan {
56
+ toInsert: ClaimSpec[];
57
+ toReactivate: ClaimSpec[];
58
+ toDeactivate: ExistingClaim[];
59
+ unchanged: ClaimSpec[];
60
+ }
61
+ /**
62
+ * Pure reconcile: compare a service's currently-discovered specs against its existing
63
+ * claim rows (already filtered to this service's `{serviceId}.` prefix). Removed
64
+ * endpoints are DEACTIVATED, not deleted, so their role assignments survive.
65
+ */
66
+ export declare function diffClaims(existing: ExistingClaim[], specs: ClaimSpec[]): ClaimReconcilePlan;
67
+ /** Fetch and JSON-parse a service's OpenAPI document. `fetchImpl` is injectable for tests. */
68
+ export declare function fetchOpenApi(baseUrl: string, openapiPath: string, fetchImpl?: typeof fetch): Promise<OpenApiDoc>;
@@ -0,0 +1,60 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import type { Logger } from '../../Logger';
3
+ import { type ClaimSpec } from './index';
4
+ export interface DiscoveryServiceConfig {
5
+ id: string;
6
+ baseUrl: string;
7
+ openapi?: string;
8
+ exclude?: string[];
9
+ }
10
+ export interface EndpointDiscoveryConfig {
11
+ enabled?: boolean;
12
+ runOnBoot?: boolean;
13
+ services?: DiscoveryServiceConfig[];
14
+ trigger?: {
15
+ enabled?: boolean;
16
+ basePath?: string;
17
+ };
18
+ }
19
+ export interface ServiceDiscoveryResult {
20
+ id: string;
21
+ added: number;
22
+ reactivated: number;
23
+ deactivated: number;
24
+ unchanged: number;
25
+ total: number;
26
+ error?: string;
27
+ }
28
+ export interface DiscoveryRunResult {
29
+ services: ServiceDiscoveryResult[];
30
+ }
31
+ type PgTable = ReturnType<typeof import('drizzle-orm/pg-core').pgTable>;
32
+ /**
33
+ * Seed + reconcile one service's discovered claims into the `claims` table.
34
+ * Idempotent: inserts missing, reactivates previously-removed, and DEACTIVATES
35
+ * (never deletes) claims for this service that are no longer present.
36
+ */
37
+ export declare function seedDiscoveredClaims(db: NodePgDatabase, claimsTable: PgTable, serviceId: string, specs: ClaimSpec[], logger: Logger): Promise<Omit<ServiceDiscoveryResult, 'id' | 'error'>>;
38
+ /**
39
+ * Boot / on-demand entrypoint: for each configured service, fetch its OpenAPI,
40
+ * derive claim specs, and seed+reconcile them. A per-service fetch/parse failure is
41
+ * logged and skipped (never fails the whole run or boot).
42
+ */
43
+ export declare function runEndpointDiscovery(db: NodePgDatabase, schemaTables: Record<string, unknown>, config: EndpointDiscoveryConfig, logger: Logger, fetchImpl?: typeof fetch): Promise<DiscoveryRunResult>;
44
+ /**
45
+ * Build the route→claim manifest a reverse proxy consumes: the active discovered
46
+ * claims for a service (by `{serviceId}.` prefix), as templated route rows.
47
+ */
48
+ export declare function getRouteManifest(db: NodePgDatabase, claimsTable: PgTable, serviceId: string): Promise<Array<{
49
+ action: string;
50
+ path: string;
51
+ method: string;
52
+ }>>;
53
+ /**
54
+ * Build the role→claim-actions manifest a reverse proxy consumes in RESOLVE mode
55
+ * (where the JWT carries roles but not claims). Returns each role's claim actions
56
+ * scoped to a service (`{serviceId}.` prefix, plus the bare `{serviceId}` grant),
57
+ * so the proxy can resolve a user's roles → claim set locally. Active rows only.
58
+ */
59
+ export declare function getRoleClaimsManifest(db: NodePgDatabase, schemaTables: Record<string, unknown>, serviceId: string): Promise<Record<string, string[]>>;
60
+ export {};
@@ -3,6 +3,12 @@ export interface AuthorizationConfig {
3
3
  autoSeedClaims: boolean;
4
4
  skipTables: string[];
5
5
  skipColumns: string[];
6
+ /**
7
+ * @deprecated NOT ENFORCED. This key was never wired into the request pipeline —
8
+ * paths listed here are still authenticated and authorized normally. To make a
9
+ * path public (skip auth), use `publicPaths` (which has method-qualification and
10
+ * anomalous-path hardening). Kept only for config back-compat; will be removed.
11
+ */
6
12
  excludedPaths: string[];
7
13
  publicPaths: string[];
8
14
  godminEmail?: string;
@@ -1,3 +1,20 @@
1
1
  import type { OAuthCallbackResult, OAuthProviderConfig } from '../types';
2
+ /**
3
+ * Decide whether the Microsoft email may be TRUSTED for account-merge.
4
+ *
5
+ * SECURITY: the Graph `mail` / `userPrincipalName` is directory-controlled, but on
6
+ * multi-tenant ("common"/"organizations"/"consumers") or personal-account apps the
7
+ * signing tenant is NOT owned by us — a hostile tenant admin (or an MSA user) can
8
+ * set `mail` to victim@ourcompany.com. Blindly trusting it let an attacker merge
9
+ * into a victim's existing local account (merge-by-email takeover). Trust the email
10
+ * only when:
11
+ * 1. Microsoft asserts it via the `xms_edov` (email domain-owner verified) claim, OR
12
+ * 2. the app is pinned to a single specific tenant (config.tenantId is a concrete
13
+ * directory, not a multi-tenant meta-authority) — then the issuing directory is
14
+ * one we trust.
15
+ * Otherwise it is treated as UNVERIFIED, so the callback refuses to merge it into a
16
+ * pre-existing local account (a fresh OAuth-origin sign-up is still allowed).
17
+ */
18
+ export declare function resolveMicrosoftEmailVerified(config: OAuthProviderConfig, idTokenPayload: Record<string, unknown> | null): boolean;
2
19
  export declare function buildMicrosoftAuthUrl(config: OAuthProviderConfig, state: string): string;
3
20
  export declare function exchangeMicrosoftCode(code: string, config: OAuthProviderConfig): Promise<OAuthCallbackResult>;
@@ -34,7 +34,7 @@ export declare class TenantRegistry {
34
34
  * Resolve a tenant from an incoming HTTP request.
35
35
  * Priority: subdomain → x-tenant header → fallback to main
36
36
  */
37
- resolveFromRequest(request: Request): TenantResolutionResult;
37
+ resolveFromRequest(request: Request, vettedClientIp?: string): TenantResolutionResult;
38
38
  /**
39
39
  * Get schema context for a specific schema name.
40
40
  */
@@ -39,4 +39,12 @@ export declare const isIpInCidr: (ip: string, cidr: string) => boolean;
39
39
  /**
40
40
  * Check if a request comes from a trusted source for the given tenant.
41
41
  */
42
- export declare const isTrustedSource: (tenant: TenantRecord, request: Request, authMode: "full" | "consumer" | undefined, fallbackSources?: TenantRecord["trustedSources"]) => boolean;
42
+ export declare const isTrustedSource: (tenant: TenantRecord, request: Request, authMode: "full" | "consumer" | undefined, fallbackSources?: TenantRecord["trustedSources"],
43
+ /**
44
+ * The VETTED client IP, resolved by the middleware via resolveClientIp (which
45
+ * only honors X-Forwarded-For from configured trusted proxies). MUST be passed
46
+ * for the allowedIps check to be safe — never re-derive it from raw request
47
+ * headers here, or a client could spoof X-Forwarded-For to match an allowedIp
48
+ * and select another tenant.
49
+ */
50
+ vettedClientIp?: string) => boolean;
@@ -0,0 +1 @@
1
+ export {};
@@ -675,6 +675,10 @@ export interface NucleusConfigOptions {
675
675
  claimsCachePrefix?: string;
676
676
  skipTables?: string[];
677
677
  skipColumns?: string[];
678
+ /**
679
+ * @deprecated NOT ENFORCED — paths here are still authenticated + authorized.
680
+ * Use `publicPaths` to make a path public. Kept for config back-compat only.
681
+ */
678
682
  excludedPaths?: string[];
679
683
  publicPaths?: string[];
680
684
  godminEmail?: string;
@@ -708,6 +712,43 @@ export interface NucleusConfigOptions {
708
712
  scope?: string;
709
713
  }>;
710
714
  };
715
+ /**
716
+ * Auto-discover the endpoints of EXTERNAL (non-nucleus) services from their
717
+ * OpenAPI documents and seed a claim per endpoint, so a nucleus IDP can protect
718
+ * 500+ routes across other services (e.g. FastAPI) with the same role/claim model
719
+ * without hand-writing claims. Full (IDP) mode only. Discovered claim actions are
720
+ * `{serviceId}.{tag}.{operationId}` (idempotent on action; removed endpoints are
721
+ * DEACTIVATED, not deleted). Enforcement happens at the reverse proxy via
722
+ * `HttpProxyTarget.authorize` (see nucleus-core-ts/proxy) consuming the
723
+ * `/authorization/route-manifest` this produces. `serviceId` must not be an
724
+ * HTTP-method word (get/post/put/…).
725
+ */
726
+ endpointDiscovery?: {
727
+ enabled?: boolean;
728
+ /** Run discovery at boot (default true when enabled). */
729
+ runOnBoot?: boolean;
730
+ services?: Array<{
731
+ /** Stable prefix for this service's claim actions (e.g. "agent"). */
732
+ id: string;
733
+ /** Base URL reachable from the IDP (e.g. "http://vorion-agent:8000"). */
734
+ baseUrl: string;
735
+ /** OpenAPI document path (default "/openapi.json"). */
736
+ openapi?: string;
737
+ /** Path globs to skip, e.g. ["/health","/api/v1/internal/*"]. */
738
+ exclude?: string[];
739
+ }>;
740
+ /** godmin-only on-demand re-discovery endpoint. */
741
+ trigger?: {
742
+ enabled?: boolean;
743
+ basePath?: string;
744
+ };
745
+ /**
746
+ * Shared secret that lets the reverse proxy fetch `/authorization/route-manifest`
747
+ * without a user session (presented as the `x-discovery-token` header). Godmin
748
+ * callers are always allowed. Falls back to the `NUCLEUS_DISCOVERY_TOKEN` env.
749
+ */
750
+ token?: string;
751
+ };
711
752
  };
712
753
  audit?: {
713
754
  enabled?: boolean;
@@ -980,6 +1021,14 @@ export interface NucleusConfigOptions {
980
1021
  wsPath?: string;
981
1022
  /** Dapr pubsub component name (default: "pubsub-redis") */
982
1023
  pubsubName?: string;
1024
+ /**
1025
+ * Shared secret Dapr presents as the `dapr-api-token` header when POSTing a
1026
+ * delivered message to the subscription endpoint. REQUIRED to secure that
1027
+ * endpoint — without it (and without the APP_API_TOKEN/DAPR_API_TOKEN env
1028
+ * fallback) any caller reaching the route can inject realtime events to any
1029
+ * user. Falls back to env APP_API_TOKEN, then DAPR_API_TOKEN.
1030
+ */
1031
+ daprApiToken?: string;
983
1032
  /** Max clients per user (default: 10) */
984
1033
  maxClientsPerUser?: number;
985
1034
  /** WebSocket idle timeout in seconds (default: 120) */