dsh-server-connector 0.1.0 → 0.2.1

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/lib/policy.js CHANGED
@@ -278,6 +278,162 @@ export function compareDecisions(a, b) {
278
278
  return RANK[a] - RANK[b]
279
279
  }
280
280
 
281
+ // ------------------------------------------------- custom rules (doc §4.4) --
282
+
283
+ /** Longest accepted `pattern` source. Past this the rule is almost certainly a paste accident. */
284
+ export const MAX_CUSTOM_PATTERN_LENGTH = 512
285
+
286
+ /** Most custom rules one server may carry. Bounds both the UI and the regex sweep. */
287
+ export const MAX_CUSTOM_RULES = 50
288
+
289
+ /**
290
+ * Shapes that invite catastrophic backtracking: a quantified group whose body is
291
+ * itself quantified (`(a+)+`, `(a*)*`, `(.*)+`). Conservative on purpose — it
292
+ * rejects a few harmless forms rather than admitting a ReDoS.
293
+ */
294
+ const NESTED_QUANTIFIER = /\(\s*[^()]*[+*]\s*\)\s*[+*]/
295
+
296
+ /** One custom rule as stored on a server record (doc §3.1). */
297
+ /**
298
+ * @typedef {object} CustomRule
299
+ * @property {string} id
300
+ * @property {string} label
301
+ * @property {string} pattern - regex source, compiled on read
302
+ * @property {Decision} defaultDecision - `confirm` or `block`; never `allow`
303
+ * @property {string} description
304
+ */
305
+
306
+ /**
307
+ * A custom rule that failed validation. The route layer turns this into a 400.
308
+ */
309
+ export class RuleError extends Error {
310
+ /**
311
+ * @param {string} code - machine-readable reason
312
+ * @param {string} message - human-readable, shown in the panel
313
+ */
314
+ constructor(code, message) {
315
+ super(message)
316
+ this.name = 'RuleError'
317
+ this.code = code
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Validate and compile one custom rule (doc §4.4.2). Every check runs before the
323
+ * rule is allowed to touch `servers.json`, so a rule that cannot be honoured is
324
+ * never stored.
325
+ *
326
+ * `samples` is optional: when given, at least one must match, which is the
327
+ * check that catches "I wrote a regex that never fires".
328
+ * @param {unknown} value - the rule as it arrived (already id-assigned)
329
+ * @param {{samples?: string[], takenIds?: Iterable<string>}} [options]
330
+ * @returns {CustomRule}
331
+ */
332
+ export function validateCustomRule(value, options = {}) {
333
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
334
+ throw new RuleError('INVALID_RULE', 'a custom rule must be an object.')
335
+ }
336
+ const rule = /** @type {Record<string, unknown>} */ (value)
337
+ const label = typeof rule.label === 'string' ? rule.label.trim() : ''
338
+ if (label === '') throw new RuleError('INVALID_RULE', 'label must be a non-empty string.')
339
+ if (label.length > 80) throw new RuleError('INVALID_RULE', 'label must be at most 80 characters.')
340
+ const description = typeof rule.description === 'string' ? rule.description.trim() : ''
341
+ if (description.length > 400) throw new RuleError('INVALID_RULE', 'description must be at most 400 characters.')
342
+ if (rule.defaultDecision !== 'confirm' && rule.defaultDecision !== 'block') {
343
+ // `allow` is refused outright: a rule that permits what the engine already
344
+ // permits buys nothing, and it is the only way to fake a loosening.
345
+ throw new RuleError('INVALID_RULE', 'defaultDecision must be "confirm" or "block".')
346
+ }
347
+ if (typeof rule.pattern !== 'string' || rule.pattern.trim() === '') {
348
+ throw new RuleError('INVALID_RULE', 'pattern must be a non-empty regex source.')
349
+ }
350
+ const pattern = rule.pattern.trim()
351
+ if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
352
+ throw new RuleError('INVALID_RULE', `pattern must be at most ${MAX_CUSTOM_PATTERN_LENGTH} characters.`)
353
+ }
354
+ if (NESTED_QUANTIFIER.test(pattern)) {
355
+ throw new RuleError(
356
+ 'INVALID_RULE',
357
+ 'pattern contains a nested quantifier like (a+)+, which can backtrack catastrophically. Rewrite it without the inner repetition.',
358
+ )
359
+ }
360
+ /** @type {RegExp} */
361
+ let compiled
362
+ try {
363
+ compiled = new RegExp(pattern, 'i')
364
+ } catch (error) {
365
+ throw new RuleError('INVALID_RULE', `pattern is not a valid regular expression: ${/** @type {Error} */ (error).message}`)
366
+ }
367
+ // A pattern that matches the empty string matches every command, which would
368
+ // block or confirm everything on that server.
369
+ if (compiled.test('')) {
370
+ throw new RuleError('INVALID_RULE', 'pattern matches the empty string, so it would match every command.')
371
+ }
372
+ const id = typeof rule.id === 'string' ? rule.id.trim() : ''
373
+ if (id === '') throw new RuleError('INVALID_RULE', 'id must be a non-empty string.')
374
+ const taken = new Set(options.takenIds ?? [])
375
+ if (RULES_BY_ID.has(id) || taken.has(id)) {
376
+ throw new RuleError('DUPLICATE_RULE', `rule id "${id}" is already in use on this server.`)
377
+ }
378
+ const samples = Array.isArray(options.samples) ? options.samples.filter((s) => typeof s === 'string' && s.trim() !== '') : []
379
+ if (samples.length > 0 && !samples.some((sample) => compiled.test(sample))) {
380
+ throw new RuleError(
381
+ 'RULE_NEVER_MATCHES',
382
+ 'none of the sample commands matched this pattern; it would never fire.',
383
+ )
384
+ }
385
+ return Object.freeze({
386
+ id,
387
+ label,
388
+ pattern,
389
+ defaultDecision: /** @type {Decision} */ (rule.defaultDecision),
390
+ description,
391
+ })
392
+ }
393
+
394
+ /**
395
+ * Compile a stored custom rule for matching. A record that fails to compile is
396
+ * skipped rather than thrown: `servers.json` may predate a stricter validator,
397
+ * and one bad rule must not take down every command on that server.
398
+ * @param {CustomRule} rule
399
+ * @returns {Rule|null}
400
+ */
401
+ function compileCustomRule(rule) {
402
+ /** @type {RegExp} */
403
+ let pattern
404
+ try {
405
+ pattern = new RegExp(rule.pattern, 'i')
406
+ } catch {
407
+ return null
408
+ }
409
+ return Object.freeze({
410
+ id: rule.id,
411
+ label: rule.label,
412
+ defaultDecision: rule.defaultDecision,
413
+ description: rule.description,
414
+ pattern,
415
+ custom: true,
416
+ })
417
+ }
418
+
419
+ /**
420
+ * The rule set that applies to one server: the built-in catalog, then that
421
+ * server's custom rules (doc §4.2.5). Custom rules enjoy no privilege — they run
422
+ * through the same match and strictest-wins logic.
423
+ * @param {ReadonlyArray<CustomRule>|undefined} customRules
424
+ * @returns {ReadonlyArray<Rule>}
425
+ */
426
+ export function rulesFor(customRules) {
427
+ if (!Array.isArray(customRules) || customRules.length === 0) return RULES
428
+ const extra = []
429
+ for (const rule of customRules) {
430
+ if (rule === null || typeof rule !== 'object') continue
431
+ const compiled = compileCustomRule(/** @type {CustomRule} */ (rule))
432
+ if (compiled !== null) extra.push(compiled)
433
+ }
434
+ return extra.length === 0 ? RULES : [...RULES, ...extra]
435
+ }
436
+
281
437
  /**
282
438
  * Split a command line into the subcommands every rule must be checked against.
283
439
  *
@@ -300,24 +456,27 @@ export function splitSubcommands(command) {
300
456
  /**
301
457
  * Separate the two accepted second-argument shapes into overrides plus disabled
302
458
  * rule ids:
303
- * - a server record (`{ policy, disabledRules }`, doc §3.1), or
459
+ * - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
304
460
  * - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
305
461
  *
306
462
  * A third argument may carry `disabledRules` alongside a bare override map.
307
463
  * @param {Record<string, unknown>|undefined} serverPolicy
308
464
  * @param {string[]|undefined} disabledRules
309
- * @returns {{overrides: Record<string, unknown>, disabled: Set<string>}}
465
+ * @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
310
466
  */
311
467
  function normalizeServerPolicy(serverPolicy, disabledRules) {
312
468
  const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
313
469
  if (serverPolicy === undefined || serverPolicy === null) {
314
- return { overrides: {}, disabled }
470
+ return { overrides: {}, disabled, customRules: [] }
315
471
  }
316
472
  if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
317
473
  if (Array.isArray(serverPolicy.disabledRules)) {
318
474
  for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
319
475
  }
320
476
  const fields = /** @type {Record<string, unknown>} */ (serverPolicy)
477
+ const customRules = Array.isArray(fields.customRules)
478
+ ? /** @type {CustomRule[]} */ (fields.customRules.filter((rule) => rule !== null && typeof rule === 'object'))
479
+ : []
321
480
  const record = 'policy' in fields || 'disabledRules' in fields
322
481
  const policy = fields.policy
323
482
  /** @type {Record<string, unknown>} */
@@ -326,7 +485,7 @@ function normalizeServerPolicy(serverPolicy, disabledRules) {
326
485
  ? /** @type {Record<string, unknown>} */ (policy)
327
486
  : {}
328
487
  : fields
329
- return { overrides, disabled }
488
+ return { overrides, disabled, customRules }
330
489
  }
331
490
 
332
491
  /**
@@ -390,10 +549,12 @@ function ruleMatches(rule, command, segments) {
390
549
  */
391
550
  export function check(command, serverPolicy = {}, disabledRules) {
392
551
  const segments = splitSubcommands(command)
393
- const { overrides, disabled } = normalizeServerPolicy(serverPolicy, disabledRules)
552
+ const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
394
553
 
395
554
  const matches = []
396
- for (const rule of RULES) {
555
+ // Built-ins first, then this server's custom rules (doc §4.2.5): the custom
556
+ // set is part of the same sweep, so strictest-wins needs no special case.
557
+ for (const rule of rulesFor(customRules)) {
397
558
  if (disabled.has(rule.id)) continue
398
559
  if (!ruleMatches(rule, command, segments)) continue
399
560
  matches.push({
package/lib/registry.js CHANGED
@@ -21,6 +21,8 @@ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
21
21
  import { homedir } from 'node:os'
22
22
  import { dirname, join, resolve } from 'node:path'
23
23
 
24
+ import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
25
+
24
26
  /** Schema version written to, and required from, `servers.json`. */
25
27
  export const REGISTRY_VERSION = 1
26
28
 
@@ -57,8 +59,10 @@ export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
57
59
  * @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
58
60
  * @property {'accept-new'|'verify'|'off'} hostKeyMode
59
61
  * @property {string|null} defaultWorkDir
62
+ * @property {boolean} enabled
60
63
  * @property {Record<string, 'allow'|'confirm'|'block'>} policy
61
64
  * @property {string[]} disabledRules
65
+ * @property {import('./policy.js').CustomRule[]} customRules
62
66
  * @property {string} createdAt
63
67
  * @property {string|null} lastConnectedAt
64
68
  */
@@ -133,9 +137,11 @@ const CREATE_FIELDS = new Set([
133
137
  'username',
134
138
  'auth',
135
139
  'hostKeyMode',
140
+ 'enabled',
136
141
  'defaultWorkDir',
137
142
  'policy',
138
143
  'disabledRules',
144
+ 'customRules',
139
145
  ])
140
146
 
141
147
  /** Fields an update() call accepts; `id` and `createdAt` are immutable. */
@@ -286,6 +292,22 @@ function normalizeWorkDir(value, errorCode) {
286
292
  return trimmed === '' ? null : trimmed
287
293
  }
288
294
 
295
+ /**
296
+ * The enable switch (doc §3.1, enable-switch spec).
297
+ *
298
+ * Absent means enabled: files written before the switch existed carry no
299
+ * `enabled` key, and silently disabling every server on load would be a
300
+ * destructive surprise.
301
+ * @param {unknown} value
302
+ * @param {RegistryErrorCode} errorCode
303
+ * @returns {boolean}
304
+ */
305
+ function normalizeEnabled(value, errorCode) {
306
+ if (value === undefined || value === null) return true
307
+ if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
308
+ return value
309
+ }
310
+
289
311
  /**
290
312
  * @param {unknown} value
291
313
  * @param {string} field
@@ -314,13 +336,92 @@ function normalizeOptionalTimestamp(value, field, errorCode) {
314
336
  return normalizeTimestamp(value, field, errorCode)
315
337
  }
316
338
 
339
+ /**
340
+ * The grammar every credential ref must satisfy: a POSIX shell identifier.
341
+ *
342
+ * This is @deepseek-ai/dsh-credentials' own contract (`REF_PATTERN`), not an
343
+ * invention of this plugin. Keeping a local copy is deliberate: a ref this
344
+ * plugin persists is later parsed by that package, and a mismatch is not a
345
+ * local error — it prevents `credentials` from activating, which fails the whole
346
+ * Host startup.
347
+ */
348
+ const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
349
+
350
+ /**
351
+ * The ref this plugin derives for a server's password (doc §6.3).
352
+ *
353
+ * `server-connector:<id>:password` was the old shape and it was illegal: the
354
+ * colon and the dash fall outside the grammar above, so persisting it broke the
355
+ * next startup. The id is folded into a single identifier instead
356
+ * (`DSH_SERVER_CONNECTOR_SRV_01_PASSWORD`).
357
+ * @param {string} serverId - e.g. `srv-01`
358
+ * @returns {string}
359
+ */
360
+ function deriveCredentialRef(serverId) {
361
+ const normalised = String(serverId)
362
+ .toUpperCase()
363
+ .replace(/[^A-Z0-9]+/g, '_')
364
+ .replace(/^_+|_+$/g, '')
365
+ const safe = normalised === '' ? 'SERVER' : normalised
366
+ return `DSH_SERVER_CONNECTOR_${safe}_PASSWORD`
367
+ }
368
+
369
+ /**
370
+ * Validate a caller-supplied credential ref against the same grammar.
371
+ *
372
+ * A caller may pass its own ref (an environment-variable name, typically), but
373
+ * an illegal one must be refused here rather than stored: storing it would
374
+ * break the next startup instead of this request.
375
+ * @param {unknown} value
376
+ * @param {RegistryErrorCode} errorCode
377
+ * @returns {string}
378
+ */
379
+ function assertCredentialRef(value, errorCode) {
380
+ const ref = requiredString(value, 'auth.credentialRef', errorCode)
381
+ if (!CREDENTIAL_REF_PATTERN.test(ref)) {
382
+ throw new RegistryError(
383
+ errorCode,
384
+ `auth.credentialRef ${JSON.stringify(ref)} must be a POSIX identifier ` +
385
+ `(letters, digits and underscores, not starting with a digit), because DSH credentials rejects ` +
386
+ `any other ref and the rejection fails the whole startup.`,
387
+ )
388
+ }
389
+ return ref
390
+ }
391
+
392
+ /**
393
+ * Read-time recovery for a ref an older build persisted (doc §6.3).
394
+ *
395
+ * Before 0.2.1 this plugin derived `server-connector:<id>:password`, which is
396
+ * outside the grammar. Those records exist on disk in the wild, and refusing
397
+ * them at load would leave the plugin unable to start — trading a Host crash for
398
+ * a plugin that never loads. Instead the illegal legacy shape is mapped onto the
399
+ * derived one, so the record keeps working and the next write persists the fixed
400
+ * ref.
401
+ *
402
+ * Only the exact legacy shape is migrated. Any other illegal ref is still
403
+ * refused: silently rewriting a ref this plugin never wrote could point at a
404
+ * different secret.
405
+ * @param {unknown} value
406
+ * @param {string} serverId
407
+ * @param {RegistryErrorCode} errorCode
408
+ * @returns {string}
409
+ */
410
+ function migrateCredentialRef(value, serverId, errorCode) {
411
+ const ref = requiredString(value, 'auth.credentialRef', errorCode)
412
+ if (CREDENTIAL_REF_PATTERN.test(ref)) return ref
413
+ if (ref === `server-connector:${serverId}:password`) return deriveCredentialRef(serverId)
414
+ return assertCredentialRef(ref, errorCode)
415
+ }
416
+
317
417
  /**
318
418
  * @param {unknown} value
319
419
  * @param {string} serverId
320
420
  * @param {RegistryErrorCode} errorCode
421
+ * @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
321
422
  * @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
322
423
  */
323
- function normalizeAuth(value, serverId, errorCode) {
424
+ function normalizeAuth(value, serverId, errorCode, options = {}) {
324
425
  if (value === undefined) return { kind: 'agent' }
325
426
  if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
326
427
  const kind = value.kind
@@ -330,12 +431,20 @@ function normalizeAuth(value, serverId, errorCode) {
330
431
  }
331
432
  if (kind === 'password') {
332
433
  assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
333
- // Doc §6.3: the record keeps a reference, never the password itself. The
334
- // conventional ref is `server-connector:<id>:password`.
434
+ // Doc §6.3: the record keeps a reference, never the password itself.
435
+ //
436
+ // The ref MUST be a POSIX shell identifier: @deepseek-ai/dsh-credentials
437
+ // validates every stored ref with /^[A-Za-z_][A-Za-z0-9_]*$/ while loading
438
+ // credentials.yaml, and a ref outside that grammar throws during
439
+ // `credentials` activation. Because `credentials` is a required plugin, that
440
+ // takes the entire Host down at startup — a persisted bad ref bricks DSH
441
+ // until the file is edited by hand. Derive and validate accordingly.
335
442
  const credentialRef =
336
443
  value.credentialRef === undefined
337
- ? `server-connector:${serverId}:password`
338
- : requiredString(value.credentialRef, 'auth.credentialRef', errorCode)
444
+ ? deriveCredentialRef(serverId)
445
+ : options.migrate === true
446
+ ? migrateCredentialRef(value.credentialRef, serverId, errorCode)
447
+ : assertCredentialRef(value.credentialRef, errorCode)
339
448
  return { kind: 'password', credentialRef }
340
449
  }
341
450
  if (kind === 'agent') {
@@ -387,6 +496,41 @@ function normalizeDisabledRules(value, errorCode) {
387
496
  return ids
388
497
  }
389
498
 
499
+ /**
500
+ * Validate the custom-rule list of one record (doc §4.4.2).
501
+ *
502
+ * Ids are unique per server and must not collide with a built-in id. The
503
+ * decision may only be `confirm` or `block` — a rule that permits what the
504
+ * engine already permits buys nothing.
505
+ * @param {unknown} value
506
+ * @param {RegistryErrorCode} errorCode
507
+ * @returns {import('./policy.js').CustomRule[]}
508
+ */
509
+ function normalizeCustomRules(value, errorCode) {
510
+ if (value === undefined) return []
511
+ if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
512
+ if (value.length > MAX_CUSTOM_RULES) {
513
+ throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
514
+ }
515
+ /** @type {import('./policy.js').CustomRule[]} */
516
+ const rules = []
517
+ /** @type {string[]} */
518
+ const seen = []
519
+ for (const item of value) {
520
+ /** @type {import('./policy.js').CustomRule} */
521
+ let rule
522
+ try {
523
+ rule = validateCustomRule(item, { takenIds: seen })
524
+ } catch (error) {
525
+ if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
526
+ throw error
527
+ }
528
+ seen.push(rule.id)
529
+ rules.push(rule)
530
+ }
531
+ return rules
532
+ }
533
+
390
534
  /**
391
535
  * Validate and canonicalise one complete record. Create, update and load all
392
536
  * go through this function, so a stored record and a freshly created one have
@@ -398,6 +542,7 @@ function normalizeDisabledRules(value, errorCode) {
398
542
  * errorCode: RegistryErrorCode,
399
543
  * label: string,
400
544
  * createdAt?: string,
545
+ * migrate?: boolean,
401
546
  * }} options
402
547
  * @returns {ServerRecord}
403
548
  */
@@ -415,11 +560,13 @@ function normalizeRecord(source, options) {
415
560
  host: normalizeHost(source.host, errorCode),
416
561
  port: normalizePort(source.port, errorCode),
417
562
  username: requiredString(source.username, 'username', errorCode),
418
- auth: normalizeAuth(source.auth, id, errorCode),
563
+ auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
419
564
  hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
420
565
  defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
566
+ enabled: normalizeEnabled(source.enabled, errorCode),
421
567
  policy: normalizePolicy(source.policy, errorCode),
422
568
  disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
569
+ customRules: normalizeCustomRules(source.customRules, errorCode),
423
570
  createdAt,
424
571
  lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
425
572
  }
@@ -727,6 +874,9 @@ export class Registry {
727
874
  id: recordId,
728
875
  errorCode: 'INVALID_REGISTRY',
729
876
  label: `servers[${index}]`,
877
+ // Loading is the one path allowed to repair a legacy credential ref;
878
+ // writes are strict so this plugin can never persist a bad one again.
879
+ migrate: true,
730
880
  })
731
881
  if (seenIds.has(record.id)) {
732
882
  throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
@@ -739,7 +889,32 @@ export class Registry {
739
889
  seenNames.add(nameKey)
740
890
  servers.push(record)
741
891
  }
742
- return { version: REGISTRY_VERSION, servers }
892
+ const document = { version: REGISTRY_VERSION, servers }
893
+ // Persist a repaired legacy ref immediately. Without this the migration
894
+ // lives only in memory: every later read re-runs it, and a copy of the file
895
+ // taken before the first write would still carry the illegal ref that breaks
896
+ // the next startup. The rewrite is conditional so a clean file is never
897
+ // touched (its mtime stays put).
898
+ if (this.#migrated(text, document)) await this.#save(document)
899
+ return document
900
+ }
901
+
902
+ /**
903
+ * Whether the loaded document differs from what is on disk, i.e. whether the
904
+ * legacy-ref migration actually changed a record and the file must be rewritten.
905
+ * @param {string} text - the raw file text that was parsed.
906
+ * @param {RegistryDocument} document - the normalised document.
907
+ * @returns {boolean}
908
+ */
909
+ #migrated(text, document) {
910
+ for (const record of document.servers) {
911
+ if (record.auth.kind !== 'password') continue
912
+ const ref = record.auth.credentialRef
913
+ // The normalised ref is legal by construction; if the raw text does not
914
+ // contain it, this record was migrated.
915
+ if (!text.includes(JSON.stringify(ref))) return true
916
+ }
917
+ return false
743
918
  }
744
919
 
745
920
  /**