@faable/faable 4.9.0 → 4.10.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,7 @@ import { connections } from './connections/index.js';
5
5
  import { logs } from './logs/index.js';
6
6
  import { sessions } from './sessions/index.js';
7
7
  import { users } from './users/index.js';
8
+ import { webhooks } from './webhooks/index.js';
8
9
 
9
10
  // `faable auth` — management commands for a Faable Auth tenant. Auth: reuses
10
11
  // the `faable login` session (or FAABLE_TOKEN); tenant: --account /
@@ -12,7 +13,7 @@ import { users } from './users/index.js';
12
13
  // resolveTenant in src/api/auth_admin.ts.
13
14
  const auth = {
14
15
  command: 'auth',
15
- describe: 'Manage Faable Auth (users, sessions, logins, actions, clients, audit logs)',
16
+ describe: 'Manage Faable Auth (users, sessions, logins, actions, clients, webhooks, audit logs)',
16
17
  builder: yargs => yargs
17
18
  .command(users)
18
19
  .command(actions)
@@ -20,6 +21,7 @@ const auth = {
20
21
  .command(logs)
21
22
  .command(sessions)
22
23
  .command(connections)
24
+ .command(webhooks)
23
25
  .command(accounts)
24
26
  .command(auth_use)
25
27
  .demandCommand(1)
@@ -0,0 +1,60 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { json_option, print_json } from '../../../lib/listing.js';
5
+ import { SECRET_WARNING } from './shared.js';
6
+
7
+ const webhooks_create = {
8
+ command: 'create',
9
+ describe: 'Create a webhook (prints its signing secret once)',
10
+ builder: yargs => json_option(tenant_options(yargs))
11
+ .option('url', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Where deliveries are POSTed. Must be https.'
15
+ })
16
+ .option('event', {
17
+ alias: 'e',
18
+ type: 'string',
19
+ array: true,
20
+ demandOption: true,
21
+ description: 'Event to subscribe to, e.g. user.created — repeatable. Unknown events are refused by the server with the list of valid ones.'
22
+ })
23
+ .option('secret', {
24
+ type: 'string',
25
+ description: 'Use this signing secret instead of a generated one (whsec_…)'
26
+ })
27
+ .option('disabled', {
28
+ type: 'boolean',
29
+ default: false,
30
+ description: 'Create it inactive: nothing is delivered until enabled'
31
+ })
32
+ .example('$0 auth webhooks create --url https://app.example.com/api/webhooks/faable-auth -e user.created', 'Get a signed POST on every new user')
33
+ .example('$0 auth webhooks create --url https://… -e user.created --json | jq -r .secret', 'Capture the secret for a script')
34
+ .showHelpOnFail(false),
35
+ handler: withAuthHints(async (args) => {
36
+ const api = await requireAuthAdmin(args);
37
+ const created = (await api.notificationsubscriptionCreate({
38
+ channel: 'webhook',
39
+ events: args.event,
40
+ active: !args.disabled,
41
+ config: {
42
+ url: args.url,
43
+ ...(args.secret ? { secret: args.secret } : {})
44
+ }
45
+ }));
46
+ if (args.json)
47
+ return print_json(created);
48
+ log.info(`✅ Created webhook ${created.id}`);
49
+ log.info(` URL: ${created.config?.url}`);
50
+ log.info(` Events: ${(created.events ?? []).join(', ')}`);
51
+ log.info(` Active: ${created.active ? 'yes' : 'no'}`);
52
+ if (created.secret) {
53
+ log.info(` Signing secret: ${created.secret}`);
54
+ log.info(` ${SECRET_WARNING}`);
55
+ }
56
+ log.info(` Check it end to end with \`faable auth webhooks test ${created.id}\`.`);
57
+ })
58
+ };
59
+
60
+ export { webhooks_create };
@@ -0,0 +1,35 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { yes_no, when } from '../render.js';
5
+ import { json_option, print_json } from '../../../lib/listing.js';
6
+
7
+ const webhooks_get = {
8
+ command: 'get <webhook_id>',
9
+ describe: 'Show a webhook',
10
+ builder: yargs => json_option(tenant_options(yargs))
11
+ .positional('webhook_id', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Webhook id (notificationsubscription_…)'
15
+ })
16
+ .showHelpOnFail(false),
17
+ handler: withAuthHints(async (args) => {
18
+ const api = await requireAuthAdmin(args);
19
+ const w = (await api.notificationsubscriptionGet(args.webhook_id));
20
+ if (args.json)
21
+ return print_json(w);
22
+ log.info(`🪝 ${w.id}`);
23
+ log.info(` URL: ${w.config?.url ?? '-'}`);
24
+ log.info(` Events: ${w.events.join(', ')}`);
25
+ log.info(` Active: ${yes_no(w.active)}`);
26
+ log.info(` Status: ${w.status ?? '-'}${w.consecutive_failures ? ` (${w.consecutive_failures} failures in a row)` : ''}`);
27
+ log.info(` Last OK: ${when(w.last_success_at)}`);
28
+ log.info(` Last fail: ${when(w.last_failure_at)}`);
29
+ if (w.config?.previous_secret_expires_at) {
30
+ log.info(` Previous secret valid until ${w.config.previous_secret_expires_at} (rotation overlap)`);
31
+ }
32
+ })
33
+ };
34
+
35
+ export { webhooks_get };
@@ -0,0 +1,29 @@
1
+ import { webhooks_create } from './create.js';
2
+ import { webhooks_get } from './get.js';
3
+ import { webhooks_list } from './list.js';
4
+ import { webhooks_rm } from './rm.js';
5
+ import { webhooks_rotate_secret } from './rotate_secret.js';
6
+ import { webhooks_test } from './test.js';
7
+ import { webhooks_update } from './update.js';
8
+
9
+ // Signed HTTP notifications of the tenant's events (user.created, …) to your
10
+ // server. Deliveries carry `x-faable-signature` (HMAC-SHA256 of
11
+ // `${x-faable-timestamp}.${body}`) and `x-faable-delivery` (same id on every
12
+ // retry); failures are retried for ~15 h.
13
+ const webhooks = {
14
+ command: 'webhooks',
15
+ describe: 'Manage webhooks (signed notifications of tenant events)',
16
+ builder: yargs => yargs
17
+ .command(webhooks_list)
18
+ .command(webhooks_get)
19
+ .command(webhooks_create)
20
+ .command(webhooks_update)
21
+ .command(webhooks_test)
22
+ .command(webhooks_rotate_secret)
23
+ .command(webhooks_rm)
24
+ .demandCommand(1)
25
+ .showHelpOnFail(false),
26
+ handler: () => { }
27
+ };
28
+
29
+ export { webhooks };
@@ -0,0 +1,41 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { list_options, fetch_page, from_paginator, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
3
+ import { log } from '../../../log.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { yes_no, when } from '../render.js';
6
+ import { isWebhook } from './shared.js';
7
+
8
+ const webhooks_list = {
9
+ command: 'list',
10
+ describe: 'List webhooks',
11
+ builder: yargs => list_options(tenant_options(yargs)).showHelpOnFail(false),
12
+ handler: withAuthHints(async (args) => {
13
+ const api = await requireAuthAdmin(args);
14
+ const page = await fetch_page(from_paginator(api.notificationsubscriptionList({})), args);
15
+ // The resource also holds email subscriptions; this command is webhooks.
16
+ const data = page.data.filter(isWebhook);
17
+ if (args.json)
18
+ return print_json({ ...page, data });
19
+ if (data.length === 0) {
20
+ log.info('📭 No webhooks. Create one with `faable auth webhooks create`.');
21
+ return;
22
+ }
23
+ log.info(`🪝 ${data.length} webhook(s):`);
24
+ const rows = data.map(w => [
25
+ w.id,
26
+ w.config?.url ?? '-',
27
+ w.events.join(','),
28
+ yes_no(w.active),
29
+ w.status ?? '-',
30
+ when(w.last_success_at)
31
+ ]);
32
+ for (const line of table_lines(['ID', 'URL', 'EVENTS', 'ACTIVE', 'STATUS', 'LAST OK'], rows)) {
33
+ print(line);
34
+ }
35
+ const hint = more_hint(page);
36
+ if (hint)
37
+ log.info(hint);
38
+ })
39
+ };
40
+
41
+ export { webhooks_list };
@@ -0,0 +1,41 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { confirm } from '../../../lib/interactive.js';
3
+ import { log } from '../../../log.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { json_option, print_json } from '../../../lib/listing.js';
6
+
7
+ const webhooks_rm = {
8
+ command: 'rm <webhook_id>',
9
+ describe: 'Delete a webhook',
10
+ builder: yargs => json_option(tenant_options(yargs))
11
+ .positional('webhook_id', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Webhook id (notificationsubscription_…)'
15
+ })
16
+ .option('yes', {
17
+ alias: 'y',
18
+ type: 'boolean',
19
+ default: false,
20
+ description: 'Skip the confirmation prompt'
21
+ })
22
+ .showHelpOnFail(false),
23
+ handler: withAuthHints(async (args) => {
24
+ const api = await requireAuthAdmin(args);
25
+ const w = (await api.notificationsubscriptionGet(args.webhook_id));
26
+ const go = await confirm({
27
+ message: `Delete webhook ${w.id} → ${w.config?.url}? Its events stop being delivered.`,
28
+ yes: args.yes
29
+ });
30
+ if (!go) {
31
+ log.info('Cancelled.');
32
+ return;
33
+ }
34
+ await api.notificationsubscriptionDelete(w.id);
35
+ if (args.json)
36
+ return print_json({ deleted: true, id: w.id });
37
+ log.info(`🗑️ Deleted webhook ${w.id}.`);
38
+ })
39
+ };
40
+
41
+ export { webhooks_rm };
@@ -0,0 +1,32 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { json_option, print_json } from '../../../lib/listing.js';
5
+ import { SECRET_WARNING } from './shared.js';
6
+
7
+ const webhooks_rotate_secret = {
8
+ command: 'rotate-secret <webhook_id>',
9
+ describe: "Rotate a webhook's signing secret (the old one stays valid 24 h)",
10
+ builder: yargs => json_option(tenant_options(yargs))
11
+ .positional('webhook_id', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Webhook id (notificationsubscription_…)'
15
+ })
16
+ .option('secret', {
17
+ type: 'string',
18
+ description: 'Use this secret instead of a generated one'
19
+ })
20
+ .showHelpOnFail(false),
21
+ handler: withAuthHints(async (args) => {
22
+ const api = await requireAuthAdmin(args);
23
+ const r = (await api.notificationsubscriptionRotateSecret(args.webhook_id, (args.secret ? { secret: args.secret } : {})));
24
+ if (args.json)
25
+ return print_json(r);
26
+ log.info(`🔑 New signing secret: ${r.secret}`);
27
+ log.info(` ${SECRET_WARNING}`);
28
+ log.info(` Until ${r.previous_secret_expires_at} deliveries carry both signatures, so the receiver can switch over without dropping any.`);
29
+ })
30
+ };
31
+
32
+ export { webhooks_rotate_secret };
@@ -0,0 +1,7 @@
1
+ // Pieces shared by the `faable auth webhooks` commands.
2
+ const isWebhook = (s) => s.channel === 'webhook';
3
+ // The secret is returned exactly once (create, rotate-secret): say so where
4
+ // the person will see it, not in the docs.
5
+ const SECRET_WARNING = "⚠️ Store it now (e.g. as the receiver's environment variable): it is never shown again. `faable auth webhooks rotate-secret` issues a new one.";
6
+
7
+ export { SECRET_WARNING, isWebhook };
@@ -0,0 +1,32 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { json_option, print_json } from '../../../lib/listing.js';
5
+
6
+ const webhooks_test = {
7
+ command: 'test <webhook_id>',
8
+ describe: 'Send a signed `ping` to a webhook and show what it answered',
9
+ builder: yargs => json_option(tenant_options(yargs))
10
+ .positional('webhook_id', {
11
+ type: 'string',
12
+ demandOption: true,
13
+ description: 'Webhook id (notificationsubscription_…)'
14
+ })
15
+ .showHelpOnFail(false),
16
+ handler: withAuthHints(async (args) => {
17
+ const api = await requireAuthAdmin(args);
18
+ const r = (await api.notificationsubscriptionTest(args.webhook_id));
19
+ if (args.json)
20
+ return print_json(r);
21
+ const what = r.status ? `HTTP ${r.status}` : (r.error ?? 'no answer');
22
+ if (r.ok) {
23
+ log.info(`✅ The endpoint answered ${what} (event ${r.event_id}).`);
24
+ }
25
+ else {
26
+ log.error(`❌ The endpoint answered ${what} (event ${r.event_id}).`);
27
+ process.exitCode = 1;
28
+ }
29
+ })
30
+ };
31
+
32
+ export { webhooks_test };
@@ -0,0 +1,46 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { yes_no } from '../render.js';
5
+ import { json_option, print_json } from '../../../lib/listing.js';
6
+
7
+ const webhooks_update = {
8
+ command: 'update <webhook_id>',
9
+ describe: 'Change a webhook (URL, events, enabled)',
10
+ builder: yargs => json_option(tenant_options(yargs))
11
+ .positional('webhook_id', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Webhook id (notificationsubscription_…)'
15
+ })
16
+ .option('url', { type: 'string', description: 'New https URL' })
17
+ .option('event', {
18
+ alias: 'e',
19
+ type: 'string',
20
+ array: true,
21
+ description: 'Replace the subscribed events — repeatable'
22
+ })
23
+ .option('enabled', {
24
+ type: 'boolean',
25
+ description: 'Enable (--enabled) or pause (--no-enabled) deliveries'
26
+ })
27
+ .example('$0 auth webhooks update notificationsubscription_… --no-enabled', 'Pause deliveries')
28
+ .showHelpOnFail(false),
29
+ handler: withAuthHints(async (args) => {
30
+ const patch = {
31
+ ...(args.url !== undefined ? { config: { url: args.url } } : {}),
32
+ ...(args.event?.length ? { events: args.event } : {}),
33
+ ...(args.enabled !== undefined ? { active: args.enabled } : {})
34
+ };
35
+ if (Object.keys(patch).length === 0) {
36
+ throw new Error('Nothing to update — pass at least one of --url, --event, --enabled');
37
+ }
38
+ const api = await requireAuthAdmin(args);
39
+ const w = (await api.notificationsubscriptionUpdate(args.webhook_id, patch));
40
+ if (args.json)
41
+ return print_json(w);
42
+ log.info(`✅ Updated webhook ${w.id} (${w.config?.url}, ${(w.events ?? []).join(', ')}, active ${yes_no(w.active)})`);
43
+ })
44
+ };
45
+
46
+ export { webhooks_update };
@@ -1,7 +1,7 @@
1
1
  import ora from 'ora';
2
2
  import { version } from '../../config.js';
3
3
  import { isDevBuild, getLatestVersion, isNewerVersion, CLI_PACKAGE } from '../../lib/UpdateChecker.js';
4
- import { cmd } from '../../lib/cmd.js';
4
+ import { upgradeTo, npmDeps } from '../../lib/self_upgrade.js';
5
5
  import { log } from '../../log.js';
6
6
 
7
7
  const upgrade = {
@@ -22,15 +22,25 @@ const upgrade = {
22
22
  return;
23
23
  }
24
24
  const spinner = ora(`Upgrading ${CLI_PACKAGE} ${version} → ${latest}`).start();
25
- try {
26
- await cmd(`npm install -g --prefer-online ${CLI_PACKAGE}@latest`, { timeout: 180_000 });
25
+ const res = await upgradeTo(latest, npmDeps, {
26
+ onRetry: () => {
27
+ spinner.text = `${latest} was just published and the npm registry is still propagating it — retrying…`;
28
+ },
29
+ });
30
+ if (res.ok) {
27
31
  spinner.succeed(`Upgraded to ${latest}`);
32
+ return;
28
33
  }
29
- catch {
30
- spinner.fail("Upgrade failed");
31
- log.error(`❌ Could not upgrade automatically. Try manually: npm install -g --prefer-online ${CLI_PACKAGE}@latest`);
32
- process.exit(1);
34
+ spinner.fail("Upgrade failed");
35
+ if (res.reason === "not_propagated") {
36
+ log.error(`❌ ${latest} is not available from the npm registry yet (still on ${res.installed ?? version}). A release takes a few minutes to propagate — run \`faable upgrade\` again shortly.`);
37
+ }
38
+ else {
39
+ if (res.output)
40
+ log.error(res.output.trim());
41
+ log.error(`❌ Could not upgrade automatically. Try manually: npm install -g ${CLI_PACKAGE}@${latest}`);
33
42
  }
43
+ process.exit(1);
34
44
  },
35
45
  };
36
46
 
@@ -0,0 +1,51 @@
1
+ import { execFile } from 'child_process';
2
+ import { CLI_PACKAGE } from './UpdateChecker.js';
3
+
4
+ // npm's ways of saying "that version does not exist (here, yet)".
5
+ const NOT_FOUND = /ETARGET|notarget|No matching version|E404/i;
6
+ const upgradeTo = async (target, deps, { attempts = 5, wait_ms = 30_000, onRetry = (_n) => { } } = {}) => {
7
+ let output = "";
8
+ for (let n = 1; n <= attempts; n++) {
9
+ const res = await deps.install(target);
10
+ output = res.output;
11
+ if (res.ok) {
12
+ const installed = await deps.installed();
13
+ if (installed === target)
14
+ return { ok: true, attempts: n };
15
+ // npm said yes and changed nothing: the same lag, from another angle.
16
+ }
17
+ else if (!NOT_FOUND.test(res.output)) {
18
+ return { ok: false, reason: "install_failed", output };
19
+ }
20
+ if (n < attempts) {
21
+ onRetry(n);
22
+ await deps.sleep(wait_ms);
23
+ }
24
+ }
25
+ return {
26
+ ok: false,
27
+ reason: "not_propagated",
28
+ output,
29
+ installed: await deps.installed(),
30
+ };
31
+ };
32
+ const run = (file, args, timeout) => new Promise((resolve) => {
33
+ execFile(file, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, output: `${stdout ?? ""}${stderr ?? ""}` }));
34
+ });
35
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
36
+ /** The real thing: global npm. */
37
+ const npmDeps = {
38
+ install: (version) => run(npm, ["install", "-g", "--prefer-online", `${CLI_PACKAGE}@${version}`], 180_000),
39
+ installed: async () => {
40
+ const res = await run(npm, ["ls", "-g", "--json", "--depth=0", CLI_PACKAGE], 30_000);
41
+ try {
42
+ return JSON.parse(res.output).dependencies?.[CLI_PACKAGE]?.version;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ },
48
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
49
+ };
50
+
51
+ export { npmDeps, upgradeTo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",