vkusvill 0.0.0-dev.0 → 0.0.0-dev.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/README.md +41 -10
- package/README.ru.md +41 -10
- package/package.json +16 -6
- package/src/api.js +102 -26
- package/src/app.js +75 -7
- package/src/auth.js +457 -0
- package/src/cli.js +121 -6
- package/src/main.js +26 -8
- package/src/paths.js +20 -0
- package/src/product.js +5 -3
- package/src/secrets.js +133 -0
- package/src/store.js +1 -2
- package/src/sys.js +13 -1
- package/src/ui/product-ui.js +13 -1
- package/src/ui/widgets.js +101 -0
- package/src/views/browser.js +1 -1
- package/src/views/cart.js +37 -15
- package/src/views/catalog.js +96 -46
- package/src/views/overlays.js +9 -4
- package/src/views/product.js +8 -5
- package/src/views/profile.js +317 -34
- package/src/views/recipes.js +74 -35
- package/src/views/shops.js +66 -39
package/src/auth.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// Signing in to VkusVill: OAuth 2.0 authorization code + PKCE, refresh, revocation.
|
|
2
|
+
//
|
|
3
|
+
// VkusVill has no dynamic client registration: every user registers a personal client
|
|
4
|
+
// (vkusvill.ru/oauth/registration/) and gets its credentials by email. So the client id comes
|
|
5
|
+
// from the user once, and is kept next to the tokens.
|
|
6
|
+
//
|
|
7
|
+
// - The authorization server is discovered from the MCP server (RFC 9728 → RFC 8414) and must be
|
|
8
|
+
// an https VkusVill host; nothing is ever sent elsewhere.
|
|
9
|
+
// - The code comes back to a one-shot loopback listener on 127.0.0.1, or is pasted by hand
|
|
10
|
+
// (for clients registered without a redirect URI).
|
|
11
|
+
// - Access tokens live 24 h. Refresh tokens rotate on every use and reusing an old one revokes the
|
|
12
|
+
// whole chain, so refreshes are serialised across processes (the TUI and `vv` in another
|
|
13
|
+
// terminal) with a lock file, and the token file is re-read under the lock.
|
|
14
|
+
// - The client secret and the tokens go to the OS credential store (secrets.js): the macOS Keychain
|
|
15
|
+
// or the Linux Secret Service. auth.json (owner-only) keeps the rest and a revision number, so
|
|
16
|
+
// other processes know when to read the store again. Without a store, the secrets stay in auth.json.
|
|
17
|
+
|
|
18
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, chmodSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { CONFIG_DIR } from "./paths.js";
|
|
23
|
+
import * as secrets from "./secrets.js";
|
|
24
|
+
|
|
25
|
+
// What VkusVill's registration form assigns when "my service has no public URI" is ticked
|
|
26
|
+
export const DEFAULT_REDIRECT = "http://localhost:3000/callback";
|
|
27
|
+
export const REGISTRATION_URL = "https://vkusvill.ru/oauth/registration/";
|
|
28
|
+
const MCP_ORIGIN = "https://mcp.vkusvill.ru";
|
|
29
|
+
const FILE = path.join(CONFIG_DIR, "auth.json");
|
|
30
|
+
const LOCK = path.join(CONFIG_DIR, "auth.lock");
|
|
31
|
+
const EARLY = 120e3; // refresh this long before expiry
|
|
32
|
+
const LOGIN_TIMEOUT = 5 * 60e3;
|
|
33
|
+
|
|
34
|
+
// Known-good metadata, used when discovery is unreachable
|
|
35
|
+
const FALLBACK = {
|
|
36
|
+
issuer: "https://oauth.vkusvill.ru/",
|
|
37
|
+
authorization_endpoint: "https://oauth.vkusvill.ru/oauth2/auth",
|
|
38
|
+
token_endpoint: "https://oauth.vkusvill.ru/oauth2/token",
|
|
39
|
+
revocation_endpoint: "https://oauth.vkusvill.ru/oauth2/revoke",
|
|
40
|
+
scopes: ["catalog.search", "purchase_history.read", "discounts.view", "recommendations.personalized"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Rights a client can be given, named as in VkusVill's registration form
|
|
44
|
+
export const SCOPE_LABELS = {
|
|
45
|
+
"catalog.search": "Каталог: поиск с учётом адреса и рекомендаций",
|
|
46
|
+
"purchase_history.read": "История покупок",
|
|
47
|
+
"discounts.view": "Лояльность и скидки",
|
|
48
|
+
"recommendations.personalized": "Персональные рекомендации",
|
|
49
|
+
offline_access: "Продление входа",
|
|
50
|
+
};
|
|
51
|
+
export const scopeLabel = (s) => SCOPE_LABELS[s] ?? s;
|
|
52
|
+
|
|
53
|
+
export class AuthError extends Error {
|
|
54
|
+
constructor(message, code) { super(message); this.code = code; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
58
|
+
const isVkusvillHttps = (u) => { try { const x = new URL(u); return x.protocol === "https:" && (x.hostname === "vkusvill.ru" || x.hostname.endsWith(".vkusvill.ru")); } catch { return false; } };
|
|
59
|
+
|
|
60
|
+
// ——— Discovery ———
|
|
61
|
+
|
|
62
|
+
let metaOverride = null;
|
|
63
|
+
let metaCache = null;
|
|
64
|
+
|
|
65
|
+
// For tests only: point the flow at a local mock server
|
|
66
|
+
export function _setMetadataForTests(meta) { metaOverride = meta; metaCache = null; }
|
|
67
|
+
|
|
68
|
+
async function getJSON(url) {
|
|
69
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10e3), headers: { Accept: "application/json" } });
|
|
70
|
+
if (!res.ok) throw new Error(`${res.status}`);
|
|
71
|
+
return res.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function metadata() {
|
|
75
|
+
if (metaOverride) return metaOverride;
|
|
76
|
+
if (metaCache) return metaCache;
|
|
77
|
+
try {
|
|
78
|
+
const pr = await getJSON(`${MCP_ORIGIN}/.well-known/oauth-protected-resource`);
|
|
79
|
+
const issuer = pr.authorization_servers?.[0];
|
|
80
|
+
if (!isVkusvillHttps(issuer)) throw new Error("issuer");
|
|
81
|
+
const as = await getJSON(new URL(".well-known/oauth-authorization-server", issuer.endsWith("/") ? issuer : issuer + "/"));
|
|
82
|
+
const endpoints = [as.authorization_endpoint, as.token_endpoint, as.revocation_endpoint].filter(Boolean);
|
|
83
|
+
if (as.issuer !== issuer || !endpoints.every(isVkusvillHttps)) throw new Error("metadata");
|
|
84
|
+
metaCache = { ...as, scopes: Array.isArray(pr.scopes_supported) ? pr.scopes_supported : FALLBACK.scopes };
|
|
85
|
+
} catch {
|
|
86
|
+
metaCache = FALLBACK;
|
|
87
|
+
}
|
|
88
|
+
return metaCache;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ——— Storage ———
|
|
92
|
+
|
|
93
|
+
const SECRET_FIELDS = ["clientSecret", "accessToken", "refreshToken"];
|
|
94
|
+
let vaultMemo = null; // { rev, data }: the store is asked again only when auth.json changes
|
|
95
|
+
|
|
96
|
+
export function read() {
|
|
97
|
+
let r;
|
|
98
|
+
try { r = JSON.parse(readFileSync(FILE, "utf8")); } catch { return null; }
|
|
99
|
+
if (!r || typeof r !== "object" || typeof r.clientId !== "string") return null;
|
|
100
|
+
if (r.secrets !== "store") {
|
|
101
|
+
// A file from before the store existed: move its secrets over once. A file marked "file" already
|
|
102
|
+
// tried; the next write (sign-in, refresh) tries the store again, so a store set up later is picked up
|
|
103
|
+
if (r.secrets === undefined && SECRET_FIELDS.some((k) => r[k])) { const moved = write(r); if (moved.secrets === "store") return { ...moved, ...pickSecrets(r) }; }
|
|
104
|
+
return r;
|
|
105
|
+
}
|
|
106
|
+
if (vaultMemo?.rev !== r.rev) vaultMemo = { rev: r.rev, data: secrets.load() ?? {} };
|
|
107
|
+
return { ...r, ...pickSecrets(vaultMemo.data) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pickSecrets = (o) => Object.fromEntries(SECRET_FIELDS.map((k) => [k, o?.[k] ?? null]));
|
|
111
|
+
|
|
112
|
+
// Writes metadata to auth.json and the secrets to the store when it takes them; returns what is in the file
|
|
113
|
+
function write(rec) {
|
|
114
|
+
memo = null;
|
|
115
|
+
const meta = Object.fromEntries(Object.entries(rec).filter(([k]) => !SECRET_FIELDS.includes(k) && k !== "secrets" && k !== "rev"));
|
|
116
|
+
const rev = `${Date.now().toString(36)}.${randomBytes(4).toString("hex")}`;
|
|
117
|
+
const secretPart = pickSecrets(rec);
|
|
118
|
+
let file;
|
|
119
|
+
if (secrets.save(secretPart)) {
|
|
120
|
+
file = { ...meta, secrets: "store", rev };
|
|
121
|
+
vaultMemo = { rev, data: secretPart };
|
|
122
|
+
} else {
|
|
123
|
+
file = { ...meta, ...secretPart, secrets: "file" };
|
|
124
|
+
}
|
|
125
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
126
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
127
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
|
|
128
|
+
renameSync(tmp, FILE);
|
|
129
|
+
try { chmodSync(FILE, 0o600); chmodSync(CONFIG_DIR, 0o700); } catch {}
|
|
130
|
+
return file;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cross-process lock: exclusive create; a lock older than 30 s is left over from a crash
|
|
134
|
+
async function withLock(fn) {
|
|
135
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
136
|
+
const deadline = Date.now() + 15e3;
|
|
137
|
+
for (;;) {
|
|
138
|
+
try {
|
|
139
|
+
closeSync(openSync(LOCK, "wx", 0o600));
|
|
140
|
+
break;
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (e.code !== "EEXIST") throw e;
|
|
143
|
+
try { if (Date.now() - statSync(LOCK).mtimeMs > 30e3) unlinkSync(LOCK); } catch {}
|
|
144
|
+
if (Date.now() > deadline) throw new AuthError("Вход занят другим процессом vkusvill", "lock");
|
|
145
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
try { return await fn(); } finally { try { unlinkSync(LOCK); } catch {} }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ——— Token endpoint ———
|
|
152
|
+
|
|
153
|
+
async function tokenRequest(meta, rec, params) {
|
|
154
|
+
const body = new URLSearchParams({ ...params, client_id: rec.clientId });
|
|
155
|
+
if (rec.clientSecret) body.set("client_secret", rec.clientSecret);
|
|
156
|
+
let res;
|
|
157
|
+
try {
|
|
158
|
+
res = await fetch(meta.token_endpoint, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
161
|
+
body,
|
|
162
|
+
signal: AbortSignal.timeout(20e3),
|
|
163
|
+
// A redirect would resend the client secret to wherever it points
|
|
164
|
+
redirect: "error",
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
throw new AuthError("Нет связи с сервером входа ВкусВилла", "net");
|
|
168
|
+
}
|
|
169
|
+
let data = {};
|
|
170
|
+
try { data = await res.json(); } catch {}
|
|
171
|
+
if (!res.ok || !data.access_token) {
|
|
172
|
+
const code = typeof data.error === "string" ? data.error : `http_${res.status}`;
|
|
173
|
+
throw new AuthError(describe(code), code);
|
|
174
|
+
}
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function describe(code) {
|
|
179
|
+
return {
|
|
180
|
+
invalid_grant: "Вход истёк — войдите заново",
|
|
181
|
+
invalid_client: "ВкусВилл не узнал клиента — проверьте client_id и секрет",
|
|
182
|
+
unauthorized_client: "Клиенту не разрешён этот способ входа",
|
|
183
|
+
invalid_scope: "Клиенту не выданы запрошенные права",
|
|
184
|
+
access_denied: "Вход отменён",
|
|
185
|
+
}[code] ?? `Сервер входа ответил: ${code}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `fresh`: a new sign-in replaces the whole grant; a refresh without a new refresh token keeps the old one
|
|
189
|
+
function store(rec, data, { fresh = false } = {}) {
|
|
190
|
+
const out = {
|
|
191
|
+
...rec,
|
|
192
|
+
accessToken: data.access_token,
|
|
193
|
+
refreshToken: data.refresh_token ?? (fresh ? null : rec.refreshToken ?? null),
|
|
194
|
+
expiresAt: Date.now() + (Number(data.expires_in) > 0 ? Number(data.expires_in) * 1000 : 24 * 3600e3),
|
|
195
|
+
scope: typeof data.scope === "string" ? data.scope : rec.scope ?? "",
|
|
196
|
+
};
|
|
197
|
+
write(out);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ——— Public API ———
|
|
202
|
+
|
|
203
|
+
// For the UI, which asks on every frame: re-read at most once a second (other processes may sign in)
|
|
204
|
+
let memo = null;
|
|
205
|
+
export function cachedStatus() {
|
|
206
|
+
if (!memo || Date.now() - memo.at > 1000) memo = { at: Date.now(), value: status() };
|
|
207
|
+
return memo.value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function status() {
|
|
211
|
+
if (process.env.VV_TOKEN) return { signedIn: true, viaEnv: true };
|
|
212
|
+
const rec = read();
|
|
213
|
+
if (!rec) return { signedIn: false, configured: false };
|
|
214
|
+
const live = !!rec.accessToken && rec.expiresAt > Date.now();
|
|
215
|
+
return {
|
|
216
|
+
signedIn: live || !!rec.refreshToken,
|
|
217
|
+
configured: true,
|
|
218
|
+
clientId: rec.clientId,
|
|
219
|
+
expiresAt: rec.expiresAt ?? null,
|
|
220
|
+
refreshable: !!rec.refreshToken,
|
|
221
|
+
scope: rec.scope ?? "",
|
|
222
|
+
storage: rec.secrets === "store" ? secrets.storageLabel() : "файл auth.json (только для вас)",
|
|
223
|
+
storageHint: rec.secrets === "store" ? null : secrets.fileHint(),
|
|
224
|
+
granted: granted(rec),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Scopes the current token was granted; null when unknown (a ready-made VV_TOKEN, or not said)
|
|
229
|
+
export function granted(rec = read()) {
|
|
230
|
+
if (process.env.VV_TOKEN || !rec?.accessToken && !rec?.refreshToken) return null;
|
|
231
|
+
const s = (rec.scope ?? "").trim();
|
|
232
|
+
return s ? s.split(/\s+/) : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Whether the signed-in token may use `scope`; unknown grants are given the benefit of the doubt
|
|
236
|
+
export function allows(scope) {
|
|
237
|
+
const g = granted();
|
|
238
|
+
return !g || g.includes(scope);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Saves (or replaces) the client the user registered at VkusVill; signs out of the previous one
|
|
242
|
+
export function setClient({ clientId, clientSecret, redirectUri, scopes }) {
|
|
243
|
+
if (!/^[\w.:-]{4,200}$/.test(clientId ?? "")) throw new AuthError("client_id выглядит неправильно", "input");
|
|
244
|
+
const prev = read();
|
|
245
|
+
const same = prev?.clientId === clientId;
|
|
246
|
+
write({
|
|
247
|
+
v: 1,
|
|
248
|
+
clientId,
|
|
249
|
+
clientSecret: clientSecret || (same ? prev.clientSecret : null) || null,
|
|
250
|
+
redirectUri: redirectUri || (same ? prev.redirectUri : null) || DEFAULT_REDIRECT,
|
|
251
|
+
// Scopes to ask for; null means everything the server offers, trimmed on the first refusal
|
|
252
|
+
scopes: scopes !== undefined ? scopes : same ? prev.scopes ?? null : null,
|
|
253
|
+
...(same ? { accessToken: prev.accessToken, refreshToken: prev.refreshToken, expiresAt: prev.expiresAt, scope: prev.scope } : {}),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let inflight = null;
|
|
258
|
+
|
|
259
|
+
// A valid access token, refreshed if needed; null when not signed in
|
|
260
|
+
export async function accessToken() {
|
|
261
|
+
if (process.env.VV_TOKEN) return process.env.VV_TOKEN;
|
|
262
|
+
const rec = read();
|
|
263
|
+
if (!rec?.accessToken && !rec?.refreshToken) return null;
|
|
264
|
+
if (rec.accessToken && rec.expiresAt - EARLY > Date.now()) return rec.accessToken;
|
|
265
|
+
if (!rec.refreshToken) return null;
|
|
266
|
+
return refresh();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// `rejected` is a token the server just refused: refresh even if it looks fresh,
|
|
270
|
+
// unless another process has already replaced it
|
|
271
|
+
export function refresh({ rejected } = {}) {
|
|
272
|
+
inflight ??= withLock(async () => {
|
|
273
|
+
const rec = read();
|
|
274
|
+
if (!rec?.refreshToken) throw new AuthError("Вход истёк — войдите заново", "invalid_grant");
|
|
275
|
+
const fresh = rec.accessToken && rec.expiresAt - EARLY > Date.now();
|
|
276
|
+
if (fresh && rec.accessToken !== rejected) return rec.accessToken;
|
|
277
|
+
const meta = await metadata();
|
|
278
|
+
try {
|
|
279
|
+
return store(rec, await tokenRequest(meta, rec, { grant_type: "refresh_token", refresh_token: rec.refreshToken })).accessToken;
|
|
280
|
+
} catch (e) {
|
|
281
|
+
// A dead refresh token stays dead: forget the tokens, keep the client for the next sign-in
|
|
282
|
+
if (e.code === "invalid_grant" || e.code === "invalid_client") write({ ...rec, accessToken: null, refreshToken: null, expiresAt: null });
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
}).finally(() => { inflight = null; });
|
|
286
|
+
return inflight;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Parses what the user pasted: the whole redirect URL, a query string, or the bare code
|
|
290
|
+
export function parsePasted(text, state) {
|
|
291
|
+
const t = String(text).trim();
|
|
292
|
+
let params;
|
|
293
|
+
try { params = new URL(t).searchParams; } catch { params = new URLSearchParams(t.includes("=") ? t.replace(/^[?#]/, "") : `code=${t}`); }
|
|
294
|
+
if (params.get("error")) return { error: params.get("error"), description: params.get("error_description") ?? "" };
|
|
295
|
+
if (params.get("state") && params.get("state") !== state) return { error: "state_mismatch" };
|
|
296
|
+
const code = params.get("code");
|
|
297
|
+
return code ? { code } : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const PAGE = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
301
|
+
<style>body{font:18px/1.5 system-ui,sans-serif;background:#0b130e;color:#e4eee6;display:grid;place-items:center;height:100vh;margin:0}
|
|
302
|
+
main{max-width:28rem;padding:2rem;text-align:center}b{color:#b5e853}</style><main><b>ВкусВилл в терминале</b><h1>${title}</h1><p>${text}</p></main>`;
|
|
303
|
+
|
|
304
|
+
// One-shot loopback listener for the redirect; resolves with { code } or { error }.
|
|
305
|
+
// "localhost" is served on both 127.0.0.1 and ::1: browsers may try either.
|
|
306
|
+
function listen(redirectUri, state, signal) {
|
|
307
|
+
const u = new URL(redirectUri);
|
|
308
|
+
const hosts = u.hostname === "localhost" ? ["127.0.0.1", "::1"] : [u.hostname.replace(/^\[|\]$/g, "")];
|
|
309
|
+
const port = Number(u.port) || 80;
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
const servers = [];
|
|
312
|
+
let listening = 0, failed = 0, lastError = null;
|
|
313
|
+
const closeAll = () => servers.forEach((s) => s.close());
|
|
314
|
+
const handler = (req, res) => {
|
|
315
|
+
const url = new URL(req.url, redirectUri);
|
|
316
|
+
if (req.method !== "GET" || url.pathname !== u.pathname) { res.writeHead(404).end(); return; }
|
|
317
|
+
const got = parsePasted(url.href, state);
|
|
318
|
+
if (!got) { res.writeHead(400).end(); return; }
|
|
319
|
+
const ok = !!got.code;
|
|
320
|
+
res.writeHead(ok || got.error === "invalid_scope" ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
321
|
+
res.end(ok ? PAGE("Готово", "Можно закрыть вкладку и вернуться в терминал.")
|
|
322
|
+
: got.error === "invalid_scope" ? PAGE("Ещё секунду", "Уточняю права клиента — откроется ещё одна вкладка. Эту можно закрыть.")
|
|
323
|
+
: PAGE("Вход не удался", "Вернитесь в терминал — там подробности."));
|
|
324
|
+
closeAll();
|
|
325
|
+
resolve(got);
|
|
326
|
+
};
|
|
327
|
+
for (const host of hosts) {
|
|
328
|
+
const server = http.createServer(handler);
|
|
329
|
+
servers.push(server);
|
|
330
|
+
server.on("listening", () => { listening++; });
|
|
331
|
+
// A missing address family (no IPv6) is fine while another one listens. A taken port is not:
|
|
332
|
+
// half-listening would let whatever holds the other half (a dev server on :3000) receive the code
|
|
333
|
+
server.on("error", (e) => {
|
|
334
|
+
failed++;
|
|
335
|
+
lastError = e;
|
|
336
|
+
const fatal = e.code === "EADDRINUSE" || e.code === "EACCES";
|
|
337
|
+
if (fatal || (failed === hosts.length && !listening)) {
|
|
338
|
+
closeAll();
|
|
339
|
+
reject(new AuthError(e.code === "EADDRINUSE" ? `Порт ${port} занят другой программой`
|
|
340
|
+
: e.code === "EACCES" ? `Порт ${port} нельзя занять без прав администратора` : lastError.message, "listen"));
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
server.listen(port, host);
|
|
344
|
+
}
|
|
345
|
+
signal?.addEventListener("abort", closeAll);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const isLoopback = (uri) => { try { const u = new URL(uri); return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname); } catch { return false; } };
|
|
350
|
+
|
|
351
|
+
// Full sign-in. `open(url)` shows the authorization page; `paste()` (optional) resolves with text
|
|
352
|
+
// the user pasted; `notice(text)` reports that the loopback receiver is unavailable. Retries once without offline_access if the client may not have refresh tokens.
|
|
353
|
+
// Hydra names the refused scope: …not allowed to request scope "purchase_history.read".
|
|
354
|
+
// A quoted name wins; otherwise any requested scope the description mentions.
|
|
355
|
+
function refusedScope(description, wanted) {
|
|
356
|
+
const d = description ?? "";
|
|
357
|
+
const quoted = /scope\s+["'“«]([\w.:-]+)["'”»]/i.exec(d)?.[1];
|
|
358
|
+
if (quoted && wanted.includes(quoted)) return quoted;
|
|
359
|
+
return wanted.find((s) => new RegExp(`(^|[^\\w.])${s.replace(/\./g, "\\.")}([^\\w.]|$)`).test(d)) ?? null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function login({ open, paste, signal, scope, notice = () => {} } = {}) {
|
|
363
|
+
const rec = read();
|
|
364
|
+
if (!rec?.clientId) throw new AuthError("Сначала укажите client_id своего клиента ВкусВилла", "no_client");
|
|
365
|
+
const meta = await metadata();
|
|
366
|
+
// Rights differ per client: start from what was asked last time (or everything) and drop
|
|
367
|
+
// each scope the server refuses, so a client with fewer boxes ticked still signs in
|
|
368
|
+
let wanted = scope ? scope.split(/\s+/) : ["offline_access", ...(rec.scopes ?? meta.scopes)];
|
|
369
|
+
for (let attempt = 0; attempt <= wanted.length + 1; attempt++) {
|
|
370
|
+
const verifier = b64url(randomBytes(48));
|
|
371
|
+
const state = b64url(randomBytes(16));
|
|
372
|
+
const auth = new URL(meta.authorization_endpoint);
|
|
373
|
+
auth.search = new URLSearchParams({
|
|
374
|
+
response_type: "code",
|
|
375
|
+
client_id: rec.clientId,
|
|
376
|
+
redirect_uri: rec.redirectUri,
|
|
377
|
+
scope: wanted.join(" "),
|
|
378
|
+
state,
|
|
379
|
+
code_challenge: b64url(createHash("sha256").update(verifier).digest()),
|
|
380
|
+
code_challenge_method: "S256",
|
|
381
|
+
}).toString();
|
|
382
|
+
|
|
383
|
+
const ctl = new AbortController();
|
|
384
|
+
const stop = () => ctl.abort();
|
|
385
|
+
signal?.addEventListener("abort", stop);
|
|
386
|
+
// Losers of the race reject after the abort below; mark them handled
|
|
387
|
+
const waiters = [];
|
|
388
|
+
const wait = (p) => { p.catch(() => {}); waiters.push(p); };
|
|
389
|
+
// If the port can't be taken, a paste still finishes the sign-in
|
|
390
|
+
if (isLoopback(rec.redirectUri)) {
|
|
391
|
+
wait(listen(rec.redirectUri, state, ctl.signal).catch((e) => {
|
|
392
|
+
if (!paste) throw e;
|
|
393
|
+
notice(`${e.message} — после входа вставьте адрес из строки браузера`);
|
|
394
|
+
return new Promise(() => {});
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
if (paste) wait(paste(ctl.signal).then((text) => parsePasted(text, state) ?? { error: "no_code" }));
|
|
398
|
+
if (!waiters.length) throw new AuthError("Redirect URI не локальный — нужна вставка ссылки", "no_receiver");
|
|
399
|
+
wait(new Promise((_, reject) => {
|
|
400
|
+
const t = setTimeout(() => reject(new AuthError("Не дождался входа за 5 минут", "timeout")), LOGIN_TIMEOUT);
|
|
401
|
+
ctl.signal.addEventListener("abort", () => { clearTimeout(t); reject(new AuthError("Вход отменён", "access_denied")); });
|
|
402
|
+
}));
|
|
403
|
+
open(auth.href);
|
|
404
|
+
let got;
|
|
405
|
+
try { got = await Promise.race(waiters); } finally { ctl.abort(); signal?.removeEventListener("abort", stop); }
|
|
406
|
+
|
|
407
|
+
if (got.error === "invalid_scope") {
|
|
408
|
+
const refused = refusedScope(got.description, wanted);
|
|
409
|
+
// Unnamed refusal: refresh rights are the usual suspect, then give up
|
|
410
|
+
const drop = refused && wanted.includes(refused) ? refused : wanted.includes("offline_access") ? "offline_access" : null;
|
|
411
|
+
if (!drop) throw new AuthError(describe("invalid_scope"), "invalid_scope");
|
|
412
|
+
wanted = wanted.filter((s) => s !== drop);
|
|
413
|
+
if (!wanted.some((s) => s !== "offline_access")) throw new AuthError("Клиенту не выдано ни одного права — отметьте действия в настройках клиента на vkusvill.ru", "invalid_scope");
|
|
414
|
+
notice(`Клиенту не выдано «${scopeLabel(drop)}» — продолжаю без этого`);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (got.error) throw new AuthError(got.error === "state_mismatch" ? "Ответ не от этой попытки входа" : describe(got.error), got.error);
|
|
418
|
+
const data = await tokenRequest(meta, rec, { grant_type: "authorization_code", code: got.code, redirect_uri: rec.redirectUri, code_verifier: verifier });
|
|
419
|
+
const scopes = wanted.filter((s) => s !== "offline_access");
|
|
420
|
+
// The server says what it granted; without that, assume what was asked
|
|
421
|
+
if (typeof data.scope !== "string") data.scope = wanted.join(" ");
|
|
422
|
+
return withLock(async () => store({ ...(read() ?? rec), scopes }, data, { fresh: true }));
|
|
423
|
+
}
|
|
424
|
+
throw new AuthError(describe("invalid_scope"), "invalid_scope");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Signs out and removes every trace of the client: tokens, secret, auth.json
|
|
428
|
+
export async function forget() {
|
|
429
|
+
const r = await logout().catch(() => null);
|
|
430
|
+
secrets.remove();
|
|
431
|
+
vaultMemo = null;
|
|
432
|
+
memo = null;
|
|
433
|
+
try { unlinkSync(FILE); } catch {}
|
|
434
|
+
return r;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Revokes both tokens at VkusVill (best effort) and forgets them locally either way; the client stays.
|
|
438
|
+
// Returns null when there was nothing to sign out of, otherwise { revoked } — whether VkusVill confirmed.
|
|
439
|
+
export async function logout() {
|
|
440
|
+
const rec = read();
|
|
441
|
+
if (!rec?.accessToken && !rec?.refreshToken) return null;
|
|
442
|
+
const meta = await metadata();
|
|
443
|
+
let revoked = !!meta.revocation_endpoint;
|
|
444
|
+
if (meta.revocation_endpoint) {
|
|
445
|
+
for (const [token, hint] of [[rec.refreshToken, "refresh_token"], [rec.accessToken, "access_token"]]) {
|
|
446
|
+
if (!token) continue;
|
|
447
|
+
const body = new URLSearchParams({ token, token_type_hint: hint, client_id: rec.clientId });
|
|
448
|
+
if (rec.clientSecret) body.set("client_secret", rec.clientSecret);
|
|
449
|
+
try {
|
|
450
|
+
const res = await fetch(meta.revocation_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10e3), redirect: "error" });
|
|
451
|
+
if (!res.ok) revoked = false;
|
|
452
|
+
} catch { revoked = false; }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
write({ ...rec, accessToken: null, refreshToken: null, expiresAt: null, scope: "" });
|
|
456
|
+
return { revoked };
|
|
457
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,9 @@ import { normalize, rub, compactNum, qtyText, lineTotal, plural } from "./produc
|
|
|
6
6
|
import { store, CART_MAX, QTY_MAX } from "./store.js";
|
|
7
7
|
import { padEnd, padStart } from "./term/width.js";
|
|
8
8
|
import { openState } from "./views/shops.js";
|
|
9
|
+
import * as auth from "./auth.js";
|
|
10
|
+
import { openUrl } from "./sys.js";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
9
12
|
|
|
10
13
|
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
11
14
|
const c = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
@@ -14,15 +17,21 @@ const width = () => Math.min(process.stdout.columns || 100, 120);
|
|
|
14
17
|
|
|
15
18
|
const out = (s = "") => process.stdout.write(s + "\n");
|
|
16
19
|
|
|
20
|
+
// Stock at the signed-in user's address, when the answer carries it
|
|
21
|
+
const stockText = (p) => (p.available == null ? null : p.available <= 0 ? "нет" : p.unit === "кг" ? `${+Number(p.available).toFixed(1)} кг` : `${p.available} шт`);
|
|
22
|
+
|
|
17
23
|
function productTable(items) {
|
|
18
24
|
const w = width();
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
const ps = items.map(normalize);
|
|
26
|
+
const withStock = ps.some((p) => p.available != null);
|
|
27
|
+
const nameW = Math.max(20, w - 40 - (withStock ? 8 : 0));
|
|
28
|
+
for (const p of ps) {
|
|
22
29
|
const price = p.old ? orange(padStart(rub(p.price), 9)) : bold(padStart(rub(p.price), 9));
|
|
23
30
|
const off = p.off ? orange(padStart(`−${p.off}%`, 5)) : " ";
|
|
24
31
|
const rating = p.rating ? yellow("★") + padEnd(p.rating.toFixed(1).replace(".", ","), 4) : " ";
|
|
25
|
-
|
|
32
|
+
const st = stockText(p);
|
|
33
|
+
const stock = withStock ? " " + (p.available > 0 ? green : red)(padStart(st ?? "", 7)) : "";
|
|
34
|
+
out(`${dim(padStart(String(p.id), 7))} ${padEnd(p.name, nameW)} ${rating} ${off} ${price}${stock}`);
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -35,17 +44,38 @@ export async function search(q, { json, sort, page = 1 }) {
|
|
|
35
44
|
if (d.meta?.has_more) out(dim(` ещё: vv search ${q} --page ${page + 1}`));
|
|
36
45
|
}
|
|
37
46
|
|
|
38
|
-
export async function deals({ json, type }) {
|
|
47
|
+
export async function deals({ json, type, personal }) {
|
|
48
|
+
if (personal) {
|
|
49
|
+
const d = await api.personalDiscounts();
|
|
50
|
+
if (json) return out(JSON.stringify(d, null, 2));
|
|
51
|
+
out(`${orange("%")} ${bold("6 скидок для меня")}`);
|
|
52
|
+
return productTable(d.products ?? d.items ?? []);
|
|
53
|
+
}
|
|
39
54
|
const d = await api.discounts({ type });
|
|
40
55
|
if (json) return out(JSON.stringify(d, null, 2));
|
|
41
56
|
out(`${orange("%")} ${bold(type === "card" ? "Скидки по карте" : "Скидки от нескольких штук")} ${dim(`· ${d.meta?.total ?? 0}`)}`);
|
|
42
57
|
productTable(d.items ?? []);
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
export async function orders({ json, page = 1 }) {
|
|
61
|
+
const d = await api.orders(page);
|
|
62
|
+
if (json) return out(JSON.stringify(d, null, 2));
|
|
63
|
+
const list = d.orders ?? d.items ?? [];
|
|
64
|
+
if (!list.length) return out(dim("Заказов за 30 дней нет"));
|
|
65
|
+
for (const o of list) {
|
|
66
|
+
const date = o.created_at ? new Date(o.created_at * (o.created_at < 1e12 ? 1000 : 1)).toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) : "";
|
|
67
|
+
const sum = typeof o.price === "object" ? o.price?.price : o.price;
|
|
68
|
+
out(`${bold(`№ ${o.id_general ?? o.id}`)} ${dim(`${date} · ${o.service ?? ""}`)} ${o.status_name ?? ""} ${lime(rub(sum))}`);
|
|
69
|
+
out(dim(` ${(o.products ?? []).map((p) => p.name).join(" · ")}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
function card(p) {
|
|
46
74
|
out(bold(p.name));
|
|
47
75
|
out(`${p.old ? orange(rub(p.price)) + " " + dim(`было ${rub(p.old)}`) : lime(rub(p.price))}${p.unit === "кг" ? dim(" за кг") : ""} ${p.rating ? yellow("★ " + String(p.rating).replace(".", ",")) + dim(` · ${compactNum(p.votes)}`) : ""}`);
|
|
48
76
|
if (p.offInfo) out(orange(p.offInfo));
|
|
77
|
+
const st = stockText(p);
|
|
78
|
+
if (st) out(p.available > 0 ? green(`● в наличии ${st}`) : red("○ нет в наличии"));
|
|
49
79
|
if (p.kcal != null) out(`${dim("на 100 г")} Б ${p.p ?? "—"} · Ж ${p.f ?? "—"} · У ${p.c ?? "—"} · ${lime(Math.round(p.kcal) + " ккал")}`);
|
|
50
80
|
if (p.comp) out(`${dim("Состав:")} ${p.comp}`);
|
|
51
81
|
out(dim(p.url));
|
|
@@ -150,10 +180,95 @@ export async function cart(args, { json }) {
|
|
|
150
180
|
return;
|
|
151
181
|
}
|
|
152
182
|
if (json) return out(JSON.stringify(store.cart.map((l) => ({ id: l.p.id, name: l.p.name, q: l.q, price: l.p.price })), null, 2));
|
|
153
|
-
if (!store.cart.length) return out(dim("Корзина пуста — npx vkusvill
|
|
183
|
+
if (!store.cart.length) return out(dim("Корзина пуста — npx vkusvill, чтобы набрать"));
|
|
154
184
|
const nameW = Math.max(20, width() - 30);
|
|
155
185
|
for (const l of store.cart) out(`${padEnd(l.p.name, nameW)} ${padStart(qtyText(l.p, l.q), 8)} ${bold(padStart(rub(lineTotal(l.p, l.q)), 10))}`);
|
|
156
186
|
out(`${padEnd("Итого", nameW + 9)} ${lime(bold(padStart(rub(store.total), 10)))}`);
|
|
157
187
|
out(dim("vv cart link — ссылка и QR-код"));
|
|
158
188
|
}
|
|
159
189
|
|
|
190
|
+
|
|
191
|
+
// ——— Sign-in ———
|
|
192
|
+
|
|
193
|
+
// One line from the terminal; `hidden` does not echo (for the client secret)
|
|
194
|
+
function ask(question, { hidden = false, signal } = {}) {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: !!process.stdin.isTTY });
|
|
197
|
+
if (hidden) rl._writeToOutput = (s) => { if (s.includes(question)) process.stdout.write(s); };
|
|
198
|
+
signal?.addEventListener("abort", () => { rl.close(); reject(new Error("aborted")); });
|
|
199
|
+
rl.question(question, (answer) => { rl.close(); if (hidden) process.stdout.write("\n"); resolve(answer.trim()); });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const when = (t) => new Date(t).toLocaleString("ru-RU", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" });
|
|
204
|
+
|
|
205
|
+
export async function login({ clientId, clientSecret, redirect, scope, noBrowser }) {
|
|
206
|
+
const st = auth.status();
|
|
207
|
+
if (st.viaEnv) throw new Error("Задан VV_TOKEN — он важнее входа. Уберите его, чтобы войти");
|
|
208
|
+
if (clientId || !st.configured) {
|
|
209
|
+
if (!clientId) {
|
|
210
|
+
out(`Нужен персональный клиент ВкусВилла. Зарегистрируйте его: ${lime(auth.REGISTRATION_URL)}`);
|
|
211
|
+
out(dim(` Отметьте «У моего сервиса нет публичного URI» — ВкусВилл подставит ${auth.DEFAULT_REDIRECT}, его и ловит vv login.`));
|
|
212
|
+
out(dim(" Данные клиента ВкусВилл пришлёт на почту."));
|
|
213
|
+
out();
|
|
214
|
+
clientId = await ask("client_id: ");
|
|
215
|
+
if (clientSecret === undefined) clientSecret = await ask("client_secret (если выдали; иначе Enter): ", { hidden: true });
|
|
216
|
+
}
|
|
217
|
+
auth.setClient({ clientId, clientSecret, redirectUri: redirect });
|
|
218
|
+
} else if (redirect || clientSecret) {
|
|
219
|
+
auth.setClient({ clientId: st.clientId, clientSecret, redirectUri: redirect });
|
|
220
|
+
}
|
|
221
|
+
// `--scope all` forgets the rights trimmed at earlier sign-ins; a list asks for exactly those
|
|
222
|
+
if (scope === "all") auth.setClient({ clientId: auth.read().clientId, scopes: null });
|
|
223
|
+
const rec = auth.read();
|
|
224
|
+
const loopback = /^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(rec.redirectUri);
|
|
225
|
+
const rec2 = await auth.login({
|
|
226
|
+
scope: scope && scope !== "all" ? scope : undefined,
|
|
227
|
+
notice: (text) => out(orange(text)),
|
|
228
|
+
open: (url) => {
|
|
229
|
+
out(`Страница входа ВкусВилла: ${dim(url)}`);
|
|
230
|
+
if (!noBrowser && openUrl(url)) out(dim("Открыл её в браузере."));
|
|
231
|
+
out(loopback ? dim("Жду подтверждения… Если браузер ушёл на адрес, который не открылся, вставьте его сюда и нажмите Enter.")
|
|
232
|
+
: "После входа скопируйте адрес из строки браузера и вставьте сюда:");
|
|
233
|
+
},
|
|
234
|
+
paste: (signal) => ask("> ", { signal }),
|
|
235
|
+
});
|
|
236
|
+
out(`${lime("✓")} Вход выполнен · до ${when(rec2.expiresAt)}${rec2.refreshToken ? dim(" · продлевается сам") : dim(" · без продления: через сутки vv login снова")}`);
|
|
237
|
+
printRights(rec2.scope);
|
|
238
|
+
process.exit(0); // stdin listeners from the race must not keep the process alive
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Granted rights in the words of VkusVill's registration form, and what is missing
|
|
242
|
+
async function printRights(scope) {
|
|
243
|
+
const granted = (scope ?? "").split(/\s+/).filter(Boolean);
|
|
244
|
+
const all = (await auth.metadata()).scopes;
|
|
245
|
+
for (const s of all) out(` ${granted.includes(s) ? lime("✓") : dim("·")} ${granted.includes(s) ? auth.scopeLabel(s) : dim(`${auth.scopeLabel(s)} — не выдано`)}`);
|
|
246
|
+
if (all.some((s) => !granted.includes(s))) out(dim(" Чтобы добавить: отметьте действие в настройках клиента на vkusvill.ru и vv login --scope all"));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function logout({ forget = false } = {}) {
|
|
250
|
+
if (forget) {
|
|
251
|
+
const had = auth.read();
|
|
252
|
+
const r = await auth.forget();
|
|
253
|
+
if (!had) return out(dim("Клиент и так не настроен"));
|
|
254
|
+
return out(`${lime("✓")} Клиент, секрет и токены стёрты${r && !r.revoked ? dim(" (ВкусВилл не подтвердил отзыв — доступ истечёт сам)") : ""}`);
|
|
255
|
+
}
|
|
256
|
+
const r = await auth.logout();
|
|
257
|
+
if (!r) return out(dim("Вход и так не выполнен"));
|
|
258
|
+
out(r.revoked ? `${lime("✓")} Вышли: токены отозваны у ВкусВилла и удалены`
|
|
259
|
+
: `${lime("✓")} Вышли: токены удалены с этого компьютера ${dim("(ВкусВилл не подтвердил отзыв — доступ истечёт сам)")}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function authStatus({ json }) {
|
|
263
|
+
const st = auth.status();
|
|
264
|
+
if (json) return out(JSON.stringify(st, null, 2));
|
|
265
|
+
if (st.viaEnv) return out("Вход: токен из VV_TOKEN");
|
|
266
|
+
if (!st.configured) return out(`Вход не настроен — ${lime("vv login")}`);
|
|
267
|
+
out(`Клиент: ${st.clientId}`);
|
|
268
|
+
out(`Секреты: ${st.storage}`);
|
|
269
|
+
if (st.storageHint) out(dim(` ${st.storageHint}`));
|
|
270
|
+
if (!st.signedIn) return out(`Вход: истёк — ${lime("vv login")}`);
|
|
271
|
+
out(`Вход: до ${when(st.expiresAt)}${st.refreshable ? dim(" · продлевается сам") : ""}`);
|
|
272
|
+
out("Права:");
|
|
273
|
+
await printRights(st.scope);
|
|
274
|
+
}
|