@wildo-ai/platform-infra-providers 1.1.2

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 (62) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/aws-s3/aws-s3.infra.module.d.ts +63 -0
  3. package/dist/esm/aws-s3/aws-s3.infra.module.d.ts.map +1 -0
  4. package/dist/esm/aws-s3/aws-s3.infra.module.js +110 -0
  5. package/dist/esm/aws-s3/aws-s3.infra.module.js.map +1 -0
  6. package/dist/esm/clamav-self/clamav-self.infra.module.d.ts +30 -0
  7. package/dist/esm/clamav-self/clamav-self.infra.module.d.ts.map +1 -0
  8. package/dist/esm/clamav-self/clamav-self.infra.module.js +61 -0
  9. package/dist/esm/clamav-self/clamav-self.infra.module.js.map +1 -0
  10. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts +26 -0
  11. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts.map +1 -0
  12. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js +54 -0
  13. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js.map +1 -0
  14. package/dist/esm/index.d.ts +88 -0
  15. package/dist/esm/index.d.ts.map +1 -0
  16. package/dist/esm/index.js +125 -0
  17. package/dist/esm/index.js.map +1 -0
  18. package/dist/esm/minio-self/minio-self.infra.module.d.ts +64 -0
  19. package/dist/esm/minio-self/minio-self.infra.module.d.ts.map +1 -0
  20. package/dist/esm/minio-self/minio-self.infra.module.js +105 -0
  21. package/dist/esm/minio-self/minio-self.infra.module.js.map +1 -0
  22. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts +52 -0
  23. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts.map +1 -0
  24. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js +201 -0
  25. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js.map +1 -0
  26. package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts +81 -0
  27. package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts.map +1 -0
  28. package/dist/esm/mongodb-self/mongodb-self.infra.module.js +311 -0
  29. package/dist/esm/mongodb-self/mongodb-self.infra.module.js.map +1 -0
  30. package/dist/esm/postgres-self/postgres-self.infra.module.d.ts +70 -0
  31. package/dist/esm/postgres-self/postgres-self.infra.module.d.ts.map +1 -0
  32. package/dist/esm/postgres-self/postgres-self.infra.module.js +328 -0
  33. package/dist/esm/postgres-self/postgres-self.infra.module.js.map +1 -0
  34. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts +88 -0
  35. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts.map +1 -0
  36. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js +473 -0
  37. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js.map +1 -0
  38. package/dist/esm/redis-self/redis-self.infra.module.d.ts +89 -0
  39. package/dist/esm/redis-self/redis-self.infra.module.d.ts.map +1 -0
  40. package/dist/esm/redis-self/redis-self.infra.module.js +331 -0
  41. package/dist/esm/redis-self/redis-self.infra.module.js.map +1 -0
  42. package/dist/tsconfig.build.tsbuildinfo +1 -0
  43. package/package.json +52 -0
  44. package/src/aws-s3/__tests__/aws-s3.infra.module.test.ts +67 -0
  45. package/src/aws-s3/aws-s3.infra.module.ts +120 -0
  46. package/src/clamav-self/__tests__/clamav-self.infra.module.test.ts +65 -0
  47. package/src/clamav-self/clamav-self.infra.module.ts +72 -0
  48. package/src/gotenberg-self/__tests__/gotenberg-self.infra.module.test.ts +65 -0
  49. package/src/gotenberg-self/gotenberg-self.infra.module.ts +65 -0
  50. package/src/index.ts +133 -0
  51. package/src/minio-self/__tests__/minio-self.infra.module.test.ts +70 -0
  52. package/src/minio-self/minio-self.infra.module.ts +116 -0
  53. package/src/mongodb-atlas/__tests__/mongodb-atlas.infra.module.test.ts +198 -0
  54. package/src/mongodb-atlas/mongodb-atlas.infra.module.ts +236 -0
  55. package/src/mongodb-self/__tests__/mongodb-self.infra.module.test.ts +419 -0
  56. package/src/mongodb-self/mongodb-self.infra.module.ts +384 -0
  57. package/src/postgres-self/__tests__/postgres-self.infra.module.test.ts +344 -0
  58. package/src/postgres-self/postgres-self.infra.module.ts +377 -0
  59. package/src/rabbitmq-self/__tests__/rabbitmq-self.infra.module.test.ts +499 -0
  60. package/src/rabbitmq-self/rabbitmq-self.infra.module.ts +531 -0
  61. package/src/redis-self/__tests__/redis-self.infra.module.test.ts +341 -0
  62. package/src/redis-self/redis-self.infra.module.ts +372 -0
@@ -0,0 +1,531 @@
1
+ /**
2
+ * `RabbitmqSelfInfraProviderModule` — first concrete `InfraProviderModule`
3
+ * (Slices 1–2.5 of `infra-provider-planner.md`).
4
+ *
5
+ * Implements the `RABBITMQ_SELF` `InfraProvider` over the
6
+ * `IRabbitMQManagementClient` transport (raw HTTP `PUT /api/users` etc.,
7
+ * exposed by `RabbitMQConnectionManagerService` after the 5C policy/
8
+ * transport split). Owns ALL per-app and per-platform-service
9
+ * permission policy — the regex shapes, the broader-permission
10
+ * special case for `crontabs-batches-manager`, the per-app username
11
+ * convention, the optional vhost activation.
12
+ *
13
+ * **What this module reads from `AppCredentialMintingContext`**:
14
+ * - `applicationNamespace` — used as the rabbit username
15
+ * (`<appSlug>-<envId>`, the legacy convention from
16
+ * `wildo-application-lifecycle.service.ts`).
17
+ * - `wildoApplicationId` — used as the permission-regex disambiguator
18
+ * (matches `^wildo\.(queue|reply)\.<id>\..*`).
19
+ * - `secretInputs['database.rabbitmq.password']` — orchestrator-
20
+ * supplied password (module is upsert-only, never generator).
21
+ * - `applicationConfigInputs?.['vhost']` — optional per-app vhost
22
+ * for true vhost-level isolation (Slice 2.5c-3).
23
+ * - `adminClient` — narrowed to `IRabbitMQManagementClient`.
24
+ *
25
+ * **What this module IGNORES from the context** (Slice 4.A.2 / M4):
26
+ * - `appSlug` — RabbitMQ resources are namespace-scoped, not slug-
27
+ * scoped. Future modules (MongoDB DB name, etc.) WILL use `appSlug`.
28
+ * This is documented forward-compat for module authors: not every
29
+ * module reads every context field; each declares which it consumes.
30
+ *
31
+ * **Three audiences this module serves:**
32
+ *
33
+ * 1. **Audience-1 (platform-admin, declared via `plan().platformAdmin`)**:
34
+ * cluster-wide management-API creds (`RABBITMQ_ADMIN_USERNAME` /
35
+ * `RABBITMQ_ADMIN_PASSWORD`). Consumers: `apps-manager`,
36
+ * `companion`. Delivered through process.env (the CLI's
37
+ * `env-vars-builder` writes them per-env). No method.
38
+ *
39
+ * 2. **Audience-2 (platform-service-scoped, via
40
+ * `mintPlatformServiceCredentials`)**: per-platform-service broker
41
+ * users for inter-service messaging (`apps-manager`,
42
+ * `crontabs-batches-manager`, `file-generator`). Username =
43
+ * `serviceName`; permission regex is service-namespaced
44
+ * (`^wildo\.platform\.<slug>\..*`); crontabs gets the broader
45
+ * `^wildo\.(queue|reply|platform)\..*` per the legacy override.
46
+ *
47
+ * **Slice 2.5 behavior-parity note**: the legacy code paths
48
+ * `provisionServiceCredentials` and `ensureAppsManagerCredentials`
49
+ * produced slightly different topic-permission regexes for the
50
+ * same apps-manager user (`wildo\.platform\.apps_manager\..*` vs
51
+ * `wildo.platform.apps_manager.*`). User permissions were therefore
52
+ * order-dependent. This module unifies on the dot-escaped variant.
53
+ * Functionally equivalent for real queue names; tighter spec.
54
+ *
55
+ * **Slice 4.A.2 / M2 design note**: the legacy
56
+ * `getDefaultRabbitMQPermissions.read` field declared a BROADER
57
+ * regex than `configure`/`write` (included
58
+ * `wildo.platform.broadcast.*`) — but that widening NEVER reached
59
+ * the broker (the legacy `upsertTenantUserPermissions` ignored
60
+ * the `read` arg and used `resourceNameRegex` for both
61
+ * `configure` and `read`). This module preserves that behavior:
62
+ * `read === configure`. If platform services need a broadcast
63
+ * namespace in the future, extend `mintPlatformServiceCredentials`
64
+ * with an explicit broadcast-read regex.
65
+ *
66
+ * 3. **Audience-3 (app-scoped, via `plan().appRuntime` +
67
+ * `mintAppCredentials`)**: per-customer-app broker user. Username
68
+ * = `applicationNamespace`; permission regex disambiguates by
69
+ * `wildoApplicationId` (Mongo ObjectId — globally unique). Result
70
+ * threaded into `Wildo_Application_Secrets.database.rabbitmq.{
71
+ * username, password}` via `_merge_WildoApplicationSecrets`.
72
+ *
73
+ * **Vhost activation (Slice 2.5c-3, opt-in)**: when the operator
74
+ * authors `backingServices.rabbitmq.vhost` in
75
+ * `wildo.infra.<envName>.config.ts`, the CLI emits `RABBITMQ_VHOST`
76
+ * into apps-manager's process.env; the lifecycle service threads it
77
+ * via `applicationConfigInputs.vhost`; this module
78
+ * `ensureVhost`-then-permissions-on-it. Without the field, the
79
+ * module falls back to `/` (legacy permission-regex isolation).
80
+ *
81
+ * **`revokeAppCredentials`**: declared and tested, but the integration
82
+ * call site does NOT exist in `wildo-application-lifecycle.service.ts`
83
+ * (no app-deletion ceremony today — F6 of plan review 2026-05-06).
84
+ * Method ships as a forward-compat seam.
85
+ */
86
+
87
+ import {
88
+ AppCredentialMintingContext,
89
+ AppCredentialMintingResult,
90
+ IRabbitMQManagementClient,
91
+ InfraCapability,
92
+ InfraPlatformAdminConsumer,
93
+ InfraProvider,
94
+ InfraProviderModule,
95
+ InfraProviderPlanContribution,
96
+ PlatformServiceCredentialMintingContext,
97
+ PlatformServiceCredentialMintingResult,
98
+ ApplicationRuntimePrincipalKind,
99
+ buildRuntimePrincipalQueueSegment,
100
+ type ApplicationRuntimePrincipal,
101
+ } from '@wildo-ai/platform-config-lib';
102
+
103
+ /**
104
+ * Default vhost — apps share `/` with regex-based per-user permission
105
+ * isolation. Per-app vhost is intentionally NOT modeled (engine has no
106
+ * `RABBITMQ_VHOST` consumer; isolation comes from the permission regex
107
+ * on the per-app user, not from a per-app vhost).
108
+ *
109
+ * **No `ensureVhost(DEFAULT_VHOST)` call** is needed: vhost `/` is
110
+ * created by RabbitMQ at server boot. Any future module variant that
111
+ * uses a non-default vhost MUST call `adminClient.ensureVhost(...)`
112
+ * before user/permissions setup.
113
+ */
114
+ const DEFAULT_VHOST = '/';
115
+
116
+ /**
117
+ * Topic exchange used by every Wildo runtime publisher / consumer.
118
+ * Matches the value already in use at
119
+ * `wildo-application-lifecycle.service.ts:568` and
120
+ * `platform-infrastructure-manager.service.ts:485`.
121
+ */
122
+ const TOPIC_EXCHANGE = 'amq.topic';
123
+
124
+ /**
125
+ * The `Wildo_Application_Secrets` JSON paths this module writes through
126
+ * `mintAppCredentials`. Declared once and reused as
127
+ * `appRuntime.perAppSecretBlobPaths` in `plan()` AND as the keys of
128
+ * `secretBlobValues` in the result, so a refactor that renames a path
129
+ * fails loudly at one place instead of drifting silently.
130
+ */
131
+ const RABBITMQ_USERNAME_PATH = 'database.rabbitmq.username';
132
+ const RABBITMQ_PASSWORD_PATH = 'database.rabbitmq.password';
133
+ /**
134
+ * The paths this module mints PER RUNTIME PRINCIPAL — the single source for both consumers.
135
+ *
136
+ * Read by `InfraProviderModule.perPrincipalSecretBlobPaths` (statically, so apps-manager can strip
137
+ * these from the application blob before delivering a principal's own material) AND spread into
138
+ * `appRuntime.perAppSecretBlobPaths` inside `plan()`. Declaring the list twice is how the delivery
139
+ * path would silently stop covering a path this module still mints.
140
+ */
141
+ const PER_PRINCIPAL_SECRET_BLOB_PATHS: ReadonlyArray<string> = [RABBITMQ_USERNAME_PATH, RABBITMQ_PASSWORD_PATH];
142
+
143
+
144
+ /**
145
+ * Key the module looks for in
146
+ * `AppCredentialMintingContext.applicationConfigInputs` to read the
147
+ * optional per-app virtual host. Documented here as the public
148
+ * contract: the orchestrator supplies the vhost via this key, the
149
+ * module reads it via this key. Slice 2.5c-3 of
150
+ * `infra-provider-planner.md`.
151
+ */
152
+ const VHOST_INPUT_KEY = 'vhost';
153
+
154
+ /**
155
+ * Key the module looks for in
156
+ * `PlatformServiceCredentialMintingContext.secretInputs` to read the
157
+ * orchestrator-supplied platform-service password. Slice 4.A.2 / M5
158
+ * of `infra-provider-planner.md` — extracted as a constant for
159
+ * symmetry with `RABBITMQ_PASSWORD_PATH` (used by `mintAppCredentials`),
160
+ * so a future rename touches one site instead of drifting silently.
161
+ */
162
+ const PLATFORM_SERVICE_PASSWORD_INPUT_KEY = 'password';
163
+
164
+ /**
165
+ * Compute the per-app rabbit username. Mirrors the existing convention
166
+ * at `wildo-application-lifecycle.service.ts:434, 440` — username is
167
+ * the application namespace (`<appSlug>-<environmentId>` per
168
+ * `buildApplicationNamespace`), NOT the raw slug. Centralized here so
169
+ * future changes touch one site.
170
+ */
171
+ function buildRabbitUsername(applicationNamespace: string): string {
172
+ return applicationNamespace;
173
+ }
174
+
175
+ /**
176
+ * Identifier of the platform-crontabs-batches-manager service. The
177
+ * crontabs special case (broader permissions covering all per-app
178
+ * queue/reply names so it can fan out scheduled jobs) lives here so
179
+ * the legacy hard-coded check at
180
+ * `platform-infrastructure-manager.service.ts:466-475` becomes a
181
+ * module-internal concern.
182
+ *
183
+ * Hardcoded as a string literal rather than imported from
184
+ * `PlatformApplicationType` because that enum lives in saas-models
185
+ * and importing it here would couple the module to that enum's
186
+ * runtime shape — the value `'platform_crontabs_batches_manager'` is
187
+ * stable and small.
188
+ */
189
+ const PLATFORM_CRONTABS_BATCHES_MANAGER_SERVICE_NAME = 'platform_crontabs_batches_manager';
190
+
191
+ /**
192
+ * Compute the per-platform-service permission regex.
193
+ *
194
+ * Default policy (apps-manager, file-generator, …): the service can
195
+ * configure / write / read on `^wildo\.platform\.<serviceSlug>\..*`
196
+ * where `serviceSlug = serviceName.replace('platform_', '')`. Mirrors
197
+ * the legacy `getDefaultRabbitMQPermissions` at
198
+ * `credentials-provisioner.service.ts:267-275`.
199
+ *
200
+ * Crontabs-batches-manager override: broader regex
201
+ * `^wildo\.(queue|reply|platform)\..*` so the crontab can publish
202
+ * scheduled jobs onto every per-app queue and read replies. Mirrors
203
+ * the legacy inline override at
204
+ * `platform-infrastructure-manager.service.ts:466-475`.
205
+ */
206
+ function buildPlatformServiceRegex(serviceName: string): string {
207
+ if (serviceName === PLATFORM_CRONTABS_BATCHES_MANAGER_SERVICE_NAME) {
208
+ return '^wildo\\.(queue|reply|platform)\\..*';
209
+ }
210
+ const serviceSlug = serviceName.replace('platform_', '');
211
+ return `^wildo\\.platform\\.${serviceSlug}\\..*`;
212
+ }
213
+
214
+ /**
215
+ * Compute the per-app permission regex. Matches the convention at
216
+ * `wildo-application-lifecycle.service.ts:552`:
217
+ * `^wildo\.(queue|reply)\.${wildoApplicationId}\..*`
218
+ * The disambiguator is the application ID, NOT the slug — slugs could
219
+ * theoretically be reassigned; the id is forever-stable. The `^` and
220
+ * `\.` escaping is required by the RabbitMQ regex evaluator.
221
+ */
222
+ function buildPermissionRegex(wildoApplicationId: string): string {
223
+ // Escape regex metacharacters that may appear in application ids
224
+ // (today they're Mongo ObjectId hex strings — no specials — but the
225
+ // contract only commits to "stable opaque string", so guard).
226
+ const escapedId = wildoApplicationId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
227
+ return `^wildo\\.(queue|reply)\\.${escapedId}\\..*`;
228
+ }
229
+
230
+ /**
231
+ * The queues a MINION may CONSUME from — its own tick queue only.
232
+ *
233
+ * ## Why only `read` narrows, measured rather than assumed
234
+ *
235
+ * A minion runs the full backend pipeline headless, so the three RabbitMQ
236
+ * permission axes are not interchangeable here:
237
+ *
238
+ * - **`read`** governs CONSUMING. Narrowing it is the containment that matters:
239
+ * without it a minion can drain the application backend's job queue, consume
240
+ * another minion's ticks, or read RPC replies addressed to the backend.
241
+ * - **`configure`** governs DECLARE. The tick consumer declares its own queue
242
+ * (`channel.assertQueue`) — but the delayed-publish path can declare a delay
243
+ * queue too, so narrowing this risks breaking a legitimate publish.
244
+ * - **`write`** governs PUBLISHING. A minion legitimately enqueues jobs and
245
+ * sends RPC replies into the application's namespace; `publish` calls
246
+ * `sendToQueue` WITHOUT asserting (verified in
247
+ * `rabbitmq-adapter.backend.service.ts`), so it needs `write` on the app
248
+ * namespace and nothing more.
249
+ *
250
+ * **What this therefore does NOT prevent, stated plainly:** a minion can still
251
+ * PUBLISH anywhere in its application's queue namespace — it could enqueue a job
252
+ * the backend will run. Constraining that is a different control (who may
253
+ * request what work), not a broker permission, and pretending otherwise would
254
+ * overstate the boundary.
255
+ *
256
+ * The trailing `(\..*)?$` admits the tick queue itself and any suffixed
257
+ * variant, while the anchored `$` stops it matching a SIBLING whose name merely
258
+ * starts with this one's.
259
+ */
260
+ /**
261
+ * Per-principal broker username — the SAME derivation `revokeAppCredentials`
262
+ * must use.
263
+ *
264
+ * The application backend keeps `applicationNamespace` VERBATIM so an existing
265
+ * deployment's user is never orphaned. A revoke that derived this differently
266
+ * would delete the BACKEND's broker user while believing it was removing a
267
+ * minion's — the identical trap already caught on the Redis side.
268
+ */
269
+ function buildRabbitPrincipalUsername(
270
+ applicationNamespace: string,
271
+ principal: ApplicationRuntimePrincipal | undefined,
272
+ ): string {
273
+ if (!principal || principal.kind === ApplicationRuntimePrincipalKind.APPLICATION_BACKEND) {
274
+ return buildRabbitUsername(applicationNamespace);
275
+ }
276
+ const sanitizedName = (principal.name ?? '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
277
+ return `${applicationNamespace}-${principal.kind}-${sanitizedName}`;
278
+ }
279
+
280
+ function buildMinionConsumeRegex(wildoApplicationId: string, minionName: string): string {
281
+ const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
282
+ const appId = escape(wildoApplicationId);
283
+ const name = escape(minionName);
284
+ // Two alternatives, and nothing else:
285
+ // its tick queue — wildo.queue.<appId>.minion-tick.<name>
286
+ // its OWN reply queue — wildo.reply.<appId>.<segment>.<podId>
287
+ // The reply segment is IMPORTED from the same builder the minion's own AMQP
288
+ // adapter uses, not reproduced here. It used to be hand-written as
289
+ // `minion-<name>` with a comment saying it "mirrors" what the adapter emits —
290
+ // which agreed only while every minion name survived sanitisation unchanged.
291
+ // The application backend gets no segment at all (the builder returns
292
+ // undefined for it), keeping the historical layout, so this cannot match its
293
+ // replies.
294
+ const replySegment = escape(
295
+ buildRuntimePrincipalQueueSegment(ApplicationRuntimePrincipalKind.MINION, minionName) ?? '',
296
+ );
297
+ return `^(wildo\\.queue\\.${appId}\\.minion-tick\\.${name}(\\..*)?`
298
+ + `|wildo\\.reply\\.${appId}\\.${replySegment}\\..*)$`;
299
+ }
300
+
301
+ export const RabbitmqSelfInfraProviderModule: InfraProviderModule = {
302
+ provider: InfraProvider.RABBITMQ_SELF,
303
+ supportedCapabilities: [InfraCapability.QUEUING_SYSTEM],
304
+ perPrincipalSecretBlobPaths: PER_PRINCIPAL_SECRET_BLOB_PATHS,
305
+
306
+ plan(): InfraProviderPlanContribution {
307
+ return {
308
+ dockerComposeServices: ['rabbitmq'],
309
+ k8sResources: ['templates/infra/k8s/internal/rabbitmq/deployment.yaml.hbs'],
310
+ platformAdmin: {
311
+ envVars: {
312
+ RABBITMQ_HOST: 'rabbitmq',
313
+ RABBITMQ_PORT: '5672',
314
+ RABBITMQ_MANAGEMENT_PORT: '15672',
315
+ },
316
+ secretEnvVars: ['RABBITMQ_ADMIN_USERNAME', 'RABBITMQ_ADMIN_PASSWORD'],
317
+ // Audience-1 only (management-API access). audience-2 platform-
318
+ // service-scoped consumers (apps-manager / crontabs / file-
319
+ // generator publish-consume creds via `provisionAllPlatformServices`)
320
+ // are LD-8 territory, deferred to Slice 2.5 — they get their
321
+ // own `mintPlatformServiceCredentials` method then.
322
+ consumers: [
323
+ InfraPlatformAdminConsumer.APPS_MANAGER,
324
+ InfraPlatformAdminConsumer.COMPANION,
325
+ ],
326
+ },
327
+ appRuntime: {
328
+ // Declared for forward-compat with Slice 2.5's
329
+ // `_merge_WildoApplicationConfig` integration. Not consumed by
330
+ // any planner path in Slice 2 — the existing hard-coded values
331
+ // at `wildo-application-lifecycle.service.ts:1796-1799` stay in
332
+ // effect (host: 'localhost', port: 5672) until the planner
333
+ // wires this slice's contribution.
334
+ perAppConfigBlobPaths: {
335
+ 'database.rabbitmq.host': 'rabbitmq',
336
+ 'database.rabbitmq.port': 5672,
337
+ },
338
+ perAppSecretBlobPaths: [...PER_PRINCIPAL_SECRET_BLOB_PATHS],
339
+ },
340
+ };
341
+ },
342
+
343
+ async mintAppCredentials(
344
+ context: AppCredentialMintingContext,
345
+ ): Promise<AppCredentialMintingResult> {
346
+ const adminClient = context.adminClient as IRabbitMQManagementClient;
347
+ const password = context.secretInputs[RABBITMQ_PASSWORD_PATH];
348
+ if (!password) {
349
+ throw new Error(
350
+ `RabbitmqSelfInfraProviderModule.mintAppCredentials: ` +
351
+ `secretInputs is missing '${RABBITMQ_PASSWORD_PATH}'. ` +
352
+ `The orchestrator MUST supply the rabbit password — modules are upsert-only.`,
353
+ );
354
+ }
355
+
356
+ const username = buildRabbitPrincipalUsername(context.applicationNamespace, context.principal);
357
+ const permissionRegex = buildPermissionRegex(context.wildoApplicationId);
358
+ // A MINION consumes exactly two things: its own tick queue, and its own RPC
359
+ // REPLY queue. Both are admitted; everything else in the application's
360
+ // namespace is refused.
361
+ //
362
+ // The reply half is why an earlier attempt at this failed. Reply queues used
363
+ // to be `wildo.reply.<appId>.<podId>`, and `podId` is a per-PROCESS token
364
+ // carrying no principal — so no regex could admit a minion's replies while
365
+ // refusing the backend's, and narrowing `read` crash-looped the minion on
366
+ // its own replies. `buildReplyQueueName` now embeds the owning runtime,
367
+ // which is what makes this expressible at all.
368
+ const readRegex = context.principal?.kind === ApplicationRuntimePrincipalKind.MINION && context.principal.name
369
+ ? buildMinionConsumeRegex(context.wildoApplicationId, context.principal.name)
370
+ : permissionRegex;
371
+
372
+ // Slice 2.5c-3: optional per-app vhost. When the orchestrator
373
+ // supplies a non-empty `applicationConfigInputs.vhost`, the module
374
+ // ensures the vhost exists and sets permissions ON THAT vhost
375
+ // (provides true vhost-level isolation). When omitted, the module
376
+ // falls back to the default vhost `/` (legacy Slice 2 behavior:
377
+ // permission-regex isolation only). This is the opt-in seam — apps
378
+ // without an authored `backingServices.rabbitmq.vhost` continue to
379
+ // share `/`.
380
+ const vhost = context.applicationConfigInputs?.[VHOST_INPUT_KEY] || DEFAULT_VHOST;
381
+ if (vhost !== DEFAULT_VHOST) {
382
+ await adminClient.ensureVhost(vhost);
383
+ }
384
+
385
+ // PUT /api/users — idempotent upsert (replaces password + tags on conflict).
386
+ await adminClient.putUser(username, password, []);
387
+
388
+ // PUT /api/permissions — `configure` and `read` use the per-app
389
+ // queue/reply regex; `write` widens to allow `amq.default` (the
390
+ // default exchange RabbitMQ uses for direct routing) and the empty
391
+ // string (broker-internal). Mirrors the legacy
392
+ // `upsertTenantUserPermissions` write-regex shape so the Wonder
393
+ // Todos behavior is unchanged.
394
+ const writeRegex = `(^$|amq\\.default|${permissionRegex})`;
395
+ await adminClient.setPermissions(vhost, username, {
396
+ configure: permissionRegex,
397
+ write: writeRegex,
398
+ read: readRegex,
399
+ });
400
+
401
+ // PUT /api/topic-permissions — same regex on the topic exchange.
402
+ await adminClient.setTopicPermissions(vhost, username, {
403
+ exchange: TOPIC_EXCHANGE,
404
+ write: permissionRegex,
405
+ read: readRegex,
406
+ });
407
+
408
+ // Per-app-vhost cron delivery (`.claude/plans/crontab-publisher-vhost-gap.md`).
409
+ //
410
+ // A per-app vhost is a HARD broker isolation boundary: the platform
411
+ // cron/minion publisher (`platform_crontabs_batches_manager`) — a single
412
+ // global user that fans scheduled jobs into every app — cannot reach THIS
413
+ // app's queues until granted access on THIS vhost. On the default vhost `/`
414
+ // it already holds a broad publish grant (see `mintPlatformServiceCredentials`),
415
+ // so the grant is only needed for a non-default per-app vhost.
416
+ //
417
+ // Scope it to this app's own queue namespace (`^wildo\.(queue|reply)\.<appId>\..*`)
418
+ // — the publisher gets exactly the reach it needs and no more, TIGHTER than
419
+ // its broad default-vhost grant. It is PUBLISH-ONLY: `configure` for the
420
+ // passive `checkQueue` existence probe the publisher runs before sending +
421
+ // any declare, `write` (`amq.default`) for `basic.publish`; NO `read` (never
422
+ // consumes) and NO topic permissions (uses the default exchange, not
423
+ // `amq.topic`). The crontabs user is provisioned before any app
424
+ // (apps-manager INIT Phase 4 < Phase 5), so it reliably exists here, and
425
+ // `setPermissions` is an idempotent PUT — a re-init safely re-applies it.
426
+ if (vhost !== DEFAULT_VHOST) {
427
+ await adminClient.setPermissions(vhost, PLATFORM_CRONTABS_BATCHES_MANAGER_SERVICE_NAME, {
428
+ configure: permissionRegex,
429
+ write: writeRegex,
430
+ read: '',
431
+ });
432
+ }
433
+
434
+ return {
435
+ secretBlobValues: {
436
+ [RABBITMQ_USERNAME_PATH]: username,
437
+ // Echo the orchestrator-supplied password — proves the module
438
+ // is upsert-only, not generator. The orchestrator persists
439
+ // this exact value into `Wildo_Application_Secrets`.
440
+ [RABBITMQ_PASSWORD_PATH]: password,
441
+ },
442
+ };
443
+ },
444
+
445
+ /**
446
+ * Tear down the per-app rabbit user. RabbitMQ users are global, so
447
+ * `deleteUser` removes the user (and its permissions on every vhost
448
+ * it had access to) atomically.
449
+ *
450
+ * **Vhost retention** (Slice 4.A.2 / M6): when `mintAppCredentials`
451
+ * provisioned a per-app vhost (Slice 2.5c-3), `revokeAppCredentials`
452
+ * does NOT delete the vhost. Reasoning: vhost deletion drops every
453
+ * queue and exchange inside it, which would destroy in-flight
454
+ * messages mid-flight if a redeploy or restart is happening
455
+ * concurrently. The user-deletion alone is sufficient to revoke
456
+ * access; the empty vhost lingers as a paper trail. A future
457
+ * "app-deletion lifecycle" slice can layer vhost cleanup with
458
+ * proper drain semantics.
459
+ *
460
+ * **Caller status** (F6 of 2026-05-06 plan review): no caller wires
461
+ * this in Slices 1–2.5. `wildo-application-lifecycle.service.ts`
462
+ * has no app-deletion ceremony to invoke it from. Method ships as a
463
+ * forward-compat seam for whichever future slice designs the
464
+ * app-deletion lifecycle.
465
+ *
466
+ * **Defensive validation** (Slice 4.A.2 / L4): empty
467
+ * `applicationNamespace` would call `DELETE /api/users/` and
468
+ * silently 404; we fail-loud instead so a bad caller surfaces
469
+ * immediately.
470
+ */
471
+ async revokeAppCredentials(context: AppCredentialMintingContext): Promise<void> {
472
+ if (!context.applicationNamespace) {
473
+ throw new Error(
474
+ `RabbitmqSelfInfraProviderModule.revokeAppCredentials: ` +
475
+ `applicationNamespace is empty. ` +
476
+ `The orchestrator MUST supply a non-empty namespace.`,
477
+ );
478
+ }
479
+ const adminClient = context.adminClient as IRabbitMQManagementClient;
480
+ const username = buildRabbitPrincipalUsername(context.applicationNamespace, context.principal);
481
+ await adminClient.deleteUser(username);
482
+ },
483
+
484
+ async mintPlatformServiceCredentials(
485
+ context: PlatformServiceCredentialMintingContext,
486
+ ): Promise<PlatformServiceCredentialMintingResult> {
487
+ const adminClient = context.adminClient as IRabbitMQManagementClient;
488
+ const password = context.secretInputs[PLATFORM_SERVICE_PASSWORD_INPUT_KEY];
489
+ if (!password) {
490
+ throw new Error(
491
+ `RabbitmqSelfInfraProviderModule.mintPlatformServiceCredentials: ` +
492
+ `secretInputs is missing '${PLATFORM_SERVICE_PASSWORD_INPUT_KEY}'. ` +
493
+ `The orchestrator MUST supply the platform-service password — modules are upsert-only.`,
494
+ );
495
+ }
496
+
497
+ // Username is the platform service identifier verbatim — preserves
498
+ // the legacy convention from
499
+ // `PlatformInfrastructureManagerService.provisionServiceCredentials`
500
+ // (line 478: `username: serviceName`).
501
+ const username = context.serviceName;
502
+ const permissionRegex = buildPlatformServiceRegex(context.serviceName);
503
+
504
+ // PUT /api/users — idempotent upsert.
505
+ await adminClient.putUser(username, password, []);
506
+
507
+ // Permissions: configure/read use the per-service regex; write
508
+ // widens to allow `amq.default` + empty string (broker-internal),
509
+ // matching the `upsertTenantUserPermissions` write-regex shape so
510
+ // post-Slice-2.5 broker permissions match pre-Slice-2.5.
511
+ const writeRegex = `(^$|amq\\.default|${permissionRegex})`;
512
+ await adminClient.setPermissions(DEFAULT_VHOST, username, {
513
+ configure: permissionRegex,
514
+ write: writeRegex,
515
+ read: permissionRegex,
516
+ });
517
+
518
+ // Topic permissions: same dot-escaped regex on amq.topic. The
519
+ // legacy two-caller divergence (one path stripped the `^` and
520
+ // produced `wildo\.platform\.<slug>\..*`; the other produced
521
+ // `wildo.platform.<slug>.*` with literal dots) is unified here on
522
+ // the dot-escaped variant — see module JSDoc behavior-parity note.
523
+ await adminClient.setTopicPermissions(DEFAULT_VHOST, username, {
524
+ exchange: TOPIC_EXCHANGE,
525
+ write: permissionRegex,
526
+ read: permissionRegex,
527
+ });
528
+
529
+ return { username };
530
+ },
531
+ };