@cosmicdrift/kumiko-bundled-features 0.296.0 → 0.299.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.
@@ -1,30 +1,22 @@
1
- // createInboundMailConnectRoutes — Hono-Route-Factory die der App-Owner
2
- // via `extraRoutes` in seinem bin/server.ts mountet (Muster
3
- // billing-foundation/webhook-handler.ts).
1
+ // createInboundMailConnectRoutes — declarative extraRoutes (kumiko-
2
+ // framework#3050) the app owner mounts via `extraRoutes` in buildServer.
4
3
  //
5
- // GET /inbound-mail/connect (auth-gated — im authed Bereich mounten)
6
- // GET /inbound-mail/oauth/callback (anonymous — MUSS außerhalb /api/*
7
- // liegen, /api ist auth-gated;
8
- // verifiziert user-data-rights §5)
4
+ // GET /api/inbound-mail/connect (entry:"user" — signed-in caller)
5
+ // GET /inbound-mail/oauth/callback (entry:"signature" — anonymous,
6
+ // outside /api/*; verify() IS the auth,
7
+ // trusting only the HMAC-signed state)
9
8
  //
10
- // Beispiel bin/server.ts:
11
- //
12
- // const routes = createInboundMailConnectRoutes({
13
- // providerCtx: { registry: deps.registry, secrets, config: undefined },
14
- // dispatchWrite: ({ handlerQn, payload, tenantId }) =>
15
- // deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
16
- // secrets,
17
- // stateSecret: env.INBOUND_MAIL_STATE_SECRET,
18
- // callbackUrl: `${env.PUBLIC_BASE_URL}/inbound-mail/oauth/callback`,
19
- // });
20
- // app.get("/api/inbound-mail/connect", routes.connect); // auth-gated
21
- // app.get("/inbound-mail/oauth/callback", routes.callback); // anonymous
22
- //
23
- // Provider registrieren KEINE eigenen Routen — ihr oauth-Block wird über
24
- // resolveInboundProviderForKey aufgelöst. Der Callback vertraut NUR dem
25
- // HMAC-verifizierten state (CSRF-/Fremd-Claiming-Sperre, oauth-state.ts).
26
-
27
- import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
9
+ // Providers register no routes of their own — their oauth block is
10
+ // resolved via resolveInboundProviderForKey. The callback trusts ONLY the
11
+ // HMAC-verified state (CSRF-/foreign-claiming lock, see oauth-state.ts).
12
+
13
+ import type {
14
+ ExtraRouteDefinition,
15
+ SignatureExtraRouteDeps,
16
+ SignatureExtraRouteVerifyRequest,
17
+ UserExtraRouteDeps,
18
+ } from "@cosmicdrift/kumiko-framework/api";
19
+ import { ExtraRouteRejection, signatureRoute } from "@cosmicdrift/kumiko-framework/api";
28
20
  import type { Context } from "hono";
29
21
  import {
30
22
  INBOUND_MAIL_FOUNDATION_FEATURE,
@@ -32,199 +24,228 @@ import {
32
24
  InboundMailFoundationHandlers,
33
25
  inboundCredentialSecretKey,
34
26
  } from "./constants";
35
- import { signOAuthState, verifyOAuthState } from "./oauth-state";
27
+ import { type OAuthStatePayload, signOAuthState, verifyOAuthState } from "./oauth-state";
36
28
  import { resolveInboundProviderForKey } from "./provider-factory";
37
- import type { InboundMailContext } from "./types";
38
29
 
39
30
  const DEFAULT_STATE_TTL_MINUTES = 15;
31
+ const DEFAULT_CONNECT_PATH = "/api/inbound-mail/connect";
32
+ const DEFAULT_CALLBACK_PATH = "/inbound-mail/oauth/callback";
40
33
 
41
- export type InboundMailConnectRoutesDeps = {
42
- /** Slim-Context für Provider-Lookups + oauth-Calls (registry Pflicht). */
43
- readonly providerCtx: InboundMailContext;
44
- /** Schreibt durch den Standard-Dispatcher mit auto-konstruiertem
45
- * SystemUser — muss connect-account als SystemAdmin durchlassen. */
46
- readonly dispatchWrite: (args: {
47
- readonly handlerQn: string;
48
- readonly payload: unknown;
49
- readonly tenantId: string;
50
- }) => Promise<{
51
- readonly isSuccess: boolean;
52
- readonly data?: unknown;
53
- readonly error?: unknown;
54
- }>;
55
- /** Tenant-Secret-Write für den Refresh-Token (Slot = accountId). */
56
- readonly secrets: SecretsContext;
57
- /** HMAC-Secret für den state-Parameter (Deploy-Config aus env —
58
- * `scope:"system"`-Secrets existieren in secrets-v1 nicht). */
34
+ export type InboundMailConnectRoutesOptions = {
35
+ /** HMAC-secret for the state param (deploy-time env — `scope:"system"`
36
+ * secrets don't exist in secrets-v1). */
59
37
  readonly stateSecret: string;
60
- /** Absolute Callback-URL — exakt so beim OAuth-Provider registriert. */
38
+ /** Absolute callback URL — must match what's registered with the OAuth
39
+ * provider exactly. */
61
40
  readonly callbackUrl: string;
62
- /** Wohin der Browser nach erfolgreichem Connect redirected wird.
63
- * Fehlt sie, antwortet der Callback mit JSON (API-/Test-Modus). */
41
+ /** Where the browser redirects after a successful connect. Omitted →
42
+ * the callback answers with JSON instead (API-/test-mode). */
64
43
  readonly successRedirectUrl?: string;
65
44
  readonly stateTtlMinutes?: number;
45
+ /** Default "/api/inbound-mail/connect". */
46
+ readonly connectPath?: string;
47
+ /** Default "/inbound-mail/oauth/callback". */
48
+ readonly callbackPath?: string;
66
49
  };
67
50
 
68
- function errorJson(c: Context, status: 400 | 401 | 404 | 502, code: string, message: string) {
51
+ function errorJson(c: Context, status: 400 | 401 | 404 | 500 | 502, code: string, message: string) {
69
52
  return c.json({ error: { code, message } }, status);
70
53
  }
71
54
 
72
- /** Session-User aus dem Hono-Context — vom Auth-Middleware gesetzt. */
73
- function sessionUserOf(c: Context): { id: string; tenantId: string } | undefined {
74
- const user = c.get("user") as { id?: unknown; tenantId?: unknown } | undefined;
75
- if (!user || typeof user.id !== "string" || typeof user.tenantId !== "string") return undefined;
76
- return { id: user.id, tenantId: user.tenantId };
55
+ type VerifiedCallback = { readonly code: string; readonly state: OAuthStatePayload };
56
+
57
+ function verifyCallbackRequest(
58
+ request: SignatureExtraRouteVerifyRequest,
59
+ stateSecret: string,
60
+ ): VerifiedCallback {
61
+ const code = request.query["code"] ?? "";
62
+ const rawState = request.query["state"] ?? "";
63
+ if (!code || !rawState) {
64
+ throw new ExtraRouteRejection(
65
+ 400,
66
+ { error: { code: "invalid_callback", message: "missing code or state" } },
67
+ "inbound-mail oauth callback missing code or state",
68
+ );
69
+ }
70
+ const verified = verifyOAuthState(rawState, stateSecret);
71
+ if (!verified.ok) {
72
+ throw new ExtraRouteRejection(
73
+ 400,
74
+ { error: { code: "invalid_state", message: `state rejected: ${verified.reason}` } },
75
+ `inbound-mail oauth callback state rejected: ${verified.reason}`,
76
+ );
77
+ }
78
+ return { code, state: verified.payload };
77
79
  }
78
80
 
79
- export function createInboundMailConnectRoutes(deps: InboundMailConnectRoutesDeps) {
80
- const ttl = deps.stateTtlMinutes ?? DEFAULT_STATE_TTL_MINUTES;
81
-
82
- const connect = async (c: Context): Promise<Response> => {
83
- const user = sessionUserOf(c);
84
- if (!user) {
85
- return errorJson(c, 401, "unauthenticated", "connect requires a signed-in user");
86
- }
87
- const providerKey = c.req.query("provider") ?? "";
88
- const scope = c.req.query("scope") ?? "";
89
- const mailbox = c.req.query("mailbox") ?? "";
90
- if (!providerKey || (scope !== "user" && scope !== "shared") || !mailbox) {
91
- return errorJson(
92
- c,
93
- 400,
94
- "invalid_connect_request",
95
- `${INBOUND_MAIL_FOUNDATION_FEATURE}: expected ?provider=<key>&scope=user|shared&mailbox=<address>`,
96
- );
97
- }
98
-
99
- let plugin: ReturnType<typeof resolveInboundProviderForKey>;
100
- try {
101
- plugin = resolveInboundProviderForKey(deps.providerCtx, providerKey);
102
- } catch (e) {
103
- return errorJson(
104
- c,
105
- 404,
106
- "provider_not_registered",
107
- e instanceof Error ? e.message : String(e),
81
+ /** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
82
+ * `extraRoutes: createInboundMailConnectRoutes(options)`. */
83
+ export function createInboundMailConnectRoutes(
84
+ options: InboundMailConnectRoutesOptions,
85
+ ): readonly ExtraRouteDefinition[] {
86
+ const ttl = options.stateTtlMinutes ?? DEFAULT_STATE_TTL_MINUTES;
87
+
88
+ const connectRoute: ExtraRouteDefinition = {
89
+ method: "GET",
90
+ path: options.connectPath ?? DEFAULT_CONNECT_PATH,
91
+ entry: "user",
92
+ handler: async (c: Context, deps: UserExtraRouteDeps): Promise<Response> => {
93
+ const providerKey = c.req.query("provider") ?? "";
94
+ const scope = c.req.query("scope") ?? "";
95
+ const mailbox = c.req.query("mailbox") ?? "";
96
+ if (!providerKey || (scope !== "user" && scope !== "shared") || !mailbox) {
97
+ return errorJson(
98
+ c,
99
+ 400,
100
+ "invalid_connect_request",
101
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}: expected ?provider=<key>&scope=user|shared&mailbox=<address>`,
102
+ );
103
+ }
104
+
105
+ let plugin: ReturnType<typeof resolveInboundProviderForKey>;
106
+ try {
107
+ plugin = resolveInboundProviderForKey({ registry: deps.registry }, providerKey);
108
+ } catch (e) {
109
+ return errorJson(
110
+ c,
111
+ 404,
112
+ "provider_not_registered",
113
+ e instanceof Error ? e.message : String(e),
114
+ );
115
+ }
116
+ if (!plugin.oauth) {
117
+ return errorJson(
118
+ c,
119
+ 400,
120
+ "provider_has_no_oauth_flow",
121
+ `provider "${providerKey}" connects via credentials form (connect-account write-handler), not OAuth`,
122
+ );
123
+ }
124
+
125
+ const state = signOAuthState(
126
+ {
127
+ tenantId: deps.user.tenantId,
128
+ ownerUserId: scope === "user" ? deps.user.id : null,
129
+ providerKey,
130
+ mailbox,
131
+ },
132
+ ttl,
133
+ options.stateSecret,
108
134
  );
109
- }
110
- if (!plugin.oauth) {
111
- return errorJson(
112
- c,
113
- 400,
114
- "provider_has_no_oauth_flow",
115
- `provider "${providerKey}" connects via credentials form (connect-account write-handler), not OAuth`,
135
+ const authorizeUrl = await plugin.oauth.buildAuthorizeUrl(
136
+ { registry: deps.registry },
137
+ { state, redirectUri: options.callbackUrl },
116
138
  );
117
- }
118
-
119
- const state = signOAuthState(
120
- {
121
- tenantId: user.tenantId,
122
- ownerUserId: scope === "user" ? user.id : null,
123
- providerKey,
124
- mailbox,
125
- },
126
- ttl,
127
- deps.stateSecret,
128
- );
129
- const authorizeUrl = await plugin.oauth.buildAuthorizeUrl(deps.providerCtx, {
130
- state,
131
- redirectUri: deps.callbackUrl,
132
- });
133
- return c.redirect(authorizeUrl, 302);
139
+ return c.redirect(authorizeUrl, 302);
140
+ },
134
141
  };
135
142
 
136
- const callback = async (c: Context): Promise<Response> => {
137
- const code = c.req.query("code") ?? "";
138
- const rawState = c.req.query("state") ?? "";
139
- if (!code || !rawState) {
140
- return errorJson(c, 400, "invalid_callback", "missing code or state");
141
- }
142
- const verified = verifyOAuthState(rawState, deps.stateSecret);
143
- if (!verified.ok) {
144
- return errorJson(c, 400, "invalid_state", `state rejected: ${verified.reason}`);
145
- }
146
- const state = verified.payload;
147
-
148
- let plugin: ReturnType<typeof resolveInboundProviderForKey>;
149
- try {
150
- plugin = resolveInboundProviderForKey(deps.providerCtx, state.providerKey);
151
- } catch (e) {
152
- return errorJson(
153
- c,
154
- 404,
155
- "provider_not_registered",
156
- e instanceof Error ? e.message : String(e),
157
- );
158
- }
159
- if (!plugin.oauth) {
160
- return errorJson(c, 400, "provider_has_no_oauth_flow", state.providerKey);
161
- }
162
-
163
- let tokens: Awaited<ReturnType<NonNullable<typeof plugin.oauth>["exchangeCode"]>>;
164
- try {
165
- tokens = await plugin.oauth.exchangeCode(deps.providerCtx, {
166
- code,
167
- redirectUri: deps.callbackUrl,
143
+ const callbackRoute = signatureRoute<VerifiedCallback>({
144
+ method: "GET",
145
+ path: options.callbackPath ?? DEFAULT_CALLBACK_PATH,
146
+ entry: "signature",
147
+ verify: async (request) => verifyCallbackRequest(request, options.stateSecret),
148
+ handler: async (
149
+ c: Context,
150
+ verified: VerifiedCallback,
151
+ deps: SignatureExtraRouteDeps,
152
+ ): Promise<Response> => {
153
+ const { code, state } = verified;
154
+ const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
155
+
156
+ let plugin: ReturnType<typeof resolveInboundProviderForKey>;
157
+ try {
158
+ plugin = resolveInboundProviderForKey(providerCtx, state.providerKey);
159
+ } catch (e) {
160
+ return errorJson(
161
+ c,
162
+ 404,
163
+ "provider_not_registered",
164
+ e instanceof Error ? e.message : String(e),
165
+ );
166
+ }
167
+ if (!plugin.oauth) {
168
+ return errorJson(c, 400, "provider_has_no_oauth_flow", state.providerKey);
169
+ }
170
+
171
+ let tokens: Awaited<ReturnType<NonNullable<typeof plugin.oauth>["exchangeCode"]>>;
172
+ try {
173
+ tokens = await plugin.oauth.exchangeCode(providerCtx, {
174
+ code,
175
+ redirectUri: options.callbackUrl,
176
+ });
177
+ } catch (e) {
178
+ return errorJson(
179
+ c,
180
+ 502,
181
+ "token_exchange_failed",
182
+ e instanceof Error ? e.message : String(e),
183
+ );
184
+ }
185
+ if (!tokens.refreshToken) {
186
+ // No refresh token → no server-side sync possible. Consent must
187
+ // request offline_access (or access_type=offline) — the provider's
188
+ // oauth block owns the scopes.
189
+ return errorJson(
190
+ c,
191
+ 502,
192
+ "no_refresh_token",
193
+ `provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
194
+ );
195
+ }
196
+
197
+ // Account creation (programmatic SystemUser; the real owner comes from
198
+ // the HMAC-verified state → ownerUserIdOverride).
199
+ const dispatched = await deps.dispatchSystemWrite({
200
+ handlerQn: InboundMailFoundationHandlers.connectAccount,
201
+ tenantId: state.tenantId,
202
+ payload: {
203
+ provider: state.providerKey,
204
+ authMethod: InboundMailAuthMethods.oauth,
205
+ displayName: state.mailbox,
206
+ address: state.mailbox,
207
+ scope: state.ownerUserId ? "user" : "shared",
208
+ ownerUserIdOverride: state.ownerUserId,
209
+ },
168
210
  });
169
- } catch (e) {
170
- return errorJson(c, 502, "token_exchange_failed", e instanceof Error ? e.message : String(e));
171
- }
172
- if (!tokens.refreshToken) {
173
- // Ohne Refresh-Token kein Server-Sync — Consent muss offline_access
174
- // (bzw. access_type=offline) anfordern; der Provider-oauth-Block
175
- // besitzt die Scopes.
176
- return errorJson(
177
- c,
178
- 502,
179
- "no_refresh_token",
180
- `provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
211
+ if (!dispatched.isSuccess) {
212
+ return errorJson(c, 502, "account_create_failed", JSON.stringify(dispatched.error ?? {}));
213
+ }
214
+ const accountId = (dispatched.data as { accountId?: unknown } | undefined)?.accountId;
215
+ if (typeof accountId !== "string") {
216
+ return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
217
+ }
218
+
219
+ // Refresh-token into the per-account secret slot (Slot = accountId).
220
+ // Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
221
+ // sync path. `deps.secrets` is optional on SignatureExtraRouteDeps —
222
+ // without it the token would be silently lost, so fail loud instead.
223
+ if (!deps.secrets) {
224
+ return errorJson(
225
+ c,
226
+ 500,
227
+ "secrets_context_missing",
228
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`,
229
+ );
230
+ }
231
+ await deps.secrets.set(
232
+ state.tenantId,
233
+ inboundCredentialSecretKey(accountId),
234
+ tokens.refreshToken,
235
+ {
236
+ redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
237
+ hint: `OAuth refresh token for inbound mail account ${accountId}`,
238
+ },
181
239
  );
182
- }
183
-
184
- // Account anlegen (programmatic SystemUser; echter Owner kommt aus
185
- // dem HMAC-verifizierten state → ownerUserIdOverride).
186
- const dispatched = await deps.dispatchWrite({
187
- handlerQn: InboundMailFoundationHandlers.connectAccount,
188
- tenantId: state.tenantId,
189
- payload: {
190
- provider: state.providerKey,
191
- authMethod: InboundMailAuthMethods.oauth,
192
- displayName: state.mailbox,
193
- address: state.mailbox,
194
- scope: state.ownerUserId ? "user" : "shared",
195
- ownerUserIdOverride: state.ownerUserId,
196
- },
197
- });
198
- if (!dispatched.isSuccess) {
199
- return errorJson(c, 502, "account_create_failed", JSON.stringify(dispatched.error ?? {}));
200
- }
201
- const accountId = (dispatched.data as { accountId?: unknown } | undefined)?.accountId;
202
- if (typeof accountId !== "string") {
203
- return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
204
- }
205
-
206
- // Refresh-Token in den per-Account-Secret-Slot (Request-Pfad =
207
- // voller SecretsContext). Access-Tokens (~1h) werden NIE persistiert
208
- // — refresh-before-poll im Sync-Pfad.
209
- await deps.secrets.set(
210
- state.tenantId,
211
- inboundCredentialSecretKey(accountId),
212
- tokens.refreshToken,
213
- {
214
- redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
215
- hint: `OAuth refresh token for inbound mail account ${accountId}`,
216
- },
217
- );
218
240
 
219
- if (deps.successRedirectUrl) {
220
- const target = new URL(deps.successRedirectUrl);
221
- target.searchParams.set("accountId", accountId);
222
- return c.redirect(target.toString(), 302);
223
- }
224
- return c.json({ connected: true, accountId }, 200);
225
- };
241
+ if (options.successRedirectUrl) {
242
+ const target = new URL(options.successRedirectUrl);
243
+ target.searchParams.set("accountId", accountId);
244
+ return c.redirect(target.toString(), 302);
245
+ }
246
+ return c.json({ connected: true, accountId }, 200);
247
+ },
248
+ });
226
249
 
227
- return { connect, callback };
250
+ return [connectRoute, callbackRoute];
228
251
  }
229
-
230
- export type InboundMailConnectRoutes = ReturnType<typeof createInboundMailConnectRoutes>;
@@ -6,8 +6,7 @@ export {
6
6
  } from "./aggregate-id";
7
7
  export {
8
8
  createInboundMailConnectRoutes,
9
- type InboundMailConnectRoutes,
10
- type InboundMailConnectRoutesDeps,
9
+ type InboundMailConnectRoutesOptions,
11
10
  } from "./connect-routes";
12
11
  export {
13
12
  INBOUND_MAIL_FOUNDATION_FEATURE,
@@ -21,12 +21,10 @@
21
21
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
22
22
  import {
23
23
  billingFoundationFeature,
24
- createSubscriptionWebhookHandler,
25
- type SubscriptionProviderPlugin,
24
+ createSubscriptionWebhookRoute,
26
25
  subscriptionAggregateId,
27
26
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
28
27
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
29
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
30
28
  import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
31
29
  import {
32
30
  createTestUser,
@@ -39,7 +37,6 @@ import type {
39
37
  Payment as MolliePayment,
40
38
  Subscription as MollieSubscription,
41
39
  } from "@mollie/api-client";
42
- import { Hono } from "hono";
43
40
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
44
41
  import { createConfigFeature } from "../../config";
45
42
  import { createTenantFeature } from "../../tenant/feature";
@@ -125,7 +122,6 @@ const PRICE_TO_CONFIG = {
125
122
 
126
123
  let stack: TestStack;
127
124
  let db: DbConnection;
128
- let webhookApp: Hono;
129
125
 
130
126
  beforeAll(async () => {
131
127
  // Echte factory mit injection-port. Das beweist factory-Logik
@@ -147,41 +143,12 @@ beforeAll(async () => {
147
143
  billingFoundationFeature,
148
144
  mollieFeature,
149
145
  ],
146
+ extraRoutes: [createSubscriptionWebhookRoute()],
150
147
  });
151
148
  db = stack.db;
152
149
  // subscriptionsProjectionTable wird von setupTestStack automatisch
153
150
  // gepusht (r.projection mit `table`-Property → auto-push).
154
151
  await unsafeCreateEntityTable(db, tenantEntity);
155
-
156
- webhookApp = new Hono();
157
- webhookApp.post(
158
- "/api/subscription/webhook/:providerName",
159
- createSubscriptionWebhookHandler({
160
- dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
161
- const systemUser = createTestUser({
162
- id: 1,
163
- tenantId: tenantId as TenantId,
164
- roles: ["SystemAdmin"],
165
- });
166
- const res = await stack.http.write(handlerQn, payload, systemUser);
167
- const body = (await res.json()) as {
168
- isSuccess?: boolean;
169
- data?: unknown;
170
- error?: unknown;
171
- };
172
- return body.isSuccess
173
- ? { isSuccess: true, ...(body.data !== undefined && { data: body.data }) }
174
- : { isSuccess: false, ...(body.error !== undefined && { error: body.error }) };
175
- },
176
- resolveProvider: (providerName) => {
177
- const usage = stack.registry
178
- .getExtensionUsages("subscriptionProvider")
179
- .find((u) => u.entityName === providerName);
180
- // @cast-boundary engine-payload — extension-usage carries unknown options
181
- return usage?.options as SubscriptionProviderPlugin | undefined;
182
- },
183
- }),
184
- );
185
152
  });
186
153
 
187
154
  afterAll(async () => {
@@ -227,7 +194,7 @@ function buildMockSubscription(
227
194
  }
228
195
 
229
196
  async function postMollieWebhook(id: string) {
230
- return webhookApp.request("/api/subscription/webhook/mollie", {
197
+ return stack.app.request("/api/subscription/webhook/mollie", {
231
198
  method: "POST",
232
199
  body: `id=${id}`,
233
200
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -386,15 +353,15 @@ describe("scenario 3: idempotency via Mollie-retry", () => {
386
353
  });
387
354
 
388
355
  describe("scenario 4: error + ignored paths", () => {
389
- test("body ohne id → 401 (Plugin throws, foundation mapped auf signature_invalid)", async () => {
390
- const res = await webhookApp.request("/api/subscription/webhook/mollie", {
356
+ test("body ohne id → 401 (Plugin throws, extraRoute mapped auf extra_route_signature_invalid)", async () => {
357
+ const res = await stack.app.request("/api/subscription/webhook/mollie", {
391
358
  method: "POST",
392
359
  body: "no-id-field",
393
360
  headers: { "content-type": "application/x-www-form-urlencoded" },
394
361
  });
395
362
  expect(res.status).toBe(401);
396
363
  const body = (await res.json()) as { error: { code: string } };
397
- expect(body.error.code).toBe("subscription_webhook_signature_invalid");
364
+ expect(body.error.code).toBe("extra_route_signature_invalid");
398
365
  });
399
366
 
400
367
  test("sub_xxx-direct-event → 200 ignored, kein DB-write", async () => {
@@ -422,7 +389,7 @@ describe("scenario 4: error + ignored paths", () => {
422
389
  // handler refactored sodass er ALLE requests an das erste plugin
423
390
  // routet, wäre dieser Test grün — bricht aber wenn der Test einen
424
391
  // unbekannten provider-name fordert.
425
- const res = await webhookApp.request("/api/subscription/webhook/paypal", {
392
+ const res = await stack.app.request("/api/subscription/webhook/paypal", {
426
393
  method: "POST",
427
394
  body: "id=tr_dummy",
428
395
  headers: { "content-type": "application/x-www-form-urlencoded" },