@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.
- package/CHANGELOG.md +23 -0
- package/README.md +48 -0
- package/dist/args.d.ts +12 -0
- package/dist/args.js +5 -1
- package/dist/args.js.map +1 -1
- package/dist/commands/config.d.ts +24 -0
- package/dist/commands/config.js +172 -7
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/project-commands.js +9 -4
- package/dist/commands/project-commands.js.map +1 -1
- package/dist/commands/referrals.d.ts +12 -2
- package/dist/commands/referrals.js +329 -97
- package/dist/commands/referrals.js.map +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.js +66 -12
- package/dist/help.js.map +1 -1
- package/dist/run.js +2 -0
- package/dist/run.js.map +1 -1
- package/dist/serialize.d.ts +3 -4
- package/dist/serialize.js +12 -129
- package/dist/serialize.js.map +1 -1
- package/package.json +6 -6
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err instanceof errors_1.CliError)
|
|
208
|
+
return null;
|
|
209
|
+
throw err;
|
|
196
210
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
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
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
371
|
-
signing_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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
526
|
+
const sync = await referalliSyncEndpoint(ctx, {
|
|
401
527
|
baseUrl,
|
|
402
528
|
key,
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 (
|
|
409
|
-
|
|
533
|
+
if (!sync.secret) {
|
|
534
|
+
syncEndpoint = sync.id;
|
|
535
|
+
syncScope = [program];
|
|
410
536
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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:
|
|
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(
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
679
|
+
const readMap = (flag, file) => {
|
|
680
|
+
if (!file)
|
|
681
|
+
return undefined;
|
|
533
682
|
let text;
|
|
534
683
|
try {
|
|
535
|
-
text = ctx.readFile(
|
|
684
|
+
text = ctx.readFile(file === '-' ? '/dev/stdin' : file);
|
|
536
685
|
}
|
|
537
686
|
catch {
|
|
538
|
-
throw new errors_1.CliError(`Could not read ${
|
|
687
|
+
throw new errors_1.CliError(`Could not read ${file}.`);
|
|
539
688
|
}
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|