@atlasauth/cli 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +26 -5
  3. package/dist/args.d.ts +30 -0
  4. package/dist/args.js +15 -0
  5. package/dist/args.js.map +1 -1
  6. package/dist/commands/config.d.ts +27 -2
  7. package/dist/commands/config.js +366 -44
  8. package/dist/commands/config.js.map +1 -1
  9. package/dist/commands/doctor-scan.d.ts +29 -0
  10. package/dist/commands/doctor-scan.js +233 -0
  11. package/dist/commands/doctor-scan.js.map +1 -0
  12. package/dist/commands/golive.js +16 -4
  13. package/dist/commands/golive.js.map +1 -1
  14. package/dist/commands/import-jsonl.d.ts +51 -0
  15. package/dist/commands/import-jsonl.js +326 -0
  16. package/dist/commands/import-jsonl.js.map +1 -0
  17. package/dist/commands/import.js +22 -12
  18. package/dist/commands/import.js.map +1 -1
  19. package/dist/commands/init-next.d.ts +4 -0
  20. package/dist/commands/init-next.js +86 -0
  21. package/dist/commands/init-next.js.map +1 -0
  22. package/dist/commands/init.js +119 -25
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/project-commands.js +74 -4
  25. package/dist/commands/project-commands.js.map +1 -1
  26. package/dist/commands/setup-guide.d.ts +3 -0
  27. package/dist/commands/setup-guide.js +38 -0
  28. package/dist/commands/setup-guide.js.map +1 -0
  29. package/dist/commands/users.js +12 -1
  30. package/dist/commands/users.js.map +1 -1
  31. package/dist/config-http.d.ts +9 -0
  32. package/dist/config-http.js +7 -1
  33. package/dist/config-http.js.map +1 -1
  34. package/dist/context.d.ts +18 -1
  35. package/dist/context.js +6 -1
  36. package/dist/context.js.map +1 -1
  37. package/dist/help.d.ts +1 -1
  38. package/dist/help.js +79 -12
  39. package/dist/help.js.map +1 -1
  40. package/dist/redact.d.ts +41 -0
  41. package/dist/redact.js +115 -0
  42. package/dist/redact.js.map +1 -0
  43. package/dist/run.d.ts +2 -2
  44. package/dist/run.js +16 -4
  45. package/dist/run.js.map +1 -1
  46. package/package.json +4 -4
@@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_CONFIG_FILES = void 0;
37
+ exports.didYouMean = didYouMean;
37
38
  exports.config = config;
38
39
  exports.resolveFormat = resolveFormat;
39
40
  exports.planConfigImport = planConfigImport;
40
41
  exports.diffFields = diffFields;
42
+ exports.printedFields = printedFields;
41
43
  exports.readSecretsEnv = readSecretsEnv;
42
44
  exports.unresolvedPlaceholders = unresolvedPlaceholders;
43
45
  exports.configTypes = configTypes;
@@ -57,6 +59,9 @@ const serialize_1 = require("../serialize");
57
59
  const backend_1 = require("@atlasauth/backend");
58
60
  const shared_1 = require("./shared");
59
61
  const secrets_1 = require("../secrets");
62
+ const redact_1 = require("../redact");
63
+ /** The refusal `rotate_secret` answers when the secret is the one already stored. */
64
+ const isSameSecretRefusal = (err) => err instanceof backend_1.AtlasApiError && err.status === 422 && /must differ/.test(err.message);
60
65
  // ───────────────────────────── small helpers ─────────────────────────────
61
66
  /** GET a `{ data: [...] }` list envelope, tolerating an absent/garbled body. */
62
67
  async function listData(http, path) {
@@ -130,12 +135,14 @@ function registry() {
130
135
  async apply(http, config) {
131
136
  await http.put('/v1/messaging_providers/email', compact(withoutMarkers(config)));
132
137
  },
133
- normalize: (c) => redactSecrets(c, ['config']),
138
+ fileKeys: ['transport', 'from_address', 'mode', 'config'],
139
+ reportsSecrets: true,
134
140
  },
135
141
  // 1. Instance config (auth_config + allowed origins) — the §9.3 singleton.
136
142
  {
137
143
  kind: 'singleton',
138
144
  type: 'instance',
145
+ fileKeys: ['allowed_origins', 'auth_config'],
139
146
  async fetchLive(http) {
140
147
  const inst = await http.get('/v1/instance');
141
148
  const hints = obj(inst.allowed_origin_integrations ?? null);
@@ -185,6 +192,7 @@ function registry() {
185
192
  {
186
193
  kind: 'singleton',
187
194
  type: 'org_settings_schema',
195
+ fileKeys: ['schema'],
188
196
  async fetchLive(http) {
189
197
  try {
190
198
  const s = await http.get('/v1/organization_settings_schema');
@@ -205,6 +213,7 @@ function registry() {
205
213
  {
206
214
  kind: 'collection',
207
215
  type: 'redirect_urls',
216
+ fileKeys: ['url'],
208
217
  keyOf: (c) => String(obj(c).url ?? ''),
209
218
  async fetchLive(http) {
210
219
  const rows = await listData(http, '/v1/redirect_urls');
@@ -221,6 +230,7 @@ function registry() {
221
230
  {
222
231
  kind: 'collection',
223
232
  type: 'oauth_clients',
233
+ fileKeys: [...OAUTH_CLIENT_KEYS, ...OAUTH_CLIENT_WRITE_KEYS],
224
234
  keyOf: (c) => String(obj(c).name ?? ''),
225
235
  async fetchLive(http) {
226
236
  const rows = await listData(http, '/v1/oauth_clients');
@@ -278,12 +288,37 @@ function registry() {
278
288
  throw err;
279
289
  }
280
290
  },
281
- normalize: (c) => redactSecrets(c, ['before_user_create']),
291
+ fileKeys: ['before_user_create'],
292
+ nestedKeys: { before_user_create: ['url', 'timeout_ms', 'fail_closed', 'enabled', 'signing_secret'] },
293
+ reportsSecrets: true,
282
294
  },
283
295
  // 5. Social / enterprise OAuth providers — key = the catalog provider slug.
284
296
  {
285
297
  kind: 'collection',
286
298
  type: 'oauth_providers',
299
+ fileKeys: [...OAUTH_PROVIDER_KEYS, 'client_secret', 'values', 'settings'],
300
+ validate(config) {
301
+ const { provider, mode } = obj(config);
302
+ if (mode === undefined || mode === 'native' || mode === 'redirect' || hasPlaceholder(mode))
303
+ return [];
304
+ return [`oauth_providers ${String(provider)}: mode must be native or redirect, got ${JSON.stringify(mode)}.`];
305
+ },
306
+ reportsSecrets: true,
307
+ defaulted: ['mode'],
308
+ writeOnly: ['values'],
309
+ warnings(config, live) {
310
+ const c = obj(config);
311
+ const lv = obj(live ?? null);
312
+ const inFile = typeof c.client_secret === 'string' && c.client_secret !== '';
313
+ const out = [];
314
+ if (c.mode === 'redirect' && !inFile && !lv.client_secret) {
315
+ out.push(`oauth_providers ${String(c.provider)}: mode redirect needs a client_secret, and neither the file nor the instance has one.`);
316
+ }
317
+ if ((c.mode ?? lv.mode) === 'native' && inFile) {
318
+ out.push(`oauth_providers ${String(c.provider)}: secret unused in native mode. Remove client_secret, or set mode: redirect.`);
319
+ }
320
+ return out;
321
+ },
287
322
  keyOf: (c) => String(obj(c).provider ?? ''),
288
323
  async fetchLive(http) {
289
324
  const rows = await listData(http, '/v1/oauth_providers');
@@ -292,7 +327,11 @@ function registry() {
292
327
  .map((r) => ({
293
328
  key: String(r.provider ?? ''),
294
329
  id: String(r.provider ?? ''),
295
- config: pick(r, OAUTH_PROVIDER_KEYS),
330
+ config: {
331
+ ...obj(pick(r, OAUTH_PROVIDER_KEYS)),
332
+ ...(r.setting_values && Object.keys(r.setting_values).length ? { settings: r.setting_values } : {}),
333
+ ...(r.has_secret === true ? { client_secret: SECRET_SET } : {}),
334
+ },
296
335
  }));
297
336
  },
298
337
  create: putProvider,
@@ -305,13 +344,15 @@ function registry() {
305
344
  {
306
345
  kind: 'collection',
307
346
  type: 'custom_oauth_providers',
347
+ fileKeys: [...CUSTOM_PROVIDER_KEYS, ...CUSTOM_PROVIDER_WRITE_KEYS],
348
+ reportsSecrets: true,
308
349
  keyOf: (c) => String(obj(c).provider_key ?? ''),
309
350
  async fetchLive(http) {
310
351
  const rows = await listData(http, '/v1/custom_oauth_providers');
311
352
  return rows.map((r) => ({
312
353
  key: String(r.provider_key ?? ''),
313
354
  id: String(r.id ?? ''),
314
- config: pick(r, CUSTOM_PROVIDER_KEYS),
355
+ config: { ...obj(pick(r, CUSTOM_PROVIDER_KEYS)), ...(r.has_secret === true ? { client_secret: SECRET_SET } : {}) },
315
356
  }));
316
357
  },
317
358
  async create(http, config) {
@@ -330,6 +371,7 @@ function registry() {
330
371
  {
331
372
  kind: 'collection',
332
373
  type: 'resource_servers',
374
+ fileKeys: [...RESOURCE_SERVER_KEYS, 'publish_resource_metadata', 'access_token_format', 'refresh_token_absolute_ttl_seconds', 'refresh_token_idle_ttl_seconds', 'introspection_client_ids'],
333
375
  keyOf: (c) => String(obj(c).identifier ?? ''),
334
376
  async fetchLive(http) {
335
377
  const rows = await listData(http, '/v1/resource_servers');
@@ -358,6 +400,7 @@ function registry() {
358
400
  {
359
401
  kind: 'collection',
360
402
  type: 'jwt_templates',
403
+ fileKeys: ['name', 'claims'],
361
404
  keyOf: (c) => String(obj(c).name ?? ''),
362
405
  async fetchLive(http) {
363
406
  const rows = await listData(http, '/v1/jwt_templates');
@@ -382,6 +425,7 @@ function registry() {
382
425
  {
383
426
  kind: 'collection',
384
427
  type: 'permissions',
428
+ fileKeys: PERMISSION_KEYS,
385
429
  keyOf: (c) => String(obj(c).key ?? ''),
386
430
  async fetchLive(http) {
387
431
  const rows = await listData(http, '/v1/permissions');
@@ -404,6 +448,7 @@ function registry() {
404
448
  {
405
449
  kind: 'collection',
406
450
  type: 'roles',
451
+ fileKeys: [...ROLE_KEYS, 'visibility'],
407
452
  keyOf: (c) => String(obj(c).key ?? ''),
408
453
  async fetchLive(http) {
409
454
  const rows = await listData(http, '/v1/roles');
@@ -436,6 +481,25 @@ function registry() {
436
481
  {
437
482
  kind: 'collection',
438
483
  type: 'webhook_endpoints',
484
+ fileKeys: [...WEBHOOK_KEYS, 'secret'],
485
+ validate(config) {
486
+ const { url, secret } = obj(config);
487
+ if (secret === undefined || secret === null || hasPlaceholder(secret))
488
+ return [];
489
+ if (typeof secret === 'string' && WEBHOOK_SECRET.test(secret))
490
+ return [];
491
+ return [`webhook_endpoints ${String(url)}: secret must start with "whsec_" followed by 32 to 256 characters (letters, digits, + / = _ -). Omit it and Atlas generates one.`];
492
+ },
493
+ // A dry-run rotation tells a new secret from the stored one without revealing either.
494
+ async secretChanged(http, live, config) {
495
+ try {
496
+ await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret?dry_run=true`, { secret: obj(config).secret });
497
+ return true;
498
+ }
499
+ catch (err) {
500
+ return isSameSecretRefusal(err) ? false : null;
501
+ }
502
+ },
439
503
  keyOf: (c) => String(obj(c).url ?? ''),
440
504
  async fetchLive(http) {
441
505
  const rows = await listData(http, '/v1/webhook_endpoints');
@@ -453,10 +517,22 @@ function registry() {
453
517
  enabled_events: c.enabled_events ?? [],
454
518
  // Sent on create so a paused endpoint is never live, even briefly.
455
519
  ...(typeof c.active === 'boolean' ? { active: c.active } : {}),
520
+ ...(typeof c.secret === 'string' ? { secret: c.secret } : {}),
456
521
  });
457
522
  },
458
523
  async update(http, live, config) {
459
- await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(config, true));
524
+ // PATCH leaves the signing secret alone; only rotate_secret changes it.
525
+ const { secret, ...rest } = obj(config);
526
+ await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(rest, true));
527
+ if (typeof secret !== 'string' || !secret)
528
+ return;
529
+ try {
530
+ await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret`, { secret });
531
+ }
532
+ catch (err) {
533
+ if (!isSameSecretRefusal(err))
534
+ throw err;
535
+ }
460
536
  },
461
537
  async remove(http, live) {
462
538
  await http.delete(`/v1/webhook_endpoints/${enc(live.id)}`);
@@ -466,11 +542,14 @@ function registry() {
466
542
  {
467
543
  kind: 'collection',
468
544
  type: 'sso_connections',
545
+ fileKeys: [...SSO_KEYS, ...SSO_WRITE_KEYS],
546
+ reportsSecrets: true,
547
+ writeOnly: ['saml_idp_certificate'],
469
548
  keyOf: ssoKey,
470
549
  async fetchLive(http) {
471
550
  const rows = await listData(http, '/v1/sso_connections');
472
551
  return rows.map((r) => {
473
- const config = pick(r, SSO_KEYS);
552
+ const config = { ...obj(pick(r, SSO_KEYS)), ...(r.has_secret === true ? { oidc_client_secret: SECRET_SET, discourse_secret: SECRET_SET } : {}) };
474
553
  return { key: ssoKey(config), id: String(r.id ?? ''), config };
475
554
  });
476
555
  },
@@ -491,6 +570,7 @@ function registry() {
491
570
  {
492
571
  kind: 'collection',
493
572
  type: 'sso_onboarding_profiles',
573
+ fileKeys: SSO_PROFILE_KEYS,
494
574
  keyOf: (c) => String(obj(c).name ?? ''),
495
575
  async fetchLive(http) {
496
576
  let rows;
@@ -521,6 +601,7 @@ function registry() {
521
601
  {
522
602
  kind: 'collection',
523
603
  type: 'billing_plans',
604
+ fileKeys: [...BILLING_KEYS, 'grace_days', 'provider_price_id', 'annual_amount', 'seat_feature_id', 'promotion'],
524
605
  keyOf: (c) => String(obj(c).slug ?? ''),
525
606
  async fetchLive(http) {
526
607
  const rows = await listData(http, '/v1/billing/plans');
@@ -546,6 +627,7 @@ function registry() {
546
627
  {
547
628
  kind: 'collection',
548
629
  type: 'notification_templates',
630
+ fileKeys: NOTIFICATION_KEYS,
549
631
  keyOf: (c) => String(obj(c).name ?? ''),
550
632
  async fetchLive(http) {
551
633
  const rows = await listData(http, '/v1/notification_templates');
@@ -571,6 +653,7 @@ function registry() {
571
653
  {
572
654
  kind: 'collection',
573
655
  type: 'email_templates',
656
+ fileKeys: EMAIL_KEYS,
574
657
  keyOf: (c) => String(obj(c).name ?? ''),
575
658
  async fetchLive(http) {
576
659
  const rows = await listData(http, '/v1/email_templates');
@@ -596,6 +679,7 @@ function registry() {
596
679
  {
597
680
  kind: 'collection',
598
681
  type: 'organization_policies',
682
+ fileKeys: ORG_POLICY_KEYS,
599
683
  keyOf: (c) => String(obj(c).organization_id ?? ''),
600
684
  async fetchLive(http) {
601
685
  const orgs = await listData(http, '/v1/organizations');
@@ -648,6 +732,7 @@ function registry() {
648
732
  {
649
733
  kind: 'singleton',
650
734
  type: 'cookie_consent',
735
+ fileKeys: COOKIE_CONSENT_KEYS,
651
736
  async fetchLive(http) {
652
737
  let cc;
653
738
  try {
@@ -677,6 +762,7 @@ function registry() {
677
762
  {
678
763
  kind: 'singleton',
679
764
  type: 'sign_in_with_atlas',
765
+ fileKeys: ['enabled', 'connection_id'],
680
766
  async fetchLive(http) {
681
767
  let live;
682
768
  try {
@@ -705,6 +791,7 @@ function registry() {
705
791
  {
706
792
  kind: 'singleton',
707
793
  type: 'signing_key_policy',
794
+ fileKeys: ['prepublish_days'],
708
795
  async fetchLive(http) {
709
796
  let live;
710
797
  try {
@@ -754,6 +841,20 @@ const OAUTH_CLIENT_KEYS = [
754
841
  'access_token_format',
755
842
  'token_lifetimes',
756
843
  ];
844
+ /** What a file may also send on an OAuth client: accepted on write, never exported. */
845
+ const OAUTH_CLIENT_WRITE_KEYS = [
846
+ 'application_type',
847
+ 'client_secret',
848
+ 'secret_expires_in_seconds',
849
+ 'jwks',
850
+ 'jwks_uri',
851
+ 'tls_client_auth_subject_dn',
852
+ 'frontchannel_logout_session_required',
853
+ 'disabled',
854
+ 'metadata',
855
+ 'external_ref',
856
+ 'add_redirect_origins',
857
+ ];
757
858
  const OAUTH_PROVIDER_KEYS = [
758
859
  'provider',
759
860
  'enabled',
@@ -761,6 +862,7 @@ const OAUTH_PROVIDER_KEYS = [
761
862
  'allow_sign_up',
762
863
  'scopes',
763
864
  'client_id',
865
+ 'mode',
764
866
  ];
765
867
  const RESOURCE_SERVER_KEYS = [
766
868
  'identifier',
@@ -794,6 +896,18 @@ const CUSTOM_PROVIDER_KEYS = [
794
896
  'allow_sign_in',
795
897
  'allow_sign_up',
796
898
  ];
899
+ const CUSTOM_PROVIDER_WRITE_KEYS = [
900
+ 'client_secret',
901
+ 'end_session_endpoint',
902
+ 'revocation_endpoint',
903
+ 'host_params_schema',
904
+ 'data_only',
905
+ 'scope_separator',
906
+ 'userinfo_auth',
907
+ 'client_assertion_audience',
908
+ 'logout',
909
+ 'idp_initiated_login',
910
+ ];
797
911
  const WEBHOOK_KEYS = ['url', 'enabled_events', 'active'];
798
912
  const SSO_KEYS = [
799
913
  'type',
@@ -812,6 +926,29 @@ const SSO_KEYS = [
812
926
  'claim_role_mappings',
813
927
  'default_role_id',
814
928
  ];
929
+ const SSO_WRITE_KEYS = [
930
+ 'oidc_client_secret',
931
+ 'saml_idp_certificate',
932
+ 'saml_single_logout',
933
+ 'saml_idp_slo_url',
934
+ 'saml_idp_slo_response_url',
935
+ 'saml_idp_slo_binding',
936
+ 'saml_idp_slo_soap_url',
937
+ 'saml_require_encrypted_assertions',
938
+ 'saml_acs_path',
939
+ 'require_consent_screen',
940
+ 'discourse_secret',
941
+ 'provisioning_mode',
942
+ 'manual_endpoints',
943
+ 'github_access',
944
+ 'attribute_mapping',
945
+ 'oidc_scopes',
946
+ 'profile_mapping',
947
+ 'token_endpoint_auth_method',
948
+ 'client_assertion_audience',
949
+ 'logout',
950
+ 'idp_initiated_login',
951
+ ];
815
952
  const SSO_PROFILE_KEYS = ['name', 'allowed_connection_types', 'organization_id', 'company_name', 'allow_scim', 'intents', 'return_url'];
816
953
  const COOKIE_CONSENT_KEYS = ['enabled', 'script_url', 'script_id', 'site_id', 'blocking_mode', 'extra_origins', 'settings_link', 'culture', 'host_aliases'];
817
954
  const BILLING_KEYS = [
@@ -852,18 +989,109 @@ function obj(config) {
852
989
  }
853
990
  /** What a write-only secret looks like on the live side, and in a plan. */
854
991
  const SECRET_SET = '(secret set)';
855
- const SECRET_KEY = /(secret|token|password|passwd|api_?key|private_?key)/i;
992
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
856
993
  /**
857
- * Show each secret value under `groups` as "(secret set)" so a plan never echoes it
858
- * and compares equal to the live "(secret set)". An unresolved \${VAR} stays visible
859
- * (apply refuses it), as does an empty value.
994
+ * Bring a file's secrets and the live document to one comparable shape. A secret
995
+ * value becomes "(secret set)", so a plan never holds it and it compares equal to
996
+ * the live marker. `unverifiable` lists the secrets set on both sides, or that the
997
+ * API never reports (`reported` false): Atlas cannot say whether they differ, so
998
+ * they are not drift, and the plan says so. A secret the API reports as unset stays
999
+ * a difference. An unresolved ${VAR} stays visible (apply refuses it), as does an
1000
+ * empty value.
860
1001
  */
861
- function redactSecrets(config, groups) {
862
- const out = { ...obj(config) };
863
- const mask = (g) => Object.fromEntries(Object.entries(g).map(([k, v]) => [k, SECRET_KEY.test(k) && typeof v === 'string' && v !== '' && !/^\$\{[A-Z0-9_]+\}$/.test(v) ? SECRET_SET : v]));
864
- for (const g of groups)
865
- if (out[g] && typeof out[g] === 'object' && !Array.isArray(out[g]))
866
- out[g] = mask(obj(out[g]));
1002
+ function reconcileSecrets(desired, live, reported, prefix = '') {
1003
+ if (!isObj(desired))
1004
+ return { desired, live, unverifiable: [] };
1005
+ const d = { ...desired };
1006
+ const l = isObj(live) ? { ...live } : {};
1007
+ const unverifiable = [];
1008
+ for (const [k, v] of Object.entries(desired)) {
1009
+ const path = prefix ? `${prefix}.${k}` : k;
1010
+ if (isObj(v)) {
1011
+ const inner = reconcileSecrets(v, l[k], reported, path);
1012
+ d[k] = inner.desired;
1013
+ if (isObj(l[k]))
1014
+ l[k] = inner.live;
1015
+ unverifiable.push(...inner.unverifiable);
1016
+ }
1017
+ else if ((0, redact_1.isSecretKey)(k) && typeof v === 'string' && v !== '' && v !== SECRET_SET && !(0, redact_1.isPlaceholder)(v)) {
1018
+ if (l[k] === SECRET_SET) {
1019
+ d[k] = SECRET_SET;
1020
+ unverifiable.push(path);
1021
+ }
1022
+ else if (l[k] === undefined || l[k] === null) {
1023
+ d[k] = SECRET_SET;
1024
+ if (!reported) {
1025
+ l[k] = SECRET_SET;
1026
+ unverifiable.push(path);
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ return { desired: d, live: isObj(live) || unverifiable.length ? l : live, unverifiable };
1032
+ }
1033
+ /** The live document without the secret markers and server defaults the file says nothing about. */
1034
+ function withoutUnstated(live, desired, defaulted = []) {
1035
+ if (!isObj(live))
1036
+ return live;
1037
+ const want = isObj(desired) ? desired : {};
1038
+ const out = {};
1039
+ for (const [k, v] of Object.entries(live)) {
1040
+ if (!(k in want) && (v === SECRET_SET || defaulted.includes(k)))
1041
+ continue;
1042
+ out[k] = isObj(v) && isObj(want[k]) ? withoutUnstated(v, want[k]) : v;
1043
+ }
1044
+ return out;
1045
+ }
1046
+ /** How close two key names are (edit distance). */
1047
+ function distance(a, b) {
1048
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1049
+ for (let i = 1; i <= a.length; i++) {
1050
+ const row = [i];
1051
+ for (let j = 1; j <= b.length; j++)
1052
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1053
+ prev = row;
1054
+ }
1055
+ return prev[b.length];
1056
+ }
1057
+ function didYouMean(key, known) {
1058
+ const norm = (x) => x.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1059
+ const best = [...known].sort((a, b) => distance(norm(key), a) - distance(norm(key), b))[0];
1060
+ return best && distance(norm(key), best) <= Math.max(2, Math.floor(key.length / 3)) ? ` (did you mean ${best}?)` : '';
1061
+ }
1062
+ /** The keys in one parsed resource file that no spec knows, each named with its nearest real key. */
1063
+ function unknownFileKeys(spec, doc) {
1064
+ const out = [];
1065
+ const check = (value, known, label) => {
1066
+ if (!isObj(value))
1067
+ return;
1068
+ for (const k of Object.keys(value))
1069
+ if (!known.includes(k))
1070
+ out.push(`${label}: ${k}${didYouMean(k, known)}`);
1071
+ };
1072
+ if (spec.kind === 'collection') {
1073
+ for (const entry of asArray(doc))
1074
+ check(entry, spec.fileKeys, `${spec.type} ${spec.keyOf(entry) || '(no key)'}`);
1075
+ }
1076
+ else if (spec.fileKeys) {
1077
+ check(doc, spec.fileKeys, spec.type);
1078
+ for (const [k, known] of Object.entries(spec.nestedKeys ?? {}))
1079
+ if (isObj(doc))
1080
+ check(doc[k], known, `${spec.type}.${k}`);
1081
+ }
1082
+ return out;
1083
+ }
1084
+ /** The API's rule for a client-chosen webhook signing secret, including its `atlas_<env>_whsec_` form. */
1085
+ const WEBHOOK_SECRET = /^(?:atlas_(?:live|test)_)?whsec_[A-Za-z0-9+/=_-]{32,256}$/;
1086
+ /** A `${VAR}` still in a value: there is nothing to check yet, and apply refuses it on its own. */
1087
+ const hasPlaceholder = (v) => typeof v === 'string' && /\$\{[A-Z0-9_]+\}/.test(v);
1088
+ /** What one resource file's values break, from each spec's own rules. */
1089
+ function invalidFileValues(spec, doc) {
1090
+ if (spec.kind !== 'collection' || !spec.validate)
1091
+ return [];
1092
+ const out = [];
1093
+ for (const entry of asArray(doc))
1094
+ out.push(...spec.validate(entry));
867
1095
  return out;
868
1096
  }
869
1097
  /** A document without any "(secret set)" marker: what an export writes and what apply sends. */
@@ -910,6 +1138,8 @@ async function putProvider(http, config) {
910
1138
  // Credential-less providers (siwe, siws, siwb, siwf) take verifier settings instead.
911
1139
  if (c.settings && typeof c.settings === 'object' && !Array.isArray(c.settings))
912
1140
  body.settings = c.settings;
1141
+ if (c.mode !== undefined)
1142
+ body.mode = c.mode;
913
1143
  const provider = String(c.provider ?? '');
914
1144
  if (Object.keys(body).length) {
915
1145
  await http.put(`/v1/oauth_providers/${enc(provider)}`, body);
@@ -976,10 +1206,10 @@ function resolveDir(values) {
976
1206
  return (0, args_1.str)(values, 'dir') || './atlas-config';
977
1207
  }
978
1208
  /** Build the config HTTP client from the resolved profile credential. */
979
- function requireHttp(ctx, values) {
1209
+ function requireHttp(ctx, values, onUnknownFields) {
980
1210
  const auth = (0, shared_1.resolveAuth)(ctx, values);
981
1211
  const make = ctx.makeHttp ?? config_http_1.createConfigHttp;
982
- return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl });
1212
+ return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl, ...(onUnknownFields ? { onUnknownFields } : {}) });
983
1213
  }
984
1214
  /** Write every resource type's live config to `dir` (deterministic, secrets omitted); returns the file count. */
985
1215
  async function writeExport(http, dir, format) {
@@ -998,7 +1228,7 @@ async function writeExport(http, dir, format) {
998
1228
  const entries = (await spec.fetchLive(http)).filter((e) => !e.managedBy);
999
1229
  // Deterministic: entries sorted by natural key, keys sorted inside each.
1000
1230
  const sorted = [...entries].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1001
- const doc = sorted.map((e) => (0, serialize_1.canonicalize)(e.config));
1231
+ const doc = sorted.map((e) => (0, serialize_1.canonicalize)(withoutMarkers(e.config)));
1002
1232
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), (0, serialize_1.serialize)(doc, format));
1003
1233
  fileCount += 1;
1004
1234
  }
@@ -1027,6 +1257,16 @@ async function planConfigImport(http, opts) {
1027
1257
  throw new errors_1.CliError(`Config directory not found: ${dir}. Run 'atlas config export' first.`);
1028
1258
  }
1029
1259
  const present = new Set((0, node_fs_1.readdirSync)(dir));
1260
+ const files = registry().flatMap((spec) => present.has(`${spec.type}.${ext}`) ? [{ spec, doc: (0, serialize_1.deserialize)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), 'utf8'), format) }] : []);
1261
+ const unknown = files.flatMap(({ spec, doc }) => unknownFileKeys(spec, doc));
1262
+ if (unknown.length)
1263
+ throw new errors_1.CliError(`Unknown key(s) in the config:\n ${unknown.join('\n ')}`);
1264
+ const invalid = files.flatMap(({ spec, doc }) => invalidFileValues(spec, interpolate(doc, env)));
1265
+ if (invalid.length)
1266
+ throw new errors_1.CliError(`Invalid value(s) in the config:\n ${invalid.join('\n ')}`);
1267
+ const rotate = opts.rotateSecrets === true;
1268
+ const warnings = [];
1269
+ const unverifiableSecrets = [];
1030
1270
  const changes = [];
1031
1271
  let unchanged = 0;
1032
1272
  const skippedDeletes = [];
@@ -1048,12 +1288,21 @@ async function planConfigImport(http, opts) {
1048
1288
  unchanged += 1;
1049
1289
  continue;
1050
1290
  }
1051
- const live = liveRaw === null ? null : comparable(norm(liveRaw), desiredN, prune);
1052
- if (live !== null && (0, serialize_1.deepEqual)(desiredN, live)) {
1291
+ if (liveRaw === null) {
1292
+ const masked = reconcileSecrets(desiredN, undefined, true).desired;
1293
+ changes.push({ type: spec.type, action: 'create', key: '', after: masked, raw: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
1294
+ continue;
1295
+ }
1296
+ const seen = withoutUnstated(comparable(norm(liveRaw), desiredN, prune), desiredN);
1297
+ const r = reconcileSecrets(desiredN, seen, spec.reportsSecrets === true);
1298
+ const forced = rotate ? r.unverifiable : [];
1299
+ if (!rotate)
1300
+ unverifiableSecrets.push(...r.unverifiable.map((path) => ({ type: spec.type, key: '', path })));
1301
+ if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, r.live)) {
1053
1302
  unchanged += 1;
1054
1303
  continue;
1055
1304
  }
1056
- changes.push({ type: spec.type, action: live === null ? 'create' : 'update', key: '', ...(live === null ? {} : { before: live }), after: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
1305
+ changes.push({ type: spec.type, action: 'update', key: '', before: r.live, after: r.desired, raw: desiredN, changedSecrets: forced, apply: () => spec.apply(http, desired, applyOpts) });
1057
1306
  continue;
1058
1307
  }
1059
1308
  // Collection: diff desired-by-key against live-by-key.
@@ -1069,19 +1318,40 @@ async function planConfigImport(http, opts) {
1069
1318
  const liveByKey = new Map(live.map((e) => [e.key, e]));
1070
1319
  for (const [key, cfg] of desiredByKey) {
1071
1320
  const existing = liveByKey.get(key);
1321
+ warnings.push(...(spec.warnings?.(cfg, existing?.config) ?? []));
1072
1322
  if (!existing) {
1073
- changes.push({ type: spec.type, action: 'create', key, after: cfg, apply: () => spec.create(http, cfg) });
1323
+ changes.push({ type: spec.type, action: 'create', key, after: reconcileSecrets(cfg, undefined, true).desired, raw: cfg, apply: () => spec.create(http, cfg) });
1324
+ continue;
1074
1325
  }
1075
- else if (!(0, serialize_1.deepEqual)(cfg, comparable(existing.config, cfg, prune))) {
1076
- if (!spec.update) {
1077
- throw new errors_1.CliError(`${spec.type} '${key}' changed but is immutable; recreate it instead.`);
1326
+ const seen = withoutUnstated(comparable(existing.config, cfg, prune), cfg, spec.defaulted);
1327
+ const r = reconcileSecrets(cfg, seen, spec.reportsSecrets === true);
1328
+ const unverifiable = [...r.unverifiable];
1329
+ const liveSeen = obj(r.live);
1330
+ for (const k of spec.writeOnly ?? []) {
1331
+ if (obj(r.desired)[k] === undefined)
1332
+ continue;
1333
+ liveSeen[k] = obj(r.desired)[k];
1334
+ unverifiable.push(k);
1335
+ }
1336
+ let forced = rotate ? unverifiable : [];
1337
+ let unknownToApi = rotate ? [] : unverifiable;
1338
+ if (spec.secretChanged && unverifiable.length) {
1339
+ const changed = await spec.secretChanged(http, existing, cfg);
1340
+ if (changed !== null) {
1341
+ forced = changed ? unverifiable : [];
1342
+ unknownToApi = [];
1078
1343
  }
1079
- const upd = spec.update;
1080
- changes.push({ type: spec.type, action: 'update', key, before: comparable(existing.config, cfg, prune), after: cfg, apply: () => upd(http, existing, cfg) });
1081
1344
  }
1082
- else {
1345
+ unverifiableSecrets.push(...unknownToApi.map((path) => ({ type: spec.type, key, path })));
1346
+ if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, liveSeen)) {
1083
1347
  unchanged += 1;
1348
+ continue;
1349
+ }
1350
+ if (!spec.update) {
1351
+ throw new errors_1.CliError(`${spec.type} '${key}' changed but is immutable; recreate it instead.`);
1084
1352
  }
1353
+ const upd = spec.update;
1354
+ changes.push({ type: spec.type, action: 'update', key, before: liveSeen, after: r.desired, raw: cfg, changedSecrets: forced, apply: () => upd(http, existing, cfg) });
1085
1355
  }
1086
1356
  for (const entry of live) {
1087
1357
  if (desiredByKey.has(entry.key))
@@ -1100,7 +1370,7 @@ async function planConfigImport(http, opts) {
1100
1370
  }
1101
1371
  }
1102
1372
  }
1103
- return { changes, unchanged, skippedDeletes, managed };
1373
+ return { changes, unchanged, skippedDeletes, managed, warnings, unverifiableSecrets };
1104
1374
  }
1105
1375
  /** True for a non-empty object whose every leaf is `null` (a file that only clears fields). */
1106
1376
  function onlyNulls(x) {
@@ -1120,7 +1390,6 @@ function onlyNulls(x) {
1120
1390
  function comparable(live, desired, prune) {
1121
1391
  if (prune)
1122
1392
  return live;
1123
- const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
1124
1393
  if (!isObj(live) || !isObj(desired))
1125
1394
  return live;
1126
1395
  const out = {};
@@ -1180,6 +1449,7 @@ async function promoteConfig(ctx, values) {
1180
1449
  const plan = await planConfigImport(target.http, { dir, format, env: ctx.env, prune });
1181
1450
  ctx.io.out(`Promoting ${from} -> ${to}`);
1182
1451
  printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
1452
+ printPlanNotes(ctx, plan);
1183
1453
  if (dryRun)
1184
1454
  return 0;
1185
1455
  for (const change of plan.changes)
@@ -1197,8 +1467,9 @@ async function importConfig(ctx, values) {
1197
1467
  const format = resolveFormat(values);
1198
1468
  const dryRun = values['dry-run'] === true;
1199
1469
  const prune = values.prune === true;
1200
- const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true });
1470
+ const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
1201
1471
  printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
1472
+ printPlanNotes(ctx, plan);
1202
1473
  if (dryRun)
1203
1474
  return 0;
1204
1475
  for (const change of plan.changes)
@@ -1211,7 +1482,6 @@ async function importConfig(ctx, values) {
1211
1482
  }
1212
1483
  /** Leaf-level differences between two documents, as dotted paths (arrays compare whole). */
1213
1484
  function diffFields(before, after, path = '') {
1214
- const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
1215
1485
  if (isObj(before) && isObj(after)) {
1216
1486
  const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
1217
1487
  return keys.flatMap((k) => diffFields(before[k], after[k], path ? `${path}.${k}` : k));
@@ -1223,6 +1493,35 @@ function diffFields(before, after, path = '') {
1223
1493
  return [{ path: path || '(document)', ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) }];
1224
1494
  }
1225
1495
  const show = (v) => (v === undefined ? '(unset)' : typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v));
1496
+ /**
1497
+ * The only form a change's fields are printed or serialised in. A secret-named
1498
+ * string is `<redacted>` with a changed flag, whatever the resource; everything else
1499
+ * goes through the deep redactor.
1500
+ */
1501
+ function printedFields(c) {
1502
+ const reveal = (0, redact_1.revealingSecrets)();
1503
+ const diffs = diffFields(c.before, reveal ? (c.raw ?? c.after) : c.after);
1504
+ const rows = diffs.map((f) => {
1505
+ const leaf = f.path.split('.').pop();
1506
+ const textual = [f.before, f.after].every((v) => v === undefined || v === null || typeof v === 'string');
1507
+ if (!reveal && (0, redact_1.isSecretKey)(leaf) && textual && !(0, redact_1.isPlaceholder)(f.after))
1508
+ return { path: f.path, redacted: true, changed: true };
1509
+ return { path: f.path, ...(f.before === undefined ? {} : { before: (0, redact_1.redactDeep)(f.before, leaf) }), ...(f.after === undefined ? {} : { after: (0, redact_1.redactDeep)(f.after, leaf) }) };
1510
+ });
1511
+ for (const path of c.changedSecrets ?? [])
1512
+ if (!rows.some((r) => r.path === path))
1513
+ rows.push({ path, redacted: true, changed: true });
1514
+ return rows;
1515
+ }
1516
+ const printedLine = (f) => (f.redacted ? `${f.path}: ${redact_1.REDACTED} (changed)` : `${f.path}: ${show(f.before)} -> ${show(f.after)}`);
1517
+ /** The plan's advice: warnings, and the secrets Atlas cannot compare. */
1518
+ function printPlanNotes(ctx, plan) {
1519
+ for (const w of plan.warnings)
1520
+ ctx.io.out(`warning: ${w}`);
1521
+ for (const u of plan.unverifiableSecrets) {
1522
+ ctx.io.out(`note: ${u.type}${u.key ? ` ${u.key}` : ''} ${u.path}: Atlas does not return this value, so a change to it cannot be detected. Send it again with --rotate-secret.`);
1523
+ }
1524
+ }
1226
1525
  /**
1227
1526
  * `atlas config plan`: a Terraform-style, read-only diff of a config directory
1228
1527
  * (`atlas config export` output, committed to git) against the live instance.
@@ -1234,14 +1533,14 @@ async function planConfig(ctx, values, source) {
1234
1533
  const { dir, format, env } = source;
1235
1534
  const where = source.label ?? dir;
1236
1535
  const prune = values.prune === true;
1237
- const plan = await planConfigImport(http, { dir, format, env, prune });
1536
+ const plan = await planConfigImport(http, { dir, format, env, prune, rotateSecrets: values['rotate-secret'] === true });
1238
1537
  const deletes = prune ? [] : plan.skippedDeletes;
1239
1538
  const all = plan.changes;
1240
1539
  const rows = all.map((c) => ({
1241
1540
  type: c.type,
1242
1541
  key: c.key || null,
1243
1542
  action: c.action,
1244
- fields: c.action === 'update' ? diffFields(c.before, c.after) : [],
1543
+ fields: c.action === 'update' ? printedFields(c) : [],
1245
1544
  }));
1246
1545
  const pending = rows.length;
1247
1546
  if (values.json === true) {
@@ -1251,6 +1550,8 @@ async function planConfig(ctx, values, source) {
1251
1550
  unchanged: plan.unchanged,
1252
1551
  unmanaged_live: deletes.map((d) => ({ type: d.type, key: d.key || null })),
1253
1552
  managed_by_platform: plan.managed.map((m) => ({ type: m.type, key: m.key, managed_by: m.managedBy })),
1553
+ warnings: plan.warnings,
1554
+ unverifiable_secrets: plan.unverifiableSecrets.map((u) => ({ type: u.type, key: u.key || null, path: u.path })),
1254
1555
  has_changes: pending > 0,
1255
1556
  }, null, 2));
1256
1557
  }
@@ -1262,8 +1563,8 @@ async function planConfig(ctx, values, source) {
1262
1563
  ctx.io.out('');
1263
1564
  ctx.io.out(`${sign[c.action]} ${label} (${c.action})`);
1264
1565
  if (c.action === 'update') {
1265
- for (const f of diffFields(c.before, c.after))
1266
- ctx.io.out(` ${f.path}: ${show(f.before)} -> ${show(f.after)}`);
1566
+ for (const f of printedFields(c))
1567
+ ctx.io.out(` ${printedLine(f)}`);
1267
1568
  }
1268
1569
  }
1269
1570
  if (deletes.length) {
@@ -1278,6 +1579,9 @@ async function planConfig(ctx, values, source) {
1278
1579
  for (const m of plan.managed)
1279
1580
  ctx.io.out(` ${m.type} ${m.key} (${m.managedBy})`);
1280
1581
  }
1582
+ if (plan.warnings.length || plan.unverifiableSecrets.length)
1583
+ ctx.io.out('');
1584
+ printPlanNotes(ctx, plan);
1281
1585
  if (pending === 0)
1282
1586
  ctx.io.out(`No drift: the instance matches ${where}.`);
1283
1587
  }
@@ -1420,10 +1724,21 @@ async function applyCommand(ctx, args) {
1420
1724
  return planCommand(ctx, args);
1421
1725
  const source = await resolveSource(ctx, args);
1422
1726
  try {
1423
- const kept = keepingSecrets(requireHttp(ctx, values), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
1727
+ const onUnknownFields = (n) => ctx.io.err(`atlas: warning: ${n.method} ${n.path} ignored field(s) ${n.fields}${n.suggestions ? ` (did you mean: ${n.suggestions})` : ''}. This instance is lenient; set auth_config.strictRequestBodies to refuse them.`);
1728
+ const kept = keepingSecrets(requireHttp(ctx, values, onUnknownFields), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
1424
1729
  const prune = values.prune === true;
1425
- const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true });
1730
+ const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
1426
1731
  printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun: false, prune });
1732
+ for (const c of plan.changes) {
1733
+ if (c.action !== 'update')
1734
+ continue;
1735
+ const fields = printedFields(c);
1736
+ if (fields.length)
1737
+ ctx.io.out(` ~ ${c.key ? `${c.type} ${c.key}` : c.type}:`);
1738
+ for (const f of fields)
1739
+ ctx.io.out(` ${printedLine(f)}`);
1740
+ }
1741
+ printPlanNotes(ctx, plan);
1427
1742
  if (plan.changes.length === 0) {
1428
1743
  ctx.io.out('No changes to apply.');
1429
1744
  return 0;
@@ -1471,7 +1786,8 @@ function keepingSecrets(http, dir) {
1471
1786
  if (path === '/v1/oauth_clients' && typeof r.client_secret === 'string') {
1472
1787
  entries = { ATLAS_CLIENT_ID: String(r.client_id ?? ''), ATLAS_CLIENT_SECRET: r.client_secret };
1473
1788
  }
1474
- else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string') {
1789
+ else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string' && !body?.secret) {
1790
+ // A secret the file chose is already the caller's; only a minted one needs keeping.
1475
1791
  entries = { ATLAS_WEBHOOK_SECRET: r.secret };
1476
1792
  }
1477
1793
  if (entries) {
@@ -1515,16 +1831,22 @@ function asArray(value) {
1515
1831
  * on disk. An unset variable is left verbatim so the plan surfaces it rather
1516
1832
  * than silently blanking a credential.
1517
1833
  */
1518
- function interpolate(value, env) {
1834
+ function interpolate(value, env, key) {
1519
1835
  if (typeof value === 'string') {
1520
- return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => env[name] ?? match);
1836
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => {
1837
+ const resolved = env[name];
1838
+ if (resolved === undefined)
1839
+ return match;
1840
+ (0, redact_1.rememberResolved)(key, resolved);
1841
+ return resolved;
1842
+ });
1521
1843
  }
1522
1844
  if (Array.isArray(value))
1523
- return value.map((v) => interpolate(v, env));
1845
+ return value.map((v) => interpolate(v, env, key));
1524
1846
  if (value !== null && typeof value === 'object') {
1525
1847
  const out = {};
1526
1848
  for (const [k, v] of Object.entries(value))
1527
- out[k] = interpolate(v, env);
1849
+ out[k] = interpolate(v, env, k);
1528
1850
  return out;
1529
1851
  }
1530
1852
  return value;