layero 0.9.5 → 0.10.1
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 +125 -0
- package/dist/bin/layero.js +92 -33
- package/dist/commands/claim.js +141 -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 +2 -2
- package/dist/commands/deploy.js +80 -10
- 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/package.json +10 -5
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// `layero data keys | origins | methods | grant | enable` — раздел «API» панели
|
|
2
|
+
// из терминала (T-20260911-9).
|
|
3
|
+
//
|
|
4
|
+
// 🚨 Зачем. У CLI по Data API была одна команда — `data env`. Выпустить ключ при
|
|
5
|
+
// заведении проекта, открыть таблицу посетителям, пустить домен сайта — всё это
|
|
6
|
+
// делалось только кнопками, то есть из скриптов и CI не делалось вовсе.
|
|
7
|
+
//
|
|
8
|
+
// 🚨 УРОВНИ ДОСТУПА СЧИТАЕТ СЕРВЕР. `data grant` не собирает SQL сам: команды
|
|
9
|
+
// приходят из той же ручки, что у панели (`userdb_api_levels`), и применяются
|
|
10
|
+
// со сверкой «выполняется ровно показанное». Своя сборка в CLI была бы ещё
|
|
11
|
+
// одной копией правил прав и расходилась бы с кнопкой молча.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ Без `--yes` вне терминала `data grant`, отзыв ключа и удаление сайта
|
|
14
|
+
// ничего не меняют и завершаются ОШИБКОЙ `confirmation_required`. Код 0 здесь
|
|
15
|
+
// значил бы зелёный CI-скрипт, который ничего не открыл (ревью 13.09).
|
|
16
|
+
import readline from "node:readline/promises";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { ApiClient } from "../api.js";
|
|
19
|
+
import { loadConfig } from "../config.js";
|
|
20
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
21
|
+
import { orgOf, pick } from "./db.js";
|
|
22
|
+
import { registerDataProbeCommand } from "./data-probe.js";
|
|
23
|
+
const LEVELS = ["closed", "visitor", "user", "server"];
|
|
24
|
+
const LEVEL_WORD = {
|
|
25
|
+
closed: "закрыто",
|
|
26
|
+
visitor: "любой посетитель",
|
|
27
|
+
user: "вошедшие",
|
|
28
|
+
server: "только сервер",
|
|
29
|
+
};
|
|
30
|
+
const KEY_EXPIRY_DAYS = [30, 90, 365];
|
|
31
|
+
function asJson(opts) {
|
|
32
|
+
return detectMode().json || Boolean(opts.json);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* База, с которой работает команда.
|
|
36
|
+
*
|
|
37
|
+
* Без `--db` — единственная подходящая база организации: с включённым Data API
|
|
38
|
+
* для работы с ним и без него — для `enable`. Из нескольких не угадываем: ключ,
|
|
39
|
+
* выпущенный не той базе, выглядит рабочим до первого запроса сайта.
|
|
40
|
+
*/
|
|
41
|
+
export async function target(opts, purpose = "api") {
|
|
42
|
+
const api = new ApiClient(await loadConfig());
|
|
43
|
+
const org = await orgOf(api, opts);
|
|
44
|
+
let db;
|
|
45
|
+
if (opts.db) {
|
|
46
|
+
db = await pick(api, org, opts.db);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const list = await api.listDatabases(org);
|
|
50
|
+
// Для включения — только базы Layero в работе: внешнюю и остановленную
|
|
51
|
+
// сервер всё равно не включит, а «единственная подходящая» не должна
|
|
52
|
+
// оказаться ею (ревью 13.09).
|
|
53
|
+
const candidates = list.filter((d) => purpose === "api"
|
|
54
|
+
? d.api_enabled
|
|
55
|
+
: !d.api_enabled && d.status === "active" && d.placement !== "external" && d.provider !== "external");
|
|
56
|
+
if (candidates.length !== 1) {
|
|
57
|
+
throw new LayeroError("database_unknown", candidates.length
|
|
58
|
+
? "не понятно, с какой базой работать"
|
|
59
|
+
: purpose === "api"
|
|
60
|
+
? `в организации «${org}» нет баз с включённым Data API`
|
|
61
|
+
: `в организации «${org}» нет баз без Data API`, candidates.length
|
|
62
|
+
? `укажите --db: ${candidates.map((d) => d.name_slug ?? d.name).join(", ")}`
|
|
63
|
+
: purpose === "api"
|
|
64
|
+
? "включите: layero data enable --db <база>"
|
|
65
|
+
: "укажите базу явно: --db <база>");
|
|
66
|
+
}
|
|
67
|
+
db = candidates[0];
|
|
68
|
+
}
|
|
69
|
+
if (purpose === "api" && !db.api_enabled) {
|
|
70
|
+
throw new LayeroError("data_api_disabled", `у базы «${db.name}» не включён Data API`, `включите: layero data enable --db ${db.name_slug ?? db.id}`);
|
|
71
|
+
}
|
|
72
|
+
return { api, org, db, ref: db.name_slug ?? db.id };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Согласие человека. `null` — спросить некого: не терминал или `--json`.
|
|
76
|
+
*
|
|
77
|
+
* В `--json` не спрашиваем даже в терминале: вопрос уехал бы в stdout и
|
|
78
|
+
* сломал бы разбор событий.
|
|
79
|
+
*/
|
|
80
|
+
async function confirmed(question, opts) {
|
|
81
|
+
if (opts.yes)
|
|
82
|
+
return true;
|
|
83
|
+
const mode = detectMode();
|
|
84
|
+
if (!mode.interactive || asJson(opts))
|
|
85
|
+
return null;
|
|
86
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
87
|
+
try {
|
|
88
|
+
const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
|
|
89
|
+
return answer === "y" || answer === "yes";
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
rl.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Аргумент подсказки, которую копируют в shell.
|
|
97
|
+
*
|
|
98
|
+
* `api.pick(integer)` без кавычек zsh читает как шаблон имён файлов и отвечает
|
|
99
|
+
* «no matches found», bash — синтаксической ошибкой (ревью 13.09).
|
|
100
|
+
*/
|
|
101
|
+
export function shellArg(value) {
|
|
102
|
+
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
103
|
+
}
|
|
104
|
+
function _capitalize(text) {
|
|
105
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
106
|
+
}
|
|
107
|
+
function day(value) {
|
|
108
|
+
return value ? value.slice(0, 10) : "";
|
|
109
|
+
}
|
|
110
|
+
// ── ключи ───────────────────────────────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Строка списка ключей — поля перечислены поимённо.
|
|
113
|
+
*
|
|
114
|
+
* 🚨 Не `...row`: ответ сервера может однажды принести лишнее поле, и всё, что
|
|
115
|
+
* напечатал CLI в агентском режиме, оседает в истории переписки.
|
|
116
|
+
*/
|
|
117
|
+
function keyRow(k) {
|
|
118
|
+
return {
|
|
119
|
+
id: k.id,
|
|
120
|
+
kind: (k.is_public ? "public" : "secret"),
|
|
121
|
+
prefix: k.key_prefix,
|
|
122
|
+
label: k.label ?? null,
|
|
123
|
+
created_at: k.created_at ?? null,
|
|
124
|
+
last_used_at: k.last_used_at ?? null,
|
|
125
|
+
expires_at: k.expires_at ?? null,
|
|
126
|
+
in_build: Boolean(k.in_build),
|
|
127
|
+
service: Boolean(k.is_service),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function dataKeysListCmd(opts) {
|
|
131
|
+
const { api, org, db, ref } = await target(opts);
|
|
132
|
+
const keys = (await api.listDataKeys(org, db.id)).map(keyRow);
|
|
133
|
+
if (asJson(opts)) {
|
|
134
|
+
emit({ event: "data_keys", org, database: ref, keys });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (!keys.length) {
|
|
138
|
+
console.log(chalk.dim(`у базы «${db.name}» нет ключей — layero data keys issue --db ${ref}`));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const k of keys) {
|
|
142
|
+
const kind = k.kind === "public" ? "публичный" : chalk.yellow("секретный");
|
|
143
|
+
const marks = [
|
|
144
|
+
k.in_build ? chalk.green("в сборке") : "",
|
|
145
|
+
k.service ? chalk.dim("служебный") : "",
|
|
146
|
+
].filter(Boolean);
|
|
147
|
+
console.log(`${chalk.bold(`${k.prefix}…`)} ${kind} ${k.label ?? ""} ${marks.join(" ")}\n` +
|
|
148
|
+
chalk.dim(` срок: ${k.expires_at ? `до ${day(k.expires_at)}` : "без срока"}` +
|
|
149
|
+
` · последний вызов: ${k.last_used_at ? day(k.last_used_at) : "не было"}` +
|
|
150
|
+
` · ${k.id}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function dataKeysIssueCmd(opts) {
|
|
154
|
+
const kind = (opts.kind ?? "public").toLowerCase();
|
|
155
|
+
if (kind !== "public" && kind !== "secret") {
|
|
156
|
+
throw new LayeroError("data_key_kind", `вид ключа «${opts.kind}» неизвестен`, "--kind public — для сайта, --kind secret — для сервера");
|
|
157
|
+
}
|
|
158
|
+
let days = null;
|
|
159
|
+
const expiry = String(opts.expiresIn ?? "never").trim().toLowerCase();
|
|
160
|
+
if (expiry !== "never") {
|
|
161
|
+
days = Number(expiry.replace(/d$/, ""));
|
|
162
|
+
if (!KEY_EXPIRY_DAYS.includes(days)) {
|
|
163
|
+
throw new LayeroError("data_key_expiry", `срок ключа «${opts.expiresIn}» не поддерживается`, `--expires-in ${KEY_EXPIRY_DAYS.join(", ")} или never`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const { api, org, db, ref } = await target(opts);
|
|
167
|
+
const issued = await api.issueDataKey(org, db.id, {
|
|
168
|
+
kind,
|
|
169
|
+
label: opts.label ?? null,
|
|
170
|
+
expires_in_days: days,
|
|
171
|
+
});
|
|
172
|
+
if (asJson(opts)) {
|
|
173
|
+
emit({
|
|
174
|
+
event: "data_key_issued",
|
|
175
|
+
org,
|
|
176
|
+
database: ref,
|
|
177
|
+
id: issued.id,
|
|
178
|
+
kind,
|
|
179
|
+
prefix: issued.prefix,
|
|
180
|
+
key: issued.key,
|
|
181
|
+
expires_at: issued.expires_at ?? null,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log(`${chalk.green("✓")} ${kind === "public" ? "публичный" : "секретный"} ключ базы «${db.name}»` +
|
|
186
|
+
`${days ? ` на ${days} дней` : ""}\n\n ${issued.key}\n`);
|
|
187
|
+
console.log(kind === "secret"
|
|
188
|
+
? chalk.yellow(" Сохраните его сейчас: секретный ключ платформа не хранит и больше не покажет.\n") +
|
|
189
|
+
chalk.dim(" Он для сервера — в код сайта и в браузер его не кладут.")
|
|
190
|
+
: chalk.dim(" Публичный ключ уезжает в сборку сайта и секретом не является."));
|
|
191
|
+
}
|
|
192
|
+
export async function dataKeysRevokeCmd(id, opts) {
|
|
193
|
+
const { api, org, db, ref } = await target(opts);
|
|
194
|
+
const rows = await api.listDataKeys(org, db.id);
|
|
195
|
+
const needle = id.replace(/…$/, "");
|
|
196
|
+
const matches = rows.filter((k) => k.id === needle || k.key_prefix === needle);
|
|
197
|
+
if (!matches.length) {
|
|
198
|
+
throw new LayeroError("data_key_unknown", `у базы «${db.name}» нет действующего ключа ${id}`, `список: layero data keys list --db ${ref}`);
|
|
199
|
+
}
|
|
200
|
+
// В префиксе всего несколько случайных знаков: молча взять первый из двух
|
|
201
|
+
// значило бы отозвать не тот ключ.
|
|
202
|
+
if (matches.length > 1) {
|
|
203
|
+
throw new LayeroError("data_key_ambiguous", `префикс ${needle} есть у нескольких ключей базы «${db.name}»`, `укажите id: ${matches.map((k) => k.id).join(", ")}`);
|
|
204
|
+
}
|
|
205
|
+
const key = matches[0];
|
|
206
|
+
const what = `${key.is_public ? "публичный" : "секретный"} ключ ${key.key_prefix}…`;
|
|
207
|
+
const warn = key.in_build ? " Он в сборке сайта: сайт перестанет получать данные." : "";
|
|
208
|
+
const ok = await confirmed(`отозвать ${what}?${warn}`, opts);
|
|
209
|
+
if (ok === null) {
|
|
210
|
+
throw new LayeroError("confirmation_required", `отзыв ключа ${key.key_prefix}… не подтверждён.${warn}`, `убедитесь, что им никто не пользуется, и повторите с --yes: layero data keys revoke ${id} --db ${ref} --yes`);
|
|
211
|
+
}
|
|
212
|
+
if (!ok) {
|
|
213
|
+
console.log(chalk.yellow("отменено."));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await api.revokeDataKey(org, db.id, key.id);
|
|
217
|
+
if (asJson(opts)) {
|
|
218
|
+
emit({ event: "data_key_revoked", org, database: ref, id: key.id });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
console.log(`${chalk.green("✓")} ${what} отозван`);
|
|
222
|
+
}
|
|
223
|
+
// ── сайты ───────────────────────────────────────────────────────────────────
|
|
224
|
+
export async function dataOriginsListCmd(opts) {
|
|
225
|
+
const { api, org, db, ref } = await target(opts);
|
|
226
|
+
const res = await api.listDataOrigins(org, db.id);
|
|
227
|
+
const origins = (res.origins ?? []).map((o) => ({ origin: o.origin, note: o.note ?? null }));
|
|
228
|
+
const fromProjects = res.from_projects ?? [];
|
|
229
|
+
const localhost = res.localhost_allowed !== false;
|
|
230
|
+
if (asJson(opts)) {
|
|
231
|
+
emit({
|
|
232
|
+
event: "data_origins",
|
|
233
|
+
org,
|
|
234
|
+
database: ref,
|
|
235
|
+
origins,
|
|
236
|
+
from_projects: fromProjects,
|
|
237
|
+
localhost_allowed: localhost,
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
console.log(chalk.bold("Адреса подключённых проектов") + chalk.dim(" — пускаются сами"));
|
|
242
|
+
if (fromProjects.length)
|
|
243
|
+
for (const o of fromProjects)
|
|
244
|
+
console.log(` ${o}`);
|
|
245
|
+
else
|
|
246
|
+
console.log(chalk.dim(" нет — подключите проект: layero db connect <база>"));
|
|
247
|
+
console.log(chalk.bold("\nДобавленные вручную"));
|
|
248
|
+
if (origins.length)
|
|
249
|
+
for (const o of origins)
|
|
250
|
+
console.log(` ${o.origin}${o.note ? chalk.dim(` ${o.note}`) : ""}`);
|
|
251
|
+
else
|
|
252
|
+
console.log(chalk.dim(` нет — добавить: layero data origins add https://example.ru --db ${ref}`));
|
|
253
|
+
console.log(`\nlocalhost: ${localhost ? "пускается" : chalk.yellow("не пускается")}`);
|
|
254
|
+
}
|
|
255
|
+
export async function dataOriginsAddCmd(origin, opts) {
|
|
256
|
+
const { api, org, db, ref } = await target(opts);
|
|
257
|
+
await api.addDataOrigin(org, db.id, origin, opts.note ?? null);
|
|
258
|
+
if (asJson(opts)) {
|
|
259
|
+
emit({ event: "data_origin_added", org, database: ref, origin });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
console.log(`${chalk.green("✓")} ${origin} может звать базу «${db.name}» из браузера\n` +
|
|
263
|
+
chalk.dim(" данных это не открывает: что можно читать и писать, решают уровни — layero data methods"));
|
|
264
|
+
}
|
|
265
|
+
export async function dataOriginsRemoveCmd(origin, opts) {
|
|
266
|
+
const { api, org, db, ref } = await target(opts);
|
|
267
|
+
const ok = await confirmed(`убрать ${origin}? Запросы с этого сайта начнут получать отказ`, opts);
|
|
268
|
+
if (ok === null) {
|
|
269
|
+
throw new LayeroError("confirmation_required", `удаление ${origin} не подтверждено: запросы с этого сайта начнут получать отказ`, `повторите с --yes: layero data origins remove ${origin} --db ${ref} --yes`);
|
|
270
|
+
}
|
|
271
|
+
if (!ok) {
|
|
272
|
+
console.log(chalk.yellow("отменено."));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await api.removeDataOrigin(org, db.id, origin);
|
|
276
|
+
if (asJson(opts)) {
|
|
277
|
+
emit({ event: "data_origin_removed", org, database: ref, origin });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
console.log(`${chalk.green("✓")} ${origin} убран`);
|
|
281
|
+
}
|
|
282
|
+
// ── методы и уровни ─────────────────────────────────────────────────────────
|
|
283
|
+
export async function dataMethodsCmd(opts) {
|
|
284
|
+
const { api, org, db, ref } = await target(opts);
|
|
285
|
+
const res = await api.listDataMethods(org, db.id);
|
|
286
|
+
const warnings = res.warnings ?? [];
|
|
287
|
+
if (asJson(opts)) {
|
|
288
|
+
emit({ event: "data_methods", org, database: ref, warnings, tables: res.tables, functions: res.functions });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
for (const w of warnings)
|
|
292
|
+
console.log(chalk.yellow(`⚠ ${w}\n`));
|
|
293
|
+
if (!res.tables.length && !res.functions.length) {
|
|
294
|
+
console.log(chalk.dim(`в базе «${db.name}» нет таблиц и функций, которые отдаёт Data API`));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (res.tables.length)
|
|
298
|
+
console.log(chalk.bold("REST"));
|
|
299
|
+
for (const t of res.tables) {
|
|
300
|
+
const levels = Object.entries(t.levels)
|
|
301
|
+
.map(([m, l]) => `${m} ${l === "closed" ? chalk.dim(LEVEL_WORD[l]) : LEVEL_WORD[l] ?? l}`)
|
|
302
|
+
.join(" · ");
|
|
303
|
+
const view = t.kind === "view" ? chalk.dim(" (представление)") : "";
|
|
304
|
+
const profile = t.shadowed_by
|
|
305
|
+
? chalk.yellow(` без заголовка Accept-Profile: ${t.schema} путь ведёт на ${t.shadowed_by}`)
|
|
306
|
+
: "";
|
|
307
|
+
console.log(` ${t.schema}.${t.name}${view} ${chalk.dim(t.path)}${profile}\n ${levels}`);
|
|
308
|
+
}
|
|
309
|
+
if (res.functions.length)
|
|
310
|
+
console.log(chalk.bold(`${res.tables.length ? "\n" : ""}RPC`));
|
|
311
|
+
for (const f of res.functions) {
|
|
312
|
+
// Причина «не метод» — по виду: процедура лежит в api, но шлюз её не вызывает;
|
|
313
|
+
// либо она затенена одноимённой функцией схемы раньше по составу (shadowed_by).
|
|
314
|
+
const where = chalk.dim(f.path ??
|
|
315
|
+
(f.kind === "procedure"
|
|
316
|
+
? "не метод: процедуру шлюз не вызывает — только функции"
|
|
317
|
+
: f.shadowed_by
|
|
318
|
+
? `не метод: по этому имени шлюз зовёт ${f.shadowed_by}`
|
|
319
|
+
: "не метод: шлюз зовёт функции только из схемы api"));
|
|
320
|
+
const pub = f.public_only ? chalk.yellow(" доступна всем по умолчанию Postgres — шлюз её не пустит") : "";
|
|
321
|
+
const twin = f.overloaded
|
|
322
|
+
? chalk.yellow(" в схеме api есть одноимённая функция или процедура — шлюз ищет по имени и может вызвать другую")
|
|
323
|
+
: "";
|
|
324
|
+
console.log(` ${f.signature} ${where}\n POST ${LEVEL_WORD[f.level] ?? f.level}${pub}${twin}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function checkLevel(flag, value) {
|
|
328
|
+
const v = String(value).trim().toLowerCase();
|
|
329
|
+
if (!LEVELS.includes(v)) {
|
|
330
|
+
throw new LayeroError("data_level_unknown", `уровень «${value}» у ${flag} неизвестен`, "closed — закрыто, visitor — любой посетитель, user — вошедшие, server — только сервер");
|
|
331
|
+
}
|
|
332
|
+
return v;
|
|
333
|
+
}
|
|
334
|
+
function planLines(plan) {
|
|
335
|
+
const o = plan.object;
|
|
336
|
+
const name = o.kind === "function" || o.kind === "procedure"
|
|
337
|
+
? `${o.schema}.${o.name}(${o.args ?? ""})`
|
|
338
|
+
: `${o.schema}.${o.name}`;
|
|
339
|
+
const out = [chalk.bold(name)];
|
|
340
|
+
for (const [method, to] of Object.entries(plan.next)) {
|
|
341
|
+
const from = plan.current[method];
|
|
342
|
+
out.push(` ${method.padEnd(6)} ${from === to ? LEVEL_WORD[to] : `${LEVEL_WORD[from ?? ""] ?? from} → ${chalk.bold(LEVEL_WORD[to] ?? to)}`}`);
|
|
343
|
+
}
|
|
344
|
+
out.push(chalk.dim("\nSQL:"), ...plan.sql.map((s) => ` ${s}`));
|
|
345
|
+
// Причины блокировки печатает сам отказ — здесь их не повторяем.
|
|
346
|
+
const blocked = new Set(plan.blocked ?? []);
|
|
347
|
+
for (const w of plan.warnings)
|
|
348
|
+
if (!blocked.has(w))
|
|
349
|
+
out.push(chalk.yellow(`⚠ ${w}`));
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
function planEvent(org, ref, plan, applied) {
|
|
353
|
+
return {
|
|
354
|
+
event: "data_grant",
|
|
355
|
+
org,
|
|
356
|
+
database: ref,
|
|
357
|
+
object: plan.object,
|
|
358
|
+
current: plan.current,
|
|
359
|
+
next: plan.next,
|
|
360
|
+
sql: plan.sql,
|
|
361
|
+
warnings: plan.warnings,
|
|
362
|
+
blocked: plan.blocked ?? [],
|
|
363
|
+
applied,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
export async function dataGrantCmd(object, opts) {
|
|
367
|
+
const levels = {};
|
|
368
|
+
for (const [flag, method] of [["get", "GET"], ["post", "POST"], ["patch", "PATCH"], ["delete", "DELETE"]]) {
|
|
369
|
+
const value = opts[flag];
|
|
370
|
+
if (value !== undefined)
|
|
371
|
+
levels[method] = checkLevel(`--${flag}`, value);
|
|
372
|
+
}
|
|
373
|
+
const level = opts.call !== undefined ? checkLevel("--call", opts.call) : undefined;
|
|
374
|
+
if (!Object.keys(levels).length && level === undefined) {
|
|
375
|
+
throw new LayeroError("data_levels_missing", "не задан ни один уровень", "таблица: --get visitor --post server …; функция: --call visitor");
|
|
376
|
+
}
|
|
377
|
+
const { api, org, db, ref } = await target(opts);
|
|
378
|
+
const request = {
|
|
379
|
+
object,
|
|
380
|
+
levels: Object.keys(levels).length ? levels : null,
|
|
381
|
+
level: level ?? null,
|
|
382
|
+
};
|
|
383
|
+
const flags = [
|
|
384
|
+
...Object.entries(levels).map(([method, value]) => `--${method.toLowerCase()} ${value}`),
|
|
385
|
+
...(level ? [`--call ${level}`] : []),
|
|
386
|
+
].join(" ");
|
|
387
|
+
const retry = `layero data grant ${shellArg(object)} --db ${shellArg(ref)} ${flags} --yes`;
|
|
388
|
+
const plan = await api.setDataLevels(org, db.id, { ...request, apply: false });
|
|
389
|
+
const json = asJson(opts);
|
|
390
|
+
const blocked = plan.blocked ?? [];
|
|
391
|
+
// 🚨 ПУСТЫХ КОМАНД У ТАБЛИЦЫ НЕ БЫВАЕТ: сервер пишет итоговое состояние
|
|
392
|
+
// целиком, и при уже выданном уровне команды повторяют выданное. Менять ли
|
|
393
|
+
// права, говорит `changes` — сверка прав в базе. Спрашивать «применить?» здесь
|
|
394
|
+
// значило бы учить соглашаться не глядя (T-20260914-9). Нет поля — старый
|
|
395
|
+
// сервер: спросить, как раньше.
|
|
396
|
+
if (plan.changes === false && !blocked.length) {
|
|
397
|
+
if (json) {
|
|
398
|
+
emit({
|
|
399
|
+
...planEvent(org, ref, plan, false),
|
|
400
|
+
next_action: `менять нечего: доступ уже такой; текущие уровни: layero data methods --db ${shellArg(ref)}`,
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
console.log(`${chalk.green("✓")} доступ уже такой: ${object} — менять нечего`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!json && (!opts.yes || blocked.length))
|
|
408
|
+
console.log(planLines(plan).join("\n"));
|
|
409
|
+
// 🚨 БЛОКИРОВКУ СЕРВЕР ПРИМЕНИТЬ НЕ ДАСТ. Спрашивать «применить?» и слать
|
|
410
|
+
// применение с `--yes` значило бы задать вопрос с известным ответом и получить
|
|
411
|
+
// http_409 с подсказкой «исправьте запрос и повторите» (ревью 13.09).
|
|
412
|
+
if (blocked.length) {
|
|
413
|
+
if (json)
|
|
414
|
+
emit(planEvent(org, ref, plan, false));
|
|
415
|
+
throw new LayeroError("data_levels_blocked", `уровень доступа нельзя применить: ${blocked.join(" ")}`, `измените запрос по тексту отказа; текущие уровни: layero data methods --db ${shellArg(ref)}`);
|
|
416
|
+
}
|
|
417
|
+
const ok = await confirmed("\nприменить?", opts);
|
|
418
|
+
if (ok === null) {
|
|
419
|
+
if (json) {
|
|
420
|
+
emit({
|
|
421
|
+
...planEvent(org, ref, plan, false),
|
|
422
|
+
next_action: `проверьте команды и предупреждения; применить: ${retry}`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
throw new LayeroError("confirmation_required", "уровень доступа не применён: команды нужно подтвердить", `проверьте команды и предупреждения и повторите: ${retry}`);
|
|
426
|
+
}
|
|
427
|
+
if (!ok) {
|
|
428
|
+
console.log(chalk.yellow("отменено."));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// `expected_sql` — сервер применит, только если команды не изменились с показа.
|
|
432
|
+
const done = await api.setDataLevels(org, db.id, { ...request, apply: true, expected_sql: plan.sql });
|
|
433
|
+
if (json) {
|
|
434
|
+
emit(planEvent(org, ref, done, true));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (opts.yes)
|
|
438
|
+
console.log(planLines(done).join("\n"));
|
|
439
|
+
console.log(`\n${chalk.green("✓")} доступ изменён`);
|
|
440
|
+
}
|
|
441
|
+
// Что снимает переприменение: агент узла при каждом включении API отзывает у
|
|
442
|
+
// ролей Data API всё в схеме `public` (`runtime/userdb-agent/agent.py`, `api_enable`).
|
|
443
|
+
const REPAIR_LOSS = "роли Data API потеряют права на схему public (USAGE) и на все её таблицы";
|
|
444
|
+
/**
|
|
445
|
+
* Включение Data API.
|
|
446
|
+
*
|
|
447
|
+
* 🚨 У БАЗЫ, ГДЕ API УЖЕ ВКЛЮЧЁН, — ОТКАЗ. Сервер включение повторяет, а агент
|
|
448
|
+
* узла при каждом включении снимает у ролей права на схему `public` и её таблицы:
|
|
449
|
+
* `data enable --db X` «на всякий случай» молча закрывал открытые уровни (ревью
|
|
450
|
+
* 13.09, 5-й круг).
|
|
451
|
+
*
|
|
452
|
+
* `--repair` оставлен осознанно: повторное включение — единственный путь вернуть
|
|
453
|
+
* роли, схему `api` и права на очередь, если их удалили или испортили руками, и
|
|
454
|
+
* именно его советуют отказы «роли Data API в базе нет — включите API заново». Он
|
|
455
|
+
* требует явного `--db` и подтверждения с предупреждением о `public`.
|
|
456
|
+
*/
|
|
457
|
+
export async function dataEnableCmd(opts) {
|
|
458
|
+
if (opts.repair && !opts.db) {
|
|
459
|
+
throw new LayeroError("database_unknown", `переприменение Data API снимает права ролей на схему public — базу нужно указать явно`, "layero data enable --db <база> --repair");
|
|
460
|
+
}
|
|
461
|
+
const { api, org, db, ref } = await target(opts, "enable");
|
|
462
|
+
if (db.api_enabled && !opts.repair) {
|
|
463
|
+
throw new LayeroError("data_api_already_enabled", `у базы «${db.name}» Data API уже включён — повторное включение ничего не выпускает, а ${REPAIR_LOSS}`,
|
|
464
|
+
// `--with-secret` здесь ничего не выпустит: секретный ключ выпускается отдельной командой.
|
|
465
|
+
(opts.withSecret ? `секретный ключ: layero data keys issue --db ${shellArg(ref)} --kind secret; ` : "") +
|
|
466
|
+
`ключи: layero data keys list --db ${shellArg(ref)}; методы: layero data methods --db ${shellArg(ref)}; ` +
|
|
467
|
+
`переприменить роли и схему api: layero data enable --db ${shellArg(ref)} --repair`);
|
|
468
|
+
}
|
|
469
|
+
if (opts.repair) {
|
|
470
|
+
if (!db.api_enabled) {
|
|
471
|
+
throw new LayeroError("data_api_disabled", `у базы «${db.name}» не включён Data API — переприменять нечего`, `включите: layero data enable --db ${shellArg(ref)}`);
|
|
472
|
+
}
|
|
473
|
+
const ok = await confirmed(`переприменить Data API у базы «${db.name}»? ${_capitalize(REPAIR_LOSS)}`, opts);
|
|
474
|
+
if (ok === null) {
|
|
475
|
+
throw new LayeroError("confirmation_required", `переприменение Data API не подтверждено: ${REPAIR_LOSS}`, `если роли или схема api повреждены, повторите с --yes: layero data enable --db ${shellArg(ref)} --repair --yes`);
|
|
476
|
+
}
|
|
477
|
+
if (!ok) {
|
|
478
|
+
console.log(chalk.yellow("отменено."));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const res = await api.enableDataApi(org, db.id, Boolean(opts.withSecret));
|
|
483
|
+
const publicKey = res.key?.key ?? null;
|
|
484
|
+
const secretKey = res.secret_key?.key ?? null;
|
|
485
|
+
const reapplied = Boolean(opts.repair);
|
|
486
|
+
if (asJson(opts)) {
|
|
487
|
+
emit({
|
|
488
|
+
event: "data_api_enabled",
|
|
489
|
+
org,
|
|
490
|
+
database: ref,
|
|
491
|
+
slug: res.slug,
|
|
492
|
+
public_key: publicKey,
|
|
493
|
+
secret_key: secretKey,
|
|
494
|
+
reapplied,
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// После переприменения — уже случившееся, а не будущее, и не «всё закрыто»:
|
|
499
|
+
// агент снимает права только в `public`, уровни в `api` и `app` остаются.
|
|
500
|
+
console.log(reapplied
|
|
501
|
+
? `${chalk.green("✓")} Data API переприменён у базы «${db.name}»\n` +
|
|
502
|
+
chalk.yellow(" Роли Data API лишились прав на схему public (USAGE) и на все её таблицы. " +
|
|
503
|
+
"Уровни таблиц в схемах api и app сохранились; таблицы public, если они были открыты, " +
|
|
504
|
+
"откройте заново: layero data methods, затем layero data grant")
|
|
505
|
+
: `${chalk.green("✓")} Data API включён у базы «${db.name}»`);
|
|
506
|
+
if (publicKey)
|
|
507
|
+
console.log(`\n публичный ключ: ${publicKey}`);
|
|
508
|
+
if (secretKey) {
|
|
509
|
+
console.log(` секретный ключ: ${secretKey}\n` +
|
|
510
|
+
chalk.yellow("\n Сохраните секретный ключ сейчас: платформа его не хранит и больше не покажет."));
|
|
511
|
+
}
|
|
512
|
+
if (!publicKey && !secretKey)
|
|
513
|
+
console.log(chalk.dim(` ключи уже были — список: layero data keys list --db ${ref}`));
|
|
514
|
+
if (!reapplied) {
|
|
515
|
+
console.log(chalk.dim("\n Все методы закрыты, пока вы их не откроете: layero data methods, затем layero data grant"));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Подкоманды `layero data`, кроме `env`. Отдельной функцией, а не в
|
|
520
|
+
* `bin/layero.ts`: там разбор аргументов запускается при импорте, и проверить
|
|
521
|
+
* регистрацию флагов тестом было бы нечем.
|
|
522
|
+
*/
|
|
523
|
+
export function registerDataApiCommands(data, program) {
|
|
524
|
+
const withDb = (c) => c
|
|
525
|
+
.option("--db <name>", "база: имя, слаг или id (по умолчанию — единственная подходящая)")
|
|
526
|
+
.option("--org <slug>", "организация (по умолчанию единственная)");
|
|
527
|
+
const json = (opts) => ({ ...opts, json: program.opts().json });
|
|
528
|
+
const keys = data.command("keys").description("Ключи Data API: список, выпуск, отзыв.");
|
|
529
|
+
withDb(keys.command("list").description("Ключи базы: префикс, срок, последний вызов. Значений нет."))
|
|
530
|
+
.action(async (opts) => dataKeysListCmd(json(opts)));
|
|
531
|
+
withDb(keys
|
|
532
|
+
.command("issue")
|
|
533
|
+
.description("Выпустить ключ. Значение печатается один раз.")
|
|
534
|
+
.option("--kind <kind>", "public — для сайта, secret — для сервера", "public")
|
|
535
|
+
.option("--label <text>", "подпись, по которой ключ узнают в списке")
|
|
536
|
+
.option("--expires-in <days>", "срок: 30, 90, 365 или never", "never")).action(async (opts) => dataKeysIssueCmd(json(opts)));
|
|
537
|
+
withDb(keys
|
|
538
|
+
.command("revoke <id>")
|
|
539
|
+
.description("Отозвать ключ по id или префиксу. Запросы с ним сразу получают отказ.")
|
|
540
|
+
.option("-y, --yes", "не спрашивать подтверждение")).action(async (id, opts) => dataKeysRevokeCmd(id, json(opts)));
|
|
541
|
+
const origins = data.command("origins").description("Сайты, которым можно звать базу из браузера.");
|
|
542
|
+
withDb(origins.command("list").description("Адреса проектов базы и добавленные вручную."))
|
|
543
|
+
.action(async (opts) => dataOriginsListCmd(json(opts)));
|
|
544
|
+
withDb(origins
|
|
545
|
+
.command("add <url>")
|
|
546
|
+
.description("Пустить сайт. Данных не открывает: это решают уровни доступа.")
|
|
547
|
+
.option("--note <text>", "зачем добавлен")).action(async (url, opts) => dataOriginsAddCmd(url, json(opts)));
|
|
548
|
+
withDb(origins
|
|
549
|
+
.command("remove <url>")
|
|
550
|
+
.description("Убрать сайт из списка.")
|
|
551
|
+
.option("-y, --yes", "не спрашивать подтверждение")).action(async (url, opts) => dataOriginsRemoveCmd(url, json(opts)));
|
|
552
|
+
withDb(data.command("methods").description("REST и RPC базы с уровнем доступа на каждый метод."))
|
|
553
|
+
.action(async (opts) => dataMethodsCmd(json(opts)));
|
|
554
|
+
withDb(data
|
|
555
|
+
.command("grant <object>")
|
|
556
|
+
.description("Уровень доступа к методам таблицы или функции: показывает SQL, применяет после подтверждения.")
|
|
557
|
+
.option("--get <level>", "чтение таблицы")
|
|
558
|
+
.option("--post <level>", "добавление строк")
|
|
559
|
+
.option("--patch <level>", "изменение строк")
|
|
560
|
+
.option("--delete <level>", "удаление строк")
|
|
561
|
+
.option("--call <level>", "вызов функции")
|
|
562
|
+
.option("-y, --yes", "применить без подтверждения")
|
|
563
|
+
.addHelpText("after", "\nУровни: closed — закрыто, visitor — любой посетитель, user — вошедшие, server — только сервер.\n" +
|
|
564
|
+
"Неназванные методы таблицы сохраняют текущий уровень.\n" +
|
|
565
|
+
"\nПримеры:\n" +
|
|
566
|
+
" $ layero data grant app.products --get visitor # каталог виден сайту\n" +
|
|
567
|
+
" $ layero data grant app.orders --get user --post user # заказы — вошедшим\n" +
|
|
568
|
+
" $ layero data grant api.order_create --call visitor # функция для сайта\n" +
|
|
569
|
+
" $ layero data grant 'api.pick(integer)' --call server # перегрузка — по типам\n" +
|
|
570
|
+
"\nБез --yes вне терминала команда показывает SQL, ничего не меняет и завершается ошибкой.")).action(async (object, opts) => dataGrantCmd(object, json(opts)));
|
|
571
|
+
withDb(data
|
|
572
|
+
.command("enable")
|
|
573
|
+
.description("Включить Data API у базы: схема api, роли и публичный ключ. Методы закрыты.")
|
|
574
|
+
.option("--with-secret", "выпустить и секретный ключ для сервера")
|
|
575
|
+
.option("--repair", "переприменить роли и схему api у базы с включённым Data API; снимает права ролей на схему public")
|
|
576
|
+
.option("-y, --yes", "не спрашивать подтверждение для --repair")).action(async (opts) => dataEnableCmd(json(opts)));
|
|
577
|
+
registerDataProbeCommand(data, withDb, json);
|
|
578
|
+
}
|