@atlasauth/cli 0.25.1 → 0.26.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 +31 -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 +26 -2
  7. package/dist/commands/config.js +334 -44
  8. package/dist/commands/config.js.map +1 -1
  9. package/dist/commands/doctor-scan.d.ts +27 -0
  10. package/dist/commands/doctor-scan.js +221 -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 +50 -0
  15. package/dist/commands/import-jsonl.js +252 -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 +112 -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 +70 -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 +5 -5
@@ -38,6 +38,7 @@ exports.config = config;
38
38
  exports.resolveFormat = resolveFormat;
39
39
  exports.planConfigImport = planConfigImport;
40
40
  exports.diffFields = diffFields;
41
+ exports.printedFields = printedFields;
41
42
  exports.readSecretsEnv = readSecretsEnv;
42
43
  exports.unresolvedPlaceholders = unresolvedPlaceholders;
43
44
  exports.configTypes = configTypes;
@@ -57,6 +58,9 @@ const serialize_1 = require("../serialize");
57
58
  const backend_1 = require("@atlasauth/backend");
58
59
  const shared_1 = require("./shared");
59
60
  const secrets_1 = require("../secrets");
61
+ const redact_1 = require("../redact");
62
+ /** The refusal `rotate_secret` answers when the secret is the one already stored. */
63
+ const isSameSecretRefusal = (err) => err instanceof backend_1.AtlasApiError && err.status === 422 && /must differ/.test(err.message);
60
64
  // ───────────────────────────── small helpers ─────────────────────────────
61
65
  /** GET a `{ data: [...] }` list envelope, tolerating an absent/garbled body. */
62
66
  async function listData(http, path) {
@@ -130,12 +134,14 @@ function registry() {
130
134
  async apply(http, config) {
131
135
  await http.put('/v1/messaging_providers/email', compact(withoutMarkers(config)));
132
136
  },
133
- normalize: (c) => redactSecrets(c, ['config']),
137
+ fileKeys: ['transport', 'from_address', 'mode', 'config'],
138
+ reportsSecrets: true,
134
139
  },
135
140
  // 1. Instance config (auth_config + allowed origins) — the §9.3 singleton.
136
141
  {
137
142
  kind: 'singleton',
138
143
  type: 'instance',
144
+ fileKeys: ['allowed_origins', 'auth_config'],
139
145
  async fetchLive(http) {
140
146
  const inst = await http.get('/v1/instance');
141
147
  const hints = obj(inst.allowed_origin_integrations ?? null);
@@ -185,6 +191,7 @@ function registry() {
185
191
  {
186
192
  kind: 'singleton',
187
193
  type: 'org_settings_schema',
194
+ fileKeys: ['schema'],
188
195
  async fetchLive(http) {
189
196
  try {
190
197
  const s = await http.get('/v1/organization_settings_schema');
@@ -205,6 +212,7 @@ function registry() {
205
212
  {
206
213
  kind: 'collection',
207
214
  type: 'redirect_urls',
215
+ fileKeys: ['url'],
208
216
  keyOf: (c) => String(obj(c).url ?? ''),
209
217
  async fetchLive(http) {
210
218
  const rows = await listData(http, '/v1/redirect_urls');
@@ -221,6 +229,7 @@ function registry() {
221
229
  {
222
230
  kind: 'collection',
223
231
  type: 'oauth_clients',
232
+ fileKeys: [...OAUTH_CLIENT_KEYS, ...OAUTH_CLIENT_WRITE_KEYS],
224
233
  keyOf: (c) => String(obj(c).name ?? ''),
225
234
  async fetchLive(http) {
226
235
  const rows = await listData(http, '/v1/oauth_clients');
@@ -278,12 +287,31 @@ function registry() {
278
287
  throw err;
279
288
  }
280
289
  },
281
- normalize: (c) => redactSecrets(c, ['before_user_create']),
290
+ fileKeys: ['before_user_create'],
291
+ nestedKeys: { before_user_create: ['url', 'timeout_ms', 'fail_closed', 'enabled', 'signing_secret'] },
292
+ reportsSecrets: true,
282
293
  },
283
294
  // 5. Social / enterprise OAuth providers — key = the catalog provider slug.
284
295
  {
285
296
  kind: 'collection',
286
297
  type: 'oauth_providers',
298
+ fileKeys: [...OAUTH_PROVIDER_KEYS, 'client_secret', 'values', 'settings'],
299
+ reportsSecrets: true,
300
+ defaulted: ['mode'],
301
+ writeOnly: ['values'],
302
+ warnings(config, live) {
303
+ const c = obj(config);
304
+ const lv = obj(live ?? null);
305
+ const inFile = typeof c.client_secret === 'string' && c.client_secret !== '';
306
+ const out = [];
307
+ if (c.mode === 'redirect' && !inFile && !lv.client_secret) {
308
+ out.push(`oauth_providers ${String(c.provider)}: mode redirect needs a client_secret, and neither the file nor the instance has one.`);
309
+ }
310
+ if ((c.mode ?? lv.mode) === 'native' && inFile) {
311
+ out.push(`oauth_providers ${String(c.provider)}: secret unused in native mode. Remove client_secret, or set mode: redirect.`);
312
+ }
313
+ return out;
314
+ },
287
315
  keyOf: (c) => String(obj(c).provider ?? ''),
288
316
  async fetchLive(http) {
289
317
  const rows = await listData(http, '/v1/oauth_providers');
@@ -292,7 +320,11 @@ function registry() {
292
320
  .map((r) => ({
293
321
  key: String(r.provider ?? ''),
294
322
  id: String(r.provider ?? ''),
295
- config: pick(r, OAUTH_PROVIDER_KEYS),
323
+ config: {
324
+ ...obj(pick(r, OAUTH_PROVIDER_KEYS)),
325
+ ...(r.setting_values && Object.keys(r.setting_values).length ? { settings: r.setting_values } : {}),
326
+ ...(r.has_secret === true ? { client_secret: SECRET_SET } : {}),
327
+ },
296
328
  }));
297
329
  },
298
330
  create: putProvider,
@@ -305,13 +337,15 @@ function registry() {
305
337
  {
306
338
  kind: 'collection',
307
339
  type: 'custom_oauth_providers',
340
+ fileKeys: [...CUSTOM_PROVIDER_KEYS, ...CUSTOM_PROVIDER_WRITE_KEYS],
341
+ reportsSecrets: true,
308
342
  keyOf: (c) => String(obj(c).provider_key ?? ''),
309
343
  async fetchLive(http) {
310
344
  const rows = await listData(http, '/v1/custom_oauth_providers');
311
345
  return rows.map((r) => ({
312
346
  key: String(r.provider_key ?? ''),
313
347
  id: String(r.id ?? ''),
314
- config: pick(r, CUSTOM_PROVIDER_KEYS),
348
+ config: { ...obj(pick(r, CUSTOM_PROVIDER_KEYS)), ...(r.has_secret === true ? { client_secret: SECRET_SET } : {}) },
315
349
  }));
316
350
  },
317
351
  async create(http, config) {
@@ -330,6 +364,7 @@ function registry() {
330
364
  {
331
365
  kind: 'collection',
332
366
  type: 'resource_servers',
367
+ fileKeys: [...RESOURCE_SERVER_KEYS, 'publish_resource_metadata', 'access_token_format', 'refresh_token_absolute_ttl_seconds', 'refresh_token_idle_ttl_seconds', 'introspection_client_ids'],
333
368
  keyOf: (c) => String(obj(c).identifier ?? ''),
334
369
  async fetchLive(http) {
335
370
  const rows = await listData(http, '/v1/resource_servers');
@@ -358,6 +393,7 @@ function registry() {
358
393
  {
359
394
  kind: 'collection',
360
395
  type: 'jwt_templates',
396
+ fileKeys: ['name', 'claims'],
361
397
  keyOf: (c) => String(obj(c).name ?? ''),
362
398
  async fetchLive(http) {
363
399
  const rows = await listData(http, '/v1/jwt_templates');
@@ -382,6 +418,7 @@ function registry() {
382
418
  {
383
419
  kind: 'collection',
384
420
  type: 'permissions',
421
+ fileKeys: PERMISSION_KEYS,
385
422
  keyOf: (c) => String(obj(c).key ?? ''),
386
423
  async fetchLive(http) {
387
424
  const rows = await listData(http, '/v1/permissions');
@@ -404,6 +441,7 @@ function registry() {
404
441
  {
405
442
  kind: 'collection',
406
443
  type: 'roles',
444
+ fileKeys: [...ROLE_KEYS, 'visibility'],
407
445
  keyOf: (c) => String(obj(c).key ?? ''),
408
446
  async fetchLive(http) {
409
447
  const rows = await listData(http, '/v1/roles');
@@ -436,6 +474,17 @@ function registry() {
436
474
  {
437
475
  kind: 'collection',
438
476
  type: 'webhook_endpoints',
477
+ fileKeys: [...WEBHOOK_KEYS, 'secret'],
478
+ // A dry-run rotation tells a new secret from the stored one without revealing either.
479
+ async secretChanged(http, live, config) {
480
+ try {
481
+ await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret?dry_run=true`, { secret: obj(config).secret });
482
+ return true;
483
+ }
484
+ catch (err) {
485
+ return isSameSecretRefusal(err) ? false : null;
486
+ }
487
+ },
439
488
  keyOf: (c) => String(obj(c).url ?? ''),
440
489
  async fetchLive(http) {
441
490
  const rows = await listData(http, '/v1/webhook_endpoints');
@@ -453,10 +502,22 @@ function registry() {
453
502
  enabled_events: c.enabled_events ?? [],
454
503
  // Sent on create so a paused endpoint is never live, even briefly.
455
504
  ...(typeof c.active === 'boolean' ? { active: c.active } : {}),
505
+ ...(typeof c.secret === 'string' ? { secret: c.secret } : {}),
456
506
  });
457
507
  },
458
508
  async update(http, live, config) {
459
- await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(config, true));
509
+ // PATCH leaves the signing secret alone; only rotate_secret changes it.
510
+ const { secret, ...rest } = obj(config);
511
+ await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(rest, true));
512
+ if (typeof secret !== 'string' || !secret)
513
+ return;
514
+ try {
515
+ await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret`, { secret });
516
+ }
517
+ catch (err) {
518
+ if (!isSameSecretRefusal(err))
519
+ throw err;
520
+ }
460
521
  },
461
522
  async remove(http, live) {
462
523
  await http.delete(`/v1/webhook_endpoints/${enc(live.id)}`);
@@ -466,11 +527,14 @@ function registry() {
466
527
  {
467
528
  kind: 'collection',
468
529
  type: 'sso_connections',
530
+ fileKeys: [...SSO_KEYS, ...SSO_WRITE_KEYS],
531
+ reportsSecrets: true,
532
+ writeOnly: ['saml_idp_certificate'],
469
533
  keyOf: ssoKey,
470
534
  async fetchLive(http) {
471
535
  const rows = await listData(http, '/v1/sso_connections');
472
536
  return rows.map((r) => {
473
- const config = pick(r, SSO_KEYS);
537
+ const config = { ...obj(pick(r, SSO_KEYS)), ...(r.has_secret === true ? { oidc_client_secret: SECRET_SET, discourse_secret: SECRET_SET } : {}) };
474
538
  return { key: ssoKey(config), id: String(r.id ?? ''), config };
475
539
  });
476
540
  },
@@ -491,6 +555,7 @@ function registry() {
491
555
  {
492
556
  kind: 'collection',
493
557
  type: 'sso_onboarding_profiles',
558
+ fileKeys: SSO_PROFILE_KEYS,
494
559
  keyOf: (c) => String(obj(c).name ?? ''),
495
560
  async fetchLive(http) {
496
561
  let rows;
@@ -521,6 +586,7 @@ function registry() {
521
586
  {
522
587
  kind: 'collection',
523
588
  type: 'billing_plans',
589
+ fileKeys: [...BILLING_KEYS, 'grace_days', 'provider_price_id', 'annual_amount', 'seat_feature_id', 'promotion'],
524
590
  keyOf: (c) => String(obj(c).slug ?? ''),
525
591
  async fetchLive(http) {
526
592
  const rows = await listData(http, '/v1/billing/plans');
@@ -546,6 +612,7 @@ function registry() {
546
612
  {
547
613
  kind: 'collection',
548
614
  type: 'notification_templates',
615
+ fileKeys: NOTIFICATION_KEYS,
549
616
  keyOf: (c) => String(obj(c).name ?? ''),
550
617
  async fetchLive(http) {
551
618
  const rows = await listData(http, '/v1/notification_templates');
@@ -571,6 +638,7 @@ function registry() {
571
638
  {
572
639
  kind: 'collection',
573
640
  type: 'email_templates',
641
+ fileKeys: EMAIL_KEYS,
574
642
  keyOf: (c) => String(obj(c).name ?? ''),
575
643
  async fetchLive(http) {
576
644
  const rows = await listData(http, '/v1/email_templates');
@@ -596,6 +664,7 @@ function registry() {
596
664
  {
597
665
  kind: 'collection',
598
666
  type: 'organization_policies',
667
+ fileKeys: ORG_POLICY_KEYS,
599
668
  keyOf: (c) => String(obj(c).organization_id ?? ''),
600
669
  async fetchLive(http) {
601
670
  const orgs = await listData(http, '/v1/organizations');
@@ -648,6 +717,7 @@ function registry() {
648
717
  {
649
718
  kind: 'singleton',
650
719
  type: 'cookie_consent',
720
+ fileKeys: COOKIE_CONSENT_KEYS,
651
721
  async fetchLive(http) {
652
722
  let cc;
653
723
  try {
@@ -677,6 +747,7 @@ function registry() {
677
747
  {
678
748
  kind: 'singleton',
679
749
  type: 'sign_in_with_atlas',
750
+ fileKeys: ['enabled', 'connection_id'],
680
751
  async fetchLive(http) {
681
752
  let live;
682
753
  try {
@@ -705,6 +776,7 @@ function registry() {
705
776
  {
706
777
  kind: 'singleton',
707
778
  type: 'signing_key_policy',
779
+ fileKeys: ['prepublish_days'],
708
780
  async fetchLive(http) {
709
781
  let live;
710
782
  try {
@@ -754,6 +826,20 @@ const OAUTH_CLIENT_KEYS = [
754
826
  'access_token_format',
755
827
  'token_lifetimes',
756
828
  ];
829
+ /** What a file may also send on an OAuth client: accepted on write, never exported. */
830
+ const OAUTH_CLIENT_WRITE_KEYS = [
831
+ 'application_type',
832
+ 'client_secret',
833
+ 'secret_expires_in_seconds',
834
+ 'jwks',
835
+ 'jwks_uri',
836
+ 'tls_client_auth_subject_dn',
837
+ 'frontchannel_logout_session_required',
838
+ 'disabled',
839
+ 'metadata',
840
+ 'external_ref',
841
+ 'add_redirect_origins',
842
+ ];
757
843
  const OAUTH_PROVIDER_KEYS = [
758
844
  'provider',
759
845
  'enabled',
@@ -761,6 +847,7 @@ const OAUTH_PROVIDER_KEYS = [
761
847
  'allow_sign_up',
762
848
  'scopes',
763
849
  'client_id',
850
+ 'mode',
764
851
  ];
765
852
  const RESOURCE_SERVER_KEYS = [
766
853
  'identifier',
@@ -794,6 +881,18 @@ const CUSTOM_PROVIDER_KEYS = [
794
881
  'allow_sign_in',
795
882
  'allow_sign_up',
796
883
  ];
884
+ const CUSTOM_PROVIDER_WRITE_KEYS = [
885
+ 'client_secret',
886
+ 'end_session_endpoint',
887
+ 'revocation_endpoint',
888
+ 'host_params_schema',
889
+ 'data_only',
890
+ 'scope_separator',
891
+ 'userinfo_auth',
892
+ 'client_assertion_audience',
893
+ 'logout',
894
+ 'idp_initiated_login',
895
+ ];
797
896
  const WEBHOOK_KEYS = ['url', 'enabled_events', 'active'];
798
897
  const SSO_KEYS = [
799
898
  'type',
@@ -812,6 +911,29 @@ const SSO_KEYS = [
812
911
  'claim_role_mappings',
813
912
  'default_role_id',
814
913
  ];
914
+ const SSO_WRITE_KEYS = [
915
+ 'oidc_client_secret',
916
+ 'saml_idp_certificate',
917
+ 'saml_single_logout',
918
+ 'saml_idp_slo_url',
919
+ 'saml_idp_slo_response_url',
920
+ 'saml_idp_slo_binding',
921
+ 'saml_idp_slo_soap_url',
922
+ 'saml_require_encrypted_assertions',
923
+ 'saml_acs_path',
924
+ 'require_consent_screen',
925
+ 'discourse_secret',
926
+ 'provisioning_mode',
927
+ 'manual_endpoints',
928
+ 'github_access',
929
+ 'attribute_mapping',
930
+ 'oidc_scopes',
931
+ 'profile_mapping',
932
+ 'token_endpoint_auth_method',
933
+ 'client_assertion_audience',
934
+ 'logout',
935
+ 'idp_initiated_login',
936
+ ];
815
937
  const SSO_PROFILE_KEYS = ['name', 'allowed_connection_types', 'organization_id', 'company_name', 'allow_scim', 'intents', 'return_url'];
816
938
  const COOKIE_CONSENT_KEYS = ['enabled', 'script_url', 'script_id', 'site_id', 'blocking_mode', 'extra_origins', 'settings_link', 'culture', 'host_aliases'];
817
939
  const BILLING_KEYS = [
@@ -852,18 +974,96 @@ function obj(config) {
852
974
  }
853
975
  /** What a write-only secret looks like on the live side, and in a plan. */
854
976
  const SECRET_SET = '(secret set)';
855
- const SECRET_KEY = /(secret|token|password|passwd|api_?key|private_?key)/i;
977
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
856
978
  /**
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.
979
+ * Bring a file's secrets and the live document to one comparable shape. A secret
980
+ * value becomes "(secret set)", so a plan never holds it and it compares equal to
981
+ * the live marker. `unverifiable` lists the secrets set on both sides, or that the
982
+ * API never reports (`reported` false): Atlas cannot say whether they differ, so
983
+ * they are not drift, and the plan says so. A secret the API reports as unset stays
984
+ * a difference. An unresolved ${VAR} stays visible (apply refuses it), as does an
985
+ * empty value.
860
986
  */
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]));
987
+ function reconcileSecrets(desired, live, reported, prefix = '') {
988
+ if (!isObj(desired))
989
+ return { desired, live, unverifiable: [] };
990
+ const d = { ...desired };
991
+ const l = isObj(live) ? { ...live } : {};
992
+ const unverifiable = [];
993
+ for (const [k, v] of Object.entries(desired)) {
994
+ const path = prefix ? `${prefix}.${k}` : k;
995
+ if (isObj(v)) {
996
+ const inner = reconcileSecrets(v, l[k], reported, path);
997
+ d[k] = inner.desired;
998
+ if (isObj(l[k]))
999
+ l[k] = inner.live;
1000
+ unverifiable.push(...inner.unverifiable);
1001
+ }
1002
+ else if ((0, redact_1.isSecretKey)(k) && typeof v === 'string' && v !== '' && v !== SECRET_SET && !(0, redact_1.isPlaceholder)(v)) {
1003
+ if (l[k] === SECRET_SET) {
1004
+ d[k] = SECRET_SET;
1005
+ unverifiable.push(path);
1006
+ }
1007
+ else if (l[k] === undefined || l[k] === null) {
1008
+ d[k] = SECRET_SET;
1009
+ if (!reported) {
1010
+ l[k] = SECRET_SET;
1011
+ unverifiable.push(path);
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ return { desired: d, live: isObj(live) || unverifiable.length ? l : live, unverifiable };
1017
+ }
1018
+ /** The live document without the secret markers and server defaults the file says nothing about. */
1019
+ function withoutUnstated(live, desired, defaulted = []) {
1020
+ if (!isObj(live))
1021
+ return live;
1022
+ const want = isObj(desired) ? desired : {};
1023
+ const out = {};
1024
+ for (const [k, v] of Object.entries(live)) {
1025
+ if (!(k in want) && (v === SECRET_SET || defaulted.includes(k)))
1026
+ continue;
1027
+ out[k] = isObj(v) && isObj(want[k]) ? withoutUnstated(v, want[k]) : v;
1028
+ }
1029
+ return out;
1030
+ }
1031
+ /** How close two key names are (edit distance). */
1032
+ function distance(a, b) {
1033
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1034
+ for (let i = 1; i <= a.length; i++) {
1035
+ const row = [i];
1036
+ for (let j = 1; j <= b.length; j++)
1037
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1038
+ prev = row;
1039
+ }
1040
+ return prev[b.length];
1041
+ }
1042
+ function didYouMean(key, known) {
1043
+ const norm = (x) => x.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1044
+ const best = [...known].sort((a, b) => distance(norm(key), a) - distance(norm(key), b))[0];
1045
+ return best && distance(norm(key), best) <= Math.max(2, Math.floor(key.length / 3)) ? ` (did you mean ${best}?)` : '';
1046
+ }
1047
+ /** The keys in one parsed resource file that no spec knows, each named with its nearest real key. */
1048
+ function unknownFileKeys(spec, doc) {
1049
+ const out = [];
1050
+ const check = (value, known, label) => {
1051
+ if (!isObj(value))
1052
+ return;
1053
+ for (const k of Object.keys(value))
1054
+ if (!known.includes(k))
1055
+ out.push(`${label}: ${k}${didYouMean(k, known)}`);
1056
+ };
1057
+ if (spec.kind === 'collection') {
1058
+ for (const entry of asArray(doc))
1059
+ check(entry, spec.fileKeys, `${spec.type} ${spec.keyOf(entry) || '(no key)'}`);
1060
+ }
1061
+ else if (spec.fileKeys) {
1062
+ check(doc, spec.fileKeys, spec.type);
1063
+ for (const [k, known] of Object.entries(spec.nestedKeys ?? {}))
1064
+ if (isObj(doc))
1065
+ check(doc[k], known, `${spec.type}.${k}`);
1066
+ }
867
1067
  return out;
868
1068
  }
869
1069
  /** A document without any "(secret set)" marker: what an export writes and what apply sends. */
@@ -910,6 +1110,8 @@ async function putProvider(http, config) {
910
1110
  // Credential-less providers (siwe, siws, siwb, siwf) take verifier settings instead.
911
1111
  if (c.settings && typeof c.settings === 'object' && !Array.isArray(c.settings))
912
1112
  body.settings = c.settings;
1113
+ if (c.mode === 'native' || c.mode === 'redirect')
1114
+ body.mode = c.mode;
913
1115
  const provider = String(c.provider ?? '');
914
1116
  if (Object.keys(body).length) {
915
1117
  await http.put(`/v1/oauth_providers/${enc(provider)}`, body);
@@ -976,10 +1178,10 @@ function resolveDir(values) {
976
1178
  return (0, args_1.str)(values, 'dir') || './atlas-config';
977
1179
  }
978
1180
  /** Build the config HTTP client from the resolved profile credential. */
979
- function requireHttp(ctx, values) {
1181
+ function requireHttp(ctx, values, onUnknownFields) {
980
1182
  const auth = (0, shared_1.resolveAuth)(ctx, values);
981
1183
  const make = ctx.makeHttp ?? config_http_1.createConfigHttp;
982
- return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl });
1184
+ return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl, ...(onUnknownFields ? { onUnknownFields } : {}) });
983
1185
  }
984
1186
  /** Write every resource type's live config to `dir` (deterministic, secrets omitted); returns the file count. */
985
1187
  async function writeExport(http, dir, format) {
@@ -998,7 +1200,7 @@ async function writeExport(http, dir, format) {
998
1200
  const entries = (await spec.fetchLive(http)).filter((e) => !e.managedBy);
999
1201
  // Deterministic: entries sorted by natural key, keys sorted inside each.
1000
1202
  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));
1203
+ const doc = sorted.map((e) => (0, serialize_1.canonicalize)(withoutMarkers(e.config)));
1002
1204
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), (0, serialize_1.serialize)(doc, format));
1003
1205
  fileCount += 1;
1004
1206
  }
@@ -1027,6 +1229,12 @@ async function planConfigImport(http, opts) {
1027
1229
  throw new errors_1.CliError(`Config directory not found: ${dir}. Run 'atlas config export' first.`);
1028
1230
  }
1029
1231
  const present = new Set((0, node_fs_1.readdirSync)(dir));
1232
+ const unknown = registry().flatMap((spec) => present.has(`${spec.type}.${ext}`) ? unknownFileKeys(spec, (0, serialize_1.deserialize)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), 'utf8'), format)) : []);
1233
+ if (unknown.length)
1234
+ throw new errors_1.CliError(`Unknown key(s) in the config:\n ${unknown.join('\n ')}`);
1235
+ const rotate = opts.rotateSecrets === true;
1236
+ const warnings = [];
1237
+ const unverifiableSecrets = [];
1030
1238
  const changes = [];
1031
1239
  let unchanged = 0;
1032
1240
  const skippedDeletes = [];
@@ -1048,12 +1256,21 @@ async function planConfigImport(http, opts) {
1048
1256
  unchanged += 1;
1049
1257
  continue;
1050
1258
  }
1051
- const live = liveRaw === null ? null : comparable(norm(liveRaw), desiredN, prune);
1052
- if (live !== null && (0, serialize_1.deepEqual)(desiredN, live)) {
1259
+ if (liveRaw === null) {
1260
+ const masked = reconcileSecrets(desiredN, undefined, true).desired;
1261
+ changes.push({ type: spec.type, action: 'create', key: '', after: masked, raw: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
1262
+ continue;
1263
+ }
1264
+ const seen = withoutUnstated(comparable(norm(liveRaw), desiredN, prune), desiredN);
1265
+ const r = reconcileSecrets(desiredN, seen, spec.reportsSecrets === true);
1266
+ const forced = rotate ? r.unverifiable : [];
1267
+ if (!rotate)
1268
+ unverifiableSecrets.push(...r.unverifiable.map((path) => ({ type: spec.type, key: '', path })));
1269
+ if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, r.live)) {
1053
1270
  unchanged += 1;
1054
1271
  continue;
1055
1272
  }
1056
- changes.push({ type: spec.type, action: live === null ? 'create' : 'update', key: '', ...(live === null ? {} : { before: live }), after: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
1273
+ 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
1274
  continue;
1058
1275
  }
1059
1276
  // Collection: diff desired-by-key against live-by-key.
@@ -1069,19 +1286,40 @@ async function planConfigImport(http, opts) {
1069
1286
  const liveByKey = new Map(live.map((e) => [e.key, e]));
1070
1287
  for (const [key, cfg] of desiredByKey) {
1071
1288
  const existing = liveByKey.get(key);
1289
+ warnings.push(...(spec.warnings?.(cfg, existing?.config) ?? []));
1072
1290
  if (!existing) {
1073
- changes.push({ type: spec.type, action: 'create', key, after: cfg, apply: () => spec.create(http, cfg) });
1291
+ changes.push({ type: spec.type, action: 'create', key, after: reconcileSecrets(cfg, undefined, true).desired, raw: cfg, apply: () => spec.create(http, cfg) });
1292
+ continue;
1293
+ }
1294
+ const seen = withoutUnstated(comparable(existing.config, cfg, prune), cfg, spec.defaulted);
1295
+ const r = reconcileSecrets(cfg, seen, spec.reportsSecrets === true);
1296
+ const unverifiable = [...r.unverifiable];
1297
+ const liveSeen = obj(r.live);
1298
+ for (const k of spec.writeOnly ?? []) {
1299
+ if (obj(r.desired)[k] === undefined)
1300
+ continue;
1301
+ liveSeen[k] = obj(r.desired)[k];
1302
+ unverifiable.push(k);
1074
1303
  }
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.`);
1304
+ let forced = rotate ? unverifiable : [];
1305
+ let unknownToApi = rotate ? [] : unverifiable;
1306
+ if (spec.secretChanged && unverifiable.length) {
1307
+ const changed = await spec.secretChanged(http, existing, cfg);
1308
+ if (changed !== null) {
1309
+ forced = changed ? unverifiable : [];
1310
+ unknownToApi = [];
1078
1311
  }
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
1312
  }
1082
- else {
1313
+ unverifiableSecrets.push(...unknownToApi.map((path) => ({ type: spec.type, key, path })));
1314
+ if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, liveSeen)) {
1083
1315
  unchanged += 1;
1316
+ continue;
1084
1317
  }
1318
+ if (!spec.update) {
1319
+ throw new errors_1.CliError(`${spec.type} '${key}' changed but is immutable; recreate it instead.`);
1320
+ }
1321
+ const upd = spec.update;
1322
+ changes.push({ type: spec.type, action: 'update', key, before: liveSeen, after: r.desired, raw: cfg, changedSecrets: forced, apply: () => upd(http, existing, cfg) });
1085
1323
  }
1086
1324
  for (const entry of live) {
1087
1325
  if (desiredByKey.has(entry.key))
@@ -1100,7 +1338,7 @@ async function planConfigImport(http, opts) {
1100
1338
  }
1101
1339
  }
1102
1340
  }
1103
- return { changes, unchanged, skippedDeletes, managed };
1341
+ return { changes, unchanged, skippedDeletes, managed, warnings, unverifiableSecrets };
1104
1342
  }
1105
1343
  /** True for a non-empty object whose every leaf is `null` (a file that only clears fields). */
1106
1344
  function onlyNulls(x) {
@@ -1120,7 +1358,6 @@ function onlyNulls(x) {
1120
1358
  function comparable(live, desired, prune) {
1121
1359
  if (prune)
1122
1360
  return live;
1123
- const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
1124
1361
  if (!isObj(live) || !isObj(desired))
1125
1362
  return live;
1126
1363
  const out = {};
@@ -1180,6 +1417,7 @@ async function promoteConfig(ctx, values) {
1180
1417
  const plan = await planConfigImport(target.http, { dir, format, env: ctx.env, prune });
1181
1418
  ctx.io.out(`Promoting ${from} -> ${to}`);
1182
1419
  printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
1420
+ printPlanNotes(ctx, plan);
1183
1421
  if (dryRun)
1184
1422
  return 0;
1185
1423
  for (const change of plan.changes)
@@ -1197,8 +1435,9 @@ async function importConfig(ctx, values) {
1197
1435
  const format = resolveFormat(values);
1198
1436
  const dryRun = values['dry-run'] === true;
1199
1437
  const prune = values.prune === true;
1200
- const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true });
1438
+ const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
1201
1439
  printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
1440
+ printPlanNotes(ctx, plan);
1202
1441
  if (dryRun)
1203
1442
  return 0;
1204
1443
  for (const change of plan.changes)
@@ -1211,7 +1450,6 @@ async function importConfig(ctx, values) {
1211
1450
  }
1212
1451
  /** Leaf-level differences between two documents, as dotted paths (arrays compare whole). */
1213
1452
  function diffFields(before, after, path = '') {
1214
- const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
1215
1453
  if (isObj(before) && isObj(after)) {
1216
1454
  const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
1217
1455
  return keys.flatMap((k) => diffFields(before[k], after[k], path ? `${path}.${k}` : k));
@@ -1223,6 +1461,35 @@ function diffFields(before, after, path = '') {
1223
1461
  return [{ path: path || '(document)', ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) }];
1224
1462
  }
1225
1463
  const show = (v) => (v === undefined ? '(unset)' : typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v));
1464
+ /**
1465
+ * The only form a change's fields are printed or serialised in. A secret-named
1466
+ * string is `<redacted>` with a changed flag, whatever the resource; everything else
1467
+ * goes through the deep redactor.
1468
+ */
1469
+ function printedFields(c) {
1470
+ const reveal = (0, redact_1.revealingSecrets)();
1471
+ const diffs = diffFields(c.before, reveal ? (c.raw ?? c.after) : c.after);
1472
+ const rows = diffs.map((f) => {
1473
+ const leaf = f.path.split('.').pop();
1474
+ const textual = [f.before, f.after].every((v) => v === undefined || v === null || typeof v === 'string');
1475
+ if (!reveal && (0, redact_1.isSecretKey)(leaf) && textual && !(0, redact_1.isPlaceholder)(f.after))
1476
+ return { path: f.path, redacted: true, changed: true };
1477
+ 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) }) };
1478
+ });
1479
+ for (const path of c.changedSecrets ?? [])
1480
+ if (!rows.some((r) => r.path === path))
1481
+ rows.push({ path, redacted: true, changed: true });
1482
+ return rows;
1483
+ }
1484
+ const printedLine = (f) => (f.redacted ? `${f.path}: ${redact_1.REDACTED} (changed)` : `${f.path}: ${show(f.before)} -> ${show(f.after)}`);
1485
+ /** The plan's advice: warnings, and the secrets Atlas cannot compare. */
1486
+ function printPlanNotes(ctx, plan) {
1487
+ for (const w of plan.warnings)
1488
+ ctx.io.out(`warning: ${w}`);
1489
+ for (const u of plan.unverifiableSecrets) {
1490
+ 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.`);
1491
+ }
1492
+ }
1226
1493
  /**
1227
1494
  * `atlas config plan`: a Terraform-style, read-only diff of a config directory
1228
1495
  * (`atlas config export` output, committed to git) against the live instance.
@@ -1234,14 +1501,14 @@ async function planConfig(ctx, values, source) {
1234
1501
  const { dir, format, env } = source;
1235
1502
  const where = source.label ?? dir;
1236
1503
  const prune = values.prune === true;
1237
- const plan = await planConfigImport(http, { dir, format, env, prune });
1504
+ const plan = await planConfigImport(http, { dir, format, env, prune, rotateSecrets: values['rotate-secret'] === true });
1238
1505
  const deletes = prune ? [] : plan.skippedDeletes;
1239
1506
  const all = plan.changes;
1240
1507
  const rows = all.map((c) => ({
1241
1508
  type: c.type,
1242
1509
  key: c.key || null,
1243
1510
  action: c.action,
1244
- fields: c.action === 'update' ? diffFields(c.before, c.after) : [],
1511
+ fields: c.action === 'update' ? printedFields(c) : [],
1245
1512
  }));
1246
1513
  const pending = rows.length;
1247
1514
  if (values.json === true) {
@@ -1251,6 +1518,8 @@ async function planConfig(ctx, values, source) {
1251
1518
  unchanged: plan.unchanged,
1252
1519
  unmanaged_live: deletes.map((d) => ({ type: d.type, key: d.key || null })),
1253
1520
  managed_by_platform: plan.managed.map((m) => ({ type: m.type, key: m.key, managed_by: m.managedBy })),
1521
+ warnings: plan.warnings,
1522
+ unverifiable_secrets: plan.unverifiableSecrets.map((u) => ({ type: u.type, key: u.key || null, path: u.path })),
1254
1523
  has_changes: pending > 0,
1255
1524
  }, null, 2));
1256
1525
  }
@@ -1262,8 +1531,8 @@ async function planConfig(ctx, values, source) {
1262
1531
  ctx.io.out('');
1263
1532
  ctx.io.out(`${sign[c.action]} ${label} (${c.action})`);
1264
1533
  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)}`);
1534
+ for (const f of printedFields(c))
1535
+ ctx.io.out(` ${printedLine(f)}`);
1267
1536
  }
1268
1537
  }
1269
1538
  if (deletes.length) {
@@ -1278,6 +1547,9 @@ async function planConfig(ctx, values, source) {
1278
1547
  for (const m of plan.managed)
1279
1548
  ctx.io.out(` ${m.type} ${m.key} (${m.managedBy})`);
1280
1549
  }
1550
+ if (plan.warnings.length || plan.unverifiableSecrets.length)
1551
+ ctx.io.out('');
1552
+ printPlanNotes(ctx, plan);
1281
1553
  if (pending === 0)
1282
1554
  ctx.io.out(`No drift: the instance matches ${where}.`);
1283
1555
  }
@@ -1420,10 +1692,21 @@ async function applyCommand(ctx, args) {
1420
1692
  return planCommand(ctx, args);
1421
1693
  const source = await resolveSource(ctx, args);
1422
1694
  try {
1423
- const kept = keepingSecrets(requireHttp(ctx, values), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
1695
+ 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.`);
1696
+ const kept = keepingSecrets(requireHttp(ctx, values, onUnknownFields), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
1424
1697
  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 });
1698
+ 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
1699
  printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun: false, prune });
1700
+ for (const c of plan.changes) {
1701
+ if (c.action !== 'update')
1702
+ continue;
1703
+ const fields = printedFields(c);
1704
+ if (fields.length)
1705
+ ctx.io.out(` ~ ${c.key ? `${c.type} ${c.key}` : c.type}:`);
1706
+ for (const f of fields)
1707
+ ctx.io.out(` ${printedLine(f)}`);
1708
+ }
1709
+ printPlanNotes(ctx, plan);
1427
1710
  if (plan.changes.length === 0) {
1428
1711
  ctx.io.out('No changes to apply.');
1429
1712
  return 0;
@@ -1471,7 +1754,8 @@ function keepingSecrets(http, dir) {
1471
1754
  if (path === '/v1/oauth_clients' && typeof r.client_secret === 'string') {
1472
1755
  entries = { ATLAS_CLIENT_ID: String(r.client_id ?? ''), ATLAS_CLIENT_SECRET: r.client_secret };
1473
1756
  }
1474
- else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string') {
1757
+ else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string' && !body?.secret) {
1758
+ // A secret the file chose is already the caller's; only a minted one needs keeping.
1475
1759
  entries = { ATLAS_WEBHOOK_SECRET: r.secret };
1476
1760
  }
1477
1761
  if (entries) {
@@ -1515,16 +1799,22 @@ function asArray(value) {
1515
1799
  * on disk. An unset variable is left verbatim so the plan surfaces it rather
1516
1800
  * than silently blanking a credential.
1517
1801
  */
1518
- function interpolate(value, env) {
1802
+ function interpolate(value, env, key) {
1519
1803
  if (typeof value === 'string') {
1520
- return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => env[name] ?? match);
1804
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => {
1805
+ const resolved = env[name];
1806
+ if (resolved === undefined)
1807
+ return match;
1808
+ (0, redact_1.rememberResolved)(key, resolved);
1809
+ return resolved;
1810
+ });
1521
1811
  }
1522
1812
  if (Array.isArray(value))
1523
- return value.map((v) => interpolate(v, env));
1813
+ return value.map((v) => interpolate(v, env, key));
1524
1814
  if (value !== null && typeof value === 'object') {
1525
1815
  const out = {};
1526
1816
  for (const [k, v] of Object.entries(value))
1527
- out[k] = interpolate(v, env);
1817
+ out[k] = interpolate(v, env, k);
1528
1818
  return out;
1529
1819
  }
1530
1820
  return value;