@vectros-ai/cli 0.22.0 → 0.23.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/dist/cli.mjs CHANGED
@@ -523,7 +523,7 @@ var require_graceful_fs = __commonJS({
523
523
  function patch(fs5) {
524
524
  polyfills(fs5);
525
525
  fs5.gracefulify = patch;
526
- fs5.createReadStream = createReadStream;
526
+ fs5.createReadStream = createReadStream2;
527
527
  fs5.createWriteStream = createWriteStream;
528
528
  var fs$readFile = fs5.readFile;
529
529
  fs5.readFile = readFile4;
@@ -733,7 +733,7 @@ var require_graceful_fs = __commonJS({
733
733
  }
734
734
  });
735
735
  }
736
- function createReadStream(path4, options) {
736
+ function createReadStream2(path4, options) {
737
737
  return new fs5.ReadStream(path4, options);
738
738
  }
739
739
  function createWriteStream(path4, options) {
@@ -1619,7 +1619,7 @@ var require_proper_lockfile = __commonJS({
1619
1619
  // src/cli-bootstrap.ts
1620
1620
  import * as readline2 from "readline/promises";
1621
1621
 
1622
- // node_modules/@vectros-ai/sdk/dist/index.mjs
1622
+ // ../../node_modules/@vectros-ai/sdk/dist/index.mjs
1623
1623
  var __defProp2 = Object.defineProperty;
1624
1624
  var __export = (target, all) => {
1625
1625
  for (var name in all)
@@ -5018,6 +5018,7 @@ var AuthClient = class {
5018
5018
  *
5019
5019
  * @throws {@link Vectros.ForbiddenError}
5020
5020
  * @throws {@link Vectros.NotFoundError}
5021
+ * @throws {@link Vectros.ConflictError}
5021
5022
  * @throws {@link Vectros.TooManyRequestsError}
5022
5023
  *
5023
5024
  * @example
@@ -5060,6 +5061,8 @@ var AuthClient = class {
5060
5061
  throw new ForbiddenError(_response.error.body, _response.rawResponse);
5061
5062
  case 404:
5062
5063
  throw new NotFoundError(_response.error.body, _response.rawResponse);
5064
+ case 409:
5065
+ throw new ConflictError(_response.error.body, _response.rawResponse);
5063
5066
  case 429:
5064
5067
  throw new TooManyRequestsError(_response.error.body, _response.rawResponse);
5065
5068
  default:
@@ -5136,7 +5139,7 @@ var AuthClient = class {
5136
5139
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/app-contexts/{contextId}");
5137
5140
  }
5138
5141
  /**
5139
- * Updates the name, description, and/or companyName of an app context. This is a partial update: any field you omit (or send as null) keeps its existing value. The `contextId` is immutable and is taken from the URL path, so any `contextId` in the request body is ignored. Requires the `app-contexts:u` scope.
5142
+ * Updates an app context. This is a partial update: any field you omit (or send as null) keeps its existing value. The `contextId` is immutable and is taken from the URL path, so any `contextId` in the request body is ignored. Patchable fields: `name`, `description`, `companyName`; the per-principal metering trio (`meteringAxis`, `principalBurstLimit`, `principalUsageCap` — only takes effect for a partner with the corresponding account-level feature enabled); `readAccessLogDefault` (the PHI read-access-logging context default); and `identityProjectionClaims` (requires the platform provisioning capability on top of the ordinary scope below — see its own field description). Requires the `app-contexts:u` scope.
5140
5143
  *
5141
5144
  * @param {Vectros.UpdateAppContextRequest} request
5142
5145
  * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -5204,7 +5207,7 @@ var AuthClient = class {
5204
5207
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "PUT", "/v1/app-contexts/{contextId}");
5205
5208
  }
5206
5209
  /**
5207
- * Permanently deletes an app context and everything in it — every record, document, folder, schema, role, and access profile belonging to the context. This is irreversible. The deletion runs asynchronously: the call returns 202 immediately and the context's data drains in the background. Poll the context's `status` field to observe when the teardown completes (`purging` while draining, then `deleted`). To guard against accidental deletion, you must echo the contextId back in the `confirm` query parameter (`?confirm={contextId}`). The reserved `default` and `vectros-admin` contexts cannot be deleted. This operation requires a root API key (one beginning with `sk_`): no scoped credential, not even one with full wildcard (`*`) scope, can trigger this teardown.
5210
+ * Permanently deletes an app context and everything in it — every record, document, folder, schema, role, access profile, and trusted-issuer registration belonging to the context. This is irreversible. The deletion runs asynchronously: the call returns 202 immediately and the context's data drains in the background. Poll the context's `status` field to observe when the teardown completes (`purging` while draining, then `deleted`). To guard against accidental deletion, you must echo the contextId back in the `confirm` query parameter (`?confirm={contextId}`). The reserved `default` and `vectros-admin` contexts cannot be deleted. This operation requires a root API key (one beginning with `sk_`): no scoped credential, not even one with full wildcard (`*`) scope, can trigger this teardown.
5208
5211
  *
5209
5212
  * @param {Vectros.DeleteAppContextRequest} request
5210
5213
  * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -5739,7 +5742,7 @@ var AuthClient = class {
5739
5742
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/auth/issuers/{issuerId}");
5740
5743
  }
5741
5744
  /**
5742
- * Updates the mutable fields of a registered issuer: `subClaim`, `emailClaim`, `userinfoUri`, `capturedClaims`, `restrictedToDomain` (#1250 Tier 1a — a new non-blank value must already be a VERIFIED domain for your account; an empty string clears it back to domain-less; see `IssuerUpdateRequest.restrictedToDomain` for the full semantics), `status` (`active`/`suspended` — a suspended issuer's tokens are rejected identically to an unregistered issuer at exchange time), and `selfSignupPolicies`. Fields omitted from the body are left unchanged (partial update). `issuer`, `jwksUri`, `audience`, and `contextId` are trust-anchor / routing-pin fields and are immutable via this route — supplying a value that differs from the current registration is rejected with 400; supplying the current value back is a no-op. Rotating a trust anchor requires deleting and re-registering the issuer, which is itself refused while any user is bound through it. **`subClaim` is identity-determining, not merely cosmetic**: it names which verified JWT claim is read as the federated user's identifier, so changing it on an issuer that already has bound users would silently re-identify (or, under self-signup, orphan) every one of them — changing it is therefore refused once any user has bound through this issuer, the same guard `DELETE` already applies, and is only free before the first real login. Requires a root API key or the bootstrap's provisioning capability, gated identically to every other operation on this surface. A credential confined to one app context may only update an issuer registered in that context; naming one registered in another context returns 404, identically to a nonexistent issuerId. A root API key may update any issuer.
5745
+ * Updates the mutable fields of a registered issuer: `subClaim`, `emailClaim`, `userinfoUri`, `capturedClaims`, `restrictedToDomain` (a new non-blank value must already be a VERIFIED domain for your account; an empty string clears it back to domain-less; see `IssuerUpdateRequest.restrictedToDomain` for the full semantics), `status` (`active`/`suspended` — a registration awaiting verification (`pending_verification`) accepts no status change until it is verified; a suspended issuer's tokens are rejected identically to an unregistered issuer at exchange time), and `selfSignupPolicies`. Fields omitted from the body are left unchanged (partial update). `issuer`, `jwksUri`, `audience`, and `contextId` are trust-anchor / routing-pin fields and are immutable via this route — supplying a value that differs from the current registration is rejected with 400; supplying the current value back is a no-op. Rotating a trust anchor requires deleting and re-registering the issuer, which is itself refused while any user is bound through it. **`subClaim` is identity-determining, not merely cosmetic**: it names which verified JWT claim is read as the federated user's identifier, so changing it on an issuer that already has bound users would silently re-identify (or, under self-signup, orphan) every one of them — changing it is therefore refused once any user has bound through this issuer, the same guard `DELETE` already applies, and is only free before the first real login. Requires a root API key or the bootstrap's provisioning capability, gated identically to every other operation on this surface. A credential confined to one app context may only update an issuer registered in that context; naming one registered in another context returns 404, identically to a nonexistent issuerId. A root API key may update any issuer.
5743
5746
  *
5744
5747
  * @param {Vectros.IssuerUpdateRequest} request
5745
5748
  * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -5932,7 +5935,7 @@ var AuthClient = class {
5932
5935
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/auth/issuers");
5933
5936
  }
5934
5937
  /**
5935
- * Registers a trusted third-party IdP issuer that BYO-IdP token exchange (`POST /v1/auth/token/exchange`) may accept a `subject_token` from. Requires a root API key or the CLI bootstrap's provisioning capability — never an ordinary partner-grantable scope. A credential authorized only via the provisioning capability may register only against the app context it is bound to; naming a different one returns 403. A root API key is unaffected and may register against any of its contexts. Idempotent by `issuerId` within your tenant; the `(issuer, audience)` pair must not already be registered by a different issuerId/tenant. If `issuerId` collides with a registration owned by a different app context than the one you're confined to, the request fails with 400 rather than returning that context's configuration. An app context may have at most one active issuer — deregister the existing one first if you need to replace it. One issuer MAY serve several contexts today, each via its own registration row with a distinct `audience`. Optionally name `restrictedToDomain` (#1250 Tier 1a) to scope this registration's (issuer, audience) uniqueness to a specific VERIFIED domain rather than the bare pair — see that field's own description for the full contract, including why it does NOT protect a shared/consumer-IdP registration with no company-domain population.
5938
+ * Registers a trusted third-party IdP issuer that BYO-IdP token exchange (`POST /v1/auth/token/exchange`) may accept a `subject_token` from. Requires a root API key or the CLI bootstrap's provisioning capability — never an ordinary partner-grantable scope. A credential authorized only via the provisioning capability may register only against the app context it is bound to; naming a different one returns 403. A root API key is unaffected and may register against any of its contexts. A registration without `restrictedToDomain` is created `pending_verification` with a one-time challenge (`verificationClaim`, `verificationNonce`, `verificationExpiresAt`) and accepts no token until you prove you control the issuer with `POST /v1/auth/issuers/{issuerId}/verify`; it claims no `(issuer, audience)` pair until then, so registering never reveals whether another tenant holds one. A registration scoped to an already-verified domain with `restrictedToDomain` is `active` at once. Idempotent by `issuerId` within your tenant. If `issuerId` collides with a registration owned by a different app context than the one you're confined to, the request fails with 400 rather than returning that context's configuration. An app context may have at most one active issuer — deregister the existing one first if you need to replace it. One issuer MAY serve several contexts today, each via its own registration row with a distinct `audience`. Optionally name `restrictedToDomain` to scope this registration's (issuer, audience) uniqueness to a specific VERIFIED domain rather than the bare pair — see that field's own description. A domain-scoped registration proves control of the DOMAIN, not of the issuer itself: it routes only tokens carrying that domain's `hd` claim.
5936
5939
  *
5937
5940
  * @param {Vectros.IssuerRequest} request
5938
5941
  * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -5998,6 +6001,82 @@ var AuthClient = class {
5998
6001
  }
5999
6002
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/v1/auth/issuers");
6000
6003
  }
6004
+ /**
6005
+ * Proves you administer the IdP application a `pending_verification` registration names, activating it. A registration created without `restrictedToDomain` accepts no tokens until this succeeds. Configure your IdP, with an admin-controlled rule (an Auth0 Action, an Okta inline hook, an Entra claims-mapping policy, a Keycloak protocol mapper), to add the registration's `verificationClaim` to the tokens it issues, set to its `verificationNonce`; sign in once; and send that token here. The `jwksUri` you registered must be exactly the `jwks_uri` your issuer's OpenID Connect discovery document (`<issuer>/.well-known/openid-configuration`) publishes, and the token must verify against that key set — an issuer that publishes no discovery document cannot be verified this way. The token is checked and discarded; it is never stored. The `(issuer, audience)` pair is claimed at this moment, so a pair another registration already holds is refused with 400. A challenge expires 7 days after registration; an expired registration cannot be verified — delete it and register again. Requires a root API key or the bootstrap's provisioning capability, gated identically to every other operation on this surface. A credential confined to one app context may only verify an issuer registered in that context; naming one registered in another context returns 404, identically to a nonexistent issuerId.
6006
+ *
6007
+ * @param {Vectros.IssuerVerifyRequest} request
6008
+ * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration.
6009
+ *
6010
+ * @throws {@link Vectros.BadRequestError}
6011
+ * @throws {@link Vectros.ForbiddenError}
6012
+ * @throws {@link Vectros.NotFoundError}
6013
+ * @throws {@link Vectros.ConflictError}
6014
+ * @throws {@link Vectros.TooManyRequestsError}
6015
+ *
6016
+ * @example
6017
+ * await client.auth.verifyIssuer({
6018
+ * issuerId: "auth0-prod",
6019
+ * token: "token"
6020
+ * })
6021
+ */
6022
+ verifyIssuer(request, requestOptions) {
6023
+ return HttpResponsePromise.fromPromise(this.__verifyIssuer(request, requestOptions));
6024
+ }
6025
+ async __verifyIssuer(request, requestOptions) {
6026
+ const { issuerId, ..._body } = request;
6027
+ const _authRequest = await this._options.authProvider.getAuthRequest();
6028
+ const _headers = mergeHeaders(
6029
+ _authRequest.headers,
6030
+ this._options?.headers,
6031
+ requestOptions?.headers
6032
+ );
6033
+ const _response = await fetcher({
6034
+ url: url_exports.join(
6035
+ await Supplier.get(this._options.baseUrl) ?? await Supplier.get(this._options.environment),
6036
+ `v1/auth/issuers/${url_exports.encodePathParam(issuerId)}/verify`
6037
+ ),
6038
+ method: "POST",
6039
+ headers: _headers,
6040
+ contentType: "application/json",
6041
+ queryString: url_exports.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(),
6042
+ requestType: "json",
6043
+ body: _body,
6044
+ timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1e3,
6045
+ maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries,
6046
+ abortSignal: requestOptions?.abortSignal,
6047
+ fetchFn: this._options?.fetch,
6048
+ logging: this._options.logging
6049
+ });
6050
+ if (_response.ok) {
6051
+ return { data: _response.body, rawResponse: _response.rawResponse };
6052
+ }
6053
+ if (_response.error.reason === "status-code") {
6054
+ switch (_response.error.statusCode) {
6055
+ case 400:
6056
+ throw new BadRequestError(_response.error.body, _response.rawResponse);
6057
+ case 403:
6058
+ throw new ForbiddenError(_response.error.body, _response.rawResponse);
6059
+ case 404:
6060
+ throw new NotFoundError(_response.error.body, _response.rawResponse);
6061
+ case 409:
6062
+ throw new ConflictError(_response.error.body, _response.rawResponse);
6063
+ case 429:
6064
+ throw new TooManyRequestsError(_response.error.body, _response.rawResponse);
6065
+ default:
6066
+ throw new VectrosError({
6067
+ statusCode: _response.error.statusCode,
6068
+ body: _response.error.body,
6069
+ rawResponse: _response.rawResponse
6070
+ });
6071
+ }
6072
+ }
6073
+ return handleNonStatusCodeError(
6074
+ _response.error,
6075
+ _response.rawResponse,
6076
+ "POST",
6077
+ "/v1/auth/issuers/{issuerId}/verify"
6078
+ );
6079
+ }
6001
6080
  /**
6002
6081
  * Returns the identity bound to your credential — your account, principal type, key id, and scope details — so you can confirm who you are authenticated as and that the credential is valid. MCP clients use this to render "signed in as ..." in a chat UI without a separate identity endpoint.
6003
6082
  *
@@ -7430,7 +7509,7 @@ var DocumentsClient = class {
7430
7509
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/documents/{id}/versions");
7431
7510
  }
7432
7511
  /**
7433
- * Starts a file-based document by returning a short-lived presigned S3 PUT URL. Upload the file bytes directly to `uploadUrl`; the document is then automatically queued for text extraction and asynchronous indexing. Supplying an `externalId` makes this idempotent — re-initiating an upload with the same `externalId` re-issues a fresh presigned URL for the SAME existing document, targeting a newly-staged object rather than the document's current one — your PUT lands there first, and the document only adopts it once the upload is validated (so a re-upload inherently replaces the file body) rather than creating a duplicate. The response's `created` field (and the HTTP status — 201 when a new document was minted, 200 when an existing one was matched) tells the two apart. With `?upsert=true`, the submitted `payload`/`title` are also applied to the matched document (file-body divergence cannot be diffed at upload-init — the bytes have not arrived yet — so the re-upload itself replaces the body). Creating a NEW document requires the `documents:c` scope. Re-uploading over an EXISTING document overwrites (and re-indexes) its body, so it is an update: it requires the `documents:u` scope (as does `?upsert=true` for the metadata). **Your PUT to `uploadUrl` MUST include the header named in the response's `requiredHeaderName`, set to `requiredHeaderValue` exactly.** It is a signed part of the presigned request — the PUT never reaches this service either way, so neither failure uses this API's JSON error shape. Omitting it, or sending a different value, invalidates the request's signature and S3 rejects it with a 403 `SignatureDoesNotMatch`. Sending it correctly makes the URL single-use: `requiredHeaderName` is always `If-None-Match` and `requiredHeaderValue` is always `*` — a create, a `#559` heal recovery, and a re-upload ALL target a freshly-minted, never-written key, so the same S3 conditional-write precondition applies uniformly (S3's own documented conditional-write behavior): a replay after the object already exists (from your own completed PUT, or anyone else's) fails with `412 Precondition Failed`; a replay after the object was deleted and never re-created finds an absent key again and can succeed — single-use is a property of the object's existence, not a one-time token. **Either precondition can also fail with `409 Conflict` if your PUT genuinely races a concurrent delete on the same object.** Whichever of `412` or `409` you receive, the remedy is the same: call this endpoint again for a fresh URL rather than retrying the same PUT — a fresh call always targets a new key, so it is never subject to whatever raced the previous one.
7512
+ * Starts a file-based document by returning a short-lived presigned S3 PUT URL. Upload the file bytes directly to `uploadUrl`; the document is then automatically queued for text extraction and asynchronous indexing. Supplying an `externalId` makes this idempotent — re-initiating an upload with the same `externalId` re-issues a fresh presigned URL for the SAME existing document, targeting a newly-staged object rather than the document's current one — your PUT lands there first, and the document only adopts it once the upload is validated (so a re-upload inherently replaces the file body) rather than creating a duplicate. The response's `created` field (and the HTTP status — 201 when a new document was minted, 200 when an existing one was matched) tells the two apart. With `?upsert=true`, the submitted `payload`/`title` are also applied to the matched document (file-body divergence cannot be diffed at upload-init — the bytes have not arrived yet — so the re-upload itself replaces the body). Creating a NEW document requires the `documents:c` scope. Re-uploading over an EXISTING document overwrites (and re-indexes) its body, so it is an update: it requires the `documents:u` scope (as does `?upsert=true` for the metadata). **Your PUT to `uploadUrl` MUST include the header named in the response's `requiredHeaderName`, set to `requiredHeaderValue` exactly.** It is a signed part of the presigned request — the PUT never reaches this service either way, so neither failure uses this API's JSON error shape. Omitting it, or sending a different value, invalidates the request's signature and S3 rejects it with a 403 `SignatureDoesNotMatch`. Sending it correctly makes the URL single-use: `requiredHeaderName` is always `If-None-Match` and `requiredHeaderValue` is always `*` — a create, a recovery upload for a document whose earlier file is missing, and a re-upload ALL target a freshly-minted, never-written key, so the same S3 conditional-write precondition applies uniformly (S3's own documented conditional-write behavior): a replay after the object already exists (from your own completed PUT, or anyone else's) fails with `412 Precondition Failed`; a replay after the object was deleted and never re-created finds an absent key again and can succeed — single-use is a property of the object's existence, not a one-time token. **Either precondition can also fail with `409 Conflict` if your PUT genuinely races a concurrent delete on the same object.** Whichever of `412` or `409` you receive, the remedy is the same: call this endpoint again for a fresh URL rather than retrying the same PUT — a fresh call always targets a new key, so it is never subject to whatever raced the previous one.
7434
7513
  *
7435
7514
  * @param {Vectros.FileUploadRequest} request
7436
7515
  * @param {DocumentsClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -10266,7 +10345,7 @@ var RecordsClient = class {
10266
10345
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/v1/records/lookup");
10267
10346
  }
10268
10347
  /**
10269
- * Returns the tombstone left behind when a record was hard-deleted, confirming the deletion and recording when it happened. Look it up using the deleted record's original ID. Requires the `records:r:<type>` scope.
10348
+ * Returns the tombstone left behind when a record was hard-deleted, confirming the deletion and recording when it happened. Look it up using the deleted record's original ID. Requires the `records:r:<type>` scope, granted without a `data_scope` restriction: the owner of a deleted record cannot be checked, so a credential confined to an ownership compartment (e.g. `data_scope: {scope:org: [org_A]}`) cannot read tombstones, including for records it owned.
10270
10349
  *
10271
10350
  * @param {Vectros.GetRecordTombstoneRequest} request
10272
10351
  * @param {RecordsClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -12384,6 +12463,10 @@ var BlueprintServicePrincipalSchema = z.object({
12384
12463
  displayName: safeDisplayString(z.string().min(1))
12385
12464
  }).strict();
12386
12465
  var ISSUER_ID_RE = CONTEXT_ID_RE;
12466
+ var RESTRICTED_DOMAIN_RE = /^(?=.{3,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
12467
+ function isRestrictedToDomainName(value) {
12468
+ return RESTRICTED_DOMAIN_RE.test(value);
12469
+ }
12387
12470
  var MAX_CAPTURED_CLAIMS = 20;
12388
12471
  var MAX_CAPTURED_CLAIM_NAME_LENGTH = 64;
12389
12472
  function assertNoDuplicateClaimNames(field) {
@@ -12448,6 +12531,26 @@ var BlueprintIssuerSchema = z.object({
12448
12531
  // `safeUrlString` — see `issuer`'s own comment above for why these three trust-anchor
12449
12532
  // fields get the stronger positive check instead of `safeDisplayString`'s blocklist.
12450
12533
  userinfoUri: safeUrlString(z.string().min(1)).optional(),
12534
+ /**
12535
+ * Opt-in domain scope: a domain ALREADY VERIFIED for your account (verified once, by the
12536
+ * account owner, in the developer portal). The registration's `(issuer, audience)` uniqueness
12537
+ * is then scoped to that domain, so an unrelated registration of the same pair never blocks
12538
+ * it, and a presented token matches only when its `hd` claim equals the domain (Google
12539
+ * Workspace emits `hd` natively; other identity providers need a claims-mapping rule that
12540
+ * emits a claim named exactly `hd`). A token with no matching `hd` claim is not matched at all,
12541
+ * so scoping an issuer that already has users signing in without one locks those users out.
12542
+ *
12543
+ * Written the way the platform stores it: a lowercase, ASCII DNS name (`acme.com`), never a URL.
12544
+ * An internationalized domain must be given in its punycode (`xn--…`) form, the same rule
12545
+ * `issuer` follows, so what an operator reads in the plan preview is the domain that is
12546
+ * actually scoped. An unverified domain fails the apply with the platform's `400`.
12547
+ *
12548
+ * Omit to register a domain-less issuer. Omitting it on a re-apply leaves an existing scope
12549
+ * unchanged; removing a scope is `vectros issuers update --clear-restricted-to-domain`.
12550
+ */
12551
+ restrictedToDomain: z.string().regex(RESTRICTED_DOMAIN_RE, {
12552
+ message: "restrictedToDomain must be a lowercase DNS name such as 'acme.com' \u2014 letters, digits and dashes in dot-separated labels, at least two labels, no scheme, path, port or uppercase; write an internationalized domain in its punycode (xn--\u2026) form"
12553
+ }).optional(),
12451
12554
  /**
12452
12555
  * Opt-in self-service signup: a list of {signupType, roleId} pairs. When a first-time exchange
12453
12556
  * caller presents no invite token but names a signupType matching one of these (or omits it and
@@ -15621,6 +15724,357 @@ function renderActionableError(info) {
15621
15724
  return null;
15622
15725
  }
15623
15726
 
15727
+ // src/redact.ts
15728
+ var JOSE_SHAPED = /eyJ[A-Za-z0-9_-]{6,}(?:\.[A-Za-z0-9_-]*){0,4}/g;
15729
+ var LONG_SEGMENTS = /[A-Za-z0-9_-]{20,}(?:\.[A-Za-z0-9_-]{16,}){1,4}/g;
15730
+ var BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
15731
+ var VECTROS_SECRET_ENV = /(?:ssk|sk|st)_(?:live|test)_[A-Za-z0-9._-]+/gi;
15732
+ var VECTROS_SECRET_BARE = /(?<![A-Za-z0-9])(?:ssk|sk|st)_[A-Za-z0-9._-]{8,}/gi;
15733
+ function redactCredentials(text) {
15734
+ return text.replace(BEARER, "Bearer [redacted]").replace(JOSE_SHAPED, "[redacted]").replace(LONG_SEGMENTS, "[redacted]").replace(VECTROS_SECRET_ENV, "[redacted]").replace(VECTROS_SECRET_BARE, "[redacted]");
15735
+ }
15736
+ function looksLikeCredential(value) {
15737
+ const trimmed = value.trim();
15738
+ if (trimmed.length > 200) return true;
15739
+ if (/eyJ[A-Za-z0-9_-]{8,}/.test(trimmed)) return true;
15740
+ const bare = trimmed.replace(/^["'`]+|["'`,;]+$/g, "").replace(/^bearer\s+/i, "");
15741
+ const segments = bare.split(".");
15742
+ return segments.length >= 3 && segments.filter((s) => s.length >= 16).length >= 2;
15743
+ }
15744
+ function safeBaseName(path4) {
15745
+ const base = path4.split(/[\\/]/).filter(Boolean).pop();
15746
+ return base !== void 0 && base.length <= 40 && /^[A-Za-z0-9._ ()-]+$/.test(base) ? base : void 0;
15747
+ }
15748
+
15749
+ // src/provision-options.ts
15750
+ var BLUEPRINT_NAMES_TOKEN = "{{BLUEPRINT_NAMES}}";
15751
+ var PROVISION_OPTIONS = [
15752
+ {
15753
+ flag: "--blueprint",
15754
+ value: "<name|file>",
15755
+ modes: ["bootstrap"],
15756
+ help: [
15757
+ "A bundled blueprint ({{BLUEPRINT_NAMES}}),",
15758
+ 'OR a path to your own blueprint YAML/JSON (./my.yaml) \u2014 the "copy &',
15759
+ 'own" path. File blueprints get the identical scope gate (no override).'
15760
+ ]
15761
+ },
15762
+ {
15763
+ flag: "--set",
15764
+ value: "name=value",
15765
+ modes: ["bootstrap", "apply"],
15766
+ help: [
15767
+ "Set an install-time input value (repeatable) for a file blueprint's",
15768
+ "'inputs:' block. Beats --values + the declared default. (Same flag as",
15769
+ "'vectros blueprint validate/plan'.)"
15770
+ ]
15771
+ },
15772
+ {
15773
+ flag: "--values",
15774
+ value: "<file>",
15775
+ modes: ["bootstrap", "apply"],
15776
+ help: [
15777
+ "A YAML/JSON map of input values. (File blueprints only.)"
15778
+ ]
15779
+ },
15780
+ {
15781
+ flag: "--blank",
15782
+ modes: ["bootstrap"],
15783
+ help: [
15784
+ "Credential only (read-only data-plane profile, no schemas)."
15785
+ ]
15786
+ },
15787
+ {
15788
+ flag: "--no-seed",
15789
+ modes: ["bootstrap"],
15790
+ help: [
15791
+ "Provision the blueprint's schemas, profile and key but SKIP its",
15792
+ "sample seed records \u2014 the right choice for a production context",
15793
+ "(a blueprint's seeds are illustrative, not real data). Unlike",
15794
+ "--blank, the schemas are still created."
15795
+ ]
15796
+ },
15797
+ {
15798
+ // The opposite default to bootstrap's: a re-apply from CI must not write sample data into a tenant by omission.
15799
+ flag: "--seed",
15800
+ modes: ["apply"],
15801
+ help: [
15802
+ "Also write the blueprint's sample seed records. Off by default, in BOTH tenants: apply",
15803
+ "never writes sample data unless you ask (a blueprint's seeds are illustrative, not real",
15804
+ "data). Seeds are written by external id, so a re-run with --seed is idempotent.",
15805
+ "(bootstrap is the other way round: it seeds unless --no-seed. apply refuses --no-seed.)"
15806
+ ]
15807
+ },
15808
+ {
15809
+ flag: "--allow-tenant-wide-namespaces",
15810
+ modes: ["bootstrap", "apply"],
15811
+ help: {
15812
+ bootstrap: [
15813
+ "Required to apply a blueprint that declares a namespace with",
15814
+ "tenantWide: true \u2014 visible to every context in the account, not",
15815
+ "just this one. Without it, bootstrap refuses before provisioning",
15816
+ "anything, naming the namespace(s). Only an account OWNER's",
15817
+ "credential can actually complete the registration either way \u2014",
15818
+ "this flag doesn't change who's allowed, only whether this run",
15819
+ "tries. Never needed for a blueprint with no tenantWide namespace."
15820
+ ],
15821
+ apply: [
15822
+ "Required to apply a blueprint that declares a namespace with",
15823
+ "tenantWide: true \u2014 visible to every context in the account, not",
15824
+ "just this one. Without it, apply refuses before provisioning",
15825
+ "anything, naming the namespace(s). Only an account OWNER's",
15826
+ "credential can actually complete the registration either way \u2014",
15827
+ "this flag doesn't change who's allowed, only whether this run",
15828
+ "tries. Never needed for a blueprint with no tenantWide namespace."
15829
+ ]
15830
+ }
15831
+ },
15832
+ {
15833
+ flag: "--allow-script-adoption",
15834
+ stacked: true,
15835
+ modes: ["bootstrap", "apply"],
15836
+ help: {
15837
+ bootstrap: [
15838
+ "Required to push a script version onto a name that already exists in",
15839
+ "this context but carries no blueprint provenance marker (hand-authored,",
15840
+ "or pushed before this marker existed) \u2014 without it, bootstrap refuses",
15841
+ "before minting anything for that name, naming it. Never needed for a",
15842
+ "brand-new script name, or for re-applying a blueprint that already owns",
15843
+ "the name it's pushing to. Does NOT help if the name is already owned by",
15844
+ "a DIFFERENT blueprint \u2014 that is refused unconditionally, no flag past it."
15845
+ ],
15846
+ apply: [
15847
+ "Required to push a script version onto a name that already exists in",
15848
+ "this context but carries no blueprint provenance marker (hand-authored,",
15849
+ "or pushed before this marker existed) \u2014 without it, apply refuses",
15850
+ "before changing anything for that name, naming it. Never needed for a",
15851
+ "brand-new script name, or for re-applying a blueprint that already owns",
15852
+ "the name it's pushing to. Does NOT help if the name is already owned by",
15853
+ "a DIFFERENT blueprint \u2014 that is refused unconditionally, no flag past it."
15854
+ ]
15855
+ }
15856
+ },
15857
+ {
15858
+ flag: "--tenant",
15859
+ value: "test|live",
15860
+ modes: ["bootstrap", "apply"],
15861
+ help: {
15862
+ bootstrap: [
15863
+ "Which tenant the minted key scopes to (default: live).",
15864
+ "'test' provisions into your isolated test tenant \u2014 a",
15865
+ "dry-run before pointing real data at the same blueprint.",
15866
+ "The tenant rides the key (ssk_test_* vs ssk_live_*), so",
15867
+ "the MCP/client config is otherwise identical."
15868
+ ],
15869
+ apply: [
15870
+ "Which tenant to apply into. REQUIRED: there is no default, so a run",
15871
+ "never lands on 'live' by omission. 'test' is your isolated test tenant",
15872
+ "\u2014 apply there first."
15873
+ ]
15874
+ }
15875
+ },
15876
+ {
15877
+ flag: "--test",
15878
+ modes: ["bootstrap"],
15879
+ help: [
15880
+ "Shorthand for --tenant test."
15881
+ ]
15882
+ },
15883
+ {
15884
+ flag: "--activate",
15885
+ modes: ["bootstrap"],
15886
+ help: [
15887
+ "Force the freshly-stored key to become your active identity, even",
15888
+ "if that would switch away from a currently-active LIVE credential",
15889
+ "(the one case --tenant test does NOT do this automatically)."
15890
+ ]
15891
+ },
15892
+ {
15893
+ flag: "--no-activate",
15894
+ modes: ["bootstrap"],
15895
+ help: [
15896
+ "Store the key without activating it, regardless of tenant."
15897
+ ]
15898
+ },
15899
+ {
15900
+ flag: "--base-url",
15901
+ value: "<url>",
15902
+ modes: ["bootstrap", "apply"],
15903
+ help: [
15904
+ "Override the API base URL (beats VECTROS_API_BASE_URL)."
15905
+ ]
15906
+ },
15907
+ {
15908
+ flag: "--client",
15909
+ value: "<desktop|code>",
15910
+ modes: ["bootstrap"],
15911
+ help: [
15912
+ "MCP host to configure (default: desktop). 'desktop' merges",
15913
+ "claude_desktop_config.json (also works for Cursor/Cline);",
15914
+ "'code' targets Claude Code's project .mcp.json and prints",
15915
+ "the equivalent 'claude mcp add' command."
15916
+ ]
15917
+ },
15918
+ {
15919
+ flag: "--token",
15920
+ value: "<t>",
15921
+ modes: ["bootstrap", "apply"],
15922
+ help: {
15923
+ bootstrap: [
15924
+ "Cognito bridge token; or set VECTROS_BOOTSTRAP_TOKEN.",
15925
+ "Falls back to your stored `vectros login` session",
15926
+ "(same env) when neither is set \u2014 login once, then",
15927
+ "bootstrap unattended."
15928
+ ],
15929
+ apply: [
15930
+ "Cognito bridge token; or set VECTROS_BOOTSTRAP_TOKEN.",
15931
+ "Falls back to your stored `vectros login` session",
15932
+ "(same env) when neither is set \u2014 login once, then",
15933
+ "apply unattended."
15934
+ ]
15935
+ }
15936
+ },
15937
+ {
15938
+ flag: "--yes",
15939
+ aliases: ["-y"],
15940
+ modes: ["bootstrap", "apply"],
15941
+ help: {
15942
+ bootstrap: [
15943
+ "Skip confirmations (agent / CI path)."
15944
+ ],
15945
+ apply: [
15946
+ "Skip the 'Apply this plan?' confirmation. REQUIRED when there is no terminal",
15947
+ "(CI, an agent): apply changes a tenant, so it never runs unattended without this."
15948
+ ]
15949
+ }
15950
+ },
15951
+ {
15952
+ flag: "--no-merge",
15953
+ modes: ["bootstrap"],
15954
+ help: [
15955
+ "Still mints/reconciles \u2014 this only changes where the result goes:",
15956
+ "print the config snippet instead of merging it into your client's",
15957
+ "config file. Not a dry run; see 'blueprint plan' for that. Does NOT",
15958
+ "skip the 'Apply this plan?' confirmation \u2014 pass --yes for that."
15959
+ ]
15960
+ },
15961
+ {
15962
+ flag: "--rotate",
15963
+ modes: ["bootstrap"],
15964
+ help: [
15965
+ "Revoke + re-mint THIS machine's key."
15966
+ ]
15967
+ },
15968
+ {
15969
+ flag: "--require-verified-issuers",
15970
+ modes: ["bootstrap", "apply"],
15971
+ help: {
15972
+ bootstrap: [
15973
+ "Exit 4 (the apply itself still completes) when an issuer this blueprint registers",
15974
+ "is still pending verification. Without it a finished apply exits 0 and prints the",
15975
+ "challenge and the command that finishes verification: an issuer registered without",
15976
+ "restrictedToDomain accepts no token until you prove control of the identity",
15977
+ "provider, which needs a real login that no bootstrap can perform."
15978
+ ],
15979
+ apply: [
15980
+ "Exit 4 (the apply itself still completes) when an issuer this blueprint registers",
15981
+ "is still pending verification. Without it a finished apply exits 0 and prints the",
15982
+ "challenge and the command that finishes verification: an issuer registered without",
15983
+ "restrictedToDomain accepts no token until you prove control of the identity",
15984
+ "provider, which needs a real login that no apply can perform."
15985
+ ]
15986
+ }
15987
+ },
15988
+ {
15989
+ flag: "--confirm-existing-principal",
15990
+ modes: ["bootstrap", "apply"],
15991
+ help: {
15992
+ bootstrap: [
15993
+ "Required (with --yes, or with no TTY) to mint a NEW key bound to a",
15994
+ "service principal that already existed in your tenant before this run",
15995
+ "\u2014 e.g. a deliberate second machine for an integration you already own.",
15996
+ "Refused by default so an unexpected collision halts a CI run loudly",
15997
+ "instead of silently minting a credential bound to the wrong identity.",
15998
+ "Never needed for --rotate or an ordinary same-machine re-apply."
15999
+ ],
16000
+ apply: [
16001
+ "Required (with --yes, or with no TTY) when the blueprint's service principal",
16002
+ "already exists in your tenant. Apply never reads a local key record, so that is",
16003
+ "every apply once the principal exists, on any machine, including one that ran",
16004
+ "bootstrap. It lets apply rewrite that principal's access profile (the",
16005
+ "blueprint's own service principal only). In a pipeline this flag is STANDING",
16006
+ "consent: it also waves through a blueprint whose servicePrincipal.externalId",
16007
+ "collides with an unrelated identity, so keep the blueprint's source reviewed.",
16008
+ "Every run that proceeds prints which principal it reused and why."
16009
+ ]
16010
+ }
16011
+ },
16012
+ {
16013
+ flag: "--prune",
16014
+ modes: ["apply"],
16015
+ help: [
16016
+ "Also remove the triggers this blueprint provisioned earlier and its file no longer",
16017
+ "declares. OFF by default: without it apply never deletes anything. A trigger is",
16018
+ "matched by a STORED MARKER \u2014 its provisionedBy equals the blueprint's name, and its",
16019
+ "principal's external id is the one this blueprint would derive \u2014 not a verified",
16020
+ "identity. The name comes from the blueprint FILE, after any input substitution (so",
16021
+ "--set and --values can change it), and the scope gate bounds what a blueprint may",
16022
+ "grant, not what it may delete. A matched row that cannot be removed (no id or",
16023
+ "principal, a failed read or delete) makes apply exit 1. Rows with no marker are left",
16024
+ "alone and reported; another blueprint's rows are left alone. Needs --yes when there",
16025
+ "is no terminal."
16026
+ ]
16027
+ },
16028
+ {
16029
+ flag: "--use-scoped-token",
16030
+ modes: ["bootstrap"],
16031
+ help: [
16032
+ "Legacy fallback.",
16033
+ "NOTE: it mints a token WITHOUT the provisioning",
16034
+ "capability, so it cannot create a blueprint's",
16035
+ "app-context \u2014 use only with the 'default' context",
16036
+ "(the dedicated endpoint is required otherwise)."
16037
+ ]
16038
+ },
16039
+ {
16040
+ flag: "--help",
16041
+ aliases: ["-h"],
16042
+ modes: ["bootstrap", "apply"],
16043
+ help: [
16044
+ "Show this help."
16045
+ ]
16046
+ },
16047
+ { flag: "--env", value: "<staging|production>", modes: ["bootstrap", "apply"] },
16048
+ { flag: "--blank-slate", modes: ["bootstrap"] }
16049
+ ];
16050
+ function optionsFor(mode) {
16051
+ return PROVISION_OPTIONS.filter((o) => o.modes.includes(mode));
16052
+ }
16053
+ function spellingsFor(mode) {
16054
+ return optionsFor(mode).flatMap((o) => [o.flag, ...o.aliases ?? []]);
16055
+ }
16056
+ function helpLines(option, mode) {
16057
+ if (!option.modes.includes(mode) || option.help === void 0) return void 0;
16058
+ return Array.isArray(option.help) ? option.help : option.help[mode];
16059
+ }
16060
+ var LABEL_COLUMN = 25;
16061
+ var TEXT_INDENT = " ".repeat(LABEL_COLUMN + 2);
16062
+ function renderOptionHelp(mode, blueprintNames) {
16063
+ const blocks = [];
16064
+ for (const option of optionsFor(mode)) {
16065
+ const lines = helpLines(option, mode);
16066
+ if (!lines) continue;
16067
+ const label = [option.flag + (option.value ? ` ${option.value}` : ""), ...option.aliases ?? []].join(", ");
16068
+ const text = lines.map((l) => l.split(BLUEPRINT_NAMES_TOKEN).join(blueprintNames.join(", ")));
16069
+ if (label.length <= LABEL_COLUMN - 2 && !option.stacked) {
16070
+ blocks.push([` ${label.padEnd(LABEL_COLUMN)}${text[0]}`, ...text.slice(1).map((l) => TEXT_INDENT + l)].join("\n"));
16071
+ } else {
16072
+ blocks.push([` ${label}`, ...text.map((l) => TEXT_INDENT + l)].join("\n"));
16073
+ }
16074
+ }
16075
+ return blocks.join("\n");
16076
+ }
16077
+
15624
16078
  // src/base-url.ts
15625
16079
  var ALLOWED_HOST_APEX = "vectros.ai";
15626
16080
  var ALLOWED_HOST_SUFFIX = ".vectros.ai";
@@ -15866,8 +16320,15 @@ import { mkdir, readFile, writeFile, rm } from "fs/promises";
15866
16320
  import { homedir } from "os";
15867
16321
  import { join as join2 } from "path";
15868
16322
 
16323
+ // src/terminal-text.ts
16324
+ var STRIPPED = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
16325
+ function stripControlCharacters(value, maxLength = 200) {
16326
+ const characters = Array.from(value.replace(STRIPPED, ""));
16327
+ return characters.length > maxLength ? `${characters.slice(0, maxLength).join("")}...` : characters.join("");
16328
+ }
16329
+
15869
16330
  // src/pkce.ts
15870
- import { createHash, randomBytes } from "crypto";
16331
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
15871
16332
 
15872
16333
  // src/cognito-config.ts
15873
16334
  var PER_ENV = {
@@ -15974,6 +16435,24 @@ function parseCallback(requestUrl) {
15974
16435
  errorDescription: url.searchParams.get("error_description") ?? void 0
15975
16436
  };
15976
16437
  }
16438
+ var CALLBACK_PATH = "/callback";
16439
+ function stateMatches(received, expected) {
16440
+ if (received === void 0) return false;
16441
+ const a = Buffer.from(received);
16442
+ const b = Buffer.from(expected);
16443
+ return a.length === b.length && timingSafeEqual(a, b);
16444
+ }
16445
+ function classifyCallback(method, requestUrl, expectedState) {
16446
+ if (method !== "GET") return { kind: "not-a-callback" };
16447
+ const target = requestUrl ?? "/";
16448
+ const queryStart = target.indexOf("?");
16449
+ const path4 = queryStart === -1 ? target : target.slice(0, queryStart);
16450
+ if (path4 !== CALLBACK_PATH) return { kind: "not-a-callback" };
16451
+ const params = parseCallback(target);
16452
+ if (!params.code && !params.error) return { kind: "not-a-callback" };
16453
+ if (!stateMatches(params.state, expectedState)) return { kind: "wrong-state" };
16454
+ return { kind: "accepted", params };
16455
+ }
15977
16456
 
15978
16457
  // src/auth-store.ts
15979
16458
  var DIR_NAME = ".vectros";
@@ -16019,7 +16498,7 @@ async function postToken(config, body, fetchImpl) {
16019
16498
  });
16020
16499
  const text = await res.text().catch(() => "");
16021
16500
  if (!res.ok) {
16022
- throw new Error(`token endpoint HTTP ${res.status}${text ? ` \u2014 ${text}` : ""}`);
16501
+ throw new Error(`token endpoint HTTP ${res.status}${text ? ` \u2014 ${stripControlCharacters(text, 500)}` : ""}`);
16023
16502
  }
16024
16503
  try {
16025
16504
  return JSON.parse(text);
@@ -16788,6 +17267,12 @@ async function drainPages(fetch2) {
16788
17267
  return out;
16789
17268
  }
16790
17269
 
17270
+ // src/shell-quote.ts
17271
+ function shellQuote(value) {
17272
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
17273
+ return `'${value.replace(/'/g, `'\\''`)}'`;
17274
+ }
17275
+
16791
17276
  // src/loader.ts
16792
17277
  function actionOf(created) {
16793
17278
  return created ? "created" : "reconciled";
@@ -16975,14 +17460,16 @@ async function pruneUndeclaredTriggers(args) {
16975
17460
  { component: "loader", err: extractServerError(err) },
16976
17461
  "could not list trigger rules \u2014 skipping the trigger reconcile entirely; a trigger removed from this blueprint may still be live"
16977
17462
  );
16978
- return { pruned, skipped: [{ reason: "the context's trigger rules could not be listed" }] };
17463
+ return { pruned, skipped: [{ reason: "the context's trigger rules could not be listed", failed: true }] };
16979
17464
  }
16980
17465
  for (const row of live) {
16981
17466
  if (!row.name || !row.id || !row.principalId) {
16982
17467
  if (!declaredNames.has(row.name ?? "")) {
17468
+ const ours = row.provisionedBy === blueprintName;
16983
17469
  skipped.push({
16984
17470
  name: row.name,
16985
- reason: "the trigger rule carries no id/name/principal, so it cannot be safely de-provisioned"
17471
+ reason: ours ? "it carries this blueprint's marker but has no id, name or principal to remove it by" : "the trigger rule carries no id/name/principal, so it cannot be safely de-provisioned",
17472
+ failed: ours
16986
17473
  });
16987
17474
  }
16988
17475
  continue;
@@ -16991,7 +17478,8 @@ async function pruneUndeclaredTriggers(args) {
16991
17478
  if (!row.provisionedBy) {
16992
17479
  skipped.push({
16993
17480
  name: row.name,
16994
- reason: "it carries no blueprint provenance marker, so it cannot be attributed to this blueprint"
17481
+ reason: "it carries no blueprint provenance marker, so it cannot be attributed to this blueprint",
17482
+ failed: false
16995
17483
  });
16996
17484
  continue;
16997
17485
  }
@@ -17012,7 +17500,8 @@ async function pruneUndeclaredTriggers(args) {
17012
17500
  );
17013
17501
  skipped.push({
17014
17502
  name: row.name,
17015
- reason: "its service principal could not be read, so ownership could not be fully established"
17503
+ reason: "its service principal could not be read, so ownership could not be fully established",
17504
+ failed: true
17016
17505
  });
17017
17506
  continue;
17018
17507
  }
@@ -17023,7 +17512,8 @@ async function pruneUndeclaredTriggers(args) {
17023
17512
  );
17024
17513
  skipped.push({
17025
17514
  name: row.name,
17026
- reason: "it names a service principal this blueprint did not mint for a trigger of that name, so its provenance is inconsistent"
17515
+ reason: "it names a service principal this blueprint did not mint for a trigger of that name, so its provenance is inconsistent",
17516
+ failed: false
17027
17517
  });
17028
17518
  continue;
17029
17519
  }
@@ -17036,7 +17526,7 @@ async function pruneUndeclaredTriggers(args) {
17036
17526
  { component: "loader", triggerName: row.name, principalId: row.principalId, err: extractServerError(err) },
17037
17527
  "could not delete a removed trigger's access profile \u2014 leaving the trigger rule in place so the next apply retries"
17038
17528
  );
17039
- skipped.push({ name: row.name, reason: "its access profile could not be deleted, so its grant may still be live" });
17529
+ skipped.push({ name: row.name, reason: "its access profile could not be deleted, so its grant may still be live", failed: true });
17040
17530
  continue;
17041
17531
  }
17042
17532
  profileDeleted = false;
@@ -17048,7 +17538,7 @@ async function pruneUndeclaredTriggers(args) {
17048
17538
  { component: "loader", triggerName: row.name, err: extractServerError(err) },
17049
17539
  "a removed trigger's access profile was deleted but the rule itself was not \u2014 it can no longer execute, and the next apply will retry the rule"
17050
17540
  );
17051
- skipped.push({ name: row.name, reason: "its grant was removed but the rule row itself could not be deleted" });
17541
+ skipped.push({ name: row.name, reason: "its grant was removed but the rule row itself could not be deleted", failed: true });
17052
17542
  continue;
17053
17543
  }
17054
17544
  pruned.push({ name: row.name, principalId: row.principalId, profileDeleted });
@@ -17138,18 +17628,20 @@ var LostKeyError = class extends Error {
17138
17628
  keyName;
17139
17629
  };
17140
17630
  var ExistingPrincipalNotConfirmedError = class extends Error {
17141
- constructor(contextId, userId, externalId) {
17631
+ constructor(contextId, userId, externalId, mintsKey = true) {
17142
17632
  super(
17143
- `About to bind a NEW scoped key to a principal that already existed in this tenant before this run (externalId '${externalId}', userId '${userId}', context '${contextId}'). This is either an intentional multi-machine setup for an integration you already own, or this blueprint's servicePrincipal.externalId collided with an unrelated existing identity \u2014 confirm before a new credential is minted bound to it.`
17633
+ mintsKey ? `About to bind a NEW scoped key to a principal that already existed in this tenant before this run (externalId '${externalId}', userId '${userId}', context '${contextId}'). This is either an intentional multi-machine setup for an integration you already own, or this blueprint's servicePrincipal.externalId collided with an unrelated existing identity \u2014 confirm before a new credential is minted bound to it.` : `About to write the grants of a principal that already existed in this tenant before this run (externalId '${externalId}', userId '${userId}', context '${contextId}'). This is either a re-apply of a blueprint you already manage, or this blueprint's servicePrincipal.externalId collided with an unrelated existing identity \u2014 confirm before that identity's access profile is changed.`
17144
17634
  );
17145
17635
  this.contextId = contextId;
17146
17636
  this.userId = userId;
17147
17637
  this.externalId = externalId;
17638
+ this.mintsKey = mintsKey;
17148
17639
  this.name = "ExistingPrincipalNotConfirmedError";
17149
17640
  }
17150
17641
  contextId;
17151
17642
  userId;
17152
17643
  externalId;
17644
+ mintsKey;
17153
17645
  };
17154
17646
  function mapEnumValues(enumValues) {
17155
17647
  if (!enumValues) return void 0;
@@ -17252,6 +17744,30 @@ function assertNoUnresolvedIdentities(blueprint) {
17252
17744
  );
17253
17745
  }
17254
17746
  }
17747
+ var IssuerScopeNotAppliedError = class extends Error {
17748
+ constructor(issuerId, contextId, declared, returned, phase) {
17749
+ super(
17750
+ `issuer '${issuerId}' was ${phase} ${returned ? `restricted to '${returned}'` : "with no domain scope"}, but the blueprint declares restrictedToDomain '${declared}': the platform did not apply it, so the issuer accepts more tokens than declared. Once the platform applies the field, run: vectros issuers update ${shellQuote(issuerId)} --context ${shellQuote(contextId)} --restricted-to-domain ${shellQuote(declared)}`
17751
+ );
17752
+ this.issuerId = issuerId;
17753
+ this.contextId = contextId;
17754
+ this.declared = declared;
17755
+ this.returned = returned;
17756
+ this.phase = phase;
17757
+ this.name = "IssuerScopeNotAppliedError";
17758
+ }
17759
+ issuerId;
17760
+ contextId;
17761
+ declared;
17762
+ returned;
17763
+ phase;
17764
+ };
17765
+ function assertIssuerScopeApplied(issuer, returned, phase) {
17766
+ const declared = issuer.restrictedToDomain;
17767
+ if (declared === void 0) return;
17768
+ if ((returned ?? "").toLowerCase() === declared.toLowerCase()) return;
17769
+ throw new IssuerScopeNotAppliedError(issuer.issuerId, issuer.contextId, declared, returned, phase);
17770
+ }
17255
17771
  var TenantWideNamespacesNotAllowedError = class extends Error {
17256
17772
  constructor(message) {
17257
17773
  super(message);
@@ -17307,7 +17823,12 @@ async function provisionContext(client, input) {
17307
17823
  externalId: blueprint.servicePrincipal.externalId
17308
17824
  }) : true;
17309
17825
  if (!proceed) {
17310
- throw new ExistingPrincipalNotConfirmedError(blueprint.contextId, userId, blueprint.servicePrincipal.externalId);
17826
+ throw new ExistingPrincipalNotConfirmedError(
17827
+ blueprint.contextId,
17828
+ userId,
17829
+ blueprint.servicePrincipal.externalId,
17830
+ !input.skipKey
17831
+ );
17311
17832
  }
17312
17833
  log.debug(
17313
17834
  { component: "loader", contextId: blueprint.contextId, userId },
@@ -17325,7 +17846,14 @@ async function provisionContext(client, input) {
17325
17846
  namespaceActions.push({ namespace: ns.namespace, action });
17326
17847
  log.debug({ component: "loader", namespace: ns.namespace, contextId: blueprint.contextId, action }, "namespace ensured");
17327
17848
  }
17328
- return { contextAction, servicePrincipalUserId: userId, userAction, namespaceActions, ctxBootstrapClient };
17849
+ return {
17850
+ contextAction,
17851
+ servicePrincipalUserId: userId,
17852
+ ...user.createdAt === void 0 ? {} : { servicePrincipalCreatedAt: user.createdAt },
17853
+ userAction,
17854
+ namespaceActions,
17855
+ ctxBootstrapClient
17856
+ };
17329
17857
  }
17330
17858
  async function runLoader(client, input) {
17331
17859
  const {
@@ -17333,6 +17861,7 @@ async function runLoader(client, input) {
17333
17861
  tenantId,
17334
17862
  machineId,
17335
17863
  rotate = false,
17864
+ skipKey = false,
17336
17865
  skipSeed = false,
17337
17866
  seedIndexPoll,
17338
17867
  provisioned,
@@ -17341,6 +17870,9 @@ async function runLoader(client, input) {
17341
17870
  allowScriptAdoption = false,
17342
17871
  log
17343
17872
  } = input;
17873
+ if (skipKey && rotate) {
17874
+ throw new Error("rotate and skipKey cannot be combined: rotate replaces this machine's key, and skipKey mints none.");
17875
+ }
17344
17876
  assertNoUnresolvedIdentities(blueprint);
17345
17877
  const { contextAction, servicePrincipalUserId: userId, userAction, namespaceActions } = provisioned;
17346
17878
  assertBlueprintGates(blueprint);
@@ -17364,13 +17896,15 @@ async function runLoader(client, input) {
17364
17896
  subClaim: issuer.subClaim,
17365
17897
  emailClaim: issuer.emailClaim,
17366
17898
  userinfoUri: issuer.userinfoUri,
17899
+ restrictedToDomain: issuer.restrictedToDomain,
17367
17900
  selfSignupPolicies,
17368
17901
  capturedClaims: issuer.capturedClaims
17369
17902
  });
17370
17903
  const action = actionOf(issuerResp.created);
17371
- issuerActions.push({ issuerId: issuer.issuerId, action });
17904
+ let observed = issuerResp;
17905
+ if (action === "created") assertIssuerScopeApplied(issuer, issuerResp.restrictedToDomain, "registered");
17372
17906
  if (action === "reconciled") {
17373
- await ctxBootstrapClient.auth.updateIssuer({
17907
+ const updated = await ctxBootstrapClient.auth.updateIssuer({
17374
17908
  issuerId: issuer.issuerId,
17375
17909
  // Trust-anchor/routing-pin fields — echoed back, never actually
17376
17910
  // changed here (the platform rejects a differing value).
@@ -17381,13 +17915,35 @@ async function runLoader(client, input) {
17381
17915
  subClaim: issuer.subClaim,
17382
17916
  emailClaim: issuer.emailClaim,
17383
17917
  userinfoUri: issuer.userinfoUri,
17918
+ // Undeclared stays `undefined` (leave the stored scope unchanged) — never '', which the
17919
+ // platform reads as "remove the scope". That "never ''" invariant is enforced by the blueprint
17920
+ // schema, not here: the schema's grammar has no empty spelling, and this CLI is only ever run
17921
+ // as a binary, so a blueprint always passes through `parseBlueprint` before it reaches this loop.
17922
+ restrictedToDomain: issuer.restrictedToDomain,
17384
17923
  selfSignupPolicies,
17385
17924
  capturedClaims: issuer.capturedClaims
17386
17925
  });
17926
+ assertIssuerScopeApplied(issuer, updated.restrictedToDomain, "reconciled");
17927
+ if (updated.status !== void 0) {
17928
+ observed = {
17929
+ status: updated.status,
17930
+ verificationClaim: updated.verificationClaim,
17931
+ verificationNonce: updated.verificationNonce,
17932
+ verificationExpiresAt: updated.verificationExpiresAt
17933
+ };
17934
+ }
17387
17935
  log.debug({ component: "loader", issuerId: issuer.issuerId }, "issuer already registered \u2014 reconciled safe fields to the declared values");
17388
17936
  } else {
17389
17937
  log.debug({ component: "loader", issuerId: issuer.issuerId, action }, "issuer registered");
17390
17938
  }
17939
+ issuerActions.push({
17940
+ issuerId: issuer.issuerId,
17941
+ action,
17942
+ ...observed.status !== void 0 ? { status: observed.status } : {},
17943
+ ...observed.verificationClaim !== void 0 ? { verificationClaim: observed.verificationClaim } : {},
17944
+ ...observed.verificationNonce !== void 0 ? { verificationNonce: observed.verificationNonce } : {},
17945
+ ...observed.verificationExpiresAt !== void 0 ? { verificationExpiresAt: observed.verificationExpiresAt } : {}
17946
+ });
17391
17947
  }
17392
17948
  const ctxClient = input.remintForContext ? await input.remintForContext(blueprint.contextId) : client;
17393
17949
  log.debug({ component: "loader", contextId: blueprint.contextId }, "re-minted per-context client");
@@ -17588,7 +18144,9 @@ async function runLoader(client, input) {
17588
18144
  { component: "loader", contextId: blueprint.contextId },
17589
18145
  "trigger reconcile-by-absence skipped \u2014 no per-context client is wired, so a removed trigger may still be live"
17590
18146
  );
17591
- triggerPruneSkipped = [{ reason: "no per-context client was wired, so triggers could not be reconciled in the blueprint's own context" }];
18147
+ triggerPruneSkipped = [
18148
+ { reason: "no per-context client was wired, so triggers could not be reconciled in the blueprint's own context", failed: true }
18149
+ ];
17592
18150
  } else {
17593
18151
  ({ pruned: triggerPrunes, skipped: triggerPruneSkipped } = await pruneUndeclaredTriggers({
17594
18152
  ctxClient,
@@ -17598,74 +18156,76 @@ async function runLoader(client, input) {
17598
18156
  log
17599
18157
  }));
17600
18158
  }
17601
- const keyName = keyNameFor(blueprint.name, machineId);
17602
- let keyAction = "created";
18159
+ const keyName = skipKey ? "" : keyNameFor(blueprint.name, machineId);
18160
+ let keyAction = skipKey ? "skipped" : "created";
17603
18161
  let revokedKeyId;
17604
- if (rotate) {
17605
- const existing = await readExistingKey(blueprint.contextId);
17606
- if (existing?.keyId) {
17607
- await ctxClient.auth.revokeScopedKey({ keyId: existing.keyId });
17608
- log.debug({ component: "loader", keyId: existing.keyId }, "revoked prior key for rotate");
17609
- revokedKeyId = [existing.keyId];
17610
- } else {
17611
- let activeKeys;
17612
- try {
17613
- activeKeys = await drainPages(() => ctxClient.auth.listScopedKeys());
17614
- } catch (err) {
17615
- throw new Error(
17616
- `--rotate could not check for a server-side key to revoke, and no local record of one exists on this machine: ${err instanceof Error ? err.message : String(err)}. Run \`vectros key list --context ${blueprint.contextId}\` and \`vectros key revoke <id>\` manually to find and remove the key blocking this, then re-run without --rotate.`,
17617
- { cause: err }
17618
- );
17619
- }
17620
- const matches = activeKeys.filter(
17621
- (k) => k.contextId === blueprint.contextId && k.userId === userId && k.keyName === keyName && k.status === "active" && k.keyId
17622
- );
17623
- if (matches.length > 1) {
17624
- log.warn(
17625
- { component: "loader", keyName, contextId: blueprint.contextId, count: matches.length },
17626
- "rotate: more than one active key matched this context/user/name tuple server-side \u2014 revoking all of them"
17627
- );
17628
- }
17629
- if (matches.length > 0) {
17630
- const revokedIds = [];
17631
- for (const match of matches) {
17632
- await ctxClient.auth.revokeScopedKey({ keyId: match.keyId });
17633
- revokedIds.push(match.keyId);
17634
- }
17635
- log.debug(
17636
- { component: "loader", keyIds: revokedIds },
17637
- "revoked prior key(s) for rotate \u2014 resolved server-side, no local record existed"
17638
- );
17639
- revokedKeyId = revokedIds;
18162
+ let rawKey = "";
18163
+ let keyId = "";
18164
+ if (!skipKey) {
18165
+ if (rotate) {
18166
+ const existing = await readExistingKey(blueprint.contextId);
18167
+ if (existing?.keyId) {
18168
+ await ctxClient.auth.revokeScopedKey({ keyId: existing.keyId });
18169
+ log.debug({ component: "loader", keyId: existing.keyId }, "revoked prior key for rotate");
18170
+ revokedKeyId = [existing.keyId];
17640
18171
  } else {
17641
- log.warn(
17642
- { component: "loader", keyName, contextId: blueprint.contextId },
17643
- "rotate: no local record and no matching active key found server-side \u2014 nothing to revoke"
18172
+ let activeKeys;
18173
+ try {
18174
+ activeKeys = await drainPages(() => ctxClient.auth.listScopedKeys());
18175
+ } catch (err) {
18176
+ throw new Error(
18177
+ `--rotate could not check for a server-side key to revoke, and no local record of one exists on this machine: ${err instanceof Error ? err.message : String(err)}. Run \`vectros key list --context ${blueprint.contextId}\` and \`vectros key revoke <id>\` manually to find and remove the key blocking this, then re-run without --rotate.`,
18178
+ { cause: err }
18179
+ );
18180
+ }
18181
+ const matches = activeKeys.filter(
18182
+ (k) => k.contextId === blueprint.contextId && k.userId === userId && k.keyName === keyName && k.status === "active" && k.keyId
17644
18183
  );
18184
+ if (matches.length > 1) {
18185
+ log.warn(
18186
+ { component: "loader", keyName, contextId: blueprint.contextId, count: matches.length },
18187
+ "rotate: more than one active key matched this context/user/name tuple server-side \u2014 revoking all of them"
18188
+ );
18189
+ }
18190
+ if (matches.length > 0) {
18191
+ const revokedIds = [];
18192
+ for (const match of matches) {
18193
+ await ctxClient.auth.revokeScopedKey({ keyId: match.keyId });
18194
+ revokedIds.push(match.keyId);
18195
+ }
18196
+ log.debug(
18197
+ { component: "loader", keyIds: revokedIds },
18198
+ "revoked prior key(s) for rotate \u2014 resolved server-side, no local record existed"
18199
+ );
18200
+ revokedKeyId = revokedIds;
18201
+ } else {
18202
+ log.warn(
18203
+ { component: "loader", keyName, contextId: blueprint.contextId },
18204
+ "rotate: no local record and no matching active key found server-side \u2014 nothing to revoke"
18205
+ );
18206
+ }
17645
18207
  }
18208
+ if (revokedKeyId) keyAction = "rotated";
17646
18209
  }
17647
- if (revokedKeyId) keyAction = "rotated";
17648
- }
17649
- const keyResp = await ctxClient.auth.createScopedKey({ keyName, tenantId, contextId: blueprint.contextId, userId });
17650
- let rawKey;
17651
- let keyId;
17652
- if (keyResp.rawKey) {
17653
- keyId = keyResp.keyId ?? "";
17654
- rawKey = keyResp.rawKey;
17655
- if (keyId) {
17656
- await persistMintedKey(blueprint.contextId, { keyId, rawKey, keyName, principalId: `usr_${userId}`, revokedKeyId });
17657
- }
17658
- if (keyAction !== "rotated") keyAction = "created";
17659
- } else {
17660
- const existing = await readExistingKey(blueprint.contextId);
17661
- if (!existing) {
17662
- throw new LostKeyError(keyName, rotate, revokedKeyId);
18210
+ const keyResp = await ctxClient.auth.createScopedKey({ keyName, tenantId, contextId: blueprint.contextId, userId });
18211
+ if (keyResp.rawKey) {
18212
+ keyId = keyResp.keyId ?? "";
18213
+ rawKey = keyResp.rawKey;
18214
+ if (keyId) {
18215
+ await persistMintedKey(blueprint.contextId, { keyId, rawKey, keyName, principalId: `usr_${userId}`, revokedKeyId });
18216
+ }
18217
+ if (keyAction !== "rotated") keyAction = "created";
18218
+ } else {
18219
+ const existing = await readExistingKey(blueprint.contextId);
18220
+ if (!existing) {
18221
+ throw new LostKeyError(keyName, rotate, revokedKeyId);
18222
+ }
18223
+ rawKey = existing.rawKey;
18224
+ keyId = existing.keyId;
18225
+ keyAction = "reused";
17663
18226
  }
17664
- rawKey = existing.rawKey;
17665
- keyId = existing.keyId;
17666
- keyAction = "reused";
18227
+ log.debug({ component: "loader", keyName, keyAction }, "scoped key resolved");
17667
18228
  }
17668
- log.debug({ component: "loader", keyName, keyAction }, "scoped key resolved");
17669
18229
  let seededCount = 0;
17670
18230
  const seededRecordIds = [];
17671
18231
  const seededDocumentIds = [];
@@ -17773,8 +18333,7 @@ async function runLoader(client, input) {
17773
18333
  };
17774
18334
  }
17775
18335
  function sanitizeForPlanDisplay(value, maxLen = 200) {
17776
- const stripped = value.replace(/[\x00-\x1f\x7f]/g, "");
17777
- return stripped.length > maxLen ? `${stripped.slice(0, maxLen)}\u2026` : stripped;
18336
+ return stripControlCharacters(value, maxLen);
17778
18337
  }
17779
18338
  function renderPlanDataScope(ds) {
17780
18339
  const entries = Object.entries(ds ?? {});
@@ -17805,11 +18364,12 @@ function buildPlan(blueprint, opts = {}) {
17805
18364
  const issuerUrl = sanitizeForPlanDisplay(iss.issuer);
17806
18365
  const jwksNote = `, jwksUri: ${sanitizeForPlanDisplay(iss.jwksUri)}`;
17807
18366
  const userinfoNote = iss.userinfoUri ? `, userinfoUri: ${sanitizeForPlanDisplay(iss.userinfoUri)}` : "";
18367
+ const domainNote = iss.restrictedToDomain ? `, restricted to domain: ${sanitizeForPlanDisplay(iss.restrictedToDomain)}` : "";
17808
18368
  const selfSignup = iss.selfSignupPolicies?.length ? `, self-signup: [${iss.selfSignupPolicies.map((p) => `${p.signupType}\u2192${p.roleId}`).join(", ")}]` : "";
17809
18369
  const captured = iss.capturedClaims?.length ? `, captured claims: [${iss.capturedClaims.map((c) => sanitizeForPlanDisplay(c)).join(", ")}]` : "";
17810
18370
  steps.push({
17811
18371
  kind: "issuer",
17812
- summary: `issuer '${iss.issuerId}' (${issuerUrl} \u2192 context '${iss.contextId}'${jwksNote}${userinfoNote}${selfSignup}${captured}) [${BLUEPRINT_FIELD_PHASES.issuers}-token phase]`
18372
+ summary: `issuer '${iss.issuerId}' (${issuerUrl} \u2192 context '${iss.contextId}'${jwksNote}${userinfoNote}${domainNote}${selfSignup}${captured}) [${BLUEPRINT_FIELD_PHASES.issuers}-token phase]`
17813
18373
  });
17814
18374
  }
17815
18375
  for (const s of blueprint.schemas) {
@@ -17899,7 +18459,9 @@ function buildPlan(blueprint, opts = {}) {
17899
18459
  });
17900
18460
  }
17901
18461
  }
17902
- steps.push({ kind: "key", summary: `key ${blueprint.name}-<machine> (mint 1 scoped ssk_*)` });
18462
+ if (!opts.noKey) {
18463
+ steps.push({ kind: "key", summary: `key ${blueprint.name}-<machine> (mint 1 scoped ssk_*)` });
18464
+ }
17903
18465
  const seeds = blueprint.seed ?? [];
17904
18466
  if (seeds.length > 0 && !opts.noSeed) {
17905
18467
  const docs = seeds.filter((s) => s.surface === "document").length;
@@ -17911,20 +18473,32 @@ function buildPlan(blueprint, opts = {}) {
17911
18473
  }
17912
18474
  return steps;
17913
18475
  }
17914
- function renderPlan(blueprint, steps) {
18476
+ function renderPlan(blueprint, steps, opts = {}) {
17915
18477
  const header = ` Plan for blueprint '${blueprint.name}' (v${blueprint.version}) \u2192 context '${blueprint.contextId}':`;
17916
18478
  const legendLines = [
17917
18479
  ` Apply is a reconcile \u2014 each item is created if absent, else updated in place to`,
17918
18480
  ` match. Nothing is recreated, and nothing is dropped except as noted below.`
17919
18481
  ];
17920
- legendLines.push(
17921
- ` Exception: triggers are also reconciled by ABSENCE \u2014 a trigger this blueprint`,
17922
- ` previously provisioned and no longer declares is DELETED (the rule and its access`,
17923
- ` profile; its service principal is kept). This applies even when this plan shows no`,
17924
- ` trigger steps: removing the last one is exactly what leaves none to show. Triggers`,
17925
- ` created outside this blueprint are never touched. Each deletion is named in the`,
17926
- ` apply output.`
17927
- );
18482
+ if (opts.prune === false) {
18483
+ legendLines.push(
18484
+ ` Nothing is deleted: this apply does not prune. A trigger this blueprint provisioned`,
18485
+ ` earlier and its file no longer declares is LEFT IN PLACE; pass --prune to remove it.`,
18486
+ ` (Access profiles and roles are still replaced in place: their scopes become the declared set.)`
18487
+ );
18488
+ } else {
18489
+ legendLines.push(
18490
+ opts.prune === true ? ` Exception (--prune): triggers are also reconciled by ABSENCE \u2014 a trigger this blueprint` : ` Exception: triggers are also reconciled by ABSENCE \u2014 a trigger this blueprint`,
18491
+ ` previously provisioned and no longer declares is DELETED (the rule and its access`,
18492
+ ` profile; its service principal is kept). This applies even when this plan shows no`,
18493
+ ` trigger steps: removing the last one is exactly what leaves none to show. Triggers`,
18494
+ ` created outside this blueprint are never touched. Each deletion is named in the`,
18495
+ ` apply output. bootstrap does this on every apply; 'vectros blueprint apply' only`,
18496
+ ` when --prune is passed.`,
18497
+ ` "This blueprint's" is decided by a STORED MARKER, not a verified identity: the row's`,
18498
+ ` provisionedBy equals this blueprint's name (read from the blueprint file) and its`,
18499
+ ` principal's external id is the one this blueprint would derive for that trigger.`
18500
+ );
18501
+ }
17928
18502
  legendLines.push(
17929
18503
  ` Counter-exception: a script is never updated in place \u2014 a changed source ADDS a new`,
17930
18504
  ` immutable version and every earlier one persists, which is also how a 'latest' rule`,
@@ -17937,12 +18511,19 @@ function renderPlan(blueprint, steps) {
17937
18511
  ` and would break any trigger still referencing it. Remove one deliberately with`,
17938
18512
  ` 'vectros scripts delete', after 'vectros scripts list' shows you what is there.`
17939
18513
  );
18514
+ if (blueprint.issuers?.some((i) => i.restrictedToDomain === void 0)) {
18515
+ legendLines.push(
18516
+ ` Note: a newly registered issuer without restrictedToDomain starts pending verification on platform`,
18517
+ ` 0.45.0 and later: it accepts no token until you prove you control the identity provider.`,
18518
+ ` the apply prints the challenge and the command that finishes it.`
18519
+ );
18520
+ }
17940
18521
  if (steps.some((s) => s.kind === "issuer")) {
17941
18522
  legendLines.push(
17942
18523
  ` Exception: an issuer's trust-anchor fields (issuer, jwksUri, audience,`,
17943
18524
  ` contextId) are immutable once registered \u2014 only subClaim/emailClaim/`,
17944
- ` userinfoUri/selfSignupPolicies actually reconcile on a re-apply (status is not`,
17945
- ` authorable in a blueprint at all).`
18525
+ ` userinfoUri/restrictedToDomain/selfSignupPolicies/capturedClaims actually reconcile`,
18526
+ ` on a re-apply (status is not authorable in a blueprint at all).`
17946
18527
  );
17947
18528
  }
17948
18529
  if (steps.some((s) => s.kind === "namespace")) {
@@ -17963,6 +18544,104 @@ function renderPlan(blueprint, steps) {
17963
18544
  return [header, legend, "", ...lines].join("\n");
17964
18545
  }
17965
18546
 
18547
+ // src/issuer-challenge.ts
18548
+ var PENDING_VERIFICATION = "pending_verification";
18549
+ function isPendingVerification(view) {
18550
+ return view.status === PENDING_VERIFICATION;
18551
+ }
18552
+ function challengeExpired(view, nowMs) {
18553
+ if (!view.verificationExpiresAt) return false;
18554
+ const at = Date.parse(view.verificationExpiresAt);
18555
+ return Number.isFinite(at) && at <= nowMs;
18556
+ }
18557
+ function humanizeRemaining(ms) {
18558
+ const minutes = Math.floor(ms / 6e4);
18559
+ if (minutes < 1) return "under a minute";
18560
+ const days = Math.floor(minutes / 1440);
18561
+ const hours = Math.floor(minutes % 1440 / 60);
18562
+ const mins = minutes % 60;
18563
+ if (days > 0) return `${days} day${days === 1 ? "" : "s"} ${hours} h`;
18564
+ if (hours > 0) return `${hours} h ${mins} min`;
18565
+ return `${mins} min`;
18566
+ }
18567
+ function targetFlags(ctx) {
18568
+ return (ctx.tenant && ctx.tenant !== "live" ? ` --tenant ${shellQuote(ctx.tenant)}` : "") + (ctx.env && ctx.env !== "production" ? ` --env ${shellQuote(ctx.env)}` : "") + (ctx.baseUrl ? ` --base-url ${shellQuote(ctx.baseUrl)}` : "");
18569
+ }
18570
+ function verifyCommand(issuerId, ctx) {
18571
+ return `vectros issuers verify ${shellQuote(issuerId)} --context ${shellQuote(ctx.context)}${targetFlags(ctx)} --idp-token-file ./idp-token.txt`;
18572
+ }
18573
+ function deleteCommand(issuerId, ctx) {
18574
+ return `vectros issuers delete ${shellQuote(issuerId)} --context ${shellQuote(ctx.context)}${targetFlags(ctx)} --force`;
18575
+ }
18576
+ function getCommand(issuerId, ctx) {
18577
+ return `vectros issuers get ${shellQuote(issuerId)} --context ${shellQuote(ctx.context)}${targetFlags(ctx)}`;
18578
+ }
18579
+ var WITHHELD = "(withheld: unexpected characters)";
18580
+ var SAFE_TEXT = /^[A-Za-z0-9:/._~+-]{1,512}$/;
18581
+ function printable(value) {
18582
+ return SAFE_TEXT.test(value) ? value : void 0;
18583
+ }
18584
+ function displayChallengeValue(value) {
18585
+ return value === void 0 ? void 0 : printable(value) ?? WITHHELD;
18586
+ }
18587
+ function renderChallengeBlock(view, ctx, now) {
18588
+ const id = view.issuerId ?? "(unknown)";
18589
+ const rawClaim = view.verificationClaim;
18590
+ const rawNonce = view.verificationNonce;
18591
+ const claim = rawClaim === void 0 ? "(not reported)" : printable(rawClaim) ?? WITHHELD;
18592
+ const nonce = rawNonce === void 0 ? "(not reported)" : printable(rawNonce) ?? WITHHELD;
18593
+ const snippetClaim = rawClaim !== void 0 && printable(rawClaim) ? rawClaim : "the claim named above";
18594
+ const lines = [];
18595
+ lines.push(` \u26A0 issuer '${id}' is pending verification: it accepts no token until you prove you control it.`);
18596
+ lines.push("");
18597
+ lines.push(" 1. At your identity provider, add an ADMIN-CONTROLLED rule that puts this exact value in this");
18598
+ lines.push(" claim of the tokens it issues (never map it from an attribute your users can edit):");
18599
+ lines.push(` claim: ${claim}`);
18600
+ lines.push(` value: ${nonce}`);
18601
+ if (view.verificationExpiresAt) {
18602
+ const expires = printable(view.verificationExpiresAt) ?? WITHHELD;
18603
+ const at = Date.parse(view.verificationExpiresAt);
18604
+ const left = Number.isFinite(at) ? ` (in ${humanizeRemaining(at - now)})` : "";
18605
+ lines.push(` expires: ${expires}${left}`);
18606
+ }
18607
+ lines.push("");
18608
+ lines.push(" Auth0 a post-login Action; keep the value in a secret named VECTROS_ISSUER_NONCE:");
18609
+ lines.push(" exports.onExecutePostLogin = async (event, api) => {");
18610
+ lines.push(` const claim = '${snippetClaim}';`);
18611
+ lines.push(" api.idToken.setCustomClaim(claim, event.secrets.VECTROS_ISSUER_NONCE);");
18612
+ lines.push(" api.accessToken.setCustomClaim(claim, event.secrets.VECTROS_ISSUER_NONCE);");
18613
+ lines.push(" };");
18614
+ lines.push(" Okta a token inline hook, or a custom claim on your authorization server, that adds the");
18615
+ lines.push(" claim with this fixed value to the access token.");
18616
+ lines.push(" Entra a claims-mapping policy (or custom claims provider) on the application that adds");
18617
+ lines.push(" the claim with this fixed value.");
18618
+ lines.push(' Keycloak a "Hardcoded claim" protocol mapper on the client, added to the access token and');
18619
+ lines.push(' the ID token. Keycloak reads a dot in "Token Claim Name" as nesting, so write each');
18620
+ lines.push(" dot in the claim name as \\. to keep it a single claim.");
18621
+ lines.push(" Other any admin-controlled rule that adds this claim with this exact value.");
18622
+ lines.push("");
18623
+ lines.push(" 2. Sign in once through that provider and save the JWT it returns to a file. It must be a token");
18624
+ lines.push(" issued for the audience you registered (with Auth0, the ACCESS token, not the ID token). Run:");
18625
+ lines.push(` ${verifyCommand(id, ctx)}`);
18626
+ lines.push("");
18627
+ lines.push(" The provider's OpenID Connect discovery document must list exactly the jwksUri you registered.");
18628
+ lines.push(" The token is checked once and discarded; the rule can be removed after verification.");
18629
+ return `${lines.join("\n")}
18630
+ `;
18631
+ }
18632
+ function renderExpiredBlock(view, ctx, rerun) {
18633
+ const id = view.issuerId ?? "(unknown)";
18634
+ const expired = view.verificationExpiresAt === void 0 ? "(unknown)" : printable(view.verificationExpiresAt) ?? WITHHELD;
18635
+ const lines = [];
18636
+ lines.push(` \u2716 issuer '${id}' is STILL pending verification and its challenge EXPIRED at ${expired} (by this machine's clock).`);
18637
+ lines.push(" It can no longer be verified, so it will never accept a token. Replace it (if that time looks wrong, check the clock first):");
18638
+ lines.push(` ${deleteCommand(id, ctx)}`);
18639
+ lines.push(` ${rerun ?? "then register it again"}`);
18640
+ lines.push(" The new registration gets a new value, so update the rule at your identity provider to match.");
18641
+ return `${lines.join("\n")}
18642
+ `;
18643
+ }
18644
+
17966
18645
  // src/config-merge.ts
17967
18646
  import { promises as fs2 } from "fs";
17968
18647
  import os2 from "os";
@@ -20099,6 +20778,7 @@ function parseBootstrapArgs(argv) {
20099
20778
  noSeed: false,
20100
20779
  allowTenantWideNamespaces: false,
20101
20780
  allowScriptAdoption: false,
20781
+ requireVerifiedIssuers: false,
20102
20782
  client: "desktop",
20103
20783
  tenant: "live",
20104
20784
  help: false
@@ -20166,6 +20846,9 @@ function parseBootstrapArgs(argv) {
20166
20846
  case "--allow-script-adoption":
20167
20847
  args.allowScriptAdoption = true;
20168
20848
  break;
20849
+ case "--require-verified-issuers":
20850
+ args.requireVerifiedIssuers = true;
20851
+ break;
20169
20852
  case "--tenant": {
20170
20853
  const v = argv[++i];
20171
20854
  if (v !== "test" && v !== "live") {
@@ -20238,6 +20921,31 @@ function seedIndexWarningBlock(failures) {
20238
20921
  ` + lines.join("") + ` They're stored and retrievable by id, but won't appear in search until the issue above is resolved.
20239
20922
  `;
20240
20923
  }
20924
+ var REQUIRE_VERIFIED_EXIT = 4;
20925
+ function issuerTally(items) {
20926
+ const base = reconcileTally(items, "issuer");
20927
+ if (!base) return null;
20928
+ const pending = items.filter(isPendingVerification).length;
20929
+ return pending > 0 ? base.replace(/\)$/, `; ${pending} pending verification)`) : base;
20930
+ }
20931
+ function pendingIssuerBlock(actions, ctx, now, rerun = "then re-run this `vectros bootstrap` command") {
20932
+ return actions.filter(isPendingVerification).map((a) => challengeExpired(a, now) ? renderExpiredBlock(a, ctx, rerun) : renderChallengeBlock(a, ctx, now)).join("\n");
20933
+ }
20934
+ function finishIssuers(actions, ctx, requireVerified, now, rerun) {
20935
+ const block = pendingIssuerBlock(actions, ctx, now, rerun);
20936
+ if (block) process.stderr.write(`
20937
+ ${block}`);
20938
+ const pending = actions.filter(isPendingVerification).length;
20939
+ if (requireVerified && pending > 0) {
20940
+ process.stderr.write(
20941
+ `
20942
+ \u2716 --require-verified-issuers: ${pending} issuer${pending === 1 ? " is" : "s are"} still ${PENDING_VERIFICATION.replace("_", " ")} (exit ${REQUIRE_VERIFIED_EXIT}). The apply itself completed.
20943
+ `
20944
+ );
20945
+ return REQUIRE_VERIFIED_EXIT;
20946
+ }
20947
+ return 0;
20948
+ }
20241
20949
  function reconcileTally(items, noun) {
20242
20950
  if (items.length === 0) return null;
20243
20951
  const created = items.filter((i) => i.action === "created").length;
@@ -20273,13 +20981,13 @@ function scriptTally(items) {
20273
20981
  if (unchanged) seg.push(`${unchanged} already current \u2014 nothing written`);
20274
20982
  return `${items.length} script${items.length === 1 ? "" : "s"} (${seg.join("; ")})`;
20275
20983
  }
20276
- function applyOutcomeSummary(result) {
20984
+ function applyOutcomeSummary(result, opts = {}) {
20277
20985
  const parts = [
20278
20986
  reconcileTally(result.schemaActions, "schema"),
20279
20987
  reconcileTally(result.roleActions, "role"),
20280
20988
  // The platform's issuer partial-update endpoint makes an issuer's
20281
20989
  // 'reconciled' mean the same "updated to match" as every other kind here.
20282
- reconcileTally(result.issuerActions, "issuer"),
20990
+ issuerTally(result.issuerActions),
20283
20991
  namespaceTally(result.namespaceActions),
20284
20992
  // Scripts — reported BEFORE triggers, mirroring the order they are provisioned in, and with
20285
20993
  // their own wording because "reconciled" would be a lie: a script version is immutable.
@@ -20292,7 +21000,7 @@ function applyOutcomeSummary(result) {
20292
21000
  `profile ${result.profileAction}`
20293
21001
  ].filter((p) => p !== null);
20294
21002
  const detail = parts.length ? ` \u2014 ${parts.join("; ")}` : "";
20295
- const principalNote = result.userAction === "reconciled" ? ` \u26A0 service principal already existed in this tenant before this run (not newly created)
21003
+ const principalNote = (opts.principalNote ?? true) && result.userAction === "reconciled" ? ` \u26A0 service principal already existed in this tenant before this run (not newly created)
20296
21004
  ` : "";
20297
21005
  const pruneNotes = result.triggerPrunes.map(
20298
21006
  (p) => ` \u26A0 trigger '${p.name}' removed from the blueprint \u2014 de-provisioned (rule + access profile deleted)
@@ -20303,13 +21011,31 @@ function applyOutcomeSummary(result) {
20303
21011
  `)
20304
21012
  ).join("");
20305
21013
  const pruneSkippedNotes = result.triggerPruneSkipped.map(
20306
- (s) => s.name ? ` \u26A0 trigger '${s.name}' is no longer declared but could NOT be removed \u2014 ${s.reason}. It is still live.
21014
+ (s) => s.failed === false ? s.name ? ` \u2139 trigger '${s.name}' is not declared here and was left in place \u2014 ${s.reason}.
21015
+ ` : ` \u2139 a trigger rule was left in place \u2014 ${s.reason}.
21016
+ ` : s.name ? ` \u26A0 trigger '${s.name}' is no longer declared but could NOT be removed \u2014 ${s.reason}. It is still live.
20307
21017
  ` : ` \u26A0 triggers could not be reconciled this run \u2014 ${s.reason}. A trigger removed from this blueprint may still be live.
20308
21018
  `
20309
21019
  ).join("");
20310
21020
  return ` \u2714 context '${result.contextId}' ${result.contextAction}${detail}
20311
21021
  ${principalNote}${pruneNotes}${pruneSkippedNotes}`;
20312
21022
  }
21023
+ function gateSummary(blueprint) {
21024
+ const roleNote = blueprint.accessProfile.roleIds ? ` via role(s) [${blueprint.accessProfile.roleIds.join(", ")}]` : "";
21025
+ const acceptedActions = effectiveAccessProfileActions(blueprint);
21026
+ const allDataPlane = !acceptedActions.some(
21027
+ (a) => KNOWN_CONTROL_PLANE_RESOURCES.includes(a.split(":", 1)[0])
21028
+ );
21029
+ let out = `
21030
+ Blueprint: ${blueprint.name}
21031
+ \u2714 Scope gate: [${acceptedActions.join(", ")}]${roleNote}` + (allDataPlane ? " \u2014 all data-plane \u2713\n" : " \u2014 includes a narrow, allowlisted control-plane exception (see above)\n");
21032
+ if (blueprint.accessProfile.capabilities?.length) {
21033
+ const capGate = evaluateCapabilityGate(blueprint.accessProfile.capabilities);
21034
+ out += ` \u2714 Capability gate: [${capGate.accepted.join(", ")}] \u2014 granted \u2713
21035
+ `;
21036
+ }
21037
+ return out;
21038
+ }
20313
21039
  var BOOTSTRAP_HELP = `vectros bootstrap \u2014 provision a least-privilege MCP credential.
20314
21040
 
20315
21041
  Usage:
@@ -20317,78 +21043,28 @@ Usage:
20317
21043
  [--set name=value ...] [--values <file>]
20318
21044
  [--tenant test|live | --test] [--client desktop|code]
20319
21045
  [--yes] [--no-merge] [--rotate] [--confirm-existing-principal]
21046
+ [--require-verified-issuers]
20320
21047
  [--token <t> | env VECTROS_BOOTSTRAP_TOKEN]
20321
21048
  [--base-url <url>] [--use-scoped-token]
20322
21049
 
21050
+ To re-apply a blueprint into an existing context WITHOUT minting a key (CI/GitOps), use
21051
+ 'vectros blueprint apply' instead.
21052
+
20323
21053
  Options:
20324
- --blueprint <name|file> A bundled blueprint (${BLUEPRINT_NAMES.join(", ")}),
20325
- OR a path to your own blueprint YAML/JSON (./my.yaml) \u2014 the "copy &
20326
- own" path. File blueprints get the identical scope gate (no override).
20327
- --set name=value Set an install-time input value (repeatable) for a file blueprint's
20328
- 'inputs:' block. Beats --values + the declared default. (Same flag as
20329
- 'vectros blueprint validate/plan'.)
20330
- --values <file> A YAML/JSON map of input values. (File blueprints only.)
20331
- --blank Credential only (read-only data-plane profile, no schemas).
20332
- --no-seed Provision the blueprint's schemas, profile and key but SKIP its
20333
- sample seed records \u2014 the right choice for a production context
20334
- (a blueprint's seeds are illustrative, not real data). Unlike
20335
- --blank, the schemas are still created.
20336
- --allow-tenant-wide-namespaces
20337
- Required to apply a blueprint that declares a namespace with
20338
- tenantWide: true \u2014 visible to every context in the account, not
20339
- just this one. Without it, bootstrap refuses before provisioning
20340
- anything, naming the namespace(s). Only an account OWNER's
20341
- credential can actually complete the registration either way \u2014
20342
- this flag doesn't change who's allowed, only whether this run
20343
- tries. Never needed for a blueprint with no tenantWide namespace.
20344
- --allow-script-adoption
20345
- Required to push a script version onto a name that already exists in
20346
- this context but carries no blueprint provenance marker (hand-authored,
20347
- or pushed before this marker existed) \u2014 without it, bootstrap refuses
20348
- before minting anything for that name, naming it. Never needed for a
20349
- brand-new script name, or for re-applying a blueprint that already owns
20350
- the name it's pushing to. Does NOT help if the name is already owned by
20351
- a DIFFERENT blueprint \u2014 that is refused unconditionally, no flag past it.
20352
- --tenant test|live Which tenant the minted key scopes to (default: live).
20353
- 'test' provisions into your isolated test tenant \u2014 a
20354
- dry-run before pointing real data at the same blueprint.
20355
- The tenant rides the key (ssk_test_* vs ssk_live_*), so
20356
- the MCP/client config is otherwise identical.
20357
- --test Shorthand for --tenant test.
20358
- --activate Force the freshly-stored key to become your active identity, even
20359
- if that would switch away from a currently-active LIVE credential
20360
- (the one case --tenant test does NOT do this automatically).
20361
- --no-activate Store the key without activating it, regardless of tenant.
20362
- --base-url <url> Override the API base URL (beats VECTROS_API_BASE_URL).
20363
- --client <desktop|code> MCP host to configure (default: desktop). 'desktop' merges
20364
- claude_desktop_config.json (also works for Cursor/Cline);
20365
- 'code' targets Claude Code's project .mcp.json and prints
20366
- the equivalent 'claude mcp add' command.
20367
- --token <t> Cognito bridge token; or set VECTROS_BOOTSTRAP_TOKEN.
20368
- Falls back to your stored \`vectros login\` session
20369
- (same env) when neither is set \u2014 login once, then
20370
- bootstrap unattended.
20371
- --yes, -y Skip confirmations (agent / CI path).
20372
- --no-merge Still mints/reconciles \u2014 this only changes where the result goes:
20373
- print the config snippet instead of merging it into your client's
20374
- config file. Not a dry run; see 'blueprint plan' for that. Does NOT
20375
- skip the 'Apply this plan?' confirmation \u2014 pass --yes for that.
20376
- --rotate Revoke + re-mint THIS machine's key.
20377
- --confirm-existing-principal
20378
- Required (with --yes, or with no TTY) to mint a NEW key bound to a
20379
- service principal that already existed in your tenant before this run
20380
- \u2014 e.g. a deliberate second machine for an integration you already own.
20381
- Refused by default so an unexpected collision halts a CI run loudly
20382
- instead of silently minting a credential bound to the wrong identity.
20383
- Never needed for --rotate or an ordinary same-machine re-apply.
20384
- --use-scoped-token Legacy fallback.
20385
- NOTE: it mints a token WITHOUT the provisioning
20386
- capability, so it cannot create a blueprint's
20387
- app-context \u2014 use only with the 'default' context
20388
- (the dedicated endpoint is required otherwise).
20389
- --help, -h Show this help.
21054
+ ${renderOptionHelp("bootstrap", BLUEPRINT_NAMES)}
20390
21055
 
20391
- No --blueprint/--blank in a TTY \u2192 interactive menu.`;
21056
+ No --blueprint/--blank in a TTY \u2192 interactive menu.
21057
+
21058
+ Exit codes:
21059
+ 0 applied; also --help, and answering no at "Apply this plan?" ("Aborted - nothing minted")
21060
+ 1 failed: a bad flag, an unknown or malformed blueprint, a missing or invalid --set input, a
21061
+ declared issuer scope the platform did not apply, or any error while applying (the message says
21062
+ what did and did not run)
21063
+ 2 refused by a gate: the scope gate or the capability gate rejected the blueprint, an identity it
21064
+ references could not be resolved, or it declares a tenant-wide namespace without the opt-in (the
21065
+ message says what, if anything, was already provisioned)
21066
+ 3 needs a confirmation it did not get (an existing service principal, a key that cannot be recovered)
21067
+ 4 applied in full, but --require-verified-issuers was given and an issuer is pending verification`;
20392
21068
  async function resolveBlueprint(args, prompt, isTty) {
20393
21069
  if (args.blankSlate) {
20394
21070
  if (args.set.length > 0 || args.values) {
@@ -20490,7 +21166,7 @@ async function runBootstrapCli(argv, deps = {}) {
20490
21166
  try {
20491
21167
  args = parseBootstrapArgs(argv);
20492
21168
  } catch (err) {
20493
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}
21169
+ process.stderr.write(`${redactCredentials(err instanceof Error ? err.message : String(err))}
20494
21170
 
20495
21171
  ${BOOTSTRAP_HELP}
20496
21172
  `);
@@ -20532,21 +21208,7 @@ ${BOOTSTRAP_HELP}
20532
21208
  }
20533
21209
  throw err;
20534
21210
  }
20535
- const roleNote = blueprint.accessProfile.roleIds ? ` via role(s) [${blueprint.accessProfile.roleIds.join(", ")}]` : "";
20536
- const acceptedActions = effectiveAccessProfileActions(blueprint);
20537
- const allDataPlane = !acceptedActions.some(
20538
- (a) => KNOWN_CONTROL_PLANE_RESOURCES.includes(a.split(":", 1)[0])
20539
- );
20540
- process.stdout.write(
20541
- `
20542
- Blueprint: ${blueprint.name}
20543
- \u2714 Scope gate: [${acceptedActions.join(", ")}]${roleNote}` + (allDataPlane ? " \u2014 all data-plane \u2713\n" : " \u2014 includes a narrow, allowlisted control-plane exception (see above)\n")
20544
- );
20545
- if (blueprint.accessProfile.capabilities?.length) {
20546
- const capGate = evaluateCapabilityGate(blueprint.accessProfile.capabilities);
20547
- process.stdout.write(` \u2714 Capability gate: [${capGate.accepted.join(", ")}] \u2014 granted \u2713
20548
- `);
20549
- }
21211
+ process.stdout.write(gateSummary(blueprint));
20550
21212
  let noSeed = args.noSeed;
20551
21213
  let seedDeclinedInteractively = false;
20552
21214
  if (shouldOfferSeedOptOut(args, blueprint, isTty)) {
@@ -20719,6 +21381,12 @@ ${renderPlan(blueprint, planSteps)}
20719
21381
  }),
20720
21382
  log
20721
21383
  });
21384
+ const finishApply = () => finishIssuers(
21385
+ result.issuerActions,
21386
+ { context: blueprint.contextId, tenant: args.tenant, env: args.env, baseUrl: args.baseUrl },
21387
+ args.requireVerifiedIssuers,
21388
+ (deps.now ?? Date.now)()
21389
+ );
20722
21390
  process.stdout.write(
20723
21391
  applyOutcomeSummary(result) + ` \u2714 ${args.tenant} key (${result.keyAction}) \u2192 ${result.keyName}
20724
21392
  ` + seedSummaryLine({
@@ -20754,7 +21422,7 @@ ${renderPlan(blueprint, planSteps)}
20754
21422
 
20755
21423
  ${snippet}
20756
21424
  ${codeAddHint}`);
20757
- return 0;
21425
+ return finishApply();
20758
21426
  }
20759
21427
  let doMerge = args.yes;
20760
21428
  if (!doMerge && isTty) {
@@ -20768,7 +21436,7 @@ ${codeAddHint}`);
20768
21436
 
20769
21437
  ${snippet}
20770
21438
  ${codeAddHint}`);
20771
- return 0;
21439
+ return finishApply();
20772
21440
  }
20773
21441
  try {
20774
21442
  const merged = await safeMergeConfigFile({ configPath, entry });
@@ -20789,7 +21457,7 @@ ${codeAddHint}`);
20789
21457
  ${snippet}
20790
21458
  ${codeAddHint}`);
20791
21459
  }
20792
- return 0;
21460
+ return finishApply();
20793
21461
  } catch (err) {
20794
21462
  if (err instanceof LostKeyError) {
20795
21463
  process.stderr.write(`
@@ -20818,8 +21486,17 @@ ${codeAddHint}`);
20818
21486
  );
20819
21487
  return 2;
20820
21488
  }
20821
- if (err instanceof TenantWideNamespacesNotAllowedError) {
20822
- process.stderr.write(`
21489
+ if (err instanceof IssuerScopeNotAppliedError) {
21490
+ process.stderr.write(
21491
+ `
21492
+ \u2716 ${err.message}
21493
+ The app-context, service principal, and any declared namespaces were already provisioned. No schemas, profile, roles, key, or seed records were.
21494
+ `
21495
+ );
21496
+ return 1;
21497
+ }
21498
+ if (err instanceof TenantWideNamespacesNotAllowedError) {
21499
+ process.stderr.write(`
20823
21500
  \u2716 ${err.message}
20824
21501
  Nothing was provisioned.
20825
21502
  `);
@@ -20840,6 +21517,494 @@ ${formatCliError(err, { action: "Bootstrap failed" })}`);
20840
21517
  // src/cli-blueprint.ts
20841
21518
  import { writeFile as writeFile2, access } from "fs/promises";
20842
21519
 
21520
+ // src/cli-apply.ts
21521
+ import * as readline3 from "readline/promises";
21522
+ function bootstrapOnly(spelling) {
21523
+ return spellingsFor("bootstrap").includes(spelling) && !spellingsFor("apply").includes(spelling);
21524
+ }
21525
+ function parseApplyArgs(argv) {
21526
+ const args = {
21527
+ set: [],
21528
+ env: "production",
21529
+ yes: false,
21530
+ seed: false,
21531
+ allowTenantWideNamespaces: false,
21532
+ allowScriptAdoption: false,
21533
+ requireVerifiedIssuers: false,
21534
+ confirmExistingPrincipal: false,
21535
+ prune: false,
21536
+ help: false
21537
+ };
21538
+ const value = (flag, i) => {
21539
+ const v = argv[i + 1];
21540
+ if (v === void 0 || v.startsWith("-") && v !== "-") throw new Error(`${flag} needs a value`);
21541
+ if (v === "") throw new Error(`${flag} needs a non-empty value`);
21542
+ return v;
21543
+ };
21544
+ for (let i = 0; i < argv.length; i++) {
21545
+ const a = argv[i];
21546
+ switch (a) {
21547
+ case "--set":
21548
+ args.set.push(value(a, i));
21549
+ i++;
21550
+ break;
21551
+ case "--values":
21552
+ args.values = value(a, i);
21553
+ i++;
21554
+ break;
21555
+ case "--env": {
21556
+ const v = value(a, i);
21557
+ i++;
21558
+ if (v !== "staging" && v !== "production") {
21559
+ throw new Error(`--env must be 'staging' or 'production' (got '${v}')`);
21560
+ }
21561
+ args.env = v;
21562
+ break;
21563
+ }
21564
+ case "--base-url":
21565
+ args.baseUrl = value(a, i);
21566
+ i++;
21567
+ break;
21568
+ case "--token":
21569
+ args.token = value(a, i);
21570
+ i++;
21571
+ break;
21572
+ case "--tenant": {
21573
+ const v = value(a, i);
21574
+ i++;
21575
+ if (v !== "test" && v !== "live") throw new Error(`--tenant must be 'test' or 'live' (got '${v}')`);
21576
+ if (args.tenant !== void 0) throw new Error("--tenant was given more than once");
21577
+ args.tenant = v;
21578
+ break;
21579
+ }
21580
+ case "--yes":
21581
+ case "-y":
21582
+ args.yes = true;
21583
+ break;
21584
+ case "--seed":
21585
+ args.seed = true;
21586
+ break;
21587
+ case "--allow-tenant-wide-namespaces":
21588
+ args.allowTenantWideNamespaces = true;
21589
+ break;
21590
+ case "--allow-script-adoption":
21591
+ args.allowScriptAdoption = true;
21592
+ break;
21593
+ case "--require-verified-issuers":
21594
+ args.requireVerifiedIssuers = true;
21595
+ break;
21596
+ case "--confirm-existing-principal":
21597
+ args.confirmExistingPrincipal = true;
21598
+ break;
21599
+ case "--prune":
21600
+ args.prune = true;
21601
+ break;
21602
+ case "--help":
21603
+ case "-h":
21604
+ args.help = true;
21605
+ break;
21606
+ default:
21607
+ if (a === "--test") {
21608
+ throw new Error("apply has no --test shorthand: write --tenant test (there is no default tenant)");
21609
+ }
21610
+ if (a === "--no-seed") {
21611
+ throw new Error("apply does not seed by default: pass --seed to write the sample records");
21612
+ }
21613
+ if (a === "--blueprint") {
21614
+ throw new Error("apply takes the blueprint as an argument, not a flag: vectros blueprint apply <file|name> --tenant test|live");
21615
+ }
21616
+ if (a === "--blank" || a === "--blank-slate") {
21617
+ throw new Error("apply has no blank slate: it needs a blueprint (vectros blueprint apply <file|name> --tenant test|live)");
21618
+ }
21619
+ if (bootstrapOnly(a)) {
21620
+ throw new Error(
21621
+ `${a} belongs to 'vectros bootstrap': apply mints no key and touches no client configuration`
21622
+ );
21623
+ }
21624
+ if (a.startsWith("-")) {
21625
+ const name = a.split("=")[0];
21626
+ throw new Error(
21627
+ `Unknown apply flag: ${name}` + (a.includes("=") ? " (a flag's value is a separate argument, not name=value)" : "")
21628
+ );
21629
+ }
21630
+ if (args.blueprint !== void 0) throw new Error("Unexpected extra argument: apply takes one blueprint");
21631
+ args.blueprint = a;
21632
+ }
21633
+ }
21634
+ return args;
21635
+ }
21636
+ function applySeedNotice(blueprint, seed) {
21637
+ const declared = blueprint.seed?.length ?? 0;
21638
+ if (declared === 0) {
21639
+ return seed ? "\n Sample records: --seed was passed, but this blueprint declares none.\n" : "";
21640
+ }
21641
+ return seed ? `
21642
+ Sample records: --seed was passed, so the ${declared} sample record(s) in the plan above WILL be written.
21643
+ ` : `
21644
+ Sample records: this blueprint declares ${declared}; they will NOT be written (apply seeds only with --seed).
21645
+ `;
21646
+ }
21647
+ function applySeedSummary(blueprint, seed, seededCount) {
21648
+ const declared = blueprint.seed?.length ?? 0;
21649
+ if (declared === 0) return seed ? " \u2714 Sample records: none written \u2014 this blueprint declares none\n" : "";
21650
+ if (seed) {
21651
+ const shortfall = seededCount < declared ? " \u2014 the rest were skipped (this blueprint declares no schema for their type)" : "";
21652
+ return ` \u2714 Sample records: seeded ${seededCount} of ${declared} (--seed)${shortfall}
21653
+ `;
21654
+ }
21655
+ return ` \u2714 Sample records: none written \u2014 this blueprint declares ${declared}; pass --seed to write them
21656
+ `;
21657
+ }
21658
+ var APPLY_HELP = `vectros blueprint apply \u2014 provision a blueprint into a tenant, minting no key.
21659
+
21660
+ Usage:
21661
+ vectros blueprint apply <file|name> --tenant test|live [--yes] [--prune] [--env staging|production]
21662
+ [--set name=value ...] [--values <file>] [--seed]
21663
+ [--confirm-existing-principal] [--require-verified-issuers]
21664
+ [--allow-tenant-wide-namespaces] [--allow-script-adoption]
21665
+ [--token <t> | env VECTROS_BOOTSTRAP_TOKEN] [--base-url <url>]
21666
+
21667
+ <file|name> is a path to your own blueprint (YAML/JSON) or the name of a bundled one
21668
+ (${BLUEPRINT_NAMES.join(", ")}). Everything it declares is created or reconciled \u2014 the context, its
21669
+ service principal and access profile, schemas, roles, issuers, triggers and scripts (and, only with --seed, its
21670
+ sample seed records) \u2014 but NO scoped key is minted, nothing is written to a keyring and no MCP client config is
21671
+ touched, so a CI runner can re-apply a changed blueprint. To also get a credential, use 'vectros bootstrap'. It
21672
+ authenticates the same way bootstrap does and refuses on the same gates (scope, capability, tenant-wide namespaces)
21673
+ before anything changes. (A script pushed onto an existing hand-authored name is checked when the scripts are
21674
+ pushed, partway through: --allow-script-adoption.)
21675
+
21676
+ Before it changes anything it prints the account, tenant, context and API it is pointed at, then the plan, and
21677
+ asks to proceed unless --yes is given. With no terminal --yes is required.
21678
+
21679
+ Options:
21680
+ ${renderOptionHelp("apply", BLUEPRINT_NAMES)}
21681
+
21682
+ Deletion: apply deletes nothing unless --prune is passed. With --prune it removes each trigger in the context
21683
+ whose stored 'provisionedBy' marker equals this blueprint's name and whose principal's external id is the one this
21684
+ blueprint would derive, and which the file no longer declares. Both are stored markers, not verified identities,
21685
+ and the name comes from the blueprint FILE (after any \${{ inputs.* }} substitution, so --set and --values can change
21686
+ it): a blueprint that reuses another one's name and omits some of its triggers will remove them. A trigger counts
21687
+ as matched when it carries this blueprint's marker and the file no longer declares it; if such a row cannot be
21688
+ removed (no id or principal to remove it by, or a failed read or delete) apply exits 1. Rows with no marker are left
21689
+ alone and reported; rows marked by another blueprint are left alone. Apply still replaces an access profile's scopes
21690
+ and a role's rules in place, as bootstrap does.
21691
+
21692
+ Seed records: unlike bootstrap, apply writes NO sample seed records unless --seed is passed, in either tenant (a
21693
+ blueprint's seeds are illustrative, not real data, and a re-apply from a pipeline must not write them by omission).
21694
+ The plan and the summary say plainly whether they will be or were written. --no-seed is refused here: apply already
21695
+ does not seed, so the flag would do nothing.
21696
+
21697
+ Exit codes:
21698
+ 0 applied; also --help, and answering no at "Apply this plan?" (nothing was changed)
21699
+ 1 failed while applying, or --prune could not remove a trigger it matched (steps earlier in the plan may
21700
+ already have been applied; a re-run converges)
21701
+ 2 refused, before anything was changed: a usage error (no --tenant, no --yes without a terminal, no
21702
+ credential, an unknown flag, an unknown or malformed blueprint, a missing --set input, an invalid base
21703
+ URL), the scope or capability gate, or a tenant-wide namespace without the opt-in. Also 2, but AFTER the
21704
+ context and the service principal were provisioned: an identity the blueprint references could not be
21705
+ resolved (the message says so)
21706
+ 3 needs a confirmation it did not get (an existing service principal: --confirm-existing-principal)
21707
+ 4 applied in full, but --require-verified-issuers was given and an issuer is pending verification`;
21708
+ function blueprintRefusal(err) {
21709
+ const message = err instanceof Error ? err.message : String(err);
21710
+ if (message.startsWith("Unknown blueprint ")) {
21711
+ return `No bundled blueprint matches the argument you gave. Bundled blueprints: ${BLUEPRINT_NAMES.join(", ")} (or pass a path like ./my.yaml).`;
21712
+ }
21713
+ return message;
21714
+ }
21715
+ var refuse = (message) => {
21716
+ process.stderr.write(`
21717
+ \u2716 ${redactCredentials(message)}
21718
+ Nothing was changed.
21719
+ `);
21720
+ return 2;
21721
+ };
21722
+ async function runApplyCli(argv, deps = {}) {
21723
+ const log = deps.log ?? createLogger2();
21724
+ let args;
21725
+ try {
21726
+ args = parseApplyArgs(argv);
21727
+ } catch (err) {
21728
+ process.stderr.write(
21729
+ `
21730
+ \u2716 ${redactCredentials(err instanceof Error ? err.message : String(err))}
21731
+ See 'vectros blueprint apply --help'. Nothing was changed.
21732
+ `
21733
+ );
21734
+ return 2;
21735
+ }
21736
+ if (args.help) {
21737
+ process.stdout.write(`${APPLY_HELP}
21738
+ `);
21739
+ return 0;
21740
+ }
21741
+ if (!args.blueprint) {
21742
+ return refuse("apply needs a blueprint: vectros blueprint apply <file|name> --tenant test|live");
21743
+ }
21744
+ if (!args.tenant) {
21745
+ return refuse(
21746
+ "apply needs --tenant test|live. There is no default, so a run never lands on 'live' by omission."
21747
+ );
21748
+ }
21749
+ const tenant = args.tenant;
21750
+ const isTty = deps.isTty ?? Boolean(process.stdin.isTTY);
21751
+ if (!args.yes && !isTty) {
21752
+ return refuse(
21753
+ "apply needs --yes when there is no terminal to confirm on: it changes a tenant" + (args.prune ? " and, with --prune, deletes triggers" : "") + ", so it never runs unattended without it. Re-run with --yes."
21754
+ );
21755
+ }
21756
+ let baseUrl;
21757
+ try {
21758
+ baseUrl = envToBaseUrl(args.env, args.baseUrl ?? process.env.VECTROS_API_BASE_URL);
21759
+ } catch (err) {
21760
+ return refuse(err instanceof Error ? err.message : String(err));
21761
+ }
21762
+ const promptCtx = deps.createPrompt ? deps.createPrompt() : (() => {
21763
+ const rl = readline3.createInterface({ input: process.stdin, output: process.stdout });
21764
+ return { prompt: (q) => rl.question(q), close: () => rl.close() };
21765
+ })();
21766
+ try {
21767
+ let blueprint;
21768
+ try {
21769
+ blueprint = await resolveBlueprint(
21770
+ { blueprint: args.blueprint, set: args.set, values: args.values, blankSlate: false },
21771
+ promptCtx.prompt,
21772
+ isTty
21773
+ );
21774
+ validateBlueprint(blueprint);
21775
+ assertTenantWideNamespacesAllowed(blueprint, args.allowTenantWideNamespaces);
21776
+ } catch (err) {
21777
+ if (err instanceof ScopeGateError || err instanceof CapabilityGateError) {
21778
+ process.stderr.write(
21779
+ `
21780
+ \u2716 ${err instanceof ScopeGateError ? "Scope" : "Capability"} gate refused blueprint:
21781
+ `
21782
+ );
21783
+ for (const r of err.rejected) process.stderr.write(` \u2022 ${formatRejection(r)}
21784
+ `);
21785
+ process.stderr.write(" Nothing was changed.\n");
21786
+ return 2;
21787
+ }
21788
+ return refuse(blueprintRefusal(err));
21789
+ }
21790
+ process.stdout.write(gateSummary(blueprint));
21791
+ const noSeed = !args.seed;
21792
+ let cognitoToken;
21793
+ try {
21794
+ cognitoToken = await resolveBridgeCognitoToken(args, promptCtx.prompt, isTty, deps.resolveCognitoToken);
21795
+ } catch (err) {
21796
+ return refuse(err instanceof Error ? err.message : String(err));
21797
+ }
21798
+ const mintBridge = deps.mintBridgeToken ?? mintBridgeToken;
21799
+ const mintContext = deps.mintContextToken ?? mintContextToken;
21800
+ let client;
21801
+ let tenantId;
21802
+ try {
21803
+ const bridge = await mintBridge({ cognitoToken, apiBaseUrl: baseUrl, tenant });
21804
+ client = new VectrosClient({ token: bridge.token, environment: baseUrl });
21805
+ const identity = await (deps.resolveIdentity ?? resolveIdentity)({ log, apiKey: bridge.token, environment: baseUrl });
21806
+ if (!identity.tenantId || typeof identity.tenantId !== "string") {
21807
+ throw new Error("the extended ping (tenant_id) must be deployed for apply to show which account it is pointed at");
21808
+ }
21809
+ tenantId = identity.tenantId;
21810
+ } catch (err) {
21811
+ process.stderr.write(`
21812
+ ${formatCliError(err, { action: "Could not authenticate or resolve the account" })} Nothing was changed.
21813
+ `);
21814
+ return 1;
21815
+ }
21816
+ process.stdout.write(
21817
+ `
21818
+ Target
21819
+ account ${tenantId}
21820
+ tenant ${tenant}
21821
+ context ${blueprint.contextId}
21822
+ api ${baseUrl}
21823
+ `
21824
+ );
21825
+ process.stdout.write(
21826
+ `
21827
+ ${renderPlan(blueprint, buildPlan(blueprint, { noSeed, noKey: true }), { prune: args.prune })}
21828
+ `
21829
+ );
21830
+ process.stdout.write(applySeedNotice(blueprint, args.seed));
21831
+ const declaredIdentities = Object.entries(blueprint.identities ?? {});
21832
+ if (declaredIdentities.length > 0) {
21833
+ process.stdout.write("\n Identities to ensure (tenant-wide, idempotent by external id):\n");
21834
+ for (const [name, decl] of declaredIdentities) {
21835
+ process.stdout.write(` ~ ${name} (${decl.kind}) \u2014 external id '${decl.externalId}'
21836
+ `);
21837
+ }
21838
+ }
21839
+ if (args.prune) {
21840
+ process.stdout.write(
21841
+ `
21842
+ \u26A0 --prune: this apply will also remove any trigger provisioned under blueprint '${blueprint.name}' that the file no longer declares \u2014 matched by stored marker, not a verified identity.
21843
+ `
21844
+ );
21845
+ }
21846
+ if (!args.yes) {
21847
+ const ans = (await promptCtx.prompt("\n Apply this plan? [y/N]: ")).trim().toLowerCase();
21848
+ if (ans !== "y" && ans !== "yes") {
21849
+ process.stdout.write(" Aborted \u2014 nothing was changed.\n");
21850
+ return 0;
21851
+ }
21852
+ try {
21853
+ client = new VectrosClient({
21854
+ token: (await mintBridge({ cognitoToken, apiBaseUrl: baseUrl, tenant })).token,
21855
+ environment: baseUrl
21856
+ });
21857
+ } catch (err) {
21858
+ process.stderr.write(
21859
+ `
21860
+ ${formatCliError(err, { action: "Could not mint a fresh bootstrap token after your confirmation" })} Nothing was changed.
21861
+ `
21862
+ );
21863
+ return 1;
21864
+ }
21865
+ }
21866
+ let confirmedBy;
21867
+ const confirmExistingPrincipal = async (d) => {
21868
+ if (!args.yes && isTty) {
21869
+ const ans = await promptCtx.prompt(
21870
+ `
21871
+ \u26A0 '${d.externalId}' already exists in your tenant (context '${d.contextId}') \u2014 this apply will REWRITE that
21872
+ existing identity's access profile to the blueprint's grants. Expected when you re-apply a blueprint you
21873
+ already manage. If you don't recognize this identity, do NOT proceed: it may be a name collision with
21874
+ something you don't control.
21875
+ Proceed and rewrite its access profile? [y/N]: `
21876
+ );
21877
+ const ok = existingPrincipalConfirmed(ans);
21878
+ if (ok) confirmedBy = "prompt";
21879
+ return ok;
21880
+ }
21881
+ if (args.confirmExistingPrincipal) confirmedBy = "flag";
21882
+ return args.confirmExistingPrincipal;
21883
+ };
21884
+ const remintBootstrapForContext = buildRemintBootstrapForContext({
21885
+ cognitoToken,
21886
+ apiBaseUrl: baseUrl,
21887
+ tenant,
21888
+ mintBridge
21889
+ });
21890
+ const provisioned = await (deps.provisionContext ?? provisionContext)(client, {
21891
+ blueprint,
21892
+ allowTenantWideNamespaces: args.allowTenantWideNamespaces,
21893
+ remintBootstrapForContext,
21894
+ // No key is minted or read here, so there is never a local record: the check fires for any principal
21895
+ // that already existed.
21896
+ readExistingKey: async () => void 0,
21897
+ confirmExistingPrincipal,
21898
+ skipKey: true,
21899
+ log
21900
+ });
21901
+ if (provisioned.userAction === "reconciled") {
21902
+ process.stdout.write(
21903
+ ` \u26A0 reusing existing service principal '${blueprint.servicePrincipal.externalId}' (id ${provisioned.servicePrincipalUserId}` + (provisioned.servicePrincipalCreatedAt ? `, created ${provisioned.servicePrincipalCreatedAt}` : "") + `) \u2014 proceeding because ` + (confirmedBy === "prompt" ? "you confirmed it at the prompt.\n" : "--confirm-existing-principal was passed (in a pipeline that is standing consent).\n")
21904
+ );
21905
+ }
21906
+ const resolvedBlueprint = await (deps.resolveApplyTimeIdentities ?? resolveApplyTimeIdentities)(
21907
+ blueprint,
21908
+ provisioned,
21909
+ client,
21910
+ log
21911
+ );
21912
+ const result = await (deps.runLoader ?? runLoader)(client, {
21913
+ blueprint: resolvedBlueprint,
21914
+ tenantId,
21915
+ machineId: "apply",
21916
+ skipKey: true,
21917
+ skipSeed: noSeed,
21918
+ // Off unless asked for: apply never deletes on its own. `blueprint-test` sets the same switch off.
21919
+ pruneTriggers: args.prune,
21920
+ allowScriptAdoption: args.allowScriptAdoption,
21921
+ readExistingKey: async () => void 0,
21922
+ persistMintedKey: async () => {
21923
+ },
21924
+ provisioned,
21925
+ remintBootstrapForContext,
21926
+ remintForContext: buildRemintForContext({ cognitoToken, apiBaseUrl: baseUrl, tenant, mintContext }),
21927
+ log
21928
+ });
21929
+ process.stdout.write(
21930
+ applyOutcomeSummary(result, { principalNote: false }) + ` \u2714 Applied '${blueprint.name}' to the ${tenant} tenant, context '${blueprint.contextId}'. No scoped key was minted and no client configuration was touched.
21931
+ ` + applySeedSummary(blueprint, args.seed, result.seededCount) + seedIndexWarningBlock(result.seedIndexFailures) + (args.prune ? "" : ` \u2139 Triggers were not pruned (--prune was not passed): a trigger this blueprint provisioned earlier and its file no longer declares is left in place. Pass --prune to remove it.
21932
+ `)
21933
+ );
21934
+ const issuerCode = finishIssuers(
21935
+ result.issuerActions,
21936
+ { context: blueprint.contextId, tenant, env: args.env, baseUrl: args.baseUrl },
21937
+ args.requireVerifiedIssuers,
21938
+ (deps.now ?? Date.now)(),
21939
+ "then re-run this `vectros blueprint apply` command"
21940
+ );
21941
+ const pruneFailures = result.triggerPruneSkipped.filter((s) => s.failed !== false);
21942
+ if (args.prune && pruneFailures.length > 0) {
21943
+ process.stderr.write(
21944
+ `
21945
+ \u2716 --prune: ${pruneFailures.length} removal${pruneFailures.length === 1 ? "" : "s"} could not be carried out (exit 1). The rest of the apply completed; the trigger(s) named above may still be live.
21946
+ `
21947
+ );
21948
+ return 1;
21949
+ }
21950
+ return issuerCode;
21951
+ } catch (err) {
21952
+ return reportApplyError(err);
21953
+ } finally {
21954
+ promptCtx.close();
21955
+ }
21956
+ }
21957
+ function reportApplyError(err) {
21958
+ if (err instanceof ExistingPrincipalNotConfirmedError) {
21959
+ process.stderr.write(
21960
+ `
21961
+ \u2716 ${err.message}
21962
+
21963
+ If this is expected (a re-apply of a blueprint you manage), re-run with --confirm-existing-principal
21964
+ (with --yes in a pipeline, where it is standing consent), or confirm at the prompt.
21965
+ If you don't recognize this identity, investigate the blueprint's servicePrincipal.externalId first.
21966
+ Only the app-context and the principal lookup had run; no grant or anything else was changed.
21967
+ `
21968
+ );
21969
+ return 3;
21970
+ }
21971
+ if (err instanceof BlueprintIdentityError || err instanceof UnresolvedIdentitiesError) {
21972
+ process.stderr.write(
21973
+ `
21974
+ \u2716 ${err.message}
21975
+ The app-context, service principal, and any declared namespaces were already provisioned. No schemas, profile, roles or seed records were.
21976
+ `
21977
+ );
21978
+ return 2;
21979
+ }
21980
+ if (err instanceof IssuerScopeNotAppliedError) {
21981
+ process.stderr.write(
21982
+ `
21983
+ \u2716 ${err.message}
21984
+ The app-context, service principal, and any declared namespaces were already provisioned. No schemas, profile, roles or seed records were.
21985
+ `
21986
+ );
21987
+ return 1;
21988
+ }
21989
+ if (err instanceof TenantWideNamespacesNotAllowedError) {
21990
+ process.stderr.write(`
21991
+ \u2716 ${err.message}
21992
+ Nothing was changed.
21993
+ `);
21994
+ return 2;
21995
+ }
21996
+ process.stderr.write(
21997
+ `
21998
+ ${formatCliError(err, { action: "Apply failed" })} The apply stopped part-way: steps earlier in the plan may already have been applied. Re-running converges.
21999
+ `
22000
+ );
22001
+ if (process.env.VECTROS_MCP_DEBUG && err instanceof Error && err.stack) {
22002
+ process.stderr.write(`${err.stack}
22003
+ `);
22004
+ }
22005
+ return 1;
22006
+ }
22007
+
20843
22008
  // src/lint.ts
20844
22009
  var ENUM_COMPATIBLE_TYPES = /* @__PURE__ */ new Set(["enum", "string", "string[]"]);
20845
22010
  var BUILT_IN_SORT_BY = /* @__PURE__ */ new Set(["createdAt", "lastUpdated"]);
@@ -21121,7 +22286,10 @@ function defaultScaffold(name) {
21121
22286
  return `# ${name}.blueprint.yaml \u2014 a Vectros blueprint.
21122
22287
  # Edit, then check it with: vectros blueprint validate ./${name}.blueprint.yaml
21123
22288
  # Preview what it provisions: vectros blueprint plan ./${name}.blueprint.yaml
21124
- # Apply it: vectros bootstrap --blueprint ./${name}.blueprint.yaml
22289
+ # Apply it (no key): vectros blueprint apply ./${name}.blueprint.yaml --tenant test
22290
+ # (writes the sample seed records below only with --seed)
22291
+ # Apply it and mint a key: vectros bootstrap --blueprint ./${name}.blueprint.yaml
22292
+ # (writes them unless you pass --no-seed)
21125
22293
 
21126
22294
  name: ${name} # stable id + idempotency key. Re-running NEVER duplicates.
21127
22295
  version: 1.0.0
@@ -21140,9 +22308,9 @@ schemas:
21140
22308
  fieldType: string # string | number | boolean | date | enum | array | object
21141
22309
  required: true
21142
22310
  searchable: true # contributes to full-text + semantic search
21143
- - fieldId: externalId # the dedup key \u2014 give every seed a stable one; it has
21144
- fieldType: string # a built-in finder, so look it up directly (do NOT
21145
- required: true # list it under lookupFields \u2014 the API reserves it)
22311
+ # Do NOT declare an externalId field here: it is a first-class identifier the
22312
+ # platform reserves (a seed's externalId is set at the TOP LEVEL of the seed, below),
22313
+ # and it has its own built-in finder, so it is not listed under lookupFields either.
21146
22314
  lookupFields: [title] # extra O(1) exact-match lookup indexes (max 10)
21147
22315
 
21148
22316
  accessProfile:
@@ -21178,7 +22346,8 @@ servicePrincipal:
21178
22346
  seed:
21179
22347
  - surface: record # 'record' (createRecord) or 'document' (text-ingest: needs title + text)
21180
22348
  typeName: thing
21181
- externalId: seed-welcome # stable \u2192 idempotent; re-apply converges
22349
+ externalId: seed-welcome # stable \u2192 idempotent; re-apply converges. Top level ONLY:
22350
+ # never inside 'fields', never as a schema field.
21182
22351
  fields: { title: "Hello from ${name}" }
21183
22352
  `;
21184
22353
  }
@@ -21246,7 +22415,10 @@ Usage:
21246
22415
  vectros blueprint init <name> [--from <bundled>] [--out <path>] [--force]
21247
22416
  Scaffold a new blueprint YAML to edit (no creds).
21248
22417
  vectros blueprint validate <file|name> Structural + scope-gate + lint checks (no creds).
21249
- vectros blueprint plan <file|name> Validate, then preview what bootstrap WOULD provision.
22418
+ vectros blueprint plan <file|name> Validate, then preview what bootstrap WOULD provision (apply: same, minus the sample seeds unless --seed).
22419
+ vectros blueprint apply <file|name> --tenant test|live
22420
+ Provision it into a tenant, minting NO key (needs credentials;
22421
+ 'vectros blueprint apply --help' for its flags and exit codes).
21250
22422
  vectros blueprint list List the bundled blueprint library.
21251
22423
 
21252
22424
  <file|name> is a path to your own blueprint (YAML .yaml/.yml or JSON .json) OR
@@ -21371,10 +22543,17 @@ ${renderPlan(blueprint, steps)}
21371
22543
  }
21372
22544
  }
21373
22545
  const setEcho = [...args.set].join(" --set ");
22546
+ const inputs = (args.set.length ? ` --set ${setEcho}` : "") + (args.values ? ` --values ${args.values}` : "");
21374
22547
  process.stdout.write(
21375
22548
  `
21376
22549
  ${steps.length} step(s). This is a preview \u2014 nothing was minted.
21377
- Apply with: vectros bootstrap --blueprint ${args.file}` + (args.set.length ? ` --set ${setEcho}` : "") + (args.values ? ` --values ${args.values}` : "") + "\n"
22550
+ Apply with: vectros blueprint apply ${args.file} --tenant test${inputs}
22551
+ (no scoped key is minted; write --tenant live for your live tenant), or
22552
+ vectros bootstrap --blueprint ${args.file}${inputs}
22553
+ (which also mints a key for your MCP client).
22554
+ ` + (blueprint.seed?.length ? ` The plan above includes the sample seed records: bootstrap writes them unless --no-seed;
22555
+ blueprint apply writes them only when you pass --seed.
22556
+ ` : "")
21378
22557
  );
21379
22558
  return 0;
21380
22559
  }
@@ -21438,7 +22617,8 @@ function runList() {
21438
22617
  }
21439
22618
  process.stdout.write(
21440
22619
  `
21441
- Apply one with: vectros bootstrap --blueprint <name>
22620
+ Apply one with: vectros blueprint apply <name> --tenant test (no key)
22621
+ vectros bootstrap --blueprint <name> (also mints a key)
21442
22622
  Scaffold your own: vectros blueprint init my-thing
21443
22623
  Validate it: vectros blueprint validate ./my-thing.blueprint.yaml
21444
22624
  `
@@ -21446,7 +22626,7 @@ function runList() {
21446
22626
  return 0;
21447
22627
  }
21448
22628
  async function runBlueprintCli(argv, deps = {}) {
21449
- void deps;
22629
+ if (argv[0] === "apply") return runApplyCli(argv.slice(1), deps);
21450
22630
  let args;
21451
22631
  try {
21452
22632
  args = parseBlueprintArgs(argv);
@@ -22936,7 +24116,7 @@ async function runRoleDelete(opts, deps = {}) {
22936
24116
  }
22937
24117
 
22938
24118
  // src/commands/context.ts
22939
- import * as readline3 from "readline/promises";
24119
+ import * as readline4 from "readline/promises";
22940
24120
  function renderContext(c) {
22941
24121
  return formatKeyValues([
22942
24122
  ["context", c.contextId],
@@ -23017,7 +24197,7 @@ async function runContextDestroy(opts, deps = {}) {
23017
24197
  return 2;
23018
24198
  }
23019
24199
  const promptCtx = deps.createPrompt ? deps.createPrompt() : (() => {
23020
- const rl = readline3.createInterface({ input: process.stdin, output: process.stdout });
24200
+ const rl = readline4.createInterface({ input: process.stdin, output: process.stdout });
23021
24201
  return { prompt: (q) => rl.question(q), close: () => rl.close() };
23022
24202
  })();
23023
24203
  try {
@@ -23088,7 +24268,8 @@ ${renderContext(ctx)}
23088
24268
  }
23089
24269
 
23090
24270
  // src/commands/issuers.ts
23091
- import * as readline4 from "readline/promises";
24271
+ import * as readline5 from "readline/promises";
24272
+ import { createReadStream } from "fs";
23092
24273
  function parseSelfSignupPolicies(raw) {
23093
24274
  if (!raw || raw.length === 0) return void 0;
23094
24275
  return raw.map((entry) => {
@@ -23127,6 +24308,24 @@ function parseCapturedClaims(raw) {
23127
24308
  }
23128
24309
  return [...raw];
23129
24310
  }
24311
+ function parseRestrictedToDomain(raw) {
24312
+ if (raw === void 0) return void 0;
24313
+ const domain = raw.trim();
24314
+ if (domain.length === 0) {
24315
+ throw new Error(
24316
+ "--restricted-to-domain must be a non-blank domain (e.g. 'acme.com'). To remove the scope from an existing issuer, use --clear-restricted-to-domain."
24317
+ );
24318
+ }
24319
+ if (!isRestrictedToDomainName(domain.toLowerCase())) {
24320
+ throw new Error(
24321
+ `--restricted-to-domain must be a DNS name such as 'acme.com' \u2014 letters, digits and dashes in dot-separated labels, at least two labels, no scheme, path or port (write an internationalized domain in its punycode xn-- form); got '${domain}'. To remove the scope from an existing issuer, use --clear-restricted-to-domain.`
24322
+ );
24323
+ }
24324
+ return domain;
24325
+ }
24326
+ function renderRestrictedToDomain(domain) {
24327
+ return domain ? domain : "\u2014";
24328
+ }
23130
24329
  function renderCapturedClaims(claims) {
23131
24330
  if (!claims || claims.length === 0) return "\u2014";
23132
24331
  return claims.join(", ");
@@ -23136,6 +24335,7 @@ function renderSelfSignup(policies) {
23136
24335
  return policies.map((p) => `${p.signup_type} \u2192 ${p.role_id}`).join(", ");
23137
24336
  }
23138
24337
  function renderIssuer(i) {
24338
+ const pending = isPendingVerification(i);
23139
24339
  return formatKeyValues([
23140
24340
  ["issuerId", i.issuerId],
23141
24341
  ["issuer", i.issuer],
@@ -23148,9 +24348,22 @@ function renderIssuer(i) {
23148
24348
  ["status", i.status],
23149
24349
  ["createdAt", i.createdAt],
23150
24350
  ["selfSignup", renderSelfSignup(i.selfSignupPolicies)],
23151
- ["capturedClaims", renderCapturedClaims(i.capturedClaims)]
24351
+ ["capturedClaims", renderCapturedClaims(i.capturedClaims)],
24352
+ ["restrictedToDomain", renderRestrictedToDomain(i.restrictedToDomain)],
24353
+ ...pending ? [
24354
+ ["verificationClaim", displayChallengeValue(i.verificationClaim)],
24355
+ ["verificationNonce", displayChallengeValue(i.verificationNonce)],
24356
+ ["verificationExpiresAt", displayChallengeValue(i.verificationExpiresAt)]
24357
+ ] : []
23152
24358
  ]);
23153
24359
  }
24360
+ function challengeContext(opts) {
24361
+ return { context: opts.context, tenant: opts.tenant, env: opts.env, baseUrl: opts.baseUrl };
24362
+ }
24363
+ function renderPendingNotes(i, ctx, now) {
24364
+ if (!isPendingVerification(i)) return "";
24365
+ return challengeExpired(i, now) ? renderExpiredBlock(i, ctx, "then register it again with the same arguments") : renderChallengeBlock(i, ctx, now);
24366
+ }
23154
24367
  async function runIssuersList(opts, deps = {}) {
23155
24368
  return withAuth({ ...opts, provisioning: true, context: opts.context }, deps, async (auth) => {
23156
24369
  const client = auth.client;
@@ -23170,7 +24383,8 @@ async function runIssuersList(opts, deps = {}) {
23170
24383
  { header: "audience", get: (i) => i.audience },
23171
24384
  { header: "status", get: (i) => i.status },
23172
24385
  { header: "self-signup", get: (i) => renderSelfSignup(i.selfSignupPolicies) },
23173
- { header: "captured claims", get: (i) => renderCapturedClaims(i.capturedClaims) }
24386
+ { header: "captured claims", get: (i) => renderCapturedClaims(i.capturedClaims) },
24387
+ { header: "restricted to domain", get: (i) => renderRestrictedToDomain(i.restrictedToDomain) }
23174
24388
  ]) + "\n"
23175
24389
  );
23176
24390
  return 0;
@@ -23190,9 +24404,11 @@ async function runIssuersGet(opts, deps = {}) {
23190
24404
  `);
23191
24405
  return 0;
23192
24406
  }
24407
+ const notes = renderPendingNotes(issuer, challengeContext(opts), (deps.now ?? Date.now)());
23193
24408
  process.stdout.write(`
23194
24409
  ${renderIssuer(issuer)}
23195
- `);
24410
+ ${notes ? `
24411
+ ${notes}` : ""}`);
23196
24412
  return 0;
23197
24413
  } catch (err) {
23198
24414
  process.stderr.write(formatCliError(err, { action: "issuers get failed" }));
@@ -23203,9 +24419,11 @@ ${renderIssuer(issuer)}
23203
24419
  async function runIssuersRegister(opts, deps = {}) {
23204
24420
  let selfSignupPolicies;
23205
24421
  let capturedClaims;
24422
+ let restrictedToDomain;
23206
24423
  try {
23207
24424
  selfSignupPolicies = parseSelfSignupPolicies(opts.selfSignupPolicy);
23208
24425
  capturedClaims = parseCapturedClaims(opts.capturedClaim);
24426
+ restrictedToDomain = parseRestrictedToDomain(opts.restrictedToDomain);
23209
24427
  } catch (err) {
23210
24428
  process.stderr.write(` \u2716 ${formatError(err)}
23211
24429
  `);
@@ -23224,18 +24442,45 @@ async function runIssuersRegister(opts, deps = {}) {
23224
24442
  emailClaim: opts.emailClaim,
23225
24443
  userinfoUri: opts.userinfoUri,
23226
24444
  selfSignupPolicies,
23227
- capturedClaims
24445
+ capturedClaims,
24446
+ restrictedToDomain
23228
24447
  });
24448
+ if (restrictedToDomain !== void 0 && (issuer.restrictedToDomain ?? "").toLowerCase() !== restrictedToDomain.toLowerCase()) {
24449
+ if (isPendingVerification(issuer)) {
24450
+ const ctx = challengeContext(opts);
24451
+ process.stderr.write(
24452
+ ` \u2716 issuer '${opts.issuerId}' is ${issuer.created === false ? "already registered and " : ""}still pending verification, so the requested
24453
+ --restricted-to-domain '${restrictedToDomain}' was NOT applied: register never changes an existing registration, and a pending
24454
+ registration cannot be re-scoped. Either replace it with a scoped one (a domain-scoped issuer needs no login):
24455
+ ${deleteCommand(opts.issuerId, ctx)}
24456
+ and register it again with the scope, or verify it first, which makes it active and scopable with issuers update:
24457
+ ${verifyCommand(opts.issuerId, ctx)}
24458
+ `
24459
+ );
24460
+ return 1;
24461
+ }
24462
+ const stored = issuer.restrictedToDomain ? `restricted to '${issuer.restrictedToDomain}'` : "with no domain scope";
24463
+ const why = issuer.created === false ? `is already registered ${stored}, and register never changes an existing registration` : `was registered ${stored}: the platform did not apply the requested scope`;
24464
+ process.stderr.write(
24465
+ ` \u2716 issuer '${opts.issuerId}' ${why} \u2014 the requested
24466
+ --restricted-to-domain '${restrictedToDomain}' was NOT applied. Run:
24467
+ vectros issuers update ${shellQuote(opts.issuerId)} --context ${shellQuote(opts.context)}${targetFlags(challengeContext(opts))} --restricted-to-domain ${shellQuote(restrictedToDomain)}
24468
+ `
24469
+ );
24470
+ return 1;
24471
+ }
23229
24472
  if (opts.json) {
23230
24473
  process.stdout.write(`${formatJson(issuer)}
23231
24474
  `);
23232
24475
  return 0;
23233
24476
  }
23234
24477
  const verb = issuer.created === false ? "already registered \u2014 returned unchanged" : "registered";
24478
+ const notes = renderPendingNotes(issuer, challengeContext(opts), (deps.now ?? Date.now)());
23235
24479
  process.stdout.write(`
23236
24480
  \u2714 issuer '${opts.issuerId}' ${verb}
23237
24481
  ${renderIssuer(issuer)}
23238
- `);
24482
+ ${notes ? `
24483
+ ${notes}` : ""}`);
23239
24484
  return 0;
23240
24485
  } catch (err) {
23241
24486
  process.stderr.write(formatCliError(err, { action: "issuers register failed" }));
@@ -23246,14 +24491,22 @@ ${renderIssuer(issuer)}
23246
24491
  async function runIssuersUpdate(opts, deps = {}) {
23247
24492
  let selfSignupPolicies;
23248
24493
  let capturedClaims;
24494
+ let restrictedToDomain;
23249
24495
  try {
23250
24496
  selfSignupPolicies = parseSelfSignupPolicies(opts.selfSignupPolicy);
23251
24497
  capturedClaims = parseCapturedClaims(opts.capturedClaim);
24498
+ restrictedToDomain = parseRestrictedToDomain(opts.restrictedToDomain);
23252
24499
  } catch (err) {
23253
24500
  process.stderr.write(` \u2716 ${formatError(err)}
23254
24501
  `);
23255
24502
  return 2;
23256
24503
  }
24504
+ if (opts.clearRestrictedToDomain && restrictedToDomain !== void 0) {
24505
+ process.stderr.write(
24506
+ " \u2716 --clear-restricted-to-domain and --restricted-to-domain are mutually exclusive.\n"
24507
+ );
24508
+ return 2;
24509
+ }
23257
24510
  if (opts.clearSelfSignupPolicies && selfSignupPolicies && selfSignupPolicies.length > 0) {
23258
24511
  process.stderr.write(
23259
24512
  " \u2716 --clear-self-signup-policies and --self-signup-policy are mutually exclusive.\n"
@@ -23276,7 +24529,8 @@ async function runIssuersUpdate(opts, deps = {}) {
23276
24529
  userinfoUri: opts.userinfoUri,
23277
24530
  status: opts.status,
23278
24531
  selfSignupPolicies: opts.clearSelfSignupPolicies ? [] : selfSignupPolicies,
23279
- capturedClaims: opts.clearCapturedClaims ? [] : capturedClaims
24532
+ capturedClaims: opts.clearCapturedClaims ? [] : capturedClaims,
24533
+ restrictedToDomain: opts.clearRestrictedToDomain ? "" : restrictedToDomain
23280
24534
  });
23281
24535
  if (opts.json) {
23282
24536
  process.stdout.write(`${formatJson(issuer)}
@@ -23307,7 +24561,7 @@ async function runIssuersDelete(opts, deps = {}) {
23307
24561
  return 2;
23308
24562
  }
23309
24563
  const promptCtx = deps.createPrompt ? deps.createPrompt() : (() => {
23310
- const rl = readline4.createInterface({ input: process.stdin, output: process.stdout });
24564
+ const rl = readline5.createInterface({ input: process.stdin, output: process.stdout });
23311
24565
  return { prompt: (q) => rl.question(q), close: () => rl.close() };
23312
24566
  })();
23313
24567
  try {
@@ -23349,6 +24603,173 @@ async function runIssuersDelete(opts, deps = {}) {
23349
24603
  }
23350
24604
  });
23351
24605
  }
24606
+ var JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
24607
+ function decodeTokenBytes(bytes) {
24608
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return bytes.subarray(2).toString("utf16le");
24609
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) {
24610
+ const swapped = Buffer.from(bytes.subarray(2));
24611
+ swapped.swap16();
24612
+ return swapped.toString("utf16le");
24613
+ }
24614
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
24615
+ return bytes.subarray(3).toString("utf8");
24616
+ }
24617
+ return bytes.toString("utf8");
24618
+ }
24619
+ var MAX_TOKEN_BYTES = 64 * 1024;
24620
+ var TokenTooLargeError = class extends Error {
24621
+ constructor() {
24622
+ super(`the input is larger than ${MAX_TOKEN_BYTES / 1024} KB, which no token is; nothing was read further and nothing was sent.`);
24623
+ this.name = "TokenTooLargeError";
24624
+ }
24625
+ };
24626
+ async function readAllFrom(stream, maxBytes = MAX_TOKEN_BYTES) {
24627
+ const chunks = [];
24628
+ let total = 0;
24629
+ for await (const chunk of stream) {
24630
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
24631
+ total += buf.length;
24632
+ if (total > maxBytes) throw new TokenTooLargeError();
24633
+ chunks.push(buf);
24634
+ }
24635
+ return decodeTokenBytes(Buffer.concat(chunks));
24636
+ }
24637
+ var drainStdin = () => readAllFrom(process.stdin);
24638
+ var readTokenFile = (path4) => readAllFrom(createReadStream(path4));
24639
+ function fileFailureReason(err) {
24640
+ if (err instanceof TokenTooLargeError) return err.message;
24641
+ switch (err?.code) {
24642
+ case "ENOENT":
24643
+ return "no such file.";
24644
+ case "EISDIR":
24645
+ return "that is a directory.";
24646
+ case "EACCES":
24647
+ case "EPERM":
24648
+ return "permission denied.";
24649
+ default:
24650
+ return "it could not be read.";
24651
+ }
24652
+ }
24653
+ async function resolveIdpToken(opts, deps) {
24654
+ if (opts.idpToken !== void 0 && opts.idpTokenFile !== void 0) {
24655
+ throw new Error("--idp-token and --idp-token-file are mutually exclusive.");
24656
+ }
24657
+ const readStdin = deps.readStdin ?? drainStdin;
24658
+ const readTextFile = deps.readTextFile ?? readTokenFile;
24659
+ let raw;
24660
+ if (opts.idpToken !== void 0) {
24661
+ raw = opts.idpToken;
24662
+ } else if (opts.idpTokenFile !== void 0 && opts.idpTokenFile !== "-") {
24663
+ if (looksLikeCredential(opts.idpTokenFile)) {
24664
+ throw new Error(
24665
+ "--idp-token-file takes the PATH of a file holding the token, but the value given looks like a token (or is too long to be a path). Save the token to a file and pass that path (for a file in this directory, write ./name), or pipe it on stdin."
24666
+ );
24667
+ }
24668
+ try {
24669
+ raw = await readTextFile(opts.idpTokenFile);
24670
+ } catch (err) {
24671
+ const name = safeBaseName(opts.idpTokenFile);
24672
+ throw new Error(`could not read the file given to --idp-token-file${name ? ` (${name})` : ""}: ${fileFailureReason(err)}`);
24673
+ }
24674
+ } else if (opts.idpTokenFile === "-" || !(deps.isTty ?? Boolean(process.stdin.isTTY))) {
24675
+ if (opts.idpTokenFile === "-" && (deps.isTty ?? Boolean(process.stdin.isTTY))) {
24676
+ throw new Error(
24677
+ "stdin is a terminal, so `--idp-token-file -` would have you paste the token where it is displayed. Pipe it instead (some-command | vectros issuers verify ...), or save it to a file and pass --idp-token-file <path>."
24678
+ );
24679
+ }
24680
+ if (opts.idpTokenFile !== "-") {
24681
+ process.stderr.write(" \u2026 no token flag given: reading the token from stdin (pass --idp-token-file <path> to avoid this).\n");
24682
+ }
24683
+ raw = await readStdin();
24684
+ } else {
24685
+ throw new Error(
24686
+ "no token given. Save the JWT your identity provider returned to a file and pass --idp-token-file <path>, or pipe it on stdin. (Not --token: that is your Vectros credential.)"
24687
+ );
24688
+ }
24689
+ if (raw.length > MAX_TOKEN_BYTES) throw new TokenTooLargeError();
24690
+ const token = raw.trim().replace(/^bearer\s+/i, "");
24691
+ if (token.length === 0) throw new Error("the token is empty.");
24692
+ if (!JWT_SHAPE.test(token)) {
24693
+ throw new Error(
24694
+ "the value does not look like a JWT (three dot-separated base64url segments). Pass the whole access token or ID token your identity provider issued for the login, not a decoded payload. An opaque access token (not a JWT) cannot be used: configure the provider to issue a JWT for this audience."
24695
+ );
24696
+ }
24697
+ return token;
24698
+ }
24699
+ async function runIssuersVerify(opts, deps = {}) {
24700
+ let token;
24701
+ try {
24702
+ token = await resolveIdpToken(opts, deps);
24703
+ } catch (err) {
24704
+ process.stderr.write(` \u2716 ${formatError(err)}
24705
+ `);
24706
+ return 2;
24707
+ }
24708
+ if (opts.idpToken !== void 0) {
24709
+ process.stderr.write(
24710
+ " \u26A0 --idp-token puts the token in your shell history and the process list; prefer --idp-token-file or stdin.\n"
24711
+ );
24712
+ }
24713
+ return withAuth({ ...opts, provisioning: true, context: opts.context }, deps, async (auth) => {
24714
+ const client = auth.client;
24715
+ try {
24716
+ const issuer = await client.auth.verifyIssuer({ issuerId: opts.issuerId, token });
24717
+ const active = issuer.status === "active";
24718
+ if (opts.json) {
24719
+ process.stdout.write(`${formatJson(issuer)}
24720
+ `);
24721
+ return active ? 0 : 1;
24722
+ }
24723
+ if (!active) {
24724
+ process.stderr.write(
24725
+ ` \u2716 the platform accepted the call but issuer '${opts.issuerId}' reports status '${issuer.status ?? "(none)"}', not 'active'. Run: ${getCommand(opts.issuerId, challengeContext(opts))}
24726
+ `
24727
+ );
24728
+ return 1;
24729
+ }
24730
+ process.stdout.write(
24731
+ `
24732
+ \u2714 issuer '${opts.issuerId}' verified: it is now active and accepts tokens from this provider.
24733
+ ${renderIssuer(issuer)}
24734
+
24735
+ The token was checked once and discarded. You can remove the rule you added at the provider.
24736
+ `
24737
+ );
24738
+ return 0;
24739
+ } catch (err) {
24740
+ const info = extractServerError(err);
24741
+ process.stderr.write(formatCliError(err, { action: "issuers verify failed" }));
24742
+ if (info.statusCode === 409) {
24743
+ process.stderr.write(
24744
+ ` The registration changed or was removed while it was being verified, and this request activated
24745
+ nothing. Run: ${getCommand(opts.issuerId, challengeContext(opts))}
24746
+ (another verification may already have activated it); otherwise retry.
24747
+ `
24748
+ );
24749
+ } else if (info.statusCode === 400) {
24750
+ process.stderr.write(
24751
+ ` This did not activate the registration. The message above says why; to see where it stands, run:
24752
+ ${getCommand(opts.issuerId, challengeContext(opts))}
24753
+ `
24754
+ );
24755
+ } else if (info.statusCode === 404) {
24756
+ process.stderr.write(
24757
+ ` No registration with this id is visible to you in this context. A pending registration whose challenge
24758
+ has expired is removed by the platform. Run: ${getCommand(opts.issuerId, challengeContext(opts))}
24759
+ `
24760
+ );
24761
+ } else {
24762
+ process.stderr.write(
24763
+ ` This request did not report success, and the platform may still have finished it (a timeout or a
24764
+ dropped connection can leave the registration verified). Before retrying, run:
24765
+ ${getCommand(opts.issuerId, challengeContext(opts))}
24766
+ `
24767
+ );
24768
+ }
24769
+ return 1;
24770
+ }
24771
+ });
24772
+ }
23352
24773
 
23353
24774
  // src/commands/namespace.ts
23354
24775
  function renderNamespace(n) {
@@ -23454,7 +24875,7 @@ ${renderNamespace(ns)}
23454
24875
 
23455
24876
  // src/commands/scripts.ts
23456
24877
  import { readFile as readFile3 } from "fs/promises";
23457
- import * as readline5 from "readline/promises";
24878
+ import * as readline6 from "readline/promises";
23458
24879
  var SOURCE_MAX_BYTES = 3e5;
23459
24880
  var INPUT_CONTRACT_MAX_BYTES = 5e4;
23460
24881
  var utf8Bytes2 = (s) => new TextEncoder().encode(s).length;
@@ -23673,7 +25094,7 @@ async function runScriptsDelete(opts, deps = {}) {
23673
25094
  return 2;
23674
25095
  }
23675
25096
  const promptCtx = deps.createPrompt ? deps.createPrompt() : (() => {
23676
- const rl = readline5.createInterface({ input: process.stdin, output: process.stdout });
25097
+ const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
23677
25098
  return { prompt: (q) => rl.question(q), close: () => rl.close() };
23678
25099
  })();
23679
25100
  try {
@@ -23788,16 +25209,36 @@ function defaultOpenBrowser(url) {
23788
25209
  void open(url).catch(() => {
23789
25210
  });
23790
25211
  }
23791
- async function startLoopback(ports, onCode) {
23792
- const server = createServer((req, res) => {
23793
- const params = parseCallback(req.url ?? "/");
23794
- const done = Boolean(params.code || params.error);
23795
- res.writeHead(done ? 200 : 404, { "Content-Type": "text/html" });
23796
- res.end(
23797
- done ? '<html><body style="font-family:sans-serif"><h3>Vectros CLI</h3><p>You can close this tab and return to the terminal.</p></body></html>' : "<html><body>Not found</body></html>"
23798
- );
23799
- if (done) onCode(params);
23800
- });
25212
+ var PAGE_SIGNED_IN = '<html><body style="font-family:sans-serif"><h3>Vectros CLI</h3><p>You can close this tab and return to the terminal.</p></body></html>';
25213
+ var PAGE_SIGN_IN_FAILED = '<html><body style="font-family:sans-serif"><h3>Vectros CLI</h3><p>The sign-in did not complete. Return to the terminal for details.</p></body></html>';
25214
+ var TEXT_NOT_THIS_SIGN_IN = "This response could not be matched to the sign-in in progress. Return to your terminal: if the sign-in did not finish there, press Ctrl+C and run `vectros login` again.";
25215
+ var REQUEST_TIMEOUT_MS = 1e4;
25216
+ async function startLoopback(ports, expectedState, onCode, onIgnored) {
25217
+ const server = createServer(
25218
+ {
25219
+ headersTimeout: REQUEST_TIMEOUT_MS,
25220
+ requestTimeout: REQUEST_TIMEOUT_MS,
25221
+ keepAliveTimeout: 1e3,
25222
+ connectionsCheckingInterval: 1e3
25223
+ },
25224
+ (req, res) => {
25225
+ const classified = classifyCallback(req.method, req.url, expectedState);
25226
+ if (classified.kind === "accepted") {
25227
+ res.writeHead(200, { "Content-Type": "text/html" });
25228
+ res.end(classified.params.error ? PAGE_SIGN_IN_FAILED : PAGE_SIGNED_IN);
25229
+ onCode(classified.params);
25230
+ return;
25231
+ }
25232
+ if (classified.kind === "wrong-state") {
25233
+ res.writeHead(400, { "Content-Type": "text/plain" });
25234
+ res.end(TEXT_NOT_THIS_SIGN_IN);
25235
+ onIgnored();
25236
+ return;
25237
+ }
25238
+ res.writeHead(404, { "Content-Type": "text/html" });
25239
+ res.end("<html><body>Not found</body></html>");
25240
+ }
25241
+ );
23801
25242
  for (const port of ports) {
23802
25243
  const ok = await new Promise((resolve) => {
23803
25244
  const onErr = () => {
@@ -23810,7 +25251,10 @@ async function startLoopback(ports, onCode) {
23810
25251
  resolve(true);
23811
25252
  });
23812
25253
  });
23813
- if (ok) return { server, port };
25254
+ if (ok) {
25255
+ const address = server.address();
25256
+ return { server, port: typeof address === "object" && address ? address.port : port };
25257
+ }
23814
25258
  }
23815
25259
  throw new Error(`No free loopback port among ${ports.join(", ")} \u2014 close whatever is using them and retry.`);
23816
25260
  }
@@ -23839,9 +25283,26 @@ async function runLogin(opts, deps = {}) {
23839
25283
  const codePromise = new Promise((resolve) => {
23840
25284
  resolveCode = resolve;
23841
25285
  });
25286
+ let warnedIgnored = false;
25287
+ let settled = false;
25288
+ const warnIgnored = () => {
25289
+ if (settled || warnedIgnored) return;
25290
+ warnedIgnored = true;
25291
+ process.stderr.write(
25292
+ " ! ignored a response that did not carry this sign-in's state, so it cannot be treated as the answer to this sign-in.\n Still waiting for the real one. If the browser showed an error, or nothing seems to happen, press Ctrl+C and run `vectros login` again.\n"
25293
+ );
25294
+ };
23842
25295
  let loopback;
23843
25296
  try {
23844
- loopback = await startLoopback(ports, resolveCode);
25297
+ loopback = await startLoopback(
25298
+ ports,
25299
+ state,
25300
+ (params) => {
25301
+ settled = true;
25302
+ resolveCode(params);
25303
+ },
25304
+ warnIgnored
25305
+ );
23845
25306
  } catch (err) {
23846
25307
  process.stderr.write(` \u2716 ${formatError(err)}
23847
25308
  `);
@@ -23871,10 +25332,7 @@ async function runLogin(opts, deps = {}) {
23871
25332
  }
23872
25333
  const params = await withTimeout(codePromise, timeoutMs);
23873
25334
  if (params.error) {
23874
- throw new Error(`authorization failed: ${params.error}${params.errorDescription ? ` \u2014 ${params.errorDescription}` : ""}`);
23875
- }
23876
- if (params.state !== state) {
23877
- throw new Error("state mismatch on the callback \u2014 aborting (possible CSRF).");
25335
+ throw new Error(`authorization failed: ${describeAuthorizationError(params.error, params.errorDescription)}`);
23878
25336
  }
23879
25337
  if (!params.code) {
23880
25338
  throw new Error("no authorization code on the callback.");
@@ -23895,13 +25353,20 @@ async function runLogin(opts, deps = {}) {
23895
25353
  return 0;
23896
25354
  } catch (err) {
23897
25355
  process.stderr.write(`
23898
- \u2716 login failed: ${formatError(err)}
25356
+ \u2716 login failed: ${stripControlCharacters(formatError(err), 1e3)}
23899
25357
  `);
23900
25358
  return 1;
23901
25359
  } finally {
23902
25360
  loopback.server.close();
25361
+ loopback.server.closeAllConnections();
23903
25362
  }
23904
25363
  }
25364
+ var OAUTH_ERROR_CODE = /^[A-Za-z0-9_.-]{1,64}$/;
25365
+ function describeAuthorizationError(error, description) {
25366
+ const code = OAUTH_ERROR_CODE.test(error) ? error : "an unrecognised error";
25367
+ const text = description ? stripControlCharacters(description) : "";
25368
+ return text ? `${code} \u2014 ${text}` : code;
25369
+ }
23905
25370
  async function runLogout(_opts, deps = {}) {
23906
25371
  const removed = await clearSession(deps.baseDir);
23907
25372
  process.stdout.write(removed ? " \u2714 Logged out (session cleared).\n" : " No stored session.\n");
@@ -24102,11 +25567,17 @@ async function runAssertions(client, bootstrapClient, deps, blueprint, tenantId,
24102
25567
  for (const { issuerId } of result.issuerActions) {
24103
25568
  try {
24104
25569
  const issuer = await bootstrapClient.auth.getIssuer({ issuerId });
24105
- const ok = issuer.issuerId === issuerId;
25570
+ const declaredScope = blueprint.issuers?.find((i) => i.issuerId === issuerId)?.restrictedToDomain;
25571
+ const scopeOk = declaredScope === void 0 || (issuer.restrictedToDomain ?? "").toLowerCase() === declaredScope.toLowerCase();
25572
+ const ok = issuer.issuerId === issuerId && scopeOk;
24106
25573
  assertions.push({
24107
25574
  name: `issuer:${issuerId}`,
24108
25575
  ok,
24109
- detail: ok ? `issuer '${issuerId}' registered + readable` : `getIssuer(${issuerId}) returned issuerId '${issuer.issuerId ?? "(none)"}'`
25576
+ detail: !scopeOk ? `getIssuer(${issuerId}) reports restrictedToDomain '${issuer.restrictedToDomain ?? "(none)"}', the blueprint declares '${declaredScope}'` : ok ? (
25577
+ // A pending issuer is a PASS here: this harness cannot perform the identity-provider login that
25578
+ // verification needs, and registration is what the blueprint declares. Say so rather than hide it.
25579
+ `issuer '${issuerId}' registered + readable` + (issuer.status === "pending_verification" ? " (pending verification: it accepts no token until a login at its identity provider proves control)" : "")
25580
+ ) : `getIssuer(${issuerId}) returned issuerId '${issuer.issuerId ?? "(none)"}'`
24110
25581
  });
24111
25582
  } catch (e) {
24112
25583
  assertions.push({ name: `issuer:${issuerId}`, ok: false, detail: `getIssuer(${issuerId}) failed: ${errMsg(e)}` });
@@ -24782,9 +26253,9 @@ function emitJoined(opts) {
24782
26253
 
24783
26254
  // src/build-info.ts
24784
26255
  var BUILD_INFO = {
24785
- cli: true ? "0.22.0" : "dev",
24786
- sdk: true ? "0.44.0" : "dev",
24787
- blueprints: true ? "0.19.0" : "dev"
26256
+ cli: true ? "0.23.0" : "dev",
26257
+ sdk: true ? "0.45.0" : "dev",
26258
+ blueprints: true ? "0.20.0" : "dev"
24788
26259
  };
24789
26260
  function formatBuildInfo() {
24790
26261
  return `vectros ${BUILD_INFO.cli} (sdk ${BUILD_INFO.sdk}, blueprints ${BUILD_INFO.blueprints})`;
@@ -24832,9 +26303,9 @@ function toCommonOptions(flags) {
24832
26303
  }
24833
26304
  function buildProgram(deps = {}) {
24834
26305
  const program = new Command();
24835
- program.name("vectros").description("The Vectros provisioning CLI \u2014 provision contexts, identities, access, and keys.").version(formatBuildInfo(), "-v, --version", "show the CLI + bundled SDK/blueprints versions").showHelpAfterError().addHelpText(
26306
+ program.name("vectros").description("The Vectros provisioning CLI \u2014 provision contexts, identities, access, and keys.").version(formatBuildInfo(), "-v, --version", "show the CLI + bundled SDK/blueprints versions").showHelpAfterError().configureOutput({ outputError: (text, write) => write(redactCredentials(text)) }).addHelpText(
24836
26307
  "after",
24837
- "\nProvisioning + authoring (own help):\n bootstrap One-shot least-privilege MCP credential. vectros bootstrap --help\n blueprint Author + inspect blueprints. vectros blueprint --help\n"
26308
+ "\nProvisioning + authoring (own help):\n bootstrap One-shot least-privilege MCP credential. vectros bootstrap --help\n blueprint Author + inspect blueprints; apply one. vectros blueprint --help\n"
24838
26309
  );
24839
26310
  const login = program.command("login").description("Sign in via your browser (Cognito PKCE) and store a session.").addOption(new Option("--env <env>", "target environment").choices(["staging", "production"]).default("production").hideHelp()).option("--no-browser", "print the sign-in URL instead of opening a browser (headless/CI/WSL)");
24840
26311
  login.action(async (flags) => {
@@ -25039,6 +26510,9 @@ function registerIssuersCommands(program, deps) {
25039
26510
  "--captured-claim <name>",
25040
26511
  "additional OIDC claim to capture from this issuer\u2019s tokens beyond the email claim, repeatable (e.g. phone_number); omit to capture nothing beyond email",
25041
26512
  collectOptional
26513
+ ).option(
26514
+ "--restricted-to-domain <domain>",
26515
+ "scope this registration to a domain already verified for your account; a token matches only when its 'hd' claim equals it (a token with no 'hd' claim is not matched)"
25042
26516
  ).option("--tenant <tenant>", "tenant the context lives in: 'test' or 'live' (default live)");
25043
26517
  addCommonAuthOptions(register).action(
25044
26518
  async (flags) => {
@@ -25057,13 +26531,14 @@ function registerIssuersCommands(program, deps) {
25057
26531
  userinfoUri: flags.userinfoUri,
25058
26532
  selfSignupPolicy: flags.selfSignupPolicy,
25059
26533
  capturedClaim: flags.capturedClaim,
26534
+ restrictedToDomain: flags.restrictedToDomain,
25060
26535
  tenant
25061
26536
  },
25062
26537
  deps
25063
26538
  );
25064
26539
  }
25065
26540
  );
25066
- const update = issuers.command("update").description("Patch the safe, mutable fields of a registered issuer (trust-anchor fields are immutable).").argument("<issuerId>").requiredOption("--context <c>", "app context the issuer is registered under").option("--sub-claim <claim>", "update the subject-identifier claim").option("--email-claim <claim>", "update the subject-email claim").option("--userinfo-uri <url>", "update the OIDC userinfo fallback endpoint").option("--status <status>", "'active' or 'suspended' \u2014 a suspended issuer's tokens are rejected at exchange time").option(
26541
+ const update = issuers.command("update").description("Patch the safe, mutable fields of a registered issuer (trust-anchor fields are immutable).").argument("<issuerId>").requiredOption("--context <c>", "app context the issuer is registered under").option("--sub-claim <claim>", "update the subject-identifier claim").option("--email-claim <claim>", "update the subject-email claim").option("--userinfo-uri <url>", "update the OIDC userinfo fallback endpoint").option("--status <status>", "'active' or 'suspended' \u2014 a suspended issuer's tokens are rejected at exchange time; refused while the registration is pending verification (see 'issuers verify')").option(
25067
26542
  "--self-signup-policy <type:roleId>",
25068
26543
  "replace self-signup policies, repeatable (e.g. default:member) \u2014 omit entirely to leave unchanged",
25069
26544
  collect,
@@ -25072,7 +26547,10 @@ function registerIssuersCommands(program, deps) {
25072
26547
  "--captured-claim <name>",
25073
26548
  "replace the additional OIDC claims captured beyond the email claim, repeatable \u2014 omit entirely to leave unchanged",
25074
26549
  collectOptional
25075
- ).option("--clear-captured-claims", "capture nothing beyond the email claim (sends an empty list; XOR --captured-claim)").option("--tenant <tenant>", "tenant the context lives in: 'test' or 'live' (default live)");
26550
+ ).option("--clear-captured-claims", "capture nothing beyond the email claim (sends an empty list; XOR --captured-claim)").option(
26551
+ "--restricted-to-domain <domain>",
26552
+ "scope the registration to a domain already verified for your account; a token with no matching 'hd' claim then stops matching \u2014 omit to leave unchanged. Refused on a registration that is pending verification: verify it first"
26553
+ ).option("--clear-restricted-to-domain", "remove the domain scope (XOR --restricted-to-domain)").option("--tenant <tenant>", "tenant the context lives in: 'test' or 'live' (default live)");
25076
26554
  addCommonAuthOptions(update).action(
25077
26555
  async (issuerId, flags) => {
25078
26556
  const tenant = parseTenantFlag(flags.tenant);
@@ -25090,6 +26568,8 @@ function registerIssuersCommands(program, deps) {
25090
26568
  clearSelfSignupPolicies: Boolean(flags.clearSelfSignupPolicies),
25091
26569
  capturedClaim: flags.capturedClaim,
25092
26570
  clearCapturedClaims: Boolean(flags.clearCapturedClaims),
26571
+ restrictedToDomain: flags.restrictedToDomain,
26572
+ clearRestrictedToDomain: Boolean(flags.clearRestrictedToDomain),
25093
26573
  tenant
25094
26574
  },
25095
26575
  deps
@@ -25107,6 +26587,29 @@ function registerIssuersCommands(program, deps) {
25107
26587
  );
25108
26588
  }
25109
26589
  );
26590
+ const verify = issuers.command("verify").description(
26591
+ "Prove you control a registered issuer, activating it: answers a 'pending_verification' registration's challenge with a JWT from a real login through the identity provider (see 'issuers get' for the challenge)."
26592
+ ).argument("<issuerId>").requiredOption("--context <c>", "app context the issuer is registered under").option(
26593
+ "--idp-token <jwt>",
26594
+ "the identity provider's JWT for a real login (NOT your Vectros credential; that is --token). Visible in your shell history and process list, so prefer --idp-token-file or stdin"
26595
+ ).option("--idp-token-file <path>", "a file holding the identity provider's JWT ('-' reads stdin); stdin is also read when no token flag is given and input is piped").option("--tenant <tenant>", "tenant the context lives in: 'test' or 'live' (default live)");
26596
+ addCommonAuthOptions(verify).action(
26597
+ async (issuerId, flags) => {
26598
+ const tenant = parseTenantFlag(flags.tenant);
26599
+ if (tenant === TENANT_FLAG_INVALID) return;
26600
+ process.exitCode = await runIssuersVerify(
26601
+ {
26602
+ ...toCommonOptions(flags),
26603
+ context: flags.context,
26604
+ issuerId,
26605
+ idpToken: flags.idpToken,
26606
+ idpTokenFile: flags.idpTokenFile,
26607
+ tenant
26608
+ },
26609
+ deps
26610
+ );
26611
+ }
26612
+ );
25110
26613
  }
25111
26614
  function registerNamespaceCommands(program, deps) {
25112
26615
  const namespace = program.command("namespace").description("Manage scope-namespace registrations (whether scope:<namespace> values resolve to identity entities).");