@faable/faable 1.40.0 → 2.1.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.
@@ -241,6 +241,22 @@ class FaableApi {
241
241
  headers: { 'x-faable-team': team }
242
242
  }));
243
243
  }
244
+ // ── per-app WAF ───────────────────────────────────────────────────────────
245
+ //
246
+ // No `x-faable-team` header on any of these: the routes are scoped by the
247
+ // app in the path (the server reads the team off the App row), and sending
248
+ // a team override would only narrow the lookup.
249
+ async getAppWaf(app_id) {
250
+ return data(this.client.get(`/app/${app_id}/waf`));
251
+ }
252
+ async addAppWafRule(app_id, params) {
253
+ return data(this.client.post(`/app/${app_id}/waf/rules`, params));
254
+ }
255
+ async removeAppWafRule(app_id, pattern) {
256
+ return data(this.client.delete(`/app/${app_id}/waf/rules`, {
257
+ data: { pattern }
258
+ }));
259
+ }
244
260
  }
245
261
 
246
262
  export { FaableApi };
@@ -3,6 +3,7 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
3
  import { log } from '../../../log.js';
4
4
  import { json_option, tenant_options } from '../options.js';
5
5
  import { print_json } from '../render.js';
6
+ import { formatTriggers } from './triggers.js';
6
7
 
7
8
  const actions_create = {
8
9
  command: 'create',
@@ -13,18 +14,12 @@ const actions_create = {
13
14
  type: 'string',
14
15
  demandOption: true,
15
16
  description: 'Action name (max 200 chars)'
16
- })
17
- .option('trigger', {
18
- alias: 't',
19
- type: 'string',
20
- choices: ['post-login', 'continue', 'client-credentials'],
21
- demandOption: true,
22
- description: 'Trigger point (client-credentials runs on M2M token grants: no user, no redirect)'
23
17
  })
24
18
  .option('code-file', {
25
19
  alias: 'f',
26
20
  type: 'string',
27
- description: 'Path to a JS file with the action code'
21
+ demandOption: true,
22
+ description: 'Path to a JS file with the action code. The triggers are derived from the hooks it exports (exports.onExecutePostLogin / onExecuteContinue / onExecuteClientCredentials)'
28
23
  })
29
24
  .option('disabled', {
30
25
  type: 'boolean',
@@ -35,27 +30,25 @@ const actions_create = {
35
30
  type: 'number',
36
31
  description: 'Execution order (lower runs first, default 0)'
37
32
  })
38
- .example('$0 auth actions create -n add-claims -t post-login -f ./claims.js', 'Create a post-login action from a file')
33
+ .example('$0 auth actions create -n add-claims -f ./claims.js', 'Create an action from a file; its triggers follow the exported hooks')
39
34
  .showHelpOnFail(false),
40
35
  handler: withAuthHints(async (args) => {
41
- let code;
42
- if (args.codeFile) {
43
- if (!(await fs.pathExists(args.codeFile))) {
44
- throw new Error(`Code file not found: ${args.codeFile}`);
45
- }
46
- code = await fs.readFile(args.codeFile, 'utf8');
36
+ if (!(await fs.pathExists(args.codeFile))) {
37
+ throw new Error(`Code file not found: ${args.codeFile}`);
47
38
  }
39
+ const code = await fs.readFile(args.codeFile, 'utf8');
48
40
  const api = await requireAuthAdmin(args);
49
41
  const action = await api.actionCreate({
50
42
  name: args.name,
51
- trigger: args.trigger,
52
- ...(code !== undefined ? { code } : {}),
43
+ code,
53
44
  ...(args.disabled ? { enabled: false } : {}),
54
45
  ...(args.order !== undefined ? { order: args.order } : {})
46
+ // `trigger` is gone from the API (auth ≥ v1.58): triggers are derived
47
+ // from the code. Cast until the SDK types catch up with that release.
55
48
  });
56
49
  if (args.json)
57
50
  return print_json(action);
58
- log.info(`✅ Created action ${action.id} (${action.name}, trigger ${action.trigger})`);
51
+ log.info(`✅ Created action ${action.id} (${action.name}, triggers ${formatTriggers(action)})`);
59
52
  })
60
53
  };
61
54
 
@@ -2,6 +2,7 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { json_option, tenant_options } from '../options.js';
4
4
  import { print_json, yes_no } from '../render.js';
5
+ import { formatTriggers } from './triggers.js';
5
6
 
6
7
  const actions_get = {
7
8
  command: 'get <action_id>',
@@ -25,7 +26,7 @@ const actions_get = {
25
26
  return print_json(action);
26
27
  log.info(`⚙️ ${action.id}`);
27
28
  log.info(` Name: ${action.name ?? '-'}`);
28
- log.info(` Trigger: ${action.trigger ?? '-'}`);
29
+ log.info(` Triggers: ${formatTriggers(action)}`);
29
30
  log.info(` Enabled: ${yes_no(action.enabled)}`);
30
31
  log.info(` Order: ${action.order ?? 0}`);
31
32
  log.info(` Revision: ${action.revision ?? 1} (updated ${action.updatedAt ?? '-'})`);
@@ -3,6 +3,7 @@ import { log } from '../../../log.js';
3
3
  import { json_option, list_options, tenant_options } from '../options.js';
4
4
  import { fetch_items } from '../paging.js';
5
5
  import { print_json, yes_no, when, table_lines } from '../render.js';
6
+ import { formatTriggers } from './triggers.js';
6
7
 
7
8
  const actions_list = {
8
9
  command: 'list',
@@ -10,7 +11,7 @@ const actions_list = {
10
11
  builder: yargs => json_option(list_options(tenant_options(yargs)))
11
12
  .option('query', {
12
13
  type: 'string',
13
- description: 'FaableQL filter, e.g. "trigger:post-login"'
14
+ description: 'FaableQL filter, e.g. "enabled:true"'
14
15
  })
15
16
  .showHelpOnFail(false),
16
17
  handler: withAuthHints(async (args) => {
@@ -26,7 +27,7 @@ const actions_list = {
26
27
  const rows = items.map(a => [
27
28
  a.id ?? '-',
28
29
  a.name ?? '-',
29
- a.trigger ?? '-',
30
+ formatTriggers(a),
30
31
  yes_no(a.enabled),
31
32
  String(a.order ?? 0),
32
33
  when(a.createdAt)
@@ -1,6 +1,7 @@
1
1
  import prompts from 'prompts';
2
2
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
3
  import { log } from '../../../log.js';
4
+ import { formatTriggers } from './triggers.js';
4
5
  import { tenant_options } from '../options.js';
5
6
 
6
7
  const actions_rm = {
@@ -27,7 +28,7 @@ const actions_rm = {
27
28
  const { confirm } = await prompts({
28
29
  type: 'toggle',
29
30
  name: 'confirm',
30
- message: `Delete action "${action.name}" (${action.id}, trigger ${action.trigger})?`,
31
+ message: `Delete action "${action.name}" (${action.id}, triggers ${formatTriggers(action)})?`,
31
32
  initial: false,
32
33
  active: 'yes',
33
34
  inactive: 'no'
@@ -0,0 +1,13 @@
1
+ // `triggers` (auth ≥ v1.58) replaced the hand-picked `trigger`: an action runs
2
+ // on every trigger whose hook its code exports. Tolerates both shapes so the
3
+ // CLI prints something sensible against either server version.
4
+ const formatTriggers = (action) => {
5
+ const list = action.triggers?.length
6
+ ? action.triggers
7
+ : action.trigger
8
+ ? [action.trigger]
9
+ : [];
10
+ return list.length ? list.join(',') : '-';
11
+ };
12
+
13
+ export { formatTriggers };
@@ -3,6 +3,7 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
3
  import { log } from '../../../log.js';
4
4
  import { json_option, tenant_options } from '../options.js';
5
5
  import { print_json } from '../render.js';
6
+ import { formatTriggers } from './triggers.js';
6
7
 
7
8
  const actions_update = {
8
9
  command: 'update <action_id>',
@@ -21,7 +22,7 @@ const actions_update = {
21
22
  .option('code-file', {
22
23
  alias: 'f',
23
24
  type: 'string',
24
- description: 'Path to a JS file with the new action code'
25
+ description: 'Path to a JS file with the new action code (triggers are re-derived from the hooks it exports)'
25
26
  })
26
27
  .option('order', {
27
28
  type: 'number',
@@ -55,7 +56,7 @@ const actions_update = {
55
56
  const action = await api.actionUpdate(args.action_id, patch);
56
57
  if (args.json)
57
58
  return print_json(action);
58
- log.info(`✅ Updated action ${action.id} (${action.name}, trigger ${action.trigger}, enabled: ${action.enabled ? '✓' : '✗'}, revision ${action.revision ?? '-'})`);
59
+ log.info(`✅ Updated action ${action.id} (${action.name}, triggers ${formatTriggers(action)}, enabled: ${action.enabled ? '✓' : '✗'}, revision ${action.revision ?? '-'})`);
59
60
  })
60
61
  };
61
62
 
@@ -19,6 +19,7 @@ import { deploy_remote } from './remote/index.js';
19
19
  import { resolve_app_id } from './resolve_app_id.js';
20
20
  import { secrets } from './secrets/index.js';
21
21
  import { is_superseded } from './superseded.js';
22
+ import { waf } from './waf/index.js';
22
23
 
23
24
  const deploy = {
24
25
  command: 'deploy [app_id]',
@@ -31,6 +32,7 @@ const deploy = {
31
32
  return yargs
32
33
  .command(secrets)
33
34
  .command(domains)
35
+ .command(waf)
34
36
  .command(logs)
35
37
  .command(status)
36
38
  .command(apps_list)
@@ -0,0 +1,24 @@
1
+ import { log } from '../../../log.js';
2
+
3
+ /**
4
+ * Shared handler for `block` and `sink` — the two differ only in the action
5
+ * they store and in what the edge answers, so the flow (resolve app, write,
6
+ * explain, tell the user how to check) lives here once.
7
+ */
8
+ const add_rule = async (opts) => {
9
+ const { api, app_id, app_name, app_url, pattern, action, description } = opts;
10
+ await api.addAppWafRule(app_id, { pattern, action, description });
11
+ const answer = action === 'deny' ? '403' : '404';
12
+ log.info(`🛡️ ${pattern} → ${answer} at the edge for ${app_name} (${app_id}).`);
13
+ log.info(``);
14
+ log.info(action === 'deny'
15
+ ? `Matching requests are blocked before they reach your app, so they no longer wake it.`
16
+ : `Faable answers matching requests with a ${answer} itself, so they no longer wake your app.`);
17
+ log.info(``);
18
+ log.info(`It takes about 20s to reach the edge. Then check it with:`);
19
+ log.info(` curl -s -o /dev/null -w '%{http_code}\\n' https://${app_url}<path>`);
20
+ log.info(``);
21
+ log.info(`Undo: faable deploy waf rm '${pattern}' -a ${app_id}`);
22
+ };
23
+
24
+ export { add_rule };
@@ -0,0 +1,42 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { resolve_app_id } from '../resolve_app_id.js';
3
+ import { add_rule } from './add_rule.js';
4
+
5
+ const waf_block = {
6
+ command: 'block <pattern>',
7
+ describe: 'Block a path at the edge with a 403 (never reaches your app)',
8
+ builder: yargs => yargs
9
+ .positional('pattern', {
10
+ type: 'string',
11
+ demandOption: true,
12
+ description: 'Anchored path regex, e.g. ^/\\.well-known/ (quote it in your shell)'
13
+ })
14
+ .option('app', {
15
+ alias: 'a',
16
+ type: 'string',
17
+ description: 'App Identifier (defaults to the linked app)'
18
+ })
19
+ .option('description', {
20
+ alias: 'd',
21
+ type: 'string',
22
+ description: 'Why this rule exists (shown in `waf list`)'
23
+ })
24
+ .example("$0 deploy waf block '^/\\.well-known/'", 'Stop scanner probes under /.well-known from waking the app')
25
+ .showHelpOnFail(false),
26
+ handler: async (args) => {
27
+ const ctx = await requireApi();
28
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
29
+ const app = await ctx.api.getApp(app_id);
30
+ await add_rule({
31
+ api: ctx.api,
32
+ app_id,
33
+ app_name: app.name,
34
+ app_url: app.url,
35
+ pattern: args.pattern,
36
+ action: 'deny',
37
+ description: args.description
38
+ });
39
+ }
40
+ };
41
+
42
+ export { waf_block };
@@ -0,0 +1,43 @@
1
+ /** Human label for a rule action, so `list` explains itself without docs. */
2
+ const action_label = (action) => action === 'sink'
3
+ ? '404 (answered by Faable, app not woken)'
4
+ : action === 'deny'
5
+ ? '403 (blocked at the edge)'
6
+ : action;
7
+ /**
8
+ * Render the effective WAF of an app.
9
+ *
10
+ * Platform profiles come back as names + counts for a normal user and with
11
+ * their patterns for an admin, so this prints whatever the server chose to
12
+ * send rather than assuming either shape.
13
+ */
14
+ const format_waf = (waf) => {
15
+ const out = [];
16
+ if (!waf.enabled) {
17
+ out.push('⚠️ WAF disabled for this app — no rule below is enforced.');
18
+ out.push('');
19
+ }
20
+ out.push('Platform rules (managed by Faable):');
21
+ if (waf.platform_profiles.length === 0) {
22
+ out.push(' (none)');
23
+ }
24
+ for (const p of waf.platform_profiles) {
25
+ out.push(` • ${p.name} — ${p.rule_count} rule(s), ${action_label(p.action)}`);
26
+ for (const r of p.rules ?? []) {
27
+ out.push(` ${r.pattern}${r.description ? ` # ${r.description}` : ''}`);
28
+ }
29
+ }
30
+ out.push('');
31
+ out.push('Your rules:');
32
+ if (waf.rules.length === 0) {
33
+ out.push(' (none)');
34
+ }
35
+ for (const r of waf.rules) {
36
+ out.push(` • ${r.pattern} → ${action_label(r.action)}`);
37
+ if (r.description)
38
+ out.push(` ${r.description}`);
39
+ }
40
+ return out;
41
+ };
42
+
43
+ export { action_label, format_waf };
@@ -0,0 +1,21 @@
1
+ import { waf_block } from './block.js';
2
+ import { waf_list } from './list.js';
3
+ import { waf_rm } from './rm.js';
4
+ import { waf_sink } from './sink.js';
5
+
6
+ const waf = {
7
+ command: 'waf <command>',
8
+ describe: 'Block or silence request paths at the edge, before the app wakes',
9
+ builder: yargs => yargs
10
+ .command(waf_list)
11
+ .command(waf_block)
12
+ .command(waf_sink)
13
+ .command(waf_rm)
14
+ .demandCommand(1, 'Specify a waf command: list, block, sink or rm'),
15
+ handler: () => {
16
+ // Unreachable: demandCommand(1) either routes to a subcommand or fails
17
+ // through the global .fail() in src/index.ts.
18
+ }
19
+ };
20
+
21
+ export { waf };
@@ -0,0 +1,29 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { format_waf } from './format.js';
5
+
6
+ const waf_list = {
7
+ command: 'list',
8
+ describe: 'Show the WAF rules in effect for the app',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .example('$0 deploy waf list', 'Show the rules protecting the linked app')
16
+ .showHelpOnFail(false),
17
+ handler: async (args) => {
18
+ const ctx = await requireApi();
19
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
20
+ const app = await ctx.api.getApp(app_id);
21
+ const waf = await ctx.api.getAppWaf(app_id);
22
+ log.info(`🛡️ WAF for ${app.name} (${app_id})`);
23
+ log.info(``);
24
+ for (const line of format_waf(waf))
25
+ log.info(line);
26
+ }
27
+ };
28
+
29
+ export { waf_list };
@@ -0,0 +1,31 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+
5
+ const waf_rm = {
6
+ command: 'rm <pattern>',
7
+ describe: 'Remove one of your WAF rules',
8
+ builder: yargs => yargs
9
+ .positional('pattern', {
10
+ type: 'string',
11
+ demandOption: true,
12
+ description: 'The exact pattern to remove (see `faable deploy waf list`)'
13
+ })
14
+ .option('app', {
15
+ alias: 'a',
16
+ type: 'string',
17
+ description: 'App Identifier (defaults to the linked app)'
18
+ })
19
+ .example("$0 deploy waf rm '^/robots\\.txt$'", 'Stop handling /robots.txt at the edge')
20
+ .showHelpOnFail(false),
21
+ handler: async (args) => {
22
+ const ctx = await requireApi();
23
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
24
+ const app = await ctx.api.getApp(app_id);
25
+ await ctx.api.removeAppWafRule(app_id, args.pattern);
26
+ log.info(`🗑️ Removed ${args.pattern} from ${app.name} (${app_id}).`);
27
+ log.info(`Requests for it reach your app again within ~20s, once the edge picks up the change.`);
28
+ }
29
+ };
30
+
31
+ export { waf_rm };
@@ -0,0 +1,45 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { resolve_app_id } from '../resolve_app_id.js';
3
+ import { add_rule } from './add_rule.js';
4
+
5
+ const waf_sink = {
6
+ command: 'sink <pattern>',
7
+ describe: 'Answer a path with a 404 from Faable, without waking your app',
8
+ builder: yargs => yargs
9
+ .positional('pattern', {
10
+ type: 'string',
11
+ demandOption: true,
12
+ description: 'Anchored path regex, e.g. ^/robots\\.txt$ (quote it in your shell)'
13
+ })
14
+ .option('app', {
15
+ alias: 'a',
16
+ type: 'string',
17
+ description: 'App Identifier (defaults to the linked app)'
18
+ })
19
+ .option('description', {
20
+ alias: 'd',
21
+ type: 'string',
22
+ description: 'Why this rule exists (shown in `waf list`)'
23
+ })
24
+ .example("$0 deploy waf sink '^/robots\\.txt$'", 'Let Faable answer /robots.txt with a 404 instead of starting your app')
25
+ .epilogue('Use `sink` for paths your app does not serve anyway: Faable replies ' +
26
+ 'with the same 404 your app would have, without the cold start. ' +
27
+ 'Use `block` instead when you want the request refused outright.')
28
+ .showHelpOnFail(false),
29
+ handler: async (args) => {
30
+ const ctx = await requireApi();
31
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
32
+ const app = await ctx.api.getApp(app_id);
33
+ await add_rule({
34
+ api: ctx.api,
35
+ app_id,
36
+ app_name: app.name,
37
+ app_url: app.url,
38
+ pattern: args.pattern,
39
+ action: 'sink',
40
+ description: args.description
41
+ });
42
+ }
43
+ };
44
+
45
+ export { waf_sink };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.40.0",
3
+ "version": "2.1.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",