@datarails-ai/dr-cli 1.6.99 → 1.6.101
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 +17 -13
- package/dist/cli.js +120 -32
- package/dist/scopes-map.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -162,19 +162,23 @@ JWT refresh is automatic. Re-run `login` only when the refresh token itself
|
|
|
162
162
|
dies (days of idle, password change, admin revoke). Never paste the
|
|
163
163
|
session/JWT — treat them like a password.
|
|
164
164
|
|
|
165
|
-
**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
165
|
+
**Device flow and credential scope.** `dr login` uses the monolith's
|
|
166
|
+
`/oauth/v2/` device flow. The consent page lets you pick the credential's scope
|
|
167
|
+
(below) before "Allow", and the login stores a cookie-less JWT pair, with no
|
|
168
|
+
session, refreshed at the external refresh endpoint. The page offers the choice
|
|
169
|
+
only where the org enforces it; elsewhere the token is unrestricted. This works
|
|
170
|
+
with a credential file too (`DR_CREDENTIALS_FILE`), which records the login as
|
|
171
|
+
`"interactiveLogin": true` so that `dr login` and `dr logout` still accept it.
|
|
172
|
+
|
|
173
|
+
`DR_OAUTH_FLOW=legacy` runs the old `/oauth/device/code/` flow instead, for a
|
|
174
|
+
server that does not serve `/oauth/v2/` yet (a 404 from the v2 code endpoint
|
|
175
|
+
says so). A legacy token carries **no** credential scope, whatever the person
|
|
176
|
+
would have picked, and the login prints a line saying so. `v2` or unset selects
|
|
177
|
+
the default; any other value is refused. Log in again to switch an identity
|
|
178
|
+
between flows.
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
DR_OAUTH_FLOW=legacy dr login --server US # PowerShell: $env:DR_OAUTH_FLOW="legacy"; dr login --server US
|
|
178
182
|
```
|
|
179
183
|
|
|
180
184
|
### Credential scope
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
|
|
|
8
8
|
// package.json
|
|
9
9
|
var package_default = {
|
|
10
10
|
name: "@datarails-ai/dr-cli",
|
|
11
|
-
version: "1.6.
|
|
11
|
+
version: "1.6.101",
|
|
12
12
|
description: "Datarails CLI",
|
|
13
13
|
type: "module",
|
|
14
14
|
engines: {
|
|
@@ -69,7 +69,7 @@ var package_default = {
|
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
// src/buildInfo.ts
|
|
72
|
-
var BUILD_TIME = "2026-10-
|
|
72
|
+
var BUILD_TIME = "2026-10-05T14:18:41.284Z";
|
|
73
73
|
function formatBuildTime(iso) {
|
|
74
74
|
if (iso === "") return "";
|
|
75
75
|
const d = new Date(iso);
|
|
@@ -336,6 +336,13 @@ var credentialsFileSchema = z.object({
|
|
|
336
336
|
// would rewrite live token material after every successful command.
|
|
337
337
|
sessionId: z.string().min(1).optional(),
|
|
338
338
|
csrfToken: z.string().min(1).optional(),
|
|
339
|
+
// DR-53844: set by a v2 `dr login`, whose pair comes with no cookie, so the
|
|
340
|
+
// file still reads as a person's login — `dr login` may replace it and
|
|
341
|
+
// `dr logout` may remove it — rather than as one provisioned externally.
|
|
342
|
+
// Written by the CLI only. `z.boolean()`, not `z.literal(true)`: a
|
|
343
|
+
// top-level rejection fails the whole file (see `apiKeyExpiresAt`), and a
|
|
344
|
+
// hand-edited `false` must read as absent, not lock the operator out.
|
|
345
|
+
interactiveLogin: z.boolean().optional(),
|
|
339
346
|
// Validated, not just typed: `KeyringStore` turns this straight into a Date
|
|
340
347
|
// and later calls `toISOString()` on it, which throws on an Invalid Date.
|
|
341
348
|
// `datetime()` rejects a bad calendar date (2026-02-30, 2026-13-40) on its
|
|
@@ -2657,7 +2664,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
|
|
|
2657
2664
|
jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
|
|
2658
2665
|
},
|
|
2659
2666
|
// The env var's NAME only — the resolved key never enters any store.
|
|
2660
|
-
...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar }
|
|
2667
|
+
...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar },
|
|
2668
|
+
...creds.interactiveLogin === true && { interactiveLogin: true }
|
|
2661
2669
|
});
|
|
2662
2670
|
}
|
|
2663
2671
|
static slotOf(creds, account) {
|
|
@@ -2694,7 +2702,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
|
|
|
2694
2702
|
// Set-if-present, never deleted on a blob that lost it (the
|
|
2695
2703
|
// `apiKeyExpiresAt` rule): it is the identity's only credential pointer,
|
|
2696
2704
|
// and a meta rewrite must not be able to brick the file.
|
|
2697
|
-
...apiKeyEnvVar !== "" && { apiKeyEnvVar }
|
|
2705
|
+
...apiKeyEnvVar !== "" && { apiKeyEnvVar },
|
|
2706
|
+
...parsed2.interactiveLogin === true && { interactiveLogin: true }
|
|
2698
2707
|
};
|
|
2699
2708
|
}
|
|
2700
2709
|
async get(_service, account) {
|
|
@@ -2788,6 +2797,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
|
|
|
2788
2797
|
if (meta.accessApiKeyFingerprint === void 0) delete next.accessApiKeyFingerprint;
|
|
2789
2798
|
else next.accessApiKeyFingerprint = meta.accessApiKeyFingerprint;
|
|
2790
2799
|
if (meta.apiKeyEnvVar !== void 0) next.apiKeyEnvVar = meta.apiKeyEnvVar;
|
|
2800
|
+
if (meta.interactiveLogin === true) next.interactiveLogin = true;
|
|
2801
|
+
else delete next.interactiveLogin;
|
|
2791
2802
|
break;
|
|
2792
2803
|
}
|
|
2793
2804
|
}
|
|
@@ -4290,9 +4301,17 @@ async function refuseIfProvisionedExternally(action, opts) {
|
|
|
4290
4301
|
`cannot ${action}: the credentials in ${path11} are an API key. Run \`dr login --api-key\` to replace the key (and update its \`apiKeyExpiresAt\` in that file, if set \u2014 nothing derives that date, and a stale one warns "expired" on every command), or \`dr logout\` to remove it and log in with a browser instead \u2014 it cannot be read back afterwards, so keep a copy if it is still needed.`
|
|
4291
4302
|
);
|
|
4292
4303
|
}
|
|
4293
|
-
if (creds.
|
|
4304
|
+
if (creds.apiKeyEnvVar !== void 0 && opts?.allowApiKey !== true) {
|
|
4305
|
+
throw new Error(
|
|
4306
|
+
`cannot ${action}: the credentials in ${path11} name the environment variable ${creds.apiKeyEnvVar} as their API key, which is what later commands would authenticate as. Remove that file (\`dr logout\`) to log in with a browser instead.`
|
|
4307
|
+
);
|
|
4308
|
+
}
|
|
4309
|
+
if (isInteractiveFileLogin(creds)) return;
|
|
4294
4310
|
throw provisionedExternallyError(path11, action);
|
|
4295
4311
|
}
|
|
4312
|
+
function isInteractiveFileLogin(creds) {
|
|
4313
|
+
return creds.sessionId !== void 0 && creds.sessionId !== "" || creds.interactiveLogin === true;
|
|
4314
|
+
}
|
|
4296
4315
|
async function refuseLoginOverStoredApiKey(store, serverKey, email) {
|
|
4297
4316
|
let existing;
|
|
4298
4317
|
try {
|
|
@@ -4323,7 +4342,7 @@ async function refuseImpersonationWithCredentialFile() {
|
|
|
4323
4342
|
`cannot ${action}: the credentials in ${path11} are an API key, and an API-key token cannot mint further credentials. Impersonating needs a browser login, so remove that file (\`dr logout\`) and run \`dr login --server <key>\` \u2014 the key cannot be read back afterwards, so keep a copy if it is still needed.`
|
|
4324
4343
|
);
|
|
4325
4344
|
}
|
|
4326
|
-
if (creds
|
|
4345
|
+
if (!isInteractiveFileLogin(creds)) {
|
|
4327
4346
|
throw provisionedExternallyError(path11, action);
|
|
4328
4347
|
}
|
|
4329
4348
|
throw new Error(
|
|
@@ -5007,7 +5026,7 @@ async function runLogout(cfg) {
|
|
|
5007
5026
|
const session2 = await cfg.store.getSession(key, meta.email);
|
|
5008
5027
|
if (session2 === void 0) continue;
|
|
5009
5028
|
try {
|
|
5010
|
-
await revoker(host2, session2);
|
|
5029
|
+
writeNotes(cfg.out, await revoker(host2, session2));
|
|
5011
5030
|
} catch {
|
|
5012
5031
|
}
|
|
5013
5032
|
await cfg.store.removeSession(key, meta.email);
|
|
@@ -5040,32 +5059,95 @@ async function runLogout(cfg) {
|
|
|
5040
5059
|
}
|
|
5041
5060
|
const host = resolve(cfg.serverKey).host;
|
|
5042
5061
|
try {
|
|
5043
|
-
await revoker(host, session);
|
|
5062
|
+
writeNotes(cfg.out, await revoker(host, session));
|
|
5044
5063
|
} catch {
|
|
5045
5064
|
}
|
|
5046
5065
|
await cfg.store.removeSession(cfg.serverKey, email);
|
|
5047
5066
|
cfg.out.write(`Logged out of ${cfg.serverKey} as ${email}.
|
|
5048
5067
|
`);
|
|
5049
5068
|
}
|
|
5069
|
+
function writeNotes(out, notes) {
|
|
5070
|
+
for (const line of notes ?? []) out.write(`${line}
|
|
5071
|
+
`);
|
|
5072
|
+
}
|
|
5073
|
+
var REVOKE_TIMEOUT_MS = 5e3;
|
|
5050
5074
|
async function bestEffortLogout(host, session) {
|
|
5051
|
-
if (host === ""
|
|
5075
|
+
if (host === "") return [];
|
|
5052
5076
|
const base = host.replace(/\/+$/, "");
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5077
|
+
if (usesApiKey(session)) {
|
|
5078
|
+
return [
|
|
5079
|
+
"Note: the API key is still valid server-side until it is revoked where it was issued; only the local copy was removed."
|
|
5080
|
+
];
|
|
5081
|
+
}
|
|
5082
|
+
const refresh = session.jwtRefresh ?? "";
|
|
5083
|
+
const hasCookieSession = !isCookielessSession(session) && session.sessionId !== "" && session.csrfToken !== "";
|
|
5084
|
+
if (refresh === "" && !hasCookieSession) {
|
|
5085
|
+
return [
|
|
5086
|
+
`Warning: no refresh token is stored for this identity, so nothing was revoked on ${base}; only the local copy was removed.`
|
|
5087
|
+
];
|
|
5088
|
+
}
|
|
5089
|
+
const [refreshFailure, sessionFailure] = await Promise.all([
|
|
5090
|
+
refresh === "" ? void 0 : revokeRefreshToken(base, refresh),
|
|
5091
|
+
hasCookieSession ? endCookieSession(base, session) : void 0
|
|
5092
|
+
]);
|
|
5093
|
+
const notes = [];
|
|
5094
|
+
if (refreshFailure !== void 0) {
|
|
5095
|
+
notes.push(
|
|
5096
|
+
`Warning: could not revoke the refresh token on ${base} (${refreshFailure}); it stays valid server-side until it expires.`
|
|
5097
|
+
);
|
|
5098
|
+
}
|
|
5099
|
+
if (sessionFailure !== void 0) {
|
|
5100
|
+
notes.push(
|
|
5101
|
+
`Warning: could not end the browser session on ${base} (${sessionFailure}); it stays valid server-side until it expires.`
|
|
5102
|
+
);
|
|
5103
|
+
}
|
|
5104
|
+
return notes;
|
|
5105
|
+
}
|
|
5106
|
+
async function revokeRefreshToken(base, refresh) {
|
|
5107
|
+
return withTimeout(async (signal) => {
|
|
5108
|
+
const res = await fetch(`${base}/jwt/api/token/revoke/`, {
|
|
5109
|
+
method: "POST",
|
|
5110
|
+
headers: { "Content-Type": "application/json" },
|
|
5111
|
+
body: JSON.stringify({ refresh }),
|
|
5112
|
+
signal
|
|
5113
|
+
});
|
|
5114
|
+
await discardBody(res);
|
|
5115
|
+
return res.status === 204 ? void 0 : `HTTP ${res.status}`;
|
|
5116
|
+
});
|
|
5117
|
+
}
|
|
5118
|
+
async function endCookieSession(base, session) {
|
|
5119
|
+
return withTimeout(async (signal) => {
|
|
5120
|
+
const res = await fetch(`${base}/api/accounts/logout/`, {
|
|
5057
5121
|
method: "POST",
|
|
5058
5122
|
headers: {
|
|
5059
5123
|
Cookie: `sessionid=${session.sessionId}; csrftoken=${session.csrfToken}`,
|
|
5060
|
-
"X-CSRFToken": session.csrfToken
|
|
5124
|
+
"X-CSRFToken": session.csrfToken,
|
|
5125
|
+
Referer: `${base}/`
|
|
5061
5126
|
},
|
|
5062
|
-
|
|
5127
|
+
redirect: "manual",
|
|
5128
|
+
signal
|
|
5063
5129
|
});
|
|
5130
|
+
await discardBody(res);
|
|
5131
|
+
return res.status >= 200 && res.status < 400 ? void 0 : `HTTP ${res.status}`;
|
|
5132
|
+
});
|
|
5133
|
+
}
|
|
5134
|
+
async function withTimeout(run) {
|
|
5135
|
+
const controller = new AbortController();
|
|
5136
|
+
const timeout = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS);
|
|
5137
|
+
try {
|
|
5138
|
+
return await run(controller.signal);
|
|
5064
5139
|
} catch {
|
|
5140
|
+
return controller.signal.aborted ? "timed out" : "network error";
|
|
5065
5141
|
} finally {
|
|
5066
5142
|
clearTimeout(timeout);
|
|
5067
5143
|
}
|
|
5068
5144
|
}
|
|
5145
|
+
async function discardBody(res) {
|
|
5146
|
+
try {
|
|
5147
|
+
await res.text();
|
|
5148
|
+
} catch {
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
5069
5151
|
|
|
5070
5152
|
// src/commands/login.ts
|
|
5071
5153
|
import { Option } from "commander";
|
|
@@ -5078,8 +5160,8 @@ var OAUTH_FLOW_ENV = "DR_OAUTH_FLOW";
|
|
|
5078
5160
|
function oauthFlowFromEnv(env = process.env) {
|
|
5079
5161
|
const raw = (env[OAUTH_FLOW_ENV] ?? "").trim();
|
|
5080
5162
|
const value = raw.toLowerCase();
|
|
5081
|
-
if (value === "" || value === "
|
|
5082
|
-
if (value === "
|
|
5163
|
+
if (value === "" || value === "v2") return "v2";
|
|
5164
|
+
if (value === "legacy") return "legacy";
|
|
5083
5165
|
throw new Error(`${OAUTH_FLOW_ENV} must be "legacy" or "v2", got "${raw}"`);
|
|
5084
5166
|
}
|
|
5085
5167
|
var PATHS = {
|
|
@@ -5089,7 +5171,7 @@ var PATHS = {
|
|
|
5089
5171
|
var GRANT_TYPE = "urn:ietf:params:oauth:grant-type:";
|
|
5090
5172
|
async function requestDeviceCode(host, options) {
|
|
5091
5173
|
const base = host.replace(/\/+$/, "");
|
|
5092
|
-
const flow = options?.flow ?? "
|
|
5174
|
+
const flow = options?.flow ?? "v2";
|
|
5093
5175
|
const paths = PATHS[flow];
|
|
5094
5176
|
const body = new URLSearchParams({ client_id: CLIENT_ID, scope: paths.scope });
|
|
5095
5177
|
const res = await fetch(`${base}${paths.code}`, {
|
|
@@ -5100,7 +5182,12 @@ async function requestDeviceCode(host, options) {
|
|
|
5100
5182
|
});
|
|
5101
5183
|
const text2 = await res.text();
|
|
5102
5184
|
if (!res.ok) {
|
|
5103
|
-
|
|
5185
|
+
let hint = "";
|
|
5186
|
+
if (flow === "v2" && res.status === 429) {
|
|
5187
|
+
hint = " (too many logins from this address: wait a minute and run `dr login` again)";
|
|
5188
|
+
} else if (flow === "v2" && res.status === 404) {
|
|
5189
|
+
hint = ` (this server does not serve the v2 device flow yet: set ${OAUTH_FLOW_ENV}=legacy to log in through the old one, whose token carries no credential scope)`;
|
|
5190
|
+
}
|
|
5104
5191
|
throw new Error(`device/code failed: ${res.status} ${text2.trim()}${hint}`);
|
|
5105
5192
|
}
|
|
5106
5193
|
let raw;
|
|
@@ -5172,7 +5259,7 @@ function tryParseErrorCode(text2) {
|
|
|
5172
5259
|
return typeof errVal === "string" ? errVal : "";
|
|
5173
5260
|
}
|
|
5174
5261
|
async function pollForToken(host, deviceCode, options = {}) {
|
|
5175
|
-
return poll(host, PATHS.legacy.token, deviceCode, options, (text2) => {
|
|
5262
|
+
return poll(host, PATHS.legacy.token, deviceCode, options, {}, (text2) => {
|
|
5176
5263
|
const tok = parseTokenResponse(text2);
|
|
5177
5264
|
if (!tok.sessionId || !tok.csrfToken) {
|
|
5178
5265
|
throw new Error("token response missing session_id or csrf_token");
|
|
@@ -5181,7 +5268,7 @@ async function pollForToken(host, deviceCode, options = {}) {
|
|
|
5181
5268
|
});
|
|
5182
5269
|
}
|
|
5183
5270
|
async function pollForV2Token(host, deviceCode, options = {}) {
|
|
5184
|
-
return poll(host, PATHS.v2.token, deviceCode, options, (text2) => {
|
|
5271
|
+
return poll(host, PATHS.v2.token, deviceCode, options, V2_TERMINAL_HINTS, (text2) => {
|
|
5185
5272
|
const obj = parseObject(text2);
|
|
5186
5273
|
const access = typeof obj.access === "string" ? obj.access : "";
|
|
5187
5274
|
const refresh = typeof obj.refresh === "string" ? obj.refresh : "";
|
|
@@ -5191,7 +5278,11 @@ async function pollForV2Token(host, deviceCode, options = {}) {
|
|
|
5191
5278
|
return { access, refresh };
|
|
5192
5279
|
});
|
|
5193
5280
|
}
|
|
5194
|
-
|
|
5281
|
+
var V2_TERMINAL_HINTS = {
|
|
5282
|
+
access_denied: " (the login was refused, expired, or already used: run `dr login` again)",
|
|
5283
|
+
token_issuance_failed: " (the server could not issue the token: run `dr login` again to start a new login)"
|
|
5284
|
+
};
|
|
5285
|
+
async function poll(host, path11, deviceCode, options, terminalHints, onSuccess) {
|
|
5195
5286
|
const interval = options.interval ?? 2e3;
|
|
5196
5287
|
const maxWait = options.maxWait ?? 6e5;
|
|
5197
5288
|
const now = options.now ?? Date.now;
|
|
@@ -5227,7 +5318,8 @@ async function poll(host, path11, deviceCode, options, onSuccess) {
|
|
|
5227
5318
|
continue;
|
|
5228
5319
|
}
|
|
5229
5320
|
if (errCode) {
|
|
5230
|
-
|
|
5321
|
+
const hint = Object.hasOwn(terminalHints, errCode) ? terminalHints[errCode] : "";
|
|
5322
|
+
throw new Error(`token error: ${errCode}${hint}`);
|
|
5231
5323
|
}
|
|
5232
5324
|
throw new Error(`token HTTP ${res.status}: ${text2.trim()}`);
|
|
5233
5325
|
}
|
|
@@ -5462,11 +5554,6 @@ function registerLoginCommand(root) {
|
|
|
5462
5554
|
await refuseIfProvisionedExternally("log in");
|
|
5463
5555
|
const flow = oauthFlowFromEnv();
|
|
5464
5556
|
const fileStore = await resolveFileStore(opts.server);
|
|
5465
|
-
if (flow === "v2" && fileStore !== void 0) {
|
|
5466
|
-
throw new Error(
|
|
5467
|
-
`DR_OAUTH_FLOW=v2 cannot log into the credential file ${fileStore.path} yet; unset DR_CREDENTIALS_FILE to log in through the keychain, or unset DR_OAUTH_FLOW.`
|
|
5468
|
-
);
|
|
5469
|
-
}
|
|
5470
5557
|
let serverKey = fileStore?.key ?? (opts.server ?? "").trim();
|
|
5471
5558
|
if (fileStore === void 0) {
|
|
5472
5559
|
if (serverKey === "") {
|
|
@@ -5575,13 +5662,14 @@ async function runLogin(cfg) {
|
|
|
5575
5662
|
await refuseIfProvisionedExternally("log in");
|
|
5576
5663
|
const host = cfg.host ?? resolve(cfg.serverKey).host;
|
|
5577
5664
|
const timeoutMs = cfg.timeoutMs !== void 0 && cfg.timeoutMs > 0 ? cfg.timeoutMs : DEFAULT_LOGIN_TIMEOUT_MS;
|
|
5578
|
-
const flow = cfg.flow
|
|
5665
|
+
const flow = cfg.flow;
|
|
5579
5666
|
const controller = new AbortController();
|
|
5580
5667
|
const overallTimer = setTimeout(() => controller.abort(), timeoutMs + ABORT_SLACK_MS);
|
|
5581
5668
|
try {
|
|
5582
|
-
if (flow === "
|
|
5583
|
-
cfg.out.write(
|
|
5584
|
-
|
|
5669
|
+
if (flow === "legacy") {
|
|
5670
|
+
cfg.out.write(
|
|
5671
|
+
"Using the legacy device flow (DR_OAUTH_FLOW=legacy): its token carries no credential scope.\n"
|
|
5672
|
+
);
|
|
5585
5673
|
}
|
|
5586
5674
|
const dc = await requestDeviceCode(host, { signal: controller.signal, flow });
|
|
5587
5675
|
cfg.out.write(`Opening browser to authenticate with Datarails (${cfg.serverKey})...
|
package/dist/scopes-map.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"cliVersion": "1.6.
|
|
3
|
+
"cliVersion": "1.6.101",
|
|
4
4
|
"_contract": "Resolve a command path through `aliases` (canonical prefix + alias segment -> canonical path) before any lookup. A path listed in `pendingCommands`, or whose first segment is a key of `pending`, is UNDECIDED, not unrestricted: fail closed or skip it, and never read a path's absence from `commands` as 'no requirement'. An entry carrying `denied` is UNAVAILABLE to any AGENT-SCOPED credential — one whose `scopes` claim carries at least one `resource:action` entry; a claim that is absent, empty, capability-only or wholly unparseable is UNRESTRICTED, and this mark does not apply to it — whatever else that entry says: it is not a ceiling and it is not always-available, its `denied.reason` says why and `denied.until` names the ticket that lifts it, and the absence of `requires` on it must NEVER be read as 'no requirement'. Only an entry in `commands` with an empty `requires` means always-available.",
|
|
5
5
|
"commands": {
|
|
6
6
|
"login": {
|