nucleus-core-ts 0.9.705 → 0.9.706

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 (28) hide show
  1. package/dist/index.js +11 -11
  2. package/dist/src/Client/Proxy/authz.d.ts +48 -0
  3. package/dist/src/Client/Proxy/authz.js +150 -0
  4. package/dist/src/Client/Proxy/authz.test.d.ts +1 -0
  5. package/dist/src/Client/Proxy/authz.test.js +192 -0
  6. package/dist/src/Client/Proxy/httpProxy.js +74 -11
  7. package/dist/src/Client/Proxy/types.d.ts +28 -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 +53 -0
  21. package/dist/src/Services/Authorization/types.d.ts +6 -0
  22. package/dist/src/Services/OAuth/providers/microsoft.d.ts +17 -0
  23. package/dist/src/Services/OAuth/providers/microsoft.test.d.ts +1 -0
  24. package/dist/src/Services/Tenant/TenantRegistry.d.ts +1 -1
  25. package/dist/src/Services/Tenant/helpers.d.ts +9 -1
  26. package/dist/src/Services/Tenant/isTrustedSource.test.d.ts +1 -0
  27. package/dist/src/types.d.ts +49 -0
  28. package/package.json +113 -113
@@ -0,0 +1,48 @@
1
+ import type { ManifestRoute, ProxyAuthorizeConfig, ProxyLogger } from './types';
2
+ /**
3
+ * Reverse-proxy claim-gate. Enforces the discovered `{serviceId}.{tag}.{op}` claims
4
+ * (see nucleus endpointDiscovery) at the proxy: a request is allowed only if the
5
+ * VERIFIED access token holds a claim that satisfies the route's required action.
6
+ *
7
+ * Security: the token is HS256-verified with the access-token secret before its
8
+ * claims are trusted — decoding without verifying would let a forged token bypass
9
+ * the gate. Requires `jwtClaimsMode: 'embed'` on the IDP (claims travel in the JWT).
10
+ */
11
+ export type { ManifestRoute, ProxyAuthorizeConfig } from './types';
12
+ /** HS256-verify a JWT and return its payload, or null if invalid/expired/tampered. */
13
+ export declare function verifyJwtHS256(token: string, secret: string): Record<string, unknown> | null;
14
+ /** Hierarchical prefix match: a shorter claim grants longer actions (`agent` ⇒ `agent.x.y`). */
15
+ export declare function claimSatisfies(userClaims: string[], required: string): boolean;
16
+ /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */
17
+ export declare function pathMatchesTemplate(template: string, concrete: string): boolean;
18
+ export declare function findRequiredAction(manifest: ManifestRoute[], method: string, path: string): string | null;
19
+ export interface AccessDecision {
20
+ decision: 'allow' | 'deny';
21
+ reason: string;
22
+ action: string | null;
23
+ }
24
+ /** Pure authorization decision (mode-independent). */
25
+ export declare function evaluateAccess(args: {
26
+ manifest: ManifestRoute[];
27
+ method: string;
28
+ path: string;
29
+ payload: Record<string, unknown> | null;
30
+ onUnmapped: 'allow' | 'deny';
31
+ publicPaths?: string[];
32
+ godminRole: string;
33
+ }): AccessDecision;
34
+ /** Fetches + caches a service's route manifest from the IDP. */
35
+ export declare class ManifestCache {
36
+ private readonly cfg;
37
+ private readonly fetchImpl;
38
+ private readonly logger?;
39
+ private routes;
40
+ private fetchedAtMs;
41
+ private inflight;
42
+ constructor(cfg: ProxyAuthorizeConfig['manifest'] & {
43
+ serviceId: string;
44
+ }, fetchImpl?: typeof fetch, logger?: ProxyLogger | undefined);
45
+ private get ttlMs();
46
+ private refresh;
47
+ get(): Promise<ManifestRoute[]>;
48
+ }
@@ -0,0 +1,150 @@
1
+ function _define_property(obj, key, value) {
2
+ if (key in obj) {
3
+ Object.defineProperty(obj, key, {
4
+ value: value,
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true
8
+ });
9
+ } else {
10
+ obj[key] = value;
11
+ }
12
+ return obj;
13
+ }
14
+ import { createHmac, timingSafeEqual } from 'node:crypto';
15
+ /** HS256-verify a JWT and return its payload, or null if invalid/expired/tampered. */ export function verifyJwtHS256(token, secret) {
16
+ const parts = token.split('.');
17
+ if (parts.length !== 3) return null;
18
+ const [header, payloadB64, sigB64] = parts;
19
+ if (!header || !payloadB64 || !sigB64) return null;
20
+ try {
21
+ const expected = createHmac('sha256', secret).update(`${header}.${payloadB64}`).digest();
22
+ const got = Buffer.from(sigB64, 'base64url');
23
+ if (expected.length !== got.length || !timingSafeEqual(expected, got)) return null;
24
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
25
+ if (typeof payload.exp === 'number' && payload.exp * 1000 < Date.now()) return null;
26
+ return payload;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Hierarchical prefix match: a shorter claim grants longer actions (`agent` ⇒ `agent.x.y`). */ export function claimSatisfies(userClaims, required) {
32
+ return userClaims.some((uc)=>required === uc || required.startsWith(`${uc}.`));
33
+ }
34
+ /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */ export function pathMatchesTemplate(template, concrete) {
35
+ const t = template.replace(/\/+$/, '').split('/');
36
+ const c = concrete.replace(/\/+$/, '').split('/');
37
+ if (t.length !== c.length) return false;
38
+ for(let i = 0; i < t.length; i++){
39
+ const seg = t[i];
40
+ if (seg === undefined) return false;
41
+ if (seg.startsWith('{') && seg.endsWith('}')) continue;
42
+ if (seg !== c[i]) return false;
43
+ }
44
+ return true;
45
+ }
46
+ export function findRequiredAction(manifest, method, path) {
47
+ const m = method.toUpperCase();
48
+ for (const r of manifest){
49
+ if (r.method === m && pathMatchesTemplate(r.path, path)) return r.action;
50
+ }
51
+ return null;
52
+ }
53
+ function matchesGlob(path, patterns) {
54
+ if (!patterns?.length) return false;
55
+ for (const raw of patterns){
56
+ const p = raw.trim();
57
+ if (!p) continue;
58
+ if (p.endsWith('/*')) {
59
+ const prefix = p.slice(0, -2);
60
+ if (path === prefix || path.startsWith(`${prefix}/`)) return true;
61
+ } else if (p.endsWith('*')) {
62
+ if (path.startsWith(p.slice(0, -1))) return true;
63
+ } else if (path === p) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ /** Pure authorization decision (mode-independent). */ export function evaluateAccess(args) {
70
+ const { manifest, method, path, payload, onUnmapped, publicPaths, godminRole } = args;
71
+ if (matchesGlob(path, publicPaths)) return {
72
+ decision: 'allow',
73
+ reason: 'public',
74
+ action: null
75
+ };
76
+ const action = findRequiredAction(manifest, method, path);
77
+ if (action === null) {
78
+ return {
79
+ decision: onUnmapped,
80
+ reason: `unmapped:${onUnmapped}`,
81
+ action: null
82
+ };
83
+ }
84
+ if (!payload) return {
85
+ decision: 'deny',
86
+ reason: 'no-valid-token',
87
+ action
88
+ };
89
+ const roles = Array.isArray(payload.roles) ? payload.roles : [];
90
+ if (roles.includes(godminRole)) return {
91
+ decision: 'allow',
92
+ reason: 'godmin',
93
+ action
94
+ };
95
+ const claims = Array.isArray(payload.claims) ? payload.claims : [];
96
+ if (claimSatisfies(claims, action)) return {
97
+ decision: 'allow',
98
+ reason: 'claim',
99
+ action
100
+ };
101
+ return {
102
+ decision: 'deny',
103
+ reason: 'missing-claim',
104
+ action
105
+ };
106
+ }
107
+ /** Fetches + caches a service's route manifest from the IDP. */ export class ManifestCache {
108
+ get ttlMs() {
109
+ return (this.cfg.refreshSec ?? 300) * 1000;
110
+ }
111
+ async refresh() {
112
+ const url = `${this.cfg.url}${this.cfg.url.includes('?') ? '&' : '?'}service=${encodeURIComponent(this.cfg.serviceId)}`;
113
+ const headers = {};
114
+ if (this.cfg.token) headers['x-discovery-token'] = this.cfg.token;
115
+ const res = await this.fetchImpl(url, {
116
+ headers
117
+ });
118
+ if (!res.ok) throw new Error(`manifest fetch ${res.status}`);
119
+ const body = await res.json();
120
+ this.routes = body.data?.routes ?? [];
121
+ this.fetchedAtMs = Date.now();
122
+ }
123
+ async get() {
124
+ const stale = Date.now() - this.fetchedAtMs > this.ttlMs;
125
+ if (stale && !this.inflight) {
126
+ this.inflight = this.refresh().catch((err)=>{
127
+ this.logger?.warn?.(`[Proxy authz] manifest refresh failed for ${this.cfg.serviceId}: ${err instanceof Error ? err.message : String(err)}`);
128
+ }).finally(()=>{
129
+ this.inflight = null;
130
+ });
131
+ }
132
+ // First-ever load must block; later refreshes serve the last good copy.
133
+ if (this.fetchedAtMs === 0 && this.inflight) await this.inflight;
134
+ return this.routes;
135
+ }
136
+ constructor(cfg, fetchImpl = fetch, logger){
137
+ _define_property(this, "cfg", void 0);
138
+ _define_property(this, "fetchImpl", void 0);
139
+ _define_property(this, "logger", void 0);
140
+ _define_property(this, "routes", void 0);
141
+ _define_property(this, "fetchedAtMs", void 0);
142
+ _define_property(this, "inflight", void 0);
143
+ this.cfg = cfg;
144
+ this.fetchImpl = fetchImpl;
145
+ this.logger = logger;
146
+ this.routes = [];
147
+ this.fetchedAtMs = 0;
148
+ this.inflight = null;
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,192 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { describe, expect, it } from 'bun:test';
3
+ import { claimSatisfies, evaluateAccess, findRequiredAction, verifyJwtHS256 } from './authz';
4
+ const b64 = (o)=>Buffer.from(JSON.stringify(o)).toString('base64url');
5
+ function signHS256(payload, secret) {
6
+ const header = b64({
7
+ alg: 'HS256',
8
+ typ: 'JWT'
9
+ });
10
+ const body = b64(payload);
11
+ const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
12
+ return `${header}.${body}.${sig}`;
13
+ }
14
+ const SECRET = 'test-secret';
15
+ describe('verifyJwtHS256', ()=>{
16
+ it('verifies a correctly signed, unexpired token', ()=>{
17
+ const t = signHS256({
18
+ sub: 'u1',
19
+ claims: [
20
+ 'agent.templates.read'
21
+ ]
22
+ }, SECRET);
23
+ expect(verifyJwtHS256(t, SECRET)?.sub).toBe('u1');
24
+ });
25
+ it('rejects a wrong secret', ()=>{
26
+ const t = signHS256({
27
+ sub: 'u1'
28
+ }, SECRET);
29
+ expect(verifyJwtHS256(t, 'other-secret')).toBeNull();
30
+ });
31
+ it('rejects a tampered payload (forged claims)', ()=>{
32
+ const t = signHS256({
33
+ sub: 'u1',
34
+ claims: []
35
+ }, SECRET);
36
+ const [h, , s] = t.split('.');
37
+ const forged = `${h}.${b64({
38
+ sub: 'u1',
39
+ claims: [
40
+ 'agent.templates.delete'
41
+ ]
42
+ })}.${s}`;
43
+ expect(verifyJwtHS256(forged, SECRET)).toBeNull();
44
+ });
45
+ it('rejects an expired token', ()=>{
46
+ const t = signHS256({
47
+ sub: 'u1',
48
+ exp: Math.floor(Date.now() / 1000) - 60
49
+ }, SECRET);
50
+ expect(verifyJwtHS256(t, SECRET)).toBeNull();
51
+ });
52
+ it('rejects malformed input', ()=>{
53
+ expect(verifyJwtHS256('not.a.jwt.token', SECRET)).toBeNull();
54
+ expect(verifyJwtHS256('garbage', SECRET)).toBeNull();
55
+ });
56
+ });
57
+ describe('claimSatisfies (prefix)', ()=>{
58
+ it('exact + prefix grants', ()=>{
59
+ expect(claimSatisfies([
60
+ 'agent.templates.read'
61
+ ], 'agent.templates.read')).toBe(true);
62
+ expect(claimSatisfies([
63
+ 'agent.templates'
64
+ ], 'agent.templates.read')).toBe(true);
65
+ expect(claimSatisfies([
66
+ 'agent'
67
+ ], 'agent.templates.read')).toBe(true);
68
+ });
69
+ it('does not over-grant across siblings', ()=>{
70
+ expect(claimSatisfies([
71
+ 'agent.templates.read'
72
+ ], 'agent.templates.delete')).toBe(false);
73
+ expect(claimSatisfies([
74
+ 'agent.templatesx'
75
+ ], 'agent.templates.read')).toBe(false);
76
+ });
77
+ });
78
+ const MANIFEST = [
79
+ {
80
+ action: 'agent.templates.list',
81
+ path: '/api/v1/templates',
82
+ method: 'GET'
83
+ },
84
+ {
85
+ action: 'agent.templates.delete',
86
+ path: '/api/v1/templates/{id}',
87
+ method: 'DELETE'
88
+ }
89
+ ];
90
+ describe('findRequiredAction', ()=>{
91
+ it('resolves templated routes', ()=>{
92
+ expect(findRequiredAction(MANIFEST, 'GET', '/api/v1/templates')).toBe('agent.templates.list');
93
+ expect(findRequiredAction(MANIFEST, 'DELETE', '/api/v1/templates/9')).toBe('agent.templates.delete');
94
+ expect(findRequiredAction(MANIFEST, 'POST', '/api/v1/templates')).toBeNull();
95
+ });
96
+ });
97
+ describe('evaluateAccess', ()=>{
98
+ const base = {
99
+ manifest: MANIFEST,
100
+ onUnmapped: 'deny',
101
+ godminRole: 'godmin'
102
+ };
103
+ const payload = (claims, roles = [])=>({
104
+ claims,
105
+ roles
106
+ });
107
+ it('allows a public path without a claim', ()=>{
108
+ const d = evaluateAccess({
109
+ ...base,
110
+ method: 'GET',
111
+ path: '/health',
112
+ payload: null,
113
+ publicPaths: [
114
+ '/health'
115
+ ]
116
+ });
117
+ expect(d.decision).toBe('allow');
118
+ });
119
+ it('denies an unmapped path when onUnmapped=deny', ()=>{
120
+ const d = evaluateAccess({
121
+ ...base,
122
+ method: 'GET',
123
+ path: '/api/v1/unknown',
124
+ payload: payload([])
125
+ });
126
+ expect(d.decision).toBe('deny');
127
+ });
128
+ it('allows an unmapped path when onUnmapped=allow', ()=>{
129
+ const d = evaluateAccess({
130
+ ...base,
131
+ onUnmapped: 'allow',
132
+ method: 'GET',
133
+ path: '/api/v1/unknown',
134
+ payload: payload([])
135
+ });
136
+ expect(d.decision).toBe('allow');
137
+ });
138
+ it('denies a mapped route with no valid token', ()=>{
139
+ const d = evaluateAccess({
140
+ ...base,
141
+ method: 'GET',
142
+ path: '/api/v1/templates',
143
+ payload: null
144
+ });
145
+ expect(d).toMatchObject({
146
+ decision: 'deny',
147
+ reason: 'no-valid-token'
148
+ });
149
+ });
150
+ it('allows godmin regardless of claims', ()=>{
151
+ const d = evaluateAccess({
152
+ ...base,
153
+ method: 'DELETE',
154
+ path: '/api/v1/templates/1',
155
+ payload: payload([], [
156
+ 'godmin'
157
+ ])
158
+ });
159
+ expect(d).toMatchObject({
160
+ decision: 'allow',
161
+ reason: 'godmin'
162
+ });
163
+ });
164
+ it('allows when a claim satisfies the required action', ()=>{
165
+ const d = evaluateAccess({
166
+ ...base,
167
+ method: 'GET',
168
+ path: '/api/v1/templates',
169
+ payload: payload([
170
+ 'agent.templates'
171
+ ])
172
+ });
173
+ expect(d).toMatchObject({
174
+ decision: 'allow',
175
+ reason: 'claim'
176
+ });
177
+ });
178
+ it('denies when the caller lacks the required claim', ()=>{
179
+ const d = evaluateAccess({
180
+ ...base,
181
+ method: 'DELETE',
182
+ path: '/api/v1/templates/1',
183
+ payload: payload([
184
+ 'agent.templates.list'
185
+ ])
186
+ });
187
+ expect(d).toMatchObject({
188
+ decision: 'deny',
189
+ reason: 'missing-claim'
190
+ });
191
+ });
192
+ });
@@ -1,3 +1,4 @@
1
+ import { evaluateAccess, ManifestCache, verifyJwtHS256 } from './authz';
1
2
  import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
2
3
  const pendingRefreshes = new Map();
3
4
  function extractAccessTokenFromSetCookie(setCookieHeaders, accessCookieName) {
@@ -132,6 +133,57 @@ function extractSubFromJwt(token) {
132
133
  }
133
134
  export function createHttpProxyHandler(config) {
134
135
  const logger = createProxyLogger('HTTP Proxy', config.debug ?? false);
136
+ // One manifest cache per authorize-enabled service (shared across requests).
137
+ const manifestCaches = new Map();
138
+ for (const t of config.targets){
139
+ if (t.authorize) {
140
+ manifestCaches.set(t.authorize.serviceId, new ManifestCache({
141
+ ...t.authorize.manifest,
142
+ serviceId: t.authorize.serviceId
143
+ }, fetch, logger));
144
+ }
145
+ }
146
+ /**
147
+ * Claim-gate a request for a target. Returns a 403 Response to block (enforce
148
+ * mode), or null to allow (also null in audit mode, where a would-block is logged).
149
+ */ async function checkAuthorize(req, target, path) {
150
+ const az = target.authorize;
151
+ if (!az) return null;
152
+ const cache = manifestCaches.get(az.serviceId);
153
+ if (!cache) return null;
154
+ const manifest = await cache.get();
155
+ const upstreamPath = target.pathRewrite ? rewritePath(path, target.pathRewrite) : path;
156
+ const cookies = parseCookies(req.headers.get('cookie'));
157
+ const bearer = az.jwt.headerName ? (req.headers.get(az.jwt.headerName) ?? '').replace(/^Bearer\s+/i, '') : '';
158
+ const tokenStr = cookies[az.jwt.cookieName ?? 'access_token'] || bearer || '';
159
+ const payload = tokenStr ? verifyJwtHS256(tokenStr, az.jwt.secret) : null;
160
+ const decision = evaluateAccess({
161
+ manifest,
162
+ method: req.method,
163
+ path: upstreamPath,
164
+ payload,
165
+ onUnmapped: az.onUnmapped ?? 'deny',
166
+ publicPaths: az.publicPaths,
167
+ godminRole: az.godminRole ?? 'godmin'
168
+ });
169
+ if (decision.decision === 'allow') return null;
170
+ const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
171
+ if ((az.mode ?? 'enforce') === 'audit') {
172
+ logger.warn(`[authz audit] WOULD block ${detail}`);
173
+ return null;
174
+ }
175
+ logger.warn(`[authz] 403 ${detail}`);
176
+ return new Response(JSON.stringify({
177
+ success: false,
178
+ message: 'Forbidden',
179
+ reason: decision.reason
180
+ }), {
181
+ status: 403,
182
+ headers: {
183
+ 'content-type': 'application/json'
184
+ }
185
+ });
186
+ }
135
187
  function findTarget(path) {
136
188
  for (const target of config.targets){
137
189
  if (matchPath(path, target.paths)) {
@@ -178,18 +230,24 @@ export function createHttpProxyHandler(config) {
178
230
  } else {
179
231
  headers.delete('host');
180
232
  }
181
- // Inject real client IP as x-forwarded-for / x-real-ip if not already present
233
+ // SECURITY: this proxy is the trust boundary for the client IP. A previous
234
+ // version APPENDED the socket IP to an existing X-Forwarded-For, so a client
235
+ // could send `X-Forwarded-For: <trusted-ip>` and the chain became
236
+ // `<trusted-ip>, <real>` — downstream allow-lists that read the LEADING entry
237
+ // (e.g. tenant allowedIps) then trusted the spoofed value. OVERWRITE, never
238
+ // append, and strip every client-supplied alternative IP header.
239
+ for (const h of [
240
+ 'cf-connecting-ip',
241
+ 'true-client-ip',
242
+ 'x-client-ip'
243
+ ])headers.delete(h);
182
244
  if (clientIp) {
183
- const existingXff = headers.get('x-forwarded-for');
184
- if (existingXff) {
185
- // Append proxy client IP to existing chain
186
- headers.set('x-forwarded-for', `${existingXff}, ${clientIp}`);
187
- } else {
188
- headers.set('x-forwarded-for', clientIp);
189
- }
190
- if (!headers.has('x-real-ip')) {
191
- headers.set('x-real-ip', clientIp);
192
- }
245
+ headers.set('x-forwarded-for', clientIp);
246
+ headers.set('x-real-ip', clientIp);
247
+ } else {
248
+ // Couldn't resolve the socket IP — do not forward a client-controlled value.
249
+ headers.delete('x-forwarded-for');
250
+ headers.delete('x-real-ip');
193
251
  }
194
252
  if (target.headers) {
195
253
  for (const [key, value] of Object.entries(target.headers)){
@@ -237,6 +295,11 @@ export function createHttpProxyHandler(config) {
237
295
  if (!target) {
238
296
  return null;
239
297
  }
298
+ // Claim-gate BEFORE forwarding: a denied request never reaches the upstream.
299
+ if (target.authorize) {
300
+ const blocked = await checkAuthorize(req, target, path);
301
+ if (blocked) return blocked;
302
+ }
240
303
  const startTime = performance.now();
241
304
  const backendUrl = buildBackendUrl(path, target);
242
305
  const finalUrl = url.search ? `${backendUrl}${url.search}` : backendUrl;
@@ -34,6 +34,32 @@ 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
+ url: string;
50
+ token?: string;
51
+ refreshSec?: number;
52
+ };
53
+ jwt: {
54
+ secret: string;
55
+ cookieName?: string;
56
+ headerName?: string;
57
+ };
58
+ mode?: 'audit' | 'enforce';
59
+ onUnmapped?: 'allow' | 'deny';
60
+ publicPaths?: string[];
61
+ godminRole?: string;
62
+ }
37
63
  export interface HttpProxyTarget {
38
64
  url: string;
39
65
  paths: string[];
@@ -48,6 +74,8 @@ export interface HttpProxyTarget {
48
74
  cookieName: string;
49
75
  headerName?: string;
50
76
  };
77
+ /** Enforce discovered endpoint claims for this target at the proxy. */
78
+ authorize?: ProxyAuthorizeConfig;
51
79
  tokenRefresh?: TokenRefreshConfig;
52
80
  changeOrigin?: boolean;
53
81
  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>;