@efa-one/sdk 1.22.0 → 1.23.0

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/README.md CHANGED
@@ -12,8 +12,8 @@ sub-paths — this keeps `express` out of the frontend and `react` out of the ba
12
12
 
13
13
  | Import | Runs in | Contains |
14
14
  |---|---|---|
15
- | `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), capability registry (`registerApiMetadata`), backend i18n |
16
- | `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), `DevHeader` |
15
+ | `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), platform-admin check (`isPlatformAdmin` — top permission `efa-all`, legacy `converge-admin` accepted until Q1-2027; `requireAdmin`/`requireAdminOrPermission` use it), capability registry (`registerApiMetadata`), backend i18n |
16
+ | `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), platform-admin check (`isPlatformAdmin`, `PLATFORM_ADMIN_PERMISSIONS`), `DevHeader` |
17
17
  | `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns, a per-user persisted view and CSV/Excel export of the current view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
18
18
  | `@efa-one/sdk/frontend/format` | Browser | Platform-wide date/time display — `formatDate` (`01.09.2026`), `formatDateTime` (`01.09.2026, 11:05`), `formatTime`, `formatFileStamp`, `localeForLanguage`. Always use these instead of `toLocaleDateString`/`toLocaleString`: without an options object those render `1.9.2026`, and without a locale argument they follow the browser language (`9/1/2026`) |
19
19
  | `@efa-one/sdk/frontend/viewPreferences` | Browser/React | Persistence seam for `DataTable` — `createViewPreferencesClient()` (standard `/api/view-preferences/:listId` adapter) + `useViewPreferences()` |
@@ -53,6 +53,10 @@ npm install @efa-one/sdk react i18next i18next-http-backend react-i18next
53
53
 
54
54
  The runtime libraries are **optional peer dependencies** — install only the ones
55
55
  matching the entry point you use. The SDK itself does not bundle them.
56
+ `i18next-http-backend` requires **4.0.2 or later**: older releases are covered by
57
+ published security advisories (`npm audit` reports GHSA-xvq9-wjp8-hwqf for every
58
+ version below 4.0.2). The range stays open upwards like the other peers, so a new
59
+ major does not need an SDK release first.
56
60
 
57
61
  ## Usage
58
62
 
@@ -239,6 +243,61 @@ exported for app-side use. CSV is CSV: Excel turns `01067` into `1067` and long
239
243
  strings into `1,23E+18`, and shows the `'` in front of `+49 …` — point users to the
240
244
  Excel export for such data. No backend or kernel change.
241
245
 
246
+ **1.23.0 — Q4-2026 security bundle, `efa-all`.** Needs **Kernel ≥ v1.1.0**.
247
+ - `efa-all` is the top permission (was `converge-admin`). `isPlatformAdmin(keys)`,
248
+ `PLATFORM_ADMIN_PERMISSION(S)` and the admin guards accept `efa-all` **or**
249
+ `converge-admin` until Q1-2027.
250
+ - Exchange rejects purpose-bound tokens (`purpose`/`serviceKey`), `gw` other than
251
+ `'app'`, `forcePasswordChange` and `tenant !== 'default'` (`classifyPlatformToken`);
252
+ `gw: 'app'` only with gateway provenance for this app.
253
+ - In production (`NODE_ENV=production`) `app_session` lives on `/apps/<SERVICE_KEY>/`
254
+ instead of `/`; the exchange removes an old `/` cookie unless the name is the generic
255
+ `app_session`. Apps still on the generic name set `APP_SESSION_COOKIE_NAME=<key>_app_session`.
256
+ - `requireRegistryKey`/`requireInternalOrAuth`/`requireInternalOrAdminOrPermission` take
257
+ `{ allowedCallers }`, set `req.callerService`, accept provenance tokens for 60 s and only
258
+ once (`jti`).
259
+ - `APP_SESSION_SECRET` missing or a placeholder aborts the start in production, shorter
260
+ than 32 bytes warns (`APP_SESSION_SECRET_STRICT=true` aborts).
261
+ - `serviceClient` no longer retries 4xx (`ServiceClientError`), has `timeoutMs`;
262
+ permission lookups time out after 5 s. `isFromPlatformParent` also checks `event.origin`.
263
+ - Peer range `i18next-http-backend >=4.0.2`; DataTable bulk bar says „Auswahl aufheben".
264
+
265
+ **Next minor (planned 1.23.0, not yet released) — exchange, cookie and S2S hardening.**
266
+ Needs a kernel with efaware/efa_kernel#106 and #107 first (gateway exchange token
267
+ `gw: 'app'`, provenance `jti`, cookie filter). Then:
268
+
269
+ - `POST /api/auth/exchange` rejects tokens with `purpose` or `serviceKey` (delegation,
270
+ RAG, run-as), with a `gw` other than `'app'`, with `forcePasswordChange: true` and with a
271
+ tenant other than `default` (`classifyPlatformToken`). The gateway exchange token
272
+ (`gw: 'app'`) is accepted only together with the gateway's provenance token for this app
273
+ (`sub=converge_kernel`, `aud=SERVICE_KEY`), so an app that received one cannot replay it
274
+ at another app. Kernel first: before efaware/efa_kernel#106 tokens carry no `gw`.
275
+ - `app_session` lives on `/apps/<SERVICE_KEY>/` instead of `/` when `NODE_ENV=production`
276
+ (every app image sets it); `/` otherwise, `APP_SESSION_COOKIE_PATH` overrides (set it to
277
+ `/` to run a built image locally without the gateway). With an app-specific cookie name
278
+ the exchange deletes a legacy cookie on `/`; the generic `app_session` is left alone,
279
+ it may belong to another app.
280
+ - `requireRegistryKey({ allowedCallers })`, `requireInternalOrAuth({ allowedCallers })`,
281
+ `requireInternalOrAdminOrPermission({ allowedCallers }, ...keys)`; the plain forms keep
282
+ working. `req.callerService` carries the verified caller. Provenance tokens older than
283
+ 60 s are rejected, each `jti` is accepted once per app (in-memory, bounded); tokens
284
+ without `jti` (older kernel) still pass.
285
+ - `APP_SESSION_SECRET`: missing or placeholder → `createExchangeRouter` throws at startup
286
+ in production (outside production it logs, so test suites that build the router before
287
+ setting env keep working); shorter than 32 bytes → warning, start abort with
288
+ `APP_SESSION_SECRET_STRICT=true`.
289
+ - `requireInternalOrAdminOrPermission` throws when an options object is not the first
290
+ argument (it would otherwise be ignored silently).
291
+ - `serviceClient` no longer retries 4xx (`ServiceClientError` with `status`), new
292
+ `call(…, body, { timeoutMs })`; permission lookups time out after 5 s.
293
+ - `registerPermissions` no longer falls back to `CONVERGE_REGISTRY_URL` +
294
+ `/api/registry/services/:key/permissions` (that kernel route always answered 500 and is
295
+ removed in efaware/efa_kernel for efa #287); without `CONVERGE_GATEWAY_URL` it logs and
296
+ skips. `options.registryBaseUrl` is ignored.
297
+ - `isFromPlatformParent` also checks `event.origin === window.location.origin`. The kernel
298
+ embeds SDK apps same-origin under `/apps/<key>/`; an app embedded from a foreign origin
299
+ no longer accepts platform messages.
300
+
242
301
  ## License
243
302
 
244
303
  [Apache-2.0](./LICENSE) — permissive with a patent grant, so customers and partners
package/backend/auth.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Router, Request, Response, NextFunction } from 'express';
2
2
  import { Pool } from 'pg';
3
+ import { type ProvenanceGuardOptions } from './serviceProvenance';
4
+ export type { ProvenanceGuardOptions } from './serviceProvenance';
3
5
  export interface JwtPayload {
4
6
  sub: string;
5
7
  name: string;
@@ -10,6 +12,7 @@ export interface JwtPayload {
10
12
  exp: number;
11
13
  forcePasswordChange?: boolean;
12
14
  jti?: string;
15
+ gw?: string;
13
16
  }
14
17
  export interface SessionPayload {
15
18
  sub: string;
@@ -23,12 +26,48 @@ export interface SessionPayload {
23
26
  }
24
27
  export interface AuthRequest extends Request {
25
28
  user?: SessionPayload;
29
+ callerService?: string;
26
30
  }
31
+ export declare const APP_SESSION_SECRET_MIN_BYTES = 32;
32
+ export type SessionSecretVerdict = {
33
+ ok: true;
34
+ } | {
35
+ ok: false;
36
+ level: 'error' | 'warn';
37
+ reason: 'missing' | 'placeholder' | 'too_short';
38
+ message: string;
39
+ };
40
+ export declare function checkSessionSecret(secret: string | undefined): SessionSecretVerdict;
41
+ export declare function assertSessionSecret(): void;
42
+ export declare function getSessionCookiePath(): string;
27
43
  export declare function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void;
28
44
  export declare function requireAdmin(req: AuthRequest, res: Response, next: NextFunction): void;
29
45
  export declare function requirePermission(permission: string): (req: AuthRequest, res: Response, next: NextFunction) => void;
30
46
  export declare function requireAdminOrPermission(...permissionKeys: string[]): (req: AuthRequest, res: Response, next: NextFunction) => void;
47
+ type Guard = (req: AuthRequest, res: Response, next: NextFunction) => void;
48
+ export declare function requireRegistryKey(options: ProvenanceGuardOptions): Guard;
31
49
  export declare function requireRegistryKey(req: Request, res: Response, next: NextFunction): void;
50
+ export declare function requireInternalOrAuth(options: ProvenanceGuardOptions): Guard;
32
51
  export declare function requireInternalOrAuth(req: AuthRequest, res: Response, next: NextFunction): void;
33
- export declare function requireInternalOrAdminOrPermission(...permissionKeys: string[]): (req: AuthRequest, res: Response, next: NextFunction) => void;
52
+ export declare function requireInternalOrAdminOrPermission(...permissionKeys: string[]): Guard;
53
+ export declare function requireInternalOrAdminOrPermission(options: ProvenanceGuardOptions, ...permissionKeys: string[]): Guard;
54
+ export type PlatformTokenVerdict = {
55
+ ok: true;
56
+ } | {
57
+ ok: false;
58
+ status: 401;
59
+ error: string;
60
+ code: 'delegation_token_not_accepted';
61
+ } | {
62
+ ok: false;
63
+ status: 403;
64
+ error: 'Password change required';
65
+ code: 'FORCE_PASSWORD_CHANGE';
66
+ } | {
67
+ ok: false;
68
+ status: 403;
69
+ error: 'tenant_not_supported';
70
+ code: 'tenant_not_supported';
71
+ };
72
+ export declare function classifyPlatformToken(payload: Record<string, unknown>): PlatformTokenVerdict;
34
73
  export declare function createExchangeRouter(pool: Pool): Router;
package/backend/auth.js CHANGED
@@ -3,6 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.APP_SESSION_SECRET_MIN_BYTES = void 0;
7
+ exports.checkSessionSecret = checkSessionSecret;
8
+ exports.assertSessionSecret = assertSessionSecret;
9
+ exports.getSessionCookiePath = getSessionCookiePath;
6
10
  exports.requireAuth = requireAuth;
7
11
  exports.requireAdmin = requireAdmin;
8
12
  exports.requirePermission = requirePermission;
@@ -10,42 +14,106 @@ exports.requireAdminOrPermission = requireAdminOrPermission;
10
14
  exports.requireRegistryKey = requireRegistryKey;
11
15
  exports.requireInternalOrAuth = requireInternalOrAuth;
12
16
  exports.requireInternalOrAdminOrPermission = requireInternalOrAdminOrPermission;
17
+ exports.classifyPlatformToken = classifyPlatformToken;
13
18
  exports.createExchangeRouter = createExchangeRouter;
14
19
  const express_1 = require("express");
15
20
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
16
21
  const permissionClient_1 = require("./permissionClient");
17
22
  const sessionStatusClient_1 = require("./sessionStatusClient");
23
+ const platformAdmin_1 = require("./platformAdmin");
24
+ const serviceProvenance_1 = require("./serviceProvenance");
18
25
  const JWT_ISSUER = 'converge';
19
26
  const JWT_AUDIENCE = 'converge';
20
27
  const KERNEL_JTI_RE = /^[A-Za-z0-9._:-]{1,128}$/;
21
28
  const KERNEL_SUB_RE = /^[A-Za-z0-9._:@-]{1,128}$/;
22
- function getPlatformPublicKey() {
23
- const s = process.env.JWT_PUBLIC_KEY;
24
- if (!s)
25
- throw new Error('JWT_PUBLIC_KEY environment variable is required');
26
- return Buffer.from(s, 'base64').toString('utf8');
27
- }
28
29
  function getSessionSecret() {
29
30
  const s = process.env.APP_SESSION_SECRET;
30
31
  if (!s)
31
32
  throw new Error('APP_SESSION_SECRET environment variable is required');
32
33
  return s;
33
34
  }
35
+ exports.APP_SESSION_SECRET_MIN_BYTES = 32;
36
+ const SESSION_SECRET_PLACEHOLDER_PREFIXES = ['changeme', 'change-me', 'change_me', 'please_change', 'replace_me', 'your-'];
37
+ const SESSION_SECRET_PLACEHOLDERS = new Set(['secret', 'password', 'test', 'dev', 'development', 'app_session_secret']);
38
+ function checkSessionSecret(secret) {
39
+ if (!secret) {
40
+ return { ok: false, level: 'error', reason: 'missing', message: 'APP_SESSION_SECRET ist nicht gesetzt. Erzeugen mit: openssl rand -hex 32' };
41
+ }
42
+ const lower = secret.trim().toLowerCase();
43
+ if (SESSION_SECRET_PLACEHOLDERS.has(lower) || SESSION_SECRET_PLACEHOLDER_PREFIXES.some((p) => lower.startsWith(p))) {
44
+ return {
45
+ ok: false,
46
+ level: 'error',
47
+ reason: 'placeholder',
48
+ message: 'APP_SESSION_SECRET ist ein Platzhalter aus einer Beispiel-Konfiguration. Echten Wert setzen: openssl rand -hex 32',
49
+ };
50
+ }
51
+ const bytes = Buffer.byteLength(secret, 'utf8');
52
+ if (bytes < exports.APP_SESSION_SECRET_MIN_BYTES) {
53
+ return {
54
+ ok: false,
55
+ level: (0, serviceProvenance_1.isTruthyEnv)(process.env.APP_SESSION_SECRET_STRICT) ? 'error' : 'warn',
56
+ reason: 'too_short',
57
+ message: `APP_SESSION_SECRET ist ${bytes} Byte lang, verlangt sind mindestens ${exports.APP_SESSION_SECRET_MIN_BYTES}. Neu erzeugen mit: openssl rand -hex 32`,
58
+ };
59
+ }
60
+ return { ok: true };
61
+ }
62
+ function assertSessionSecret() {
63
+ const verdict = checkSessionSecret(process.env.APP_SESSION_SECRET);
64
+ if (verdict.ok)
65
+ return;
66
+ const fatal = verdict.level === 'error'
67
+ && (process.env.NODE_ENV === 'production' || (0, serviceProvenance_1.isTruthyEnv)(process.env.APP_SESSION_SECRET_STRICT));
68
+ if (fatal)
69
+ throw new Error(verdict.message);
70
+ const level = verdict.level === 'error' ? 'error' : 'warn';
71
+ console[level](JSON.stringify({ level, msg: verdict.message, reason: verdict.reason }));
72
+ }
73
+ const DEFAULT_COOKIE_NAME = 'app_session';
34
74
  function getCookieName() {
35
- return process.env.APP_SESSION_COOKIE_NAME || 'app_session';
75
+ return process.env.APP_SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME;
36
76
  }
37
- function setSessionCookie(res, token, maxAgeMs) {
38
- const secure = process.env.COOKIE_SECURE
39
- ? ['1', 'true', 'yes', 'on'].includes(process.env.COOKIE_SECURE.toLowerCase())
77
+ function getSessionCookiePath() {
78
+ const explicit = process.env.APP_SESSION_COOKIE_PATH?.trim();
79
+ if (explicit)
80
+ return explicit;
81
+ if (process.env.NODE_ENV !== 'production')
82
+ return '/';
83
+ const serviceKey = process.env.SERVICE_KEY;
84
+ if (serviceKey && serviceProvenance_1.SERVICE_KEY_RE.test(serviceKey))
85
+ return `/apps/${serviceKey}/`;
86
+ return '/';
87
+ }
88
+ function assertSessionCookiePath() {
89
+ const path = getSessionCookiePath();
90
+ if (!path.startsWith('/') || /[;,\s]/.test(path)) {
91
+ throw new Error(`APP_SESSION_COOKIE_PATH ungültig: ${JSON.stringify(path)} (muss mit / beginnen, ohne ; , oder Leerzeichen)`);
92
+ }
93
+ }
94
+ function isSecureCookie() {
95
+ return process.env.COOKIE_SECURE
96
+ ? (0, serviceProvenance_1.isTruthyEnv)(process.env.COOKIE_SECURE)
40
97
  : process.env.NODE_ENV === 'production';
98
+ }
99
+ function setSessionCookie(res, token, maxAgeMs) {
41
100
  res.cookie(getCookieName(), token, {
42
101
  httpOnly: true,
43
102
  sameSite: 'lax',
44
103
  maxAge: maxAgeMs,
45
- secure,
46
- path: '/',
104
+ secure: isSecureCookie(),
105
+ path: getSessionCookiePath(),
47
106
  });
48
107
  }
108
+ function clearLegacySessionCookie(req, res) {
109
+ if (getSessionCookiePath() === '/')
110
+ return;
111
+ if (getCookieName() === DEFAULT_COOKIE_NAME)
112
+ return;
113
+ if (req.cookies?.[getCookieName()] === undefined)
114
+ return;
115
+ res.clearCookie(getCookieName(), { httpOnly: true, sameSite: 'lax', secure: isSecureCookie(), path: '/' });
116
+ }
49
117
  function requireAuth(req, res, next) {
50
118
  const token = req.cookies?.[getCookieName()];
51
119
  if (!token) {
@@ -87,7 +155,7 @@ function requireAdmin(req, res, next) {
87
155
  requireAuth(req, res, () => {
88
156
  (0, permissionClient_1.getUserPermissions)(req.user.convergeId)
89
157
  .then((keys) => {
90
- if (!keys.includes('converge-admin')) {
158
+ if (!(0, platformAdmin_1.isPlatformAdmin)(keys)) {
91
159
  res.status(403).json({ error: 'Admin access required' });
92
160
  return;
93
161
  }
@@ -104,7 +172,10 @@ function requirePermission(permission) {
104
172
  requireAuth(req, res, () => {
105
173
  (0, permissionClient_1.getUserPermissions)(req.user.convergeId)
106
174
  .then((keys) => {
107
- if (!keys.includes(permission)) {
175
+ const granted = (0, platformAdmin_1.isPlatformAdminPermission)(permission)
176
+ ? (0, platformAdmin_1.isPlatformAdmin)(keys)
177
+ : keys.includes(permission);
178
+ if (!granted) {
108
179
  res.status(403).json({ error: `Permission required: ${permission}` });
109
180
  return;
110
181
  }
@@ -122,7 +193,7 @@ function requireAdminOrPermission(...permissionKeys) {
122
193
  requireAuth(req, res, () => {
123
194
  (0, permissionClient_1.getUserPermissions)(req.user.convergeId)
124
195
  .then((keys) => {
125
- if (keys.includes('converge-admin')) {
196
+ if ((0, platformAdmin_1.isPlatformAdmin)(keys)) {
126
197
  next();
127
198
  return;
128
199
  }
@@ -139,50 +210,99 @@ function requireAdminOrPermission(...permissionKeys) {
139
210
  });
140
211
  };
141
212
  }
142
- const PROVENANCE_ISSUER = 'converge-gateway';
143
- function hasValidServiceProvenance(req) {
144
- const token = req.headers['x-service-token'];
145
- if (!token)
146
- return false;
147
- const audience = process.env.SERVICE_KEY;
148
- if (!audience)
149
- return false;
150
- try {
151
- jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
152
- algorithms: ['RS256'],
153
- issuer: PROVENANCE_ISSUER,
154
- audience,
155
- });
156
- return true;
157
- }
158
- catch {
159
- return false;
160
- }
213
+ function isMiddlewareCall(res, next) {
214
+ return res !== undefined && typeof next === 'function';
161
215
  }
162
- function requireRegistryKey(req, res, next) {
163
- if (hasValidServiceProvenance(req)) {
164
- next();
216
+ function rejectProvenance(res, result) {
217
+ if (!result.ok && result.reason === 'caller_not_allowed') {
218
+ res.status(403).json({ error: 'Service caller not allowed' });
165
219
  return;
166
220
  }
167
221
  res.status(401).json({ error: 'Service provenance token required' });
168
222
  }
169
- function requireInternalOrAuth(req, res, next) {
170
- if (hasValidServiceProvenance(req)) {
171
- next();
223
+ function registryKeyGuard(options) {
224
+ return (req, res, next) => {
225
+ const result = (0, serviceProvenance_1.verifyServiceProvenance)(req, options);
226
+ if (result.ok) {
227
+ next();
228
+ return;
229
+ }
230
+ rejectProvenance(res, result);
231
+ };
232
+ }
233
+ function internalOrAuthGuard(options) {
234
+ return (req, res, next) => {
235
+ if ((0, serviceProvenance_1.verifyServiceProvenance)(req, options).ok) {
236
+ next();
237
+ return;
238
+ }
239
+ requireAuth(req, res, next);
240
+ };
241
+ }
242
+ function dualGuard(factory, a, res, next) {
243
+ if (isMiddlewareCall(res, next)) {
244
+ factory({})(a, res, next);
172
245
  return;
173
246
  }
174
- requireAuth(req, res, next);
247
+ const options = (a ?? {});
248
+ (0, serviceProvenance_1.assertProvenanceOptions)(options);
249
+ return factory(options);
250
+ }
251
+ function requireRegistryKey(a, res, next) {
252
+ return dualGuard(registryKeyGuard, a, res, next);
253
+ }
254
+ function requireInternalOrAuth(a, res, next) {
255
+ return dualGuard(internalOrAuthGuard, a, res, next);
175
256
  }
176
- function requireInternalOrAdminOrPermission(...permissionKeys) {
257
+ function requireInternalOrAdminOrPermission(...args) {
258
+ const [first, ...rest] = args;
259
+ const options = typeof first === 'object' && first !== null ? first : {};
260
+ const keys = typeof first === 'string' ? args : rest;
261
+ if (!keys.every((a) => typeof a === 'string')) {
262
+ throw new Error('requireInternalOrAdminOrPermission: Optionen nur als erstes Argument, danach Permission-Keys');
263
+ }
264
+ const permissionKeys = keys;
265
+ (0, serviceProvenance_1.assertProvenanceOptions)(options);
266
+ const userGuard = requireAdminOrPermission(...permissionKeys);
177
267
  return (req, res, next) => {
178
- if (hasValidServiceProvenance(req)) {
268
+ if ((0, serviceProvenance_1.verifyServiceProvenance)(req, options).ok) {
179
269
  next();
180
270
  return;
181
271
  }
182
- requireAdminOrPermission(...permissionKeys)(req, res, next);
272
+ userGuard(req, res, next);
183
273
  };
184
274
  }
275
+ const PLATFORM_TENANT = 'default';
276
+ const GATEWAY_EXCHANGE_CALLER = 'converge_kernel';
277
+ function classifyPlatformToken(payload) {
278
+ const serviceKey = payload.serviceKey;
279
+ if (serviceKey !== undefined && serviceKey !== null && serviceKey !== '') {
280
+ return { ok: false, status: 401, error: 'Service-Token begründet keine Sitzung', code: 'delegation_token_not_accepted' };
281
+ }
282
+ const purpose = payload.purpose;
283
+ if (purpose !== undefined && purpose !== null && purpose !== '') {
284
+ const label = typeof purpose === 'string' ? purpose.slice(0, 64) : typeof purpose;
285
+ return {
286
+ ok: false,
287
+ status: 401,
288
+ error: `Zweckgebundenes Token (purpose=${label}) begründet keine Sitzung`,
289
+ code: 'delegation_token_not_accepted',
290
+ };
291
+ }
292
+ if (payload.gw !== undefined && payload.gw !== 'app') {
293
+ return { ok: false, status: 401, error: 'Gateway-Token für ein anderes Ziel begründet keine Sitzung', code: 'delegation_token_not_accepted' };
294
+ }
295
+ if (payload.forcePasswordChange === true) {
296
+ return { ok: false, status: 403, error: 'Password change required', code: 'FORCE_PASSWORD_CHANGE' };
297
+ }
298
+ if (payload.tenant !== PLATFORM_TENANT) {
299
+ return { ok: false, status: 403, error: 'tenant_not_supported', code: 'tenant_not_supported' };
300
+ }
301
+ return { ok: true };
302
+ }
185
303
  function createExchangeRouter(pool) {
304
+ assertSessionSecret();
305
+ assertSessionCookiePath();
186
306
  const router = (0, express_1.Router)();
187
307
  router.post('/exchange', async (req, res) => {
188
308
  const { token } = req.body;
@@ -192,7 +312,7 @@ function createExchangeRouter(pool) {
192
312
  }
193
313
  let convergePayload;
194
314
  try {
195
- convergePayload = jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
315
+ convergePayload = jsonwebtoken_1.default.verify(token, (0, serviceProvenance_1.getPlatformPublicKey)(), {
196
316
  algorithms: ['RS256'],
197
317
  issuer: JWT_ISSUER,
198
318
  audience: JWT_AUDIENCE,
@@ -202,6 +322,29 @@ function createExchangeRouter(pool) {
202
322
  res.status(401).json({ error: 'Invalid or expired platform token' });
203
323
  return;
204
324
  }
325
+ const verdict = classifyPlatformToken(convergePayload);
326
+ if (!verdict.ok) {
327
+ console.warn(JSON.stringify({
328
+ level: 'warn',
329
+ msg: 'Token-Exchange abgelehnt',
330
+ code: verdict.code,
331
+ sub: typeof convergePayload.sub === 'string' ? convergePayload.sub : null,
332
+ }));
333
+ res.status(verdict.status).json({ error: verdict.error, code: verdict.code });
334
+ return;
335
+ }
336
+ if (convergePayload.gw === 'app') {
337
+ const provenance = (0, serviceProvenance_1.verifyServiceProvenance)(req, { allowedCallers: [GATEWAY_EXCHANGE_CALLER] });
338
+ if (!provenance.ok) {
339
+ console.warn(JSON.stringify({
340
+ level: 'warn',
341
+ msg: 'Token-Exchange abgelehnt: Gateway-Tausch-Token ohne Herkunftsnachweis',
342
+ reason: provenance.reason,
343
+ }));
344
+ res.status(401).json({ error: 'Gateway-Tausch-Token nur auf dem Tausch-Aufruf des Gateways', code: 'gateway_provenance_required' });
345
+ return;
346
+ }
347
+ }
205
348
  if (typeof convergePayload.jti !== 'string' || !KERNEL_JTI_RE.test(convergePayload.jti) ||
206
349
  typeof convergePayload.sub !== 'string' || !KERNEL_SUB_RE.test(convergePayload.sub) ||
207
350
  !Number.isInteger(convergePayload.iat) || !Number.isInteger(convergePayload.exp)) {
@@ -238,6 +381,7 @@ function createExchangeRouter(pool) {
238
381
  };
239
382
  const sessionToken = jsonwebtoken_1.default.sign(sessionPayload, getSessionSecret(), { algorithm: 'HS256', expiresIn: remainingSec });
240
383
  setSessionCookie(res, sessionToken, remainingSec * 1000);
384
+ clearLegacySessionCookie(req, res);
241
385
  res.json({
242
386
  user: {
243
387
  id: appUser.id,
@@ -254,7 +398,11 @@ function createExchangeRouter(pool) {
254
398
  }
255
399
  });
256
400
  router.post('/logout', (_req, res) => {
257
- res.clearCookie(getCookieName());
401
+ const opts = { httpOnly: true, sameSite: 'lax', secure: isSecureCookie() };
402
+ res.clearCookie(getCookieName(), { ...opts, path: getSessionCookiePath() });
403
+ if (getSessionCookiePath() !== '/' && getCookieName() !== DEFAULT_COOKIE_NAME) {
404
+ res.clearCookie(getCookieName(), { ...opts, path: '/' });
405
+ }
258
406
  res.json({ ok: true });
259
407
  });
260
408
  router.get('/permissions', requireAuth, async (req, res) => {
@@ -8,8 +8,10 @@ export * from './permissions';
8
8
  export * from './permissionClient';
9
9
  export * from './sessionStatusClient';
10
10
  export * from './permissionCheck';
11
+ export * from './platformAdmin';
11
12
  export * from './customPermissions';
12
13
  export * from './serviceClient';
13
14
  export * from './serviceDiscovery';
14
15
  export * from './apiRegistry';
15
16
  export * from './i18n-backend';
17
+ export * from './serviceProvenance';
package/backend/index.js CHANGED
@@ -24,8 +24,10 @@ __exportStar(require("./permissions"), exports);
24
24
  __exportStar(require("./permissionClient"), exports);
25
25
  __exportStar(require("./sessionStatusClient"), exports);
26
26
  __exportStar(require("./permissionCheck"), exports);
27
+ __exportStar(require("./platformAdmin"), exports);
27
28
  __exportStar(require("./customPermissions"), exports);
28
29
  __exportStar(require("./serviceClient"), exports);
29
30
  __exportStar(require("./serviceDiscovery"), exports);
30
31
  __exportStar(require("./apiRegistry"), exports);
31
32
  __exportStar(require("./i18n-backend"), exports);
33
+ __exportStar(require("./serviceProvenance"), exports);
@@ -2,11 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkPermission = checkPermission;
4
4
  const serviceClient_1 = require("./serviceClient");
5
+ const permissionClient_1 = require("./permissionClient");
5
6
  async function checkPermission(convergeId, key, sourceApp) {
6
7
  if (!convergeId)
7
8
  throw new Error('checkPermission: convergeId required');
8
9
  if (!key)
9
10
  throw new Error('checkPermission: key required');
10
- const { granted } = await serviceClient_1.serviceClient.call('converge_access', 'POST', '/api/internal/check-permission', { convergeId, key, sourceApp: sourceApp ?? process.env.APP_NAME ?? null });
11
+ const { granted } = await serviceClient_1.serviceClient.call('converge_access', 'POST', '/api/internal/check-permission', { convergeId, key, sourceApp: sourceApp ?? process.env.APP_NAME ?? null }, { timeoutMs: permissionClient_1.PERMISSION_LOOKUP_TIMEOUT_MS });
11
12
  return Boolean(granted);
12
13
  }
@@ -1 +1,2 @@
1
+ export declare const PERMISSION_LOOKUP_TIMEOUT_MS = 5000;
1
2
  export declare function getUserPermissions(convergeId: string): Promise<string[]>;
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PERMISSION_LOOKUP_TIMEOUT_MS = void 0;
3
4
  exports.getUserPermissions = getUserPermissions;
4
5
  const serviceClient_1 = require("./serviceClient");
6
+ exports.PERMISSION_LOOKUP_TIMEOUT_MS = 5000;
5
7
  async function getUserPermissions(convergeId) {
6
8
  if (!convergeId) {
7
9
  throw new Error('getUserPermissions: convergeId required');
8
10
  }
9
- const { keys } = await serviceClient_1.serviceClient.call('converge_access', 'GET', `/api/internal/users/${encodeURIComponent(convergeId)}/permissions`);
11
+ const { keys } = await serviceClient_1.serviceClient.call('converge_access', 'GET', `/api/internal/users/${encodeURIComponent(convergeId)}/permissions`, undefined, { timeoutMs: exports.PERMISSION_LOOKUP_TIMEOUT_MS });
10
12
  return keys ?? [];
11
13
  }
@@ -8,18 +8,11 @@ async function registerPermissions(serviceKey, permissions, options) {
8
8
  if (permissions.length === 0)
9
9
  return;
10
10
  const gatewayUrl = (process.env.CONVERGE_GATEWAY_URL ?? '').replace(/\/$/, '');
11
- const legacyUrl = (options?.registryBaseUrl ?? process.env.CONVERGE_REGISTRY_URL ?? '').replace(/\/$/, '');
12
- let url;
13
- if (gatewayUrl) {
14
- url = `${gatewayUrl}/api/gateway/converge_access/api/internal/register`;
15
- }
16
- else if (legacyUrl) {
17
- url = `${legacyUrl}/api/registry/services/${encodeURIComponent(serviceKey)}/permissions`;
18
- }
19
- else {
20
- log('warn', 'registerPermissions: Weder CONVERGE_GATEWAY_URL noch CONVERGE_REGISTRY_URL gesetzt – übersprungen');
11
+ if (!gatewayUrl) {
12
+ log('warn', 'registerPermissions: CONVERGE_GATEWAY_URL nicht gesetzt – übersprungen');
21
13
  return;
22
14
  }
15
+ const url = `${gatewayUrl}/api/gateway/converge_access/api/internal/register`;
23
16
  const maxRetries = options?.retries ?? 3;
24
17
  const retryDelay = options?.retryDelayMs ?? 5000;
25
18
  const fetchImpl = options?.fetchImpl ?? fetch;
@@ -0,0 +1,4 @@
1
+ export declare const PLATFORM_ADMIN_PERMISSION = "efa-all";
2
+ export declare const PLATFORM_ADMIN_PERMISSIONS: readonly string[];
3
+ export declare function isPlatformAdminPermission(key: string): boolean;
4
+ export declare function isPlatformAdmin(permissionKeys: readonly string[] | null | undefined): boolean;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLATFORM_ADMIN_PERMISSIONS = exports.PLATFORM_ADMIN_PERMISSION = void 0;
4
+ exports.isPlatformAdminPermission = isPlatformAdminPermission;
5
+ exports.isPlatformAdmin = isPlatformAdmin;
6
+ exports.PLATFORM_ADMIN_PERMISSION = 'efa-all';
7
+ exports.PLATFORM_ADMIN_PERMISSIONS = Object.freeze([
8
+ exports.PLATFORM_ADMIN_PERMISSION,
9
+ 'converge-admin',
10
+ ]);
11
+ function isPlatformAdminPermission(key) {
12
+ return exports.PLATFORM_ADMIN_PERMISSIONS.includes(key);
13
+ }
14
+ function isPlatformAdmin(permissionKeys) {
15
+ return !!permissionKeys && permissionKeys.some(isPlatformAdminPermission);
16
+ }
@@ -5,6 +5,15 @@ export interface ServiceClientOptions {
5
5
  retries?: number;
6
6
  retryDelayMs?: number;
7
7
  }
8
+ export interface ServiceCallOptions {
9
+ timeoutMs?: number;
10
+ }
11
+ export declare class ServiceClientError extends Error {
12
+ readonly status: number | null;
13
+ readonly serviceKey: string;
14
+ readonly timedOut: boolean;
15
+ constructor(message: string, serviceKey: string, status: number | null, timedOut?: boolean);
16
+ }
8
17
  export declare class ServiceClient {
9
18
  private gatewayUrl;
10
19
  private registryKey;
@@ -12,6 +21,6 @@ export declare class ServiceClient {
12
21
  private retries;
13
22
  private retryDelay;
14
23
  constructor(options?: ServiceClientOptions);
15
- call<T = unknown>(serviceKey: string, method: string, path: string, body?: unknown): Promise<T>;
24
+ call<T = unknown>(serviceKey: string, method: string, path: string, body?: unknown, options?: ServiceCallOptions): Promise<T>;
16
25
  }
17
26
  export declare const serviceClient: ServiceClient;
@@ -1,9 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.serviceClient = exports.ServiceClient = void 0;
3
+ exports.serviceClient = exports.ServiceClient = exports.ServiceClientError = void 0;
4
4
  function log(level, msg, extra) {
5
5
  console.log(JSON.stringify({ level, msg, timestamp: new Date().toISOString(), ...extra }));
6
6
  }
7
+ class ServiceClientError extends Error {
8
+ constructor(message, serviceKey, status, timedOut = false) {
9
+ super(message);
10
+ this.name = 'ServiceClientError';
11
+ this.serviceKey = serviceKey;
12
+ this.status = status;
13
+ this.timedOut = timedOut;
14
+ }
15
+ }
16
+ exports.ServiceClientError = ServiceClientError;
17
+ function isAbortError(err) {
18
+ const name = err?.name;
19
+ return name === 'AbortError' || name === 'TimeoutError';
20
+ }
7
21
  class ServiceClient {
8
22
  constructor(options) {
9
23
  this.gatewayUrl = (options?.gatewayUrl ?? process.env.CONVERGE_GATEWAY_URL ?? '').replace(/\/$/, '');
@@ -12,7 +26,7 @@ class ServiceClient {
12
26
  this.retries = options?.retries ?? 2;
13
27
  this.retryDelay = options?.retryDelayMs ?? 1000;
14
28
  }
15
- async call(serviceKey, method, path, body) {
29
+ async call(serviceKey, method, path, body, options = {}) {
16
30
  if (!this.gatewayUrl) {
17
31
  throw new Error('ServiceClient: CONVERGE_GATEWAY_URL nicht gesetzt');
18
32
  }
@@ -29,44 +43,63 @@ class ServiceClient {
29
43
  if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
30
44
  fetchOptions.body = JSON.stringify(body);
31
45
  }
46
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
47
+ fetchOptions.signal = AbortSignal.timeout(options.timeoutMs);
48
+ }
49
+ const timedOut = () => new ServiceClientError(`ServiceClient: ${serviceKey} ${method} ${path} → Timeout nach ${options.timeoutMs} ms`, serviceKey, null, true);
32
50
  for (let attempt = 1; attempt <= this.retries + 1; attempt++) {
51
+ let res;
33
52
  try {
34
- const res = await this.fetchImpl(url, fetchOptions);
35
- if (res.status === 204) {
36
- return undefined;
53
+ res = await this.fetchImpl(url, fetchOptions);
54
+ }
55
+ catch (err) {
56
+ if (isAbortError(err) || fetchOptions.signal?.aborted)
57
+ throw timedOut();
58
+ if (attempt <= this.retries) {
59
+ log('warn', 'ServiceClient: Netzwerkfehler, Retry', {
60
+ serviceKey, method, path, err: String(err), attempt,
61
+ });
62
+ await new Promise((r) => setTimeout(r, this.retryDelay));
63
+ if (fetchOptions.signal?.aborted)
64
+ throw timedOut();
65
+ continue;
37
66
  }
38
- if (!res.ok) {
39
- let errorMessage = `ServiceClient: ${serviceKey} ${method} ${path} → ${res.status}`;
40
- try {
41
- const errBody = await res.json();
42
- if (errBody.error)
43
- errorMessage = errBody.error;
44
- }
45
- catch { }
46
- if (res.status >= 400 && res.status < 500) {
47
- throw new Error(errorMessage);
48
- }
49
- if (attempt <= this.retries) {
50
- log('warn', 'ServiceClient: Retry nach Server-Fehler', {
51
- serviceKey, method, path, status: res.status, attempt,
52
- });
53
- await new Promise((r) => setTimeout(r, this.retryDelay));
54
- continue;
55
- }
56
- throw new Error(errorMessage);
67
+ throw err;
68
+ }
69
+ if (res.status === 204) {
70
+ return undefined;
71
+ }
72
+ if (!res.ok) {
73
+ let errorMessage = `ServiceClient: ${serviceKey} ${method} ${path} → ${res.status}`;
74
+ try {
75
+ const errBody = await res.json();
76
+ if (errBody.error)
77
+ errorMessage = errBody.error;
78
+ }
79
+ catch (err) {
80
+ if (isAbortError(err))
81
+ throw timedOut();
82
+ }
83
+ if (res.status >= 400 && res.status < 500) {
84
+ throw new ServiceClientError(errorMessage, serviceKey, res.status);
85
+ }
86
+ if (attempt <= this.retries) {
87
+ log('warn', 'ServiceClient: Retry nach Server-Fehler', {
88
+ serviceKey, method, path, status: res.status, attempt,
89
+ });
90
+ await new Promise((r) => setTimeout(r, this.retryDelay));
91
+ if (fetchOptions.signal?.aborted)
92
+ throw timedOut();
93
+ continue;
57
94
  }
58
- return res.json();
95
+ throw new ServiceClientError(errorMessage, serviceKey, res.status);
96
+ }
97
+ try {
98
+ return await res.json();
59
99
  }
60
100
  catch (err) {
61
- if (err instanceof Error && !err.message.startsWith('ServiceClient:')) {
62
- if (attempt <= this.retries) {
63
- log('warn', 'ServiceClient: Netzwerkfehler, Retry', {
64
- serviceKey, method, path, err: String(err), attempt,
65
- });
66
- await new Promise((r) => setTimeout(r, this.retryDelay));
67
- continue;
68
- }
69
- }
101
+ if (isAbortError(err))
102
+ throw timedOut();
70
103
  throw err;
71
104
  }
72
105
  }
@@ -0,0 +1,31 @@
1
+ import type { Request } from 'express';
2
+ export declare const PROVENANCE_ISSUER = "converge-gateway";
3
+ export declare const PROVENANCE_MAX_AGE_SEC = 60;
4
+ export declare const SERVICE_KEY_RE: RegExp;
5
+ export declare const PROVENANCE_REPLAY_CACHE_MAX = 20000;
6
+ export interface ProvenanceGuardOptions {
7
+ allowedCallers?: readonly string[];
8
+ }
9
+ export type ProvenanceResult = {
10
+ ok: true;
11
+ caller: string;
12
+ } | {
13
+ ok: false;
14
+ reason: 'missing';
15
+ } | {
16
+ ok: false;
17
+ reason: 'invalid';
18
+ } | {
19
+ ok: false;
20
+ reason: 'replayed';
21
+ } | {
22
+ ok: false;
23
+ reason: 'caller_not_allowed';
24
+ caller: string;
25
+ };
26
+ export declare function getPlatformPublicKey(): string;
27
+ export declare function isTruthyEnv(value: string | undefined): boolean;
28
+ export declare function resetProvenanceReplayCache(max?: number): void;
29
+ export declare function provenanceReplayCacheSize(): number;
30
+ export declare function verifyServiceProvenance(req: Request, options?: ProvenanceGuardOptions): ProvenanceResult;
31
+ export declare function assertProvenanceOptions(options: ProvenanceGuardOptions): void;
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PROVENANCE_REPLAY_CACHE_MAX = exports.SERVICE_KEY_RE = exports.PROVENANCE_MAX_AGE_SEC = exports.PROVENANCE_ISSUER = void 0;
7
+ exports.getPlatformPublicKey = getPlatformPublicKey;
8
+ exports.isTruthyEnv = isTruthyEnv;
9
+ exports.resetProvenanceReplayCache = resetProvenanceReplayCache;
10
+ exports.provenanceReplayCacheSize = provenanceReplayCacheSize;
11
+ exports.verifyServiceProvenance = verifyServiceProvenance;
12
+ exports.assertProvenanceOptions = assertProvenanceOptions;
13
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
14
+ exports.PROVENANCE_ISSUER = 'converge-gateway';
15
+ exports.PROVENANCE_MAX_AGE_SEC = 60;
16
+ const CLOCK_TOLERANCE_SEC = 5;
17
+ exports.SERVICE_KEY_RE = /^[a-z][a-z0-9_]{1,63}$/;
18
+ exports.PROVENANCE_REPLAY_CACHE_MAX = 20000;
19
+ let decodedKey = null;
20
+ function getPlatformPublicKey() {
21
+ const s = process.env.JWT_PUBLIC_KEY;
22
+ if (!s)
23
+ throw new Error('JWT_PUBLIC_KEY environment variable is required');
24
+ if (decodedKey?.b64 !== s)
25
+ decodedKey = { b64: s, pem: Buffer.from(s, 'base64').toString('utf8') };
26
+ return decodedKey.pem;
27
+ }
28
+ function isTruthyEnv(value) {
29
+ return !!value && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
30
+ }
31
+ const usedJtis = new Map();
32
+ const SWEEP_INTERVAL_MS = 5000;
33
+ let lastSweep = 0;
34
+ let capacityWarned = false;
35
+ let replayCacheMax = exports.PROVENANCE_REPLAY_CACHE_MAX;
36
+ function consumeJti(jti, expSec) {
37
+ const now = Date.now();
38
+ if (now - lastSweep >= SWEEP_INTERVAL_MS) {
39
+ lastSweep = now;
40
+ for (const [k, exp] of usedJtis) {
41
+ if (exp >= now)
42
+ break;
43
+ usedJtis.delete(k);
44
+ }
45
+ }
46
+ if (usedJtis.has(jti))
47
+ return false;
48
+ while (usedJtis.size >= replayCacheMax) {
49
+ const oldest = usedJtis.keys().next().value;
50
+ usedJtis.delete(oldest);
51
+ if (!capacityWarned) {
52
+ capacityWarned = true;
53
+ console.warn(JSON.stringify({
54
+ level: 'warn',
55
+ msg: 'Provenance-Replay-Liste voll – älteste Einträge fallen heraus',
56
+ max: replayCacheMax,
57
+ }));
58
+ }
59
+ }
60
+ usedJtis.set(jti, (expSec + CLOCK_TOLERANCE_SEC) * 1000);
61
+ return true;
62
+ }
63
+ function resetProvenanceReplayCache(max = exports.PROVENANCE_REPLAY_CACHE_MAX) {
64
+ usedJtis.clear();
65
+ lastSweep = 0;
66
+ capacityWarned = false;
67
+ replayCacheMax = max;
68
+ }
69
+ function provenanceReplayCacheSize() {
70
+ return usedJtis.size;
71
+ }
72
+ const verifiedInRequest = new WeakMap();
73
+ function readToken(req) {
74
+ const raw = req.headers?.['x-service-token'];
75
+ const token = Array.isArray(raw) ? raw[0] : raw;
76
+ return typeof token === 'string' && token ? token : undefined;
77
+ }
78
+ function verifyToken(req) {
79
+ const token = readToken(req);
80
+ if (!token)
81
+ return { ok: false, reason: 'missing' };
82
+ const memo = verifiedInRequest.get(req);
83
+ if (memo && memo.token === token)
84
+ return { ok: true, caller: memo.caller };
85
+ const audience = process.env.SERVICE_KEY;
86
+ if (!audience)
87
+ return { ok: false, reason: 'invalid' };
88
+ let payload;
89
+ try {
90
+ payload = jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
91
+ algorithms: ['RS256'],
92
+ issuer: exports.PROVENANCE_ISSUER,
93
+ audience,
94
+ maxAge: exports.PROVENANCE_MAX_AGE_SEC,
95
+ clockTolerance: CLOCK_TOLERANCE_SEC,
96
+ });
97
+ }
98
+ catch {
99
+ return { ok: false, reason: 'invalid' };
100
+ }
101
+ const caller = payload.sub;
102
+ if (typeof caller !== 'string' || !exports.SERVICE_KEY_RE.test(caller))
103
+ return { ok: false, reason: 'invalid' };
104
+ if (payload.jti !== undefined) {
105
+ if (typeof payload.jti !== 'string' || !payload.jti || typeof payload.exp !== 'number') {
106
+ return { ok: false, reason: 'invalid' };
107
+ }
108
+ if (!consumeJti(payload.jti, payload.exp))
109
+ return { ok: false, reason: 'replayed' };
110
+ }
111
+ verifiedInRequest.set(req, { token, caller });
112
+ return { ok: true, caller };
113
+ }
114
+ function verifyServiceProvenance(req, options = {}) {
115
+ const result = verifyToken(req);
116
+ if (!result.ok)
117
+ return result;
118
+ if (options.allowedCallers && !options.allowedCallers.includes(result.caller)) {
119
+ delete req.callerService;
120
+ return { ok: false, reason: 'caller_not_allowed', caller: result.caller };
121
+ }
122
+ req.callerService = result.caller;
123
+ return result;
124
+ }
125
+ function assertProvenanceOptions(options) {
126
+ const callers = options.allowedCallers;
127
+ if (callers === undefined)
128
+ return;
129
+ if (!Array.isArray(callers) || callers.length === 0) {
130
+ throw new Error('allowedCallers: mindestens ein Service-Key angeben (leere Liste sperrt jeden Aufrufer)');
131
+ }
132
+ for (const c of callers) {
133
+ if (typeof c !== 'string' || !exports.SERVICE_KEY_RE.test(c)) {
134
+ throw new Error(`allowedCallers: ungültiger Service-Key ${JSON.stringify(c)}`);
135
+ }
136
+ }
137
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './ipc.js';
2
2
  export * from './format.js';
3
+ export * from './platformAdmin.js';
3
4
  export { useIsMobile, MOBILE_QUERY } from './useIsMobile.js';
4
5
  export * from './i18n.js';
5
6
  export { default as DevHeader } from './DevHeader.js';
package/frontend/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './ipc.js';
2
2
  export * from './format.js';
3
+ export * from './platformAdmin.js';
3
4
  export { useIsMobile, MOBILE_QUERY } from './useIsMobile.js';
4
5
  export * from './i18n.js';
5
6
  export { default as DevHeader } from './DevHeader.js';
package/frontend/ipc.js CHANGED
@@ -15,7 +15,9 @@ export function getParentOrigin() {
15
15
  }
16
16
  }
17
17
  export function isFromPlatformParent(event) {
18
- return event.source === window.parent;
18
+ if (event.source !== window.parent)
19
+ return false;
20
+ return event.origin === window.location.origin;
19
21
  }
20
22
  export function sendAtStart() {
21
23
  if (!isEmbedded())
@@ -0,0 +1,4 @@
1
+ export declare const PLATFORM_ADMIN_PERMISSION = "efa-all";
2
+ export declare const PLATFORM_ADMIN_PERMISSIONS: readonly string[];
3
+ export declare function isPlatformAdminPermission(key: string): boolean;
4
+ export declare function isPlatformAdmin(permissionKeys: readonly string[] | null | undefined): boolean;
@@ -0,0 +1,11 @@
1
+ export const PLATFORM_ADMIN_PERMISSION = 'efa-all';
2
+ export const PLATFORM_ADMIN_PERMISSIONS = Object.freeze([
3
+ PLATFORM_ADMIN_PERMISSION,
4
+ 'converge-admin',
5
+ ]);
6
+ export function isPlatformAdminPermission(key) {
7
+ return PLATFORM_ADMIN_PERMISSIONS.includes(key);
8
+ }
9
+ export function isPlatformAdmin(permissionKeys) {
10
+ return !!permissionKeys && permissionKeys.some(isPlatformAdminPermission);
11
+ }
@@ -433,7 +433,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
433
433
  hasColumns: visibleColumns.some((c) => c.exportable !== false),
434
434
  run: runExport,
435
435
  } : undefined, columns: columns, prefs: prefs, setColumnVisible: setColumnVisible, moveColumn: moveColumn, reset: reset, groupBy: groupBy, addGroupBy: addGroupBy, removeGroupBy: removeGroupBy, moveGroupBy: moveGroupBy, clearGroupBy: clearGroupBy }));
436
- return (_jsxs("div", { ref: containerRef, className: "border border-[var(--color-border)] overflow-x-auto", style: { '--dt-cols': gridTemplate }, children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl l\u00F6schen" }), _jsx("div", { className: "flex-1" }), selection.bulkActions] })), isMobile ? (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [selection && (_jsxs("label", { className: "flex items-center gap-2 py-2 pr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" }), _jsx("span", { className: "text-[var(--color-text-muted)]", children: "Alle" })] })), _jsx("div", { className: "flex-1" }), gearButton] })) : (_jsxs("div", { className: "grid items-center min-w-min px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns: 'var(--dt-cols)', background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
436
+ return (_jsxs("div", { ref: containerRef, className: "border border-[var(--color-border)] overflow-x-auto", style: { '--dt-cols': gridTemplate }, children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl aufheben" }), _jsx("div", { className: "flex-1" }), selection.bulkActions] })), isMobile ? (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [selection && (_jsxs("label", { className: "flex items-center gap-2 py-2 pr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" }), _jsx("span", { className: "text-[var(--color-text-muted)]", children: "Alle" })] })), _jsx("div", { className: "flex-1" }), gearButton] })) : (_jsxs("div", { className: "grid items-center min-w-min px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns: 'var(--dt-cols)', background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
437
437
  const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
438
438
  const filterValue = prefs.filters[col.id];
439
439
  const isFiltered = filterValue != null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efa-one/sdk",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Offizielles SDK für efa-one-Apps: Auth/Session, Service-Discovery, Gateway-Client, Audit/Reporting/Mail/Notifications, Permission- und Capability-Registrierung sowie das Frontend-IPC-/i18n-Toolkit.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -58,7 +58,7 @@
58
58
  "@radix-ui/react-tooltip": ">=1",
59
59
  "express": ">=4",
60
60
  "i18next": ">=23",
61
- "i18next-http-backend": ">=2",
61
+ "i18next-http-backend": ">=4.0.2",
62
62
  "jsonwebtoken": ">=9",
63
63
  "lucide-react": ">=0.400.0",
64
64
  "pg": ">=8",
@@ -111,12 +111,12 @@
111
111
  "@radix-ui/react-tooltip": "^1.1.6",
112
112
  "@types/express": "^4.17.21",
113
113
  "@types/jsonwebtoken": "^9.0.6",
114
- "@types/node": "^20.14.0",
114
+ "@types/node": "^22.20.5",
115
115
  "@types/pg": "^8.11.0",
116
116
  "@types/react": "^18.2.46",
117
117
  "@types/react-dom": "^18.2.0",
118
118
  "@types/supertest": "^7.2.0",
119
- "@vitest/coverage-v8": "^4.1.11",
119
+ "@vitest/coverage-v8": "^5.0.5",
120
120
  "cookie-parser": "^1.4.6",
121
121
  "eslint": "^10.9.1",
122
122
  "eslint-plugin-react-hooks": "^7.1.1",
@@ -132,7 +132,7 @@
132
132
  "supertest": "^7.2.2",
133
133
  "typescript": "6.0.3",
134
134
  "typescript-eslint": "^8.67.0",
135
- "vitest": "^4.1.4"
135
+ "vitest": "^5.0.5"
136
136
  },
137
137
  "engines": {
138
138
  "node": ">=20"