layero 0.9.4 → 0.10.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/LICENSE +21 -0
- package/README.md +203 -167
- package/dist/agent.js +130 -2
- package/dist/api.js +130 -0
- package/dist/bin/layero.js +110 -36
- package/dist/commands/claim.js +135 -0
- package/dist/commands/data-api.js +578 -0
- package/dist/commands/data-probe.js +541 -0
- package/dist/commands/data.js +4 -1
- package/dist/commands/db.js +94 -4
- package/dist/commands/deploy.js +81 -23
- package/dist/commands/env.js +13 -2
- package/dist/commands/envs.js +44 -0
- package/dist/commands/hooks.js +29 -32
- package/dist/commands/init.js +63 -91
- package/dist/commands/link.js +12 -25
- package/dist/commands/logout.js +2 -2
- package/dist/commands/orgs.js +11 -15
- package/dist/commands/projects.js +179 -12
- package/dist/commands/sources.js +127 -0
- package/dist/commands/whoami.js +9 -10
- package/dist/config.js +1 -0
- package/dist/exit-codes.js +102 -0
- package/dist/project-config.js +2 -0
- package/dist/project-ref.js +20 -0
- package/dist/urls.js +21 -0
- package/package.json +10 -5
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// `layero data probe` — проба метода Data API настоящим запросом через шлюз
|
|
2
|
+
// (T-20260911-9; ручка — T-20260911-1).
|
|
3
|
+
//
|
|
4
|
+
// 🚨 Зачем. Проверить, открыт ли метод посетителю и что увидит вошедший, можно
|
|
5
|
+
// было только кнопкой «Выполнить» в панели. Из скрипта, CI и терминала агента —
|
|
6
|
+
// никак. Ключ и токен выбранного пользователя подставляет платформа, запись
|
|
7
|
+
// откатывается по её подписи; права, правила строк и отказы — настоящие.
|
|
8
|
+
//
|
|
9
|
+
// 🚨 КОД ВЫХОДА — ОДНО ПРАВИЛО, ПО ПОРЯДКУ (`verdict`):
|
|
10
|
+
// 1. откат записи не подтверждён → `data_probe_not_rolled_back`, всегда;
|
|
11
|
+
// 2. статус совпал с `--expect` → 0;
|
|
12
|
+
// 3. шлюз ответил 5xx → `data_probe_gateway_failed`: проба не ответила на вопрос;
|
|
13
|
+
// 4. статус не совпал с `--expect` → `data_probe_unexpected_status`;
|
|
14
|
+
// 5. иначе 0 — в том числе на отказ 4xx. «Закрыт ли метод посетителю» —
|
|
15
|
+
// главный вопрос пробы, и 403 на него — ответ, а не сбой.
|
|
16
|
+
// Событие `data_probe` приходит во всех случаях, когда шлюз ответил; ошибка —
|
|
17
|
+
// следом. Без `--expect` скрипту без `--json` нечем отличить 200 от 403: флаг
|
|
18
|
+
// заведён ради CI. Код 0 на неподтверждённом откате значил бы зелёный скрипт,
|
|
19
|
+
// который, возможно, записал в боевую базу.
|
|
20
|
+
//
|
|
21
|
+
// ⚠️ Проверки до запроса повторяют правила ручки (`userdb_api._probe_checked`)
|
|
22
|
+
// там, где её общий текст не объясняет, что исправить: `?` и косая черта в
|
|
23
|
+
// конце пути, метод функции и `/whoami`, тело не у того метода, `--user` не UUID,
|
|
24
|
+
// `--schema`, которую ручка молча проигнорировала бы. Остальные пределы — её.
|
|
25
|
+
import { readFile } from "node:fs/promises";
|
|
26
|
+
import chalk from "chalk";
|
|
27
|
+
import { ApiError } from "../api.js";
|
|
28
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
29
|
+
import { shellArg, target } from "./data-api.js";
|
|
30
|
+
const METHODS = ["GET", "POST", "PATCH", "DELETE"];
|
|
31
|
+
const AS = ["visitor", "user", "server"];
|
|
32
|
+
/**
|
|
33
|
+
* Схемы, для которых ручка ставит заголовок профиля (`_PROBE_SCHEMAS`). Любое
|
|
34
|
+
* другое значение она молча игнорирует, и проба отвечала бы про таблицу, которую
|
|
35
|
+
* шлюз выбрал сам по порядку api → public → app.
|
|
36
|
+
*/
|
|
37
|
+
const SCHEMAS = ["api", "public", "app"];
|
|
38
|
+
/** Заголовки ответа шлюза, которые пропускает ручка (`_PROBE_ANSWER_HEADERS`). */
|
|
39
|
+
const ANSWER_HEADERS = [
|
|
40
|
+
"content-type",
|
|
41
|
+
"content-range",
|
|
42
|
+
"x-layero-caller",
|
|
43
|
+
"x-layero-key",
|
|
44
|
+
"x-layero-user",
|
|
45
|
+
"x-layero-rolled-back",
|
|
46
|
+
];
|
|
47
|
+
/** Что откат пробы не возвращает — теми же словами, что панель. */
|
|
48
|
+
const NOT_UNDONE = "номера последовательностей, внешние вызовы из базы, сессионные блокировки и суточная квота вызовов";
|
|
49
|
+
/** Ручка ждёт UUID (`UUID(user_id)`). */
|
|
50
|
+
const UUID = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
51
|
+
const CONTACTS = "https://docs.layero.ru/contacts/";
|
|
52
|
+
/** Роль пробы и `x-layero-caller` шлюза — теми же словами, что в панели. */
|
|
53
|
+
const AS_WORD = { visitor: "посетитель", user: "вошедший", server: "сервер" };
|
|
54
|
+
function pluralRu(n, one, few, many) {
|
|
55
|
+
const a = Math.abs(n) % 100;
|
|
56
|
+
if (a >= 11 && a <= 14)
|
|
57
|
+
return many;
|
|
58
|
+
const b = a % 10;
|
|
59
|
+
if (b === 1)
|
|
60
|
+
return one;
|
|
61
|
+
if (b >= 2 && b <= 4)
|
|
62
|
+
return few;
|
|
63
|
+
return many;
|
|
64
|
+
}
|
|
65
|
+
const CONTROL = /[\x00-\x1f\x7f]/;
|
|
66
|
+
/**
|
|
67
|
+
* Слово для shell. Значение с управляющими символами — записью `$'…'`.
|
|
68
|
+
*
|
|
69
|
+
* 🚨 Внутри одинарных кавычек интерактивный bash съедает табуляцию (это клавиша
|
|
70
|
+
* автодополнения), и набранный повтор уходил бы без неё. В `$'…'` табуляция —
|
|
71
|
+
* текст `\t`. `!` там же — `\x21`: иначе интерактивный bash развернул бы историю.
|
|
72
|
+
*/
|
|
73
|
+
function shellWord(value) {
|
|
74
|
+
if (!CONTROL.test(value))
|
|
75
|
+
return shellArg(value);
|
|
76
|
+
const named = { "\t": "\\t", "\n": "\\n", "\r": "\\r", "\\": "\\\\", "'": "\\'", "!": "\\x21" };
|
|
77
|
+
const body = [...value]
|
|
78
|
+
.map((c) => named[c] ?? (CONTROL.test(c) ? `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}` : c))
|
|
79
|
+
.join("");
|
|
80
|
+
return `$'${body}'`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Команда повтора со ВСЕМИ флагами, которые передал человек.
|
|
84
|
+
*
|
|
85
|
+
* 🚨 Агент выполняет `next_action` дословно. Подсказка без `--as` или `--schema`
|
|
86
|
+
* повторила бы пробу посетителем в другой схеме — ответ на другой вопрос.
|
|
87
|
+
*/
|
|
88
|
+
function retryCommand(method, path, opts, fromPath = []) {
|
|
89
|
+
const parts = ["layero data probe", method, shellWord(path)];
|
|
90
|
+
const flag = (name, value) => {
|
|
91
|
+
if (value !== undefined)
|
|
92
|
+
parts.push(name, shellWord(value));
|
|
93
|
+
};
|
|
94
|
+
for (const q of [...fromPath, ...(opts.query ?? [])])
|
|
95
|
+
flag("--query", q);
|
|
96
|
+
flag("--as", opts.as);
|
|
97
|
+
flag("--user", opts.user);
|
|
98
|
+
// Пустая схема — отсутствие схемы, в повтор не переносится.
|
|
99
|
+
flag("--schema", opts.schema?.trim() ? opts.schema : undefined);
|
|
100
|
+
flag("--body", opts.body);
|
|
101
|
+
flag("--body-file", opts.bodyFile);
|
|
102
|
+
flag("--expect", opts.expect);
|
|
103
|
+
flag("--db", opts.db);
|
|
104
|
+
flag("--org", opts.org);
|
|
105
|
+
return parts.join(" ");
|
|
106
|
+
}
|
|
107
|
+
function queryOf(items) {
|
|
108
|
+
const out = new Map();
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
const eq = item.indexOf("=");
|
|
111
|
+
if (eq <= 0) {
|
|
112
|
+
throw new LayeroError("data_probe_query", `параметр «${item}» — не пара имя=значение`, "--query select=id,title --query price=gt.100");
|
|
113
|
+
}
|
|
114
|
+
const name = item.slice(0, eq);
|
|
115
|
+
// Ручка принимает одно значение на имя: второе молча заменило бы первое, и
|
|
116
|
+
// проба ответила бы не на тот запрос, который человек написал.
|
|
117
|
+
if (out.has(name)) {
|
|
118
|
+
throw new LayeroError("data_probe_query", `параметр «${name}» указан дважды: проба передаёт одно значение на имя`, `объедините условия в одно, например --query 'or=(${name}.gte.1,${name}.lte.5)'`);
|
|
119
|
+
}
|
|
120
|
+
out.set(name, item.slice(eq + 1));
|
|
121
|
+
}
|
|
122
|
+
// `fromEntries`, а не присваивание: имя `__proto__` станет параметром, а не прототипом.
|
|
123
|
+
return Object.fromEntries(out);
|
|
124
|
+
}
|
|
125
|
+
/** Число JSON как значение: знак, значащие цифры без нулей по краям, порядок. */
|
|
126
|
+
function decimal(text) {
|
|
127
|
+
const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(text);
|
|
128
|
+
if (!m)
|
|
129
|
+
return null;
|
|
130
|
+
const digits = (m[2] + (m[3] ?? "")).replace(/^0+/, "");
|
|
131
|
+
if (!digits)
|
|
132
|
+
return "0";
|
|
133
|
+
const trimmed = digits.replace(/0+$/, "");
|
|
134
|
+
const exp = Number(m[4] ?? 0) - (m[3]?.length ?? 0) + (digits.length - trimmed.length);
|
|
135
|
+
return `${m[1]}${trimmed}e${exp}`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Первое число тела, которое разбор в CLI передал бы не тем значением.
|
|
139
|
+
*
|
|
140
|
+
* 🚨 `JSON.parse` читает числа в double: `9007199254740993` становится
|
|
141
|
+
* `9007199254740992`, и проба пишет или ищет другой id — молча. Сравниваем
|
|
142
|
+
* значение литерала с тем, что уйдёт в запрос. Текст уже разобран без ошибок,
|
|
143
|
+
* поэтому вне строк любая цифра или минус начинает число.
|
|
144
|
+
*/
|
|
145
|
+
function lossyNumber(raw) {
|
|
146
|
+
const number = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < raw.length) {
|
|
149
|
+
const c = raw[i];
|
|
150
|
+
if (c === '"') {
|
|
151
|
+
i++;
|
|
152
|
+
while (i < raw.length && raw[i] !== '"')
|
|
153
|
+
i += raw[i] === "\\" ? 2 : 1;
|
|
154
|
+
i++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (c === "-" || (c >= "0" && c <= "9")) {
|
|
158
|
+
number.lastIndex = i;
|
|
159
|
+
const text = number.exec(raw)?.[0] ?? c;
|
|
160
|
+
// Так число уйдёт в запрос: `JSON.stringify` пишет бесконечность как `null`.
|
|
161
|
+
const sent = JSON.stringify(Number(text));
|
|
162
|
+
if (decimal(text) !== decimal(sent))
|
|
163
|
+
return { text, sent };
|
|
164
|
+
i += text.length;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
async function bodyOf(opts) {
|
|
172
|
+
if (opts.body !== undefined && opts.bodyFile !== undefined) {
|
|
173
|
+
throw new LayeroError("data_probe_body", "заданы и --body, и --body-file", "оставьте один из флагов");
|
|
174
|
+
}
|
|
175
|
+
let raw;
|
|
176
|
+
let from;
|
|
177
|
+
if (opts.bodyFile !== undefined) {
|
|
178
|
+
from = `из файла «${opts.bodyFile}»`;
|
|
179
|
+
try {
|
|
180
|
+
raw = await readFile(opts.bodyFile, "utf8");
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
throw new LayeroError("data_probe_body", `не удалось прочитать файл тела «${opts.bodyFile}»: ${e?.code ?? e?.message ?? e}`, "проверьте путь: --body-file <файл с JSON>");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (opts.body !== undefined) {
|
|
187
|
+
from = "из --body";
|
|
188
|
+
raw = opts.body;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
// BOM ставит Блокнот Windows; `JSON.parse` на нём падает «Unexpected token».
|
|
194
|
+
raw = raw.replace(/^\uFEFF/, "");
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(raw);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
throw new LayeroError("data_probe_body", `тело ${from} — не JSON: ${e?.message ?? e}`, `передайте объект или массив: --body '{"qty":1}'`);
|
|
201
|
+
}
|
|
202
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
203
|
+
throw new LayeroError("data_probe_body", `тело ${from} — не объект и не массив JSON`, `передайте объект или массив: --body '{"qty":1}'`);
|
|
204
|
+
}
|
|
205
|
+
const lossy = lossyNumber(raw);
|
|
206
|
+
if (lossy) {
|
|
207
|
+
throw new LayeroError("data_probe_body", `число ${lossy.text} в теле ${from} не передаётся точно: в запрос ушло бы ${lossy.sent}`, `передайте его строкой в кавычках: "${lossy.text}"`);
|
|
208
|
+
}
|
|
209
|
+
return parsed;
|
|
210
|
+
}
|
|
211
|
+
function expectOf(value) {
|
|
212
|
+
if (value === undefined)
|
|
213
|
+
return null;
|
|
214
|
+
const items = String(value).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
215
|
+
if (!items.length || items.some((s) => !/^[1-5](?:\d\d|xx)$/.test(s))) {
|
|
216
|
+
throw new LayeroError("data_probe_expect", `--expect «${value}» не разобран: нужен статус ответа или класс статусов`, "--expect 200, --expect 2xx или несколько через запятую: --expect 201,204");
|
|
217
|
+
}
|
|
218
|
+
return items;
|
|
219
|
+
}
|
|
220
|
+
function expected(status, expect) {
|
|
221
|
+
return expect.some((e) => (e.endsWith("xx") ? Math.floor(status / 100) === Number(e[0]) : Number(e) === status));
|
|
222
|
+
}
|
|
223
|
+
/** Запрос пробы из аргументов. Всё, что отклоняется здесь, отклоняется до сети. */
|
|
224
|
+
async function requestOf(methodArg, pathArg, opts) {
|
|
225
|
+
const method = String(methodArg).trim().toUpperCase();
|
|
226
|
+
if (!METHODS.includes(method)) {
|
|
227
|
+
throw new LayeroError("data_probe_method", `метод «${methodArg}» проба не умеет`, "GET, POST, PATCH или DELETE");
|
|
228
|
+
}
|
|
229
|
+
const rawPath = String(pathArg);
|
|
230
|
+
const cut = rawPath.indexOf("?");
|
|
231
|
+
// Путь режем по «?» сразу. Проверки ниже смотрят на путь без строки запроса, а их
|
|
232
|
+
// подсказки уже несут параметры из неё флагами --query: иначе исправлять пришлось
|
|
233
|
+
// бы в два шага — сначала «?», потом схему или метод. Отказ за сам «?» — после них.
|
|
234
|
+
const path = cut >= 0 ? rawPath.slice(0, cut) : rawPath;
|
|
235
|
+
const fromPath = cut >= 0
|
|
236
|
+
? [...new URLSearchParams(rawPath.slice(cut + 1)).entries()].map(([k, v]) => `${k}=${v}`)
|
|
237
|
+
: [];
|
|
238
|
+
if (cut >= 0) {
|
|
239
|
+
// Имя и в пути, и во флаге: подсказка несла бы два `--query` с одним именем, и
|
|
240
|
+
// повтор упал бы на них же. Отказываем сразу и говорим, где второе.
|
|
241
|
+
const nameOf = (q) => (q.includes("=") ? q.slice(0, q.indexOf("=")) : q);
|
|
242
|
+
const inPath = new Set(fromPath.map(nameOf));
|
|
243
|
+
const twice = (opts.query ?? []).map(nameOf).find((n) => inPath.has(n));
|
|
244
|
+
if (twice !== undefined) {
|
|
245
|
+
throw new LayeroError("data_probe_query", `параметр «${twice}» задан и в пути после «?», и флагом --query: проба передаёт одно значение на имя`, "оставьте одно значение: уберите параметр из пути или из --query");
|
|
246
|
+
}
|
|
247
|
+
// Остальное, на чём упал бы повтор (пустое имя, повтор внутри «?»), — сейчас.
|
|
248
|
+
queryOf([...fromPath, ...(opts.query ?? [])]);
|
|
249
|
+
}
|
|
250
|
+
const bare = /^\/rest\/v1(\/rpc)?\/+$/.exec(path);
|
|
251
|
+
if (bare) {
|
|
252
|
+
// Срезанная косая черта сделала бы из `/rest/v1/rpc/` пробу таблицы `rpc`.
|
|
253
|
+
throw new LayeroError("data_probe_path", bare[1]
|
|
254
|
+
? `в пути «${path}» нет имени функции: /rest/v1/rpc/<функция>`
|
|
255
|
+
: `в пути «${path}» нет имени таблицы: /rest/v1/<таблица>`, `имена таблиц и функций — layero data methods${opts.db ? ` --db ${shellWord(opts.db)}` : ""}`);
|
|
256
|
+
}
|
|
257
|
+
if (path.length > 1 && path.endsWith("/")) {
|
|
258
|
+
throw new LayeroError("data_probe_path", `в пути «${path}» лишняя косая черта в конце: такой адрес ручка не примет`, retryCommand(method, path.replace(/\/+$/, ""), opts, fromPath));
|
|
259
|
+
}
|
|
260
|
+
const whoami = path === "/whoami";
|
|
261
|
+
const rpc = path.startsWith("/rest/v1/rpc/");
|
|
262
|
+
if (whoami && method !== "GET") {
|
|
263
|
+
throw new LayeroError("data_probe_method", "/whoami отвечает только на GET", retryCommand("GET", path, { ...opts, body: undefined, bodyFile: undefined }, fromPath));
|
|
264
|
+
}
|
|
265
|
+
if (rpc && method !== "GET" && method !== "POST") {
|
|
266
|
+
throw new LayeroError("data_probe_method", `функцию вызывают GET или POST, а метод — ${method}`, retryCommand("POST", path, opts, fromPath));
|
|
267
|
+
}
|
|
268
|
+
const as = String(opts.as ?? "visitor").trim().toLowerCase();
|
|
269
|
+
if (!AS.includes(as)) {
|
|
270
|
+
throw new LayeroError("data_probe_as", `от чьего имени «${opts.as}» — неизвестно`, "--as visitor — посетитель, --as user --user <id> — вошедший, --as server — сервер");
|
|
271
|
+
}
|
|
272
|
+
const user = opts.user?.trim();
|
|
273
|
+
if (as === "user" && !user) {
|
|
274
|
+
throw new LayeroError("data_probe_user_required", "--as user пробует от имени вошедшего пользователя, а он не указан", "добавьте --user <id пользователя приложения>");
|
|
275
|
+
}
|
|
276
|
+
// Без этой проверки `--user` при посетителе молча пропадал бы, и ответ посетителю
|
|
277
|
+
// читался бы как ответ этому пользователю.
|
|
278
|
+
if (as !== "user" && opts.user !== undefined) {
|
|
279
|
+
throw new LayeroError("data_probe_as", `--user работает только с --as user, а проба идёт от имени: ${as}`, "добавьте --as user или уберите --user");
|
|
280
|
+
}
|
|
281
|
+
if (as === "user" && !UUID.test(user)) {
|
|
282
|
+
throw new LayeroError("data_probe_user_invalid", `--user «${opts.user}» — не id пользователя: платформа ждёт UUID, например 7c8aa53d-3b61-40a7-bd2e-695058acd72f`, "возьмите id в панели базы, в списке пользователей приложения");
|
|
283
|
+
}
|
|
284
|
+
// Правило схемы — то же, что у MCP (`data_api.probe_request`). Пустая — отсутствие.
|
|
285
|
+
// У `/whoami` схему молча отбрасываем: его ответ от неё не зависит.
|
|
286
|
+
const schemaArg = opts.schema?.trim().toLowerCase() || null;
|
|
287
|
+
let schema = null;
|
|
288
|
+
if (schemaArg !== null && !whoami) {
|
|
289
|
+
if (rpc) {
|
|
290
|
+
// Функции шлюз зовёт только из api, и `api` отдаёт опись методов — это
|
|
291
|
+
// законно. Ручке схема функции не нужна: профиль она ставит только таблице.
|
|
292
|
+
if (schemaArg !== "api") {
|
|
293
|
+
throw new LayeroError("data_probe_schema", `у функций схема — только api: шлюз зовёт функции из api, а не из ${opts.schema.trim()}`, retryCommand(method, path, { ...opts, schema: undefined }, fromPath));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else if (SCHEMAS.includes(schemaArg)) {
|
|
297
|
+
schema = schemaArg;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
throw new LayeroError("data_probe_schema", `схемы «${opts.schema.trim()}» среди тех, что отдаёт шлюз, нет — флаг был бы молча проигнорирован`, "--schema api, public или app; без флага шлюз ищет таблицу в api, затем в public, затем в app");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const expect = expectOf(opts.expect);
|
|
304
|
+
// Метод — до чтения файла: иначе GET с неверным путём к файлу жаловался бы на
|
|
305
|
+
// файл, а не на то, что тела у GET не бывает.
|
|
306
|
+
if ((opts.body !== undefined || opts.bodyFile !== undefined) && method !== "POST" && method !== "PATCH") {
|
|
307
|
+
throw new LayeroError("data_probe_body", `тело бывает только у POST и PATCH, а метод — ${method}`, retryCommand(method, path, { ...opts, body: undefined, bodyFile: undefined }, fromPath));
|
|
308
|
+
}
|
|
309
|
+
if (cut >= 0) {
|
|
310
|
+
// Ручка принимает путь без строки запроса и отвечает общим «адрес пробы: …» —
|
|
311
|
+
// из него не понять, что параметры надо было отдать отдельно. До чтения файла
|
|
312
|
+
// тела: подсказка не зависит от него.
|
|
313
|
+
throw new LayeroError("data_probe_path", `в пути «${rawPath}» есть «?»: параметры запроса проба принимает только флагами --query`, retryCommand(method, path, opts, fromPath));
|
|
314
|
+
}
|
|
315
|
+
const body = await bodyOf(opts);
|
|
316
|
+
return {
|
|
317
|
+
request: {
|
|
318
|
+
method,
|
|
319
|
+
path,
|
|
320
|
+
query: queryOf(opts.query ?? []),
|
|
321
|
+
body: body ?? null,
|
|
322
|
+
as: as,
|
|
323
|
+
user_id: as === "user" ? user : null,
|
|
324
|
+
schema,
|
|
325
|
+
},
|
|
326
|
+
expect,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Подсказки к отказу ручки — по его тексту (`userdb_api.probe_http`).
|
|
331
|
+
*
|
|
332
|
+
* ⚠️ Текст на сервере поменяется — подсказка упадёт в общую; сам текст отказа
|
|
333
|
+
* всё равно дойдёт до человека как есть.
|
|
334
|
+
*/
|
|
335
|
+
const REFUSAL_HINTS = [
|
|
336
|
+
["адрес пробы не собрался", (ref) => `проверьте имя таблицы или функции: layero data methods --db ${ref}`],
|
|
337
|
+
["адрес пробы", (ref) => `пути методов базы — layero data methods --db ${ref}`],
|
|
338
|
+
["больше 50 параметров", () => "оставьте не больше 50 флагов --query"],
|
|
339
|
+
["больше 64 КБ", () => "уменьшите тело до 64 КБ"],
|
|
340
|
+
["не включён вход", () => "у базы нет вошедших пользователей — пробуйте --as visitor или --as server"],
|
|
341
|
+
["не включён Data API", (ref) => `включите: layero data enable --db ${ref}`],
|
|
342
|
+
["роли Data API не заведены", () => "роли заводит включение Data API — включите его заново в панели базы, раздел «API»"],
|
|
343
|
+
["не найдена", () => "список баз и их состояние: layero db list"],
|
|
344
|
+
["ещё не умеет пробу", () => "запрос не выполнен, данные не тронуты — повторите пробу после выкатки шлюза данных"],
|
|
345
|
+
];
|
|
346
|
+
/**
|
|
347
|
+
* Отказ ручки до запроса к шлюзу — ошибкой с кодом и подсказкой по случаю.
|
|
348
|
+
*
|
|
349
|
+
* 401 и 402 не трогаем: их переводит общий обработчик (`auth_expired`,
|
|
350
|
+
* `plan_limit`). 5xx ручки — тоже: это авария платформы, а не отказ.
|
|
351
|
+
*/
|
|
352
|
+
function refusal(err, ref) {
|
|
353
|
+
if (!(err instanceof ApiError) || err.status < 400 || err.status >= 500)
|
|
354
|
+
return null;
|
|
355
|
+
if (err.status === 401 || err.status === 402)
|
|
356
|
+
return null;
|
|
357
|
+
let detail;
|
|
358
|
+
try {
|
|
359
|
+
detail = JSON.parse(err.body)?.detail;
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
let message = null;
|
|
365
|
+
if (typeof detail === "string" && detail.trim()) {
|
|
366
|
+
message = detail;
|
|
367
|
+
}
|
|
368
|
+
else if (Array.isArray(detail)) {
|
|
369
|
+
// 422 разбора тела: `[{loc: [...], msg: "..."}]`.
|
|
370
|
+
const parts = detail
|
|
371
|
+
.map((d) => (typeof d?.msg === "string" ? `${(d.loc ?? []).slice(1).join(".") || "запрос"}: ${d.msg}` : ""))
|
|
372
|
+
.filter(Boolean);
|
|
373
|
+
message = parts.length ? parts.join("; ") : null;
|
|
374
|
+
}
|
|
375
|
+
if (!message)
|
|
376
|
+
return null;
|
|
377
|
+
// Шлюз не ответил платформе — тот же сбой, что его 5xx: проба не дала ответа.
|
|
378
|
+
if (message.includes("шлюз данных не ответил")) {
|
|
379
|
+
return new LayeroError("data_probe_gateway_failed", `проба не выполнена: ${message}`, `повторите пробу позже; сбой повторяется — сообщите: ${CONTACTS}`);
|
|
380
|
+
}
|
|
381
|
+
const db = shellArg(ref);
|
|
382
|
+
const found = REFUSAL_HINTS.find(([mark]) => message.includes(mark));
|
|
383
|
+
const hint = found
|
|
384
|
+
? found[1](db)
|
|
385
|
+
: err.status === 404
|
|
386
|
+
? "список баз и их состояние: layero db list"
|
|
387
|
+
: err.status === 403
|
|
388
|
+
? "проба доступна только администратору организации"
|
|
389
|
+
: err.status === 422
|
|
390
|
+
? "исправьте флаги по тексту отказа"
|
|
391
|
+
: `исправьте запрос по тексту отказа; пути методов базы — layero data methods --db ${db}`;
|
|
392
|
+
return new LayeroError("data_probe_rejected", `проба не выполнена: ${message}`, hint);
|
|
393
|
+
}
|
|
394
|
+
/** Запись прошла, откат ждали, а подтверждения нет: данные могли измениться. */
|
|
395
|
+
export function notRolledBack(res) {
|
|
396
|
+
return Boolean(res.rollback_expected) && res.status >= 200 && res.status < 400 && res.rolled_back !== true;
|
|
397
|
+
}
|
|
398
|
+
/** Код выхода пробы — правило из шапки файла, по порядку. `null` — код 0. */
|
|
399
|
+
function verdict(res, request, expect, dbName, ref) {
|
|
400
|
+
const what = `${request.method} ${request.path}`;
|
|
401
|
+
if (notRolledBack(res)) {
|
|
402
|
+
return new LayeroError("data_probe_not_rolled_back", `${what}: шлюз ответил ${res.status} и не подтвердил откат — изменения могли записаться в базу «${dbName}»`, "проверьте данные базы и не повторяйте пробу записи, пока причина не найдена");
|
|
403
|
+
}
|
|
404
|
+
if (expect && expected(res.status, expect))
|
|
405
|
+
return null;
|
|
406
|
+
if (res.status >= 500) {
|
|
407
|
+
const body = res.body;
|
|
408
|
+
const why = body && typeof body === "object" && typeof body.error === "string" ? ` (${body.error})` : "";
|
|
409
|
+
return new LayeroError("data_probe_gateway_failed", `${what}: шлюз ответил ${res.status}${why} — проба не дала ответа о доступе`, `повторите пробу позже; сбой повторяется — сообщите: ${CONTACTS}`);
|
|
410
|
+
}
|
|
411
|
+
if (expect) {
|
|
412
|
+
return new LayeroError("data_probe_unexpected_status", `${what}: шлюз ответил ${res.status}, а ожидали ${expect.join(" или ")}`, `ответ шлюза — в выводе пробы; уровни доступа методов: layero data methods --db ${shellArg(ref)}`);
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* «N из M». Знаменатель — только счёт ВЛАДЕЛЬЦА.
|
|
418
|
+
*
|
|
419
|
+
* 🚨 `total` — это сколько строк видно самой роли; «3 из 3» по нему при закрытых
|
|
420
|
+
* правилом строках читалось бы как «роли видна вся таблица».
|
|
421
|
+
*/
|
|
422
|
+
function outOf(res, tableGet) {
|
|
423
|
+
if (res.rows === null || res.rows === undefined)
|
|
424
|
+
return null;
|
|
425
|
+
if (res.owner_total !== null && res.owner_total !== undefined)
|
|
426
|
+
return `${res.rows} из ${res.owner_total}`;
|
|
427
|
+
const n = `${res.rows} ${pluralRu(res.rows, "строка", "строки", "строк")}`;
|
|
428
|
+
return tableGet ? `${n}, сколько всего в таблице — не посчитали` : n;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* «Кто пришёл» — со слов шлюза. Пустой вызывающий значит «ключ или токен не
|
|
432
|
+
* приняты», только если шлюз до них дошёл: у 404 неизвестной базы и у 5xx — нет.
|
|
433
|
+
*/
|
|
434
|
+
function callerOf(res) {
|
|
435
|
+
if (res.caller)
|
|
436
|
+
return AS_WORD[res.caller] ?? res.caller;
|
|
437
|
+
if (res.status < 400 || res.status === 401 || res.status === 403)
|
|
438
|
+
return "никто — ключ или токен не приняты";
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
/** Заголовки — только из списка: всё, что напечатал CLI, оседает в истории агента. */
|
|
442
|
+
function headersOf(headers) {
|
|
443
|
+
return Object.fromEntries(Object.entries(headers ?? {}).filter(([k]) => ANSWER_HEADERS.includes(k.toLowerCase())));
|
|
444
|
+
}
|
|
445
|
+
export async function dataProbeCmd(method, path, opts) {
|
|
446
|
+
const { request, expect } = await requestOf(method, path, opts);
|
|
447
|
+
const { api, org, db, ref } = await target(opts);
|
|
448
|
+
let res;
|
|
449
|
+
try {
|
|
450
|
+
res = await api.probeDataApi(org, db.id, request);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
throw refusal(err, ref) ?? err;
|
|
454
|
+
}
|
|
455
|
+
const alarm = notRolledBack(res);
|
|
456
|
+
const { body: _sent, ...shown } = request;
|
|
457
|
+
if (detectMode().json || Boolean(opts.json)) {
|
|
458
|
+
// Поля перечислены поимённо, не `...res`: лишнее поле ответа не уедет в
|
|
459
|
+
// историю переписки агента.
|
|
460
|
+
emit({
|
|
461
|
+
event: "data_probe",
|
|
462
|
+
org,
|
|
463
|
+
database: ref,
|
|
464
|
+
request: shown,
|
|
465
|
+
status: res.status,
|
|
466
|
+
elapsed_ms: res.elapsed_ms,
|
|
467
|
+
caller: res.caller ?? null,
|
|
468
|
+
rows: res.rows ?? null,
|
|
469
|
+
total: res.total ?? null,
|
|
470
|
+
owner_total: res.owner_total ?? null,
|
|
471
|
+
rollback_expected: Boolean(res.rollback_expected),
|
|
472
|
+
rolled_back: res.rolled_back === true,
|
|
473
|
+
not_rolled_back: alarm,
|
|
474
|
+
body_truncated: Boolean(res.body_truncated),
|
|
475
|
+
headers: headersOf(res.headers),
|
|
476
|
+
body: res.body ?? null,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
const tableGet = request.method === "GET" && /^\/rest\/v1\/(?!rpc\/)/.test(request.path);
|
|
481
|
+
// У `/whoami` откатывать нечего — об откате ни слова.
|
|
482
|
+
const rolledBack = Boolean(res.rollback_expected) && res.rolled_back === true;
|
|
483
|
+
const who = `${AS_WORD[request.as]}${request.user_id ? ` ${request.user_id}` : ""}`;
|
|
484
|
+
console.log(chalk.bold(`${request.method} ${request.path}`) + chalk.dim(` база «${db.name}», от имени: ${who}`));
|
|
485
|
+
const caller = callerOf(res);
|
|
486
|
+
const facts = [
|
|
487
|
+
res.status >= 400 ? chalk.red(String(res.status)) : chalk.green(String(res.status)),
|
|
488
|
+
`${Math.round(res.elapsed_ms)} мс`,
|
|
489
|
+
outOf(res, tableGet),
|
|
490
|
+
caller ? `кто пришёл: ${caller}` : null,
|
|
491
|
+
rolledBack ? "изменения откатились" : null,
|
|
492
|
+
].filter(Boolean);
|
|
493
|
+
console.log(`Ответ ${facts.join(" · ")}`);
|
|
494
|
+
if (alarm) {
|
|
495
|
+
console.log(chalk.red.bold("🚨 изменения НЕ откатились: шлюз не подтвердил откат. Проверьте данные — запрос мог их изменить"));
|
|
496
|
+
}
|
|
497
|
+
const answer = res.body === null || res.body === undefined
|
|
498
|
+
? chalk.dim("(пустое тело)")
|
|
499
|
+
: typeof res.body === "string"
|
|
500
|
+
? res.body
|
|
501
|
+
: JSON.stringify(res.body, null, 2);
|
|
502
|
+
console.log(`\n${answer}`);
|
|
503
|
+
if (res.body_truncated)
|
|
504
|
+
console.log(chalk.yellow("\nответ длиннее 64 КБ, показано начало"));
|
|
505
|
+
console.log(chalk.dim("\nПроверены права и правила строк. Ваш ключ и сайт не проверялись: ключ подставила платформа, запрос шёл не из браузера"));
|
|
506
|
+
if (rolledBack)
|
|
507
|
+
console.log(chalk.dim(`Не откатываются: ${NOT_UNDONE}`));
|
|
508
|
+
}
|
|
509
|
+
const failure = verdict(res, request, expect, db.name, ref);
|
|
510
|
+
if (failure)
|
|
511
|
+
throw failure;
|
|
512
|
+
}
|
|
513
|
+
/** Регистрация `layero data probe`. Зовётся из `registerDataApiCommands`. */
|
|
514
|
+
export function registerDataProbeCommand(data, withDb, json) {
|
|
515
|
+
const collect = (value, prev = []) => [...prev, value];
|
|
516
|
+
withDb(data
|
|
517
|
+
.command("probe <method> <path>")
|
|
518
|
+
.description("Проба метода настоящим запросом через шлюз: права и правила строк настоящие, запись откатывается.")
|
|
519
|
+
.option("--as <who>", "от чьего имени: visitor — посетитель (по умолчанию), user — вошедший, server — сервер")
|
|
520
|
+
.option("--user <id>", "для --as user: id пользователя приложения (UUID)")
|
|
521
|
+
.option("--query <name=value>", "параметр запроса в синтаксисе PostgREST; флаг повторяется", collect)
|
|
522
|
+
.option("--body <json>", "тело POST и PATCH: объект или массив JSON")
|
|
523
|
+
.option("--body-file <file>", "тело из файла с JSON")
|
|
524
|
+
.option("--schema <schema>", "схема таблицы: api, public или app; без флага шлюз ищет в api, затем в public, затем в app")
|
|
525
|
+
.option("--expect <status>", "ожидаемый статус: 200, 2xx или несколько через запятую; не совпал — ошибка")
|
|
526
|
+
.addHelpText("after", "\nПути: /rest/v1/<таблица>, /rest/v1/rpc/<функция> (GET или POST), /whoami (только GET).\n" +
|
|
527
|
+
"Параметры — флагами --query, не через «?» в пути.\n" +
|
|
528
|
+
"\nПримеры:\n" +
|
|
529
|
+
" $ layero data probe GET /rest/v1/products --query select=id,title --query limit=5\n" +
|
|
530
|
+
" $ layero data probe POST /rest/v1/cart --as user --user <id> --body '{\"qty\":1}'\n" +
|
|
531
|
+
" $ layero data probe POST /rest/v1/rpc/order_create --body-file order.json --as server\n" +
|
|
532
|
+
" $ layero data probe GET /rest/v1/orders --expect 401,403 # закрыто ли посетителю\n" +
|
|
533
|
+
" $ layero data probe GET /whoami\n" +
|
|
534
|
+
`\nЗапись откатывается. Не откатываются: ${NOT_UNDONE}.\n` +
|
|
535
|
+
"\nКод выхода — по порядку:\n" +
|
|
536
|
+
" 1. откат записи не подтверждён — ошибка data_probe_not_rolled_back;\n" +
|
|
537
|
+
" 2. статус совпал с --expect — 0;\n" +
|
|
538
|
+
" 3. шлюз ответил 5xx — ошибка data_probe_gateway_failed;\n" +
|
|
539
|
+
" 4. статус не совпал с --expect — ошибка data_probe_unexpected_status;\n" +
|
|
540
|
+
" 5. иначе 0, в том числе на отказ 4xx: 401, 403, 404 — ответ пробы, а не сбой.")).action(async (method, path, opts) => dataProbeCmd(method, path, json(opts)));
|
|
541
|
+
}
|
package/dist/commands/data.js
CHANGED
|
@@ -12,7 +12,10 @@ async function projectRef(api, opts) {
|
|
|
12
12
|
if (!ref) {
|
|
13
13
|
throw new LayeroError("project_unknown", "не понятно, у какого проекта брать переменные Data API", "запусти из каталога проекта или передай --project <id|slug>");
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
// По идентификатору ИЛИ по слагу — ровно как обещает справка команды.
|
|
16
|
+
// До 09.09 слаг уезжал в путь ручки как есть и возвращался 422
|
|
17
|
+
// `uuid_parsing`, который CLI показывал как «internal, сообщите об ошибке».
|
|
18
|
+
const p = await api.resolveProject(ref);
|
|
16
19
|
return { id: p.id, slug: p.slug };
|
|
17
20
|
}
|
|
18
21
|
/**
|