@caelo-cms/provisioning 0.10.35 → 0.10.37

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/dist/adapter.d.ts +7 -1
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/cli.js +81 -12
  4. package/dist/cli.js.map +1 -1
  5. package/dist/compose.d.ts +12 -0
  6. package/dist/compose.d.ts.map +1 -1
  7. package/dist/compose.js +12 -4
  8. package/dist/compose.js.map +1 -1
  9. package/dist/default-firewall.d.ts +45 -0
  10. package/dist/default-firewall.d.ts.map +1 -0
  11. package/dist/default-firewall.js +171 -0
  12. package/dist/default-firewall.js.map +1 -0
  13. package/dist/deployed-release.d.ts +5 -3
  14. package/dist/deployed-release.d.ts.map +1 -1
  15. package/dist/deployed-release.js +6 -4
  16. package/dist/deployed-release.js.map +1 -1
  17. package/dist/gateway-credentials.d.ts +42 -0
  18. package/dist/gateway-credentials.d.ts.map +1 -0
  19. package/dist/gateway-credentials.js +95 -0
  20. package/dist/gateway-credentials.js.map +1 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/install-state.d.ts +27 -0
  25. package/dist/install-state.d.ts.map +1 -1
  26. package/dist/install-state.js +67 -0
  27. package/dist/install-state.js.map +1 -1
  28. package/dist/lifecycle.d.ts +6 -0
  29. package/dist/lifecycle.d.ts.map +1 -1
  30. package/dist/lifecycle.js +225 -56
  31. package/dist/lifecycle.js.map +1 -1
  32. package/dist/runtime-secrets.d.ts +39 -2
  33. package/dist/runtime-secrets.d.ts.map +1 -1
  34. package/dist/runtime-secrets.js +77 -8
  35. package/dist/runtime-secrets.js.map +1 -1
  36. package/dist/self-hosted-roles.d.ts +64 -0
  37. package/dist/self-hosted-roles.d.ts.map +1 -0
  38. package/dist/self-hosted-roles.js +77 -0
  39. package/dist/self-hosted-roles.js.map +1 -0
  40. package/dist/stack-contract.d.ts +68 -3
  41. package/dist/stack-contract.d.ts.map +1 -1
  42. package/dist/stack-contract.js +69 -12
  43. package/dist/stack-contract.js.map +1 -1
  44. package/dist/stack-converge.d.ts +24 -18
  45. package/dist/stack-converge.d.ts.map +1 -1
  46. package/dist/stack-converge.js +47 -2
  47. package/dist/stack-converge.js.map +1 -1
  48. package/dist/wizard.d.ts.map +1 -1
  49. package/dist/wizard.js +4 -2
  50. package/dist/wizard.js.map +1 -1
  51. package/dist/wizards/gcp.d.ts.map +1 -1
  52. package/dist/wizards/gcp.js +64 -2
  53. package/dist/wizards/gcp.js.map +1 -1
  54. package/package.json +3 -3
  55. package/stacks/aws/README.md +1 -1
  56. package/stacks/aws/index.ts +20 -2
  57. package/stacks/azure/README.md +1 -1
  58. package/stacks/azure/index.ts +46 -7
  59. package/stacks/gcp/README.md +1 -1
  60. package/stacks/gcp/index.ts +25 -5
  61. package/stacks/gcp-firebase/index.ts +23 -2
  62. package/stacks/self-hosted/index.ts +26 -12
@@ -82,6 +82,11 @@ function randomHex(bytes: number): string {
82
82
  }
83
83
 
84
84
  const postgresPassword = pulumi.secret(randomHex(32));
85
+ // #613 — one password per database role, so the gateway (public_role +
86
+ // gateway_role) never holds admin_role's. bootstrap.sh creates the roles
87
+ // with these (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD).
88
+ const publicRolePassword = pulumi.secret(randomHex(32));
89
+ const gatewayRolePassword = pulumi.secret(randomHex(32));
85
90
  const csrfSecret = pulumi.secret(randomHex(32));
86
91
  const cookieSecret = pulumi.secret(randomHex(32));
87
92
  const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
@@ -110,6 +115,8 @@ function keyVaultSecret(shortName: string, value: pulumi.Output<string>): azure.
110
115
  }
111
116
 
112
117
  const _pgSecret = keyVaultSecret("pg-password", postgresPassword);
118
+ keyVaultSecret("public-role-password", publicRolePassword);
119
+ keyVaultSecret("gateway-role-password", gatewayRolePassword);
113
120
  keyVaultSecret("csrf-secret", csrfSecret);
114
121
  keyVaultSecret("cookie-secret", cookieSecret);
115
122
  keyVaultSecret("anthropic-api-key", anthropicApiKey);
@@ -152,9 +159,18 @@ const _pgPublic = new azure.dbforpostgresql.Database(`${namePrefix}-cms-public-d
152
159
  const adminDatabaseUrl = pulumi
153
160
  .all([pgServer.fullyQualifiedDomainName, postgresPassword])
154
161
  .apply(([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_admin?sslmode=require`);
155
- const publicDatabaseUrl = pulumi
162
+ // What the admin connects to cms_public with (DDL, migrations, moderation).
163
+ const publicAdminDatabaseUrl = pulumi
156
164
  .all([pgServer.fullyQualifiedDomainName, postgresPassword])
157
- .apply(([host, pw]) => `postgresql://caelo_public:${pw}@${host}:5432/cms_public?sslmode=require`);
165
+ .apply(([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_public?sslmode=require`);
166
+ // The gateway's two logins (#613): public_role on cms_public and
167
+ // gateway_role on cms_admin, each with its own password.
168
+ const publicDatabaseUrl = pulumi
169
+ .all([pgServer.fullyQualifiedDomainName, publicRolePassword])
170
+ .apply(([host, pw]) => `postgresql://public_role:${pw}@${host}:5432/cms_public?sslmode=require`);
171
+ const gatewayDatabaseUrl = pulumi
172
+ .all([pgServer.fullyQualifiedDomainName, gatewayRolePassword])
173
+ .apply(([host, pw]) => `postgresql://gateway_role:${pw}@${host}:5432/cms_admin?sslmode=require`);
158
174
 
159
175
  // === 4. Storage account + containers ===
160
176
  const storage = new azure.storage.StorageAccount(`${namePrefix}-st`, {
@@ -215,6 +231,12 @@ const cappEnv = new azure.app.ManagedEnvironment(`${namePrefix}-capp-env`, {
215
231
 
216
232
  interface ContainerAppArgs {
217
233
  readonly serviceName: string;
234
+ /**
235
+ * The service's database connection env. The admin-side services get
236
+ * admin_role's URLs; the gateway gets only its own two logins (#613,
237
+ * CLAUDE.md §2 — never an admin_role credential).
238
+ */
239
+ readonly databaseEnv: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
218
240
  readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
219
241
  /** Defaults to 0.5 vCPU / 1 GiB. */
220
242
  readonly resources?: { readonly cpu: number; readonly memory: string };
@@ -246,8 +268,7 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
246
268
  env: [
247
269
  { name: "CAELO_PROVIDER", value: "azure" },
248
270
  { name: "CAELO_ENV", value: env },
249
- { name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
250
- { name: "PUBLIC_ADMIN_DATABASE_URL", value: publicDatabaseUrl },
271
+ ...args.databaseEnv,
251
272
  {
252
273
  name: "MEDIA_STORAGE_URL",
253
274
  value: pulumi.interpolate`https://${storage.name}.blob.core.windows.net/media`,
@@ -261,18 +282,34 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
261
282
  });
262
283
  }
263
284
 
285
+ const adminDatabaseEnv = [
286
+ { name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
287
+ { name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl },
288
+ ];
264
289
  const adminApp = containerApp({
265
290
  serviceName: "admin",
291
+ databaseEnv: adminDatabaseEnv,
266
292
  resources: adminResources,
267
293
  // #551 — the public site URL; the admin seeds site_defaults.site_base_url
268
294
  // from it (canonical, og:url, sitemap) when it is not configured yet.
269
295
  extraEnv: [{ name: "CAELO_SITE_URL", value: `https://${domain}` }],
270
296
  });
271
- const _gatewayApp = containerApp({ serviceName: "gateway" });
272
- const _orchestratorApp = containerApp({ serviceName: "orchestrator" });
273
- const _runnerApp = containerApp({ serviceName: "runner" });
297
+ const _gatewayApp = containerApp({
298
+ serviceName: "gateway",
299
+ databaseEnv: [
300
+ { name: "GATEWAY_DATABASE_URL", value: gatewayDatabaseUrl },
301
+ { name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl },
302
+ ],
303
+ });
304
+ const _orchestratorApp = containerApp({
305
+ serviceName: "orchestrator",
306
+ databaseEnv: adminDatabaseEnv,
307
+ });
308
+ const _runnerApp = containerApp({ serviceName: "runner", databaseEnv: adminDatabaseEnv });
274
309
  const edgeRouterApp = containerApp({
275
310
  serviceName: "edge-router",
311
+ // Serves the static site and the A/B routing manifest; no database.
312
+ databaseEnv: [],
276
313
  extraEnv: [
277
314
  {
278
315
  name: "STATIC_BUCKET_URL",
@@ -304,6 +341,7 @@ const dnsRecordsRequired: DnsRecord[] = [
304
341
  const out: CloudAdapterOutputs = {
305
342
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
306
343
  publicDatabaseUrl: publicDatabaseUrl as unknown as string,
344
+ gatewayDatabaseUrl: gatewayDatabaseUrl as unknown as string,
307
345
  mediaStorageUrl:
308
346
  pulumi.interpolate`https://${storage.name}.blob.core.windows.net/media` as unknown as string,
309
347
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
@@ -317,6 +355,7 @@ const out: CloudAdapterOutputs = {
317
355
 
318
356
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
319
357
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
358
+ export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
320
359
  export const mediaStorageUrlOut = out.mediaStorageUrl;
321
360
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
322
361
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -11,7 +11,7 @@ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `
11
11
  | CDN | Cloud CDN backend bucket (operator wires the URL map + load balancer for v1; full LB in P15 review-pass) |
12
12
  | Edge compute (A/B + redirects) | Cloud Run service `<env>-edge-router` running `edge-handler.ts` with `@caelo-cms/edge-router` |
13
13
  | Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
14
- | Secret store | Secret Manager: postgres-password + secret-kek (Pulumi), internal-secret + tool-approval-secret (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. |
14
+ | Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret, public-role-password and gateway-role-password (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. The gateway's service account reads only the two gateway role passwords (#613); `gateway_role` itself is created by migration 0248 and gets its password from the CLI after migrations. |
15
15
  | Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
16
16
 
17
17
  The Caelo runtime never knows it's on GCP — every Cloud Run service consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
@@ -245,8 +245,22 @@ const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
245
245
  "secret-kek": kekSecret.resource.secretId,
246
246
  "internal-secret": gcpSecretId(env, "internal-secret"),
247
247
  "tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
248
+ "public-role-password": gcpSecretId(env, "public-role-password"),
249
+ "gateway-role-password": gcpSecretId(env, "gateway-role-password"),
248
250
  };
249
251
 
252
+ // #613 — public_role's own password (CLI-generated, stack-contract.ts
253
+ // DATABASE_ROLE_SECRET), so the gateway, which reads it, never holds
254
+ // admin_role's. gateway_role is not declared here: migration 0248 creates
255
+ // it in SQL (an API-created user would join cloudsqlsuperuser) and the CLI
256
+ // sets its password after migrations.
257
+ const publicRolePassword = pulumi.secret(
258
+ gcp.secretmanager.getSecretVersionOutput(
259
+ { project, secret: gcpSecretId(env, "public-role-password") },
260
+ { provider: gcpProvider },
261
+ ).secretData,
262
+ );
263
+
250
264
  // =========================================================================
251
265
  // Tier 4 — Cloud SQL Postgres (private IP only; HA + retention configurable)
252
266
  // =========================================================================
@@ -307,7 +321,7 @@ const pgAdminUser = new gcp.sql.User(
307
321
 
308
322
  const pgPublicUser = new gcp.sql.User(
309
323
  `${namePrefix}-pg-public`,
310
- { instance: sqlInstance.name, name: "public_role", password: postgresPassword },
324
+ { instance: sqlInstance.name, name: "public_role", password: publicRolePassword },
311
325
  opts,
312
326
  );
313
327
 
@@ -631,7 +645,12 @@ const envContractInputs = {
631
645
  env,
632
646
  domain,
633
647
  region,
634
- databaseUrls: { admin: dbUrls.admin, publicAdmin: dbUrls.publicAdmin, public: dbUrls.public },
648
+ databaseUrls: {
649
+ admin: dbUrls.admin,
650
+ publicAdmin: dbUrls.publicAdmin,
651
+ public: dbUrls.public,
652
+ gateway: dbUrls.gateway,
653
+ },
635
654
  } as const;
636
655
 
637
656
  function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
@@ -721,9 +740,8 @@ const gatewaySvc = cloudRunService({
721
740
  minInstances: gatewayMinInstances,
722
741
  maxInstances: 100,
723
742
  memory: "512Mi",
724
- // public_role on cms_public for visitor writes — plus, for now, an
725
- // admin_role pool (see the known gap on stack-contract.ts
726
- // SERVICE_SECRET_ENV.gateway).
743
+ // public_role on cms_public + gateway_role on cms_admin, each with its
744
+ // own password; never admin_role (#613, CLAUDE.md §2).
727
745
  contractEnv: gatewayEnvContract(envContractInputs),
728
746
  });
729
747
 
@@ -1116,6 +1134,7 @@ const out: CloudAdapterOutputs = {
1116
1134
  // Password-less — the password lives only in Secret Manager.
1117
1135
  adminDatabaseUrl: dbUrls.admin as unknown as string,
1118
1136
  publicDatabaseUrl: dbUrls.public as unknown as string,
1137
+ gatewayDatabaseUrl: dbUrls.gateway as unknown as string,
1119
1138
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
1120
1139
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
1121
1140
  bootstrapUrl:
@@ -1129,6 +1148,7 @@ const out: CloudAdapterOutputs = {
1129
1148
 
1130
1149
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
1131
1150
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
1151
+ export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
1132
1152
  export const mediaStorageUrlOut = out.mediaStorageUrl;
1133
1153
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
1134
1154
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -219,8 +219,22 @@ const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
219
219
  "secret-kek": kekSecret.resource.secretId,
220
220
  "internal-secret": gcpSecretId(env, "internal-secret"),
221
221
  "tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
222
+ "public-role-password": gcpSecretId(env, "public-role-password"),
223
+ "gateway-role-password": gcpSecretId(env, "gateway-role-password"),
222
224
  };
223
225
 
226
+ // #613 — public_role's own password (CLI-generated, stack-contract.ts
227
+ // DATABASE_ROLE_SECRET), so the gateway, which reads it, never holds
228
+ // admin_role's. gateway_role is not declared here: migration 0248 creates
229
+ // it in SQL (an API-created user would join cloudsqlsuperuser) and the CLI
230
+ // sets its password after migrations.
231
+ const publicRolePassword = pulumi.secret(
232
+ gcp.secretmanager.getSecretVersionOutput(
233
+ { project, secret: gcpSecretId(env, "public-role-password") },
234
+ { provider: gcpProvider },
235
+ ).secretData,
236
+ );
237
+
224
238
  // =========================================================================
225
239
  // Tier 4 — Cloud SQL Postgres (private IP only; HA configurable)
226
240
  // =========================================================================
@@ -279,7 +293,7 @@ const pgAdminUser = new gcp.sql.User(
279
293
  );
280
294
  new gcp.sql.User(
281
295
  `${namePrefix}-public-user`,
282
- { instance: sqlInstance.name, name: "public_role", password: postgresPassword },
296
+ { instance: sqlInstance.name, name: "public_role", password: publicRolePassword },
283
297
  opts,
284
298
  );
285
299
 
@@ -576,7 +590,12 @@ const envContractInputs = {
576
590
  env,
577
591
  domain,
578
592
  region,
579
- databaseUrls: { admin: dbUrls.admin, publicAdmin: dbUrls.publicAdmin, public: dbUrls.public },
593
+ databaseUrls: {
594
+ admin: dbUrls.admin,
595
+ publicAdmin: dbUrls.publicAdmin,
596
+ public: dbUrls.public,
597
+ gateway: dbUrls.gateway,
598
+ },
580
599
  } as const;
581
600
 
582
601
  // Gateway provisioned first so we can pass its name/region into the
@@ -947,6 +966,7 @@ const out: CloudAdapterOutputs = {
947
966
  // Password-less — the password lives only in Secret Manager.
948
967
  adminDatabaseUrl: dbUrls.admin as unknown as string,
949
968
  publicDatabaseUrl: dbUrls.public as unknown as string,
969
+ gatewayDatabaseUrl: dbUrls.gateway as unknown as string,
950
970
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
951
971
  // Firebase Hosting serves media via its own CDN; we proxy through
952
972
  // the Firebase site's apex domain for canonical URLs.
@@ -963,6 +983,7 @@ const out: CloudAdapterOutputs = {
963
983
 
964
984
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
965
985
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
986
+ export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
966
987
  export const mediaStorageUrlOut = out.mediaStorageUrl;
967
988
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
968
989
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -42,21 +42,35 @@ const tokenPath = resolve(caeloDir, "pending-token.json");
42
42
  // in the state file; `pulumi stack output --show-secrets postgresPassword`
43
43
  // is the operator's recovery path.
44
44
  const postgresPassword = pulumi.secret(randomHex(32));
45
+ // #613 — one password per application role; the gateway gets only the
46
+ // public_role and gateway_role ones.
47
+ const adminRolePassword = pulumi.secret(randomHex(32));
48
+ const publicRolePassword = pulumi.secret(randomHex(32));
49
+ const gatewayRolePassword = pulumi.secret(randomHex(32));
45
50
  const minioRootUser = "caelo";
46
51
  const minioRootPassword = pulumi.secret(randomHex(32));
47
52
 
48
- const composeYaml = pulumi.all([postgresPassword, minioRootPassword]).apply(([pgPw, minioPw]) =>
49
- generateDockerCompose({
50
- domain,
51
- postgresPassword: pgPw,
52
- minioRootUser,
53
- minioRootPassword: minioPw,
54
- anthropicApiKey: process.env.ANTHROPIC_API_KEY,
55
- resendApiKey: process.env.RESEND_API_KEY,
56
- diskSize: "20Gi",
57
- ...(adminMemory ? { adminMemory } : {}),
58
- }),
59
- );
53
+ const composeYaml = pulumi
54
+ .all([
55
+ postgresPassword,
56
+ minioRootPassword,
57
+ adminRolePassword,
58
+ publicRolePassword,
59
+ gatewayRolePassword,
60
+ ])
61
+ .apply(([pgPw, minioPw, adminPw, publicPw, gatewayPw]) =>
62
+ generateDockerCompose({
63
+ domain,
64
+ postgresPassword: pgPw,
65
+ rolePasswords: { admin: adminPw, public: publicPw, gateway: gatewayPw },
66
+ minioRootUser,
67
+ minioRootPassword: minioPw,
68
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
69
+ resendApiKey: process.env.RESEND_API_KEY,
70
+ diskSize: "20Gi",
71
+ ...(adminMemory ? { adminMemory } : {}),
72
+ }),
73
+ );
60
74
 
61
75
  const caddyConf = generateCaddyfile({
62
76
  ownerEmail,