@orchestraworks/cli 0.5.7 → 0.6.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 (94) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/api/approval.js +3 -3
  3. package/dist/cli/commands/outpost/local.js +3 -3
  4. package/dist/cli/commands/outpost/machine-names.d.ts +17 -0
  5. package/dist/cli/commands/outpost/machine-names.js +22 -0
  6. package/dist/cli/commands/outpost/up.js +7 -5
  7. package/dist/cli/commands/worker/enroll.d.ts +4 -3
  8. package/dist/cli/commands/worker/enroll.js +4 -3
  9. package/dist/cli/core/auth.d.ts +22 -2
  10. package/dist/cli/core/auth.js +69 -8
  11. package/dist/cli/core/errors.js +4 -0
  12. package/dist/cli/core/platform-client.d.ts +3 -2
  13. package/dist/cli/core/session-body.d.ts +1 -1
  14. package/dist/cli/core/session-body.js +1 -1
  15. package/dist/cli/firstRun.d.ts +3 -3
  16. package/dist/cli/firstRun.js +7 -5
  17. package/dist/cli/main.js +4 -3
  18. package/dist/constructs/environment.d.ts +24 -11
  19. package/dist/constructs/environment.js +76 -12
  20. package/dist/constructs/secret.d.ts +17 -7
  21. package/dist/constructs/secret.js +24 -16
  22. package/dist/core/labels.d.ts +32 -6
  23. package/dist/core/labels.js +70 -8
  24. package/dist/engine/diff.js +38 -11
  25. package/dist/engine/gateway-key-reconcile.d.ts +1 -2
  26. package/dist/engine/gateway-key-reconcile.js +19 -15
  27. package/dist/engine/pipeline-reconcile.js +2 -2
  28. package/dist/engine/pipeline-workflow-reconcile.js +15 -9
  29. package/dist/engine/reconcile.js +48 -15
  30. package/dist/engine/refusals.d.ts +0 -5
  31. package/dist/engine/refusals.js +1 -5
  32. package/dist/engine/secrets-apply.d.ts +10 -14
  33. package/dist/engine/secrets-apply.js +12 -17
  34. package/dist/engine/workflow-reconcile.d.ts +1 -1
  35. package/dist/generated/cli-commands.json +170 -34
  36. package/dist/generated/providers.d.ts +40 -0
  37. package/dist/generated/providers.js +30 -0
  38. package/docs/REFERENCE.md +26 -12
  39. package/node_modules/@orchestraworks/client/dist/esm/models/ApiErrorDetail.d.ts +1 -1
  40. package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.d.ts +7 -0
  41. package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.js +3 -0
  42. package/node_modules/@orchestraworks/client/dist/esm/models/CreateSecretRequest.d.ts +4 -3
  43. package/node_modules/@orchestraworks/client/dist/esm/models/Environment.d.ts +7 -0
  44. package/node_modules/@orchestraworks/client/dist/esm/models/Environment.js +3 -0
  45. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.d.ts +38 -0
  46. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.js +45 -0
  47. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpec.d.ts +1 -1
  48. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
  49. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentSnapshot.d.ts +1 -1
  50. package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.d.ts +6 -0
  51. package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.js +2 -0
  52. package/node_modules/@orchestraworks/client/dist/esm/models/OAuthTokenRequest.d.ts +1 -1
  53. package/node_modules/@orchestraworks/client/dist/esm/models/Secret.d.ts +1 -7
  54. package/node_modules/@orchestraworks/client/dist/esm/models/Secret.js +0 -4
  55. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.d.ts +7 -0
  56. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.js +3 -0
  57. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateSecretRequest.d.ts +4 -3
  58. package/node_modules/@orchestraworks/client/dist/esm/models/index.d.ts +1 -0
  59. package/node_modules/@orchestraworks/client/dist/esm/models/index.js +1 -0
  60. package/node_modules/@orchestraworks/client/dist/models/ApiErrorDetail.d.ts +1 -1
  61. package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.d.ts +7 -0
  62. package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.js +3 -0
  63. package/node_modules/@orchestraworks/client/dist/models/CreateSecretRequest.d.ts +4 -3
  64. package/node_modules/@orchestraworks/client/dist/models/Environment.d.ts +7 -0
  65. package/node_modules/@orchestraworks/client/dist/models/Environment.js +3 -0
  66. package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.d.ts +38 -0
  67. package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.js +52 -0
  68. package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpec.d.ts +1 -1
  69. package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
  70. package/node_modules/@orchestraworks/client/dist/models/EnvironmentSnapshot.d.ts +1 -1
  71. package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.d.ts +6 -0
  72. package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.js +2 -0
  73. package/node_modules/@orchestraworks/client/dist/models/OAuthTokenRequest.d.ts +1 -1
  74. package/node_modules/@orchestraworks/client/dist/models/Secret.d.ts +1 -7
  75. package/node_modules/@orchestraworks/client/dist/models/Secret.js +0 -4
  76. package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.d.ts +7 -0
  77. package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.js +3 -0
  78. package/node_modules/@orchestraworks/client/dist/models/UpdateSecretRequest.d.ts +4 -3
  79. package/node_modules/@orchestraworks/client/dist/models/index.d.ts +1 -0
  80. package/node_modules/@orchestraworks/client/dist/models/index.js +1 -0
  81. package/node_modules/@orchestraworks/client/package.json +1 -1
  82. package/package.json +4 -4
  83. package/skills/orchestra/SKILL.md +38 -17
  84. package/skills/orchestra-fdk/references/authoring.md +34 -26
  85. package/skills/orchestra-fdk/references/environment.md +49 -12
  86. package/skills/orchestra-fdk/references/integrations/code-repo.md +26 -5
  87. package/skills/orchestra-fdk/references/phases/1-discover.md +1 -1
  88. package/skills/orchestra-fdk/references/phases/2-plan.md +1 -1
  89. package/skills/orchestra-fdk/references/troubleshooting.md +5 -3
  90. package/templates/bug-fix/main.ts +7 -4
  91. package/templates/engineering-team/lib/environment.ts +6 -3
  92. package/templates/engineering-team/package.json +1 -1
  93. package/templates/todo-factory/main.ts +7 -4
  94. package/templates/todo-factory/package.json +1 -1
@@ -130,10 +130,30 @@ function normalizePaths(ruleName, paths) {
130
130
  }
131
131
  return [...paths];
132
132
  }
133
+ /**
134
+ * The variables {@link EgressPolicy.routeModelsViaGateway} sets to the key Secret's reference: claude sends the
135
+ * first as its key, and the worker writes the second into codex's own auth file.
136
+ */
137
+ const GATEWAY_KEY_ENV = ["ANTHROPIC_API_KEY", "ORCHESTRA_WORKER_OPENAI_API_KEY"];
138
+ /** The edge reads this prefix where a request's token goes, and adds the credential the rest names. */
139
+ const REFERENCE_PREFIX = "orchestra:";
140
+ /**
141
+ * A Secret or a Connection as the platform names it, `secret/<id>` or `connection/<id>`, the id a reference
142
+ * token or the import as written until apply resolves it. Undefined for anything else.
143
+ */
144
+ function credentialReference(value) {
145
+ if (typeof value !== "object" || value === null)
146
+ return undefined;
147
+ if ("connectionId" in value && typeof value.connectionId === "string")
148
+ return `connection/${value.connectionId}`;
149
+ if ("secretId" in value && typeof value.secretId === "string")
150
+ return `secret/${value.secretId}`;
151
+ return undefined;
152
+ }
133
153
  /**
134
154
  * A rule that still names a credential is an authored statement that can no longer apply, so it fails here
135
- * with the Secret declaration that replaces it: the rule's hosts become the Secret's `hosts`, and the binder's
136
- * form becomes its `injection`.
155
+ * with the Secret declaration that replaces it: the binder's form becomes the Secret's `injection`, and the rule's
156
+ * hosts its `injection.hosts`.
137
157
  */
138
158
  function refuseRuleCredential(ruleName, options, hosts) {
139
159
  const declared = options.credentials !== undefined
@@ -148,7 +168,7 @@ function refuseRuleCredential(ruleName, options, hosts) {
148
168
  const remedies = declared.map((entry) => secretRemedy(entry, hosts));
149
169
  throw new Error(`egress rule '${ruleName}': a rule carries no credential, so 'credential'/'credentials' is not accepted. ` +
150
170
  `A credential declares where and how it is presented on its own resource: ` +
151
- `${remedies.length > 0 ? remedies.join("; ") : "give the Secret 'hosts' and an 'injection' recipe"}. ` +
171
+ `${remedies.length > 0 ? remedies.join("; ") : "give the Secret an 'injection' recipe with its 'hosts'"}. ` +
152
172
  `Then keep this rule as a network rule, with no credential.`);
153
173
  }
154
174
  /** The Secret declaration one binder stands for, with no value: a recipe and host names only. */
@@ -157,7 +177,7 @@ function secretRemedy(entry, hosts) {
157
177
  ? entry._credentialBinding()
158
178
  : undefined;
159
179
  if (binding === undefined)
160
- return "give the Secret 'hosts' and an 'injection' recipe";
180
+ return "give the Secret an 'injection' recipe with its 'hosts'";
161
181
  const name = isRefToken(binding.secretId)
162
182
  ? `Secret '${parseRefToken(binding.secretId).path.join("/")}'`
163
183
  : `secret '${binding.secretId}'`;
@@ -166,7 +186,7 @@ function secretRemedy(entry, hosts) {
166
186
  : binding.header !== undefined
167
187
  ? JSON.stringify({ headers: [{ name: binding.header, value: binding.valueTemplate }] })
168
188
  : "its own injection recipe";
169
- return `${name} takes hosts: ${JSON.stringify(hosts)} and injection: ${injection}`;
189
+ return `${name} takes injection: ${injection}, with hosts: ${JSON.stringify(hosts)} inside it`;
170
190
  }
171
191
  /**
172
192
  * **The builder is mutating, and every chaining method returns `this`, not a copy.** `denyAll()` or
@@ -181,6 +201,7 @@ function secretRemedy(entry, hosts) {
181
201
  export class EgressPolicy {
182
202
  defaultAction;
183
203
  rules = [];
204
+ gatewayKey;
184
205
  constructor(defaultAction) {
185
206
  this.defaultAction = defaultAction;
186
207
  }
@@ -300,18 +321,27 @@ export class EgressPolicy {
300
321
  }
301
322
  if (key instanceof Secret) {
302
323
  const remedy = `Declare it as new Secret(scope, id, { value: SecretValue.fromGatewayKey(gatewayKey), ` +
303
- `hosts: ["${host}"], injection: { headers: [{ name: "Authorization", value: "Bearer \${secret}" }], ` +
304
- `removeHeaders: ["x-api-key"] } }).`;
324
+ `injection: { headers: [{ name: "Authorization", value: "Bearer \${secret}" }], ` +
325
+ `removeHeaders: ["x-api-key"], hosts: ["${host}"] } }).`;
305
326
  if (!key._presentsHeader()) {
306
327
  throw new Error(`routeModelsViaGateway(): key Secret '${key.node.path.join("/")}' has no 'injection' header, so the ` +
307
328
  `gateway key is never presented. ${remedy}`);
308
329
  }
309
330
  if (!key._hosts().some((pattern) => hostPatternCovers(pattern, host))) {
310
331
  throw new Error(`routeModelsViaGateway(): key Secret '${key.node.path.join("/")}' does not cover '${host}' in its ` +
311
- `'hosts' ${JSON.stringify(key._hosts())}, so model calls would leave with no key. ${remedy}`);
332
+ `'injection.hosts' ${JSON.stringify(key._hosts())}, so model calls would leave with no key. ${remedy}`);
312
333
  }
313
334
  }
314
- return this.allow(DEFAULT_MODEL_GATEWAY_RULE_NAME, { hosts: [host], priority: DEFAULT_MODEL_GATEWAY_PRIORITY });
335
+ this.allow(DEFAULT_MODEL_GATEWAY_RULE_NAME, { hosts: [host], priority: DEFAULT_MODEL_GATEWAY_PRIORITY });
336
+ this.gatewayKey = key;
337
+ return this;
338
+ }
339
+ /** @internal The variables this policy names a credential in, each set to its reference. */
340
+ _declaredEnv() {
341
+ if (this.gatewayKey === undefined)
342
+ return {};
343
+ const reference = REFERENCE_PREFIX + credentialReference(this.gatewayKey);
344
+ return Object.fromEntries(GATEWAY_KEY_ENV.map((name) => [name, reference]));
315
345
  }
316
346
  /**
317
347
  * Each entry lands as an ordinary allow rule — the same wire shape, in the same `rules` array, in the same
@@ -403,6 +433,16 @@ export class Environment extends Construct {
403
433
  constructor(scope, id, props) {
404
434
  super(scope, id);
405
435
  validateExplicitSlug(this, props.slug, "environment");
436
+ for (const [name, value] of Object.entries(props.env ?? {})) {
437
+ if (typeof value !== "string" && credentialReference(value) === undefined) {
438
+ throw new Error(`Environment '${id}': env '${name}' must be text, a Secret or a Connection.`);
439
+ }
440
+ }
441
+ props.credentials?.forEach((entry, index) => {
442
+ if (credentialReference(entry?.credential) === undefined) {
443
+ throw new Error(`Environment '${id}': credentials[${index}]: 'credential' must be a Secret or a Connection.`);
444
+ }
445
+ });
406
446
  const declaredOnStartMachine = props.hooks?.onStartMachine?.script;
407
447
  if (props.setup !== undefined && declaredOnStartMachine !== undefined && props.setup !== declaredOnStartMachine) {
408
448
  throw new Error(`Environment '${id}' declares both setup and hooks.onStartMachine with different scripts. ` +
@@ -435,6 +475,9 @@ export class Environment extends Construct {
435
475
  // Copied so a key added after the construct accepted the literal cannot ride a later synth into a
436
476
  // machine's env.
437
477
  ...(props.env !== undefined ? { env: { ...props.env } } : {}),
478
+ ...(props.credentials !== undefined
479
+ ? { credentials: props.credentials.map((entry) => ({ ...entry })) }
480
+ : {}),
438
481
  };
439
482
  this.explicitSlug = props.slug;
440
483
  this.displayName = props.name ?? id;
@@ -464,6 +507,19 @@ export class Environment extends Construct {
464
507
  const resources = this.props.resources ?? {};
465
508
  const cpu = resources.cpu ?? STANDARD_CPU;
466
509
  const onStartMachine = this.props.hooks?.onStartMachine ?? (this.props.setup ? { script: this.props.setup } : undefined);
510
+ // A FRESH object each synth: emitting the stored map by reference would let a caller mutating one returned
511
+ // plan's `payload.env` change every later synth.
512
+ const env = {};
513
+ for (const [name, value] of Object.entries(this.props.env ?? {})) {
514
+ env[name] = typeof value === "string" ? value : REFERENCE_PREFIX + credentialReference(value);
515
+ }
516
+ for (const [name, reference] of Object.entries(this.props.egress._declaredEnv())) {
517
+ if (env[name] !== undefined && env[name] !== reference) {
518
+ throw new Error(`Environment '${this.node.id}': env '${name}' is declared by routeModelsViaGateway() as the gateway ` +
519
+ `key's reference, ${reference}. Drop it from env.`);
520
+ }
521
+ env[name] = reference;
522
+ }
467
523
  // The egress rules, `ingressPolicy.endpoints`, `volumeMounts` and `env` are ALWAYS emitted — empty when
468
524
  // unset — so clearing any of them reconciles through the platform's whole-object PATCH replace instead of
469
525
  // being masked as omitted. `env` inherits the known map residual: emptying a declared map as the SOLE
@@ -518,9 +574,7 @@ export class Environment extends Construct {
518
574
  }),
519
575
  ...(this.props.disks ?? []).map((disk) => ({ type: "disk", mountPath: disk.mountPath, size: disk.size })),
520
576
  ],
521
- // A FRESH object each synth: emitting the stored map by reference would let a caller mutating one
522
- // returned plan's `payload.env` change every later synth.
523
- env: { ...(this.props.env ?? {}) },
577
+ env,
524
578
  // Always emitted — a token or an explicit null — so removing the credential reconciles to a cleared
525
579
  // field. An omitted key would be dropped by JSON.stringify and the PATCH would read "unchanged".
526
580
  imagePullSecretId: this.props.imagePullCredential?.secretId ?? null,
@@ -531,6 +585,16 @@ export class Environment extends Construct {
531
585
  ? { computeProviderId: this.props.computeProvider?.computeProviderId ?? null }
532
586
  : {}),
533
587
  },
588
+ // Only when declared, `[]` included: an omitted list must not clear defaults set in the console or by
589
+ // the platform's own seed.
590
+ ...(this.props.credentials !== undefined
591
+ ? {
592
+ credentials: this.props.credentials.map((entry) => ({
593
+ credential: credentialReference(entry.credential),
594
+ replace: entry.replace ?? true,
595
+ })),
596
+ }
597
+ : {}),
534
598
  ...(this.props.team !== undefined ? { teamId: this.props.team.teamId } : {}),
535
599
  };
536
600
  return { kind: "environment", path: this.node.path.join("/"), slug, managed: true, payload };
@@ -3,9 +3,14 @@ import { Construct } from "../core/construct.js";
3
3
  import type { PlanResource, ValueFrom } from "../core/plan.js";
4
4
  import type { CredentialBindingData, IGatewayKey, ISecret, ITeam } from "./interfaces.js";
5
5
  /**
6
- * The request mutations the broker applies from this secret: `headers` are injected after credential
7
- * rendering, `removeHeaders` strips client-sent headers before injection. `removeHeaders` is optional here
8
- * and normalized to `[]` on the wire, so an inject-only recipe round-trips against the server's shape.
6
+ * The request mutations the broker applies from this secret, and where: `headers` are injected after credential
7
+ * rendering, `removeHeaders` strips client-sent headers first, and `hosts` names the hosts the recipe applies on.
8
+ * Both lists are optional here and `[]` on the wire, so an inject-only recipe round-trips against the server's
9
+ * shape. A secret with no recipe is stored only: the broker presents it on no request.
10
+ *
11
+ * For git over HTTPS, store `base64("<user>:<token>")` and present it as `Basic ${secret}`; GitHub's user is
12
+ * `x-access-token`. Encode it first, `printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n'`, or let
13
+ * `SecretValue.basicAuth` encode it at apply.
9
14
  */
10
15
  export interface InjectionRecipeProps {
11
16
  readonly headers: {
@@ -13,6 +18,12 @@ export interface InjectionRecipeProps {
13
18
  readonly value: string;
14
19
  }[];
15
20
  readonly removeHeaders?: string[];
21
+ /**
22
+ * The hosts this secret is presented on, in egress rule host syntax: an exact host, `*.suffix`, or `*`. A
23
+ * request to a covered host gets the `headers` at the egress edge; an environment's rules only decide whether
24
+ * the request may leave. Omitted, the recipe covers no host.
25
+ */
26
+ readonly hosts?: string[];
16
27
  }
17
28
  /**
18
29
  * Shared with the egress `inject()` binding factory so a bad recipe fails at synth identically whether it
@@ -84,12 +95,11 @@ export interface SecretProps {
84
95
  slug?: string;
85
96
  name?: string;
86
97
  description?: string;
98
+ /** How and where the secret is presented. Omitted, the secret is stored only. */
87
99
  injection?: InjectionRecipeProps;
88
100
  /**
89
- * The hosts this secret is presented on, in egress rule host syntax: an exact host, `*.suffix`, or `*`. A
90
- * request to a covered host gets the `injection` headers at the egress edge; an environment's rules only
91
- * decide whether the request may leave. Omitted, the secret covers no host, and apply sends `[]`, so
92
- * removing the prop clears the list.
101
+ * @deprecated Declare `injection.hosts` instead. Accepted for one release and moved into the recipe; refused
102
+ * when the recipe declares a different list.
93
103
  */
94
104
  hosts?: string[];
95
105
  /** Fixed at create; undeclared, the secret is created in Shared. */
@@ -77,6 +77,7 @@ export function cloneInjectionRecipe(recipe) {
77
77
  return {
78
78
  headers: (recipe.headers ?? []).map((h) => ({ name: h.name, value: h.value })),
79
79
  removeHeaders: [...recipe.removeHeaders],
80
+ ...(recipe.hosts !== undefined ? { hosts: [...recipe.hosts] } : {}),
80
81
  };
81
82
  }
82
83
  /**
@@ -319,16 +320,25 @@ function isIpv6Literal(host) {
319
320
  /**
320
321
  * The client-side mirror of the platform's write-time `hosts` checks, so a bad list fails at synth: each
321
322
  * entry is an exact host, `*.` and a suffix, or `*`, and a secret that covers a host needs a recipe header to
322
- * be presented by. The platform-origin and object-store refusals need the deployment's own addresses, so they
323
+ * be presented by. The deprecated top-level `hosts` is moved into the recipe, and refused when the recipe
324
+ * declares a different list, an empty one included. The platform-origin and object-store refusals need the deployment's own addresses, so they
323
325
  * stay the platform's `400`.
324
326
  */
325
- function normalizeSecretHosts(hosts, injection, label) {
326
- if (hosts === undefined)
327
- return [];
328
- normalizeCredentialHosts(hosts, label);
327
+ function normalizeSecretHosts(recipeHosts, deprecatedHosts, injection, label) {
328
+ if (recipeHosts !== undefined)
329
+ normalizeCredentialHosts(recipeHosts, `${label}: injection`);
330
+ if (deprecatedHosts !== undefined)
331
+ normalizeCredentialHosts(deprecatedHosts, label);
332
+ if (recipeHosts !== undefined &&
333
+ deprecatedHosts !== undefined &&
334
+ (recipeHosts.length !== deprecatedHosts.length || recipeHosts.some((host, index) => host !== deprecatedHosts[index]))) {
335
+ throw new Error(`${label}: 'hosts' and 'injection.hosts' name different hosts; declare 'injection.hosts' alone.`);
336
+ }
337
+ const hosts = recipeHosts ?? deprecatedHosts ?? [];
329
338
  if (hosts.length > 0 && (injection === undefined || (injection.headers ?? []).length === 0)) {
330
- throw new Error(`${label}: a secret with 'hosts' needs an 'injection' recipe with at least one header, which is how it ` +
331
- `is presented, such as injection: { headers: [{ name: "Authorization", value: "Bearer \${secret}" }] }.`);
339
+ throw new Error(`${label}: a secret with hosts needs an 'injection' recipe with at least one header, which is how it ` +
340
+ `is presented, such as injection: { headers: [{ name: "Authorization", value: "Bearer \${secret}" }], ` +
341
+ `hosts: ["api.example.com"] }.`);
332
342
  }
333
343
  return [...hosts];
334
344
  }
@@ -337,7 +347,6 @@ export class Secret extends Construct {
337
347
  secretId;
338
348
  #valueFrom;
339
349
  #injection;
340
- #hosts;
341
350
  explicitSlug;
342
351
  displayName;
343
352
  description;
@@ -347,8 +356,9 @@ export class Secret extends Construct {
347
356
  validateExplicitSlug(this, props.slug, "secret");
348
357
  const label = `secret '${this.node.path.join("/")}'`;
349
358
  this.#valueFrom = props.value._toValueFrom();
350
- this.#injection = props.injection === undefined ? undefined : normalizeInjectionRecipe(props.injection, label);
351
- this.#hosts = normalizeSecretHosts(props.hosts, this.#injection, label);
359
+ const recipe = props.injection === undefined ? undefined : normalizeInjectionRecipe(props.injection, label);
360
+ const hosts = normalizeSecretHosts(props.injection?.hosts, props.hosts, recipe, label);
361
+ this.#injection = recipe === undefined ? undefined : { ...recipe, hosts };
352
362
  this.explicitSlug = props.slug;
353
363
  this.displayName = props.name ?? id;
354
364
  this.description = props.description ?? "";
@@ -368,7 +378,7 @@ export class Secret extends Construct {
368
378
  }
369
379
  /** @internal — a fresh copy of the declared hosts, for synth checks that read a declared secret. */
370
380
  _hosts() {
371
- return [...this.#hosts];
381
+ return [...(this.#injection?.hosts ?? [])];
372
382
  }
373
383
  /** @internal — whether the declared recipe has a header to present this secret by. */
374
384
  _presentsHeader() {
@@ -385,16 +395,14 @@ export class Secret extends Construct {
385
395
  const slug = this.slug();
386
396
  // `description` is ALWAYS emitted, empty when unset, so it is never omit-masked; the only Secret PATCH
387
397
  // rides a value overwrite that sends name+description together, so a clear travels with that write.
388
- // `hosts` is ALWAYS emitted, `[]` when undeclared, so apply sends it and a row at `[]` or with other hosts
389
- // converges on the declaration.
390
- // `injection` is emitted only when declared, keeping a recipe-less secret's payload byte-identical, and
391
- // is DEEP-COPIED because the stored recipe is shared across synths: emitting it by reference would let a
398
+ // `injection` is emitted only when declared, its `hosts` always, `[]` when undeclared, so a live recipe with
399
+ // other hosts converges on the declaration and a dropped recipe is cleared, leaving the secret stored only.
400
+ // It is DEEP-COPIED because the stored recipe is shared across synths: emitting it by reference would let a
392
401
  // caller mutating one returned plan corrupt the validated recipe for every later synth.
393
402
  const payload = {
394
403
  name: this.displayName,
395
404
  slug,
396
405
  description: this.description,
397
- hosts: [...this.#hosts],
398
406
  ...(this.#injection !== undefined ? { injection: cloneInjectionRecipe(this.#injection) } : {}),
399
407
  ...(this.team !== undefined ? { teamId: this.team.teamId } : {}),
400
408
  };
@@ -2,18 +2,23 @@
2
2
  * Reserving the whole prefix rather than one literal key is what makes the ownership marker unforgeable and
3
3
  * leaves room for the other markers `apply` writes without a second rule.
4
4
  */
5
- export declare const LABEL_KEY_PREFIX = "sfdk.";
5
+ export declare const LABEL_KEY_PREFIX = "orchestra.";
6
+ /**
7
+ * The prefix `apply` wrote before the rename. A factory applied then carries its markers under it until its next
8
+ * apply moves them, so every marker is read under both prefixes, the new key winning, and both stay reserved.
9
+ */
10
+ export declare const OLD_LABEL_KEY_PREFIX = "sfdk.";
6
11
  /**
7
12
  * Stamped on every resource `apply` creates or adopts among the kinds whose API request exposes `labels`.
8
13
  * Its value is the root factory name, which is always present — even for a factory that declares no team — so a CLI
9
14
  * write against a managed resource can name the owning factory verbatim.
10
15
  */
11
- export declare const MANAGED_BY_LABEL_KEY = "sfdk.managedBy";
16
+ export declare const MANAGED_BY_LABEL_KEY = "orchestra.managedBy";
12
17
  export declare function rejectTemplateBraces(map: Record<string, string> | undefined, where: string): void;
13
18
  /**
14
19
  * A synth-time rejection rather than an apply-time overwrite on purpose: a declared key that could shadow
15
- * `sfdk.managedBy` would make the ownership marker forgeable and make a re-apply fight itself over the same
16
- * key. Refusing at the authoring boundary leaves the marker exactly one writer.
20
+ * the managed-by marker, under either prefix, would make the ownership marker forgeable and make a re-apply
21
+ * fight itself over the same key. Refusing at the authoring boundary leaves the marker exactly one writer.
17
22
  */
18
23
  export declare function rejectReservedLabelKeys(map: Record<string, string> | undefined, where: string): void;
19
24
  /** Constructs call this rather than the two halves, so a new rule lands everywhere at once. */
@@ -28,12 +33,33 @@ export declare function stampManagedBy(labels: Record<string, string> | undefine
28
33
  * an explicit `null`. This record of the keys `apply` last wrote from a declaration is how a later apply
29
34
  * tells a key the declaration dropped, which it removes, from a key someone else set, which it leaves alone.
30
35
  */
31
- export declare const DECLARED_LABELS_KEY = "sfdk.declaredLabels";
36
+ export declare const DECLARED_LABELS_KEY = "orchestra.declaredLabels";
37
+ /**
38
+ * Set on a gateway key's paired Secret while a mint is in flight, so a mint whose token write never landed is
39
+ * seen and refused by name rather than read as a usable Secret.
40
+ */
41
+ export declare const PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY = "orchestra.pendingGatewayKeyToken";
42
+ /** The key a marker had under the old prefix. */
43
+ export declare function oldMarkerKey(key: string): string;
44
+ /** A marker's value: under its key, else under the old prefix's, else `undefined`. */
45
+ export declare function readMarker(labels: Record<string, unknown> | null | undefined, key: string): string | undefined;
46
+ /** A merge patch removing each marker these labels carry under the old prefix. */
47
+ export declare function oldMarkersCleared(labels: Record<string, unknown> | null | undefined): Record<string, null>;
48
+ /**
49
+ * The merge patch that moves each marker found under the old prefix, for a resource whose labels apply does not
50
+ * otherwise reconcile: the old key sent as `null`, and the new key given the old value unless it is already
51
+ * live. `undefined` when no marker is under the old prefix.
52
+ */
53
+ export declare function oldMarkersMoved(labels: Record<string, unknown> | null | undefined): Record<string, string | null> | undefined;
54
+ /** The factory whose apply stamped these labels, read under either prefix. */
55
+ export declare function managedByOf(labels: Record<string, unknown> | null | undefined): string | undefined;
32
56
  /** A create's labels: the declaration, the record of its keys when it declares any, and the stamps. */
33
57
  export declare function createLabels(declared: Record<string, string> | undefined, stamps?: Record<string, string>): Record<string, string>;
34
58
  /**
35
59
  * The merge patch that brings the live labels to the declaration, or `undefined` when they already agree: a
36
60
  * declared value that differs, a key an earlier apply declared and this one does not (as `null`), the record
37
- * of declared keys, and the stamps. A live key no apply declared is neither sent nor drift.
61
+ * of declared keys, and the stamps. A live key no apply declared is neither sent nor drift. A marker found under
62
+ * the old prefix is moved: its old key is sent as `null`, and its new key carries the stamp, or the old value
63
+ * when nothing stamps it.
38
64
  */
39
65
  export declare function labelsPatch(declared: Record<string, string> | undefined, live: Record<string, string> | null | undefined, stamps?: Record<string, string>): Record<string, string | null> | undefined;
@@ -4,7 +4,13 @@
4
4
  * Reserving the whole prefix rather than one literal key is what makes the ownership marker unforgeable and
5
5
  * leaves room for the other markers `apply` writes without a second rule.
6
6
  */
7
- export const LABEL_KEY_PREFIX = "sfdk.";
7
+ export const LABEL_KEY_PREFIX = "orchestra.";
8
+ /**
9
+ * The prefix `apply` wrote before the rename. A factory applied then carries its markers under it until its next
10
+ * apply moves them, so every marker is read under both prefixes, the new key winning, and both stay reserved.
11
+ */
12
+ export const OLD_LABEL_KEY_PREFIX = "sfdk.";
13
+ const RESERVED_LABEL_KEY_PREFIXES = [LABEL_KEY_PREFIX, OLD_LABEL_KEY_PREFIX];
8
14
  /**
9
15
  * Stamped on every resource `apply` creates or adopts among the kinds whose API request exposes `labels`.
10
16
  * Its value is the root factory name, which is always present — even for a factory that declares no team — so a CLI
@@ -24,16 +30,17 @@ export function rejectTemplateBraces(map, where) {
24
30
  }
25
31
  /**
26
32
  * A synth-time rejection rather than an apply-time overwrite on purpose: a declared key that could shadow
27
- * `sfdk.managedBy` would make the ownership marker forgeable and make a re-apply fight itself over the same
28
- * key. Refusing at the authoring boundary leaves the marker exactly one writer.
33
+ * the managed-by marker, under either prefix, would make the ownership marker forgeable and make a re-apply
34
+ * fight itself over the same key. Refusing at the authoring boundary leaves the marker exactly one writer.
29
35
  */
30
36
  export function rejectReservedLabelKeys(map, where) {
31
37
  if (map === undefined)
32
38
  return;
33
39
  for (const key of Object.keys(map)) {
34
- if (key.startsWith(LABEL_KEY_PREFIX)) {
40
+ const prefix = reservedPrefixOf(key);
41
+ if (prefix !== undefined) {
35
42
  throw new Error(`Label key ${JSON.stringify(key)} in ${where} uses the reserved ` +
36
- `"${LABEL_KEY_PREFIX}" prefix, which belongs to orchestra itself (it is how apply marks the ` +
43
+ `"${prefix}" prefix, which belongs to orchestra itself (it is how apply marks the ` +
37
44
  `resources it manages, e.g. ${JSON.stringify(MANAGED_BY_LABEL_KEY)}). Rename the key to ` +
38
45
  `anything outside that prefix.`);
39
46
  }
@@ -57,6 +64,51 @@ export function stampManagedBy(labels, factory) {
57
64
  * tells a key the declaration dropped, which it removes, from a key someone else set, which it leaves alone.
58
65
  */
59
66
  export const DECLARED_LABELS_KEY = `${LABEL_KEY_PREFIX}declaredLabels`;
67
+ /**
68
+ * Set on a gateway key's paired Secret while a mint is in flight, so a mint whose token write never landed is
69
+ * seen and refused by name rather than read as a usable Secret.
70
+ */
71
+ export const PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY = `${LABEL_KEY_PREFIX}pendingGatewayKeyToken`;
72
+ const MARKER_KEYS = [MANAGED_BY_LABEL_KEY, DECLARED_LABELS_KEY, PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY];
73
+ /** The key a marker had under the old prefix. */
74
+ export function oldMarkerKey(key) {
75
+ return OLD_LABEL_KEY_PREFIX + key.slice(LABEL_KEY_PREFIX.length);
76
+ }
77
+ /** A marker's value: under its key, else under the old prefix's, else `undefined`. */
78
+ export function readMarker(labels, key) {
79
+ const value = labels?.[key] ?? labels?.[oldMarkerKey(key)];
80
+ return typeof value === "string" ? value : undefined;
81
+ }
82
+ /** A merge patch removing each marker these labels carry under the old prefix. */
83
+ export function oldMarkersCleared(labels) {
84
+ return Object.fromEntries(MARKER_KEYS.map(oldMarkerKey)
85
+ .filter((key) => labels?.[key] !== undefined)
86
+ .map((key) => [key, null]));
87
+ }
88
+ /**
89
+ * The merge patch that moves each marker found under the old prefix, for a resource whose labels apply does not
90
+ * otherwise reconcile: the old key sent as `null`, and the new key given the old value unless it is already
91
+ * live. `undefined` when no marker is under the old prefix.
92
+ */
93
+ export function oldMarkersMoved(labels) {
94
+ const patch = {};
95
+ for (const key of MARKER_KEYS) {
96
+ const old = labels?.[oldMarkerKey(key)];
97
+ if (old === undefined)
98
+ continue;
99
+ patch[oldMarkerKey(key)] = null;
100
+ if (labels?.[key] === undefined && typeof old === "string")
101
+ patch[key] = old;
102
+ }
103
+ return Object.keys(patch).length > 0 ? patch : undefined;
104
+ }
105
+ /** The factory whose apply stamped these labels, read under either prefix. */
106
+ export function managedByOf(labels) {
107
+ return readMarker(labels, MANAGED_BY_LABEL_KEY);
108
+ }
109
+ function reservedPrefixOf(key) {
110
+ return RESERVED_LABEL_KEY_PREFIXES.find((prefix) => key.startsWith(prefix));
111
+ }
60
112
  /** A create's labels: the declaration, the record of its keys when it declares any, and the stamps. */
61
113
  export function createLabels(declared, stamps = {}) {
62
114
  return { ...withDeclaredKeys(declared ?? {}), ...stamps };
@@ -64,7 +116,9 @@ export function createLabels(declared, stamps = {}) {
64
116
  /**
65
117
  * The merge patch that brings the live labels to the declaration, or `undefined` when they already agree: a
66
118
  * declared value that differs, a key an earlier apply declared and this one does not (as `null`), the record
67
- * of declared keys, and the stamps. A live key no apply declared is neither sent nor drift.
119
+ * of declared keys, and the stamps. A live key no apply declared is neither sent nor drift. A marker found under
120
+ * the old prefix is moved: its old key is sent as `null`, and its new key carries the stamp, or the old value
121
+ * when nothing stamps it.
68
122
  */
69
123
  export function labelsPatch(declared, live, stamps = {}) {
70
124
  const current = live ?? {};
@@ -80,6 +134,14 @@ export function labelsPatch(declared, live, stamps = {}) {
80
134
  }
81
135
  if (!(DECLARED_LABELS_KEY in desired) && DECLARED_LABELS_KEY in current)
82
136
  patch[DECLARED_LABELS_KEY] = null;
137
+ for (const key of MARKER_KEYS) {
138
+ const old = oldMarkerKey(key);
139
+ if (!(old in current))
140
+ continue;
141
+ patch[old] = null;
142
+ if (key !== DECLARED_LABELS_KEY && !(key in desired) && !(key in current))
143
+ patch[key] = current[old];
144
+ }
83
145
  return Object.keys(patch).length > 0 ? patch : undefined;
84
146
  }
85
147
  function withDeclaredKeys(declared) {
@@ -88,13 +150,13 @@ function withDeclaredKeys(declared) {
88
150
  }
89
151
  /** A malformed record removes nothing, and a reserved key is never removed through it. */
90
152
  function previouslyDeclaredKeys(live) {
91
- const record = live[DECLARED_LABELS_KEY];
153
+ const record = readMarker(live, DECLARED_LABELS_KEY);
92
154
  if (record === undefined)
93
155
  return [];
94
156
  try {
95
157
  const keys = JSON.parse(record);
96
158
  return Array.isArray(keys)
97
- ? keys.filter((key) => typeof key === "string" && !key.startsWith(LABEL_KEY_PREFIX))
159
+ ? keys.filter((key) => typeof key === "string" && reservedPrefixOf(key) === undefined)
98
160
  : [];
99
161
  }
100
162
  catch {
@@ -10,13 +10,12 @@ import { createHash } from "node:crypto";
10
10
  import { CONNECTOR_SECRET_WIRE_FIELDS } from "../constructs/connector.js";
11
11
  import { isEnvImportMarker, parseEnvImportMarker } from "../constructs/references.js";
12
12
  import { gatewayKeyRefOf } from "../core/gateway-key-recipe.js";
13
- import { labelsPatch, MANAGED_BY_LABEL_KEY } from "../core/labels.js";
13
+ import { labelsPatch, MANAGED_BY_LABEL_KEY, oldMarkersMoved, PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY, readMarker, } from "../core/labels.js";
14
14
  import { isRefToken, refToken } from "../core/tokens.js";
15
15
  import { createTeamResolver, mapError, modelSettingsUnchanged, standbyCapacityUnchanged, platformErrorMessage, } from "./apply-core.js";
16
16
  import { httpStatusOf } from "./client.js";
17
17
  import { describeRefusal, describeStageEffects, leftoverRefusals, pipelineChange, pipelineUnchanged, previewPipelineChange, resolveGateReferences, workflowStagesAfterApply, } from "./pipeline-reconcile.js";
18
18
  import { describeEntryChange, describeOverlaps, entriesUnchanged, pipelineEntryChange, proposedWorkflows, } from "./pipeline-workflow-reconcile.js";
19
- import { PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY } from "./gateway-key-reconcile.js";
20
19
  import { scanModelProviderKeys } from "./model-provider-key-reconcile.js";
21
20
  import { computeProviderPatch, } from "./compute-provider-reconcile.js";
22
21
  import { paginate } from "./pages.js";
@@ -25,7 +24,7 @@ import { reservedProviderSecretIds } from "./preflight.js";
25
24
  import { gatewayKeyPairRefusal, refusalOf, } from "./refusals.js";
26
25
  import { connectionUnchanged, environmentUnchanged, getBySlugOrUndefined, subsetDeepEqual, } from "./reconcile.js";
27
26
  import { removedConfigurationNotice } from "./connector-configuration.js";
28
- import { containsCommandLineRecipe, declaredSecretHosts, metadataHostsChange, metadataInjectionChange, pendingCredentialWrite, replacesLiveCredentialValue, resolveSecretValueSource, rootEnvName, } from "./secrets-apply.js";
27
+ import { containsCommandLineRecipe, declaredSecretInjection, metadataInjectionChange, pendingCredentialWrite, replacesLiveCredentialValue, resolveSecretValueSource, rootEnvName, } from "./secrets-apply.js";
29
28
  /** Overridable for tests. */
30
29
  export const ORPHAN_SCAN_CAP = 5;
31
30
  /**
@@ -428,20 +427,30 @@ async function diffSecret(resource, pass) {
428
427
  }
429
428
  return entry(resource, "no-change", unsetNote, marks);
430
429
  }
431
- /** Value-free: a recipe and a host list are shape, never credential material. */
430
+ /** Value-free: a recipe and its host list are shape, never credential material. */
432
431
  const INJECTION_RECIPE_CHANGE = "injection recipe changes";
433
432
  const HOSTS_CHANGE = "hosts change";
433
+ const OWNERSHIP_LABELS_MOVE = "ownership labels move to the orchestra. prefix";
434
434
  function secretMetadataDetail(resource, live) {
435
435
  const details = [
436
- ...(metadataInjectionChange(declaredInjection(resource), live.injection) !== undefined
437
- ? [INJECTION_RECIPE_CHANGE]
438
- : []),
439
- ...(metadataHostsChange(declaredSecretHosts(resource.payload), live.hosts) !== undefined ? [HOSTS_CHANGE] : []),
436
+ ...secretRecipeDetails(resource, live),
437
+ ...(oldMarkersMoved(live.labels) !== undefined ? [OWNERSHIP_LABELS_MOVE] : []),
440
438
  ];
441
439
  return details.length > 0 ? details.join("; ") : undefined;
442
440
  }
443
- function declaredInjection(resource) {
444
- return resource.payload.injection;
441
+ function secretRecipeDetails(resource, live) {
442
+ const declared = declaredSecretInjection(resource.payload);
443
+ if (metadataInjectionChange(declared, live.injection) === undefined)
444
+ return [];
445
+ if (declared === undefined || live.injection === undefined)
446
+ return [INJECTION_RECIPE_CHANGE];
447
+ const { hosts: desiredHosts = [], ...desiredRecipe } = declared;
448
+ const { hosts: liveHosts = [], ...liveRecipe } = live.injection;
449
+ const sameHosts = desiredHosts.length === liveHosts.length && desiredHosts.every((host, index) => host === liveHosts[index]);
450
+ return [
451
+ ...(metadataInjectionChange(desiredRecipe, liveRecipe) !== undefined ? [INJECTION_RECIPE_CHANGE] : []),
452
+ ...(sameHosts ? [] : [HOSTS_CHANGE]),
453
+ ];
445
454
  }
446
455
  function withDetail(note, detail) {
447
456
  return note === undefined ? detail : `${note}; ${detail}`;
@@ -480,7 +489,7 @@ async function diffGatewayKey(resource, plan, pass) {
480
489
  return entry(resource, keyUnchanged(body, live) ? "no-change" : "update", undefined, marks);
481
490
  }
482
491
  function hasPendingMarker(secret) {
483
- return secret?.labels?.[PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY] !== undefined;
492
+ return readMarker(secret?.labels, PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY) !== undefined;
484
493
  }
485
494
  /**
486
495
  * Mirrors apply's `pairedSecretResource` lookup but returns `undefined` instead of throwing when the plan
@@ -630,6 +639,16 @@ async function diffEnvironment(resource, pass) {
630
639
  if (typeof providerRef === "string" && providerRef.length > 0) {
631
640
  launchSpec.computeProviderId = await resolveIdForDiff(providerRef, "compute-provider", pass);
632
641
  }
642
+ // Each credential reference an env value or a default names, resolved to the id apply writes.
643
+ const env = (launchSpec.env ?? {});
644
+ for (const [name, value] of Object.entries(env)) {
645
+ if (!value.startsWith("orchestra:"))
646
+ continue;
647
+ env[name] = `orchestra:${await credentialReferenceForDiff(value.slice("orchestra:".length), pass)}`;
648
+ }
649
+ for (const entry of (body.credentials ?? [])) {
650
+ entry.credential = await credentialReferenceForDiff(String(entry.credential), pass);
651
+ }
633
652
  const bound = pullSecrets.length === 0
634
653
  ? null
635
654
  : verdictFor(resource, live, {
@@ -929,6 +948,14 @@ async function orphanReport(plan, apis, cap) {
929
948
  function refKeyOf(resource) {
930
949
  return refToken(resource.kind, resource.path.split("/"));
931
950
  }
951
+ /** `secret/<ref>` or `connection/<ref>` with its ref resolved as {@link resolveIdForDiff} resolves one. */
952
+ async function credentialReferenceForDiff(text, pass) {
953
+ const match = /^(secret|connection)\/(.+)$/.exec(text);
954
+ if (match === null)
955
+ return text;
956
+ const kind = match[1];
957
+ return `${kind}/${await resolveIdForDiff(match[2], kind, pass)}`;
958
+ }
932
959
  /** Lenient: any miss leaves the ref unchanged, so the compare simply reports `update`. */
933
960
  async function resolveIdForDiff(ref, literalKind, pass) {
934
961
  if (isRefToken(ref)) {
@@ -8,7 +8,7 @@ import { PreflightError } from "./preflight.js";
8
8
  * second column — and is removed by the final token PATCH. It lives in a leaf module because the aggregate
9
9
  * refusal classifier's rotation message names it too.
10
10
  */
11
- export { PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY } from "./refusals.js";
11
+ export { PENDING_GATEWAY_KEY_TOKEN_LABEL_KEY } from "../core/labels.js";
12
12
  /**
13
13
  * The platform requires a non-empty `value` on create and the token does not exist yet, so a reservation
14
14
  * writes this inert literal and the final PATCH replaces it. The Secret is detectably pending for exactly as
@@ -38,7 +38,6 @@ export interface LiveSecretRow {
38
38
  id: string;
39
39
  teamId?: string;
40
40
  injection?: InjectionRecipe;
41
- hosts?: string[];
42
41
  labels?: Record<string, string>;
43
42
  }
44
43
  /** Value-free. */