@faable/faable 4.14.0 → 4.16.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.
@@ -95,7 +95,9 @@ class FaableApi {
95
95
  ? 'This Faable API key is invalid or was revoked. Create a new one in the dashboard (project settings → API Keys).'
96
96
  : 'Your Faable session has expired or is invalid. Run `faable login` to sign in again.', { cause: error });
97
97
  expired.status = 401;
98
- expired.code = by_key ? 'apikey_invalid' : 'session_expired';
98
+ expired.code = by_key
99
+ ? 'apikey_invalid'
100
+ : 'session_expired';
99
101
  throw expired;
100
102
  }
101
103
  const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
@@ -157,7 +159,11 @@ class FaableApi {
157
159
  async listProjects(params) {
158
160
  const { q, user_id, ...page } = params;
159
161
  return data(this.client.get(`/project`, {
160
- params: { ...page_params(page), ...(q ? { q } : {}), ...(user_id ? { user_id } : {}) }
162
+ params: {
163
+ ...page_params(page),
164
+ ...(q ? { q } : {}),
165
+ ...(user_id ? { user_id } : {})
166
+ }
161
167
  }));
162
168
  }
163
169
  async getProject(project_id) {
@@ -7,6 +7,7 @@ import { log } from '../../log.js';
7
7
  import { requireProject } from '../../api/project.js';
8
8
  import { git_context } from './git_context.js';
9
9
  import { show_deploy_help } from './help.js';
10
+ import { reachable_timeout_ms, wait_reachable } from './reachable.js';
10
11
  import { propose_release } from './release_version.js';
11
12
  import { assert_name_free } from './apps/name_free.js';
12
13
  import { deploy_remote } from './remote/index.js';
@@ -221,6 +222,13 @@ const launch = {
221
222
  log.debug(`Polling app status failed, retrying...`);
222
223
  }
223
224
  }
225
+ // Promoted is not yet answering: wait for the edge to route the URL
226
+ // (F9). `undefined` when the check is off (FAABLE_REACHABLE_TIMEOUT_MS=0).
227
+ let reachable;
228
+ if (promoted && reachable_timeout_ms() > 0) {
229
+ log.info(`⏳ Live — waiting for https://${app.url} to answer...`);
230
+ reachable = await wait_reachable(`https://${app.url}`, reachable_timeout_ms());
231
+ }
224
232
  if (args.json) {
225
233
  const outcome = promoted
226
234
  ? 'live'
@@ -239,6 +247,7 @@ const launch = {
239
247
  deployment_id: deployment.id,
240
248
  outcome,
241
249
  url: `https://${app.url}`,
250
+ ...(reachable !== undefined ? { reachable } : {}),
242
251
  ...(failure
243
252
  ? {
244
253
  phase: failure.phase,
@@ -262,6 +271,9 @@ const launch = {
262
271
  }
263
272
  if (promoted) {
264
273
  log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
274
+ if (reachable === false) {
275
+ log.warn(`⚠️ The URL still answers "app not found" — the edge is picking up the route. Try again in a minute.`);
276
+ }
265
277
  }
266
278
  else if (superseded) {
267
279
  // Not a failure: the app IS live, just on a sibling deployment (e.g.
@@ -0,0 +1,52 @@
1
+ // A promoted deployment is not yet a URL that answers: the edge picks up a
2
+ // new app's route a little later, and until then it says `APP_NOT_FOUND`
3
+ // (header `x-faable-error`, cacheable for 120 s). On 2026-10-16 a deploy
4
+ // reported `outcome: live` and the URL gave 404 for about a minute — an agent
5
+ // checking it right away would conclude the deploy was broken
6
+ // (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F9).
7
+ //
8
+ // Only the edge's own "no app here" counts as not served: an app is free to
9
+ // answer 404 (or anything) on `/`, and that is its answer, not ours.
10
+ const NOT_FOUND = 'APP_NOT_FOUND';
11
+ // FAABLE_REACHABLE_TIMEOUT_MS: how long to wait for the edge (default 120 s);
12
+ // `0` skips the check — a stub API in tests, or a caller that only wants the
13
+ // deployment's phase.
14
+ const reachable_timeout_ms = (env = process.env) => {
15
+ const raw = env.FAABLE_REACHABLE_TIMEOUT_MS;
16
+ const n = raw === undefined || raw === '' ? 120_000 : Number(raw);
17
+ return Number.isFinite(n) && n >= 0 ? n : 120_000;
18
+ };
19
+ /**
20
+ * Wait until `url` is served by the app rather than the edge's "no app here".
21
+ * True as soon as it is; false when `timeout_ms` runs out (the deploy is
22
+ * still live — the route is just late). Never throws: a failed request is one
23
+ * more "not yet".
24
+ */
25
+ const wait_reachable = async (url, timeout_ms, deps = {}) => {
26
+ const fetch_ = deps.fetch ?? fetch;
27
+ const sleep = deps.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
28
+ const now = deps.now ?? Date.now;
29
+ const deadline = now() + timeout_ms;
30
+ for (;;) {
31
+ try {
32
+ // The not-found answer is cacheable: bust it, or we would read our own
33
+ // first answer back for two minutes.
34
+ const probe = `${url}${url.includes('?') ? '&' : '?'}_faable_probe=${now()}`;
35
+ const res = await fetch_(probe, {
36
+ method: 'GET',
37
+ redirect: 'manual',
38
+ headers: { 'cache-control': 'no-cache' }
39
+ });
40
+ if (res.headers.get('x-faable-error') !== NOT_FOUND)
41
+ return true;
42
+ }
43
+ catch {
44
+ // not reachable yet
45
+ }
46
+ if (now() >= deadline)
47
+ return false;
48
+ await sleep(3_000);
49
+ }
50
+ };
51
+
52
+ export { reachable_timeout_ms, wait_reachable };
@@ -62,6 +62,21 @@ const add_rule = async (opts) => {
62
62
  }
63
63
  if (json)
64
64
  return waf;
65
+ if (action === 'allow') {
66
+ // The one rule that opens instead of closing: say exactly what it opens.
67
+ log.info(`🔓 ${describe_selector(want)} → reaches your app`);
68
+ log.info(` for ${app_name} (${app_id}), even where Faable blocks it for every app`);
69
+ log.info(` (archives like .zip or .tar.gz, dotfiles, config files).`);
70
+ log.info(``);
71
+ log.info(`Your own block and sink rules still win over it, and certificate checks,`);
72
+ log.info(`/.faable/* and /_next/* are never affected.`);
73
+ log.info(``);
74
+ log.info(`It takes about 20s to reach the edge. Then check it with:`);
75
+ log.info(` curl -s -o /dev/null -w '%{http_code}\\n' https://${app_url}<path>`);
76
+ log.info(``);
77
+ log.info(`Undo: faable deploy waf rm ${undo_args(want)} --action allow -a ${app_id}`);
78
+ return waf;
79
+ }
65
80
  const answer = action === 'deny' ? '403' : '404';
66
81
  log.info(`🛡️ ${describe_selector(want)} → ${answer} at the edge`);
67
82
  log.info(` for ${app_name} (${app_id}).`);
@@ -0,0 +1,58 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { print_json, json_option } from '../../../lib/listing.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { add_rule } from './add_rule.js';
5
+
6
+ /**
7
+ * The one WAF rule that opens instead of closing: paths of THIS app that Faable
8
+ * blocks for every app (archives, dotfiles, config files) and that this app
9
+ * really serves — a `.zip` download, say. Path only: a user-agent or query
10
+ * exception could be claimed by anyone sending that header.
11
+ */
12
+ const waf_allow = {
13
+ command: 'allow <pattern>',
14
+ describe: 'Let a path Faable blocks for every app (e.g. *.zip) reach your app',
15
+ builder: yargs => json_option(yargs)
16
+ .positional('pattern', {
17
+ type: 'string',
18
+ demandOption: true,
19
+ description: 'Anchored path regex, e.g. ^/downloads/.*\\.zip$ (quote it in your shell)'
20
+ })
21
+ .option('app', {
22
+ alias: 'a',
23
+ type: 'string',
24
+ description: 'App id, name or slug (defaults to the linked app)'
25
+ })
26
+ .option('description', {
27
+ alias: 'd',
28
+ type: 'string',
29
+ description: 'Why this rule exists (shown in `waf list`)'
30
+ })
31
+ .example("$0 deploy waf allow '^/downloads/.*\\.zip$'", 'Serve the .zip files under /downloads that Faable would otherwise refuse')
32
+ .epilogue('Faable refuses some paths for every app before they reach it — ' +
33
+ 'archives like .zip or .tar.gz, dotfiles, config files — because ' +
34
+ 'scanners request them by the thousand. If your app really serves ' +
35
+ 'one, allow exactly that path. Keep the pattern narrow: your own ' +
36
+ 'block and sink rules still win over it, and it can never cover ' +
37
+ '/.well-known/acme-challenge, /.faable or /_next.')
38
+ .showHelpOnFail(false),
39
+ handler: async (args) => {
40
+ const ctx = await requireApi();
41
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
42
+ const app = await ctx.api.getApp(app_id);
43
+ const waf = await add_rule({
44
+ api: ctx.api,
45
+ app_id,
46
+ app_name: app.name,
47
+ app_url: app.url,
48
+ pattern: args.pattern,
49
+ action: 'allow',
50
+ description: args.description,
51
+ json: args.json
52
+ });
53
+ if (args.json)
54
+ print_json(waf);
55
+ }
56
+ };
57
+
58
+ export { waf_allow };
@@ -5,7 +5,9 @@ const action_label = (action) => action === 'sink'
5
5
  ? '403 (blocked at the edge)'
6
6
  : action === 'probe'
7
7
  ? 'answered by Faable, app not woken (uptime monitors)'
8
- : action;
8
+ : action === 'allow'
9
+ ? 'allowed (reaches your app where Faable blocks it for every app)'
10
+ : action;
9
11
  /**
10
12
  * Render the effective WAF of an app.
11
13
  *
@@ -1,3 +1,4 @@
1
+ import { waf_allow } from './allow.js';
1
2
  import { waf_block } from './block.js';
2
3
  import { waf_list } from './list.js';
3
4
  import { waf_rm } from './rm.js';
@@ -10,8 +11,9 @@ const waf = {
10
11
  .command(waf_list)
11
12
  .command(waf_block)
12
13
  .command(waf_sink)
14
+ .command(waf_allow)
13
15
  .command(waf_rm)
14
- .demandCommand(1, 'Specify a waf command: list, block, sink or rm'),
16
+ .demandCommand(1, 'Specify a waf command: list, block, sink, allow or rm'),
15
17
  handler: () => {
16
18
  // Unreachable: demandCommand(1) either routes to a subcommand or fails
17
19
  // through the global .fail() in src/index.ts.
@@ -27,7 +27,7 @@ const waf_rm = {
27
27
  })
28
28
  .option('action', {
29
29
  type: 'string',
30
- choices: ['deny', 'sink'],
30
+ choices: ['deny', 'sink', 'allow'],
31
31
  description: 'Only needed when the same rule exists as both'
32
32
  })
33
33
  .option('app', {
@@ -1,3 +1,5 @@
1
+ import { reachable_timeout_ms, wait_reachable } from './reachable.js';
2
+
1
3
  // The phases a deployment does not leave on its own. READY is the good one.
2
4
  const TERMINAL_PHASES = new Set([
3
5
  'READY',
@@ -27,8 +29,15 @@ const wait_for_deployment = async (api, deployment_id, opts, deps = {}) => {
27
29
  done: true,
28
30
  ok: phase === 'READY'
29
31
  };
30
- if (phase === 'READY' && opts.app_url)
32
+ if (phase === 'READY' && opts.app_url) {
31
33
  result.url = opts.app_url;
34
+ const check = deps.reachable ??
35
+ (reachable_timeout_ms() > 0
36
+ ? (url) => wait_reachable(url, reachable_timeout_ms())
37
+ : undefined);
38
+ if (check)
39
+ result.reachable = await check(opts.app_url);
40
+ }
32
41
  if (phase !== 'READY' && d?.status?.reason) {
33
42
  result.reason = d.status.reason;
34
43
  }
package/dist/mcp/tools.js CHANGED
@@ -854,6 +854,25 @@ const TOOLS = [
854
854
  },
855
855
  example: { app: 'web', repo: 'acme/web', branch: 'main' }
856
856
  },
857
+ {
858
+ name: 'delete_app',
859
+ title: 'Delete an app',
860
+ description: "Delete an app, as the dashboard's Delete app does: its deployments, domains and secrets go with it and its URL stops being served. It cannot be undone — confirm with the user which app (get_app) before calling it. Only on the local server with `faable mcp --writes`.",
861
+ input: { app, project },
862
+ command: ['deploy', 'apps', 'rm'],
863
+ flags: a => ['--yes', ...target(a)],
864
+ write: true,
865
+ // The hosted server promises its users nothing destructive (the consent
866
+ // screen and docs/cli.md say so): deleting stays with whoever runs the
867
+ // MCP on their own machine and passed --writes.
868
+ local_only: true,
869
+ annotations: {
870
+ readOnlyHint: false,
871
+ destructiveHint: true,
872
+ openWorldHint: true
873
+ },
874
+ example: { app: 'web' }
875
+ },
857
876
  {
858
877
  name: 'deploy_directory',
859
878
  title: 'Deploy a local directory',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.14.0",
3
+ "version": "4.16.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",