@getxflow/cli 0.10.5 → 0.11.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/README.md CHANGED
@@ -7,7 +7,7 @@ npm i -g @getxflow/cli
7
7
  xflow login
8
8
  xflow init my-app
9
9
  cd my-app && npm install
10
- xflow deploy
10
+ xflow deploy -m "First version"
11
11
  xflow publish
12
12
  ```
13
13
 
package/dist/args.js CHANGED
@@ -27,7 +27,8 @@ function parseArgs(argv) {
27
27
  const words = [];
28
28
  const flags = {};
29
29
  for (let i = 0; i < argv.length; i++) {
30
- const token = argv[i];
30
+ // -m is what git taught everyone to type for a message; honour it as an alias.
31
+ const token = argv[i] === '-m' ? '--message' : argv[i];
31
32
  if (token === '--') {
32
33
  words.push(...argv.slice(i + 1));
33
34
  break;
@@ -158,5 +158,11 @@ async function whoami() {
158
158
  ? `yes, no more often than one run every ${interval} min`
159
159
  : 'yes'
160
160
  : 'no'}`);
161
+ if (typeof me.features.function_timeout_s === 'number') {
162
+ (0, ui_1.out)(`Function time cap: ${me.features.function_timeout_s} s per call, applied on the next build`);
163
+ }
164
+ if (typeof me.features.builds_concurrent === 'number') {
165
+ (0, ui_1.out)(`Concurrent builds: ${me.features.builds_concurrent}`);
166
+ }
161
167
  }
162
168
  }
@@ -72,12 +72,12 @@ async function refreshFunctionsEnv(root, client, projectId) {
72
72
  * refuses such a build until the caller says yes, and the question is asked
73
73
  * here, before the build starts, rather than reported once it already has.
74
74
  */
75
- async function startBuild(client, projectId, revision, allowRemovals) {
75
+ async function startBuild(client, projectId, revision, allowRemovals, comment) {
76
76
  const path = `/api/v1/projects/${projectId}/builds`;
77
77
  try {
78
78
  return await (0, api_1.apiJson)(client, path, {
79
79
  method: 'POST',
80
- body: { revision, allow_removals: allowRemovals },
80
+ body: { revision, allow_removals: allowRemovals, comment },
81
81
  });
82
82
  }
83
83
  catch (e) {
@@ -104,7 +104,7 @@ async function startBuild(client, projectId, revision, allowRemovals) {
104
104
  throw new errors_1.CliError('The build did not start', 'Nothing was removed');
105
105
  return (0, api_1.apiJson)(client, path, {
106
106
  method: 'POST',
107
- body: { revision, allow_removals: true },
107
+ body: { revision, allow_removals: true, comment },
108
108
  });
109
109
  }
110
110
  }
@@ -120,7 +120,29 @@ async function startBuild(client, projectId, revision, allowRemovals) {
120
120
  async function assertBuildAllowed(client, projectId) {
121
121
  await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/builds?check=plan`);
122
122
  }
123
+ /** Keep in sync with the builds route on the platform. */
124
+ const MAX_COMMENT_LENGTH = 200;
125
+ /** Control and bidi-override characters: they spoof terminals, not comments. */
126
+ const COMMENT_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
127
+ /**
128
+ * The version comment, checked before anything goes up: a refusal after the push
129
+ * would leave the project with a revision newer than its build for no good reason.
130
+ */
131
+ function requireComment(args) {
132
+ const comment = ((0, args_1.flagString)(args, 'message') ?? '')
133
+ .replace(/\s+/g, ' ')
134
+ .replace(COMMENT_CONTROL_CHARS, '')
135
+ .trim();
136
+ if (!comment) {
137
+ throw new errors_1.CliError('Every version needs a comment saying what changed in it', 'Add -m: xflow deploy -m "Cart and card payments". One short line, written in the language the user speaks: it names this version in the history');
138
+ }
139
+ if (comment.length > MAX_COMMENT_LENGTH) {
140
+ throw new errors_1.CliError(`The version comment is too long: ${MAX_COMMENT_LENGTH} characters at most`, 'One short line about what changed in this version is enough');
141
+ }
142
+ return comment;
143
+ }
123
144
  async function deploy(args) {
145
+ const comment = requireComment(args);
124
146
  const { root, config } = (0, config_1.requireProject)();
125
147
  const client = await (0, session_1.connectProject)(config.projectId, root, config);
126
148
  let revision;
@@ -137,7 +159,7 @@ async function deploy(args) {
137
159
  revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
138
160
  }
139
161
  (0, ui_1.step)(`Building on the platform from revision ${revision}`);
140
- const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'));
162
+ const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
141
163
  if (started.removed_functions?.length) {
142
164
  (0, ui_1.warn)(`Removed from the cloud, gone from the sources: ${started.removed_functions.join(', ')}`);
143
165
  }
@@ -213,6 +235,8 @@ async function deployments(args) {
213
235
  d.revision !== null ? `revision ${d.revision}` : 'no revision',
214
236
  d.status === 'deployed' ? 'ready' : d.status,
215
237
  (0, ui_1.formatAge)(d.deployed_at ?? d.created_at),
238
+ // Plain text only: a padded cell with ANSI codes would skew every column after it.
239
+ d.comment && d.comment.length > 60 ? `${d.comment.slice(0, 59)}…` : (d.comment ?? ''),
216
240
  [d.is_dev ? (0, ui_1.bold)('dev') : '', d.is_live ? (0, ui_1.bold)('live') : ''].filter(Boolean).join(' '),
217
241
  ]));
218
242
  (0, ui_1.note)((0, ui_1.dim)(' Serve the pages of an earlier build: xflow rollback <number>'));
@@ -67,6 +67,10 @@ async function functionsInvoke(args) {
67
67
  catch {
68
68
  (0, ui_1.note)((0, ui_1.dim)(' Could not get a visitor pass: calling with the project token only'));
69
69
  }
70
+ // The cap the deployed version actually carries, not the plan's current one:
71
+ // they diverge until the next build. Null means deployed before the platform
72
+ // recorded caps, that is with 90.
73
+ const capS = fn.execution_timeout_s ?? 90;
70
74
  const started = Date.now();
71
75
  let response;
72
76
  try {
@@ -78,8 +82,8 @@ async function functionsInvoke(args) {
78
82
  ...(pass ? { 'X-Project-Pass': pass } : {}),
79
83
  },
80
84
  body: sendsBody ? (data ?? '{}') : undefined,
81
- // Wait past the function's own 90 s cap to see its timeout, not ours.
82
- signal: AbortSignal.timeout(100_000),
85
+ // Wait past the function's own cap to see its timeout, not ours.
86
+ signal: AbortSignal.timeout((capS + 10) * 1000),
83
87
  });
84
88
  }
85
89
  catch (e) {
@@ -100,6 +100,18 @@ async function init(args) {
100
100
  (0, ui_1.out)(' npm run dev # develop');
101
101
  (0, ui_1.out)(' xflow deploy # send the code, build and release');
102
102
  }
103
+ /**
104
+ * Linking is a step, not a task. Without this the agent reports the command as done and
105
+ * goes quiet, leaving the person in front of a folder: it has to read the skill and come
106
+ * back with a question instead.
107
+ */
108
+ function nextAfterLink(skillReady) {
109
+ (0, ui_1.out)('');
110
+ (0, ui_1.out)(` ${(0, ui_1.bold)('Next:')}`);
111
+ if (skillReady)
112
+ (0, ui_1.out)(` read ${skills_1.SKILL_IN_PROJECT}: how this platform is built and shipped`);
113
+ (0, ui_1.out)(' then ask the user what to build or change, and start there');
114
+ }
103
115
  async function link(args) {
104
116
  const root = (0, config_1.findProjectRoot)() ?? process.cwd();
105
117
  const existing = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, config_1.CONFIG_FILE)) ? (0, config_1.readConfig)(root) : undefined;
@@ -148,11 +160,13 @@ async function link(args) {
148
160
  write(dir, '.env', (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions));
149
161
  (0, ui_1.note)((0, ui_1.dim)(' Created .env with the project token and the function addresses'));
150
162
  }
151
- (0, skills_1.installSkillQuietly)(dir);
163
+ const skillReady = (0, skills_1.installSkillQuietly)(dir);
152
164
  (0, ui_1.ok)(`The folder is linked to the project "${card.name}"`);
153
165
  (0, ui_1.note)((0, ui_1.dim)(` ${(0, node_path_1.join)(dir, config_1.CONFIG_FILE)}`));
154
- if (!fresh)
166
+ if (!fresh) {
167
+ nextAfterLink(skillReady);
155
168
  return;
169
+ }
156
170
  // A clone without the code is half a clone, and the folder has to remember
157
171
  // which revision it works from: without that the first push has nothing to
158
172
  // compare against and stops.
@@ -164,11 +178,15 @@ async function link(args) {
164
178
  catch (e) {
165
179
  if (e instanceof api_1.ApiError && e.status === 404) {
166
180
  (0, ui_1.note)((0, ui_1.dim)(' The project has no sources yet: write the code and run xflow deploy'));
181
+ nextAfterLink(skillReady);
167
182
  return;
168
183
  }
184
+ // No next step while the code is missing: the pull has to succeed first.
169
185
  (0, ui_1.warn)(`The sources did not arrive: ${e instanceof Error ? e.message : e}`);
170
186
  (0, ui_1.note)((0, ui_1.dim)(' The folder is linked, fetch them separately: xflow pull'));
187
+ return;
171
188
  }
189
+ nextAfterLink(skillReady);
172
190
  }
173
191
  async function list() {
174
192
  const root = (0, config_1.findProjectRoot)();
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SKILL_IN_PROJECT = void 0;
4
+ exports.skillCopyPaths = skillCopyPaths;
3
5
  exports.replacePointer = replacePointer;
4
6
  exports.copyIsStale = copyIsStale;
5
7
  exports.staleSkillCopy = staleSkillCopy;
@@ -28,13 +30,25 @@ const AGENTS = [
28
30
  const DEFAULT_PROJECT = ['claude', 'cursor', 'codex'];
29
31
  const DEFAULT_GLOBAL = ['claude', 'codex', 'universal'];
30
32
  const CURSOR_RULE = ['.cursor', 'rules', 'xflow.mdc'];
33
+ /** Written by every default install, so the pointer and the link nudge can both name it. */
34
+ exports.SKILL_IN_PROJECT = '.agents/skills/xflow/SKILL.md';
35
+ /**
36
+ * Every place inside a project a copy of the skill can live. The archive of sources is
37
+ * built around this list, so a new agent in the table above is kept out of it by adding
38
+ * the agent and nothing else.
39
+ */
40
+ function skillCopyPaths() {
41
+ const paths = new Set(AGENTS.map((agent) => `${[...agent.project, 'xflow'].join('/')}/`));
42
+ paths.add(CURSOR_RULE.join('/'));
43
+ return [...paths];
44
+ }
31
45
  /** AGENTS.md is always read, unlike the lazily loaded skill: a short pointer lives there. */
32
46
  const POINTER_MARKER = '<!-- xflow-skill -->';
33
47
  const POINTER = `${POINTER_MARKER}
34
48
  ## XFlow
35
49
 
36
50
  This project is hosted on the XFlow platform and ships through the \`xflow\` CLI, not
37
- through a git push. Read \`.agents/skills/xflow/SKILL.md\` before deploying, publishing,
51
+ through a git push. Read \`${exports.SKILL_IN_PROJECT}\` before deploying, publishing,
38
52
  rolling back, touching the database or migrations, cloud functions, schedules,
39
53
  environment variables, file storage and uploads, connected accounts or production logs.
40
54
  Command list: \`xflow help\`.
@@ -242,15 +256,21 @@ function defaultIds(base, global) {
242
256
  }
243
257
  return [...ids];
244
258
  }
245
- /** Best-effort install from init and link: a failure must not break the linking. */
259
+ /**
260
+ * Best-effort install from init and link: a failure must not break the linking.
261
+ * Reports whether the skill is in place, so the caller does not send an agent to a
262
+ * file that was never written.
263
+ */
246
264
  function installSkillQuietly(base) {
247
265
  try {
248
266
  if (install(base, defaultIds(base, false), false).length > 0) {
249
267
  (0, ui_1.note)((0, ui_1.dim)(' The platform instructions are laid out for the AI agent'));
250
268
  }
269
+ return true;
251
270
  }
252
271
  catch {
253
272
  (0, ui_1.note)((0, ui_1.dim)(' Could not lay out the AI agent instructions: xflow skills'));
273
+ return false;
254
274
  }
255
275
  }
256
276
  function pathLabel(agent, global) {
@@ -142,7 +142,9 @@ async function downloadRevision(client, projectId, target, revision) {
142
142
  (0, ui_1.warn)('The hash of the downloaded tree did not match the server one: the contents may have changed in transit');
143
143
  }
144
144
  (0, node_fs_1.mkdirSync)(target, { recursive: true });
145
- const files = (0, zip_1.dropDirectoryEntries)(entries);
145
+ // The hash above covers the archive as the server holds it; what lands on disk is the
146
+ // code, without the skill copies older revisions still carry.
147
+ const files = (0, zip_1.dropDirectoryEntries)(entries).filter((entry) => !(0, tree_1.isSkillCopy)(entry.path));
146
148
  for (const entry of files) {
147
149
  const path = (0, node_path_1.join)(target, entry.path);
148
150
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
package/dist/flags.js CHANGED
@@ -73,7 +73,7 @@ const KNOWN = {
73
73
  'storage remove': ['folder', 'yes', 'project'],
74
74
  status: [],
75
75
  pull: ['into', 'force', 'revision'],
76
- deploy: ['no-push', 'force', 'allow-removals'],
76
+ deploy: ['no-push', 'force', 'allow-removals', 'message'],
77
77
  publish: ['project'],
78
78
  rollback: ['project'],
79
79
  deployments: ['project'],
package/dist/help.js CHANGED
@@ -57,7 +57,7 @@ ${(0, ui_1.bold)('Files')}
57
57
  delete a folder with everything in it
58
58
 
59
59
  ${(0, ui_1.bold)('Releasing')}
60
- xflow deploy [--no-push] send the code, ship the functions, build on the platform
60
+ xflow deploy -m "what changed" send the code, ship the functions, build on the platform
61
61
  xflow publish show the dev version to visitors
62
62
  xflow rollback <version number> serve the pages of an earlier build
63
63
  xflow deployments version history
@@ -253,7 +253,8 @@ ${(0, ui_1.bold)('console.log')} lines of that call) and sends it to the platfor
253
253
  write nothing, otherwise every request would pay for it in latency.
254
254
 
255
255
  What never reaches this list: a crash while the module is starting (the function
256
- never gets as far as the wrapper), going over 90 seconds, and running out of memory.
256
+ never gets as far as the wrapper), going over the execution time cap of the plan
257
+ (${(0, ui_1.bold)('xflow whoami')} names it), and running out of memory.
257
258
  Those show up in the answer to ${(0, ui_1.bold)('xflow functions invoke')}.
258
259
 
259
260
  Browser errors are collected by ${(0, ui_1.bold)('src/utils/error-logger.ts')} of the template and
@@ -467,6 +468,9 @@ Three steps: sending the sources, shipping the cloud functions, building the app
467
468
  The build command and the output directory come from xflow.json (npm run build and dist
468
469
  by default).
469
470
 
471
+ -m <text> the version comment, required: one short line in the language the
472
+ user speaks, saying what changed. It names the version in the
473
+ history (--message is the long form)
470
474
  --no-push do not send sources, build from the latest server revision
471
475
  --force allow overwriting the server revision while sending
472
476
  --allow-removals agree in advance to remove the functions gone from the sources
package/dist/limits.js CHANGED
@@ -5,8 +5,11 @@ exports.limitLine = limitLine;
5
5
  exports.quotaRows = quotaRows;
6
6
  const DENIAL_LABELS = {
7
7
  limit_projects: 'projects',
8
+ limit_databases: 'databases',
9
+ limit_connections: 'active connector connections',
8
10
  limit_functions: 'cloud functions',
9
11
  limit_builds: 'builds this month',
12
+ limit_builds_concurrent: 'builds running at once',
10
13
  limit_seats: 'seats in the organization',
11
14
  limit_schedule_interval: 'schedule frequency',
12
15
  db_write_locked: 'database volume, writes are off',
@@ -29,6 +32,8 @@ function limitLine(detail) {
29
32
  }
30
33
  const QUOTA_ORDER = [
31
34
  'projects',
35
+ 'databases',
36
+ 'connections',
32
37
  'functions',
33
38
  'developers',
34
39
  'members',
@@ -39,6 +44,8 @@ const QUOTA_ORDER = [
39
44
  ];
40
45
  const QUOTA_LABELS = {
41
46
  projects: 'Projects',
47
+ databases: 'Databases',
48
+ connections: 'Active connections',
42
49
  functions: 'Cloud functions',
43
50
  developers: 'Developers',
44
51
  members: 'Staff',
package/dist/tree.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSkillCopy = isSkillCopy;
3
4
  exports.loadIgnoreRules = loadIgnoreRules;
4
5
  exports.collectFiles = collectFiles;
5
6
  exports.treeHash = treeHash;
@@ -7,6 +8,7 @@ exports.heaviest = heaviest;
7
8
  const node_crypto_1 = require("node:crypto");
8
9
  const node_fs_1 = require("node:fs");
9
10
  const node_path_1 = require("node:path");
11
+ const skills_1 = require("./commands/skills");
10
12
  /** Never sent. */
11
13
  const DEFAULT_IGNORE = [
12
14
  'node_modules/',
@@ -22,6 +24,10 @@ const DEFAULT_IGNORE = [
22
24
  // Compiler cache: rewritten by every local build, so it would make a new
23
25
  // revision out of nothing.
24
26
  'tsconfig.tsbuildinfo',
27
+ // The skill ships inside the CLI and is laid out by init and link, so a copy of it
28
+ // is not project code. Sent once, it comes back with every clone and overwrites the
29
+ // fresh copy the CLI has just written with the one deployed back then.
30
+ ...(0, skills_1.skillCopyPaths)(),
25
31
  ];
26
32
  const IGNORE_FILE = '.xflowignore';
27
33
  function globToRegExpSource(pattern) {
@@ -58,6 +64,16 @@ function compileRule(raw) {
58
64
  const suffix = dirOnly ? '/' : '($|/)';
59
65
  return { re: new RegExp(`${prefix}${source}${suffix}`) };
60
66
  }
67
+ const skillRules = (0, skills_1.skillCopyPaths)()
68
+ .map(compileRule)
69
+ .filter((rule) => rule !== null);
70
+ /**
71
+ * The same paths on the way back: revisions deployed before the rule above still carry a
72
+ * copy, and unpacking it would put a stale skill in front of the agent.
73
+ */
74
+ function isSkillCopy(path) {
75
+ return skillRules.some((rule) => rule.re.test(path));
76
+ }
61
77
  /** Defaults, the .xflowignore file and the ignore field of xflow.json. */
62
78
  function loadIgnoreRules(root, extra = []) {
63
79
  const lines = [...DEFAULT_IGNORE, ...extra];
package/dist/version.js CHANGED
@@ -2,6 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
4
4
  /** Keep in sync with cli/package.json. */
5
- exports.CLI_VERSION = '0.10.5';
5
+ exports.CLI_VERSION = '0.11.0';
6
6
  /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
7
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.10.5",
3
+ "version": "0.11.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -24,7 +24,7 @@ contains the fix.
24
24
 
25
25
  ## Keeping these instructions current
26
26
 
27
- These instructions ship with xflow CLI 0.10.5. They travel inside the package, so the copy
27
+ These instructions ship with xflow CLI 0.11.0. They travel inside the package, so the copy
28
28
  you are reading can be older than the CLI answering your commands, and nothing about that
29
29
  is visible in the text itself.
30
30
 
@@ -65,11 +65,23 @@ the error points away from the cause. The sections below carry the details.
65
65
 
66
66
  ## Plan limits
67
67
 
68
- The organization runs on a plan with finite limits: projects, cloud functions, developer
69
- and staff seats, database and file storage, function minutes per month, plus schedules and
70
- how often they may run. `xflow whoami` prints every one of them next to what is already
68
+ The organization runs on a plan with finite limits: projects, databases, cloud functions,
69
+ active connector connections, developer and staff seats, database and file storage,
70
+ function minutes and builds per month, builds running at once, plus schedules and how
71
+ often they may run. `xflow whoami` prints every one of them next to what is already
71
72
  used, and reading it before a long task is cheaper than hitting a wall mid-way.
72
73
 
74
+ Two of them deserve a word. A database outlives its project (deleting a project only
75
+ detaches it), so a refused database on a small plan usually means an orphan: the fix is
76
+ deleting the unused database in the "Data" section of the web interface, not renaming
77
+ anything. Connector connections are counted while active: switching an unused connection
78
+ off in the web interface frees the seat without deleting it.
79
+
80
+ The plan also caps how long one call of a cloud function may run. The cap is written
81
+ into the function when it is deployed, so a plan change reaches the cloud only with the
82
+ next `xflow deploy` of each project, in both directions. `xflow whoami` names the plan's
83
+ cap; what a deployed function actually carries is what it was last deployed with.
84
+
73
85
  The same output names the rights of your own key, which is the other half of the answer:
74
86
  destroying data in a migration, deleting files from storage and linking a connected
75
87
  account each need a right that is off by default. Reading that line first turns a refusal
@@ -96,8 +108,10 @@ back within an hour of the data going under the limit.
96
108
  script, then `npx tsc --noEmit`).
97
109
  3. `npm run build` if the change is substantial. The platform builds again in its own
98
110
  sandbox, on one Node version for everyone, so this is only a fast way to see errors early.
99
- 4. `xflow deploy` sends the sources, ships the cloud functions and builds the application
100
- on the platform, printing each phase and the six-digit number of the version it built.
111
+ 4. `xflow deploy -m "what changed"` sends the sources, ships the cloud functions and
112
+ builds the application on the platform, printing each phase and the six-digit number
113
+ of the version it built. The comment is required: one short line in the language the
114
+ user speaks, it names this version in the history.
101
115
  5. Give the user the project link the CLI printed and let them look. Do not open a
102
116
  browser for them.
103
117
  6. `xflow publish` makes that same version visible to visitors.
@@ -361,7 +375,7 @@ The pieces line up in one pass. From a new function to a verified schedule:
361
375
  ```
362
376
  xflow env set SMTP_PASSWORD=... # secrets first: values ride the next deploy
363
377
  # write functions/report/index.ts, reading process.env.SMTP_PASSWORD literally
364
- xflow deploy # ships the function, then builds the app
378
+ xflow deploy -m "Nightly report function" # ships the function, then builds the app
365
379
  xflow schedules set report "0 3 ? * * *" --payload '{"mode":"full"}' # needs a deployed function
366
380
  xflow functions invoke report # run it once, the way the app would
367
381
  xflow functions logs report # empty output means it never crashed
@@ -467,7 +481,7 @@ for a visitor who is signed in and has access to this project, the same rule tha
467
481
  application itself, so it works on your pages and does nothing in an email or on a page
468
482
  anyone can open.
469
483
 
470
- Three things bite an upload that otherwise looks right:
484
+ Four things bite an upload that otherwise looks right:
471
485
 
472
486
  - **A name already taken in that folder is refused before the link is issued.** Pass
473
487
  `overwrite: true` to replace the file: the bytes change and the address stays, which is
@@ -477,11 +491,17 @@ Three things bite an upload that otherwise looks right:
477
491
  bytes are already up; the platform then removes the object and your table stays clean.
478
492
  - **Confirm only after the PUT has finished.** The platform looks the object up in storage
479
493
  and takes its real size and content type from there, not from what you declared, so an
480
- early `confirm` answers that the file is not there. Send on the PUT the `Content-Type` you
481
- named when asking for the link: storage serves the file under the header it received.
494
+ early `confirm` answers that the file is not there. Send on the PUT exactly the
495
+ `Content-Type` you named when asking for the link: the link is signed for that header, a
496
+ different or missing type gets 403, and storage serves the file under it.
497
+ - **Storage takes application assets, not code.** Images, documents, data files, audio,
498
+ video, fonts and archives pass; pages, scripts and executables (`html`, `js`, `exe`, …)
499
+ are refused with `forbidden_type` when the link is requested. Both the extension and the
500
+ declared `content_type` are checked. Application code travels through `xflow deploy`, not
501
+ through storage.
482
502
 
483
503
  A refusal comes back as `{ error, code }`. Branch on `code` (`invalid_name`, `file_too_large`,
484
- `quota_exceeded`, `not_uploaded`, `duplicate_name`, `not_found`, …) and never on the text:
504
+ `quota_exceeded`, `not_uploaded`, `duplicate_name`, `forbidden_type`, `not_found`, …) and never on the text:
485
505
  the wording is free to change, the code is not.
486
506
 
487
507
  What the app may do with files is decided inside that function, because the page in the