@atlasauth/cli 0.16.0 → 0.18.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.
@@ -5,6 +5,9 @@ exports.pingSkewMs = pingSkewMs;
5
5
  exports.describeTestLines = describeTestLines;
6
6
  exports.parseCodeCsv = parseCodeCsv;
7
7
  exports.referalliKeyMode = referalliKeyMode;
8
+ exports.referalliList = referalliList;
9
+ exports.referalliLinkTemplate = referalliLinkTemplate;
10
+ exports.webhookScope = webhookScope;
8
11
  exports.referrals = referrals;
9
12
  const node_crypto_1 = require("node:crypto");
10
13
  const referral_conformance_1 = require("@atlasauth/referral-conformance");
@@ -17,7 +20,7 @@ const secrets_1 = require("../secrets");
17
20
  const project_commands_1 = require("./project-commands");
18
21
  const shared_1 = require("./shared");
19
22
  /**
20
- * `atlas referrals connect | backfill | status`: referral attribution from the
23
+ * `atlas referrals connect | backfill | resync | status`: referral attribution from the
21
24
  * terminal.
22
25
  *
23
26
  * `connect --provider referalli` is bring-your-own-key: the Referalli merchant
@@ -108,7 +111,7 @@ function describeTestLines(test, receivedAt) {
108
111
  return lines;
109
112
  }
110
113
  /** A `customerRef,code` CSV → map; throws on a bad line. A header row is skipped. */
111
- function parseCodeCsv(text) {
114
+ function parseCodeCsv(text, flag = '--map') {
112
115
  const out = {};
113
116
  const lines = text.split(/\r?\n/);
114
117
  lines.forEach((line, i) => {
@@ -119,9 +122,9 @@ function parseCodeCsv(text) {
119
122
  if (i === 0 && /^(customer_?ref|user_?id|external_?id|user)$/i.test(ref ?? ''))
120
123
  return;
121
124
  if (!ref || !code)
122
- throw new errors_1.CliError(`--map line ${i + 1}: expected customerRef,code.`);
125
+ throw new errors_1.CliError(`${flag} line ${i + 1}: expected customerRef,code.`);
123
126
  if (!/^[A-Za-z0-9_-]{3,64}$/.test(code))
124
- throw new errors_1.CliError(`--map line ${i + 1}: '${code}' is not a valid code.`);
127
+ throw new errors_1.CliError(`${flag} line ${i + 1}: '${code}' is not a valid code.`);
125
128
  out[ref] = code;
126
129
  });
127
130
  return out;
@@ -186,19 +189,111 @@ exports.REFERALLI_SYNC_TOPICS = [
186
189
  * `known` is false when Referalli reports no mode (no test mode there).
187
190
  */
188
191
  async function referalliKeyMode(ctx, input) {
192
+ const org = await referalliTry(ctx, { ...input, path: '/v1/org', what: 'read the organization' });
193
+ if (typeof org?.livemode === 'boolean')
194
+ return { live: org.livemode, known: true, org };
195
+ if (input.key.startsWith('rk_test_'))
196
+ return { live: false, known: true, org };
197
+ if (input.key.startsWith('rk_live_'))
198
+ return { live: true, known: true, org };
199
+ return { live: true, known: false, org };
200
+ }
201
+ /** A best-effort Referalli read: null when it fails (an older Referalli, a key without access). */
202
+ async function referalliTry(ctx, input) {
189
203
  try {
190
- const org = await referalliRequest(ctx, { ...input, method: 'GET', path: '/v1/org', what: 'read the organization' });
191
- if (typeof org.livemode === 'boolean')
192
- return { live: org.livemode, known: true };
204
+ return await referalliRequest(ctx, { ...input, method: 'GET' });
193
205
  }
194
- catch {
195
- /* fall back to the key prefix */
206
+ catch (err) {
207
+ if (err instanceof errors_1.CliError)
208
+ return null;
209
+ throw err;
196
210
  }
197
- if (input.key.startsWith('rk_test_'))
198
- return { live: false, known: true };
199
- if (input.key.startsWith('rk_live_'))
200
- return { live: true, known: true };
201
- return { live: true, known: false };
211
+ }
212
+ /** A Referalli list answer: a plain array (API version 2026-10-21) or a `{ data: [...] }` page. */
213
+ function referalliList(body) {
214
+ const rows = Array.isArray(body) ? body : body && typeof body === 'object' && Array.isArray(body.data) ? body.data : [];
215
+ return rows.filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
216
+ }
217
+ /** Referalli's public tracked link for a code: `{host}/go/{trackingToken}/{code}`, on the org's link domain once it is active. */
218
+ function referalliLinkTemplate(baseUrl, trackingToken, branding) {
219
+ const domain = branding?.linkDomainStatus === 'active' && typeof branding.linkDomain === 'string' && branding.linkDomain ? branding.linkDomain : null;
220
+ return `${domain ? `https://${domain}` : baseUrl}/go/${encodeURIComponent(trackingToken)}/{code}`;
221
+ }
222
+ /** The merchant key from --referalli-key / --referalli-key-file / REFERALLI_API_KEY (or their old names); never printed. */
223
+ function referalliKeyFrom(ctx, v) {
224
+ const oldKeyFile = (0, args_1.str)(v, LEGACY.keyFileFlag);
225
+ const oldKey = (0, args_1.str)(v, LEGACY.keyFlag);
226
+ if (oldKeyFile)
227
+ legacyNotice(ctx, `--${LEGACY.keyFileFlag}`, '--referalli-key-file');
228
+ if (oldKey)
229
+ legacyNotice(ctx, `--${LEGACY.keyFlag}`, '--referalli-key');
230
+ const legacyKey = ctx.env[LEGACY.apiKeyEnv]?.trim();
231
+ if (!ctx.env.REFERALLI_API_KEY?.trim() && legacyKey)
232
+ legacyNotice(ctx, LEGACY.apiKeyEnv, 'REFERALLI_API_KEY');
233
+ const keyFile = (0, args_1.str)(v, 'referalli-key-file') ?? oldKeyFile;
234
+ return ((0, args_1.str)(v, 'referalli-key') ??
235
+ oldKey ??
236
+ (keyFile ? readKeyFile(ctx, keyFile, 'the Referalli key') : (ctx.env.REFERALLI_API_KEY?.trim() || legacyKey || undefined)));
237
+ }
238
+ /** The `programIds` a Referalli webhook endpoint is limited to: null or [] means every program in the organization. */
239
+ function webhookScope(endpoint) {
240
+ if (!endpoint)
241
+ return { scope: 'unknown', program_ids: null };
242
+ const ids = Array.isArray(endpoint.programIds) ? endpoint.programIds.filter((x) => typeof x === 'string') : [];
243
+ return ids.length ? { scope: 'program', program_ids: ids } : { scope: 'all_programs', program_ids: null };
244
+ }
245
+ /**
246
+ * The two-way sync webhook on Referalli, limited to the connected program
247
+ * (`programIds: [program]`) so another product's referrals in a shared
248
+ * organization never reach this instance. Idempotent: an endpoint already
249
+ * pointing at this instance's inbound URL is reused and re-scoped in place
250
+ * (an unscoped one from an earlier run is repaired); its secret is rotated
251
+ * only when Atlas does not hold it (`held`: the endpoint whose secret Atlas
252
+ * stores). `secret` is null when nothing new needs saving.
253
+ */
254
+ async function referalliSyncEndpoint(ctx, input) {
255
+ const { baseUrl, key } = input;
256
+ const scope = { topics: [...exports.REFERALLI_SYNC_TOPICS], legacySignature: false, programIds: [input.program] };
257
+ const listed = await referalliTry(ctx, { baseUrl, key, path: '/v1/webhook-endpoints', what: 'list the webhook endpoints' });
258
+ const mine = referalliList(listed).filter((e) => e.url === input.url && typeof e.id === 'string');
259
+ const found = mine.find((e) => e.id === input.held) ?? mine[0];
260
+ if (!found) {
261
+ const endpoint = await referalliRequest(ctx, {
262
+ baseUrl,
263
+ key,
264
+ method: 'POST',
265
+ path: '/v1/webhook-endpoints',
266
+ body: { url: input.url, ...scope },
267
+ what: 'register the referral event webhook',
268
+ });
269
+ if (typeof endpoint.id !== 'string' || typeof endpoint.secret !== 'string') {
270
+ throw new errors_1.CliError('Referalli answered without a webhook endpoint id and secret.');
271
+ }
272
+ return { id: endpoint.id, secret: endpoint.secret, duplicates: [] };
273
+ }
274
+ const id = found.id;
275
+ const duplicates = mine.filter((e) => e !== found).map((e) => e.id);
276
+ await referalliRequest(ctx, {
277
+ baseUrl,
278
+ key,
279
+ method: 'PATCH',
280
+ path: `/v1/webhook-endpoints/${encodeURIComponent(id)}`,
281
+ body: scope,
282
+ what: 'limit the referral event webhook to the program',
283
+ });
284
+ if (id === input.held)
285
+ return { id, secret: null, duplicates };
286
+ const rotated = await referalliRequest(ctx, {
287
+ baseUrl,
288
+ key,
289
+ method: 'POST',
290
+ path: `/v1/webhook-endpoints/${encodeURIComponent(id)}/rotate-secret`,
291
+ body: {},
292
+ what: 'rotate the referral event webhook secret',
293
+ });
294
+ if (typeof rotated.secret !== 'string')
295
+ throw new errors_1.CliError('Referalli answered without a webhook secret.');
296
+ return { id, secret: rotated.secret, duplicates };
202
297
  }
203
298
  /**
204
299
  * `connect --provider referalli --oauth`: Atlas starts an OAuth install at
@@ -301,19 +396,7 @@ async function connect(ctx, args) {
301
396
  if (!baseRaw)
302
397
  throw new errors_1.CliError('Pass --base-url <your Referalli base URL> (or set REFERALLI_BASE_URL).');
303
398
  const baseUrl = httpsUrl(baseRaw, '--base-url');
304
- const oldKeyFile = (0, args_1.str)(v, LEGACY.keyFileFlag);
305
- const oldKey = (0, args_1.str)(v, LEGACY.keyFlag);
306
- if (oldKeyFile)
307
- legacyNotice(ctx, `--${LEGACY.keyFileFlag}`, '--referalli-key-file');
308
- if (oldKey)
309
- legacyNotice(ctx, `--${LEGACY.keyFlag}`, '--referalli-key');
310
- const legacyKey = ctx.env[LEGACY.apiKeyEnv]?.trim();
311
- if (!ctx.env.REFERALLI_API_KEY?.trim() && legacyKey)
312
- legacyNotice(ctx, LEGACY.apiKeyEnv, 'REFERALLI_API_KEY');
313
- const keyFile = (0, args_1.str)(v, 'referalli-key-file') ?? oldKeyFile;
314
- const key = (0, args_1.str)(v, 'referalli-key') ??
315
- oldKey ??
316
- (keyFile ? readKeyFile(ctx, keyFile, 'the Referalli key') : (ctx.env.REFERALLI_API_KEY?.trim() ?? legacyKey));
399
+ const key = referalliKeyFrom(ctx, v);
317
400
  if (!key) {
318
401
  throw new errors_1.CliError('Pass your Referalli merchant key with --referalli-key-file <path|-> (or --referalli-key, or REFERALLI_API_KEY). It is used once and never stored.');
319
402
  }
@@ -336,17 +419,49 @@ async function connect(ctx, args) {
336
419
  else if (!mode.known && !instanceLive) {
337
420
  ctx.io.err(`atlas: warning: Referalli did not say whether this key reaches test data; instance ${instance.id} is ${instance.environment ?? 'not production'}.`);
338
421
  }
339
- const created = await referalliCreateConnection(ctx, {
340
- baseUrl,
341
- key,
342
- body: {
343
- provider: 'atlas',
344
- instanceId: instance.id,
345
- programId: program,
346
- ...(v['auto-enroll'] === true ? { autoEnrollUsers: true } : {}),
347
- ...(destination ? { destinationUrl: destination } : {}),
348
- },
349
- });
422
+ const enrollment = {
423
+ ...(v['auto-enroll'] === true ? { autoEnrollUsers: true } : {}),
424
+ ...(destination ? { destinationUrl: destination } : {}),
425
+ };
426
+ const current = await http.get('/v1/instance/referrals');
427
+ // Re-runs reuse the instance's connection, updated in place: Referalli keeps one per instance per organization.
428
+ const listed = await referalliTry(ctx, { baseUrl, key, path: '/v1/auth/connections', what: 'list the connections' });
429
+ const existing = referalliList(listed).find((c) => c.provider === 'atlas' && c.instanceId === instance.id && typeof c.id === 'string');
430
+ if (existing && typeof existing.programId === 'string' && existing.programId !== program) {
431
+ throw new errors_1.CliError(`Referalli already has connection ${String(existing.id)} for instance ${instance.id}, in program ${existing.programId}. ` +
432
+ `It allows one per instance: run again with --program ${existing.programId}, or delete that connection on Referalli first.`);
433
+ }
434
+ let connectionId;
435
+ // Null when Atlas already holds the reused connection's secret.
436
+ let secret = null;
437
+ let createdToken = null;
438
+ if (existing) {
439
+ connectionId = existing.id;
440
+ if (current.connection_id !== connectionId || !current.signing_secret_set) {
441
+ // Atlas does not hold this connection's secret: rotate it (the old one keeps verifying for 24 hours).
442
+ const rotated = await referalliRequest(ctx, {
443
+ baseUrl,
444
+ key,
445
+ method: 'POST',
446
+ path: `/v1/auth/connections/${encodeURIComponent(connectionId)}/rotate-secret`,
447
+ body: {},
448
+ what: 'rotate the connection secret',
449
+ });
450
+ if (typeof rotated.secret !== 'string')
451
+ throw new errors_1.CliError('Referalli answered without a connection secret.');
452
+ secret = rotated.secret;
453
+ }
454
+ }
455
+ else {
456
+ const created = await referalliCreateConnection(ctx, {
457
+ baseUrl,
458
+ key,
459
+ body: { provider: 'atlas', instanceId: instance.id, programId: program, ...enrollment },
460
+ });
461
+ connectionId = created.id;
462
+ secret = created.secret;
463
+ createdToken = typeof created.trackingToken === 'string' ? created.trackingToken : null;
464
+ }
350
465
  // Atlas always signs with v2: require it from the first call, so v1 is never accepted on this connection.
351
466
  let signatureV2 = true;
352
467
  try {
@@ -354,24 +469,32 @@ async function connect(ctx, args) {
354
469
  baseUrl,
355
470
  key,
356
471
  method: 'PATCH',
357
- path: `/v1/auth/connections/${encodeURIComponent(created.id)}`,
358
- body: { signatureVersion: 'v2' },
359
- what: 'require v2 signatures on the connection',
472
+ path: `/v1/auth/connections/${encodeURIComponent(connectionId)}`,
473
+ body: { signatureVersion: 'v2', ...(existing ? enrollment : {}) },
474
+ what: existing ? 'update the connection' : 'require v2 signatures on the connection',
360
475
  });
361
476
  }
362
477
  catch (err) {
363
478
  signatureV2 = false;
364
479
  ctx.io.err(`atlas: warning: ${err instanceof Error ? err.message : String(err)} It switches to v2 on Atlas's first call anyway.`);
365
480
  }
366
- const trackingToken = (0, args_1.str)(v, 'tracking-token') ?? (typeof created.trackingToken === 'string' ? created.trackingToken : null);
481
+ // The org's public tracking token (GET /v1/org) builds Referalli's tracked links; a link template set before is kept.
482
+ const orgToken = typeof mode.org?.trackingToken === 'string' ? mode.org.trackingToken : null;
483
+ const trackingToken = (0, args_1.str)(v, 'tracking-token') ?? orgToken ?? createdToken;
484
+ let linkTemplate = (0, args_1.str)(v, 'link-template') ?? null;
485
+ if (!linkTemplate && !current.link_template && trackingToken) {
486
+ const branding = await referalliTry(ctx, { baseUrl, key, path: '/v1/branding', what: 'read the link domain' });
487
+ linkTemplate = referalliLinkTemplate(baseUrl, trackingToken, branding);
488
+ }
367
489
  patch = {
368
490
  provider: 'referalli',
369
491
  endpoint_url: baseUrl,
370
- connection_id: created.id,
371
- signing_secret: created.secret,
492
+ connection_id: connectionId,
493
+ ...(secret ? { signing_secret: secret } : {}),
372
494
  program_id: program,
373
495
  ...(v['auto-enroll'] === true ? { auto_enroll_users: true } : {}),
374
496
  ...(trackingToken ? { tracking_token: trackingToken } : {}),
497
+ ...(linkTemplate ? { link_template: linkTemplate } : {}),
375
498
  };
376
499
  let saved;
377
500
  try {
@@ -379,51 +502,60 @@ async function connect(ctx, args) {
379
502
  }
380
503
  catch (err) {
381
504
  // The secret is shown once by Referalli: keep it so the merchant can finish by hand.
382
- const saved = (0, secrets_1.deliverSecret)({
383
- io: out,
384
- env: ctx.env,
385
- product,
386
- fileName: `referalli-connection-${created.id}.env`,
387
- entries: { REFERALLI_CONNECTION_ID: created.id, REFERALLI_CONNECTION_SECRET: created.secret },
388
- label: 'The Referalli connection secret (not saved on Atlas)',
389
- force: v.force === true,
390
- comment: `Referalli connection ${created.id}: saving it on the Atlas instance failed. Paste it in Users → Referrals.`,
391
- });
392
- ctx.io.err(`atlas: the connection ${created.id} was created on Referalli, but saving it on the instance failed.${saved.path ? ` Its secret is in ${saved.path}.` : ''}`);
505
+ const kept = secret
506
+ ? (0, secrets_1.deliverSecret)({
507
+ io: out,
508
+ env: ctx.env,
509
+ product,
510
+ fileName: `referalli-connection-${connectionId}.env`,
511
+ entries: { REFERALLI_CONNECTION_ID: connectionId, REFERALLI_CONNECTION_SECRET: secret },
512
+ label: 'The Referalli connection secret (not saved on Atlas)',
513
+ force: v.force === true,
514
+ comment: `Referalli connection ${connectionId}: saving it on the Atlas instance failed. Paste it in Users → Referrals.`,
515
+ })
516
+ : { path: undefined };
517
+ ctx.io.err(`atlas: the connection ${connectionId} ${existing ? 'exists' : 'was created'} on Referalli, but saving it on the instance failed.${kept.path ? ` Its secret is in ${kept.path}.` : ''}`);
393
518
  throw err;
394
519
  }
395
- // Two-way sync: Referalli's referral events go to the instance's inbound URL.
520
+ // Two-way sync: Referalli's referral events for this program go to the instance's inbound URL.
396
521
  let syncEndpoint = null;
522
+ let syncScope = null;
397
523
  const inboundUrl = saved?.inbound?.url ?? null;
398
524
  if (v['no-sync'] !== true && inboundUrl) {
399
525
  try {
400
- const endpoint = await referalliRequest(ctx, {
526
+ const sync = await referalliSyncEndpoint(ctx, {
401
527
  baseUrl,
402
528
  key,
403
- method: 'POST',
404
- path: '/v1/webhook-endpoints',
405
- body: { url: inboundUrl, topics: [...exports.REFERALLI_SYNC_TOPICS], legacySignature: false },
406
- what: 'register the referral event webhook',
529
+ url: inboundUrl,
530
+ program,
531
+ held: current.inbound?.secret_set ? (current.inbound.endpoint_id ?? null) : null,
407
532
  });
408
- if (typeof endpoint.id !== 'string' || typeof endpoint.secret !== 'string') {
409
- throw new errors_1.CliError('Referalli answered without a webhook endpoint id and secret.');
533
+ if (!sync.secret) {
534
+ syncEndpoint = sync.id;
535
+ syncScope = [program];
410
536
  }
411
- try {
412
- await http.patch('/v1/instance/referrals', { inbound_secret: endpoint.secret, inbound_endpoint_id: endpoint.id });
413
- syncEndpoint = endpoint.id;
537
+ else {
538
+ try {
539
+ await http.patch('/v1/instance/referrals', { inbound_secret: sync.secret, inbound_endpoint_id: sync.id });
540
+ syncEndpoint = sync.id;
541
+ syncScope = [program];
542
+ }
543
+ catch (err) {
544
+ const kept = (0, secrets_1.deliverSecret)({
545
+ io: out,
546
+ env: ctx.env,
547
+ product,
548
+ fileName: `referalli-webhook-${sync.id}.env`,
549
+ entries: { REFERALLI_WEBHOOK_ENDPOINT_ID: sync.id, REFERALLI_WEBHOOK_SECRET: sync.secret },
550
+ label: 'The Referalli webhook secret (not saved on Atlas)',
551
+ force: v.force === true,
552
+ comment: `Referalli webhook ${sync.id}: saving it on the Atlas instance failed. Paste it as the inbound secret in Users → Referrals.`,
553
+ });
554
+ ctx.io.err(`atlas: warning: the webhook ${sync.id} was registered on Referalli, but saving its secret failed (${err instanceof Error ? err.message : String(err)}).${kept.path ? ` It is in ${kept.path}.` : ''}`);
555
+ }
414
556
  }
415
- catch (err) {
416
- const kept = (0, secrets_1.deliverSecret)({
417
- io: out,
418
- env: ctx.env,
419
- product,
420
- fileName: `referalli-webhook-${endpoint.id}.env`,
421
- entries: { REFERALLI_WEBHOOK_ENDPOINT_ID: endpoint.id, REFERALLI_WEBHOOK_SECRET: endpoint.secret },
422
- label: 'The Referalli webhook secret (not saved on Atlas)',
423
- force: v.force === true,
424
- comment: `Referalli webhook ${endpoint.id}: saving it on the Atlas instance failed. Paste it as the inbound secret in Users → Referrals.`,
425
- });
426
- ctx.io.err(`atlas: warning: the webhook ${endpoint.id} was registered on Referalli, but saving its secret failed (${err instanceof Error ? err.message : String(err)}).${kept.path ? ` It is in ${kept.path}.` : ''}`);
557
+ if (sync.duplicates.length) {
558
+ ctx.io.err(`atlas: warning: Referalli has more webhook endpoints for this instance (${sync.duplicates.join(', ')}). Atlas cannot verify their deliveries; delete them on Referalli.`);
427
559
  }
428
560
  }
429
561
  catch (err) {
@@ -432,21 +564,38 @@ async function connect(ctx, args) {
432
564
  ctx.io.err(`atlas: warning: ${err.message} Referral changes on Referalli won't reach Atlas until it is registered (see the docs).`);
433
565
  }
434
566
  }
567
+ // What connect could not set: the exact settings call that finishes it.
568
+ const leftToDo = {};
569
+ if (!(trackingToken ?? current.tracking_token))
570
+ leftToDo.tracking_token = '<your Referalli tracking token (tk_…), from GET /v1/org on Referalli>';
571
+ if (!(linkTemplate ?? current.link_template))
572
+ leftToDo.link_template = 'https://<your Referalli link host>/go/<tracking token>/{code}';
435
573
  summary = {
436
574
  provider: 'referalli',
437
575
  instance_id: instance.id,
438
576
  endpoint_url: baseUrl,
439
- connection_id: created.id,
577
+ connection_id: connectionId,
578
+ reused: Boolean(existing),
440
579
  program_id: program,
441
- tracking_token: trackingToken,
580
+ tracking_token: trackingToken ?? current.tracking_token ?? null,
581
+ link_template: linkTemplate ?? current.link_template ?? null,
442
582
  livemode: mode.known ? mode.live : null,
443
583
  signature_v2_required: signatureV2,
444
584
  sync_endpoint_id: syncEndpoint,
585
+ sync_program_ids: syncScope,
586
+ left_to_do: Object.keys(leftToDo).length ? { method: 'PATCH', path: '/v1/instance/referrals', body: leftToDo } : null,
445
587
  };
446
588
  if (!json) {
447
- out.out(`Created Referalli connection ${created.id} for instance ${instance.id} and saved it (the secret was not printed).`);
589
+ out.out(existing
590
+ ? `Reused Referalli connection ${connectionId} for instance ${instance.id} and updated it${secret ? ' (new secret saved, not printed)' : ''}.`
591
+ : `Created Referalli connection ${connectionId} for instance ${instance.id} and saved it (the secret was not printed).`);
448
592
  if (syncEndpoint)
449
- out.out(`Referral changes on Referalli sync back to Atlas (webhook ${syncEndpoint}).`);
593
+ out.out(`Referral changes on Referalli sync back to Atlas (webhook ${syncEndpoint}, program ${program} only).`);
594
+ if (Object.keys(leftToDo).length) {
595
+ out.out(`Left to do: ${Object.keys(leftToDo).join(' and ')} could not be read from Referalli. Set them with PATCH /v1/instance/referrals:`);
596
+ out.out(` ${JSON.stringify(leftToDo)}`);
597
+ out.out(' (or run this again with --tracking-token <tk_…> and --link-template <url with {code}>)');
598
+ }
450
599
  }
451
600
  }
452
601
  else {
@@ -527,27 +676,40 @@ async function backfill(ctx, args) {
527
676
  if (!since || !/^\d{4}-\d{2}-\d{2}(T.*)?$/.test(since) || Number.isNaN(Date.parse(since))) {
528
677
  throw new errors_1.CliError('referrals backfill needs --since <YYYY-MM-DD>.');
529
678
  }
530
- const mapFile = (0, args_1.str)(v, 'map');
531
- let codes;
532
- if (mapFile) {
679
+ const readMap = (flag, file) => {
680
+ if (!file)
681
+ return undefined;
533
682
  let text;
534
683
  try {
535
- text = ctx.readFile(mapFile === '-' ? '/dev/stdin' : mapFile);
684
+ text = ctx.readFile(file === '-' ? '/dev/stdin' : file);
536
685
  }
537
686
  catch {
538
- throw new errors_1.CliError(`Could not read ${mapFile}.`);
687
+ throw new errors_1.CliError(`Could not read ${file}.`);
539
688
  }
540
- codes = parseCodeCsv(text);
541
- }
542
- const metadataPath = (0, args_1.str)(v, 'metadata-path');
543
- if (metadataPath && !/^(public|private|unsafe)_metadata(\.[A-Za-z0-9_-]{1,64}){1,4}$/.test(metadataPath)) {
544
- throw new errors_1.CliError(`--metadata-path must look like public_metadata.referral_code, got '${metadataPath}'.`);
545
- }
689
+ return parseCodeCsv(text, `--${flag}`);
690
+ };
691
+ const checkPath = (flag) => {
692
+ const p = (0, args_1.str)(v, flag);
693
+ if (p && !/^(public|private|unsafe)_metadata(\.[A-Za-z0-9_-]{1,64}){1,4}$/.test(p)) {
694
+ throw new errors_1.CliError(`--${flag} must look like public_metadata.referral_code, got '${p}'.`);
695
+ }
696
+ return p;
697
+ };
698
+ const mapFile = (0, args_1.str)(v, 'map');
699
+ const ownMapFile = (0, args_1.str)(v, 'own-map');
700
+ if (mapFile === '-' && ownMapFile === '-')
701
+ throw new errors_1.CliError('Only one of --map and --own-map can read stdin.');
702
+ const codes = readMap('map', mapFile);
703
+ const ownCodes = readMap('own-map', ownMapFile);
704
+ const metadataPath = checkPath('metadata-path');
705
+ const ownMetadataPath = checkPath('own-metadata-path');
546
706
  const http = (0, project_1.httpFor)(ctx, (0, project_commands_1.instanceCredential)(ctx, args));
547
707
  let job = await http.post('/v1/instance/referrals/backfill', {
548
708
  since,
549
709
  ...(codes && Object.keys(codes).length ? { codes } : {}),
550
710
  ...(metadataPath ? { code_metadata_path: metadataPath } : {}),
711
+ ...(ownCodes && Object.keys(ownCodes).length ? { own_codes: ownCodes } : {}),
712
+ ...(ownMetadataPath ? { own_code_metadata_path: ownMetadataPath } : {}),
551
713
  });
552
714
  const running = (s) => s === 'queued' || s === 'running' || s === 'pending';
553
715
  if (v.wait === true) {
@@ -569,6 +731,13 @@ async function backfill(ctx, args) {
569
731
  ctx.io.out(`Backfill ${job.id}: ${job.status}${counts.length ? ` (${counts.join(', ')})` : ''}.`);
570
732
  if (codes)
571
733
  ctx.io.out(`${Object.keys(codes).length} code(s) read from ${mapFile}.`);
734
+ if (ownCodes)
735
+ ctx.io.out(`${Object.keys(ownCodes).length} own code(s) read from ${ownMapFile}.`);
736
+ if (typeof job.own_codes_set === 'number' && (ownCodes || ownMetadataPath))
737
+ ctx.io.out(`${job.own_codes_set} own code(s) kept.`);
738
+ if (job.own_codes_taken?.length) {
739
+ ctx.io.err(`atlas: ${job.own_codes_taken.length} own code(s) already belong to another user, not set: ${job.own_codes_taken.join(', ')}`);
740
+ }
572
741
  if (running(job.status) && v.wait !== true)
573
742
  ctx.io.out("Add --wait to follow it, or watch the delivery log in the dashboard.");
574
743
  if (job.truncated && job.next_since) {
@@ -579,6 +748,24 @@ async function backfill(ctx, args) {
579
748
  }
580
749
  return job.status === 'failed' || (job.calls?.failed ?? 0) > 0 ? 1 : 0;
581
750
  }
751
+ async function resync(ctx, args) {
752
+ const userId = args.positionals[2];
753
+ if (!userId)
754
+ throw new errors_1.CliError('referrals resync needs a user id: atlas referrals resync <user_id> [--dry-run].');
755
+ const http = (0, project_1.httpFor)(ctx, (0, project_commands_1.instanceCredential)(ctx, args));
756
+ const res = await http.post(`/v1/users/${encodeURIComponent(userId)}/referral/resync${args.values['dry-run'] === true ? '?dry_run=true' : ''}`, {});
757
+ if ((0, shared_1.wantsJson)(args.values))
758
+ (0, output_1.printJson)(ctx.io, res);
759
+ else {
760
+ ctx.io.out(`Re-reported ${res.user_id}: call ${res.call_id ?? '-'} ${res.call_status ?? 'not sent'}${args.values['dry-run'] === true ? ' (dry run, nothing sent)' : ''}.`);
761
+ if (res.error)
762
+ ctx.io.err(`atlas: ${res.error}`);
763
+ const code = res.referral?.referral_code;
764
+ if (code)
765
+ ctx.io.out(`Referral code ${code}${res.referral?.referral_link ? `, link ${res.referral.referral_link}` : ''}.`);
766
+ }
767
+ return res.call_status === 'failed' ? 1 : 0;
768
+ }
582
769
  const FINDING_COLUMNS = [
583
770
  { header: 'SEVERITY', get: (f) => f.severity },
584
771
  { header: 'CODE', get: (f) => f.code },
@@ -596,8 +783,47 @@ async function status(ctx, args) {
596
783
  if (!(err instanceof backend_1.AtlasApiError))
597
784
  throw err;
598
785
  }
786
+ // The sync webhook's program scope lives on Referalli: read it when a merchant key is given (never required).
787
+ const inbound = settings.inbound;
788
+ let webhook = null;
789
+ const key = settings.provider === 'referalli' && settings.endpoint_url && inbound?.endpoint_id ? referalliKeyFrom(ctx, args.values) : undefined;
790
+ if (key && settings.endpoint_url && inbound?.endpoint_id) {
791
+ webhook = await referalliTry(ctx, {
792
+ baseUrl: settings.endpoint_url,
793
+ key,
794
+ path: `/v1/webhook-endpoints/${encodeURIComponent(inbound.endpoint_id)}`,
795
+ what: 'read the referral event webhook',
796
+ });
797
+ }
798
+ const scope = webhookScope(webhook);
599
799
  if ((0, shared_1.wantsJson)(args.values)) {
600
- (0, output_1.printJson)(ctx.io, { settings, findings });
800
+ const c = settings.connection ?? null;
801
+ (0, output_1.printJson)(ctx.io, {
802
+ // Flat, stable fields for scripts; `settings` is the raw GET /v1/instance/referrals answer.
803
+ status: {
804
+ enabled: settings.enabled,
805
+ provider: settings.provider,
806
+ connection_id: settings.connection_id,
807
+ program_id: settings.program_id,
808
+ signing_secret_set: settings.signing_secret_set,
809
+ webhook: {
810
+ endpoint_id: inbound?.endpoint_id ?? null,
811
+ url: inbound?.url ?? null,
812
+ secret_set: inbound?.secret_set ?? false,
813
+ // program: limited to program_ids; all_programs: every program in the organization; unknown: no merchant key given.
814
+ scope: inbound?.endpoint_id ? scope.scope : null,
815
+ program_ids: scope.program_ids,
816
+ },
817
+ link_template_set: Boolean(settings.link_template),
818
+ tracking_token_set: Boolean(settings.tracking_token),
819
+ last_sync: inbound?.last_event ?? null,
820
+ last_test: c?.last_test ?? null,
821
+ last_success_at: c?.last_success_at ?? null,
822
+ missing: settings.missing ?? [],
823
+ },
824
+ settings,
825
+ findings,
826
+ });
601
827
  return findings.some((f) => f.severity === 'error') ? 1 : 0;
602
828
  }
603
829
  const c = settings.connection ?? null;
@@ -608,6 +834,8 @@ async function status(ctx, args) {
608
834
  `Connection ID: ${settings.connection_id ?? '(not set)'}`,
609
835
  `Program ID: ${settings.program_id ?? '(not set)'}`,
610
836
  `Signing secret: ${settings.signing_secret_set ? 'set' : 'not set'}`,
837
+ `Link template: ${settings.link_template ?? '(not set)'}`,
838
+ `Tracking token: ${settings.tracking_token ?? '(not set)'}`,
611
839
  ];
612
840
  if (settings.forward_lifecycle !== undefined)
613
841
  lines.push(`Forward lifecycle: ${settings.forward_lifecycle ? 'on' : 'off'}`);
@@ -618,11 +846,13 @@ async function status(ctx, args) {
618
846
  }
619
847
  if (c)
620
848
  lines.push(`Failed (7 days): ${c.failed_calls_7d ?? 0} Retrying: ${c.retrying_calls ?? 0}`);
621
- const inbound = settings.inbound;
622
849
  if (inbound?.url) {
623
850
  // Referalli needs its own webhook secret; the Atlas protocol falls back to the signing secret.
624
851
  const syncing = inbound.secret_set || (settings.provider === 'webhook' && settings.signing_secret_set);
625
852
  lines.push(`Two-way sync: ${syncing ? 'on' : 'off (no inbound secret)'}${inbound.endpoint_id ? ` (webhook ${inbound.endpoint_id})` : ''}`);
853
+ if (scope.scope !== 'unknown') {
854
+ lines.push(`Webhook scope: ${scope.program_ids ? `program ${scope.program_ids.join(', ')}` : 'every program (re-run connect to limit it)'}`);
855
+ }
626
856
  lines.push(`Inbound URL: ${inbound.url}`);
627
857
  if (inbound.last_event) {
628
858
  lines.push(`Last event: ${inbound.last_event.type} → ${inbound.last_event.outcome} at ${new Date(inbound.last_event.at).toISOString()}`);
@@ -713,14 +943,16 @@ async function referrals(ctx, args) {
713
943
  return connect(ctx, args);
714
944
  case 'backfill':
715
945
  return backfill(ctx, args);
946
+ case 'resync':
947
+ return resync(ctx, args);
716
948
  case 'status':
717
949
  return status(ctx, args);
718
950
  case 'conformance':
719
951
  return conformance(ctx, args);
720
952
  default:
721
953
  throw new errors_1.CliError(sub
722
- ? `Unknown subcommand 'referrals ${sub}'. Try: connect, backfill, status, conformance.`
723
- : 'referrals needs a subcommand: connect, backfill, status, conformance.');
954
+ ? `Unknown subcommand 'referrals ${sub}'. Try: connect, backfill, resync, status, conformance.`
955
+ : 'referrals needs a subcommand: connect, backfill, resync, status, conformance.');
724
956
  }
725
957
  }
726
958
  //# sourceMappingURL=referrals.js.map