@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.
- package/LICENSE +34 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.d.ts +63 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.d.ts.map +1 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.js +110 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.js.map +1 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.d.ts +30 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.d.ts.map +1 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.js +61 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.js.map +1 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts +26 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts.map +1 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js +54 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js.map +1 -0
- package/dist/esm/index.d.ts +88 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +125 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/minio-self/minio-self.infra.module.d.ts +64 -0
- package/dist/esm/minio-self/minio-self.infra.module.d.ts.map +1 -0
- package/dist/esm/minio-self/minio-self.infra.module.js +105 -0
- package/dist/esm/minio-self/minio-self.infra.module.js.map +1 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts +52 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts.map +1 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js +201 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js.map +1 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts +81 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts.map +1 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.js +311 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.js.map +1 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.d.ts +70 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.d.ts.map +1 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.js +328 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.js.map +1 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts +88 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts.map +1 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js +473 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js.map +1 -0
- package/dist/esm/redis-self/redis-self.infra.module.d.ts +89 -0
- package/dist/esm/redis-self/redis-self.infra.module.d.ts.map +1 -0
- package/dist/esm/redis-self/redis-self.infra.module.js +331 -0
- package/dist/esm/redis-self/redis-self.infra.module.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +52 -0
- package/src/aws-s3/__tests__/aws-s3.infra.module.test.ts +67 -0
- package/src/aws-s3/aws-s3.infra.module.ts +120 -0
- package/src/clamav-self/__tests__/clamav-self.infra.module.test.ts +65 -0
- package/src/clamav-self/clamav-self.infra.module.ts +72 -0
- package/src/gotenberg-self/__tests__/gotenberg-self.infra.module.test.ts +65 -0
- package/src/gotenberg-self/gotenberg-self.infra.module.ts +65 -0
- package/src/index.ts +133 -0
- package/src/minio-self/__tests__/minio-self.infra.module.test.ts +70 -0
- package/src/minio-self/minio-self.infra.module.ts +116 -0
- package/src/mongodb-atlas/__tests__/mongodb-atlas.infra.module.test.ts +198 -0
- package/src/mongodb-atlas/mongodb-atlas.infra.module.ts +236 -0
- package/src/mongodb-self/__tests__/mongodb-self.infra.module.test.ts +419 -0
- package/src/mongodb-self/mongodb-self.infra.module.ts +384 -0
- package/src/postgres-self/__tests__/postgres-self.infra.module.test.ts +344 -0
- package/src/postgres-self/postgres-self.infra.module.ts +377 -0
- package/src/rabbitmq-self/__tests__/rabbitmq-self.infra.module.test.ts +499 -0
- package/src/rabbitmq-self/rabbitmq-self.infra.module.ts +531 -0
- package/src/redis-self/__tests__/redis-self.infra.module.test.ts +341 -0
- 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
|
+
};
|