@aifabrix/builder 2.61.2 → 2.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/lib/api/applications.api.js +22 -2
  2. package/lib/api/bootstrap-snapshot.api.js +115 -0
  3. package/lib/api/onboarding-status.api.js +117 -0
  4. package/lib/app/deploy.js +14 -5
  5. package/lib/app/register.js +24 -2
  6. package/lib/app/run-env-compose.js +44 -8
  7. package/lib/app/run-env-recovery.js +1 -0
  8. package/lib/app/run-helpers.js +1 -1
  9. package/lib/app/run.js +6 -4
  10. package/lib/build/standard-docker-build.js +4 -0
  11. package/lib/channels/channel-artifact-generator.js +13 -3
  12. package/lib/cli/infra-guided-footers.js +10 -8
  13. package/lib/cli/infra-guided.js +60 -12
  14. package/lib/cli/setup-infra-up-platform-action.js +14 -1
  15. package/lib/cli/setup-platform.js +20 -3
  16. package/lib/commands/auth-config.js +2 -0
  17. package/lib/commands/login.js +4 -0
  18. package/lib/commands/repair-datasource-apply.js +3 -1
  19. package/lib/commands/repair-datasource-legacy.js +94 -0
  20. package/lib/commands/setup-image-refresh.js +26 -0
  21. package/lib/commands/setup-modes.js +9 -2
  22. package/lib/commands/setup-onboarding-readiness.js +83 -0
  23. package/lib/commands/setup-prompts-platform-mode.js +34 -0
  24. package/lib/commands/setup.js +35 -10
  25. package/lib/commands/up-builder-api.js +24 -8
  26. package/lib/commands/up-common.js +16 -1
  27. package/lib/commands/up-dataplane-bootstrap.js +149 -16
  28. package/lib/commands/up-dataplane-credentials.js +21 -0
  29. package/lib/commands/up-dataplane.js +26 -34
  30. package/lib/commands/up-integration-server.js +112 -0
  31. package/lib/commands/wizard-config-normalizer.js +5 -1
  32. package/lib/core/admin-secrets-env-overlay.js +8 -3
  33. package/lib/core/environment-access-policy.js +121 -0
  34. package/lib/core/environment-mode-policy.js +177 -0
  35. package/lib/core/local-env-overrides.js +149 -0
  36. package/lib/core/secrets-ensure.js +4 -2
  37. package/lib/core/secrets-env-content.js +12 -9
  38. package/lib/core/secrets-env-write.js +1 -1
  39. package/lib/datasource/capability/capability-manifest.js +1 -0
  40. package/lib/deployment/installation/index.js +14 -1
  41. package/lib/deployment/installation/infra-catalog.js +3 -0
  42. package/lib/deployment/installation/local-environment-stage.js +285 -0
  43. package/lib/deployment/installation/local-installation.js +76 -0
  44. package/lib/deployment/installation/stage-input.js +5 -1
  45. package/lib/generator/builders.js +3 -1
  46. package/lib/generator/index.js +32 -9
  47. package/lib/generator/split-variables.js +2 -2
  48. package/lib/parameters/database-adoption.js +125 -0
  49. package/lib/parameters/physical-database-naming.js +136 -0
  50. package/lib/parameters/physical-secret-name.js +194 -0
  51. package/lib/parameters/rsa-secret-values.js +165 -0
  52. package/lib/programmatic/run-channel-install-artifacts.js +28 -2
  53. package/lib/programmatic/run-channel-package.js +3 -1
  54. package/lib/programmatic/workspace-context.js +37 -2
  55. package/lib/schema/infra.parameter.yaml +61 -27
  56. package/lib/schema/infrastructure-schema.json +213 -120
  57. package/lib/utils/compose-generate-docker-compose.js +2 -2
  58. package/lib/utils/compose-generator.js +4 -7
  59. package/lib/utils/compose-miso-env.js +2 -2
  60. package/lib/utils/compose-traefik-ingress-base.js +3 -4
  61. package/lib/utils/dataplane-dimension-abac-setting.js +120 -0
  62. package/lib/utils/declarative-url-ports.js +3 -0
  63. package/lib/utils/env-environment-file-paths.js +105 -0
  64. package/lib/utils/environment-scoped-resources.js +48 -14
  65. package/lib/utils/log-redaction.js +4 -1
  66. package/lib/utils/paths-system-builder-keys.js +7 -1
  67. package/lib/utils/paths.js +7 -2
  68. package/lib/utils/postgres-platform-bootstrap.js +35 -1
  69. package/lib/utils/redis-env-scope.js +21 -1
  70. package/lib/utils/registry-auth-sources.js +154 -0
  71. package/lib/utils/registry-credentials.js +131 -14
  72. package/lib/utils/secrets-generator.js +30 -21
  73. package/lib/utils/secrets-helpers.js +5 -7
  74. package/lib/utils/secrets-kv-scope.js +127 -28
  75. package/lib/utils/secrets-materialize-local.js +9 -0
  76. package/lib/utils/secrets-missing-error.js +35 -1
  77. package/lib/utils/token-manager.js +14 -8
  78. package/lib/utils/url-public-path-prefix.js +6 -10
  79. package/package.json +4 -2
  80. package/templates/README.md +6 -0
  81. package/templates/applications/builder-api/application.yaml +1 -1
  82. package/templates/applications/dataplane/application.yaml +2 -2
  83. package/templates/applications/dataplane/env.template +1 -1
  84. package/templates/applications/integration-server/application.yaml +91 -0
  85. package/templates/applications/integration-server/env.template +215 -0
  86. package/templates/applications/mirrored-templates.json +52 -0
  87. package/templates/applications/miso-controller/application.yaml +43 -1
  88. package/templates/applications/miso-controller/env.template +45 -39
  89. package/templates/marketplace/createUiDefinition.json +52 -4
  90. package/templates/marketplace/main.json +85 -7
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Physical database, role and password-secret names for a local installation.
3
+ *
4
+ * Local runs every entitled environment on one PostgreSQL server, which is 227.0's shared
5
+ * model. Without an environment segment two environments declaring the same logical
6
+ * database name collide on the database, the role and the secret — the defect Miso 227.3
7
+ * fixed for Azure. The local path had none of it: keys are derived as
8
+ * `databases-{appKey}-{i}-*` with no environment in them.
9
+ *
10
+ * This mirrors Miso's `resolvePhysicalDatabaseNames`
11
+ * (`services/infra-parameters/physical-database-naming.ts`) deliberately. Matching it makes
12
+ * a local environment and an Azure environment the same object at a different address,
13
+ * which is the point of validating a near-Azure stack in Docker. A second scheme would be
14
+ * two isolation rules to keep in step, and the Azure one already ships.
15
+ *
16
+ * Pure: no I/O. The environment comes from the caller, never from `process.env` and never
17
+ * from a manifest.
18
+ *
19
+ * @fileoverview Environment-encoded PostgreSQL naming for shared local infrastructure
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ /** Canonical environment keys. Matches the Azure and database vocabulary, not licence slots. */
25
+ const PHYSICAL_NAME_ENVIRONMENTS = Object.freeze(['miso', 'dev', 'tst', 'pro']);
26
+
27
+ /** PostgreSQL truncates identifiers at 63 bytes, silently, which would reintroduce collisions. */
28
+ const POSTGRES_IDENTIFIER_LIMIT = 63;
29
+
30
+ class UnknownPhysicalEnvironmentError extends Error {
31
+ constructor(environment) {
32
+ super(
33
+ `Unknown environment '${environment}'. Expected one of: ${PHYSICAL_NAME_ENVIRONMENTS.join(', ')}.`
34
+ );
35
+ this.name = 'UnknownPhysicalEnvironmentError';
36
+ this.environment = environment;
37
+ }
38
+ }
39
+
40
+ class PhysicalNameTooLongError extends Error {
41
+ constructor(resolvedName, limit) {
42
+ super(
43
+ `Resolved physical name '${resolvedName}' is ${resolvedName.length} characters, over the ` +
44
+ `${limit} character limit. Shorten the application key or the logical database name.`
45
+ );
46
+ this.name = 'PhysicalNameTooLongError';
47
+ this.resolvedName = resolvedName;
48
+ this.limit = limit;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Normalise a segment to the PostgreSQL identifier character set.
54
+ *
55
+ * @param {string} value - Raw segment
56
+ * @returns {string}
57
+ */
58
+ function normalizeSegment(value) {
59
+ return String(value ?? '')
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9_]/g, '_')
63
+ .replace(/_+/g, '_')
64
+ .replace(/^_|_$/g, '');
65
+ }
66
+
67
+ /**
68
+ * Reject an unknown environment instead of defaulting to one.
69
+ *
70
+ * Defaulting is how a production database ends up on a development server, so this fails
71
+ * closed.
72
+ *
73
+ * @param {string} environment - Environment key
74
+ * @returns {string} Normalised environment key
75
+ * @throws {UnknownPhysicalEnvironmentError}
76
+ */
77
+ function assertPhysicalEnvironment(environment) {
78
+ const normalized = String(environment ?? '').trim().toLowerCase();
79
+ if (!PHYSICAL_NAME_ENVIRONMENTS.includes(normalized)) {
80
+ throw new UnknownPhysicalEnvironmentError(environment);
81
+ }
82
+ return normalized;
83
+ }
84
+
85
+ /**
86
+ * Resolve the database, role and password-secret names together.
87
+ *
88
+ * Resolved as one call so a caller cannot take two of the three and leave the third
89
+ * verbatim, which is the shape of the original defect.
90
+ *
91
+ * @param {Object} params - Inputs
92
+ * @param {string} params.logicalName - Logical database name from the manifest
93
+ * @param {string} params.appKey - Application key
94
+ * @param {string} params.environment - Logical environment
95
+ * @returns {{ logicalName: string, databaseName: string, roleName: string, passwordSecretKey: string }}
96
+ * @throws {UnknownPhysicalEnvironmentError|PhysicalNameTooLongError|Error}
97
+ */
98
+ function resolvePhysicalDatabaseNames(params = {}) {
99
+ const environment = assertPhysicalEnvironment(params.environment);
100
+ const logicalName = String(params.logicalName ?? '').trim();
101
+ const app = normalizeSegment(params.appKey);
102
+ const logical = normalizeSegment(logicalName);
103
+
104
+ if (!app) {
105
+ throw new Error('appKey is required to resolve a physical database name');
106
+ }
107
+ if (!logical) {
108
+ throw new Error('logicalName is required to resolve a physical database name');
109
+ }
110
+
111
+ const databaseName = `${environment}_${app}_${logical}`;
112
+ const roleName = `${databaseName}_user`;
113
+
114
+ // The role is the longest derived identifier, so checking it covers the database too.
115
+ if (roleName.length > POSTGRES_IDENTIFIER_LIMIT) {
116
+ throw new PhysicalNameTooLongError(roleName, POSTGRES_IDENTIFIER_LIMIT);
117
+ }
118
+
119
+ return {
120
+ logicalName,
121
+ databaseName,
122
+ roleName,
123
+ // Hyphenated because the vault naming contract rejects underscores.
124
+ passwordSecretKey: `${databaseName.replace(/_/g, '-')}-password`
125
+ };
126
+ }
127
+
128
+ module.exports = {
129
+ PHYSICAL_NAME_ENVIRONMENTS,
130
+ POSTGRES_IDENTIFIER_LIMIT,
131
+ UnknownPhysicalEnvironmentError,
132
+ PhysicalNameTooLongError,
133
+ normalizeSegment,
134
+ assertPhysicalEnvironment,
135
+ resolvePhysicalDatabaseNames
136
+ };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The physical secret name for a catalog key (plan 210.0, mirroring Miso 227.4).
3
+ *
4
+ * Builder resolved `tst` and `pro` secrets by prefixing the logical key: `pro-<key>`. That is
5
+ * right for an environment-scoped parameter and **wrong for the other two cases**, which
6
+ * Miso's naming rule (`services/parameters/parameter-naming.ts`) states as:
7
+ *
8
+ * ```text
9
+ * system <key> no prefix, deliberately
10
+ * environment-scoped dev-<key>
11
+ * application-scoped dev-<app>-<key>
12
+ * ```
13
+ *
14
+ * So the database password an installation actually holds is
15
+ * `pro-dataplane-databases-0-passwordKeyVault`, not `pro-databases-dataplane-0-passwordKeyVault`
16
+ * — the application segment sits before the key, not after the environment alone. And system
17
+ * names such as `keycloak-*` and `acr-*` must not move at all, because the Marketplace
18
+ * template writes them and every installation-level reader expects them where they are.
19
+ *
20
+ * Rather than restate that rule, this resolves the name the way Miso's `computeVaultSecretName`
21
+ * does: from the catalog entry's own `azure.vaultSecretName` or `azure.vaultSecretNamePattern`.
22
+ * The catalog is the Miso master and Builder mirrors it byte for byte, so the pattern is the
23
+ * contract. A key with no pattern is installation-wide by declaration, not by omission.
24
+ *
25
+ * This is why no caller may build a prefix itself: the three cases are not distinguishable
26
+ * from the key alone.
27
+ *
28
+ * @fileoverview Catalog-driven physical secret names, mirroring Miso's computeVaultSecretName
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ /** Database catalog keys carry both the owning application and the index in the key. */
34
+ const DATABASE_KEY_PARTS = /^databases-([a-z0-9-]+)-(\d+)-(?:url|password)KeyVault$/;
35
+
36
+ /**
37
+ * @param {string} key - Logical catalog key
38
+ * @returns {string} The index, or empty when the key is not a database key
39
+ */
40
+ function extractDatabaseIndex(key) {
41
+ const match = String(key ?? '').match(DATABASE_KEY_PARTS);
42
+ return match ? match[2] : '';
43
+ }
44
+
45
+ /**
46
+ * The application a database key belongs to.
47
+ *
48
+ * Most database entries match the catalog's generic `keyPattern` rather than an exact key, so
49
+ * they carry no `ownerAppKey` to read — the owner is in the key. Without this the `{appKey}`
50
+ * placeholder resolves empty and the name collapses to `pro--databases-0-…`, a name nothing
51
+ * writes and nothing reads.
52
+ *
53
+ * @param {string} key - Logical catalog key
54
+ * @returns {string} The application key, or empty when the key is not a database key
55
+ */
56
+ function extractDatabaseAppKey(key) {
57
+ const match = String(key ?? '').match(DATABASE_KEY_PARTS);
58
+ return match ? match[1] : '';
59
+ }
60
+
61
+ /**
62
+ * Substitute the placeholders a vault name pattern may carry.
63
+ *
64
+ * @param {string} pattern - From `azure.vaultSecretNamePattern`
65
+ * @param {string} key - Logical catalog key
66
+ * @param {{ environment?: string, serviceName?: string, appKey?: string }} context
67
+ * @returns {string}
68
+ */
69
+ function applyVaultNamePattern(pattern, key, context = {}) {
70
+ return String(pattern)
71
+ .replace(/\{key\}/g, key)
72
+ .replace(/\{environment\}/g, context.environment ?? '')
73
+ .replace(/\{serviceName\}/g, context.serviceName ?? '')
74
+ .replace(/\{appKey\}/g, context.appKey ?? '')
75
+ .replace(/\{index\}/g, extractDatabaseIndex(key));
76
+ }
77
+
78
+ /**
79
+ * The physical name of the secret holding a logical key's value.
80
+ *
81
+ * @param {string} key - Logical catalog key
82
+ * @param {Object} [context] - Resolution context
83
+ * @param {string} [context.environment] - Environment key
84
+ * @param {string} [context.serviceName] - Installation service name
85
+ * @param {Object} [context.catalog] - Catalog override, for tests
86
+ * @returns {string} Physical secret name, or the key unchanged when the catalog says so
87
+ */
88
+ function computePhysicalSecretName(key, context = {}) {
89
+ const logicalKey = String(key ?? '').trim();
90
+ if (!logicalKey) {
91
+ return logicalKey;
92
+ }
93
+ const entry = findCatalogEntry(logicalKey, context.catalog);
94
+ const azure = entry && entry.azure ? entry.azure : null;
95
+ if (!azure) {
96
+ return logicalKey;
97
+ }
98
+ if (azure.vaultSecretName) {
99
+ return String(azure.vaultSecretName);
100
+ }
101
+ if (!azure.vaultSecretNamePattern) {
102
+ return logicalKey;
103
+ }
104
+ // The owning application comes from the entry or from the key, never from the caller: an
105
+ // application-scoped parameter names its own owner, and a caller passing a different one
106
+ // would silently address another application's secret.
107
+ return applyVaultNamePattern(azure.vaultSecretNamePattern, logicalKey, {
108
+ environment: context.environment,
109
+ serviceName: context.serviceName,
110
+ appKey: entry.ownerAppKey || extractDatabaseAppKey(logicalKey)
111
+ });
112
+ }
113
+
114
+ /**
115
+ * @param {string} key - Logical catalog key
116
+ * @param {Object} [override] - Catalog override, for tests
117
+ * @returns {Object|null}
118
+ */
119
+ function findCatalogEntry(key, override) {
120
+ const catalog = override || loadCatalogQuietly();
121
+ if (!catalog || typeof catalog.findEntryForKey !== 'function') {
122
+ return null;
123
+ }
124
+ try {
125
+ return catalog.findEntryForKey(key) || null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The catalog, or nothing.
133
+ *
134
+ * A key resolves to itself when the catalog cannot be read, which is the same answer an
135
+ * absent entry gives. Failing here instead would turn an unreadable catalog into "no secret
136
+ * resolves", and the catalog is already validated by its own checks.
137
+ *
138
+ * @returns {Object|null}
139
+ */
140
+ function loadCatalogQuietly() {
141
+ try {
142
+ return require('./infra-parameter-catalog').getInfraParameterCatalog();
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The name an environment-isolated installation must read.
150
+ *
151
+ * `computePhysicalSecretName` answers what the catalog declares. This answers what an
152
+ * environment may actually use, and the two differ in one place that matters.
153
+ *
154
+ * **An application-scoped secret is never shared between environments.** Each environment's
155
+ * application holds its own credential. Some catalog entries still pin a literal from before
156
+ * environments existed — `dataplane-client-secretKeyVault` names itself, with no
157
+ * `{environment}` anywhere — and honouring that in `pro` would hand `pro` the credential
158
+ * `dev` is using. One leaked client secret would then authenticate as the other environment's
159
+ * application, which is the whole isolation gone.
160
+ *
161
+ * So an app-scoped name that does not carry its environment gets one. System names
162
+ * (`keycloak-*`, `acr-*`) are left exactly as they are: there is one Keycloak and one
163
+ * registry per platform, and those secrets are shared on purpose.
164
+ *
165
+ * @param {string} key - Logical catalog key
166
+ * @param {string} environment - Environment key
167
+ * @returns {string} The secret name this environment must read
168
+ */
169
+ function resolveStrictSecretName(key, environment) {
170
+ const logicalKey = String(key ?? '').trim();
171
+ const env = String(environment ?? '').trim().toLowerCase();
172
+ if (!logicalKey || !env) {
173
+ return logicalKey;
174
+ }
175
+ const physical = computePhysicalSecretName(logicalKey, { environment: env });
176
+ if (physical.startsWith(`${env}-`)) {
177
+ return physical;
178
+ }
179
+ const entry = findCatalogEntry(logicalKey);
180
+ // Unplaced keys are this installation's own and are scoped by environment; `system`,
181
+ // `infra` and `shared` entries are platform-wide and keep their names.
182
+ if (!entry || entry.scope === 'app') {
183
+ return `${env}-${physical}`;
184
+ }
185
+ return physical;
186
+ }
187
+
188
+ module.exports = {
189
+ extractDatabaseIndex,
190
+ extractDatabaseAppKey,
191
+ applyVaultNamePattern,
192
+ computePhysicalSecretName,
193
+ resolveStrictSecretName
194
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @fileoverview Generate linked catalog RSA secrets without rotating existing identities.
3
+ */
4
+ const crypto = require('crypto');
5
+ const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
6
+
7
+ /** @param {*} value @returns {boolean} Whether a stored value is populated. */
8
+ function populated(value) {
9
+ return value !== undefined && value !== null && String(value).trim() !== '';
10
+ }
11
+
12
+ /**
13
+ * Read a stored key without exposing crypto errors or key material.
14
+ * @param {string} value
15
+ * @param {string} encryptionKey
16
+ * @param {string} pairId
17
+ * @returns {import('crypto').KeyObject}
18
+ */
19
+ function readPrivateKey(value, encryptionKey, pairId) {
20
+ try {
21
+ const pem = isEncrypted(value) ? decryptSecret(value, encryptionKey) : value;
22
+ const key = crypto.createPrivateKey(pem);
23
+ if (key.asymmetricKeyType !== 'rsa') throw new Error('Not RSA');
24
+ return key;
25
+ } catch {
26
+ throw new Error(`Cannot generate RSA pair "${pairId}": existing private key cannot be read as RSA. Restore a valid private key before retrying.`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Pairs whose private half belongs to Mori, not to an installation.
32
+ *
33
+ * Builder must never mint these. Licence trust is a two-signature chain, and both links
34
+ * exist so that substituting a key fails rather than silently succeeding:
35
+ *
36
+ * 1. Mori signs the licence. The installation verifies it against the public key held on
37
+ * disk, so a licence from anywhere else does not verify.
38
+ * 2. That on-disk material is itself validated against a key in Key Vault. The Key Vault is
39
+ * provisioned by the Marketplace deployment and readable only by Miso's managed
40
+ * identity — nobody can change it, including us — so swapping the on-disk public key for
41
+ * one whose private half an attacker holds fails at the second check.
42
+ *
43
+ * The second link is an **Azure** guarantee. A Docker installation has no Key Vault and no
44
+ * managed identity, so locally the chain is the pinned public key alone. Closing that is
45
+ * online validation against Mori, which is a known gap deferred on purpose: the delivery
46
+ * target is Azure, and Docker exists to validate the shape of that stack.
47
+ *
48
+ * It does not soften this rule. The one environment lacking the second check is the last
49
+ * one that should mint its own keys, and a locally generated pair would make local
50
+ * behaviour diverge from the Azure stack it exists to mirror.
51
+ *
52
+ * Generating the pair locally breaks both links at once: the installation would hold the
53
+ * private half of the key it verifies against, so it could sign a licence it then accepts,
54
+ * and the material would no longer correspond to what Key Vault validates. 227.1 pinned
55
+ * this key "precisely because a key travelling inside the token it verifies proves
56
+ * nothing"; a self-generated key proves exactly as little.
57
+ *
58
+ * The public half reaches an installation through the `moriLicensePublicKey` deployment
59
+ * parameter, which the installation contract already classifies as registration-supplied.
60
+ * An installation with neither half stays unpinned and refuses every licence, which is the
61
+ * documented and correct failure.
62
+ */
63
+ const ISSUER_SUPPLIED_PAIR_IDS = Object.freeze(['mori-license-signing', 'mori-policy-signing']);
64
+
65
+ /**
66
+ * @param {Object} entry - Catalog entry
67
+ * @returns {boolean} True when the pair is supplied by Mori rather than generated here
68
+ */
69
+ function isIssuerSuppliedPair(entry) {
70
+ return ISSUER_SUPPLIED_PAIR_IDS.includes(entry?.generator?.pairId);
71
+ }
72
+
73
+ /**
74
+ * Generate missing parts for one catalog pair, preserving existing key material.
75
+ * @param {Object[]} entries
76
+ * @param {Object} existing
77
+ * @param {string} encryptionKey
78
+ * @returns {Object} Missing values, including the linked counterpart when declared.
79
+ */
80
+ function generatePair(entries, existing, encryptionKey) {
81
+ const pairId = entries[0].generator.pairId;
82
+ const privateEntry = entries.find(e => e.generator.part === 'private');
83
+ const publicEntries = entries.filter(e => e.generator.part === 'public');
84
+ if (!privateEntry) throw new Error(`Cannot generate RSA pair "${pairId}": catalog has no private key entry.`);
85
+ const storedPrivate = existing[privateEntry.key];
86
+ const hasPublic = publicEntries.some(e => populated(existing[e.key]));
87
+ if (!populated(storedPrivate) && hasPublic) {
88
+ throw new Error(`Cannot generate RSA pair "${pairId}": public key exists but private key is missing. Restore the matching private key before retrying.`);
89
+ }
90
+ const privateKey = populated(storedPrivate)
91
+ ? readPrivateKey(storedPrivate, encryptionKey, pairId)
92
+ : crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
93
+ const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
94
+ const values = {};
95
+ if (!populated(storedPrivate)) {
96
+ values[privateEntry.key] = privateKey.export({ type: 'pkcs8', format: 'pem' });
97
+ }
98
+ for (const entry of publicEntries) {
99
+ if (!populated(existing[entry.key])) values[entry.key] = publicKey;
100
+ }
101
+ return values;
102
+ }
103
+
104
+ /**
105
+ * Prepare linked RSA values for a single write operation; never cache identities globally.
106
+ * @param {string[]} keys - Missing keys requested by the caller
107
+ * @param {Object} [existing] - Existing or explicitly supplied values (plain or encrypted)
108
+ * @param {string} [encryptionKey] - Local encryption key
109
+ * @returns {Object} Newly generated values only
110
+ */
111
+ function generateRsaSecretValues(keys, existing = {}, encryptionKey) {
112
+ let catalog;
113
+ try {
114
+ catalog = require('./infra-parameter-catalog').getInfraParameterCatalog();
115
+ } catch {
116
+ return {}; // Ordinary generation retains its existing catalog-unavailable handling.
117
+ }
118
+ const values = {};
119
+ for (const key of keys) {
120
+ if (populated(existing[key]) || populated(values[key])) continue;
121
+ const entry = catalog.findEntryForKey(key);
122
+ if (entry?.generator?.type !== 'rsaKeyPair') continue;
123
+ // Supplied by Mori at deployment and validated against Key Vault. Generating it here
124
+ // would hand the installation the private half of the key it verifies licences against.
125
+ if (isIssuerSuppliedPair(entry)) continue;
126
+ const entries = catalog.data.parameters.filter(e => e.key &&
127
+ e.generator?.type === 'rsaKeyPair' && e.generator.pairId === entry.generator.pairId);
128
+ Object.assign(values, generatePair(entries, { ...existing, ...values }, encryptionKey));
129
+ }
130
+ return values;
131
+ }
132
+
133
+ /**
134
+ * The rsaKeyPair branch, which is the only one that can refuse.
135
+ *
136
+ * Builder never mints a pair whose private half belongs to the issuer: an installation
137
+ * holding it could sign a licence it would then accept. Miso's catalog has since changed
138
+ * these three entries to `emptyAllowed`, so this is now a guard against the declaration
139
+ * coming back rather than a branch the shipped catalog reaches.
140
+ *
141
+ * @param {string} key - Secret key
142
+ * @param {Object} catalogEntry - Catalog entry
143
+ * @returns {string|undefined}
144
+ * @throws {Error} When the pair is issuer-supplied
145
+ */
146
+ function generateRsaValueFromCatalog(key, catalogEntry) {
147
+ if (isIssuerSuppliedPair(catalogEntry)) {
148
+ throw new Error(
149
+ `Secret "${key}" is supplied by Mori and is never generated here. Licence trust is a ` +
150
+ 'two-signature chain: Mori signs the licence, which is verified against the on-disk ' +
151
+ 'public key, and that material is validated against a Key Vault key readable only by ' +
152
+ 'Miso\'s managed identity. The public half reaches an installation through the ' +
153
+ 'moriLicensePublicKey deployment parameter. An installation with neither stays ' +
154
+ 'unpinned and refuses every licence, which is the intended failure.'
155
+ );
156
+ }
157
+ return generateRsaSecretValues([key])[key];
158
+ }
159
+
160
+ module.exports = {
161
+ generateRsaSecretValues,
162
+ generateRsaValueFromCatalog,
163
+ isIssuerSuppliedPair,
164
+ ISSUER_SUPPLIED_PAIR_IDS
165
+ };
@@ -45,6 +45,30 @@ function listChannelArtifacts(ctx) {
45
45
  return { artifacts };
46
46
  }
47
47
 
48
+ /**
49
+ * Locate the rendered setup document.
50
+ *
51
+ * `dist/channels/<target>/` is build output and is gitignored, so anything served only from
52
+ * there disappears on a clean, a rebuild, or in a fresh workspace — which is what made the
53
+ * portal report "Setup document not available" after an install that had succeeded. The
54
+ * durable copy lives beside the channel's other committed files under
55
+ * `integration/<systemKey>/`, so that is preferred and `dist/` is only a fallback for a
56
+ * package built but not yet persisted.
57
+ *
58
+ * @param {import('./workspace-context').WorkspaceContext} ctx - Workspace context
59
+ * @param {string} root - User root
60
+ * @param {string} target - Channel target / system key
61
+ * @returns {string} Absolute path, or '' when no copy exists
62
+ */
63
+ function resolveInstallGuidePath(ctx, root, target) {
64
+ const candidates = [];
65
+ if (typeof ctx.getIntegrationPath === 'function') {
66
+ candidates.push(path.join(ctx.getIntegrationPath(), 'INSTALL.md'));
67
+ }
68
+ candidates.push(path.join(path.dirname(getArtifactOutputPath(root, target)), 'INSTALL.md'));
69
+ return candidates.find(candidate => fs.existsSync(candidate)) || '';
70
+ }
71
+
48
72
  /**
49
73
  * @param {import('./workspace-context').WorkspaceContext} ctx
50
74
  * @param {string} artifactId
@@ -68,8 +92,8 @@ function resolveArtifactFile(ctx, artifactId) {
68
92
  }
69
93
  }
70
94
  if (id === 'installMd') {
71
- const absPath = path.join(path.dirname(getArtifactOutputPath(root, target)), 'INSTALL.md');
72
- if (fs.existsSync(absPath)) {
95
+ const absPath = resolveInstallGuidePath(ctx, root, target);
96
+ if (absPath) {
73
97
  return { absPath, contentType: 'text/markdown; charset=utf-8', fileName: 'INSTALL.md' };
74
98
  }
75
99
  }
@@ -131,6 +155,8 @@ async function handleChannelCallback(ctx, provider, query, body, deps = {}) {
131
155
 
132
156
  module.exports = {
133
157
  listChannelArtifacts,
158
+ resolveArtifactFile,
159
+ resolveInstallGuidePath,
134
160
  getChannelArtifact,
135
161
  handleChannelCallback,
136
162
  storeChannelCallbackCredential
@@ -119,7 +119,9 @@ function generatePackageArtifact(ctx, body, channelTarget, branding, dataplaneUr
119
119
  projectRoot: ctx.getUserRoot(),
120
120
  channelTarget,
121
121
  runtimeValues,
122
- dryRun: body.dryRun === true
122
+ dryRun: body.dryRun === true,
123
+ // Persist the setup document beside the channel's committed files; dist/ is disposable.
124
+ durableDir: workspaceSystemDir(ctx)
123
125
  });
124
126
  }
125
127
 
@@ -27,6 +27,38 @@ const WORKSPACE_INTEGRATION_FORMAT = 'json';
27
27
  * @property {string} bearerToken
28
28
  */
29
29
 
30
+ /**
31
+ * The root a given environment's work lives under.
32
+ *
33
+ * `tst` and `pro` get a path segment of their own so an environment's work is separable and
34
+ * can be deleted on its own; `dev` keeps the existing layout, so no in-flight workspace moves
35
+ * and nothing that already holds a path stops resolving.
36
+ *
37
+ * Because `dev` is unprefixed, a user id equal to a promotion-only environment name would
38
+ * make `<root>/pro` mean two things — that environment, or that user's dev work. `userId`
39
+ * comes from a token and only has to avoid traversal to pass validation, so the collision is
40
+ * refused here rather than assumed away.
41
+ *
42
+ * @param {string} workspaceRoot - Resolved WORKSPACE_ROOT
43
+ * @param {string} userId - Validated user id
44
+ * @param {string} [environment] - Environment key
45
+ * @returns {string} Root for this environment
46
+ * @throws {Error} When the user id would collide with an environment segment
47
+ */
48
+ function resolveEnvironmentWorkspaceRoot(workspaceRoot, userId, environment) {
49
+ const { isPromotionOnlyEnvironment } = require('../core/environment-mode-policy');
50
+ if (isPromotionOnlyEnvironment(userId)) {
51
+ throw new Error(
52
+ `userId "${userId}" collides with an environment workspace segment; it cannot be an ` +
53
+ 'environment name'
54
+ );
55
+ }
56
+ if (!isPromotionOnlyEnvironment(environment)) {
57
+ return workspaceRoot;
58
+ }
59
+ return path.join(workspaceRoot, String(environment).trim().toLowerCase());
60
+ }
61
+
30
62
  /**
31
63
  * @param {string} segment
32
64
  * @returns {string}
@@ -63,7 +95,9 @@ function createWorkspaceContext(params) {
63
95
  }
64
96
 
65
97
  const workspaceRoot = resolveWorkspaceRoot(params.workspaceRoot);
66
- const userRoot = path.join(workspaceRoot, userId);
98
+ const environment = params.environment || process.env.DEFAULT_ENVIRONMENT || 'dev';
99
+ const environmentRoot = resolveEnvironmentWorkspaceRoot(workspaceRoot, userId, environment);
100
+ const userRoot = path.join(environmentRoot, userId);
67
101
  const integrationRoot = path.join(userRoot, 'integration');
68
102
  const useExplicitIntegration = Boolean(params.integrationPath);
69
103
  const integrationPath = useExplicitIntegration
@@ -82,7 +116,7 @@ function createWorkspaceContext(params) {
82
116
  userId,
83
117
  workspaceRoot,
84
118
  systemKey,
85
- environment: params.environment || process.env.DEFAULT_ENVIRONMENT || 'dev',
119
+ environment,
86
120
  controllerUrl:
87
121
  params.controllerUrl ||
88
122
  process.env.MISO_CONTROLLER_URL ||
@@ -149,5 +183,6 @@ module.exports = {
149
183
  runWithWorkspaceContext,
150
184
  ensureIntegrationDirectory,
151
185
  resolveWorkspaceRoot,
186
+ resolveEnvironmentWorkspaceRoot,
152
187
  assertPathContained
153
188
  };