@faable/faable 4.9.1 → 4.10.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.
@@ -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)
@@ -36,12 +36,5 @@ const parse_user_ids = (args, stdin) => {
36
36
  }
37
37
  return ids;
38
38
  };
39
- const read_stdin = async () => {
40
- const chunks = [];
41
- for await (const chunk of process.stdin) {
42
- chunks.push(Buffer.from(chunk));
43
- }
44
- return Buffer.concat(chunks).toString('utf8');
45
- };
46
39
 
47
- export { parse_user_ids, read_stdin, wants_stdin };
40
+ export { parse_user_ids, wants_stdin };
@@ -2,9 +2,10 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { tenant_options } from '../options.js';
4
4
  import { json_option, print_json } from '../../../lib/listing.js';
5
- import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
5
+ import { wants_stdin, parse_user_ids } from './ids.js';
6
6
  import { resolve_user_refs } from './resolve.js';
7
7
  import { confirm } from '../../../lib/interactive.js';
8
+ import { read_stdin } from '../../../lib/stdin.js';
8
9
 
9
10
  const users_reinstate = {
10
11
  command: 'reinstate [user_ids..]',
@@ -2,9 +2,10 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { tenant_options } from '../options.js';
4
4
  import { json_option, print_json } from '../../../lib/listing.js';
5
- import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
5
+ import { wants_stdin, parse_user_ids } from './ids.js';
6
6
  import { resolve_user_refs } from './resolve.js';
7
7
  import { confirm } from '../../../lib/interactive.js';
8
+ import { read_stdin } from '../../../lib/stdin.js';
8
9
 
9
10
  const users_suspend = {
10
11
  command: 'suspend [user_ids..]',
@@ -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 };
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { requireApi } from '../../../api/context.js';
4
4
  import { print_json, json_option } from '../../../lib/listing.js';
5
+ import { read_stdin } from '../../../lib/stdin.js';
5
6
  import { log } from '../../../log.js';
6
7
  import { resolve_app_id } from '../resolve_app_id.js';
7
8
  import { managed_warning } from './managed_names.js';
@@ -20,9 +21,11 @@ const DEFAULT_ENV_FILE = '.env';
20
21
  // even after the script, and dies with "node: -: not found" before the CLI
21
22
  // runs; the short alias is invisible to it.
22
23
  const STDIN = '-';
23
- const read_env_file = (raw_path) => {
24
+ const read_env_file = async (raw_path) => {
25
+ // Asynchronously: `readFileSync(0)` throws EAGAIN on a pipe that has not
26
+ // been written yet (lib/stdin.ts).
24
27
  if (raw_path === STDIN)
25
- return parse_env(readFileSync(0, 'utf8'), 'stdin');
28
+ return parse_env(await read_stdin(), 'stdin');
26
29
  const path = raw_path || DEFAULT_ENV_FILE;
27
30
  let content;
28
31
  try {
@@ -86,7 +89,7 @@ const secrets_set = {
86
89
  }
87
90
  // Validate EVERYTHING before writing ANY, so one malformed line or pair
88
91
  // aborts the whole command with no partial writes.
89
- const from_file = env_file === undefined ? [] : read_env_file(env_file);
92
+ const from_file = env_file === undefined ? [] : await read_env_file(env_file);
90
93
  const parsed = combine(from_file, parse_pairs(pairs));
91
94
  if (env_file !== undefined) {
92
95
  const path = env_file === STDIN ? 'stdin' : env_file || DEFAULT_ENV_FILE;
@@ -0,0 +1,15 @@
1
+ // All of stdin, read asynchronously.
2
+ //
3
+ // Never `readFileSync(0)`: when stdin is a pipe from another process that has
4
+ // not written yet (`faable auth webhooks create --json | … | faable deploy
5
+ // secrets set -f -`), the descriptor is non-blocking and the synchronous read
6
+ // throws EAGAIN instead of waiting.
7
+ const read_stdin = async () => {
8
+ const chunks = [];
9
+ for await (const chunk of process.stdin) {
10
+ chunks.push(Buffer.from(chunk));
11
+ }
12
+ return Buffer.concat(chunks).toString('utf8');
13
+ };
14
+
15
+ export { read_stdin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.9.1",
3
+ "version": "4.10.1",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",