@vibe-commit/capture 0.1.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/agents/claude_code.d.ts +11 -0
- package/dist/agents/claude_code.js +162 -0
- package/dist/agents/claude_code.js.map +1 -0
- package/dist/agents/codex_cli.d.ts +15 -0
- package/dist/agents/codex_cli.js +67 -0
- package/dist/agents/codex_cli.js.map +1 -0
- package/dist/agents/cursor.d.ts +21 -0
- package/dist/agents/cursor.js +89 -0
- package/dist/agents/cursor.js.map +1 -0
- package/dist/agents/registry.d.ts +184 -0
- package/dist/agents/registry.js +212 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/types.d.ts +279 -0
- package/dist/agents/types.js +67 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/commands/connect.d.ts +36 -0
- package/dist/commands/connect.js +499 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/context.d.ts +45 -0
- package/dist/commands/context.js +14 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/report.d.ts +105 -0
- package/dist/commands/report.js +430 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +236 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/why.d.ts +349 -0
- package/dist/commands/why.js +937 -0
- package/dist/commands/why.js.map +1 -0
- package/dist/consent.d.ts +59 -0
- package/dist/consent.js +111 -0
- package/dist/consent.js.map +1 -0
- package/dist/copy/claims.d.ts +79 -0
- package/dist/copy/claims.js +343 -0
- package/dist/copy/claims.js.map +1 -0
- package/dist/copy/grades.d.ts +117 -0
- package/dist/copy/grades.js +96 -0
- package/dist/copy/grades.js.map +1 -0
- package/dist/copy/index.d.ts +12 -0
- package/dist/copy/index.js +13 -0
- package/dist/copy/index.js.map +1 -0
- package/dist/copy/strings.d.ts +1328 -0
- package/dist/copy/strings.js +1243 -0
- package/dist/copy/strings.js.map +1 -0
- package/dist/copy/time.d.ts +27 -0
- package/dist/copy/time.js +43 -0
- package/dist/copy/time.js.map +1 -0
- package/dist/credential.d.ts +211 -0
- package/dist/credential.js +298 -0
- package/dist/credential.js.map +1 -0
- package/dist/exit.d.ts +59 -0
- package/dist/exit.js +60 -0
- package/dist/exit.js.map +1 -0
- package/dist/git.d.ts +299 -0
- package/dist/git.js +653 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks/entry.d.ts +277 -0
- package/dist/hooks/entry.js +1092 -0
- package/dist/hooks/entry.js.map +1 -0
- package/dist/hooks/post_commit.d.ts +59 -0
- package/dist/hooks/post_commit.js +160 -0
- package/dist/hooks/post_commit.js.map +1 -0
- package/dist/hooks/post_rewrite.d.ts +59 -0
- package/dist/hooks/post_rewrite.js +161 -0
- package/dist/hooks/post_rewrite.js.map +1 -0
- package/dist/hooks/session_end.d.ts +101 -0
- package/dist/hooks/session_end.js +120 -0
- package/dist/hooks/session_end.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +153 -0
- package/dist/install.js +284 -0
- package/dist/install.js.map +1 -0
- package/dist/json.d.ts +100 -0
- package/dist/json.js +94 -0
- package/dist/json.js.map +1 -0
- package/dist/oauth/discovery.d.ts +96 -0
- package/dist/oauth/discovery.js +163 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/lock.d.ts +100 -0
- package/dist/oauth/lock.js +287 -0
- package/dist/oauth/lock.js.map +1 -0
- package/dist/oauth/loopback.d.ts +85 -0
- package/dist/oauth/loopback.js +124 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +58 -0
- package/dist/oauth/pkce.js +77 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/read.d.ts +120 -0
- package/dist/oauth/read.js +144 -0
- package/dist/oauth/read.js.map +1 -0
- package/dist/oauth/session.d.ts +80 -0
- package/dist/oauth/session.js +112 -0
- package/dist/oauth/session.js.map +1 -0
- package/dist/oauth/signin.d.ts +178 -0
- package/dist/oauth/signin.js +225 -0
- package/dist/oauth/signin.js.map +1 -0
- package/dist/oauth/token.d.ts +139 -0
- package/dist/oauth/token.js +183 -0
- package/dist/oauth/token.js.map +1 -0
- package/dist/paths.d.ts +366 -0
- package/dist/paths.js +460 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +195 -0
- package/dist/policy.js +244 -0
- package/dist/policy.js.map +1 -0
- package/dist/post.d.ts +302 -0
- package/dist/post.js +384 -0
- package/dist/post.js.map +1 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +192 -0
- package/dist/project.js.map +1 -0
- package/dist/redact.d.ts +175 -0
- package/dist/redact.js +700 -0
- package/dist/redact.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.js +37 -0
- package/dist/runtime.js.map +1 -0
- package/dist/spawn_budget.d.ts +101 -0
- package/dist/spawn_budget.js +124 -0
- package/dist/spawn_budget.js.map +1 -0
- package/dist/spool.d.ts +390 -0
- package/dist/spool.js +556 -0
- package/dist/spool.js.map +1 -0
- package/dist/state.d.ts +109 -0
- package/dist/state.js +217 -0
- package/dist/state.js.map +1 -0
- package/dist/system_message.d.ts +101 -0
- package/dist/system_message.js +141 -0
- package/dist/system_message.js.map +1 -0
- package/dist/term.d.ts +112 -0
- package/dist/term.js +134 -0
- package/dist/term.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-shot loopback listener that catches the authorization redirect.
|
|
3
|
+
*
|
|
4
|
+
* ## ⚠ THE HOST IS THE LITERAL `127.0.0.1`, NEVER `localhost`
|
|
5
|
+
*
|
|
6
|
+
* The server's redirect-URI policy (`vibecommit-mcp/src/oauth/loopback.ts`,
|
|
7
|
+
* read for its RULE and not imported — closed source, D60 §D1a) accepts
|
|
8
|
+
* `http://127.0.0.1:<any port>` and `http://[::1]:<any port>` per RFC 8252 §7.3,
|
|
9
|
+
* and REJECTS the literal string `localhost` with `localhost_disallowed`,
|
|
10
|
+
* because DNS resolution of `localhost` is OS-configurable and could point at a
|
|
11
|
+
* different host. So `redirect_uri` is built from the literal below, and the
|
|
12
|
+
* socket is bound to that same literal.
|
|
13
|
+
*
|
|
14
|
+
* **A client that builds `http://localhost:<port>` fails in a way that looks
|
|
15
|
+
* like a config problem rather than a spelling one** — the browser round-trip
|
|
16
|
+
* works, the listener receives nothing, and the visible symptom is a hang. That
|
|
17
|
+
* is why `test/oauth-loopback.test.ts` asserts the host rather than asserting
|
|
18
|
+
* that the flow completes.
|
|
19
|
+
*
|
|
20
|
+
* ## What this listener refuses
|
|
21
|
+
*
|
|
22
|
+
* `state` (RFC 6749 §10.12) and `iss` (RFC 9207) are both checked, and a request
|
|
23
|
+
* failing either is answered 400 and **does not end the wait**. Ending the wait
|
|
24
|
+
* on a bad request would let anything able to reach the port cancel a sign-in;
|
|
25
|
+
* accepting it would be a CSRF hole with a green happy-path suite over it, which
|
|
26
|
+
* is the specific vacuity this task was briefed on.
|
|
27
|
+
*
|
|
28
|
+
* @provenance vibecommit-mcp src/oauth/loopback.ts — redirect-URI policy, retyped
|
|
29
|
+
* @provenance vibecommit-mcp src/transport/server.ts — the code+state+iss handoff, retyped
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The literal the server's policy admits. **Not a default — a requirement.**
|
|
33
|
+
* Changing this to `localhost` is refused by the authorization server.
|
|
34
|
+
*/
|
|
35
|
+
export declare const LOOPBACK_HOST = "127.0.0.1";
|
|
36
|
+
/** The path the redirect lands on. Any path is legal; a fixed one is readable. */
|
|
37
|
+
export declare const CALLBACK_PATH = "/callback";
|
|
38
|
+
export type Callback =
|
|
39
|
+
/** The authorization code, with `state` and `iss` already verified. */
|
|
40
|
+
{
|
|
41
|
+
readonly kind: "code";
|
|
42
|
+
readonly code: string;
|
|
43
|
+
}
|
|
44
|
+
/** The AS redirected with an OAuth error — `access_denied` when a user declines. */
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: "denied";
|
|
47
|
+
readonly error: string;
|
|
48
|
+
}
|
|
49
|
+
/** The deadline passed with no acceptable callback. */
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "timeout";
|
|
52
|
+
};
|
|
53
|
+
export interface LoopbackOptions {
|
|
54
|
+
/** The exact `state` this attempt sent. */
|
|
55
|
+
readonly expectedState: string;
|
|
56
|
+
/**
|
|
57
|
+
* The issuer discovered from RFC 8414 metadata. RFC 9207 requires the
|
|
58
|
+
* authorization response to carry it, and D26 makes the comparison
|
|
59
|
+
* EXACT-STRING — scheme, host, apex-vs-www and trailing slash all count.
|
|
60
|
+
*/
|
|
61
|
+
readonly expectedIssuer: string;
|
|
62
|
+
/** How long to wait before giving up. The browser round-trip includes a login. */
|
|
63
|
+
readonly deadlineMs: number;
|
|
64
|
+
/** What the browser tab shows. Copy lives in `src/copy/`, never here. */
|
|
65
|
+
readonly page: {
|
|
66
|
+
readonly done: string;
|
|
67
|
+
readonly refused: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export interface Loopback {
|
|
71
|
+
/** `http://127.0.0.1:<ephemeral>/callback` — presented as `redirect_uri`. */
|
|
72
|
+
readonly redirectUri: string;
|
|
73
|
+
/** Resolves once, on the first acceptable callback, a denial, or the deadline. */
|
|
74
|
+
readonly result: Promise<Callback>;
|
|
75
|
+
/** Idempotent. Always call it — a listening socket outliving the flow is a bug. */
|
|
76
|
+
close(): void;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Bind an ephemeral loopback port and wait for one authorization redirect.
|
|
80
|
+
*
|
|
81
|
+
* Port 0 rather than a fixed port: RFC 8252 §7.3 requires the AS to accept any
|
|
82
|
+
* port on the loopback for exactly this reason, and a fixed port collides with a
|
|
83
|
+
* second terminal and with whatever else on the machine wanted it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function listenForCallback(opts: LoopbackOptions): Promise<Loopback>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-shot loopback listener that catches the authorization redirect.
|
|
3
|
+
*
|
|
4
|
+
* ## ⚠ THE HOST IS THE LITERAL `127.0.0.1`, NEVER `localhost`
|
|
5
|
+
*
|
|
6
|
+
* The server's redirect-URI policy (`vibecommit-mcp/src/oauth/loopback.ts`,
|
|
7
|
+
* read for its RULE and not imported — closed source, D60 §D1a) accepts
|
|
8
|
+
* `http://127.0.0.1:<any port>` and `http://[::1]:<any port>` per RFC 8252 §7.3,
|
|
9
|
+
* and REJECTS the literal string `localhost` with `localhost_disallowed`,
|
|
10
|
+
* because DNS resolution of `localhost` is OS-configurable and could point at a
|
|
11
|
+
* different host. So `redirect_uri` is built from the literal below, and the
|
|
12
|
+
* socket is bound to that same literal.
|
|
13
|
+
*
|
|
14
|
+
* **A client that builds `http://localhost:<port>` fails in a way that looks
|
|
15
|
+
* like a config problem rather than a spelling one** — the browser round-trip
|
|
16
|
+
* works, the listener receives nothing, and the visible symptom is a hang. That
|
|
17
|
+
* is why `test/oauth-loopback.test.ts` asserts the host rather than asserting
|
|
18
|
+
* that the flow completes.
|
|
19
|
+
*
|
|
20
|
+
* ## What this listener refuses
|
|
21
|
+
*
|
|
22
|
+
* `state` (RFC 6749 §10.12) and `iss` (RFC 9207) are both checked, and a request
|
|
23
|
+
* failing either is answered 400 and **does not end the wait**. Ending the wait
|
|
24
|
+
* on a bad request would let anything able to reach the port cancel a sign-in;
|
|
25
|
+
* accepting it would be a CSRF hole with a green happy-path suite over it, which
|
|
26
|
+
* is the specific vacuity this task was briefed on.
|
|
27
|
+
*
|
|
28
|
+
* @provenance vibecommit-mcp src/oauth/loopback.ts — redirect-URI policy, retyped
|
|
29
|
+
* @provenance vibecommit-mcp src/transport/server.ts — the code+state+iss handoff, retyped
|
|
30
|
+
*/
|
|
31
|
+
import { createServer } from "node:http";
|
|
32
|
+
import { statesMatch } from "./pkce.js";
|
|
33
|
+
/**
|
|
34
|
+
* The literal the server's policy admits. **Not a default — a requirement.**
|
|
35
|
+
* Changing this to `localhost` is refused by the authorization server.
|
|
36
|
+
*/
|
|
37
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
38
|
+
/** The path the redirect lands on. Any path is legal; a fixed one is readable. */
|
|
39
|
+
export const CALLBACK_PATH = "/callback";
|
|
40
|
+
/**
|
|
41
|
+
* Bind an ephemeral loopback port and wait for one authorization redirect.
|
|
42
|
+
*
|
|
43
|
+
* Port 0 rather than a fixed port: RFC 8252 §7.3 requires the AS to accept any
|
|
44
|
+
* port on the loopback for exactly this reason, and a fixed port collides with a
|
|
45
|
+
* second terminal and with whatever else on the machine wanted it.
|
|
46
|
+
*/
|
|
47
|
+
export async function listenForCallback(opts) {
|
|
48
|
+
let settle;
|
|
49
|
+
const result = new Promise((resolve) => {
|
|
50
|
+
settle = resolve;
|
|
51
|
+
});
|
|
52
|
+
const server = createServer((req, res) => {
|
|
53
|
+
// `req.url` is a path with a query, so it needs a base to parse. The base is
|
|
54
|
+
// this listener's own address and is never used for anything else.
|
|
55
|
+
const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
56
|
+
const verdict = classify(url.searchParams, opts);
|
|
57
|
+
if (verdict === null) {
|
|
58
|
+
// Neither acceptable nor a recognised denial: answer, and keep waiting.
|
|
59
|
+
// A stray request must not be able to cancel somebody's sign-in.
|
|
60
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
61
|
+
res.end(opts.page.refused);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
65
|
+
res.end(verdict.kind === "code" ? opts.page.done : opts.page.refused);
|
|
66
|
+
finish(verdict);
|
|
67
|
+
});
|
|
68
|
+
const timer = setTimeout(() => finish({ kind: "timeout" }), opts.deadlineMs);
|
|
69
|
+
// The deadline must not by itself hold the process open — the flow is awaited
|
|
70
|
+
// elsewhere, and a stray timer keeping Node alive after a `close()` is the
|
|
71
|
+
// same class of bug as the socket this module is careful to shut.
|
|
72
|
+
timer.unref();
|
|
73
|
+
let closed = false;
|
|
74
|
+
const close = () => {
|
|
75
|
+
if (closed)
|
|
76
|
+
return;
|
|
77
|
+
closed = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
// ⚠ `close()` alone waits for KEEP-ALIVE sockets to go idle, and a browser
|
|
80
|
+
// holds one open after the tab renders. Without this the CLI sits there with
|
|
81
|
+
// the code already exchanged, which reads to a user as a hang at the last
|
|
82
|
+
// step. `closeAllConnections` is why the listener actually goes away.
|
|
83
|
+
server.closeAllConnections();
|
|
84
|
+
server.close();
|
|
85
|
+
};
|
|
86
|
+
const finish = (value) => {
|
|
87
|
+
close();
|
|
88
|
+
settle(value);
|
|
89
|
+
};
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
server.once("error", reject);
|
|
92
|
+
// The LITERAL, both here and in the redirect_uri below. See the module note.
|
|
93
|
+
server.listen(0, LOOPBACK_HOST, resolve);
|
|
94
|
+
});
|
|
95
|
+
const { port } = server.address();
|
|
96
|
+
return { redirectUri: `http://${LOOPBACK_HOST}:${port}${CALLBACK_PATH}`, result, close };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Decide what one inbound request is: the callback, a denial, or noise.
|
|
100
|
+
*
|
|
101
|
+
* Null means "not this attempt's callback" — the caller keeps waiting. The
|
|
102
|
+
* ORDER matters: `state` is checked before anything is read out of the query,
|
|
103
|
+
* so an unauthenticated caller cannot use the `error` branch to end a wait it
|
|
104
|
+
* does not own.
|
|
105
|
+
*/
|
|
106
|
+
function classify(params, opts) {
|
|
107
|
+
const state = params.get("state");
|
|
108
|
+
if (state === null || !statesMatch(opts.expectedState, state))
|
|
109
|
+
return null;
|
|
110
|
+
// RFC 9207 issuer identification, D26's exact-string match. Applied to the
|
|
111
|
+
// ERROR redirect too: a mix-up attack is not less of one because the response
|
|
112
|
+
// it forged happens to be a refusal.
|
|
113
|
+
const iss = params.get("iss");
|
|
114
|
+
if (iss !== opts.expectedIssuer)
|
|
115
|
+
return null;
|
|
116
|
+
const error = params.get("error");
|
|
117
|
+
if (error !== null && error !== "")
|
|
118
|
+
return { kind: "denied", error };
|
|
119
|
+
const code = params.get("code");
|
|
120
|
+
if (code === null || code === "")
|
|
121
|
+
return null;
|
|
122
|
+
return { kind: "code", code };
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=loopback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../src/oauth/loopback.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) and the authorization-request `state` — `CR-084d`.
|
|
3
|
+
*
|
|
4
|
+
* Two secrets are minted here and both are one-shot: the `code_verifier`, which
|
|
5
|
+
* is what stops an attacker who steals the authorization code from exchanging
|
|
6
|
+
* it, and `state`, which is what stops an attacker who can reach the loopback
|
|
7
|
+
* listener from injecting THEIR code into OUR flow.
|
|
8
|
+
*
|
|
9
|
+
* **`S256` is the only method, and it is a TYPE and not a default.** The server
|
|
10
|
+
* advertises `code_challenge_methods_supported: ["S256"]` and rejects `plain`
|
|
11
|
+
* (its `verifyPkce` recomputes the SHA-256 unconditionally), so a client that
|
|
12
|
+
* downgraded would not be refused by an obvious error — it would send a
|
|
13
|
+
* challenge the server hashes anyway and fail as `invalid_grant`, which reads as
|
|
14
|
+
* a config problem. Making the literal the field's type means the downgrade
|
|
15
|
+
* cannot compile, and `test/oauth-pkce.test.ts` proves the challenge really is
|
|
16
|
+
* the digest rather than the verifier copied across.
|
|
17
|
+
*
|
|
18
|
+
* @provenance vibecommit-mcp src/oauth/token.ts — `verifyPkce` base64url-of-SHA-256 rule, retyped
|
|
19
|
+
* @provenance vibecommit-mcp src/oauth/discovery.ts — `code_challenge_methods_supported`, retyped
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The one supported challenge method. Declared `const` so it is a literal type:
|
|
23
|
+
* `method: "S256"` cannot be assigned `"plain"` anywhere downstream.
|
|
24
|
+
*/
|
|
25
|
+
export declare const CHALLENGE_METHOD: "S256";
|
|
26
|
+
export type ChallengeMethod = typeof CHALLENGE_METHOD;
|
|
27
|
+
export interface PkcePair {
|
|
28
|
+
/** Secret. Sent ONLY to the token endpoint, never on the authorize URL. */
|
|
29
|
+
readonly verifier: string;
|
|
30
|
+
/** Public. Sent on the authorize URL; safe to log. */
|
|
31
|
+
readonly challenge: string;
|
|
32
|
+
readonly method: ChallengeMethod;
|
|
33
|
+
}
|
|
34
|
+
/** RFC 4648 §5 base64url, unpadded — the encoding both ends of PKCE use. */
|
|
35
|
+
export declare function base64url(bytes: Buffer): string;
|
|
36
|
+
/**
|
|
37
|
+
* Mint a fresh verifier and its challenge.
|
|
38
|
+
*
|
|
39
|
+
* `randomBytes` and not `Math.random`: this is the value an attacker must guess
|
|
40
|
+
* to exchange a stolen code, so it comes from the CSPRNG or it is decoration.
|
|
41
|
+
*/
|
|
42
|
+
export declare function generatePkce(): PkcePair;
|
|
43
|
+
/** base64url(SHA-256(verifier)) — the server recomputes exactly this. */
|
|
44
|
+
export declare function challengeFor(verifier: string): string;
|
|
45
|
+
/** A fresh per-attempt `state`. Never reused across sign-ins. */
|
|
46
|
+
export declare function generateState(): string;
|
|
47
|
+
/**
|
|
48
|
+
* Compare two `state` values without leaking the position of the first
|
|
49
|
+
* difference.
|
|
50
|
+
*
|
|
51
|
+
* The timing channel here is thin — an attacker would need many attempts against
|
|
52
|
+
* a listener that closes after one — but the comparison is one line either way,
|
|
53
|
+
* and `===` on a secret is the habit that matters. Lengths are compared first
|
|
54
|
+
* because `timingSafeEqual` THROWS on a length mismatch rather than returning
|
|
55
|
+
* false, which would turn a wrong-length callback into an unhandled exception on
|
|
56
|
+
* the sign-in path.
|
|
57
|
+
*/
|
|
58
|
+
export declare function statesMatch(expected: string, presented: string): boolean;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) and the authorization-request `state` — `CR-084d`.
|
|
3
|
+
*
|
|
4
|
+
* Two secrets are minted here and both are one-shot: the `code_verifier`, which
|
|
5
|
+
* is what stops an attacker who steals the authorization code from exchanging
|
|
6
|
+
* it, and `state`, which is what stops an attacker who can reach the loopback
|
|
7
|
+
* listener from injecting THEIR code into OUR flow.
|
|
8
|
+
*
|
|
9
|
+
* **`S256` is the only method, and it is a TYPE and not a default.** The server
|
|
10
|
+
* advertises `code_challenge_methods_supported: ["S256"]` and rejects `plain`
|
|
11
|
+
* (its `verifyPkce` recomputes the SHA-256 unconditionally), so a client that
|
|
12
|
+
* downgraded would not be refused by an obvious error — it would send a
|
|
13
|
+
* challenge the server hashes anyway and fail as `invalid_grant`, which reads as
|
|
14
|
+
* a config problem. Making the literal the field's type means the downgrade
|
|
15
|
+
* cannot compile, and `test/oauth-pkce.test.ts` proves the challenge really is
|
|
16
|
+
* the digest rather than the verifier copied across.
|
|
17
|
+
*
|
|
18
|
+
* @provenance vibecommit-mcp src/oauth/token.ts — `verifyPkce` base64url-of-SHA-256 rule, retyped
|
|
19
|
+
* @provenance vibecommit-mcp src/oauth/discovery.ts — `code_challenge_methods_supported`, retyped
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
|
+
/**
|
|
23
|
+
* The one supported challenge method. Declared `const` so it is a literal type:
|
|
24
|
+
* `method: "S256"` cannot be assigned `"plain"` anywhere downstream.
|
|
25
|
+
*/
|
|
26
|
+
export const CHALLENGE_METHOD = "S256";
|
|
27
|
+
/**
|
|
28
|
+
* 32 random bytes, which base64url-encode to exactly 43 characters — the FLOOR
|
|
29
|
+
* of RFC 7636 §4.1's 43..128 range, and the server enforces that range verbatim
|
|
30
|
+
* (`codeVerifier.length < 43 || > 128` → PKCE mismatch). 32 bytes is also the
|
|
31
|
+
* size the digest itself is, so a longer verifier buys no entropy the challenge
|
|
32
|
+
* can carry.
|
|
33
|
+
*/
|
|
34
|
+
const VERIFIER_BYTES = 32;
|
|
35
|
+
/** `state` needs unguessability, not length. 32 bytes matches the verifier. */
|
|
36
|
+
const STATE_BYTES = 32;
|
|
37
|
+
/** RFC 4648 §5 base64url, unpadded — the encoding both ends of PKCE use. */
|
|
38
|
+
export function base64url(bytes) {
|
|
39
|
+
return bytes.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Mint a fresh verifier and its challenge.
|
|
43
|
+
*
|
|
44
|
+
* `randomBytes` and not `Math.random`: this is the value an attacker must guess
|
|
45
|
+
* to exchange a stolen code, so it comes from the CSPRNG or it is decoration.
|
|
46
|
+
*/
|
|
47
|
+
export function generatePkce() {
|
|
48
|
+
const verifier = base64url(randomBytes(VERIFIER_BYTES));
|
|
49
|
+
return { verifier, challenge: challengeFor(verifier), method: CHALLENGE_METHOD };
|
|
50
|
+
}
|
|
51
|
+
/** base64url(SHA-256(verifier)) — the server recomputes exactly this. */
|
|
52
|
+
export function challengeFor(verifier) {
|
|
53
|
+
return base64url(createHash("sha256").update(verifier).digest());
|
|
54
|
+
}
|
|
55
|
+
/** A fresh per-attempt `state`. Never reused across sign-ins. */
|
|
56
|
+
export function generateState() {
|
|
57
|
+
return base64url(randomBytes(STATE_BYTES));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Compare two `state` values without leaking the position of the first
|
|
61
|
+
* difference.
|
|
62
|
+
*
|
|
63
|
+
* The timing channel here is thin — an attacker would need many attempts against
|
|
64
|
+
* a listener that closes after one — but the comparison is one line either way,
|
|
65
|
+
* and `===` on a secret is the habit that matters. Lengths are compared first
|
|
66
|
+
* because `timingSafeEqual` THROWS on a length mismatch rather than returning
|
|
67
|
+
* false, which would turn a wrong-length callback into an unhandled exception on
|
|
68
|
+
* the sign-in path.
|
|
69
|
+
*/
|
|
70
|
+
export function statesMatch(expected, presented) {
|
|
71
|
+
const a = Buffer.from(expected, "utf8");
|
|
72
|
+
const b = Buffer.from(presented, "utf8");
|
|
73
|
+
if (a.length !== b.length)
|
|
74
|
+
return false;
|
|
75
|
+
return timingSafeEqual(a, b);
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=pkce.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.js","sourceRoot":"","sources":["../../src/oauth/pkce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEvE;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAe,CAAC;AAGhD;;;;;;GAMG;AACH,MAAM,cAAc,GAAG,EAAE,CAAC;AAE1B,+EAA+E;AAC/E,MAAM,WAAW,GAAG,EAAE,CAAC;AAUvB,4EAA4E;AAC5E,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,OAAO,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;AAC7F,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IAC1B,MAAM,QAAQ,GAAG,SAAS,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,CAAC;IACxD,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;AACnF,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;AACnE,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,aAAa;IAC3B,OAAO,SAAS,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,QAAgB,EAAE,SAAiB;IAC7D,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACxC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IACzC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IACxC,OAAO,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read transport: ONE `tools/call` POST to the EXISTING `/mcp` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is not
|
|
5
|
+
*
|
|
6
|
+
* There is no REST `/read` route, no second Fly app, no second hostname and no
|
|
7
|
+
* second PRM document (D73, which closed `CR-083` as unnecessary and recorded
|
|
8
|
+
* the "2nd Fly app (read lane)" provisioning item as CLOSED — NOT NEEDED). A
|
|
9
|
+
* read is the same endpoint the agent already talks to, with a user-principal
|
|
10
|
+
* bearer instead of a machine one.
|
|
11
|
+
*
|
|
12
|
+
* There is also no third audience set. The access token is minted at
|
|
13
|
+
* `mcpAcceptedAudiences`, which is typed `readonly [string]` and populated as a
|
|
14
|
+
* ONE-element tuple. `readAcceptedAudiences` is GONE — `CR-084b` deleted it
|
|
15
|
+
* (merged after this client was written, re-verified against it), and the claim
|
|
16
|
+
* it encoded (that a machine credential can read) was false as built anyway: a
|
|
17
|
+
* `vcik_` is opaque base64url with no dots, so the JWT verifier rejects it
|
|
18
|
+
* before any audience check runs.
|
|
19
|
+
*
|
|
20
|
+
* ## The envelope
|
|
21
|
+
*
|
|
22
|
+
* A successful `tools/call` returns `result.content[0].text` carrying a JSON
|
|
23
|
+
* DOCUMENT — every tool in the server ends
|
|
24
|
+
* `{ content: [{ type: "text", text: JSON.stringify(payload) }] }`. So the
|
|
25
|
+
* caller parses that string; it does not read a typed result off the JSON-RPC
|
|
26
|
+
* envelope directly.
|
|
27
|
+
*
|
|
28
|
+
* ⚠ A tool's OWN failures travel the same way — one text part with a JSON body,
|
|
29
|
+
* plus `isError: true`. They are not JSON-RPC errors and not HTTP errors, and a
|
|
30
|
+
* caller that only branched on the transport would read one as success. So
|
|
31
|
+
* `document` is returned uninterpreted with `isError` beside it: parse ONCE,
|
|
32
|
+
* branch after, which is exactly what the server's seam says this client does.
|
|
33
|
+
*
|
|
34
|
+
* `CR-084b` pins both forms with a golden test and names this client as the
|
|
35
|
+
* envelope's external consumer, so the shape is a contract with an owner rather
|
|
36
|
+
* than a convention that happens to hold.
|
|
37
|
+
*
|
|
38
|
+
* @provenance vibecommit-mcp src/handshake/router.ts — JSON-RPC methods, retyped
|
|
39
|
+
* @provenance vibecommit-mcp src/handshake/tools.ts — `tools/call` params, retyped
|
|
40
|
+
* @provenance vibecommit-mcp src/read/envelope.ts — `content[0].text` envelope, retyped
|
|
41
|
+
*
|
|
42
|
+
* ⚠ THAT THIRD CITATION WAS RE-POINTED, NOT ADDED (`CR-163`, D136 §2). It named
|
|
43
|
+
* `src/tools/query_history.ts`, and **no such file exists**: `query_history` was
|
|
44
|
+
* retired by `CR-076b` and now has an entry in `vibecommit-mcp/src/tools/
|
|
45
|
+
* retired.ts` that returns a migration error. A dangling citation is worse than
|
|
46
|
+
* none (D54) — it points a reader at a file they cannot open and stops them
|
|
47
|
+
* looking further.
|
|
48
|
+
*
|
|
49
|
+
* ⚠ AND THE TAG NAME IS NOT SPELLED OUT IN PROSE ANYWHERE BELOW THIS LINE.
|
|
50
|
+
* `test/provenance.test.ts` counts the tags in this file by scanning for the
|
|
51
|
+
* literal token, so writing it inside a sentence adds a fourth "tag" that cannot
|
|
52
|
+
* parse and turns the gate RED against correct prose. It did, on the first
|
|
53
|
+
* wording of this note. Same species as the linkage wall reading `from` plus a
|
|
54
|
+
* quoted phrase as an import — see `src/json.ts` and `src/commands/why.ts`.
|
|
55
|
+
*
|
|
56
|
+
* The envelope is still real and still has a source: `readEnvelope()` in
|
|
57
|
+
* `vibecommit-mcp/src/read/envelope.ts` is the single function that builds
|
|
58
|
+
* `{ content: [{ type: "text", text: JSON.stringify(payload) }] }`, and
|
|
59
|
+
* `readErrorEnvelope()` beside it builds the `isError` form. Both live read
|
|
60
|
+
* tools return them — verified 2026-08-16 against `vibecommit-mcp` `main`:
|
|
61
|
+
* `src/tools/blame_commit.ts` and `src/tools/commit_coverage.ts` each import
|
|
62
|
+
* from that module and return it. The citation is MUTUAL: that file names this
|
|
63
|
+
* client as the envelope's external consumer by name.
|
|
64
|
+
*/
|
|
65
|
+
import type { AccessToken } from "../credential.js";
|
|
66
|
+
export type ReadOutcome =
|
|
67
|
+
/**
|
|
68
|
+
* `content[0].text`, JSON-parsed.
|
|
69
|
+
*
|
|
70
|
+
* `isError` is MCP's flag for a failed call and is part of the SAME envelope:
|
|
71
|
+
* the error form carries one text part with a JSON body, plus `isError: true`.
|
|
72
|
+
* That is what lets this client `JSON.parse` ONCE and branch afterwards,
|
|
73
|
+
* rather than needing to know which shape it is about to receive.
|
|
74
|
+
*
|
|
75
|
+
* ⚠ Surfaced rather than dropped because it is a documented field of a
|
|
76
|
+
* contract with an external owner. `document` alone would still carry
|
|
77
|
+
* `{ error, detail }`, so a caller COULD infer the failure by sniffing for an
|
|
78
|
+
* `error` key — and inferring a fact the wire states outright is how a caller
|
|
79
|
+
* ends up treating a legitimate payload that has an `error` field as a failure.
|
|
80
|
+
*/
|
|
81
|
+
{
|
|
82
|
+
readonly kind: "ok";
|
|
83
|
+
readonly document: unknown;
|
|
84
|
+
readonly isError: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The bearer was rejected. A FIRST-CLASS OUTCOME so the caller can refresh
|
|
88
|
+
* ONCE and retry ONCE — never in a loop. A loop here would present a rotated
|
|
89
|
+
* refresh token repeatedly and end in a family revoke, which is a signed-out
|
|
90
|
+
* user produced by the error handling rather than by the error.
|
|
91
|
+
*/
|
|
92
|
+
| {
|
|
93
|
+
readonly kind: "unauthorized";
|
|
94
|
+
}
|
|
95
|
+
/** A JSON-RPC error object. Data, not an exception (`CR-084d` scope). */
|
|
96
|
+
| {
|
|
97
|
+
readonly kind: "rpc-error";
|
|
98
|
+
readonly code: number;
|
|
99
|
+
readonly message: string;
|
|
100
|
+
}
|
|
101
|
+
/** No response at all — DNS, refused, TLS, timeout. */
|
|
102
|
+
| {
|
|
103
|
+
readonly kind: "unreachable";
|
|
104
|
+
}
|
|
105
|
+
/** A 200 whose body is not the envelope. Never treated as success. */
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: "malformed";
|
|
108
|
+
};
|
|
109
|
+
export interface ReadDeps {
|
|
110
|
+
readonly fetch: typeof fetch;
|
|
111
|
+
readonly timeoutMs: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Call one read tool.
|
|
115
|
+
*
|
|
116
|
+
* `id` is fixed at 1: this is a single request/response over a fresh connection
|
|
117
|
+
* with no batching and no notifications, so a counter would be state kept for
|
|
118
|
+
* nobody. The server echoes it and nothing correlates on it.
|
|
119
|
+
*/
|
|
120
|
+
export declare function callTool(mcpEndpoint: string, access: AccessToken, name: string, args: Record<string, unknown>, deps: ReadDeps): Promise<ReadOutcome>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read transport: ONE `tools/call` POST to the EXISTING `/mcp` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is not
|
|
5
|
+
*
|
|
6
|
+
* There is no REST `/read` route, no second Fly app, no second hostname and no
|
|
7
|
+
* second PRM document (D73, which closed `CR-083` as unnecessary and recorded
|
|
8
|
+
* the "2nd Fly app (read lane)" provisioning item as CLOSED — NOT NEEDED). A
|
|
9
|
+
* read is the same endpoint the agent already talks to, with a user-principal
|
|
10
|
+
* bearer instead of a machine one.
|
|
11
|
+
*
|
|
12
|
+
* There is also no third audience set. The access token is minted at
|
|
13
|
+
* `mcpAcceptedAudiences`, which is typed `readonly [string]` and populated as a
|
|
14
|
+
* ONE-element tuple. `readAcceptedAudiences` is GONE — `CR-084b` deleted it
|
|
15
|
+
* (merged after this client was written, re-verified against it), and the claim
|
|
16
|
+
* it encoded (that a machine credential can read) was false as built anyway: a
|
|
17
|
+
* `vcik_` is opaque base64url with no dots, so the JWT verifier rejects it
|
|
18
|
+
* before any audience check runs.
|
|
19
|
+
*
|
|
20
|
+
* ## The envelope
|
|
21
|
+
*
|
|
22
|
+
* A successful `tools/call` returns `result.content[0].text` carrying a JSON
|
|
23
|
+
* DOCUMENT — every tool in the server ends
|
|
24
|
+
* `{ content: [{ type: "text", text: JSON.stringify(payload) }] }`. So the
|
|
25
|
+
* caller parses that string; it does not read a typed result off the JSON-RPC
|
|
26
|
+
* envelope directly.
|
|
27
|
+
*
|
|
28
|
+
* ⚠ A tool's OWN failures travel the same way — one text part with a JSON body,
|
|
29
|
+
* plus `isError: true`. They are not JSON-RPC errors and not HTTP errors, and a
|
|
30
|
+
* caller that only branched on the transport would read one as success. So
|
|
31
|
+
* `document` is returned uninterpreted with `isError` beside it: parse ONCE,
|
|
32
|
+
* branch after, which is exactly what the server's seam says this client does.
|
|
33
|
+
*
|
|
34
|
+
* `CR-084b` pins both forms with a golden test and names this client as the
|
|
35
|
+
* envelope's external consumer, so the shape is a contract with an owner rather
|
|
36
|
+
* than a convention that happens to hold.
|
|
37
|
+
*
|
|
38
|
+
* @provenance vibecommit-mcp src/handshake/router.ts — JSON-RPC methods, retyped
|
|
39
|
+
* @provenance vibecommit-mcp src/handshake/tools.ts — `tools/call` params, retyped
|
|
40
|
+
* @provenance vibecommit-mcp src/read/envelope.ts — `content[0].text` envelope, retyped
|
|
41
|
+
*
|
|
42
|
+
* ⚠ THAT THIRD CITATION WAS RE-POINTED, NOT ADDED (`CR-163`, D136 §2). It named
|
|
43
|
+
* `src/tools/query_history.ts`, and **no such file exists**: `query_history` was
|
|
44
|
+
* retired by `CR-076b` and now has an entry in `vibecommit-mcp/src/tools/
|
|
45
|
+
* retired.ts` that returns a migration error. A dangling citation is worse than
|
|
46
|
+
* none (D54) — it points a reader at a file they cannot open and stops them
|
|
47
|
+
* looking further.
|
|
48
|
+
*
|
|
49
|
+
* ⚠ AND THE TAG NAME IS NOT SPELLED OUT IN PROSE ANYWHERE BELOW THIS LINE.
|
|
50
|
+
* `test/provenance.test.ts` counts the tags in this file by scanning for the
|
|
51
|
+
* literal token, so writing it inside a sentence adds a fourth "tag" that cannot
|
|
52
|
+
* parse and turns the gate RED against correct prose. It did, on the first
|
|
53
|
+
* wording of this note. Same species as the linkage wall reading `from` plus a
|
|
54
|
+
* quoted phrase as an import — see `src/json.ts` and `src/commands/why.ts`.
|
|
55
|
+
*
|
|
56
|
+
* The envelope is still real and still has a source: `readEnvelope()` in
|
|
57
|
+
* `vibecommit-mcp/src/read/envelope.ts` is the single function that builds
|
|
58
|
+
* `{ content: [{ type: "text", text: JSON.stringify(payload) }] }`, and
|
|
59
|
+
* `readErrorEnvelope()` beside it builds the `isError` form. Both live read
|
|
60
|
+
* tools return them — verified 2026-08-16 against `vibecommit-mcp` `main`:
|
|
61
|
+
* `src/tools/blame_commit.ts` and `src/tools/commit_coverage.ts` each import
|
|
62
|
+
* from that module and return it. The citation is MUTUAL: that file names this
|
|
63
|
+
* client as the envelope's external consumer by name.
|
|
64
|
+
*/
|
|
65
|
+
/** JSON-RPC 2.0. The server rejects anything else at `validateRequest`. */
|
|
66
|
+
const JSONRPC_VERSION = "2.0";
|
|
67
|
+
/**
|
|
68
|
+
* Call one read tool.
|
|
69
|
+
*
|
|
70
|
+
* `id` is fixed at 1: this is a single request/response over a fresh connection
|
|
71
|
+
* with no batching and no notifications, so a counter would be state kept for
|
|
72
|
+
* nobody. The server echoes it and nothing correlates on it.
|
|
73
|
+
*/
|
|
74
|
+
export async function callTool(mcpEndpoint, access, name, args, deps) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = await deps.fetch(mcpEndpoint, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
// The one place this token's plaintext is used — the request boundary,
|
|
83
|
+
// the same discipline `post.ts` states for the ingest credential.
|
|
84
|
+
authorization: `Bearer ${access.expose()}`,
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
accept: "application/json",
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
jsonrpc: JSONRPC_VERSION,
|
|
90
|
+
id: 1,
|
|
91
|
+
method: "tools/call",
|
|
92
|
+
params: { name, arguments: args },
|
|
93
|
+
}),
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
// undici strips Authorization across origins, so a followed redirect would
|
|
96
|
+
// arrive unauthenticated and read as a mysterious 401 — and one that did
|
|
97
|
+
// NOT cross origins would carry the bearer somewhere unconfigured.
|
|
98
|
+
redirect: "error",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { kind: "unreachable" };
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
if (res.status === 401)
|
|
108
|
+
return { kind: "unauthorized" };
|
|
109
|
+
let body;
|
|
110
|
+
try {
|
|
111
|
+
body = (await res.json());
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return { kind: "malformed" };
|
|
115
|
+
}
|
|
116
|
+
const envelope = body;
|
|
117
|
+
if (envelope.error !== undefined && envelope.error !== null) {
|
|
118
|
+
const { code, message } = envelope.error;
|
|
119
|
+
return {
|
|
120
|
+
kind: "rpc-error",
|
|
121
|
+
code: typeof code === "number" ? code : 0,
|
|
122
|
+
message: typeof message === "string" ? message : "",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const content = envelope.result?.content;
|
|
126
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
127
|
+
return { kind: "malformed" };
|
|
128
|
+
const text = content[0].text;
|
|
129
|
+
if (typeof text !== "string")
|
|
130
|
+
return { kind: "malformed" };
|
|
131
|
+
try {
|
|
132
|
+
return {
|
|
133
|
+
kind: "ok",
|
|
134
|
+
document: JSON.parse(text),
|
|
135
|
+
// Strictly `=== true`: the field is OPTIONAL on the wire, so an absent one
|
|
136
|
+
// must read as "not an error" rather than as anything truthy-ish.
|
|
137
|
+
isError: envelope.result?.isError === true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { kind: "malformed" };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=read.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read.js","sourceRoot":"","sources":["../../src/oauth/read.ts"],"names":[],"mappings":"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"}
|