@kici-dev/shared 0.1.3 → 0.1.6

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.
@@ -41,10 +41,32 @@ export declare function dropDatabaseDirect(databaseUrl: string): Promise<void>;
41
41
  * existing backend connections so the DROP doesn't block.
42
42
  */
43
43
  export declare function dropAndCreateDatabase(databaseUrl: string): Promise<void>;
44
+ export interface EnsureDatabaseOpts {
45
+ /**
46
+ * DB owner role. Defaults to the URL's username. Pass when the admin
47
+ * connection user is privileged (e.g. Patroni superuser) but the new
48
+ * database should be owned by a separate, non-privileged role — the
49
+ * cross-owner case for provisioning shared-cluster databases like
50
+ * Keycloak's on the Platform Patroni cluster.
51
+ */
52
+ owner?: string;
53
+ /**
54
+ * After creating (or finding) the database, run
55
+ * `REVOKE CONNECT ON DATABASE "<name>" FROM PUBLIC`. Recommended for
56
+ * shared Patroni clusters where multiple unrelated databases coexist
57
+ * (Platform + Keycloak today, more tomorrow) — the default `PUBLIC`
58
+ * CONNECT grant otherwise lets any role with LOGIN on the cluster
59
+ * reach the new database. Idempotent (REVOKE on an already-revoked
60
+ * grant is a no-op).
61
+ */
62
+ revokeConnectFromPublic?: boolean;
63
+ }
44
64
  /**
45
- * CREATE DATABASE IF NOT EXISTS (idempotent). Uses the URL's username as owner.
65
+ * CREATE DATABASE IF NOT EXISTS (idempotent). With no `opts`, the URL's
66
+ * username is the owner. Pass `owner` to override and
67
+ * `revokeConnectFromPublic` to lock down the default CONNECT grant.
46
68
  */
47
- export declare function ensureDatabase(databaseUrl: string): Promise<'created' | 'exists'>;
69
+ export declare function ensureDatabase(databaseUrl: string, opts?: EnsureDatabaseOpts): Promise<'created' | 'exists'>;
48
70
  /**
49
71
  * CREATE ROLE ... LOGIN [CREATEDB] (idempotent — updates password if role
50
72
  * already exists).
@@ -582,6 +604,18 @@ export declare function platformConnectionExistsDirect(databaseUrl: string, conn
582
604
  * by Platform migration 017 actually fires when the parent
583
605
  * `platform_connections` row is deleted.
584
606
  */
607
+ /**
608
+ * Look up a Platform-side `webhook_sources` row by routing key. Returns the
609
+ * row (org_id + provider + connection) or null. Used by E2E to assert that a
610
+ * source added at runtime on the orchestrator propagated to the Platform's
611
+ * `webhook_sources` table (the dashboard-visible source list) without a
612
+ * restart.
613
+ */
614
+ export declare function getWebhookSourceByRoutingKeyDirect(databaseUrl: string, routingKey: string): Promise<{
615
+ routing_key: string;
616
+ org_id: string;
617
+ provider: string;
618
+ } | null>;
585
619
  export declare function countWebhookSourcesByConnectionIdDirect(databaseUrl: string, connectionId: string): Promise<number>;
586
620
  /**
587
621
  * Find a `user_api_keys.id` preferring rows scoped to `preferredOrgId`, else
@@ -1350,7 +1384,7 @@ export declare function resolvePlatformWebhookSourceRoutingKeyDirect(platformDbU
1350
1384
  */
1351
1385
  export declare function ensureOrgOwnerMemberDirect(platformDbUrl: string, opts: {
1352
1386
  orgId: string;
1353
- zitadelSub: string;
1387
+ idpSub: string;
1354
1388
  email: string;
1355
1389
  displayName: string;
1356
1390
  }): Promise<{
package/dist/db-admin.js CHANGED
@@ -93,16 +93,25 @@ async function dropAndCreateDatabase(databaseUrl) {
93
93
  });
94
94
  }
95
95
  /**
96
- * CREATE DATABASE IF NOT EXISTS (idempotent). Uses the URL's username as owner.
96
+ * CREATE DATABASE IF NOT EXISTS (idempotent). With no `opts`, the URL's
97
+ * username is the owner. Pass `owner` to override and
98
+ * `revokeConnectFromPublic` to lock down the default CONNECT grant.
97
99
  */
98
- async function ensureDatabase(databaseUrl) {
100
+ async function ensureDatabase(databaseUrl, opts = {}) {
99
101
  const { adminUrl, dbName, dbOwner } = parseDatabaseUrl(databaseUrl);
102
+ const finalOwner = opts.owner ?? dbOwner;
100
103
  assertValidIdentifier(dbName, "database name");
101
- assertValidIdentifier(dbOwner, "database owner");
104
+ assertValidIdentifier(finalOwner, "database owner");
102
105
  return withAdminPool(adminUrl, async (pool) => {
103
- if ((await pool.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName])).rows.length > 0) return "exists";
104
- await pool.query(`CREATE DATABASE "${dbName}" OWNER "${dbOwner}"`);
105
- return "created";
106
+ const check = await pool.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
107
+ let outcome;
108
+ if (check.rows.length > 0) outcome = "exists";
109
+ else {
110
+ await pool.query(`CREATE DATABASE "${dbName}" OWNER "${finalOwner}"`);
111
+ outcome = "created";
112
+ }
113
+ if (opts.revokeConnectFromPublic) await pool.query(`REVOKE CONNECT ON DATABASE "${dbName}" FROM PUBLIC`);
114
+ return outcome;
106
115
  });
107
116
  }
108
117
  /**
@@ -1115,6 +1124,29 @@ async function platformConnectionExistsDirect(databaseUrl, connectionId) {
1115
1124
  * by Platform migration 017 actually fires when the parent
1116
1125
  * `platform_connections` row is deleted.
1117
1126
  */
1127
+ /**
1128
+ * Look up a Platform-side `webhook_sources` row by routing key. Returns the
1129
+ * row (org_id + provider + connection) or null. Used by E2E to assert that a
1130
+ * source added at runtime on the orchestrator propagated to the Platform's
1131
+ * `webhook_sources` table (the dashboard-visible source list) without a
1132
+ * restart.
1133
+ */
1134
+ async function getWebhookSourceByRoutingKeyDirect(databaseUrl, routingKey) {
1135
+ const pool = createPool(databaseUrl);
1136
+ try {
1137
+ const row = (await pool.query(`SELECT routing_key, org_id, provider
1138
+ FROM webhook_sources
1139
+ WHERE routing_key = $1
1140
+ LIMIT 1`, [routingKey])).rows[0];
1141
+ return row ? {
1142
+ routing_key: String(row.routing_key),
1143
+ org_id: String(row.org_id),
1144
+ provider: String(row.provider)
1145
+ } : null;
1146
+ } finally {
1147
+ await pool.end();
1148
+ }
1149
+ }
1118
1150
  async function countWebhookSourcesByConnectionIdDirect(databaseUrl, connectionId) {
1119
1151
  const pool = createPool(databaseUrl);
1120
1152
  try {
@@ -2402,19 +2434,19 @@ async function ensureOrgOwnerMemberDirect(platformDbUrl, opts) {
2402
2434
  const ownerRole = await pool.query(`SELECT id FROM roles WHERE org_id = $1 AND is_owner = true LIMIT 1`, [opts.orgId]);
2403
2435
  if (ownerRole.rows.length === 0) throw new Error(`ensureOrgOwnerMemberDirect: no owner role found for org ${opts.orgId}`);
2404
2436
  const ownerRoleId = ownerRole.rows[0].id;
2405
- await pool.query(`INSERT INTO users (zitadel_sub, email, display_name)
2437
+ await pool.query(`INSERT INTO users (idp_sub, email, display_name)
2406
2438
  VALUES ($1, $2, $3)
2407
- ON CONFLICT (zitadel_sub) DO NOTHING`, [
2408
- opts.zitadelSub,
2439
+ ON CONFLICT (idp_sub) DO NOTHING`, [
2440
+ opts.idpSub,
2409
2441
  opts.email,
2410
2442
  opts.displayName
2411
2443
  ]);
2412
- await pool.query(`INSERT INTO org_members (org_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [opts.orgId, opts.zitadelSub]);
2444
+ await pool.query(`INSERT INTO org_members (org_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [opts.orgId, opts.idpSub]);
2413
2445
  await pool.query(`INSERT INTO role_assignments (org_id, user_id, role_id, assigned_by)
2414
2446
  VALUES ($1, $2, $3, $2)
2415
2447
  ON CONFLICT (org_id, user_id, role_id) DO NOTHING`, [
2416
2448
  opts.orgId,
2417
- opts.zitadelSub,
2449
+ opts.idpSub,
2418
2450
  ownerRoleId
2419
2451
  ]);
2420
2452
  return { ownerRoleId };
@@ -2520,6 +2552,6 @@ async function countActivePeerCredentialsByInstanceDirect(databaseUrl, opts) {
2520
2552
  }
2521
2553
  }
2522
2554
  //#endregion
2523
- export { MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createDbRole, createEnvironmentTemplateDirect, createJoinTokenDirect, createReadOnlyDbUser, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listEnvironmentsDirect, listExecutionJobsDirect, listExecutionRunsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedCrossRepoTrustDirect, seedEnvironmentBindingDirect, seedEnvironmentDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setEnvironmentPolicyDirect, setEnvironmentSecretDirect, showEnvironmentDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2555
+ export { MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createDbRole, createEnvironmentTemplateDirect, createJoinTokenDirect, createReadOnlyDbUser, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listEnvironmentsDirect, listExecutionJobsDirect, listExecutionRunsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedCrossRepoTrustDirect, seedEnvironmentBindingDirect, seedEnvironmentDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setEnvironmentPolicyDirect, setEnvironmentSecretDirect, showEnvironmentDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2524
2556
 
2525
2557
  //# sourceMappingURL=db-admin.js.map
@@ -0,0 +1,60 @@
1
+ import pg from 'pg';
2
+ /**
3
+ * Collation-drift helpers shared between `kici-admin` (orchestrator DB) and
4
+ * `kici-platform-admin` (Platform DB).
5
+ *
6
+ * Background: `pg_database.datcollversion` records the libc collation version
7
+ * at database-bootstrap time. B-tree indexes on text columns were built under
8
+ * those collation rules. When the running Postgres process's libc collation
9
+ * version differs (commonly after a container image rebuild on a newer libc
10
+ * base), indexes can silently misindex non-ASCII data — `LIKE 'foo%'` may miss
11
+ * rows, `ORDER BY` becomes unstable, rule-equivalent duplicates can sneak past
12
+ * unique constraints.
13
+ *
14
+ * Detection compares `datcollversion` (the version stamped at bootstrap)
15
+ * against `pg_database_collation_actual_version(oid)` (the version the running
16
+ * libc reports). Healing is a two-step operator action:
17
+ *
18
+ * 1. `REINDEX DATABASE CONCURRENTLY <name>` — rebuild every index under the
19
+ * running libc rules. Non-blocking (short locks per index); needs ~2× temp
20
+ * disk while parallel indexes coexist.
21
+ * 2. `ALTER DATABASE <name> REFRESH COLLATION VERSION` — bump the metadata
22
+ * stamp so future probes report clean.
23
+ *
24
+ * All callers pass a `pg.Pool`. The helpers do not own pool lifecycle — the
25
+ * CLI / probe layer creates and ends pools using the standard
26
+ * `createPool` / `pool.end()` pattern from `db.js`.
27
+ */
28
+ export interface CollationDrift {
29
+ /** Version stamped into pg_database.datcollversion at bootstrap. */
30
+ stamped: string;
31
+ /** Version the running libc reports via pg_database_collation_actual_version. */
32
+ actual: string;
33
+ }
34
+ /**
35
+ * Read `pg_database.datcollversion` and
36
+ * `pg_database_collation_actual_version(oid)` for `dbName` and return drift
37
+ * details when they differ. Returns `null` when stamped === actual OR when
38
+ * stamped is null (Postgres marks template0-style locked databases that way —
39
+ * benign, no drift to report).
40
+ */
41
+ export declare function getDatabaseCollationDrift(pool: pg.Pool, dbName: string): Promise<CollationDrift | null>;
42
+ /**
43
+ * Issue `REINDEX DATABASE CONCURRENTLY <quoted_db>`. Rebuilds every index in
44
+ * the database under the running libc collation rules.
45
+ *
46
+ * Identifier escaping uses `pg.escapeIdentifier` so a database name containing
47
+ * a double-quote (legal in Postgres) is quoted correctly.
48
+ *
49
+ * REINDEX DATABASE CONCURRENTLY refuses to run inside a transaction block, so
50
+ * the helper issues the query directly via `pool.query` (no explicit BEGIN);
51
+ * `node-postgres` does not start an implicit transaction.
52
+ */
53
+ export declare function reindexDatabaseConcurrently(pool: pg.Pool, dbName: string): Promise<void>;
54
+ /**
55
+ * Issue `ALTER DATABASE <quoted_db> REFRESH COLLATION VERSION`. Updates
56
+ * `pg_database.datcollversion` to match the running libc's reported version.
57
+ * Metadata-only; safe to run any time after a REINDEX has rebuilt the indexes.
58
+ */
59
+ export declare function refreshDatabaseCollationVersion(pool: pg.Pool, dbName: string): Promise<void>;
60
+ //# sourceMappingURL=db-collation.d.ts.map
@@ -0,0 +1,52 @@
1
+ import "./chunk-gOLHoazu.js";
2
+ import pg from "pg";
3
+ //#region src/db-collation.ts
4
+ /**
5
+ * Read `pg_database.datcollversion` and
6
+ * `pg_database_collation_actual_version(oid)` for `dbName` and return drift
7
+ * details when they differ. Returns `null` when stamped === actual OR when
8
+ * stamped is null (Postgres marks template0-style locked databases that way —
9
+ * benign, no drift to report).
10
+ */
11
+ async function getDatabaseCollationDrift(pool, dbName) {
12
+ const row = (await pool.query(`SELECT datcollversion AS stamped,
13
+ pg_database_collation_actual_version(oid) AS actual
14
+ FROM pg_database
15
+ WHERE datname = $1`, [dbName])).rows[0];
16
+ if (!row) throw new Error(`getDatabaseCollationDrift: database not found: ${dbName}`);
17
+ if (row.stamped === null) return null;
18
+ if (row.actual === null) throw new Error(`getDatabaseCollationDrift: pg_database_collation_actual_version returned null for ${dbName}`);
19
+ if (row.stamped === row.actual) return null;
20
+ return {
21
+ stamped: row.stamped,
22
+ actual: row.actual
23
+ };
24
+ }
25
+ /**
26
+ * Issue `REINDEX DATABASE CONCURRENTLY <quoted_db>`. Rebuilds every index in
27
+ * the database under the running libc collation rules.
28
+ *
29
+ * Identifier escaping uses `pg.escapeIdentifier` so a database name containing
30
+ * a double-quote (legal in Postgres) is quoted correctly.
31
+ *
32
+ * REINDEX DATABASE CONCURRENTLY refuses to run inside a transaction block, so
33
+ * the helper issues the query directly via `pool.query` (no explicit BEGIN);
34
+ * `node-postgres` does not start an implicit transaction.
35
+ */
36
+ async function reindexDatabaseConcurrently(pool, dbName) {
37
+ const quoted = pg.escapeIdentifier(dbName);
38
+ await pool.query(`REINDEX DATABASE CONCURRENTLY ${quoted}`);
39
+ }
40
+ /**
41
+ * Issue `ALTER DATABASE <quoted_db> REFRESH COLLATION VERSION`. Updates
42
+ * `pg_database.datcollversion` to match the running libc's reported version.
43
+ * Metadata-only; safe to run any time after a REINDEX has rebuilt the indexes.
44
+ */
45
+ async function refreshDatabaseCollationVersion(pool, dbName) {
46
+ const quoted = pg.escapeIdentifier(dbName);
47
+ await pool.query(`ALTER DATABASE ${quoted} REFRESH COLLATION VERSION`);
48
+ }
49
+ //#endregion
50
+ export { getDatabaseCollationDrift, refreshDatabaseCollationVersion, reindexDatabaseConcurrently };
51
+
52
+ //# sourceMappingURL=db-collation.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=db-collation.test.d.ts.map
@@ -53,12 +53,12 @@
53
53
  * - Forgejo dev server — FORGEJO_URL, FORGEJO_CONTAINER (read by the
54
54
  * staging Forgejo bootstrap script; conventional
55
55
  * names owned by Forgejo, not KiCI).
56
- * - Zitadel SDK configZITADEL_* (canonical names for the staging
57
- * Zitadel client config: issuer, project ID,
58
- * CLI client ID, test/admin user SUBs, the
59
- * staging Terraform PAT, and the management
60
- * API base URL). NOT aliases of `KICI_OIDC_*`
61
- * these are the names Zitadel itself uses.
56
+ * - Keycloak admin CLIKEYCLOAK_* (Keycloak's own admin tooling and
57
+ * docs use unprefixed `KEYCLOAK_BASE_URL`,
58
+ * `KEYCLOAK_ADMIN_CLIENT_ID`,
59
+ * `KEYCLOAK_ADMIN_CLIENT_SECRET`, etc. the
60
+ * E2E admin helpers honour those names as a
61
+ * fallback after `KICI_KEYCLOAK_*`).
62
62
  * - Vite client config — VITE_* (Vite reserves this prefix for env
63
63
  * vars exposed to client bundles; `VITE_BASE`
64
64
  * and `VITE_DOCS_BASE_URL` flow through this
@@ -55,12 +55,12 @@ import "../chunk-gOLHoazu.js";
55
55
  * - Forgejo dev server — FORGEJO_URL, FORGEJO_CONTAINER (read by the
56
56
  * staging Forgejo bootstrap script; conventional
57
57
  * names owned by Forgejo, not KiCI).
58
- * - Zitadel SDK configZITADEL_* (canonical names for the staging
59
- * Zitadel client config: issuer, project ID,
60
- * CLI client ID, test/admin user SUBs, the
61
- * staging Terraform PAT, and the management
62
- * API base URL). NOT aliases of `KICI_OIDC_*`
63
- * these are the names Zitadel itself uses.
58
+ * - Keycloak admin CLIKEYCLOAK_* (Keycloak's own admin tooling and
59
+ * docs use unprefixed `KEYCLOAK_BASE_URL`,
60
+ * `KEYCLOAK_ADMIN_CLIENT_ID`,
61
+ * `KEYCLOAK_ADMIN_CLIENT_SECRET`, etc. the
62
+ * E2E admin helpers honour those names as a
63
+ * fallback after `KICI_KEYCLOAK_*`).
64
64
  * - Vite client config — VITE_* (Vite reserves this prefix for env
65
65
  * vars exposed to client bundles; `VITE_BASE`
66
66
  * and `VITE_DOCS_BASE_URL` flow through this
@@ -70,7 +70,7 @@ import "../chunk-gOLHoazu.js";
70
70
  * the dev proxy), HEADED (Playwright convention
71
71
  * for `--headed` runs).
72
72
  */
73
- const OS_SDK_ALLOWLIST_REGEX = /^(KICI_.*|NODE_ENV|HOME|PATH|TZ|LANG|TMPDIR|USER|USERNAME|SHELL|COMSPEC|PWD|OLDPWD|HOSTNAME|COLUMNS|LINES|TERM|COLORTERM|DISPLAY|WAYLAND_DISPLAY|LOCALAPPDATA|XDG_CACHE_HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_RUNTIME_DIR|XDG_STATE_HOME|INIT_CWD|npm_.*|SSH_.*|CI|GITHUB_ACTIONS|GITHUB_ENV|GITHUB_OUTPUT|GITHUB_PATH|GITHUB_STEP_SUMMARY|GITLAB_CI|AWS_.*|REDIS_.*|OTEL_.*|STRIPE_.*|DOCKER_.*|CONTAINER_HOST|container|PGHOST|PGPORT|PGUSER|PGPASSWORD|PGDATABASE|PGSERVICEFILE|PGSSLMODE|FORGEJO_URL|FORGEJO_CONTAINER|ZITADEL_.*|VITE_.*|PLAYWRIGHT|HEADED)$/;
73
+ const OS_SDK_ALLOWLIST_REGEX = /^(KICI_.*|NODE_ENV|HOME|PATH|TZ|LANG|TMPDIR|USER|USERNAME|SHELL|COMSPEC|PWD|OLDPWD|HOSTNAME|COLUMNS|LINES|TERM|COLORTERM|DISPLAY|WAYLAND_DISPLAY|LOCALAPPDATA|XDG_CACHE_HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_RUNTIME_DIR|XDG_STATE_HOME|INIT_CWD|npm_.*|SSH_.*|CI|GITHUB_ACTIONS|GITHUB_ENV|GITHUB_OUTPUT|GITHUB_PATH|GITHUB_STEP_SUMMARY|GITLAB_CI|AWS_.*|REDIS_.*|OTEL_.*|STRIPE_.*|DOCKER_.*|CONTAINER_HOST|container|PGHOST|PGPORT|PGUSER|PGPASSWORD|PGDATABASE|PGSERVICEFILE|PGSSLMODE|FORGEJO_URL|FORGEJO_CONTAINER|KEYCLOAK_.*|VITE_.*|PLAYWRIGHT|HEADED)$/;
74
74
  /**
75
75
  * Returns true when `name` is allowed under the KiCI env-var convention:
76
76
  * it matches the OS/SDK allowlist regex (which includes the `KICI_*`
@@ -19,12 +19,17 @@ export declare const LoggerEnvSchema: z.ZodObject<{
19
19
  KICI_LOG_DIR: z.ZodOptional<z.ZodString>;
20
20
  KICI_LOG_MAX_SIZE: z.ZodDefault<z.ZodString>;
21
21
  KICI_LOG_RETENTION_DAYS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
22
+ KICI_LOG_FORMAT: z.ZodDefault<z.ZodEnum<{
23
+ plain: "plain";
24
+ json: "json";
25
+ auto: "auto";
26
+ }>>;
22
27
  KICI_CLUSTER_INSTANCE_ID: z.ZodOptional<z.ZodString>;
23
28
  KICI_AGENT_ID: z.ZodOptional<z.ZodString>;
24
29
  KICI_PLATFORM_INSTANCE_ID: z.ZodOptional<z.ZodString>;
25
30
  }, z.core.$strip>;
26
31
  /** All env vars the logger reads, for the unknown-KICI-var scanner. */
27
- export declare const LOGGER_ENV_VARS: readonly ["KICI_LOG_DIR", "KICI_LOG_MAX_SIZE", "KICI_LOG_RETENTION_DAYS", "KICI_CLUSTER_INSTANCE_ID", "KICI_AGENT_ID", "KICI_PLATFORM_INSTANCE_ID"];
32
+ export declare const LOGGER_ENV_VARS: readonly ["KICI_LOG_DIR", "KICI_LOG_MAX_SIZE", "KICI_LOG_RETENTION_DAYS", "KICI_LOG_FORMAT", "KICI_CLUSTER_INSTANCE_ID", "KICI_AGENT_ID", "KICI_PLATFORM_INSTANCE_ID"];
28
33
  /** Doc-friendly description map (consumed by the env-reference generator). */
29
34
  export declare const LOGGER_ENV_FIELD_SPECS: EnvFieldSpec[];
30
35
  //# sourceMappingURL=logger-env.d.ts.map
@@ -20,6 +20,17 @@ const LoggerEnvSchema = z.object({
20
20
  KICI_LOG_DIR: z.string().optional(),
21
21
  KICI_LOG_MAX_SIZE: z.string().default("500m"),
22
22
  KICI_LOG_RETENTION_DAYS: z.coerce.number().default(7),
23
+ /**
24
+ * Output format selection: `auto` (default — JSON when stdout is piped,
25
+ * plain text when stdout is a TTY), `plain` (always pretty), or `json`
26
+ * (always structured). Runtime services pin this to `json` so a stray
27
+ * journal-attached PTY can never flip them into plain mode.
28
+ */
29
+ KICI_LOG_FORMAT: z.enum([
30
+ "auto",
31
+ "plain",
32
+ "json"
33
+ ]).default("auto"),
23
34
  /** Set by the orchestrator process; used as a filename suffix. */
24
35
  KICI_CLUSTER_INSTANCE_ID: z.string().optional(),
25
36
  /** Set by the agent process; used as a filename suffix. */
@@ -32,6 +43,7 @@ const LOGGER_ENV_VARS = [
32
43
  "KICI_LOG_DIR",
33
44
  "KICI_LOG_MAX_SIZE",
34
45
  "KICI_LOG_RETENTION_DAYS",
46
+ "KICI_LOG_FORMAT",
35
47
  "KICI_CLUSTER_INSTANCE_ID",
36
48
  "KICI_AGENT_ID",
37
49
  "KICI_PLATFORM_INSTANCE_ID"
@@ -62,6 +74,15 @@ const LOGGER_ENV_FIELD_SPECS = [
62
74
  type: "string",
63
75
  description: "Directory for rotated JSON log files. When unset, the logger only writes to stdout/stderr."
64
76
  },
77
+ {
78
+ envVar: "KICI_LOG_FORMAT",
79
+ aliases: [],
80
+ fieldPath: "KICI_LOG_FORMAT",
81
+ required: false,
82
+ defaultValue: "\"auto\"",
83
+ type: "enum:auto|plain|json",
84
+ description: "Output format for stdout/stderr. `auto` (default) emits JSON when stdout is piped and plain coloured text when stdout is a TTY. `plain` and `json` force the corresponding format regardless of TTY. Runtime services (orchestrator, agent, platform, dashboard SSR) pin `json` so a journal-attached PTY cannot flip them into plain mode."
85
+ },
65
86
  {
66
87
  envVar: "KICI_LOG_MAX_SIZE",
67
88
  aliases: [],
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Env-file semantic diff for the idempotent-step preview path.
3
+ *
4
+ * Inputs are the raw byte strings of two env files (local generated +
5
+ * remote-cat'd). Output is a sorted list of `EnvDiffEntry` records — one
6
+ * per key that was added, removed, or changed (plus an unchanged count).
7
+ *
8
+ * The renderer masks every value by default: an env file is sops-derived
9
+ * credential material in the deploy-prod context, and the confirm prompt
10
+ * is tee'd to `release-prod.log.<ts>`. Operators who need to see the
11
+ * actual values pass `--reveal-env-values` end-to-end (CLI flag → env var
12
+ * `KICI_REVEAL_ENV_VALUES=1` → `reveal: true` on this renderer). Without
13
+ * the flag the rendered output carries only the key name + classification.
14
+ */
15
+ export type EnvDiffKind = 'added' | 'removed' | 'changed' | 'unchanged';
16
+ export interface EnvDiffEntry {
17
+ key: string;
18
+ kind: EnvDiffKind;
19
+ /** Only populated for `changed` / `removed` kinds. */
20
+ oldValue?: string;
21
+ /** Only populated for `added` / `changed` kinds. */
22
+ newValue?: string;
23
+ }
24
+ /**
25
+ * Parse an env-file body into a `Map<string, string>`. The shape matches
26
+ * what Docker / systemd `EnvironmentFile=` / Keycloak's env loader consume:
27
+ *
28
+ * - Blank lines and `#`-prefixed comments are skipped.
29
+ * - The first `=` splits key and value; values may contain further `=`.
30
+ * - Surrounding single or double quotes on the value are stripped when
31
+ * they are balanced; otherwise the literal bytes are preserved.
32
+ * - Duplicate keys: last write wins (POSIX shell semantics).
33
+ * - Whitespace is stripped from the key only — values keep their bytes
34
+ * verbatim so a trailing space in a credential is detected as drift.
35
+ */
36
+ export declare function parseEnvLines(content: string): Map<string, string>;
37
+ /**
38
+ * Diff two env-file bodies. Returns one entry per key in the union of
39
+ * both inputs. Entry order: `added`, `changed`, `removed`, `unchanged`,
40
+ * alphabetically inside each group.
41
+ *
42
+ * `local` is the new-side bytes (what the deploy would ship), `remote`
43
+ * is the old-side bytes (what's currently on the box). The convention
44
+ * matches `previewRsyncFileToBox`'s `localContent` / `remoteContent`
45
+ * field names.
46
+ */
47
+ export declare function diffEnvFiles(local: string, remote: string): EnvDiffEntry[];
48
+ export interface EnvDiffRenderOpts {
49
+ /** When true, show old + new values inline. Default false (masked output). */
50
+ reveal?: boolean;
51
+ /** When true, color the kind label with ANSI red/green/cyan. */
52
+ color?: boolean;
53
+ }
54
+ /**
55
+ * Render the env-semantic diff body lines. The returned array carries
56
+ * NO surrounding indentation — the caller (e.g. `renderFileDrifts`)
57
+ * decides how to nest each line under its containing per-file row.
58
+ *
59
+ * Masked layout (default):
60
+ *
61
+ * KEY_A: changed
62
+ * KEY_B: added
63
+ * KEY_C: removed
64
+ * 3 other key(s) unchanged
65
+ *
66
+ * Revealed layout (`reveal: true`):
67
+ *
68
+ * KEY_A: changed
69
+ * - old=hunter2
70
+ * + new=tr0ub4dor
71
+ * KEY_B: added
72
+ * + new=AKIA…
73
+ * KEY_C: removed
74
+ * - old=zzz
75
+ * 3 other key(s) unchanged
76
+ *
77
+ * When there are no changed / added / removed entries (every key matches
78
+ * byte-for-byte), the function returns a single "N key(s) unchanged"
79
+ * line so the operator gets a positive "in sync at the env level"
80
+ * confirmation rather than an empty body.
81
+ */
82
+ export declare function renderEnvDiff(entries: EnvDiffEntry[], opts?: EnvDiffRenderOpts): string[];
83
+ //# sourceMappingURL=idempotency-env-diff.d.ts.map
@@ -0,0 +1,163 @@
1
+ import "./chunk-gOLHoazu.js";
2
+ //#region src/idempotency-env-diff.ts
3
+ /**
4
+ * Env-file semantic diff for the idempotent-step preview path.
5
+ *
6
+ * Inputs are the raw byte strings of two env files (local generated +
7
+ * remote-cat'd). Output is a sorted list of `EnvDiffEntry` records — one
8
+ * per key that was added, removed, or changed (plus an unchanged count).
9
+ *
10
+ * The renderer masks every value by default: an env file is sops-derived
11
+ * credential material in the deploy-prod context, and the confirm prompt
12
+ * is tee'd to `release-prod.log.<ts>`. Operators who need to see the
13
+ * actual values pass `--reveal-env-values` end-to-end (CLI flag → env var
14
+ * `KICI_REVEAL_ENV_VALUES=1` → `reveal: true` on this renderer). Without
15
+ * the flag the rendered output carries only the key name + classification.
16
+ */
17
+ const ANSI_RED = "\x1B[31m";
18
+ const ANSI_GREEN = "\x1B[32m";
19
+ const ANSI_CYAN = "\x1B[36m";
20
+ const ANSI_RESET = "\x1B[0m";
21
+ /**
22
+ * Parse an env-file body into a `Map<string, string>`. The shape matches
23
+ * what Docker / systemd `EnvironmentFile=` / Keycloak's env loader consume:
24
+ *
25
+ * - Blank lines and `#`-prefixed comments are skipped.
26
+ * - The first `=` splits key and value; values may contain further `=`.
27
+ * - Surrounding single or double quotes on the value are stripped when
28
+ * they are balanced; otherwise the literal bytes are preserved.
29
+ * - Duplicate keys: last write wins (POSIX shell semantics).
30
+ * - Whitespace is stripped from the key only — values keep their bytes
31
+ * verbatim so a trailing space in a credential is detected as drift.
32
+ */
33
+ function parseEnvLines(content) {
34
+ const out = /* @__PURE__ */ new Map();
35
+ for (const rawLine of content.split("\n")) {
36
+ const trimmedLeft = rawLine.replace(/\r$/, "").replace(/^[\t ]+/, "");
37
+ if (trimmedLeft === "") continue;
38
+ if (trimmedLeft.startsWith("#")) continue;
39
+ const eqIdx = trimmedLeft.indexOf("=");
40
+ if (eqIdx < 0) continue;
41
+ const key = trimmedLeft.slice(0, eqIdx).trim();
42
+ if (key === "") continue;
43
+ let value = trimmedLeft.slice(eqIdx + 1);
44
+ if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
45
+ out.set(key, value);
46
+ }
47
+ return out;
48
+ }
49
+ /**
50
+ * Diff two env-file bodies. Returns one entry per key in the union of
51
+ * both inputs. Entry order: `added`, `changed`, `removed`, `unchanged`,
52
+ * alphabetically inside each group.
53
+ *
54
+ * `local` is the new-side bytes (what the deploy would ship), `remote`
55
+ * is the old-side bytes (what's currently on the box). The convention
56
+ * matches `previewRsyncFileToBox`'s `localContent` / `remoteContent`
57
+ * field names.
58
+ */
59
+ function diffEnvFiles(local, remote) {
60
+ const localMap = parseEnvLines(local);
61
+ const remoteMap = parseEnvLines(remote);
62
+ const entries = [];
63
+ const keys = new Set([...localMap.keys(), ...remoteMap.keys()]);
64
+ for (const key of keys) {
65
+ const inLocal = localMap.has(key);
66
+ const inRemote = remoteMap.has(key);
67
+ const localValue = localMap.get(key);
68
+ const remoteValue = remoteMap.get(key);
69
+ if (inLocal && !inRemote) entries.push({
70
+ key,
71
+ kind: "added",
72
+ newValue: localValue
73
+ });
74
+ else if (!inLocal && inRemote) entries.push({
75
+ key,
76
+ kind: "removed",
77
+ oldValue: remoteValue
78
+ });
79
+ else if (inLocal && inRemote && localValue !== remoteValue) entries.push({
80
+ key,
81
+ kind: "changed",
82
+ oldValue: remoteValue,
83
+ newValue: localValue
84
+ });
85
+ else entries.push({
86
+ key,
87
+ kind: "unchanged"
88
+ });
89
+ }
90
+ const ORDER = {
91
+ added: 0,
92
+ changed: 1,
93
+ removed: 2,
94
+ unchanged: 3
95
+ };
96
+ entries.sort((a, b) => {
97
+ if (ORDER[a.kind] !== ORDER[b.kind]) return ORDER[a.kind] - ORDER[b.kind];
98
+ return a.key.localeCompare(b.key);
99
+ });
100
+ return entries;
101
+ }
102
+ /**
103
+ * Render the env-semantic diff body lines. The returned array carries
104
+ * NO surrounding indentation — the caller (e.g. `renderFileDrifts`)
105
+ * decides how to nest each line under its containing per-file row.
106
+ *
107
+ * Masked layout (default):
108
+ *
109
+ * KEY_A: changed
110
+ * KEY_B: added
111
+ * KEY_C: removed
112
+ * 3 other key(s) unchanged
113
+ *
114
+ * Revealed layout (`reveal: true`):
115
+ *
116
+ * KEY_A: changed
117
+ * - old=hunter2
118
+ * + new=tr0ub4dor
119
+ * KEY_B: added
120
+ * + new=AKIA…
121
+ * KEY_C: removed
122
+ * - old=zzz
123
+ * 3 other key(s) unchanged
124
+ *
125
+ * When there are no changed / added / removed entries (every key matches
126
+ * byte-for-byte), the function returns a single "N key(s) unchanged"
127
+ * line so the operator gets a positive "in sync at the env level"
128
+ * confirmation rather than an empty body.
129
+ */
130
+ function renderEnvDiff(entries, opts = {}) {
131
+ const reveal = opts.reveal ?? false;
132
+ const color = opts.color ?? false;
133
+ const lines = [];
134
+ const changedish = entries.filter((e) => e.kind !== "unchanged");
135
+ let unchangedCount = entries.length - changedish.length;
136
+ for (const entry of changedish) {
137
+ lines.push(`${entry.key}: ${colorizeKind(entry.kind, color)}`);
138
+ if (reveal) {
139
+ if (entry.kind === "changed" || entry.kind === "removed") lines.push(` ${colorize("- old=" + (entry.oldValue ?? ""), "old", color)}`);
140
+ if (entry.kind === "changed" || entry.kind === "added") lines.push(` ${colorize("+ new=" + (entry.newValue ?? ""), "new", color)}`);
141
+ }
142
+ }
143
+ if (unchangedCount > 0) lines.push(`${unchangedCount} other key(s) unchanged`);
144
+ else if (lines.length === 0) lines.push(`0 key(s) drifted (env content matches)`);
145
+ return lines;
146
+ }
147
+ function colorizeKind(kind, color) {
148
+ if (!color) return kind;
149
+ switch (kind) {
150
+ case "added": return `${ANSI_GREEN}${kind}${ANSI_RESET}`;
151
+ case "removed": return `${ANSI_RED}${kind}${ANSI_RESET}`;
152
+ case "changed": return `${ANSI_CYAN}${kind}${ANSI_RESET}`;
153
+ case "unchanged": return kind;
154
+ }
155
+ }
156
+ function colorize(text, side, color) {
157
+ if (!color) return text;
158
+ return side === "new" ? `${ANSI_GREEN}${text}${ANSI_RESET}` : `${ANSI_RED}${text}${ANSI_RESET}`;
159
+ }
160
+ //#endregion
161
+ export { diffEnvFiles, parseEnvLines, renderEnvDiff };
162
+
163
+ //# sourceMappingURL=idempotency-env-diff.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=idempotency-env-diff.test.d.ts.map