@mason-data/cli 0.8.0 → 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/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CLIENT OF A HOSTED MASON SERVICE: the requests `mason login` and `mason logout` make, as docs/HOSTED-API.md
|
|
3
|
+
* documents them (the device authorization grant of RFC 8628, a revoke of RFC 7009, and `GET /v1/me`). Nothing here
|
|
4
|
+
* knows how a service is built; it speaks the documented API to the base URL it is given.
|
|
5
|
+
*
|
|
6
|
+
* WHERE: `MASON_CLOUD_URL`, else `DEFAULT_SERVICE_URL`, read by ONE function (`serviceOf`). HTTPS only, the certificate
|
|
7
|
+
* verified (fetch's own, never turned off); plain http only to this machine (127.0.0.1, ::1, localhost), for a service
|
|
8
|
+
* run beside it. A base URL with a path, a query, a fragment or a user is refused before any byte is sent.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IS SENT: every request carries `User-Agent: mason-cli/<version>` and `Accept: application/json`, a form or JSON
|
|
11
|
+
* body where one is sent, and a redirect is never followed (a Bearer header must not travel to another origin, nor https
|
|
12
|
+
* fall back to http). Every request has a time limit, and an answer is read up to `MAX_BODY_BYTES`.
|
|
13
|
+
*
|
|
14
|
+
* WHAT COMES BACK IS UNTRUSTED: each answer is held to its documented shape, and the token is masked in all of it: a
|
|
15
|
+
* token the command holds by its VALUE — whole, or every run of 4 or more of its body's characters once the runs the
|
|
16
|
+
* command prints would show more than a quarter of it together, however re-cased, split or percent-encoded
|
|
17
|
+
* (`heldRuns`); and anything shaped as a documented token, held or not. A GRAMMAR IS NO MASK (PR 609, issue 627): before
|
|
18
|
+
* any value of an answer is held to its grammar or printed, every string of it, each key included, is put to the exact
|
|
19
|
+
* test (`carriesNoSecret`), and an answer that carries a secret is refused whole — a device code's answer (`deviceOf`),
|
|
20
|
+
* who signed in (`meOf`), a deploy's state (src/cli/deploy.ts), a replica's users (src/cli/user-hosted.ts); a failure's
|
|
21
|
+
* code too (`failureShape`, `oauthFailure`), its words masked. THESE ARE THE FIRST LINE; THE LAST IS THE BYTES WRITTEN
|
|
22
|
+
* (PR 645's class round: what was tested was not what was printed): every hosted command holds its secrets in the
|
|
23
|
+
* output's gate (src/cli/output-gate.ts `holdOutput`), which puts each write to stdout and stderr, as written, to the
|
|
24
|
+
* same exact test, and writes the command's refusal in place of what would carry one.
|
|
25
|
+
*
|
|
26
|
+
* A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (the decision of PR 481's class round: a
|
|
27
|
+
* service forged a block of next steps with a newline in an email, and offered `mason destroy` as a next step). Two
|
|
28
|
+
* rules make that hold, each in one place:
|
|
29
|
+
*
|
|
30
|
+
* - EVERY STRING THE SERVICE SENDS LEAVES THIS MODULE AS ONE LINE, through `serviceText` alone, which it applies at
|
|
31
|
+
* the boundary (`whoAmI`, `serviceFailure`, `oauthFailure`, `call`, `revoke`): no line break of any kind, no
|
|
32
|
+
* control, format or bidi character, no run of spaces, bounded, masked. A value can sit INSIDE a line the CLI
|
|
33
|
+
* writes — a headline, a row's value, a failure's message — and never start one, so it can never be a block of
|
|
34
|
+
* next steps, a tip, a row or a failure of its own, for a person or in `--json`. On a terminal that holds for
|
|
35
|
+
* the VISUAL line too: a command whose output holds a service's text renders it in `serviceStyle`
|
|
36
|
+
* (src/cli/command.ts), under which the CLI breaks every line itself, never the terminal, and every
|
|
37
|
+
* continuation starts at column 4 or deeper, where nothing of the CLI's starts.
|
|
38
|
+
* - WHAT TO RUN NEXT, WHAT THE CODE AND THE EXIT ARE, AND WHERE A PERSON IS SENT ARE THE CLI'S OWN: a failure's
|
|
39
|
+
* code by the HTTP status (`codeOfStatus`), the service's own code kept in `why` as information; its next step
|
|
40
|
+
* the CLI's (`serviceNext`), the service's `next` never read, since a CLI cannot know what a command the service
|
|
41
|
+
* names would do to this machine (`mason destroy`, `mason ui --ssh <anywhere>`); the page printed and opened on
|
|
42
|
+
* the service's own origin (`pageUrl`); every number that times the poll bounded here.
|
|
43
|
+
*/
|
|
44
|
+
import * as os from "node:os";
|
|
45
|
+
import { version } from "./version.js";
|
|
46
|
+
/** The hosted service a login goes to when `MASON_CLOUD_URL` is unset: the one place it is named. */
|
|
47
|
+
export const DEFAULT_SERVICE_URL = "https://app.masondata.ai";
|
|
48
|
+
/** The client this CLI is to the service's OAuth endpoints. */
|
|
49
|
+
export const CLIENT_ID = "mason-cli";
|
|
50
|
+
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
51
|
+
/** How long one request may take, and how much of an answer is read. */
|
|
52
|
+
const REQUEST_MS = 15_000;
|
|
53
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
54
|
+
/** The documented token shapes (docs/HOSTED-API.md), masked wherever they appear in what is printed. */
|
|
55
|
+
const TOKEN_SHAPE = /\bmasondata_(pat|ci)_[A-Za-z0-9_-]{43}/g;
|
|
56
|
+
/**
|
|
57
|
+
* A Mason user's password (src/duck/users.ts `userPassword`), masked wherever it appears in what is printed of the
|
|
58
|
+
* service's text: a password is printed by `mason user add`'s own rows alone, from its one answer, held to its grammar
|
|
59
|
+
* (src/cli/user-hosted.ts), never by a message, a note or a row of anything else. Matched whatever precedes it (a
|
|
60
|
+
* letter, a digit or `_` before `mason_` is no boundary a password needs: `user_mason_…`) and in any case. A SHAPE
|
|
61
|
+
* MASK HAS A LIMIT: a password split (by a space, a dash), percent-encoded or written without its `mason_` prefix has
|
|
62
|
+
* no shape to match, and outside add's answer the CLI holds no password to mask by its value.
|
|
63
|
+
*/
|
|
64
|
+
const PASSWORD_SHAPE = /mason_[0-9a-f]{16}_[0-9a-f]{32}/gi;
|
|
65
|
+
/**
|
|
66
|
+
* WHAT OF A HELD SECRET MAY BE PRINTED (`heldRuns`): a run of its body is any `HELD_RUN` or more of its characters in a
|
|
67
|
+
* row, and what every run the process prints shows TOGETHER — across fields, strings and passes — is at most a quarter
|
|
68
|
+
* of the body (`HELD_SHOWN`), else each run is masked. A quarter of a 43-character body is 10 characters, about 60
|
|
69
|
+
* bits, leaving at least 33 (about 198 bits) unknown, so no run, nor any number of runs together, puts the token in
|
|
70
|
+
* reach; any run of a body shorter than 16 is masked (a quarter of it rounds below a run): fail closed. Four, because a
|
|
71
|
+
* 4-character window of ordinary text matches one of a body's 40 windows about once in 40,000 (36^4 / 40), and runs
|
|
72
|
+
* that cover 11 of its characters are three such chances in one command (no text a service words is masked by
|
|
73
|
+
* chance); and a run of 3 or fewer between other characters is no echo but an encoding, which a service that holds
|
|
74
|
+
* the token can make in any form it likes, out of reach of any mask (the note of PR 591).
|
|
75
|
+
*/
|
|
76
|
+
const HELD_RUN = 4;
|
|
77
|
+
const HELD_SHOWN = (bodyLength) => Math.floor(bodyLength / 4);
|
|
78
|
+
/** How many times a value is masked before it is given up: a mask joins what lay either side of it, so the value is
|
|
79
|
+
* read again; read a fifth time and still holding a run to mask, it is masked whole. */
|
|
80
|
+
const MASK_PASSES = 4;
|
|
81
|
+
/** What this process has printed of each held secret: the positions of its body every run shown covered (one command
|
|
82
|
+
* is one process, so this is what the command printed, in every field and stream). */
|
|
83
|
+
const SHOWN = new Map();
|
|
84
|
+
/** The hosts plain http may name: this machine. */
|
|
85
|
+
const LOOPBACK = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
86
|
+
/**
|
|
87
|
+
* A URL the service may name, or the CLI may be pointed at: https, or http to this machine. The reason it is not one,
|
|
88
|
+
* else null.
|
|
89
|
+
*/
|
|
90
|
+
export function urlProblem(u) {
|
|
91
|
+
if (u.protocol === "https:")
|
|
92
|
+
return null;
|
|
93
|
+
if (u.protocol === "http:")
|
|
94
|
+
return LOOPBACK.has(u.hostname) ? null : `http:// is taken only for this machine (127.0.0.1, ::1, localhost), and ${u.hostname} is not it: the login would travel unencrypted`;
|
|
95
|
+
return `${u.protocol.replace(/:$/, "")}:// is not https://`;
|
|
96
|
+
}
|
|
97
|
+
/** THE SERVICE THIS COMMAND MEANS, from `MASON_CLOUD_URL` or the default; a Failure, before any byte is sent, when it is not one. */
|
|
98
|
+
export function serviceOf(env = process.env) {
|
|
99
|
+
const raw = env.MASON_CLOUD_URL;
|
|
100
|
+
if (raw === undefined)
|
|
101
|
+
return { base: new URL(DEFAULT_SERVICE_URL).origin };
|
|
102
|
+
const refuse = (message, why) => ({ message, code: "refused", ...(why ? { why } : {}),
|
|
103
|
+
fix: `set MASON_CLOUD_URL to the service's address alone, such as ${DEFAULT_SERVICE_URL} (or http://127.0.0.1:<port> for one on this machine), or unset it for ${DEFAULT_SERVICE_URL}`,
|
|
104
|
+
next: [["unset MASON_CLOUD_URL", `use ${DEFAULT_SERVICE_URL}`]] });
|
|
105
|
+
if (raw.trim() === "")
|
|
106
|
+
return refuse("MASON_CLOUD_URL is set, and empty");
|
|
107
|
+
let u;
|
|
108
|
+
try {
|
|
109
|
+
u = new URL(raw);
|
|
110
|
+
}
|
|
111
|
+
// never repeated: what is not a URL may be anything, a secret pasted in the wrong place included
|
|
112
|
+
catch {
|
|
113
|
+
return refuse("MASON_CLOUD_URL is not a URL", "it is written as https://<host>[:<port>]");
|
|
114
|
+
}
|
|
115
|
+
if (u.username || u.password)
|
|
116
|
+
return refuse(`MASON_CLOUD_URL carries a user or a password (${u.protocol}//****@${u.host})`, "a login is made by mason login, never written in the URL");
|
|
117
|
+
const problem = urlProblem(u);
|
|
118
|
+
if (problem)
|
|
119
|
+
return refuse(`MASON_CLOUD_URL is not a URL Mason sends a login to: ${u.origin}`, problem);
|
|
120
|
+
// the extra parts are named, never repeated: a query may carry a secret
|
|
121
|
+
const extra = [u.pathname !== "/" ? "a path" : "", u.search ? "a query" : "", u.hash ? "a fragment" : ""].filter(Boolean);
|
|
122
|
+
if (extra.length)
|
|
123
|
+
return refuse(`MASON_CLOUD_URL has ${extra.join(" and ")} after the service's address (${u.origin})`, "it is the address alone: every path is the API's own (docs/HOSTED-API.md)");
|
|
124
|
+
return { base: u.origin };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* TEXT FROM THE SERVICE, AS IT MAY BE PRINTED: ONE LINE (above: data, never structure) — every control character, the
|
|
128
|
+
* newline and the carriage return included, and every line or paragraph separator, format or bidi mark replaced by
|
|
129
|
+
* `?`, so none can end a line, move a cursor, clear a screen or reorder what a person reads; every run of white space
|
|
130
|
+
* one space, none at either end; at most `max` characters; and every secret masked — the ones named (`secrets`, the
|
|
131
|
+
* tokens this command holds) whole, and by the runs of their body that would show more than a quarter of it together
|
|
132
|
+
* with what this process printed before (`heldRuns`, `shown`: the process's own record unless a test gives one), and
|
|
133
|
+
* anything shaped as a documented token or a Mason user's password. The masks read the value as it came, before it is made one line and bounded,
|
|
134
|
+
* so neither uncovers what they would have masked. On a terminal the line it sits in is broken by the CLI
|
|
135
|
+
* (`serviceStyle`).
|
|
136
|
+
*/
|
|
137
|
+
export function serviceText(s, max, secrets = [], shown = SHOWN) {
|
|
138
|
+
let t = maskSecrets(s, secrets, shown);
|
|
139
|
+
// the value as masked, read as the exact test reads it (with and without its escapes), before a line is made of it
|
|
140
|
+
if (echoesSecret(t, secrets))
|
|
141
|
+
return "****";
|
|
142
|
+
// C0 (newline, return and tab with it), DEL and C1 (NEL with it); the zero-width and directional marks; the line and
|
|
143
|
+
// paragraph separators; the bidi embeddings, overrides and isolates; the byte-order mark
|
|
144
|
+
t = t.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, "?");
|
|
145
|
+
// and a lone surrogate, which is no character: --json would write it as an escape (`\udc00`), whose letters and
|
|
146
|
+
// digits a reader reads (PR 645's review, round 2)
|
|
147
|
+
t = t.replace(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "?");
|
|
148
|
+
// and no run of spaces: a value padded to a terminal's width aligns nothing, and leaves no gap to break at a column of
|
|
149
|
+
// its choosing (src/cli/command.ts breaks every line that holds it: serviceStyle)
|
|
150
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
151
|
+
const chars = [...t];
|
|
152
|
+
const line = chars.length > max ? `${chars.slice(0, max).join("")}…` : t;
|
|
153
|
+
// and what is printed is what is tested (PR 645's review): the line as it leaves, put to the exact test too — its
|
|
154
|
+
// changes replace a character by a separator or cut the end, which join nothing, so this holds what they keep so
|
|
155
|
+
return echoesSecret(line, secrets) ? "****" : line;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* THE SECRET MASKS OF `serviceText`, ALONE, the one place a service's string is read for a secret: every secret named
|
|
159
|
+
* (`secrets`, the tokens this command holds) whole, and by the runs of their body that would show more than a quarter
|
|
160
|
+
* of it together with what `shown` records (`heldRuns`); anything shaped as a documented token; and, unless
|
|
161
|
+
* `passwords` is false (the one answer that makes a password, src/cli/user-hosted.ts), anything shaped as a Mason
|
|
162
|
+
* user's password. The value as it came, masked; unchanged when it carries none. For free text (a note, a refusal's
|
|
163
|
+
* words): a value held to a grammar is put to the exact test instead (`carriesSecret`).
|
|
164
|
+
*/
|
|
165
|
+
export function maskSecrets(s, secrets = [], shown = SHOWN, o = {}) {
|
|
166
|
+
let t = s;
|
|
167
|
+
// each held secret whole, in every form the one reading reads as it (`canonicalMap`): each span of `t` it is found in
|
|
168
|
+
for (const secret of secrets)
|
|
169
|
+
t = maskWhole(t, secret);
|
|
170
|
+
// a held secret by its value (`heldRuns`): a mask leaves `****`, a separator, so what lay either side of it is read
|
|
171
|
+
// again as one run; read MASK_PASSES + 1 times and still holding a run to mask, the value is masked whole
|
|
172
|
+
for (let pass = 0;; pass++) {
|
|
173
|
+
const found = heldRuns(t, secrets);
|
|
174
|
+
const spans = [];
|
|
175
|
+
for (const [secret, f] of found) {
|
|
176
|
+
const seen = new Set([...(shown.get(secret) ?? []), ...f.covered]);
|
|
177
|
+
if (seen.size > f.most)
|
|
178
|
+
spans.push(...f.spans);
|
|
179
|
+
}
|
|
180
|
+
if (!spans.length) {
|
|
181
|
+
// what is printed is recorded, so the next field's runs are counted with these
|
|
182
|
+
for (const [secret, f] of found)
|
|
183
|
+
shown.set(secret, new Set([...(shown.get(secret) ?? []), ...f.covered]));
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (pass === MASK_PASSES)
|
|
187
|
+
return "****";
|
|
188
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
189
|
+
const merged = [];
|
|
190
|
+
for (const m of spans) {
|
|
191
|
+
const last = merged[merged.length - 1];
|
|
192
|
+
if (last && m[0] <= last[1])
|
|
193
|
+
last[1] = Math.max(last[1], m[1]);
|
|
194
|
+
else
|
|
195
|
+
merged.push([m[0], m[1]]);
|
|
196
|
+
}
|
|
197
|
+
for (const [from, to] of merged.reverse())
|
|
198
|
+
t = `${t.slice(0, from)}****${t.slice(to)}`;
|
|
199
|
+
}
|
|
200
|
+
t = t.replace(TOKEN_SHAPE, "masondata_$1_****");
|
|
201
|
+
return o.passwords === false ? t : t.replace(PASSWORD_SHAPE, "mason_****");
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A HELD SECRET, BY ITS VALUE (issue 540: the shape alone let a token's body through, echoed without its prefix). The
|
|
205
|
+
* body of a secret this command holds is the value after a documented prefix, else the whole value (a token is opaque).
|
|
206
|
+
* The text is read as body characters (`bodyChars`): ASCII letters in lower case, digits, `-` and `_`, a `%XX` escape
|
|
207
|
+
* as the character it encodes, any other character as its compatibility decomposition without its combining marks (a
|
|
208
|
+
* full-width letter, an accented one, precomposed or not), and everything else skipped, as a separator. For each held
|
|
209
|
+
* secret: the spans of `t` that are runs of its body (`HELD_RUN` or more in a row, read once keeping `-` and `_` and once
|
|
210
|
+
* skipping them, so a body split by dashes is read too), with a `-` or `_` beside one; the positions of the body they
|
|
211
|
+
* cover; and how many the process may show (`HELD_SHOWN`). Nothing for a secret none of `t` runs with.
|
|
212
|
+
*/
|
|
213
|
+
function heldRuns(t, secrets) {
|
|
214
|
+
const found = new Map();
|
|
215
|
+
const views = [true, false].map((keep) => { const units = bodyChars(t, keep); return { keep, units, read: units.map((u) => u.c).join("") }; });
|
|
216
|
+
for (const secret of secrets) {
|
|
217
|
+
const raw = /^masondata_(?:pat|ci)_(.+)$/s.exec(secret)?.[1] ?? secret;
|
|
218
|
+
const length = bodyChars(raw, true).length;
|
|
219
|
+
// a body shorter than a run is masked whole alone (above), never by runs of ordinary words
|
|
220
|
+
if (length < HELD_RUN)
|
|
221
|
+
continue;
|
|
222
|
+
const n = Math.min(HELD_RUN, length), spans = [], covered = new Set();
|
|
223
|
+
for (const { keep, units, read } of views) {
|
|
224
|
+
// the secret read by the reading the text is read by (`readUnits`): one reading, both sides (PR 660's review, R2-B1)
|
|
225
|
+
const body = bodyChars(raw, keep), text = body.map((u) => u.c).join("");
|
|
226
|
+
const at = new Map();
|
|
227
|
+
for (let j = 0; j + n <= text.length; j++) {
|
|
228
|
+
const w = text.slice(j, j + n);
|
|
229
|
+
at.set(w, [...(at.get(w) ?? []), j]);
|
|
230
|
+
}
|
|
231
|
+
const marked = new Array(units.length).fill(false);
|
|
232
|
+
for (let i = 0; i + n <= units.length; i++) {
|
|
233
|
+
const js = at.get(read.slice(i, i + n));
|
|
234
|
+
if (!js)
|
|
235
|
+
continue;
|
|
236
|
+
for (let d = 0; d < n; d++)
|
|
237
|
+
marked[i + d] = true;
|
|
238
|
+
for (const j of js)
|
|
239
|
+
for (let d = 0; d < n; d++)
|
|
240
|
+
covered.add(body[j + d].from);
|
|
241
|
+
}
|
|
242
|
+
// the marked characters in a row are one span, the separators between them in it
|
|
243
|
+
for (let i = 0; i < units.length; i++) {
|
|
244
|
+
if (!marked[i])
|
|
245
|
+
continue;
|
|
246
|
+
let e = i;
|
|
247
|
+
while (e + 1 < units.length && marked[e + 1])
|
|
248
|
+
e++;
|
|
249
|
+
const span = [units[i].from, units[e].to];
|
|
250
|
+
// a `-` or `_` beside a run is the body's too (read skipping them, a body that begins or ends with one is a run
|
|
251
|
+
// without it), and a combining mark after it is its last letter's: the mask takes them
|
|
252
|
+
while (span[0] > 0 && /[-_]/.test(t[span[0] - 1]))
|
|
253
|
+
span[0]--;
|
|
254
|
+
while (span[1] < t.length && /[-_\p{M}]/u.test(t[span[1]]))
|
|
255
|
+
span[1]++;
|
|
256
|
+
spans.push(span);
|
|
257
|
+
i = e;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (spans.length)
|
|
261
|
+
found.set(secret, { spans, covered, most: HELD_SHOWN(length) });
|
|
262
|
+
}
|
|
263
|
+
return found;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* WHETHER A VALUE IS AN ECHO OF A HELD SECRET, exactly (PR 609's review, round 2: the quarter rule above bounds what a
|
|
267
|
+
* command SHOWS of a token across what it prints, which is right for masking a few sentences; read across a 2000-user
|
|
268
|
+
* answer of random identifiers, its chance runs of 4 refused up to a fifth of honest lists). A value is an echo when
|
|
269
|
+
* ONE string holds `ECHO_RUN` or more of the secret's body in a row, read as the mask reads it (`bodyChars`: re-cased,
|
|
270
|
+
* percent-decoded, in its compatibility form, with and without `-` and `_`), or the secret whole: never a count across
|
|
271
|
+
* strings, so an answer's size cannot make an honest value one.
|
|
272
|
+
*
|
|
273
|
+
* WHY 11 (the bound it is derived from: an honest answer is refused with a probability under 1 in a million). An honest
|
|
274
|
+
* answer is independent of the token, whose body is 43 random base64url characters: at a fixed place, a character of
|
|
275
|
+
* the answer equals the body's re-cased with a probability of at most 2/62 = 1/31 whatever the answer holds (the last
|
|
276
|
+
* character, of 16 values, at most 1/16, counted twice below). A run of n at one of the body's 43 places is then at
|
|
277
|
+
* most 31^-n, and over W characters read in both views, P(refused) ≤ W × 43 × 2 × 31^-n. The largest answer read is
|
|
278
|
+
* 1 MiB (src/cli/user-hosted.ts `MAX_LIST_BYTES`), each character read at most 18 times by its compatibility form, in
|
|
279
|
+
* two views: W ≤ 2 × 18 × 2^20, and n = 11 gives P ≤ 1.3 × 10^-7 (an ASCII answer: 7 × 10^-9); n = 10 would give 4 ×
|
|
280
|
+
* 10^-6. tests/cli-user-hosted-refusals.test.ts measures it with a fixed seed. WHAT IS LEFT: up to 10 of a body's
|
|
281
|
+
* characters in a row in one string, in any number of strings; a body spread otherwise (a character between each two)
|
|
282
|
+
* is an encoding, out of reach of any mask (the note of PR 591).
|
|
283
|
+
*/
|
|
284
|
+
export const ECHO_RUN = 11;
|
|
285
|
+
/**
|
|
286
|
+
* A HELD SECRET THAT IS NOT A TOKEN (the database's password `mason replica create` sends, src/cli/replica-hosted.ts):
|
|
287
|
+
* its body may be short, or made of characters a body reading skips (`!*!*…a1`), so it is read by its runs only when its
|
|
288
|
+
* body is at least `MIN_BODY_RUN` characters (a run of fewer is met by ordinary words), and WHOLE always (`wholeIn`).
|
|
289
|
+
* Both by THE ONE READING (`readUnits`), the secret and the text alike, at every boundary — the exact test of a value
|
|
290
|
+
* (`echoesSecret`), the masks of free text (`maskSecrets`) and the output's gate (src/cli/output-gate.ts
|
|
291
|
+
* `readingsCarry`) — so no boundary, and no side of a comparison, reads it by another rule (PR 660's review, B1, R2-B1).
|
|
292
|
+
*/
|
|
293
|
+
export const MIN_BODY_RUN = 8;
|
|
294
|
+
/** A terminal's control sequences (CSI, OSC, a two-character escape): a text is read with them and without them, since
|
|
295
|
+
* one between a secret's characters hides it from a reading that keeps them (`withoutEscapes`). */
|
|
296
|
+
export const ESCAPES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g;
|
|
297
|
+
/** `t` without its terminal escapes, the second reading of a text that holds one; else `t`. */
|
|
298
|
+
export const withoutEscapes = (t) => t.includes("\x1b") ? t.replace(ESCAPES, "") : t;
|
|
299
|
+
const NO_TRACE = /[\p{M}\p{Cf}]/gu;
|
|
300
|
+
/** One character as the one reading reads it: its compatibility decomposition (NFKD: NFC, NFD and NFKC alike, a
|
|
301
|
+
* full-width or ligature form as its letters), its marks and format characters dropped (an accent, a zero-width or
|
|
302
|
+
* bidi mark), its case folded (upper then lower: `ß` and `SS` alike), any white space one space, any other control
|
|
303
|
+
* nothing. Its characters, in that reading. */
|
|
304
|
+
function readOne(s) {
|
|
305
|
+
const folded = s.normalize("NFKD").replace(NO_TRACE, "").toUpperCase().toLowerCase().normalize("NFKD").replace(NO_TRACE, "");
|
|
306
|
+
return folded.replace(/\s/gu, " ").replace(/\p{Cc}/gu, "");
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* THE ONE READING OF A TEXT FOR A SECRET (PR 660's review, R2-B1: the secret and the text were read two ways, and no
|
|
310
|
+
* Unicode form was one): each code point by `readOne`; a run of `%XX` escapes (unless `percent` is false) as what it
|
|
311
|
+
* encodes, UTF-8 where it is, a byte each where it is not. Every comparison of a held secret with a text reads BOTH by
|
|
312
|
+
* it: whole (`canonicalMap`), and by its body (`bodyChars`); a text with a terminal escape, with it and without it.
|
|
313
|
+
*/
|
|
314
|
+
export function readUnits(t, percent = true) {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (let i = 0; i < t.length;) {
|
|
317
|
+
if (percent && t[i] === "%") {
|
|
318
|
+
const m = /^(?:%[0-9a-f]{2})+/i.exec(t.slice(i));
|
|
319
|
+
if (m) {
|
|
320
|
+
const run = m[0];
|
|
321
|
+
let decoded;
|
|
322
|
+
try {
|
|
323
|
+
decoded = decodeURIComponent(run);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
decoded = run.replace(/%([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
327
|
+
} // not UTF-8: a byte each
|
|
328
|
+
out.push({ c: [...decoded].map(readOne).join(""), from: i, to: i + run.length });
|
|
329
|
+
i += run.length;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const code = t.charCodeAt(i);
|
|
334
|
+
// ASCII, as readOne reads it, without the normalisations it would leave unchanged: a letter in lower case, white
|
|
335
|
+
// space one space, a control nothing
|
|
336
|
+
if (code < 0x80) {
|
|
337
|
+
const c = code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : (code >= 9 && code <= 13) || code === 32 ? " " : code < 32 || code === 127 ? "" : t[i];
|
|
338
|
+
out.push({ c, from: i, to: i + 1 });
|
|
339
|
+
i += 1;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const one = String.fromCodePoint(t.codePointAt(i));
|
|
343
|
+
out.push({ c: readOne(one), from: i, to: i + one.length });
|
|
344
|
+
i += one.length;
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
/** A text in the one reading, as one string (every run of spaces one), and for each of its characters the unit it was
|
|
349
|
+
* read from: what a secret is found whole in, and the span it is masked by. */
|
|
350
|
+
export function canonicalMap(t) {
|
|
351
|
+
const units = readUnits(t);
|
|
352
|
+
let text = "";
|
|
353
|
+
const at = [];
|
|
354
|
+
units.forEach((u, k) => { for (const ch of u.c) {
|
|
355
|
+
if (ch === " " && (text === "" || text.endsWith(" ")))
|
|
356
|
+
continue;
|
|
357
|
+
text += ch;
|
|
358
|
+
at.push(k);
|
|
359
|
+
} });
|
|
360
|
+
return { text, at, units };
|
|
361
|
+
}
|
|
362
|
+
const CANON = new Map();
|
|
363
|
+
/** A held secret in the one reading, as it is found whole: without white space at either end. */
|
|
364
|
+
export function canonicalSecret(secret) {
|
|
365
|
+
let c = CANON.get(secret);
|
|
366
|
+
if (c === undefined) {
|
|
367
|
+
c = canonicalMap(secret).text.trim();
|
|
368
|
+
CANON.set(secret, c);
|
|
369
|
+
}
|
|
370
|
+
return c;
|
|
371
|
+
}
|
|
372
|
+
/** Whether `s` holds `secret` whole, in the one reading: as written, re-cased, `%XX`-encoded, in any Unicode form,
|
|
373
|
+
* its accents dropped, its white space another, a format or control character or a terminal escape inside it. */
|
|
374
|
+
export function wholeIn(s, secret) {
|
|
375
|
+
return canonicalSecret(secret) !== "" && canonicalTexts(s).some((t) => wholeInCanonical(t, secret));
|
|
376
|
+
}
|
|
377
|
+
/** `s` in the one reading, with its terminal escapes and (when it has one) without them: what a secret is found whole in. */
|
|
378
|
+
export function canonicalTexts(s) {
|
|
379
|
+
const bare = withoutEscapes(s);
|
|
380
|
+
return bare === s ? [canonicalMap(s).text] : [canonicalMap(s).text, canonicalMap(bare).text];
|
|
381
|
+
}
|
|
382
|
+
/** Whether a text already in the one reading (`canonicalTexts`) holds `secret` whole. */
|
|
383
|
+
export const wholeInCanonical = (canonical, secret) => { const want = canonicalSecret(secret); return want !== "" && canonical.includes(want); };
|
|
384
|
+
/** `t` with every span the one reading finds `secret` whole in masked, `****`. */
|
|
385
|
+
function maskWhole(t, secret) {
|
|
386
|
+
const want = canonicalSecret(secret);
|
|
387
|
+
if (want === "")
|
|
388
|
+
return t;
|
|
389
|
+
const m = canonicalMap(t), spans = [];
|
|
390
|
+
for (let i = m.text.indexOf(want); i >= 0; i = m.text.indexOf(want, i + 1))
|
|
391
|
+
spans.push([m.units[m.at[i]].from, m.units[m.at[i + want.length - 1]].to]);
|
|
392
|
+
if (!spans.length)
|
|
393
|
+
return wholeIn(t, secret) ? "****" : t;
|
|
394
|
+
const merged = [];
|
|
395
|
+
for (const sp of spans) {
|
|
396
|
+
const last = merged[merged.length - 1];
|
|
397
|
+
if (last && sp[0] <= last[1])
|
|
398
|
+
last[1] = Math.max(last[1], sp[1]);
|
|
399
|
+
else
|
|
400
|
+
merged.push([sp[0], sp[1]]);
|
|
401
|
+
}
|
|
402
|
+
let out = t;
|
|
403
|
+
for (const [from, to] of merged.reverse())
|
|
404
|
+
out = `${out.slice(0, from)}****${out.slice(to)}`;
|
|
405
|
+
// a terminal escape between its characters: found only without them, so the value is masked whole (fail closed)
|
|
406
|
+
return wholeIn(out, secret) ? "****" : out;
|
|
407
|
+
}
|
|
408
|
+
const echoGrams = new Map();
|
|
409
|
+
/** A held secret's runs of `run` (each of its body's windows, read once keeping `-` and `_` and once skipping them):
|
|
410
|
+
* what an echo is matched against, here and by the output's gate (src/cli/output-gate.ts). */
|
|
411
|
+
export function echoViews(secret, run = ECHO_RUN) {
|
|
412
|
+
const key = `${run}\u0000${secret}`;
|
|
413
|
+
let views = echoGrams.get(key);
|
|
414
|
+
if (views === undefined) {
|
|
415
|
+
const raw = /^masondata_(?:pat|ci)_(.+)$/s.exec(secret)?.[1] ?? secret;
|
|
416
|
+
views = [true, false].map((keep) => {
|
|
417
|
+
const body = bodyChars(raw, keep).map((u) => u.c).join("");
|
|
418
|
+
// a body too short to be told apart from words by its runs is read whole alone (`wholeIn`)
|
|
419
|
+
const n = body.length < MIN_BODY_RUN ? 0 : Math.min(run, body.length), grams = new Set();
|
|
420
|
+
for (let j = 0; n > 0 && j + n <= body.length; j++)
|
|
421
|
+
grams.add(body.slice(j, j + n));
|
|
422
|
+
return { keep, n, grams };
|
|
423
|
+
});
|
|
424
|
+
echoGrams.set(key, views);
|
|
425
|
+
}
|
|
426
|
+
return views;
|
|
427
|
+
}
|
|
428
|
+
export function echoesSecret(s, secrets, run = ECHO_RUN) {
|
|
429
|
+
for (const secret of secrets) {
|
|
430
|
+
if (!secret)
|
|
431
|
+
continue;
|
|
432
|
+
if (wholeIn(s, secret))
|
|
433
|
+
return true;
|
|
434
|
+
const views = echoViews(secret, run);
|
|
435
|
+
for (const text of new Set([s, withoutEscapes(s)]))
|
|
436
|
+
for (const { keep, n, grams } of views) {
|
|
437
|
+
if (n === 0)
|
|
438
|
+
continue;
|
|
439
|
+
const read = bodyChars(text, keep).map((u) => u.c).join("");
|
|
440
|
+
for (let i = 0; i + n <= read.length; i++)
|
|
441
|
+
if (grams.has(read.slice(i, i + n)))
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* WHETHER A STRING THE SERVICE SENT CARRIES A SECRET, exactly: an echo of a held secret (`echoesSecret`), anything
|
|
449
|
+
* shaped as a documented token, and — unless `passwords` is false (the one answer that makes a password) — anything
|
|
450
|
+
* shaped as a Mason user's password. The one test a value held to a grammar is put to before it is printed (a grammar
|
|
451
|
+
* alone is no mask: src/cli/user-hosted.ts `carriesNoSecret`, and a failure's code, `failureShape`).
|
|
452
|
+
*/
|
|
453
|
+
export function carriesSecret(s, secrets = [], o = {}) {
|
|
454
|
+
return echoesSecret(s, secrets) || shapedSecret(s, o);
|
|
455
|
+
}
|
|
456
|
+
/** Whether `s` holds anything shaped as a documented token, or (unless `passwords` is false) a Mason user's password. */
|
|
457
|
+
export function shapedSecret(s, o = {}) {
|
|
458
|
+
return s.search(TOKEN_SHAPE) >= 0 || (o.passwords !== false && s.search(PASSWORD_SHAPE) >= 0);
|
|
459
|
+
}
|
|
460
|
+
/** `t` as a held secret is compared by (`bodyChars`), as one string: re-cased, in its compatibility form, `-` and `_`
|
|
461
|
+
* kept or not, a `%XX` escape read as what it encodes or (`percent` false) as the characters it is written with. */
|
|
462
|
+
export const bodyRead = (t, keepMarks, percent) => bodyChars(t, keepMarks, percent).map((u) => u.c).join("");
|
|
463
|
+
/**
|
|
464
|
+
* NO STRING OF AN ANSWER CARRIES A SECRET (PR 609's decision, the one point every hosted command's answer passes, issue
|
|
465
|
+
* 627: a grammar alone is no mask — a deploy's id, a device code, a sign-in page, a user's name can each spell the token
|
|
466
|
+
* and were printed as they came): EVERY string of the answer, each key included, is put to the exact test
|
|
467
|
+
* (`carriesSecret`: an echo of a held secret, `ECHO_RUN` of its body in a row in that one string; a token's or a
|
|
468
|
+
* password's shape), before any is held to its grammar or printed. An answer any string of which carries one is outside
|
|
469
|
+
* the contract, refused whole: nothing of it is printed. The test is per string and exact, so an honest answer is
|
|
470
|
+
* refused with a probability under 1 in a million whatever its size (`ECHO_RUN`'s derivation). `own`: the top-level
|
|
471
|
+
* keys that carry the password this answer makes (src/cli/user-hosted.ts add's `password`, `url`, `psql`), read for the
|
|
472
|
+
* token alone. A FAILURE'S WORDS (a message, a why, a fix) are free text the CLI prints as the service worded them,
|
|
473
|
+
* masked (`serviceText`), never refused: a caller passes an answer without them, and its code is put to this test
|
|
474
|
+
* (`failureShape`, `oauthFailure`).
|
|
475
|
+
*/
|
|
476
|
+
export function carriesNoSecret(json, secrets, own = []) {
|
|
477
|
+
const clean = (v, passwords) => !carriesSecret(v, secrets, { passwords });
|
|
478
|
+
// the walk keeps its own stack, never the call stack (PR 645's review: an answer of 10 KB nested 3000 deep overflowed
|
|
479
|
+
// it, a crash after login's token was granted), and an answer deeper than any documented shape is outside them all
|
|
480
|
+
const stack = [{ v: json, depth: 0, passwords: true }];
|
|
481
|
+
while (stack.length) {
|
|
482
|
+
const { v, depth, passwords } = stack.pop();
|
|
483
|
+
if (typeof v === "string") {
|
|
484
|
+
if (!clean(v, passwords))
|
|
485
|
+
return false;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (typeof v !== "object" || v === null)
|
|
489
|
+
continue;
|
|
490
|
+
if (depth >= MAX_ANSWER_DEPTH)
|
|
491
|
+
return false;
|
|
492
|
+
if (Array.isArray(v)) {
|
|
493
|
+
for (const x of v)
|
|
494
|
+
stack.push({ v: x, depth: depth + 1, passwords });
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
for (const [k, x] of Object.entries(v)) {
|
|
498
|
+
if (!clean(k, true))
|
|
499
|
+
return false;
|
|
500
|
+
// `own` names top-level keys alone
|
|
501
|
+
stack.push({ v: x, depth: depth + 1, passwords: depth === 0 ? !own.includes(k) : passwords });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
/** How deep an answer may nest (an object or an array in another): every documented answer is under 6 deep
|
|
507
|
+
* (docs/HOSTED-API.md; a replica's user list, the deepest, is 4), so 32 is room, and anything deeper is no answer of
|
|
508
|
+
* the contract's. The walk's size is bounded by the answer's bytes (`call`'s `maxBytes`). */
|
|
509
|
+
export const MAX_ANSWER_DEPTH = 32;
|
|
510
|
+
/** The characters of `t` a held secret is compared by, its BODY (letters, digits, and `-` and `_` unless `keepMarks` is
|
|
511
|
+
* false), in the one reading (`readUnits`), each with the span of `t` it was read from: the secret and the text alike. */
|
|
512
|
+
function bodyChars(t, keepMarks, percent = true) {
|
|
513
|
+
const kept = keepMarks ? /[a-z0-9_-]/ : /[a-z0-9]/;
|
|
514
|
+
const out = [];
|
|
515
|
+
for (const u of readUnits(t, percent))
|
|
516
|
+
for (const c of u.c)
|
|
517
|
+
if (kept.test(c))
|
|
518
|
+
out.push({ c, from: u.from, to: u.to });
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
/** This machine's name, as the service lists the login: printable ASCII, at most 64 characters, never empty (the
|
|
522
|
+
* service refuses any other `device_name` with 400 invalid_request). */
|
|
523
|
+
export function deviceName(hostname = os.hostname()) {
|
|
524
|
+
const name = hostname.replace(/[^\x20-\x7e]/g, "-").trim().slice(0, 64).trim();
|
|
525
|
+
return name || "unknown";
|
|
526
|
+
}
|
|
527
|
+
/** `Retry-After` as delta-seconds (RFC 9110 10.2.3), 1 to 3600; null for none, an HTTP date or anything else. */
|
|
528
|
+
export function retryAfterOf(v) {
|
|
529
|
+
if (v === null || !/^\d{1,4}$/.test(v.trim()))
|
|
530
|
+
return null;
|
|
531
|
+
const n = Number(v.trim());
|
|
532
|
+
return n >= 1 && n <= 3600 ? n : null;
|
|
533
|
+
}
|
|
534
|
+
/** A time the service sends: ISO 8601 with its zone, digits, `-`, `T`, `:` and `.` alone, and a time Date reads. */
|
|
535
|
+
export const isServiceTime = (v) => typeof v === "string" && v.length <= 64 && /^\d{4}-\d\d-\d\dT[\d:.]+(Z|[+-]\d\d:\d\d)$/.test(v) && !Number.isNaN(Date.parse(v));
|
|
536
|
+
/**
|
|
537
|
+
* ONE REQUEST TO THE SERVICE: `form` as an urlencoded body or `json` as a JSON one, `token` as a Bearer header. The
|
|
538
|
+
* answer, or a Failure when none came back whole: not reached, a redirect, larger than `maxBytes` (`MAX_BODY_BYTES`
|
|
539
|
+
* unless the contract documents another bound for this request), no answer within `timeoutMs` (`REQUEST_MS` unless the
|
|
540
|
+
* contract documents a longer wait: a replica's creation). A status is the caller's to read.
|
|
541
|
+
*/
|
|
542
|
+
export async function call(service, method, path, o = {}) {
|
|
543
|
+
const what = `${method} ${path}`;
|
|
544
|
+
const secrets = o.token ? [o.token] : [];
|
|
545
|
+
const headers = { "User-Agent": `mason-cli/${version()}`, Accept: "application/json" };
|
|
546
|
+
if (o.form)
|
|
547
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
548
|
+
if (o.json !== undefined)
|
|
549
|
+
headers["Content-Type"] = "application/json";
|
|
550
|
+
const body = o.form ? new URLSearchParams(o.form).toString() : o.json !== undefined ? JSON.stringify(o.json) : undefined;
|
|
551
|
+
if (o.token)
|
|
552
|
+
headers.Authorization = `Bearer ${o.token}`;
|
|
553
|
+
let res;
|
|
554
|
+
try {
|
|
555
|
+
res = await fetch(new URL(path, service.base), { method, headers, redirect: "manual", signal: AbortSignal.timeout(o.timeoutMs ?? REQUEST_MS),
|
|
556
|
+
...(body !== undefined ? { body } : {}) });
|
|
557
|
+
}
|
|
558
|
+
catch (e) {
|
|
559
|
+
return { message: `could not reach ${service.base}`, code: "failed", why: serviceText(reachError(e, o.timeoutMs ?? REQUEST_MS), 300, secrets),
|
|
560
|
+
fix: `check this machine is online, and that MASON_CLOUD_URL names the service (it is ${service.base})`, next: [] };
|
|
561
|
+
}
|
|
562
|
+
if (res.status >= 300 && res.status < 400) {
|
|
563
|
+
await res.body?.cancel();
|
|
564
|
+
return { message: `${service.base} answered ${what} with a redirect (${res.status})`, code: "failed",
|
|
565
|
+
why: "Mason follows no redirect: a login must never travel to an address it was not given", fix: "set MASON_CLOUD_URL to the service's own address" };
|
|
566
|
+
}
|
|
567
|
+
const max = o.maxBytes ?? MAX_BODY_BYTES;
|
|
568
|
+
const text = await boundedText(res, max);
|
|
569
|
+
if (text === null)
|
|
570
|
+
return { message: `${service.base} answered ${what} with more than ${max / 1024} KiB`, code: "failed", why: "no documented answer is that large" };
|
|
571
|
+
let json;
|
|
572
|
+
if (/^application\/(?:[\w.+-]+\+)?json\b/i.test(res.headers.get("content-type") ?? "")) {
|
|
573
|
+
try {
|
|
574
|
+
json = JSON.parse(text);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
json = undefined;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { status: res.status, json, what, retryAfter: retryAfterOf(res.headers.get("retry-after")) };
|
|
581
|
+
}
|
|
582
|
+
/** Why a fetch did not get an answer, in a line: the system's code, a certificate's reason, or the time limit. */
|
|
583
|
+
function reachError(e, ms) {
|
|
584
|
+
const err = e;
|
|
585
|
+
if (err.name === "TimeoutError")
|
|
586
|
+
return `no answer within ${ms / 1000} s`;
|
|
587
|
+
const cause = err.cause;
|
|
588
|
+
if (cause?.code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS/.test(cause.code))
|
|
589
|
+
return `its certificate could not be verified (${cause.code}): Mason sends a login only over a verified connection`;
|
|
590
|
+
if (cause?.code)
|
|
591
|
+
return `${cause.code}${cause.message ? `: ${cause.message}` : ""}`;
|
|
592
|
+
return cause?.message ?? err.message ?? String(e);
|
|
593
|
+
}
|
|
594
|
+
/** The body as text, or null past `max` bytes (the rest is not read). */
|
|
595
|
+
async function boundedText(res, max) {
|
|
596
|
+
if (!res.body)
|
|
597
|
+
return "";
|
|
598
|
+
const chunks = [];
|
|
599
|
+
let size = 0;
|
|
600
|
+
const reader = res.body.getReader();
|
|
601
|
+
for (;;) {
|
|
602
|
+
let r;
|
|
603
|
+
try {
|
|
604
|
+
r = await reader.read();
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return null;
|
|
608
|
+
} // a body cut off midway is no answer: said as one the caller cannot read
|
|
609
|
+
if (r.done)
|
|
610
|
+
break;
|
|
611
|
+
size += r.value.byteLength;
|
|
612
|
+
if (size > max) {
|
|
613
|
+
await reader.cancel();
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
chunks.push(r.value);
|
|
617
|
+
}
|
|
618
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
619
|
+
}
|
|
620
|
+
export const isFailure = (a) => typeof a === "object" && a !== null && "message" in a && "code" in a;
|
|
621
|
+
const str = (v, max) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
622
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
623
|
+
/** An answer outside the documented shape: said by its status alone, never its body (which may carry anything). */
|
|
624
|
+
export function unexpected(service, a, step) {
|
|
625
|
+
return { message: `${service.base} answered ${a.what} with ${a.status}${a.json === undefined ? ", not JSON" : ""}, where ${step}`, code: "failed",
|
|
626
|
+
why: "the answer is not the one docs/HOSTED-API.md documents for it", fix: "try again in a moment; if it persists, this Mason may be older than the service: upgrade it" };
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* THE CLI'S CODE FOR A SERVICE'S FAILURE, by its HTTP status alone (the one place it is decided): the service refused
|
|
630
|
+
* what was asked (401, 403, any other 4xx) is `refused`, a thing it does not have (404) `not_found`, anything else
|
|
631
|
+
* (5xx) `failed`. Never `usage` or `timeout`, whose exit codes (2, 124) say something of THIS command line or wait:
|
|
632
|
+
* the service's own word for it is information (`serviceFailure` keeps it in `why`), never the CLI's contract.
|
|
633
|
+
*/
|
|
634
|
+
export function codeOfStatus(status) {
|
|
635
|
+
if (status === 404)
|
|
636
|
+
return "not_found";
|
|
637
|
+
if (status >= 400 && status < 500)
|
|
638
|
+
return "refused";
|
|
639
|
+
return "failed";
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* WHAT TO RUN AFTER A SERVICE'S FAILURE: the CLI's own step, by the HTTP status (the one place it is decided). The
|
|
643
|
+
* service's `next` is never read: whatever it names, the CLI cannot know what it would do to this machine, and a coding
|
|
644
|
+
* agent runs the step it is given.
|
|
645
|
+
*/
|
|
646
|
+
export function serviceNext(status) {
|
|
647
|
+
return [status === 401 || status === 403 ? ["mason login", "sign in again"] : ["mason login", "try again in a moment"]];
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* A `/v1/` FAILURE: `{ error: { code, message, why?, fix? }, next: [{ command, what? }] }`, its message, why and fix
|
|
651
|
+
* said as the service worded them, each one line (`serviceText`); its CODE the CLI's own (`codeOfStatus`), the
|
|
652
|
+
* service's kept in `why`; its next step the CLI's own (`serviceNext`, or the command's own `next` for the status, never
|
|
653
|
+
* the service's). Null when the answer is not that shape.
|
|
654
|
+
*/
|
|
655
|
+
export function serviceFailure(a, secrets, next = serviceNext) {
|
|
656
|
+
if (a.status < 400 || !obj(a.json))
|
|
657
|
+
return null;
|
|
658
|
+
return failureShape(a.json.error, secrets, { code: codeOfStatus(a.status), said: (code) => `the service said ${code}, HTTP ${a.status}`, next: next(a.status) });
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* A FAILURE'S WORDS AS THE SERVICE SENDS THEM, `{ code, message, why?, fix? }`: message, why and fix each one line
|
|
662
|
+
* (`serviceText`); everything else the CLI's — the code `as.code`, the service's kept in `why` (`as.said`), the next
|
|
663
|
+
* steps `as.next` (any `next` beside it is never read). Null when it is not that shape. A `/v1/` failure's `error`
|
|
664
|
+
* (`serviceFailure`), and a refused or failed deploy's `failure` (src/cli/deploy.ts): one reader for both.
|
|
665
|
+
*/
|
|
666
|
+
export function failureShape(error, secrets, as) {
|
|
667
|
+
if (!obj(error))
|
|
668
|
+
return null;
|
|
669
|
+
const e = error;
|
|
670
|
+
// the code is printed as it came (inside why), so it is held to its grammar AND carries no secret (`maskSecrets`)
|
|
671
|
+
if (!str(e.code, 64) || !/^[a-z][a-z0-9_]*$/.test(e.code) || carriesSecret(e.code, secrets) || !str(e.message, 2000))
|
|
672
|
+
return null;
|
|
673
|
+
const text = (v, max) => typeof v === "string" && v ? serviceText(v, max, secrets) : undefined;
|
|
674
|
+
const why = text(e.why, 2000);
|
|
675
|
+
const said = as.said(e.code);
|
|
676
|
+
return { message: serviceText(e.message, 2000, secrets), code: as.code, why: why ? `${why} (${said})` : said, fix: text(e.fix, 2000), next: as.next };
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* A PAGE THE SERVICE SENDS A PERSON TO, which is printed and, on a terminal, opened: on the service's OWN origin (the
|
|
680
|
+
* address MASON_CLOUD_URL named, or the default), as the URL parser writes it, with no user or password. A page
|
|
681
|
+
* elsewhere would send the person, and their code, to an address nobody chose: `elsewhere` names it, as it is printed.
|
|
682
|
+
* Null when it is not a URL, or when what would be printed carries a secret.
|
|
683
|
+
*
|
|
684
|
+
* WHAT IS PRINTED IS WHAT IS TESTED (PR 645's review: the answer's string passed the exact test, and the parser's
|
|
685
|
+
* `href` printed the saved login's body whole, in pieces of 10 — `/x/../` between them is a dot segment the parser
|
|
686
|
+
* removes — and nothing masked it; a host in Unicode labels is printed in its `xn--` form, which can spell the body):
|
|
687
|
+
* the parser's own serialisation, the `href` or the `origin`, is what is tested, as it is printed — by the exact test
|
|
688
|
+
* (`carriesSecret`), and by the held secrets' mask (`maskSecrets`), since a page is printed and opened whole, so a page
|
|
689
|
+
* the mask would change is no page to send a person to: either refuses it. An honest page is refused by the mask with
|
|
690
|
+
* a probability under 1 in a million (it must hold 3 runs of 4 of a 43-character body by chance).
|
|
691
|
+
*/
|
|
692
|
+
export function pageUrl(service, v, secrets) {
|
|
693
|
+
if (!str(v, 2048))
|
|
694
|
+
return null;
|
|
695
|
+
let u;
|
|
696
|
+
try {
|
|
697
|
+
u = new URL(v);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
// and the line as it is printed, cut at `max` (PR 645's review, round 2: a cut inside a `%XX` escape left `%4…`, whose
|
|
703
|
+
// 4 the whole page's reading had decoded away)
|
|
704
|
+
const shown = (printed, max) => {
|
|
705
|
+
const line = serviceText(printed, max);
|
|
706
|
+
return carriesSecret(printed, secrets) || carriesSecret(line, secrets) || maskSecrets(printed, secrets) !== printed ? null : line;
|
|
707
|
+
};
|
|
708
|
+
if (u.origin !== service.base) {
|
|
709
|
+
const at = shown(u.protocol === "https:" || u.protocol === "http:" ? u.origin : `${u.protocol}//`, 300);
|
|
710
|
+
return at === null ? null : { elsewhere: at };
|
|
711
|
+
}
|
|
712
|
+
if (u.username || u.password)
|
|
713
|
+
return null;
|
|
714
|
+
const href = shown(u.href, 2048);
|
|
715
|
+
return href === null ? null : { href };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* `POST /oauth/device/code`: a device code, or a Failure. `secrets`: what the command holds that the service may echo (a
|
|
719
|
+
* login saved for this service, which a new one replaces).
|
|
720
|
+
*/
|
|
721
|
+
export async function startDevice(service, secrets = []) {
|
|
722
|
+
const a = await call(service, "POST", "/oauth/device/code", { form: { client_id: CLIENT_ID, device_name: deviceName() } });
|
|
723
|
+
if (isFailure(a))
|
|
724
|
+
return a;
|
|
725
|
+
if (a.status === 429) {
|
|
726
|
+
// the service's limit of sign-ins from this address: waiting is the way out, never a loop of retries
|
|
727
|
+
return { message: `${service.base} is limiting sign-ins from this address (429)`, code: "failed", why: "too many sign-ins were asked for from here in a short time",
|
|
728
|
+
fix: "try again in a few minutes", next: [["mason login", "in a few minutes"]] };
|
|
729
|
+
}
|
|
730
|
+
if (a.status !== 200)
|
|
731
|
+
return oauthFailure(service, a, "a sign-in was asked for", secrets);
|
|
732
|
+
return deviceOf(service, a, secrets);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* A DEVICE CODE'S ANSWER (200), held: every string of it, each key included, put to the exact test first
|
|
736
|
+
* (`carriesNoSecret`: the device code, the user code, each page, a key outside the shape), then each to its grammar,
|
|
737
|
+
* the pages on the service's own origin (`pageUrl`). The device code, or a Failure said by the status alone.
|
|
738
|
+
*/
|
|
739
|
+
export function deviceOf(service, a, secrets) {
|
|
740
|
+
const j = a.json;
|
|
741
|
+
if (!obj(j) || !carriesNoSecret(j, secrets))
|
|
742
|
+
return unexpected(service, a, "a device code was expected");
|
|
743
|
+
const userCode = typeof j.user_code === "string" ? /^([A-Za-z]{4})-?([A-Za-z]{4})$/.exec(j.user_code) : null;
|
|
744
|
+
const page = pageUrl(service, j.verification_uri, secrets);
|
|
745
|
+
const completePage = j.verification_uri_complete === undefined ? null : pageUrl(service, j.verification_uri_complete, secrets);
|
|
746
|
+
for (const p of [page, completePage]) {
|
|
747
|
+
if (p && "elsewhere" in p) {
|
|
748
|
+
return { message: `${service.base} named a sign-in page on another address: ${p.elsewhere}`, code: "refused",
|
|
749
|
+
why: `a sign-in page is printed and opened only on the service's own address, ${service.base}: anything else would send you, and the code, to an address nobody chose`,
|
|
750
|
+
fix: "check MASON_CLOUD_URL names the service you mean; if it does, the service is answering outside its documented API" };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const verificationUri = page && "href" in page ? page.href : null;
|
|
754
|
+
const complete = completePage && "href" in completePage ? completePage.href : null;
|
|
755
|
+
const expiresIn = j.expires_in, interval = j.interval ?? 5;
|
|
756
|
+
if (!str(j.device_code, 2048) || !/^[\x21-\x7e]+$/.test(j.device_code) || !userCode || !verificationUri
|
|
757
|
+
|| (j.verification_uri_complete !== undefined && complete === null)
|
|
758
|
+
|| !Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 3600
|
|
759
|
+
|| !Number.isInteger(interval) || interval < 1 || interval > 300)
|
|
760
|
+
return unexpected(service, a, "a device code was expected");
|
|
761
|
+
// the user code as it is printed, put to the test as it is printed (8 letters and a dash: it cannot hold ECHO_RUN)
|
|
762
|
+
const shownCode = `${userCode[1]}-${userCode[2]}`;
|
|
763
|
+
if (carriesSecret(shownCode, secrets))
|
|
764
|
+
return unexpected(service, a, "a device code was expected");
|
|
765
|
+
return { deviceCode: j.device_code, userCode: shownCode, verificationUri, verificationUriComplete: complete,
|
|
766
|
+
expiresIn: expiresIn, interval: interval };
|
|
767
|
+
}
|
|
768
|
+
/** `secrets`: as `startDevice`'s (the token this poll is answered with is the service's to send, never one it echoes). */
|
|
769
|
+
export async function pollToken(service, code, secrets = []) {
|
|
770
|
+
const a = await call(service, "POST", "/oauth/token", { form: { grant_type: DEVICE_GRANT, device_code: code.deviceCode, client_id: CLIENT_ID } });
|
|
771
|
+
if (isFailure(a))
|
|
772
|
+
return a;
|
|
773
|
+
if (a.status === 200) {
|
|
774
|
+
const j = a.json;
|
|
775
|
+
// a token is opaque, but it is sent back as a header: printable ASCII, no space, bounded
|
|
776
|
+
if (!obj(j) || !str(j.access_token, 4096) || !/^[\x21-\x7e]+$/.test(j.access_token) || typeof j.token_type !== "string" || j.token_type.toLowerCase() !== "bearer") {
|
|
777
|
+
return unexpected(service, a, "a token was expected");
|
|
778
|
+
}
|
|
779
|
+
return { token: j.access_token };
|
|
780
|
+
}
|
|
781
|
+
const error = obj(a.json) && typeof a.json.error === "string" ? a.json.error : null;
|
|
782
|
+
if (a.status === 400 && error === "authorization_pending")
|
|
783
|
+
return { pending: true };
|
|
784
|
+
if (a.status === 400 && error === "slow_down")
|
|
785
|
+
return { slowDown: true };
|
|
786
|
+
const again = [["mason login", "a new code, to approve"]];
|
|
787
|
+
if (a.status === 400 && error === "access_denied") {
|
|
788
|
+
return { message: `the sign-in with code ${code.userCode} was denied`, code: "refused", why: "the code was refused on the service's page",
|
|
789
|
+
fix: "run mason login again and approve the code it shows, signed in as the account this machine should use", next: again };
|
|
790
|
+
}
|
|
791
|
+
if (a.status === 400 && error === "expired_token")
|
|
792
|
+
return expired(code);
|
|
793
|
+
return oauthFailure(service, a, "the sign-in was polled", secrets);
|
|
794
|
+
}
|
|
795
|
+
/** The code ran out before it was approved: a timeout, its way out a new one. */
|
|
796
|
+
export function expired(code) {
|
|
797
|
+
return { message: `the code ${code.userCode} expired before it was approved`, code: "timeout", why: `a code lasts ${Math.round(code.expiresIn / 60) || 1} min from mason login`,
|
|
798
|
+
fix: "run mason login again, and approve the new code on the page it opens", next: [["mason login", "a new code, to approve"]] };
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* An OAuth endpoint's refusal (`{ error, error_description? }`, RFC 6749), or anything else it answered, as a Failure.
|
|
802
|
+
* Its code is printed as it came, so it is held to its grammar AND carries no secret (`carriesSecret`, as
|
|
803
|
+
* `failureShape` holds a `/v1/` failure's); its description is free text, masked (`serviceText`).
|
|
804
|
+
*/
|
|
805
|
+
export function oauthFailure(service, a, step, secrets) {
|
|
806
|
+
const j = obj(a.json) ? a.json : null;
|
|
807
|
+
if (j && str(j.error, 64) && /^[a-z_]+$/.test(j.error) && !carriesSecret(j.error, secrets)) {
|
|
808
|
+
return { message: `${service.base} refused this sign-in (${j.error}) when ${step}`, code: "failed",
|
|
809
|
+
why: typeof j.error_description === "string" && j.error_description ? serviceText(j.error_description, 1000, secrets) : undefined,
|
|
810
|
+
fix: "run mason login again; if it is refused again, this Mason may be older than the service expects: upgrade it", next: [["mason login", "try again"]] };
|
|
811
|
+
}
|
|
812
|
+
return unexpected(service, a, step);
|
|
813
|
+
}
|
|
814
|
+
/** `held`: what else the command holds that the service may echo (the login this one replaces). */
|
|
815
|
+
export async function whoAmI(service, token, held = []) {
|
|
816
|
+
const a = await call(service, "GET", "/v1/me", { token });
|
|
817
|
+
if (isFailure(a))
|
|
818
|
+
return a;
|
|
819
|
+
const secrets = [token, ...held];
|
|
820
|
+
if (a.status !== 200)
|
|
821
|
+
return serviceFailure(a, secrets) ?? unexpected(service, a, "the account signed in was asked for");
|
|
822
|
+
return meOf(service, a, secrets);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* `GET /v1/me`'s answer (200), held: every string of it, each key included, put to the exact test first
|
|
826
|
+
* (`carriesNoSecret`: the email, the name, each organisation's slug, name and role, a key outside the shape), then to
|
|
827
|
+
* its shape; each value then one line, masked (`serviceText`, with `secrets[0]`, the token it was asked with). Who
|
|
828
|
+
* signed in, or a Failure said by the status alone.
|
|
829
|
+
*/
|
|
830
|
+
export function meOf(service, a, secrets) {
|
|
831
|
+
const j = a.json;
|
|
832
|
+
const orgs = obj(j) && Array.isArray(j.orgs) ? j.orgs : null;
|
|
833
|
+
if (!obj(j) || !carriesNoSecret(j, secrets) || !str(j.email, 320) || typeof j.name !== "string" || j.name.length > 300 || orgs === null || orgs.length > 1000
|
|
834
|
+
|| !orgs.every((o) => obj(o) && str(o.slug, 200) && typeof o.name === "string" && o.name.length <= 300 && str(o.role, 32))) {
|
|
835
|
+
return unexpected(service, a, "the account signed in was asked for");
|
|
836
|
+
}
|
|
837
|
+
// at the boundary: who signed in leaves here as one line each, the token masked in it (above), for every reader
|
|
838
|
+
const line = (v, max) => serviceText(v, max, secrets.slice(0, 1));
|
|
839
|
+
return { email: line(j.email, 320), name: line(j.name, 300),
|
|
840
|
+
orgs: orgs.map((o) => ({ slug: line(o.slug, 200), name: line(o.name, 200), role: line(o.role, 32) })) };
|
|
841
|
+
}
|
|
842
|
+
/** `POST /oauth/revoke` (RFC 7009): null once the service took it, else why it did not (a revoke is best effort). */
|
|
843
|
+
export async function revoke(service, token) {
|
|
844
|
+
const a = await call(service, "POST", "/oauth/revoke", { form: { token, token_type_hint: "access_token", client_id: CLIENT_ID } });
|
|
845
|
+
if (isFailure(a))
|
|
846
|
+
return serviceText(`${a.message}${a.why ? ` (${a.why})` : ""}`, 400, [token]);
|
|
847
|
+
if (a.status !== 200)
|
|
848
|
+
return `${service.base} answered ${a.what} with ${a.status}`;
|
|
849
|
+
return null;
|
|
850
|
+
}
|