@getxflow/cli 0.3.0 → 0.3.2
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/dist/commands/functions.js +19 -0
- package/dist/help.js +3 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +115 -8
|
@@ -8,6 +8,12 @@ const config_1 = require("../config");
|
|
|
8
8
|
const errors_1 = require("../errors");
|
|
9
9
|
const session_1 = require("../session");
|
|
10
10
|
const ui_1 = require("../ui");
|
|
11
|
+
/** Who can call the function: inside the app only, or an outside service holding a key. */
|
|
12
|
+
function accessLabel(fn) {
|
|
13
|
+
if (!fn.external_keys)
|
|
14
|
+
return 'in-app only';
|
|
15
|
+
return `external (${fn.external_keys} key${fn.external_keys > 1 ? 's' : ''})`;
|
|
16
|
+
}
|
|
11
17
|
const FUNCTIONS_DIR = 'functions';
|
|
12
18
|
async function functionsList() {
|
|
13
19
|
const { config } = (0, config_1.requireProject)();
|
|
@@ -20,6 +26,7 @@ async function functionsList() {
|
|
|
20
26
|
(0, ui_1.table)(data.functions.map((fn) => [
|
|
21
27
|
fn.name,
|
|
22
28
|
fn.status === 'deployed' ? 'deployed' : fn.status === 'failed' ? 'failed' : fn.status,
|
|
29
|
+
accessLabel(fn),
|
|
23
30
|
fn.last_deployed_at ? (0, ui_1.formatAge)(fn.last_deployed_at) : '-',
|
|
24
31
|
fn.error_message ?? '',
|
|
25
32
|
]));
|
|
@@ -49,6 +56,17 @@ async function functionsInvoke(args) {
|
|
|
49
56
|
const data = (0, args_1.flagString)(args, 'data');
|
|
50
57
|
const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
|
|
51
58
|
const sendsBody = method !== 'GET' && method !== 'HEAD';
|
|
59
|
+
// A pass for the person who owns the key: the project token alone is not an
|
|
60
|
+
// identity, and a project built with the current template refuses without one.
|
|
61
|
+
// A read-only key cannot get a pass, so the call still goes out without it.
|
|
62
|
+
let pass = '';
|
|
63
|
+
try {
|
|
64
|
+
const issued = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pass`, { method: 'POST' });
|
|
65
|
+
pass = issued.pass;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
(0, ui_1.note)((0, ui_1.dim)(' Could not get a visitor pass: calling with the project token only'));
|
|
69
|
+
}
|
|
52
70
|
const started = Date.now();
|
|
53
71
|
let response;
|
|
54
72
|
try {
|
|
@@ -57,6 +75,7 @@ async function functionsInvoke(args) {
|
|
|
57
75
|
headers: {
|
|
58
76
|
'Content-Type': 'application/json',
|
|
59
77
|
'X-Project-Token': card.project_token ?? '',
|
|
78
|
+
...(pass ? { 'X-Project-Pass': pass } : {}),
|
|
60
79
|
},
|
|
61
80
|
body: sendsBody ? (data ?? '{}') : undefined,
|
|
62
81
|
// Wait past the function's own 90 s cap to see its timeout, not ours.
|
package/dist/help.js
CHANGED
|
@@ -150,8 +150,9 @@ A failed run shows up in ${(0, ui_1.bold)('xflow functions logs')}: nobody is wa
|
|
|
150
150
|
function, so the wrapper reports a crash the same way it does on an ordinary call.`,
|
|
151
151
|
invoke: `${(0, ui_1.bold)('xflow functions invoke')} <name>: call a function
|
|
152
152
|
|
|
153
|
-
Calls it exactly the way the application does
|
|
154
|
-
|
|
153
|
+
Calls it exactly the way the application does: the project token plus a visitor pass
|
|
154
|
+
for the person who owns the key, so the function sees a real caller and its role.
|
|
155
|
+
Both are taken from the platform, no local .env is needed.
|
|
155
156
|
|
|
156
157
|
--data '{"a":1}' request body (the method becomes POST by default)
|
|
157
158
|
--method GET a different method
|
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.3.
|
|
5
|
+
exports.CLI_VERSION = '0.3.1';
|
|
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
package/skills/xflow/SKILL.md
CHANGED
|
@@ -146,13 +146,60 @@ output of that call. Only failed calls are logged, so an empty output means the
|
|
|
146
146
|
never crashed, not that logging is broken.
|
|
147
147
|
|
|
148
148
|
From the app, call a function through `src/lib/xflow.ts`:
|
|
149
|
-
`await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the
|
|
150
|
-
|
|
149
|
+
`await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the credentials
|
|
150
|
+
for you. Addresses are baked into the build, which is why the functions go out first:
|
|
151
151
|
by the time the bundle is built they already exist, and a new function is never missing
|
|
152
152
|
from the application that calls it.
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
### Who is calling
|
|
155
|
+
|
|
156
|
+
A function answers only to a member of the organization who has access to that project.
|
|
157
|
+
The platform issues a short-lived pass when it opens the application, the wrapper checks it
|
|
158
|
+
with the platform on every call, and the handler receives the answer in `event.xflow`:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
exports.handler = async (event) => {
|
|
162
|
+
const { caller, user } = event.xflow
|
|
163
|
+
// caller: 'visitor' (a person), 'service' (another function of this project),
|
|
164
|
+
// 'external' (an outside service with a key), 'schedule' (a timer run)
|
|
165
|
+
// user: { id, role } for a visitor, null for everything else
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Never trust an identity that arrives in the body or in a header of the request: those are
|
|
170
|
+
written by the page, which lives on someone else's computer. `event.xflow` is the only
|
|
171
|
+
identity the platform stands behind, and `usePlatformAuth()` in the frontend is a hint for
|
|
172
|
+
the interface, not a check.
|
|
173
|
+
|
|
174
|
+
A function that changes data should say so instead of checking the role by hand:
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
exports.minRole = 'admin' // 'member' | 'developer' | 'admin' | 'owner'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The wrapper refuses anything below that role before your code runs. Without the line every
|
|
181
|
+
member of the project can call the function, including the ones who may only look at apps.
|
|
182
|
+
|
|
183
|
+
Losing access closes the function within five minutes, so a removed member cannot keep calling it.
|
|
184
|
+
Opening the deployed address directly does not work either: there is no pass outside the
|
|
185
|
+
platform.
|
|
186
|
+
|
|
187
|
+
Calling a function from another function is a server call. Send two headers, both from the
|
|
188
|
+
environment the platform fills in: `X-Project-Token` with `process.env.XFLOW_PROJECT_TOKEN`
|
|
189
|
+
and `X-Server-Key` with `process.env.XFLOW_SERVER_KEY`. The token is the ticket into the
|
|
190
|
+
project and the key is the identity; the wrapper checks the ticket first, so the key alone
|
|
191
|
+
answers 401.
|
|
192
|
+
|
|
193
|
+
An outside service (a webhook from a payment provider, a bot, a CRM) has no person behind it
|
|
194
|
+
and needs a key of that one function. Keys are not issued by default and the CLI cannot
|
|
195
|
+
create one: a human issues it in the project settings: «Облачные функции» → the function →
|
|
196
|
+
«Настройки». Ask the user to do that and to paste the address back to you — never invent
|
|
197
|
+
another way in. A function holds at most two keys, and the second one exists to replace the
|
|
198
|
+
first without downtime, not to serve a second consumer.
|
|
199
|
+
|
|
200
|
+
`xflow functions list` shows who can reach each function: `in-app only` (no keys, answers
|
|
201
|
+
only inside the application) or `external (N keys)` (a human issued external access). Key
|
|
202
|
+
values are never shown there.
|
|
156
203
|
|
|
157
204
|
Keys and passwords live on the platform, not in the repository: `xflow env set SMTP_PASSWORD=…`
|
|
158
205
|
writes one, `xflow env` lists the names, `xflow env check` tells you which variables your
|
|
@@ -160,8 +207,9 @@ functions read but the platform does not have. Values never come back out — th
|
|
|
160
207
|
they exist is inside the running function.
|
|
161
208
|
|
|
162
209
|
A function receives only the variables it mentions by name via `process.env.NAME`, so never
|
|
163
|
-
assemble a variable name from an expression
|
|
164
|
-
|
|
210
|
+
assemble a variable name from an expression and never destructure the environment
|
|
211
|
+
(`const { API_KEY } = process.env` reads as no mention at all, and the variable arrives
|
|
212
|
+
empty). New values arrive on the next `xflow deploy`, not at the moment they are written.
|
|
165
213
|
|
|
166
214
|
To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily at 03:00).
|
|
167
215
|
Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
|
|
@@ -184,7 +232,9 @@ const db = new Client({ connectionString: process.env.DATABASE_URL })
|
|
|
184
232
|
|
|
185
233
|
The platform passes `DATABASE_URL` only to functions that mention it, and sets the project
|
|
186
234
|
schema on every connection, so plain table names (`select * from tasks`) hit your project.
|
|
187
|
-
You never write that variable yourself: `xflow env set DATABASE_URL=...` is refused.
|
|
235
|
+
You never write that variable yourself: `xflow env set DATABASE_URL=...` is refused. The same
|
|
236
|
+
goes for every name starting with `XFLOW`: the platform fills those in itself, and your value
|
|
237
|
+
under one of them would shadow the real one.
|
|
188
238
|
|
|
189
239
|
The platform keeps no database history and no backups. Anything that destroys data
|
|
190
240
|
(`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
|
|
@@ -195,6 +245,57 @@ One logical database can be shared by several projects, so your migration can br
|
|
|
195
245
|
you do not see. `xflow db status` lists applied migrations that have no file in your
|
|
196
246
|
repository: that is what someone else's project did.
|
|
197
247
|
|
|
248
|
+
## File storage
|
|
249
|
+
|
|
250
|
+
The project has file storage, and the browser cannot reach it. Those endpoints take only the
|
|
251
|
+
server key of the project, and the platform puts it into the environment of your cloud
|
|
252
|
+
functions as `XFLOW_SERVER_KEY`. Nothing else holds it: not the bundle, not `.env`, not
|
|
253
|
+
`xflow env`.
|
|
254
|
+
|
|
255
|
+
So uploading is a function of your own. It asks the platform for a one-time link, the browser
|
|
256
|
+
then sends the bytes straight to storage, and a second call records the file:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
const link = await fetch(`${process.env.XFLOW_API_URL}/api/storage/project/upload-url`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: {
|
|
262
|
+
'Content-Type': 'application/json',
|
|
263
|
+
'X-Server-Key': process.env.XFLOW_SERVER_KEY,
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify({ fileName, fileSize, contentType, folderPath: 'invoices' }),
|
|
266
|
+
}).then((r) => r.json())
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`confirm` takes the same fields plus the returned `s3Key` and answers with the file and its
|
|
270
|
+
public address; `delete` takes the file `url`. Never pipe the bytes through the function itself.
|
|
271
|
+
|
|
272
|
+
There is no endpoint that lists the files back, so the address that `confirm` returns is the
|
|
273
|
+
only copy you get: write it into a table of your own in the same call, and the application
|
|
274
|
+
reads its files from there.
|
|
275
|
+
|
|
276
|
+
Four things bite an upload that otherwise looks right, and none of them is obvious from the
|
|
277
|
+
answers you get:
|
|
278
|
+
|
|
279
|
+
- **The content type can quietly split in two.** Nothing checks that the `Content-Type` the
|
|
280
|
+
browser sends on the PUT matches the one you named in `upload-url`: both calls answer 200.
|
|
281
|
+
But storage keeps the header the browser sent and serves the file under it, while the
|
|
282
|
+
record keeps the one you named, so a card can say `image/png` about a file every browser
|
|
283
|
+
treats as HTML. Send the same string in both calls and they cannot drift.
|
|
284
|
+
- **The same name in the same folder is refused.** Names are unique per folder, so a second
|
|
285
|
+
`avatar.png` fails instead of replacing the first. Give the name a suffix of your own, or
|
|
286
|
+
delete the old file before confirming the new one.
|
|
287
|
+
- **The limits are 200 MB per file and the storage quota of the organization.** The quota is
|
|
288
|
+
checked again on `confirm`, by the real size, which means a refusal can land after the
|
|
289
|
+
bytes are already up; the platform then removes the object and your table stays clean.
|
|
290
|
+
- **Confirm only after the PUT has finished.** The platform looks the object up in storage
|
|
291
|
+
and takes its real size from there, not from what you declared, so an early `confirm`
|
|
292
|
+
answers that the file is not there.
|
|
293
|
+
|
|
294
|
+
What the app may do with files is decided inside that function, because the page in the
|
|
295
|
+
browser can be edited by whoever opened it. Never write the key into the sources and never
|
|
296
|
+
send it to the frontend: the build gate stops on a key found in the application code, and a
|
|
297
|
+
key that reached a visitor lets them delete every file of the project.
|
|
298
|
+
|
|
198
299
|
## Syncing code
|
|
199
300
|
|
|
200
301
|
`xflow status` shows how the local copy differs from the server revision.
|
|
@@ -244,7 +345,9 @@ the design system sits in the project.
|
|
|
244
345
|
|
|
245
346
|
The app runs inside the platform in an iframe and receives the theme and the current
|
|
246
347
|
user from it. The `usePlatformAuth()` hook gives the name, role, permissions and the
|
|
247
|
-
list of organization members.
|
|
348
|
+
list of organization members. Use it to draw the interface, never to guard data: the
|
|
349
|
+
value lives on the page and is edited from the console. Guard data in the function, by
|
|
350
|
+
`event.xflow`.
|
|
248
351
|
|
|
249
352
|
## Errors from a deployed app
|
|
250
353
|
|
|
@@ -256,6 +359,10 @@ records per project are kept.
|
|
|
256
359
|
Local `npm run dev` does not report anything: these logs exist for what you cannot open
|
|
257
360
|
in your own devtools.
|
|
258
361
|
|
|
362
|
+
Cloud functions do work under `npm run dev`, and nothing has to be configured for that:
|
|
363
|
+
the dev server swaps the access key of the logged-in developer for the same narrow pass and
|
|
364
|
+
forwards the call. If it answers that the key is missing, the fix is `xflow login`.
|
|
365
|
+
|
|
259
366
|
## Environment
|
|
260
367
|
|
|
261
368
|
`VITE_XFLOW_PROJECT_TOKEN` and `VITE_XFLOW_API_URL` are written by the CLI when the
|