@aifabrix/builder 2.60.0 → 2.62.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.
- package/README.md +14 -11
- package/docs/README.md +80 -0
- package/docs/builder-help/evidence-patterns.json +155 -0
- package/docs/builder-help/golden-examples/crm-company.json +30 -0
- package/docs/builder-help/golden-examples/crm-deal.json +29 -0
- package/docs/builder-help/golden-examples/document-storage-keyed-get.json +85 -0
- package/docs/builder-help/golden-examples/document-storage.json +30 -0
- package/docs/builder-help/golden-examples/meeting-transcript.json +29 -0
- package/docs/builder-help/golden-examples/repository-template.json +29 -0
- package/docs/builder-help/golden-examples/service-ticket.json +29 -0
- package/docs/builder-help/platform-roles.json +98 -0
- package/docs/builder-help/resource-type-catalog.json +402 -0
- package/lib/api/applications.api.js +22 -2
- package/lib/api/bootstrap-snapshot.api.js +115 -0
- package/lib/api/configuration.api.js +131 -0
- package/lib/api/system-secrets.api.js +72 -0
- package/lib/app/deploy.js +14 -5
- package/lib/app/run-docker-fallback.js +6 -1
- package/lib/app/run-env-compose.js +5 -6
- package/lib/app/run-helpers.js +2 -1
- package/lib/app/run-parameter-sync.js +142 -0
- package/lib/app/run.js +6 -4
- package/lib/build/docker-build-args.js +5 -2
- package/lib/build/index.js +3 -2
- package/lib/build/standard-docker-build.js +6 -2
- package/lib/channels/channel-artifact-generator.js +13 -3
- package/lib/cli/infra-guided-footers.js +10 -8
- package/lib/cli/infra-guided.js +30 -10
- package/lib/cli/setup-app.js +5 -0
- package/lib/cli/setup-environment.js +156 -0
- package/lib/cli/setup-infra-up-platform-action.js +14 -1
- package/lib/cli/setup-platform.js +2 -0
- package/lib/commands/auth-config.js +2 -0
- package/lib/commands/datasource-capability-upsert-cli.js +112 -0
- package/lib/commands/datasource-capability.js +4 -2
- package/lib/commands/env-secret-context.js +113 -0
- package/lib/commands/env-secret-list.js +200 -0
- package/lib/commands/env-secret-push-confirm.js +54 -0
- package/lib/commands/env-secret-push-run.js +227 -0
- package/lib/commands/env-secret-push.js +168 -0
- package/lib/commands/login.js +4 -0
- package/lib/commands/repair-datasource-apply.js +5 -1
- package/lib/commands/repair-datasource-keyed-document.js +122 -0
- package/lib/commands/repair-datasource-legacy.js +94 -0
- package/lib/commands/repair-datasource-run.js +1 -0
- package/lib/commands/setup-image-refresh.js +26 -0
- package/lib/commands/setup-modes.js +8 -9
- package/lib/commands/setup-prompts-platform-mode.js +34 -0
- package/lib/commands/setup-prompts.js +2 -180
- package/lib/commands/setup.js +3 -0
- package/lib/commands/up-builder-api.js +24 -8
- package/lib/commands/up-common.js +16 -1
- package/lib/commands/up-dataplane-bootstrap.js +70 -14
- package/lib/commands/up-dataplane-credentials.js +21 -0
- package/lib/commands/up-dataplane.js +37 -18
- package/lib/commands/up-integration-server.js +112 -0
- package/lib/commands/verify-operations-skip-e2e.js +25 -1
- package/lib/commands/verify-operations-steps.js +13 -1
- package/lib/commands/wizard-config-normalizer.js +12 -5
- package/lib/commands/wizard-core.js +5 -157
- package/lib/commands/wizard-file-saving.js +163 -0
- package/lib/core/admin-secrets-env-overlay.js +8 -3
- package/lib/core/env-platform-expand.js +5 -1
- package/lib/core/environment-access-policy.js +121 -0
- package/lib/core/environment-mode-policy.js +177 -0
- package/lib/core/local-env-overrides.js +149 -0
- package/lib/core/secrets-ensure.js +4 -2
- package/lib/core/secrets-env-content.js +17 -10
- package/lib/core/secrets-env-write.js +11 -4
- package/lib/core/secrets-load.js +4 -2
- package/lib/datasource/binary-documents-validator.js +190 -0
- package/lib/datasource/capability/capability-manifest.js +1 -0
- package/lib/datasource/capability/run-capability-upsert.js +202 -0
- package/lib/datasource/capability/upsert-ingredients.js +291 -0
- package/lib/datasource/capability/upsert-operations.js +138 -0
- package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
- package/lib/datasource/validate.js +12 -5
- package/lib/deployment/installation/index.js +14 -1
- package/lib/deployment/installation/infra-catalog.js +3 -0
- package/lib/deployment/installation/local-environment-stage.js +285 -0
- package/lib/deployment/installation/local-installation.js +76 -0
- package/lib/deployment/installation/stage-input.js +5 -1
- package/lib/generator/builders.js +3 -1
- package/lib/generator/index.js +3 -0
- package/lib/generator/split-variables.js +2 -2
- package/lib/lifecycle/product-model.js +4 -3
- package/lib/lifecycle/report-display.js +3 -2
- package/lib/parameters/database-adoption.js +125 -0
- package/lib/parameters/infra-parameter-catalog.js +1 -1
- package/lib/parameters/physical-database-naming.js +136 -0
- package/lib/parameters/physical-secret-name.js +194 -0
- package/lib/parameters/rsa-secret-values.js +165 -0
- package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
- package/lib/programmatic/builder-help-governance.js +1 -1
- package/lib/programmatic/builder-help.js +1 -1
- package/lib/programmatic/run-channel-install-artifacts.js +28 -2
- package/lib/programmatic/run-channel-package.js +3 -1
- package/lib/programmatic/workspace-context.js +37 -2
- package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
- package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
- package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
- package/lib/role-assistant/test-runner-workhub.js +28 -19
- package/lib/schema/application-schema.json +14 -2
- package/lib/schema/external-datasource.schema.json +23 -3
- package/lib/schema/infra.parameter.yaml +440 -56
- package/lib/schema/infrastructure-schema.json +213 -120
- package/lib/utils/compose-generate-docker-compose.js +9 -14
- package/lib/utils/compose-generator.js +4 -7
- package/lib/utils/compose-miso-env.js +2 -2
- package/lib/utils/compose-traefik-ingress-base.js +3 -4
- package/lib/utils/datasource-binary-evidence.js +92 -0
- package/lib/utils/datasource-test-run-capability-scope.js +44 -1
- package/lib/utils/datasource-test-run-debug-display.js +2 -0
- package/lib/utils/datasource-test-run-display.js +8 -2
- package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
- package/lib/utils/datasource-test-run-tty-log.js +2 -0
- package/lib/utils/declarative-url-ports.js +3 -0
- package/lib/utils/env-copy.js +11 -10
- package/lib/utils/env-environment-file-paths.js +105 -0
- package/lib/utils/environment-scoped-resources.js +48 -14
- package/lib/utils/external-system-system-test-tty.js +3 -2
- package/lib/utils/image-tags.js +2 -2
- package/lib/utils/paths-system-builder-keys.js +7 -1
- package/lib/utils/paths.js +7 -2
- package/lib/utils/platform-kv-ref.js +1 -1
- package/lib/utils/postgres-platform-bootstrap.js +35 -1
- package/lib/utils/prepare-local-data-mount.js +58 -0
- package/lib/utils/redis-env-scope.js +21 -1
- package/lib/utils/registry-auth-sources.js +154 -0
- package/lib/utils/registry-credentials.js +131 -14
- package/lib/utils/secrets-generator.js +30 -21
- package/lib/utils/secrets-helpers.js +5 -8
- package/lib/utils/secrets-kv-scope.js +127 -28
- package/lib/utils/secrets-materialize-local.js +9 -0
- package/lib/utils/secrets-missing-error.js +35 -1
- package/lib/utils/system-secret-mapping.js +125 -0
- package/lib/utils/test-log-writer.js +2 -1
- package/lib/utils/token-manager.js +14 -8
- package/lib/utils/url-public-path-prefix.js +6 -10
- package/lib/validation/external-manifest-validator.js +5 -0
- package/lib/validation/openapi-contract-surface-validator.js +3 -1
- package/lib/validation/validate-external-file.js +5 -1
- package/package.json +6 -4
- package/templates/agent-kit/agent-kit.yaml +1 -1
- package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
- package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
- package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -2
- package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +6 -2
- package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
- package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +7 -1
- package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
- package/templates/agent-kit/skills/shared/interaction.md +83 -0
- package/templates/agent-kit/skills/shared/status.md +3 -12
- package/templates/applications/builder-api/application.yaml +1 -1
- package/templates/applications/builder-api/env.template +5 -1
- package/templates/applications/dataplane/application.yaml +2 -2
- package/templates/applications/dataplane/env.template +10 -12
- package/templates/applications/integration-server/application.yaml +91 -0
- package/templates/applications/integration-server/env.template +215 -0
- package/templates/applications/keycloak/application.yaml +6 -1
- package/templates/applications/mirrored-templates.json +52 -0
- package/templates/applications/miso-controller/application.yaml +116 -2
- package/templates/applications/miso-controller/env.template +62 -17
- package/templates/applications/miso-controller/rbac.yaml +17 -0
- package/templates/external-system/external-datasource.yaml.hbs +7 -1
- package/templates/marketplace/createUiDefinition.json +52 -4
- package/templates/marketplace/main.json +88 -10
- package/templates/python/docker-compose.hbs +1 -1
- package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -41
- /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/workflow.md +0 -0
|
@@ -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
|
+
};
|
|
@@ -10,7 +10,7 @@ const fs = require('fs');
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { buildHelpEnvelope } = require('./builder-help-envelope');
|
|
12
12
|
|
|
13
|
-
const HELP_MD_DIR = path.join(__dirname, 'help
|
|
13
|
+
const HELP_MD_DIR = path.join(__dirname, '..', '..', 'docs', 'builder-help', 'content');
|
|
14
14
|
|
|
15
15
|
/** @type {ReadonlyArray<string>} */
|
|
16
16
|
const REPAIR_COMMANDS = Object.freeze([
|
|
@@ -15,7 +15,7 @@ const { listPlatformRoles } = require('./platform-rbac-catalog');
|
|
|
15
15
|
const { CHANNEL_TARGET_KEYS, CHANNEL_TARGETS } = require('../channels/channel-targets');
|
|
16
16
|
|
|
17
17
|
const BUILDER_HELP_DIR = path.join(__dirname, '..', '..', 'docs', 'builder-help');
|
|
18
|
-
const HELP_MD_DIR = path.join(
|
|
18
|
+
const HELP_MD_DIR = path.join(BUILDER_HELP_DIR, 'content');
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* @param {string} fileName
|
|
@@ -65,7 +65,7 @@ const PROSE_TOPIC_FILES = Object.freeze({
|
|
|
65
65
|
enterpriseSyncFabrix: 'enterprise-sync-fabrix.md'
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
const HELP_MD_DIR = path.join(__dirname, 'help
|
|
68
|
+
const HELP_MD_DIR = path.join(__dirname, '..', '..', 'docs', 'builder-help', 'content');
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* @param {string} message
|
|
@@ -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 =
|
|
72
|
-
if (
|
|
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
|
|
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
|
|
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
|
};
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
+
const { coversMissingFields } = require('./test-runner-workhub-missing-fields');
|
|
9
|
+
|
|
8
10
|
const {
|
|
9
11
|
resolveTargetSystemKey,
|
|
10
12
|
shouldStampSuiteBid,
|
|
@@ -409,7 +411,8 @@ function resolveRespondValue(testCase, wait, statusRow, objective) {
|
|
|
409
411
|
if (fromCase !== undefined) {
|
|
410
412
|
return fromCase;
|
|
411
413
|
}
|
|
412
|
-
|
|
414
|
+
const synthesized = synthesizeWaitAnswer(wait, statusRow, objective, testCase);
|
|
415
|
+
return coversMissingFields(wait, synthesized) ? synthesized : undefined;
|
|
413
416
|
}
|
|
414
417
|
|
|
415
418
|
function waitRespondFingerprint(wait) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Missing-field coverage for synthesized Work Hub answers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
function asPlainObject(value) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fields the runtime named as missing (missingRequestBody waits). A synthesized
|
|
16
|
+
* answer that lacks any of them only makes the runtime ask the same question
|
|
17
|
+
* again, so it must not be sent.
|
|
18
|
+
* @param {Object} wait
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
*/
|
|
21
|
+
function waitMissingFields(wait) {
|
|
22
|
+
const raw = wait?.objectRef && typeof wait.objectRef === 'object' ? wait.objectRef.missingFields : null;
|
|
23
|
+
const list = Array.isArray(raw) ? raw : String(raw || '').split(',');
|
|
24
|
+
return list.map((row) => String(row || '').trim()).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function coversMissingFields(wait, value) {
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const missing = waitMissingFields(wait);
|
|
32
|
+
if (missing.length === 0) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const obj = asPlainObject(value);
|
|
36
|
+
if (!obj) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return missing.every((field) => obj[field] !== undefined && obj[field] !== null && obj[field] !== '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { coversMissingFields, waitMissingFields };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Work Hub poll stop rules for waits the runner cannot resolve.
|
|
3
|
+
*
|
|
4
|
+
* A wait with no matching case answer, a question the runtime re-issues
|
|
5
|
+
* unchanged after our answer, or a failed respond call never resolves by
|
|
6
|
+
* polling. Stopping at once keeps a stalled case at seconds, not the 180 s
|
|
7
|
+
* poll timeout (dev01 sales ladder, 2026-09-26: six cases, 18 min waiting).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const UNANSWERED_WAIT_TYPES = new Set(['respond-skipped', 'respond-repeated', 'respond-error']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The runtime asked the same question again after our answer: the answer did
|
|
16
|
+
* not resolve it, and repeating it cannot. Record once so the poll can stop.
|
|
17
|
+
*/
|
|
18
|
+
function recordRepeatedResponse(interactions, peId, fingerprint, wait) {
|
|
19
|
+
const already = interactions.some(
|
|
20
|
+
(row) => row && row.type === 'respond-repeated' && row.fingerprint === fingerprint
|
|
21
|
+
);
|
|
22
|
+
if (already) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
interactions.push({
|
|
26
|
+
type: 'respond-repeated',
|
|
27
|
+
processExecutionId: peId,
|
|
28
|
+
fingerprint,
|
|
29
|
+
questionKey: wait.questionKey || wait.requestKey || null,
|
|
30
|
+
objectRefKind: wait.objectRef?.kind || null,
|
|
31
|
+
reason: 'same-question-after-answer'
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function recordRespondError(interactions, peId, error) {
|
|
36
|
+
interactions.push({
|
|
37
|
+
type: 'respond-error',
|
|
38
|
+
processExecutionId: peId,
|
|
39
|
+
error: (error && error.message) || String(error)
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A wait we could not answer (no matching case answer, the runtime re-asked
|
|
45
|
+
* after our answer, or respond failed) will not change by polling: stop now
|
|
46
|
+
* instead of waiting for the poll timeout.
|
|
47
|
+
* @param {Array<Object>} interactions
|
|
48
|
+
* @returns {string|null} stop reason or null to keep polling
|
|
49
|
+
*/
|
|
50
|
+
function unansweredWaitStopReason(interactions) {
|
|
51
|
+
const last = interactions.length > 0 ? interactions[interactions.length - 1] : null;
|
|
52
|
+
if (!last || !UNANSWERED_WAIT_TYPES.has(last.type)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return last.type === 'respond-error' ? 'respondFailed' : 'unansweredQuestion';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function recordSkippedResponse(interactions, peId, fingerprint, wait) {
|
|
59
|
+
interactions.push({
|
|
60
|
+
type: 'respond-skipped',
|
|
61
|
+
processExecutionId: peId,
|
|
62
|
+
fingerprint,
|
|
63
|
+
questionKey: wait.questionKey || wait.requestKey || null,
|
|
64
|
+
objectRefKind: wait.objectRef?.kind || null,
|
|
65
|
+
reason: 'no-matching-answer-or-synthesis'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ALREADY_TERMINAL_RE = /already terminal/i;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* work.respond resumes the Decision Engine inline; by the time work.continue
|
|
73
|
+
* lands the execution may already be terminal. That is a finished run, not a
|
|
74
|
+
* failed answer.
|
|
75
|
+
*/
|
|
76
|
+
async function continueAfterRespond(dataplaneUrl, authConfig, peId) {
|
|
77
|
+
const { continueWorkExecution } = require('../api/work.api');
|
|
78
|
+
try {
|
|
79
|
+
await continueWorkExecution(dataplaneUrl, authConfig, peId, {});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (!ALREADY_TERMINAL_RE.test((err && err.message) || String(err))) {
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
continueAfterRespond,
|
|
89
|
+
UNANSWERED_WAIT_TYPES,
|
|
90
|
+
recordSkippedResponse,
|
|
91
|
+
recordRepeatedResponse,
|
|
92
|
+
recordRespondError,
|
|
93
|
+
unansweredWaitStopReason
|
|
94
|
+
};
|
|
@@ -13,8 +13,7 @@ const {
|
|
|
13
13
|
getWorkTask,
|
|
14
14
|
approveWorkTask,
|
|
15
15
|
getWorkExecutionStatus,
|
|
16
|
-
respondWorkExecution
|
|
17
|
-
continueWorkExecution
|
|
16
|
+
respondWorkExecution
|
|
18
17
|
} = require('../api/work.api');
|
|
19
18
|
const {
|
|
20
19
|
synthesizeWaitAnswer,
|
|
@@ -29,6 +28,13 @@ const {
|
|
|
29
28
|
persistCaseOpenApiViaBuilderApi
|
|
30
29
|
} = require('./test-runner-bid-apply');
|
|
31
30
|
const { resolveTargetSystemKey } = require('./test-runner-bid-document');
|
|
31
|
+
const {
|
|
32
|
+
recordSkippedResponse,
|
|
33
|
+
recordRepeatedResponse,
|
|
34
|
+
recordRespondError,
|
|
35
|
+
unansweredWaitStopReason,
|
|
36
|
+
continueAfterRespond
|
|
37
|
+
} = require('./test-runner-workhub-wait-stop');
|
|
32
38
|
const {
|
|
33
39
|
TERMINAL_TASK,
|
|
34
40
|
delayMs,
|
|
@@ -178,17 +184,6 @@ async function maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx) {
|
|
|
178
184
|
await _attemptApproveTask(dataplaneUrl, authConfig, taskId, ctx);
|
|
179
185
|
}
|
|
180
186
|
|
|
181
|
-
function recordSkippedResponse(interactions, peId, fingerprint, wait) {
|
|
182
|
-
interactions.push({
|
|
183
|
-
type: 'respond-skipped',
|
|
184
|
-
processExecutionId: peId,
|
|
185
|
-
fingerprint,
|
|
186
|
-
questionKey: wait.questionKey || wait.requestKey || null,
|
|
187
|
-
objectRefKind: wait.objectRef?.kind || null,
|
|
188
|
-
reason: 'no-matching-answer-or-synthesis'
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
187
|
function recordBidEmit(testCase, interactions, peId) {
|
|
193
188
|
const { loadCaseBidDocument } = require('./test-runner-workhub-answers');
|
|
194
189
|
const emittedBid = loadCaseBidDocument(testCase);
|
|
@@ -237,6 +232,7 @@ async function respondToWaitingExecution(
|
|
|
237
232
|
const wait = statusRow.wait && typeof statusRow.wait === 'object' ? statusRow.wait : {};
|
|
238
233
|
const fingerprint = waitRespondFingerprint(wait);
|
|
239
234
|
if (alreadyResponded(interactions, fingerprint)) {
|
|
235
|
+
recordRepeatedResponse(interactions, peId, fingerprint, wait);
|
|
240
236
|
return;
|
|
241
237
|
}
|
|
242
238
|
const payload = buildRespondPayload(
|
|
@@ -260,7 +256,7 @@ async function respondToWaitingExecution(
|
|
|
260
256
|
}
|
|
261
257
|
}
|
|
262
258
|
await respondWorkExecution(dataplaneUrl, authConfig, peId, payload);
|
|
263
|
-
await
|
|
259
|
+
await continueAfterRespond(dataplaneUrl, authConfig, peId);
|
|
264
260
|
interactions.push({ type: 'respond', processExecutionId: peId, fingerprint });
|
|
265
261
|
if (shouldApplyBidAfterWait(testCase, wait)) {
|
|
266
262
|
recordBidEmit(testCase, interactions, peId);
|
|
@@ -291,8 +287,9 @@ async function maybeRespondWaiting(dataplaneUrl, authConfig, taskRow, testCase,
|
|
|
291
287
|
interactions,
|
|
292
288
|
statusRow
|
|
293
289
|
);
|
|
294
|
-
} catch (
|
|
295
|
-
//
|
|
290
|
+
} catch (err) {
|
|
291
|
+
// A failed respond/continue never resolves itself; record it so the poll stops.
|
|
292
|
+
recordRespondError(interactions, peId, err);
|
|
296
293
|
}
|
|
297
294
|
}
|
|
298
295
|
|
|
@@ -301,7 +298,13 @@ function assertTerminalWorkHubTask(last, taskId, timeoutMs, options = {}) {
|
|
|
301
298
|
throw new Error(`Work Hub task ${taskId} produced no status rows.`);
|
|
302
299
|
}
|
|
303
300
|
const finalStatusToken = String(last?.status || last?.taskStatus || '').trim().toLowerCase();
|
|
304
|
-
if (
|
|
301
|
+
if (TERMINAL_TASK.has(finalStatusToken)) {
|
|
302
|
+
return last;
|
|
303
|
+
}
|
|
304
|
+
if (options.stoppedReason) {
|
|
305
|
+
return { ...last, pollStopped: options.stoppedReason, lastStatus: finalStatusToken || 'unknown' };
|
|
306
|
+
}
|
|
307
|
+
if (options.recordTimeout === true) {
|
|
305
308
|
last = {
|
|
306
309
|
...last,
|
|
307
310
|
pollTimedOut: true,
|
|
@@ -353,14 +356,18 @@ async function pollWorkHubTask(dataplaneUrl, authConfig, taskId, testCase, optio
|
|
|
353
356
|
applyWorkHubPollStatus(ctx, last, status);
|
|
354
357
|
await maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx);
|
|
355
358
|
await maybeRespondWaiting(dataplaneUrl, authConfig, last, testCase, ctx.interactions);
|
|
356
|
-
|
|
359
|
+
ctx.stoppedReason = unansweredWaitStopReason(ctx.interactions);
|
|
360
|
+
if (ctx.stoppedReason || shouldStopPendingApprovalPoll(status, ctx)) {
|
|
357
361
|
break;
|
|
358
362
|
}
|
|
359
363
|
await delayMs(intervalMs);
|
|
360
364
|
}
|
|
361
365
|
|
|
362
366
|
return {
|
|
363
|
-
finalTask: assertTerminalWorkHubTask(last, taskId, timeoutMs, {
|
|
367
|
+
finalTask: assertTerminalWorkHubTask(last, taskId, timeoutMs, {
|
|
368
|
+
recordTimeout: !ctx.stoppedReason,
|
|
369
|
+
stoppedReason: ctx.stoppedReason || null
|
|
370
|
+
}),
|
|
364
371
|
timeline,
|
|
365
372
|
interactions: ctx.interactions
|
|
366
373
|
};
|
|
@@ -478,6 +485,8 @@ async function runWorkHubCaseLifecycle(roleKey, testCase, runtimeContext, option
|
|
|
478
485
|
module.exports = {
|
|
479
486
|
wantsWorkHubPath,
|
|
480
487
|
runWorkHubCaseLifecycle,
|
|
488
|
+
pollWorkHubTask,
|
|
489
|
+
unansweredWaitStopReason,
|
|
481
490
|
planAlreadyActivated,
|
|
482
491
|
isPlanLostError,
|
|
483
492
|
extractPeFromTask,
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"key": "application-schema",
|
|
8
8
|
"name": "Application Configuration Schema",
|
|
9
9
|
"description": "JSON schema for validating AI Fabrix application configuration files",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.10.0",
|
|
11
11
|
"type": "schema",
|
|
12
12
|
"category": "infrastructure",
|
|
13
13
|
"author": "AI Fabrix Team",
|
|
14
14
|
"createdAt": "2024-01-01T00:00:00Z",
|
|
15
|
-
"updatedAt": "2026-09-
|
|
15
|
+
"updatedAt": "2026-09-25T00:00:00Z",
|
|
16
16
|
"compatibility": {
|
|
17
17
|
"minVersion": "1.0.0",
|
|
18
18
|
"maxVersion": "2.0.0",
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
"external-datasource.schema.json"
|
|
31
31
|
],
|
|
32
32
|
"changelog": [
|
|
33
|
+
{
|
|
34
|
+
"version": "1.10.0",
|
|
35
|
+
"date": "2026-09-25T00:00:00Z",
|
|
36
|
+
"changes": [
|
|
37
|
+
"Added optional top-level webSocketsEnabled: request Azure App Service WebSocket support for applications whose manifest opts in. Absent or false preserves existing deployment behavior."
|
|
38
|
+
],
|
|
39
|
+
"breaking": false
|
|
40
|
+
},
|
|
33
41
|
{
|
|
34
42
|
"version": "1.9.0",
|
|
35
43
|
"date": "2026-09-23T00:00:00Z",
|
|
@@ -231,6 +239,10 @@
|
|
|
231
239
|
"type": "boolean",
|
|
232
240
|
"description": "Whether application requires storage.Physical storage is mapped to /mnt/data/. Blob storage is mapped to SMB File share /data/{app-key}/."
|
|
233
241
|
},
|
|
242
|
+
"webSocketsEnabled": {
|
|
243
|
+
"type": "boolean",
|
|
244
|
+
"description": "Whether the hosting provider must enable WebSocket upgrades for this application. Optional and false by default."
|
|
245
|
+
},
|
|
234
246
|
"platforms": {
|
|
235
247
|
"type": "array",
|
|
236
248
|
"description": "Platform resolution: the target Azure resource group is matched against these entries, yielding {platform} and {platform.name}. Optional and independent of environments.",
|