driggsby 0.6.3 → 0.7.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 +5 -1
- package/dist/args-login.js +41 -0
- package/dist/args.js +4 -3
- package/dist/dev/app-policy.js +78 -0
- package/dist/dev/dev-servers.js +33 -18
- package/dist/dev/host-page.js +7 -2
- package/dist/help.js +7 -2
- package/dist/login/await-token.js +260 -0
- package/dist/login/claim-client.js +55 -6
- package/dist/login/login.js +204 -41
- package/dist/login/loopback.js +153 -0
- package/dist/login/pending.js +68 -0
- package/dist/login/pkce.js +14 -0
- package/dist/login/sleep.js +21 -0
- package/dist/login/terminal-prompt.js +51 -0
- package/dist/main.js +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -35,7 +35,9 @@ https://app.driggsby.com/mcp
|
|
|
35
35
|
|
|
36
36
|
Some driggsby commands work with your Driggsby account directly and need
|
|
37
37
|
an app token saved on your machine: run
|
|
38
|
-
`npx driggsby@latest login` and approve the page it opens
|
|
38
|
+
`npx driggsby@latest login` and approve the page it opens (approving in a
|
|
39
|
+
browser on another computer shows a code to paste, or to finish with
|
|
40
|
+
`npx driggsby@latest login --code <CODE>`). The token is
|
|
39
41
|
stored in your platform keychain when one is available, otherwise in
|
|
40
42
|
`~/.driggsby/credentials.json`, and is never printed.
|
|
41
43
|
`npx driggsby@latest logout` removes it.
|
|
@@ -67,6 +69,8 @@ can't reach it, but anything running locally can, so stop it when you're
|
|
|
67
69
|
done: press Ctrl+C, or run `npx driggsby@latest dev --stop` in the app's
|
|
68
70
|
folder. It also stops on its own after 30 minutes with no page open.
|
|
69
71
|
Previews for several apps can run at once; each prints its own addresses.
|
|
72
|
+
Each data call the page makes prints one line in the terminal, after any
|
|
73
|
+
retries: a check and how long it took, or a cross and the reason.
|
|
70
74
|
|
|
71
75
|
## Deploying an app
|
|
72
76
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Argv parsing for driggsby login: help, and --code to finish a sign-in that
|
|
2
|
+
// is waiting on this computer with the code its approval page showed. Same
|
|
3
|
+
// contract as the rest of the tree: usage errors exit 2, echoed argv is
|
|
4
|
+
// sanitized.
|
|
5
|
+
import { helpCommand, unexpectedArgument } from "./args-shared.js";
|
|
6
|
+
import { CliError } from "./cli-error.js";
|
|
7
|
+
import { LOGIN_HELP } from "./help.js";
|
|
8
|
+
const LOGIN_USAGE = "Usage: npx driggsby@latest login [--code <CODE>]";
|
|
9
|
+
export function parseLogin(argv) {
|
|
10
|
+
let code = null;
|
|
11
|
+
let optionsEnded = false;
|
|
12
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
13
|
+
const token = argv[index] ?? "";
|
|
14
|
+
if (!optionsEnded && token === "--") {
|
|
15
|
+
optionsEnded = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (!optionsEnded && (token === "-h" || token === "--help")) {
|
|
19
|
+
return helpCommand(LOGIN_HELP);
|
|
20
|
+
}
|
|
21
|
+
if (!optionsEnded && code === null && token === "--code") {
|
|
22
|
+
code = requireCode(argv[index + 1]);
|
|
23
|
+
index += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!optionsEnded && code === null && token.startsWith("--code=")) {
|
|
27
|
+
code = requireCode(token.slice("--code=".length));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
throw unexpectedArgument(token, LOGIN_USAGE);
|
|
31
|
+
}
|
|
32
|
+
return { kind: "login", code };
|
|
33
|
+
}
|
|
34
|
+
// A code is never empty or flag-like; a pasted one may carry spaces, which
|
|
35
|
+
// Driggsby reads past.
|
|
36
|
+
function requireCode(value) {
|
|
37
|
+
if (value === undefined || value.trim() === "" || value.startsWith("-")) {
|
|
38
|
+
throw new CliError(`error: a value is required for '--code <CODE>' but none was supplied\n\n${LOGIN_USAGE}\n\nFor more information, try '--help'.`, 2);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
package/dist/args.js
CHANGED
|
@@ -10,13 +10,14 @@ import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
|
|
|
10
10
|
import { parseDev } from "./args-dev.js";
|
|
11
11
|
import { parseDelete } from "./args-delete.js";
|
|
12
12
|
import { parseInit } from "./args-init.js";
|
|
13
|
+
import { parseLogin } from "./args-login.js";
|
|
13
14
|
import { parseMcpSetup } from "./args-mcp-setup.js";
|
|
14
15
|
import { parseQuery } from "./args-query.js";
|
|
15
16
|
import { parseRules } from "./args-rules.js";
|
|
16
17
|
import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
|
|
17
18
|
import { didYouMean } from "./clap-suggestions.js";
|
|
18
19
|
import { CliError } from "./cli-error.js";
|
|
19
|
-
import {
|
|
20
|
+
import { LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
|
|
20
21
|
import { sanitizeForTerminal } from "./terminal-text.js";
|
|
21
22
|
export {} from "./args-shared.js";
|
|
22
23
|
const ROOT_USAGE = "Usage: npx driggsby@latest <COMMAND>";
|
|
@@ -90,7 +91,7 @@ function dispatchSubcommand(name, rest) {
|
|
|
90
91
|
case "mcp":
|
|
91
92
|
return parseLevel(MCP_LEVEL, rest);
|
|
92
93
|
case "login":
|
|
93
|
-
return
|
|
94
|
+
return parseLogin(rest);
|
|
94
95
|
case "logout":
|
|
95
96
|
return parseBareCommand({ kind: "logout" }, LOGOUT_HELP, "Usage: npx driggsby@latest logout", rest);
|
|
96
97
|
case "init":
|
|
@@ -117,7 +118,7 @@ function dispatchSubcommand(name, rest) {
|
|
|
117
118
|
throw new Error(`unhandled subcommand dispatch: ${name}`);
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
|
-
// Parsing for commands that take no arguments beyond help:
|
|
121
|
+
// Parsing for commands that take no arguments beyond help: logout.
|
|
121
122
|
// Same exit codes and error shape as the rest of the tree, no clap quirks.
|
|
122
123
|
function parseBareCommand(command, helpText, usage, argv) {
|
|
123
124
|
let optionsEnded = false;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The security headers on every app-origin response, the service worker
|
|
2
|
+
// the app origin hands out, and the policy the host page requires of its
|
|
3
|
+
// frame: a copy of what Driggsby's serving host and console do for a
|
|
4
|
+
// deployed app, so the preview refuses what a deployed app is refused and a
|
|
5
|
+
// page that works here works once deployed. The Driggsby service is
|
|
6
|
+
// canonical: change it there first, then mirror it here.
|
|
7
|
+
//
|
|
8
|
+
// The content security policy keeps the page's loads on its own origin
|
|
9
|
+
// (another site's script, font, image, or fetch is refused), refuses frames,
|
|
10
|
+
// objects, forms, and eval, and sandboxes the page as the serving host does.
|
|
11
|
+
// frame-ancestors names the host page, the only page that may frame the
|
|
12
|
+
// app. A file that can run script (a page, an SVG, JavaScript) also carries
|
|
13
|
+
// Connection-Allowlist, which has Chrome refuse any connection the page
|
|
14
|
+
// starts to anywhere else, WebRTC included. Any other file gets neither the
|
|
15
|
+
// list nor script: its sandbox grants nothing, so it opens in an origin of
|
|
16
|
+
// its own, as it does deployed. Never widen the list to other files; the
|
|
17
|
+
// serving host sends it only where script can run.
|
|
18
|
+
const SCRIPT_MEDIA_TYPES = new Set([
|
|
19
|
+
"text/html",
|
|
20
|
+
"image/svg+xml",
|
|
21
|
+
"text/javascript",
|
|
22
|
+
"application/javascript",
|
|
23
|
+
]);
|
|
24
|
+
const POLICY_DIRECTIVES = [
|
|
25
|
+
"default-src 'self'",
|
|
26
|
+
"script-src 'self' 'unsafe-inline'",
|
|
27
|
+
"style-src 'self' 'unsafe-inline'",
|
|
28
|
+
"img-src 'self' data: blob:",
|
|
29
|
+
"font-src 'self' data:",
|
|
30
|
+
"connect-src 'self'",
|
|
31
|
+
"frame-src 'none'",
|
|
32
|
+
"object-src 'none'",
|
|
33
|
+
"form-action 'none'",
|
|
34
|
+
"base-uri 'none'",
|
|
35
|
+
];
|
|
36
|
+
// What the host page requires of every document its frame shows (the
|
|
37
|
+
// iframe's csp attribute), as the console does: it binds even a document
|
|
38
|
+
// that doesn't come from this server.
|
|
39
|
+
export const FRAME_POLICY = POLICY_DIRECTIVES.join("; ");
|
|
40
|
+
// The only service worker either preview origin hands out, verbatim from
|
|
41
|
+
// the serving host. An app's own worker could answer the frame's
|
|
42
|
+
// navigations with documents it builds, carrying none of these headers, and
|
|
43
|
+
// on these fixed ports it would outlive the preview and answer the next
|
|
44
|
+
// app's too; a worker some other local server once left on the host port
|
|
45
|
+
// would answer the host page, which runs tools. This one listens for
|
|
46
|
+
// nothing.
|
|
47
|
+
export const INERT_SERVICE_WORKER = `// This origin's only service worker. It listens for nothing, so it answers
|
|
48
|
+
// no request: every page and file comes from the network with the host's
|
|
49
|
+
// own headers. It behaves like any default worker: it waits until no page
|
|
50
|
+
// is using the worker before it, then takes over.
|
|
51
|
+
`;
|
|
52
|
+
// hostOrigin is empty until the host server has bound (a startup race
|
|
53
|
+
// measured in milliseconds); until then nothing may frame the app.
|
|
54
|
+
export function appResponseHeaders(hostOrigin, contentType) {
|
|
55
|
+
const mediaType = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
56
|
+
const runsScript = SCRIPT_MEDIA_TYPES.has(mediaType);
|
|
57
|
+
const directives = [
|
|
58
|
+
runsScript ? "sandbox allow-scripts allow-same-origin" : "sandbox",
|
|
59
|
+
...POLICY_DIRECTIVES,
|
|
60
|
+
`frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin}`,
|
|
61
|
+
];
|
|
62
|
+
const headers = {
|
|
63
|
+
"Content-Security-Policy": directives.join("; "),
|
|
64
|
+
// The served content types are final; the browser never guesses.
|
|
65
|
+
"X-Content-Type-Options": "nosniff",
|
|
66
|
+
"Referrer-Policy": "no-referrer",
|
|
67
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
68
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
69
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
|
|
70
|
+
};
|
|
71
|
+
if (hostOrigin !== "") {
|
|
72
|
+
headers["Allow-CSP-From"] = hostOrigin;
|
|
73
|
+
}
|
|
74
|
+
if (runsScript) {
|
|
75
|
+
headers["Connection-Allowlist"] = "(response-origin)";
|
|
76
|
+
}
|
|
77
|
+
return headers;
|
|
78
|
+
}
|
package/dist/dev/dev-servers.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// only. The app token never appears here — the broker callback holds it in
|
|
6
6
|
// the CLI process.
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
|
+
import { appResponseHeaders, INERT_SERVICE_WORKER } from "./app-policy.js";
|
|
8
9
|
import { DEV_IDENTITY_PATH } from "./dev-state.js";
|
|
9
10
|
import { hostPageHtml, HOST_PAGE_JS } from "./host-page.js";
|
|
10
11
|
import { serveStaticFile } from "./static-files.js";
|
|
@@ -15,13 +16,16 @@ import { APP_TOOL_ALLOWLIST } from "./tool-allowlist.js";
|
|
|
15
16
|
export const GENERIC_TOOL_TROUBLE = "We couldn't run that just now. Try again in a moment.";
|
|
16
17
|
export const TOOL_NOT_ALLOWED_MESSAGE = "That tool isn't available to Driggsby apps. Apps can use read-only tools like get_overview and list_accounts.";
|
|
17
18
|
const MAX_TOOL_CALL_BODY_BYTES = 65_536;
|
|
19
|
+
const PLAIN_TEXT = "text/plain; charset=utf-8";
|
|
20
|
+
const JAVASCRIPT = "text/javascript; charset=utf-8";
|
|
18
21
|
export async function startDevServers(options) {
|
|
19
22
|
// Both origins bind and advertise 127.0.0.1: advertising "localhost"
|
|
20
23
|
// while binding only the IPv4 loopback breaks on machines whose browser
|
|
21
24
|
// resolves localhost to ::1 first.
|
|
22
25
|
let appPortForGuard = 0;
|
|
23
|
-
// Filled in once the host server binds; app responses
|
|
24
|
-
// frame-ancestors
|
|
26
|
+
// Filled in once the host server binds; app responses name it in their
|
|
27
|
+
// frame-ancestors and Allow-CSP-From, so only the real host page can
|
|
28
|
+
// frame the app.
|
|
25
29
|
let hostOriginForFraming = "";
|
|
26
30
|
const appServer = createServer((request, response) => {
|
|
27
31
|
void handleAppRequest(options, appPortForGuard, hostOriginForFraming, request, response);
|
|
@@ -70,39 +74,41 @@ export async function startDevServers(options) {
|
|
|
70
74
|
// The app origin: the SDK path, then the project's own files.
|
|
71
75
|
// ---------------------------------------------------------------------------
|
|
72
76
|
async function handleAppRequest(options, appPort, hostOrigin, request, response) {
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
const appHeaders = {
|
|
79
|
-
"Content-Security-Policy": `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin};`,
|
|
80
|
-
"X-Content-Type-Options": "nosniff",
|
|
81
|
-
};
|
|
77
|
+
// Every response carries the deployed app's headers (app-policy.ts). Their
|
|
78
|
+
// frame-ancestors lets only the real host page frame the app: without it,
|
|
79
|
+
// any other loopback-served page could embed the app origin, pin itself
|
|
80
|
+
// as the app's host, and feed it fabricated numbers.
|
|
81
|
+
const plainTextHeaders = appResponseHeaders(hostOrigin, PLAIN_TEXT);
|
|
82
82
|
// Same rebinding guard as the host origin: a page on another site that
|
|
83
83
|
// tricked DNS into pointing here carries its own Host, and fails.
|
|
84
84
|
if (!hostHeaderIsLocal(request.headers.host, appPort)) {
|
|
85
|
-
sendText(response, 403,
|
|
85
|
+
sendText(response, 403, PLAIN_TEXT, "Forbidden.", plainTextHeaders);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Every service worker script request, whatever path it names, gets the
|
|
89
|
+
// inert worker (app-policy.ts), as deployed. Browsers mark each one.
|
|
90
|
+
if (isServiceWorkerScriptRequest(request)) {
|
|
91
|
+
sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER, appResponseHeaders(hostOrigin, JAVASCRIPT));
|
|
86
92
|
return;
|
|
87
93
|
}
|
|
88
94
|
const path = requestPath(request);
|
|
89
95
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
90
|
-
sendText(response, 405,
|
|
96
|
+
sendText(response, 405, PLAIN_TEXT, "Method not allowed.", plainTextHeaders);
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
if (path === "/-/driggsby-sdk.js") {
|
|
94
|
-
sendText(response, 200,
|
|
100
|
+
sendText(response, 200, JAVASCRIPT, options.sdkBundle, appResponseHeaders(hostOrigin, JAVASCRIPT));
|
|
95
101
|
return;
|
|
96
102
|
}
|
|
97
103
|
const file = await serveStaticFile(options.serveDirectory, path);
|
|
98
104
|
if (file === null) {
|
|
99
|
-
sendText(response, 404,
|
|
105
|
+
sendText(response, 404, PLAIN_TEXT, "Not found.", plainTextHeaders);
|
|
100
106
|
return;
|
|
101
107
|
}
|
|
102
108
|
response.writeHead(file.status, {
|
|
103
109
|
"Content-Type": file.contentType,
|
|
104
110
|
"Cache-Control": "no-store",
|
|
105
|
-
...
|
|
111
|
+
...appResponseHeaders(hostOrigin, file.contentType),
|
|
106
112
|
});
|
|
107
113
|
response.end(file.body);
|
|
108
114
|
}
|
|
@@ -117,6 +123,12 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
|
|
|
117
123
|
sendJson(response, 403, { ok: false, error: { message: "Forbidden." } });
|
|
118
124
|
return;
|
|
119
125
|
}
|
|
126
|
+
// The host page registers no worker, so a worker script request here is
|
|
127
|
+
// a foreign one; the inert worker replaces it (app-policy.ts).
|
|
128
|
+
if (isServiceWorkerScriptRequest(request)) {
|
|
129
|
+
sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
120
132
|
const path = requestPath(request);
|
|
121
133
|
if (request.method === "GET" && path === DEV_IDENTITY_PATH) {
|
|
122
134
|
// Lets `dev --stop` (and `dev`) confirm that a record in ~/.driggsby
|
|
@@ -129,7 +141,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
|
|
|
129
141
|
return;
|
|
130
142
|
}
|
|
131
143
|
if (request.method === "GET" && path === "/host.js") {
|
|
132
|
-
sendText(response, 200,
|
|
144
|
+
sendText(response, 200, JAVASCRIPT, HOST_PAGE_JS);
|
|
133
145
|
return;
|
|
134
146
|
}
|
|
135
147
|
if (request.method === "GET" && path === "/events") {
|
|
@@ -150,7 +162,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
|
|
|
150
162
|
await handleToolCall(options, hostPort, request, response);
|
|
151
163
|
return;
|
|
152
164
|
}
|
|
153
|
-
sendText(response, 404,
|
|
165
|
+
sendText(response, 404, PLAIN_TEXT, "Not found.");
|
|
154
166
|
return;
|
|
155
167
|
}
|
|
156
168
|
async function handleToolCall(options, hostPort, request, response) {
|
|
@@ -209,6 +221,9 @@ function requestPath(request) {
|
|
|
209
221
|
const queryStart = url.indexOf("?");
|
|
210
222
|
return queryStart === -1 ? url : url.slice(0, queryStart);
|
|
211
223
|
}
|
|
224
|
+
function isServiceWorkerScriptRequest(request) {
|
|
225
|
+
return request.headers["service-worker"] === "script" || request.headers["sec-fetch-dest"] === "serviceworker";
|
|
226
|
+
}
|
|
212
227
|
function hostHeaderIsLocal(host, port) {
|
|
213
228
|
return host === `localhost:${String(port)}` || host === `127.0.0.1:${String(port)}`;
|
|
214
229
|
}
|
package/dist/dev/host-page.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// inside Driggsby. The page carries no token and no data; every tool call
|
|
6
6
|
// goes to the CLI's own /tool-calls endpoint, which holds the token and
|
|
7
7
|
// enforces the allowlist and bounds.
|
|
8
|
+
import { FRAME_POLICY } from "./app-policy.js";
|
|
8
9
|
// The page's script is served as its own file so the page can carry a
|
|
9
10
|
// Content-Security-Policy with no inline scripts.
|
|
10
11
|
//
|
|
@@ -14,7 +15,11 @@
|
|
|
14
15
|
// `appOrigin` is built by startDevServers from a literal plus a bound port
|
|
15
16
|
// number, and `background` has passed readProjectConfig's strict hex-color
|
|
16
17
|
// rule (or is null). Anything looser than those sources must not be
|
|
17
|
-
// passed here.
|
|
18
|
+
// passed here. FRAME_POLICY is a constant with no double quote in it.
|
|
19
|
+
//
|
|
20
|
+
// The frame gets the console's sandbox and, as its csp attribute, the
|
|
21
|
+
// deployed policy (app-policy.ts), which Chrome then requires of every
|
|
22
|
+
// document the frame shows, even one this server didn't send.
|
|
18
23
|
export function hostPageHtml(slug, appOrigin, background) {
|
|
19
24
|
return `<!doctype html>
|
|
20
25
|
<html lang="en" data-app-origin="${appOrigin}">
|
|
@@ -84,7 +89,7 @@ export function hostPageHtml(slug, appOrigin, background) {
|
|
|
84
89
|
<span class="slug">${slug}</span>
|
|
85
90
|
<span class="mode">driggsby dev — your live data</span>
|
|
86
91
|
</header>
|
|
87
|
-
<iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin
|
|
92
|
+
<iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin" csp="${FRAME_POLICY}"></iframe>
|
|
88
93
|
<script type="module" src="/host.js"></script>
|
|
89
94
|
</body>
|
|
90
95
|
</html>
|
package/dist/help.js
CHANGED
|
@@ -64,10 +64,15 @@ app token that lets this machine read your Driggsby data, deploy Driggsby
|
|
|
64
64
|
apps, and manage your transaction rules. The token is saved locally for
|
|
65
65
|
other driggsby commands and is never printed.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
If you approve in a browser on another computer, the page shows a code
|
|
68
|
+
instead. Paste it where login is waiting, or finish with --code.
|
|
69
|
+
|
|
70
|
+
Usage: npx driggsby@latest login [--code <CODE>]
|
|
68
71
|
|
|
69
72
|
Options:
|
|
70
|
-
-
|
|
73
|
+
--code <CODE> Finish the sign-in waiting on this machine with the
|
|
74
|
+
code its approval page showed
|
|
75
|
+
-h, --help Print help
|
|
71
76
|
`;
|
|
72
77
|
export const LOGOUT_HELP = `Remove the saved Driggsby app token.
|
|
73
78
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The wait inside driggsby login, once the approval page is in front of the
|
|
2
|
+
// person: the first of the loopback's code, a pasted code, or the claim
|
|
3
|
+
// going away (declined or expired) ends it. Every code is traded with the
|
|
4
|
+
// verifier; only that trade returns the token.
|
|
5
|
+
import { CliError } from "../cli-error.js";
|
|
6
|
+
import { pollClaimRequest, tradeCode } from "./claim-client.js";
|
|
7
|
+
import {} from "./loopback.js";
|
|
8
|
+
export const LOGIN_RETRY_COMMAND = "npx driggsby@latest login";
|
|
9
|
+
export const CODE_COMMAND = "npx driggsby@latest login --code <CODE>";
|
|
10
|
+
// Approving leaves at least this long to trade the code, even past the
|
|
11
|
+
// link's own lifetime, so a late approval can still finish.
|
|
12
|
+
export const CODE_WINDOW_MS = 300_000;
|
|
13
|
+
// Driggsby's codes are four groups of five; a paste longer than this is
|
|
14
|
+
// not one (the approval link, say), and is refused here without a trade.
|
|
15
|
+
const MAX_CODE_CHARS = 64;
|
|
16
|
+
// A trade that hits a momentary hiccup is tried again this many times.
|
|
17
|
+
const TRADE_ATTEMPTS = 3;
|
|
18
|
+
const TRADE_RETRY_MS = 2_000;
|
|
19
|
+
// A code from the loopback is the only copy there is: when Driggsby can't
|
|
20
|
+
// be reached, the wait keeps trying it this often until the wait ends.
|
|
21
|
+
const LOOPBACK_RETRY_MS = 5_000;
|
|
22
|
+
const CODE_SHAPE_REFUSED = "That doesn't look like a sign-in code. Copy the code the approval page\nshows.";
|
|
23
|
+
const UNREACHABLE = "We couldn't reach Driggsby to finish signing in just now.";
|
|
24
|
+
// The claim went away: declined, expired, or already spent. Distinct, so
|
|
25
|
+
// login can tell it from other failures (another command may have spent it).
|
|
26
|
+
export class ClaimGone extends CliError {
|
|
27
|
+
}
|
|
28
|
+
// The trades in flight. The trade that spends the claim makes the poll
|
|
29
|
+
// read gone before the trade's own answer arrives; the poll waits for any
|
|
30
|
+
// trade in flight so that answer speaks first.
|
|
31
|
+
class Trades {
|
|
32
|
+
#inFlight = 0;
|
|
33
|
+
#started = 0;
|
|
34
|
+
#idle = [];
|
|
35
|
+
get started() {
|
|
36
|
+
return this.#started;
|
|
37
|
+
}
|
|
38
|
+
busy() {
|
|
39
|
+
return this.#inFlight > 0;
|
|
40
|
+
}
|
|
41
|
+
async run(work) {
|
|
42
|
+
this.#inFlight += 1;
|
|
43
|
+
this.#started += 1;
|
|
44
|
+
try {
|
|
45
|
+
return await work();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
this.#inFlight -= 1;
|
|
49
|
+
if (this.#inFlight === 0) {
|
|
50
|
+
for (const resolve of this.#idle.splice(0)) {
|
|
51
|
+
resolve();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
idle() {
|
|
57
|
+
return this.#inFlight === 0 ? Promise.resolve() : new Promise((resolve) => this.#idle.push(resolve));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function awaitToken(sign, clock) {
|
|
61
|
+
const stop = new AbortController();
|
|
62
|
+
const trades = new Trades();
|
|
63
|
+
try {
|
|
64
|
+
const outcome = await Promise.race([
|
|
65
|
+
fromLoopback(sign, clock, trades, stop.signal),
|
|
66
|
+
fromPaste(sign, clock, trades, stop.signal),
|
|
67
|
+
fromPoll(sign, clock, trades, stop.signal),
|
|
68
|
+
]);
|
|
69
|
+
if (outcome.kind === "error") {
|
|
70
|
+
throw outcome.error;
|
|
71
|
+
}
|
|
72
|
+
return outcome.appToken;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
stop.abort();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// A browser on this computer handed over a code, or Driggsby's denial. Its
|
|
79
|
+
// request waits on the loopback until the trade is done, so it lands on a
|
|
80
|
+
// page that already says how it went. Any page on this computer can reach
|
|
81
|
+
// the loopback, so a callback proves nothing by itself: a code that doesn't
|
|
82
|
+
// trade is refused without saying where the approval page is, and a denial
|
|
83
|
+
// counts only once Driggsby confirms the claim is gone.
|
|
84
|
+
async function fromLoopback(sign, clock, trades, signal) {
|
|
85
|
+
if (sign.loopback === null) {
|
|
86
|
+
return never();
|
|
87
|
+
}
|
|
88
|
+
for (;;) {
|
|
89
|
+
const callback = await sign.loopback.next();
|
|
90
|
+
if (stopped(signal)) {
|
|
91
|
+
// The sign-in already ended; the page says how.
|
|
92
|
+
callback.answer(sign.landingUrl);
|
|
93
|
+
return never();
|
|
94
|
+
}
|
|
95
|
+
if (callback.kind === "denied") {
|
|
96
|
+
const mark = trades.started;
|
|
97
|
+
const poll = await pollLettingTradesSpeak(sign, trades, mark);
|
|
98
|
+
if (poll.kind === "approved") {
|
|
99
|
+
// A server older than PKCE handed this poll the token; it counts,
|
|
100
|
+
// as it does in fromPoll.
|
|
101
|
+
callback.answer(sign.landingUrl);
|
|
102
|
+
return { kind: "token", appToken: poll.appToken };
|
|
103
|
+
}
|
|
104
|
+
if (poll.kind === "gone") {
|
|
105
|
+
// A trade in flight may be what spent the claim; it speaks first.
|
|
106
|
+
await settleTrades(trades, mark);
|
|
107
|
+
callback.answer(sign.landingUrl);
|
|
108
|
+
return stopped(signal) ? never() : { kind: "error", error: declined() };
|
|
109
|
+
}
|
|
110
|
+
callback.refuse();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (;;) {
|
|
114
|
+
const result = await trades.run(() => tradeWithRetries(sign, callback.code, clock, signal));
|
|
115
|
+
if (stopped(signal)) {
|
|
116
|
+
callback.refuse();
|
|
117
|
+
return never();
|
|
118
|
+
}
|
|
119
|
+
if (result.kind === "approved") {
|
|
120
|
+
callback.answer(sign.landingUrl);
|
|
121
|
+
return { kind: "token", appToken: result.appToken };
|
|
122
|
+
}
|
|
123
|
+
if (result.kind === "rejected") {
|
|
124
|
+
callback.refuse();
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
// Unreachable: the code may still work, and it's the only copy.
|
|
128
|
+
await clock.sleep(LOOPBACK_RETRY_MS, signal);
|
|
129
|
+
if (stopped(signal)) {
|
|
130
|
+
return never();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// A code pasted at the prompt. A refused code asks again, so a typo costs
|
|
136
|
+
// nothing; the claim's own expiry still ends the wait.
|
|
137
|
+
async function fromPaste(sign, clock, trades, signal) {
|
|
138
|
+
if (sign.prompt === null) {
|
|
139
|
+
return never();
|
|
140
|
+
}
|
|
141
|
+
let question = sign.promptQuestion;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const line = await sign.prompt.ask(question);
|
|
144
|
+
if (stopped(signal) || line === null) {
|
|
145
|
+
return never();
|
|
146
|
+
}
|
|
147
|
+
const code = line.trim();
|
|
148
|
+
if (code === "") {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const result = await trades.run(() => tradeWithRetries(sign, code, clock, signal));
|
|
152
|
+
if (stopped(signal)) {
|
|
153
|
+
return never();
|
|
154
|
+
}
|
|
155
|
+
if (result.kind === "approved") {
|
|
156
|
+
return { kind: "token", appToken: result.appToken };
|
|
157
|
+
}
|
|
158
|
+
question =
|
|
159
|
+
result.kind === "unreachable"
|
|
160
|
+
? `${result.message}\nPaste the code again to retry: `
|
|
161
|
+
: `${result.message}\nCheck the code and paste it again: `;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// The claim's own state: declined or expired ends the wait. An approval
|
|
165
|
+
// waiting for its code still polls as pending, so the wait runs past the
|
|
166
|
+
// link's lifetime by up to the code window, until Driggsby says it's gone.
|
|
167
|
+
// Driggsby never hands a PKCE claim's token to the poll; a server that did
|
|
168
|
+
// (one older than PKCE) is still handing it to the CLI that made the claim,
|
|
169
|
+
// so it counts.
|
|
170
|
+
async function fromPoll(sign, clock, trades, signal) {
|
|
171
|
+
const ceiling = sign.deadline + CODE_WINDOW_MS;
|
|
172
|
+
while (clock.now() < ceiling) {
|
|
173
|
+
await clock.sleep(clock.pollIntervalMs, signal);
|
|
174
|
+
if (stopped(signal)) {
|
|
175
|
+
return never();
|
|
176
|
+
}
|
|
177
|
+
const mark = trades.started;
|
|
178
|
+
const result = await pollLettingTradesSpeak(sign, trades, mark);
|
|
179
|
+
if (stopped(signal)) {
|
|
180
|
+
return never();
|
|
181
|
+
}
|
|
182
|
+
if (result.kind === "approved") {
|
|
183
|
+
return { kind: "token", appToken: result.appToken };
|
|
184
|
+
}
|
|
185
|
+
if (result.kind === "gone") {
|
|
186
|
+
await settleTrades(trades, mark);
|
|
187
|
+
if (stopped(signal)) {
|
|
188
|
+
return never();
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
kind: "error",
|
|
192
|
+
error: new ClaimGone("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
|
|
193
|
+
`Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// A trade still in flight at the ceiling may be the one that spends it.
|
|
198
|
+
await settleTrades(trades, trades.started);
|
|
199
|
+
if (stopped(signal)) {
|
|
200
|
+
return never();
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
kind: "error",
|
|
204
|
+
error: new CliError(`The sign-in link expired before it was approved.\n\nStart a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// The claim's state. A poll that fails outright (an answer Driggsby never
|
|
208
|
+
// gives) ends the wait, but only after any trade in flight speaks: that
|
|
209
|
+
// trade may be what spent the claim, and its token must not be dropped.
|
|
210
|
+
function pollLettingTradesSpeak(sign, trades, mark) {
|
|
211
|
+
return pollClaimRequest(sign.baseUrl, { claimRequestId: sign.claimRequestId, pollSecret: sign.pollSecret }).catch(async (error) => {
|
|
212
|
+
await settleTrades(trades, mark);
|
|
213
|
+
throw error;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Waits out any trade that is in flight, or that started since mark, and
|
|
217
|
+
// then one more turn, so a trade that spent the claim settles the race
|
|
218
|
+
// before a "gone" or "expired" does.
|
|
219
|
+
async function settleTrades(trades, mark) {
|
|
220
|
+
if (!trades.busy() && trades.started === mark) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await trades.idle();
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
225
|
+
}
|
|
226
|
+
export async function tradeWithRetries(sign, code, clock, signal) {
|
|
227
|
+
if (code === "" || code.length > MAX_CODE_CHARS) {
|
|
228
|
+
return { kind: "rejected", message: CODE_SHAPE_REFUSED };
|
|
229
|
+
}
|
|
230
|
+
for (let attempt = 1;; attempt += 1) {
|
|
231
|
+
if (attempt > 1 && signal !== undefined && stopped(signal)) {
|
|
232
|
+
return { kind: "unreachable", message: UNREACHABLE };
|
|
233
|
+
}
|
|
234
|
+
const result = await tradeCode(sign.baseUrl, {
|
|
235
|
+
claimRequestId: sign.claimRequestId,
|
|
236
|
+
code,
|
|
237
|
+
codeVerifier: sign.codeVerifier,
|
|
238
|
+
});
|
|
239
|
+
if (result.kind !== "transient") {
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
if (attempt >= TRADE_ATTEMPTS || (signal !== undefined && stopped(signal))) {
|
|
243
|
+
return { kind: "unreachable", message: UNREACHABLE };
|
|
244
|
+
}
|
|
245
|
+
await clock.sleep(TRADE_RETRY_MS, signal);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// A confirmed denial is the claim gone too, so a login whose sign-in
|
|
249
|
+
// another command finished still tells that apart.
|
|
250
|
+
function declined() {
|
|
251
|
+
return new ClaimGone(`This sign-in was declined.\n\nStart a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
252
|
+
}
|
|
253
|
+
// Read through a call: the signal flips while this code awaits, which a
|
|
254
|
+
// property check narrowed before the await would not see.
|
|
255
|
+
function stopped(signal) {
|
|
256
|
+
return signal.aborted;
|
|
257
|
+
}
|
|
258
|
+
function never() {
|
|
259
|
+
return new Promise(() => undefined);
|
|
260
|
+
}
|