@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,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9728 + RFC 8414 discovery — where the authorize and token endpoints are.
|
|
3
|
+
*
|
|
4
|
+
* **The authorize URL is DISCOVERED, never constructed.** `vibecommit-mcp`
|
|
5
|
+
* deliberately does not route `/oauth/authorize`: the authorize UI lives in
|
|
6
|
+
* `vibecommit-web` on a different host in production, and the server carries its
|
|
7
|
+
* address as configuration (`DiscoveryUrls.authorizeEndpointUrl`, whose own
|
|
8
|
+
* docblock says *"there is no correct cross-repo default (any `${issuer}/...`
|
|
9
|
+
* 404s on the MCP host)"*). A hardcoded path has already cost this project once:
|
|
10
|
+
* the constant pointed at `/oauth/authorize` while the live route was
|
|
11
|
+
* `/auth/claude`, and every failure read as a config problem.
|
|
12
|
+
*
|
|
13
|
+
* So the chain is:
|
|
14
|
+
*
|
|
15
|
+
* 1. `GET <resource-origin>/.well-known/oauth-protected-resource` → RFC 9728
|
|
16
|
+
* protected-resource metadata → `authorization_servers[0]` is the issuer.
|
|
17
|
+
* 2. `GET <issuer>/.well-known/oauth-authorization-server` → RFC 8414
|
|
18
|
+
* authorization-server metadata → `authorization_endpoint`, `token_endpoint`.
|
|
19
|
+
*
|
|
20
|
+
* ## The issuer check is not paperwork
|
|
21
|
+
*
|
|
22
|
+
* RFC 8414 §3.3 requires the `issuer` INSIDE the metadata to equal the issuer
|
|
23
|
+
* the document was fetched from. That exact-string equality is the same
|
|
24
|
+
* constraint D26 records on the other side of the wire — the authorization
|
|
25
|
+
* response carries RFC 9207 `iss`, and it must string-equal both the PRM
|
|
26
|
+
* `authorization_servers[]` entry and this document's `issuer`. Three places,
|
|
27
|
+
* one string; a mismatch anywhere is a mix-up attack or a misconfiguration, and
|
|
28
|
+
* either way the flow must stop rather than proceed against an issuer nobody
|
|
29
|
+
* vouched for.
|
|
30
|
+
*
|
|
31
|
+
* Nothing is imported from `vibecommit-mcp` and nothing may be: it is closed
|
|
32
|
+
* source and this package is MIT (D60 §D1a). The two documents below are the
|
|
33
|
+
* WIRE SHAPE, retyped from reading it.
|
|
34
|
+
*
|
|
35
|
+
* @provenance vibecommit-mcp src/oauth/discovery.ts — both metadata documents, retyped
|
|
36
|
+
* @provenance vibecommit-mcp src/transport/server.ts — both `.well-known` routes, retyped
|
|
37
|
+
* @provenance vibecommit-web /auth/* — authorize UI is hosted there, not on the MCP host
|
|
38
|
+
*/
|
|
39
|
+
/** RFC 9728 §3.1. */
|
|
40
|
+
export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
|
41
|
+
/** RFC 8414 §3. */
|
|
42
|
+
export declare const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
|
|
43
|
+
/**
|
|
44
|
+
* The `/mcp` resource this client reads from. One `tools/call` POST goes here
|
|
45
|
+
* with the user token as bearer — there is no second Fly app, no `/read` route
|
|
46
|
+
* and no second hostname (D73, which closed `CR-083` as not needed).
|
|
47
|
+
*/
|
|
48
|
+
export declare const DEFAULT_MCP_URL = "https://api.vibecommit.ai/mcp";
|
|
49
|
+
/** Override for tests and for a self-hosted server, same shape as the ingest lane's. */
|
|
50
|
+
export declare function mcpUrl(env: NodeJS.ProcessEnv): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* What this client needs out of the two documents.
|
|
53
|
+
*
|
|
54
|
+
* A subset, deliberately: the server publishes `scopes_supported`,
|
|
55
|
+
* `response_types_supported` and more, and a client that retyped every field
|
|
56
|
+
* would break on an addition it does not use.
|
|
57
|
+
*/
|
|
58
|
+
export interface AuthorizationServerMetadata {
|
|
59
|
+
readonly issuer: string;
|
|
60
|
+
readonly authorization_endpoint: string;
|
|
61
|
+
readonly token_endpoint: string;
|
|
62
|
+
readonly code_challenge_methods_supported?: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
export type Discovery = {
|
|
65
|
+
readonly kind: "ok";
|
|
66
|
+
readonly metadata: AuthorizationServerMetadata;
|
|
67
|
+
}
|
|
68
|
+
/** No document, or one that is not JSON. The server may simply be down. */
|
|
69
|
+
| {
|
|
70
|
+
readonly kind: "unreachable";
|
|
71
|
+
readonly url: string;
|
|
72
|
+
}
|
|
73
|
+
/** A document arrived and is not usable. `detail` names WHICH rule failed. */
|
|
74
|
+
| {
|
|
75
|
+
readonly kind: "malformed";
|
|
76
|
+
readonly url: string;
|
|
77
|
+
readonly detail: DiscoveryFault;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Why a document was refused. A named cause rather than a boolean, because
|
|
81
|
+
* "issuer does not match" and "S256 not offered" want different fixes and the
|
|
82
|
+
* error block has to say which.
|
|
83
|
+
*/
|
|
84
|
+
export type DiscoveryFault = "no_authorization_server" | "issuer_mismatch" | "no_authorize_endpoint" | "no_token_endpoint" | "insecure_endpoint" | "no_s256";
|
|
85
|
+
/** Injected so tests drive a real local server rather than the network. */
|
|
86
|
+
export interface DiscoveryDeps {
|
|
87
|
+
readonly fetch: typeof fetch;
|
|
88
|
+
readonly timeoutMs: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Discover the authorization server for `resourceUrl`.
|
|
92
|
+
*
|
|
93
|
+
* `resourceUrl` is the `/mcp` endpoint. Both documents are public by design
|
|
94
|
+
* (RFC 9728/8414 §"no auth"), so nothing here carries a credential.
|
|
95
|
+
*/
|
|
96
|
+
export declare function discover(resourceUrl: string, deps: DiscoveryDeps): Promise<Discovery>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9728 + RFC 8414 discovery — where the authorize and token endpoints are.
|
|
3
|
+
*
|
|
4
|
+
* **The authorize URL is DISCOVERED, never constructed.** `vibecommit-mcp`
|
|
5
|
+
* deliberately does not route `/oauth/authorize`: the authorize UI lives in
|
|
6
|
+
* `vibecommit-web` on a different host in production, and the server carries its
|
|
7
|
+
* address as configuration (`DiscoveryUrls.authorizeEndpointUrl`, whose own
|
|
8
|
+
* docblock says *"there is no correct cross-repo default (any `${issuer}/...`
|
|
9
|
+
* 404s on the MCP host)"*). A hardcoded path has already cost this project once:
|
|
10
|
+
* the constant pointed at `/oauth/authorize` while the live route was
|
|
11
|
+
* `/auth/claude`, and every failure read as a config problem.
|
|
12
|
+
*
|
|
13
|
+
* So the chain is:
|
|
14
|
+
*
|
|
15
|
+
* 1. `GET <resource-origin>/.well-known/oauth-protected-resource` → RFC 9728
|
|
16
|
+
* protected-resource metadata → `authorization_servers[0]` is the issuer.
|
|
17
|
+
* 2. `GET <issuer>/.well-known/oauth-authorization-server` → RFC 8414
|
|
18
|
+
* authorization-server metadata → `authorization_endpoint`, `token_endpoint`.
|
|
19
|
+
*
|
|
20
|
+
* ## The issuer check is not paperwork
|
|
21
|
+
*
|
|
22
|
+
* RFC 8414 §3.3 requires the `issuer` INSIDE the metadata to equal the issuer
|
|
23
|
+
* the document was fetched from. That exact-string equality is the same
|
|
24
|
+
* constraint D26 records on the other side of the wire — the authorization
|
|
25
|
+
* response carries RFC 9207 `iss`, and it must string-equal both the PRM
|
|
26
|
+
* `authorization_servers[]` entry and this document's `issuer`. Three places,
|
|
27
|
+
* one string; a mismatch anywhere is a mix-up attack or a misconfiguration, and
|
|
28
|
+
* either way the flow must stop rather than proceed against an issuer nobody
|
|
29
|
+
* vouched for.
|
|
30
|
+
*
|
|
31
|
+
* Nothing is imported from `vibecommit-mcp` and nothing may be: it is closed
|
|
32
|
+
* source and this package is MIT (D60 §D1a). The two documents below are the
|
|
33
|
+
* WIRE SHAPE, retyped from reading it.
|
|
34
|
+
*
|
|
35
|
+
* @provenance vibecommit-mcp src/oauth/discovery.ts — both metadata documents, retyped
|
|
36
|
+
* @provenance vibecommit-mcp src/transport/server.ts — both `.well-known` routes, retyped
|
|
37
|
+
* @provenance vibecommit-web /auth/* — authorize UI is hosted there, not on the MCP host
|
|
38
|
+
*/
|
|
39
|
+
import { isAllowedIngestUrl } from "../post.js";
|
|
40
|
+
import { CHALLENGE_METHOD } from "./pkce.js";
|
|
41
|
+
/** RFC 9728 §3.1. */
|
|
42
|
+
export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
|
43
|
+
/** RFC 8414 §3. */
|
|
44
|
+
export const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
|
|
45
|
+
/**
|
|
46
|
+
* The `/mcp` resource this client reads from. One `tools/call` POST goes here
|
|
47
|
+
* with the user token as bearer — there is no second Fly app, no `/read` route
|
|
48
|
+
* and no second hostname (D73, which closed `CR-083` as not needed).
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_MCP_URL = "https://api.vibecommit.ai/mcp";
|
|
51
|
+
/** Override for tests and for a self-hosted server, same shape as the ingest lane's. */
|
|
52
|
+
export function mcpUrl(env) {
|
|
53
|
+
const override = env.VIBECOMMIT_MCP_URL;
|
|
54
|
+
if (override === undefined || override.trim() === "")
|
|
55
|
+
return DEFAULT_MCP_URL;
|
|
56
|
+
const candidate = override.trim();
|
|
57
|
+
return isAllowedIngestUrl(candidate) ? candidate : null;
|
|
58
|
+
}
|
|
59
|
+
async function getJson(url, deps) {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
|
|
62
|
+
try {
|
|
63
|
+
const res = await deps.fetch(url, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
headers: { accept: "application/json" },
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
// A discovery document that redirects is a document from somewhere the
|
|
68
|
+
// operator did not configure. `post.ts` refuses redirects on the same
|
|
69
|
+
// reasoning and for the same lane.
|
|
70
|
+
redirect: "error",
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
return { ok: false };
|
|
74
|
+
return { ok: true, body: (await res.json()) };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { ok: false };
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** `https://host/mcp` → `https://host`. The `.well-known` paths sit at the root. */
|
|
84
|
+
function originOf(url) {
|
|
85
|
+
return new URL(url).origin;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Discover the authorization server for `resourceUrl`.
|
|
89
|
+
*
|
|
90
|
+
* `resourceUrl` is the `/mcp` endpoint. Both documents are public by design
|
|
91
|
+
* (RFC 9728/8414 §"no auth"), so nothing here carries a credential.
|
|
92
|
+
*/
|
|
93
|
+
export async function discover(resourceUrl, deps) {
|
|
94
|
+
const prmUrl = `${originOf(resourceUrl)}${PROTECTED_RESOURCE_PATH}`;
|
|
95
|
+
const prm = await getJson(prmUrl, deps);
|
|
96
|
+
if (!prm.ok)
|
|
97
|
+
return { kind: "unreachable", url: prmUrl };
|
|
98
|
+
const servers = prm.body.authorization_servers;
|
|
99
|
+
const issuer = Array.isArray(servers) && typeof servers[0] === "string" ? servers[0] : null;
|
|
100
|
+
if (issuer === null || issuer === "") {
|
|
101
|
+
return { kind: "malformed", url: prmUrl, detail: "no_authorization_server" };
|
|
102
|
+
}
|
|
103
|
+
// ⚠ `/cso` finding, MEDIUM. VALIDATE BEFORE FETCHING, not after. The issuer
|
|
104
|
+
// arrives INSIDE a document, so it is remote input, and the next line turns it
|
|
105
|
+
// into an outbound request from the user's machine. Validating only the two
|
|
106
|
+
// ENDPOINTS below — which is what this did — left the AS-metadata GET itself
|
|
107
|
+
// unchecked, so a hostile or compromised discovery document could aim it at
|
|
108
|
+
// `http://169.254.169.254`, a private RFC 1918 address, or an arbitrary
|
|
109
|
+
// internal port: a blind SSRF probe with the request coming from inside the
|
|
110
|
+
// network. The response cannot be read back by the attacker, which is what
|
|
111
|
+
// keeps this MEDIUM rather than HIGH.
|
|
112
|
+
//
|
|
113
|
+
// Same predicate the endpoints get, applied one step earlier so the rule is
|
|
114
|
+
// consistent: every URL this module fetches passed it first.
|
|
115
|
+
if (!isAllowedIngestUrl(issuer)) {
|
|
116
|
+
return { kind: "malformed", url: prmUrl, detail: "insecure_endpoint" };
|
|
117
|
+
}
|
|
118
|
+
const asUrl = `${issuer.replace(/\/+$/, "")}${AUTHORIZATION_SERVER_PATH}`;
|
|
119
|
+
const as = await getJson(asUrl, deps);
|
|
120
|
+
if (!as.ok)
|
|
121
|
+
return { kind: "unreachable", url: asUrl };
|
|
122
|
+
const doc = as.body;
|
|
123
|
+
// RFC 8414 §3.3, and D26's exact-string rule. Not normalised, not
|
|
124
|
+
// case-folded, not trailing-slash-tolerant: the whole value of the check is
|
|
125
|
+
// that it is the SAME comparison the RFC 9207 `iss` check makes later, so a
|
|
126
|
+
// server whose three copies of this string disagree fails here rather than
|
|
127
|
+
// three steps further on where it reads as something else.
|
|
128
|
+
if (doc.issuer !== issuer)
|
|
129
|
+
return { kind: "malformed", url: asUrl, detail: "issuer_mismatch" };
|
|
130
|
+
if (typeof doc.authorization_endpoint !== "string" || doc.authorization_endpoint === "") {
|
|
131
|
+
return { kind: "malformed", url: asUrl, detail: "no_authorize_endpoint" };
|
|
132
|
+
}
|
|
133
|
+
if (typeof doc.token_endpoint !== "string" || doc.token_endpoint === "") {
|
|
134
|
+
return { kind: "malformed", url: asUrl, detail: "no_token_endpoint" };
|
|
135
|
+
}
|
|
136
|
+
// The token endpoint receives the verifier and returns the refresh token, and
|
|
137
|
+
// the authorize endpoint receives the challenge. Neither may be plaintext off
|
|
138
|
+
// the loopback. Same predicate as the ingest lane: https, or http to a
|
|
139
|
+
// loopback host. (⚠ NOT the redirect-URI rule — that one additionally
|
|
140
|
+
// forbids the literal `localhost`, and it lives in `loopback.ts` because it is
|
|
141
|
+
// the SERVER's policy about a value we send it, not ours about where we send.)
|
|
142
|
+
for (const endpoint of [doc.authorization_endpoint, doc.token_endpoint]) {
|
|
143
|
+
if (!isAllowedIngestUrl(endpoint)) {
|
|
144
|
+
return { kind: "malformed", url: asUrl, detail: "insecure_endpoint" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Absent means "unstated", and RFC 8414 §2 says a client that needs S256 must
|
|
148
|
+
// not assume it. We need it: it is the only method this client can produce.
|
|
149
|
+
const methods = doc.code_challenge_methods_supported;
|
|
150
|
+
if (!Array.isArray(methods) || !methods.includes(CHALLENGE_METHOD)) {
|
|
151
|
+
return { kind: "malformed", url: asUrl, detail: "no_s256" };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
kind: "ok",
|
|
155
|
+
metadata: {
|
|
156
|
+
issuer,
|
|
157
|
+
authorization_endpoint: doc.authorization_endpoint,
|
|
158
|
+
token_endpoint: doc.token_endpoint,
|
|
159
|
+
code_challenge_methods_supported: methods,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.js","sourceRoot":"","sources":["../../src/oauth/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7C,qBAAqB;AACrB,MAAM,CAAC,MAAM,uBAAuB,GAAG,uCAAuC,CAAC;AAC/E,mBAAmB;AACnB,MAAM,CAAC,MAAM,yBAAyB,GAAG,yCAAyC,CAAC;AAEnF;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,+BAA+B,CAAC;AAE/D,wFAAwF;AACxF,MAAM,UAAU,MAAM,CAAC,GAAsB;IAC3C,MAAM,QAAQ,GAAG,GAAG,CAAC,kBAAkB,CAAC;IACxC,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,eAAe,CAAC;IAC7E,MAAM,SAAS,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC;IAClC,OAAO,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;AAC1D,CAAC;AA0CD,KAAK,UAAU,OAAO,CACpB,GAAW,EACX,IAAmB;IAEnB,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;IACnE,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE;YAChC,MAAM,EAAE,KAAK;YACb,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;YACvC,MAAM,EAAE,UAAU,CAAC,MAAM;YACzB,uEAAuE;YACvE,sEAAsE;YACtE,mCAAmC;YACnC,QAAQ,EAAE,OAAO;SAClB,CAAC,CAAC;QACH,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;QAClC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAY,EAAE,CAAC;IAC3D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;IACvB,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC;AAED,oFAAoF;AACpF,SAAS,QAAQ,CAAC,GAAW;IAC3B,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;AAC7B,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,WAAmB,EAAE,IAAmB;IACrE,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,WAAW,CAAC,GAAG,uBAAuB,EAAE,CAAC;IACpE,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACxC,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC;IAEzD,MAAM,OAAO,GAAI,GAAG,CAAC,IAA4C,CAAC,qBAAqB,CAAC;IACxF,MAAM,MAAM,GACV,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,OAAO,OAAO,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAE,OAAO,CAAC,CAAC,CAAY,CAAC,CAAC,CAAC,IAAI,CAAC;IAC3F,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;QACrC,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,yBAAyB,EAAE,CAAC;IAC/E,CAAC;IAED,4EAA4E;IAC5E,+EAA+E;IAC/E,4EAA4E;IAC5E,6EAA6E;IAC7E,4EAA4E;IAC5E,wEAAwE;IACxE,4EAA4E;IAC5E,2EAA2E;IAC3E,sCAAsC;IACtC,EAAE;IACF,4EAA4E;IAC5E,6DAA6D;IAC7D,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;QAChC,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;IACzE,CAAC;IAED,MAAM,KAAK,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,yBAAyB,EAAE,CAAC;IAC1E,MAAM,EAAE,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACtC,IAAI,CAAC,EAAE,CAAC,EAAE;QAAE,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;IAEvD,MAAM,GAAG,GAAG,EAAE,CAAC,IAA4C,CAAC;IAE5D,kEAAkE;IAClE,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,2DAA2D;IAC3D,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAE/F,IAAI,OAAO,GAAG,CAAC,sBAAsB,KAAK,QAAQ,IAAI,GAAG,CAAC,sBAAsB,KAAK,EAAE,EAAE,CAAC;QACxF,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,uBAAuB,EAAE,CAAC;IAC5E,CAAC;IACD,IAAI,OAAO,GAAG,CAAC,cAAc,KAAK,QAAQ,IAAI,GAAG,CAAC,cAAc,KAAK,EAAE,EAAE,CAAC;QACxE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;IACxE,CAAC;IACD,8EAA8E;IAC9E,8EAA8E;IAC9E,uEAAuE;IACvE,sEAAsE;IACtE,+EAA+E;IAC/E,+EAA+E;IAC/E,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,CAAC,sBAAsB,EAAE,GAAG,CAAC,cAAc,CAAC,EAAE,CAAC;QACxE,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,CAAC;YAClC,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;QACxE,CAAC;IACH,CAAC;IAED,8EAA8E;IAC9E,4EAA4E;IAC5E,MAAM,OAAO,GAAG,GAAG,CAAC,gCAAgC,CAAC;IACrD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACnE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC9D,CAAC;IAED,OAAO;QACL,IAAI,EAAE,IAAI;QACV,QAAQ,EAAE;YACR,MAAM;YACN,sBAAsB,EAAE,GAAG,CAAC,sBAAsB;YAClD,cAAc,EAAE,GAAG,CAAC,cAAc;YAClC,gCAAgC,EAAE,OAAO;SAC1C;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process single-flight for the refresh grant.
|
|
3
|
+
*
|
|
4
|
+
* ## ⚠ WHY THIS FILE EXISTS AT ALL — read before simplifying it away
|
|
5
|
+
*
|
|
6
|
+
* `vibecommit-mcp/src/oauth/refresh.ts` implements refresh-token FAMILY rotation
|
|
7
|
+
* with application-layer reuse detection: presenting a token that already has a
|
|
8
|
+
* child row is treated as a replay and **revokes the ENTIRE FAMILY** with
|
|
9
|
+
* `revoked_reason = 'reuse_detected'`. There is a database-level guard behind it
|
|
10
|
+
* too — a partial unique index on `parent_token_id`, so of two concurrent
|
|
11
|
+
* rotations from the same tail exactly one INSERT wins and the loser's 23505 is
|
|
12
|
+
* *also* read as reuse.
|
|
13
|
+
*
|
|
14
|
+
* Two terminals running a read at the same moment present the same tail token.
|
|
15
|
+
* That is not an edge case; it is Tuesday. Without a lock, one of them wins, the
|
|
16
|
+
* other is classed a replay, and **the user is signed out of every machine at
|
|
17
|
+
* once** — from doing nothing but running the same command twice.
|
|
18
|
+
*
|
|
19
|
+
* So the mutual exclusion has to be BETWEEN PROCESSES. An in-process mutex, a
|
|
20
|
+
* module-level promise, an `async` queue — every in-memory construct is the
|
|
21
|
+
* wrong shape here, because the two racers are two separate `vibecommit`
|
|
22
|
+
* invocations that share nothing but a filesystem.
|
|
23
|
+
*
|
|
24
|
+
* ## The loser waits and RE-READS — it does not queue up to present the old token
|
|
25
|
+
*
|
|
26
|
+
* `withRefreshLock` gives its callback the lock, nothing more. The callback's
|
|
27
|
+
* first act must be to re-read the session from disk, because by the time a
|
|
28
|
+
* waiter acquires the lock the winner has already rotated and saved: the tail
|
|
29
|
+
* the waiter loaded BEFORE waiting is now the parent of a live row, and
|
|
30
|
+
* presenting it is precisely the replay this file exists to prevent. See
|
|
31
|
+
* `signin.ts`'s `authorizedAccessToken`, which is the only caller and is written
|
|
32
|
+
* in exactly that order.
|
|
33
|
+
*
|
|
34
|
+
* ## The stale timeout is DERIVED, not guessed
|
|
35
|
+
*
|
|
36
|
+
* A crashed holder must not wedge every future read, so a lock older than
|
|
37
|
+
* `STALE_MS` is reclaimable. That number is not a feel for how long things take:
|
|
38
|
+
* this project has already paid for one wall-clock guess (`CR-126`/D83 found a
|
|
39
|
+
* `setTimeout(..., 60)` that was a guess at boot duration, sitting between p50
|
|
40
|
+
* and p90 and firing ~14% of the time idle and ~28% under load).
|
|
41
|
+
*
|
|
42
|
+
* The holder does exactly ONE thing while holding this lock: a single POST to
|
|
43
|
+
* the token endpoint, and that POST is bounded by an `AbortController` at
|
|
44
|
+
* `REFRESH_REQUEST_TIMEOUT_MS`. So the longest a HEALTHY holder can hold the
|
|
45
|
+
* lock is that timeout plus the file I/O either side of it, and `STALE_MS` is
|
|
46
|
+
* that bound plus a margin — an arithmetic consequence of the operation it
|
|
47
|
+
* bounds, which means changing the request timeout moves it automatically
|
|
48
|
+
* rather than leaving a stale constant behind.
|
|
49
|
+
*
|
|
50
|
+
* Reclaiming too EARLY is the dangerous direction: it puts two processes into
|
|
51
|
+
* the refresh at once, which is the family revoke. Reclaiming too LATE only
|
|
52
|
+
* makes a rare recovery slower. The margin is therefore generous on purpose.
|
|
53
|
+
*
|
|
54
|
+
* Nothing is imported or copied from the closed core (D60 §D1a): what crossed
|
|
55
|
+
* the boundary is the RULE that concurrent rotation is a revoke, which is a
|
|
56
|
+
* behavioural contract this client must obey, not an implementation.
|
|
57
|
+
*
|
|
58
|
+
* @provenance vibecommit-mcp src/oauth/refresh.ts — family-rotation reuse-detection rule, retyped
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* The whole budget for one refresh POST. Also the ONLY thing done under the
|
|
62
|
+
* lock, which is what makes `STALE_MS` below derivable.
|
|
63
|
+
*/
|
|
64
|
+
export declare const REFRESH_REQUEST_TIMEOUT_MS = 10000;
|
|
65
|
+
/**
|
|
66
|
+
* When a lock is old enough to be a corpse rather than a holder.
|
|
67
|
+
*
|
|
68
|
+
* `REFRESH_REQUEST_TIMEOUT_MS` bounds the healthy hold; the rest is margin for
|
|
69
|
+
* the read/write either side and for a machine under enough load that a process
|
|
70
|
+
* does not get scheduled promptly. Erring long is the cheap direction — see the
|
|
71
|
+
* module note.
|
|
72
|
+
*/
|
|
73
|
+
export declare const STALE_MS: number;
|
|
74
|
+
/**
|
|
75
|
+
* How long a waiter waits before giving up.
|
|
76
|
+
*
|
|
77
|
+
* Strictly greater than `STALE_MS`: a waiter that gave up sooner would abandon a
|
|
78
|
+
* refresh that a perfectly healthy holder was still allowed to be running, and
|
|
79
|
+
* the user would see a spurious failure while the thing they needed was in
|
|
80
|
+
* flight one process over.
|
|
81
|
+
*/
|
|
82
|
+
export declare const WAIT_TIMEOUT_MS: number;
|
|
83
|
+
/** One lock for the refresh grant. Sits beside the session it protects. */
|
|
84
|
+
export declare function refreshLockPath(home: string): string;
|
|
85
|
+
export type LockFailure = "timeout";
|
|
86
|
+
/**
|
|
87
|
+
* Run `fn` with the refresh lock held.
|
|
88
|
+
*
|
|
89
|
+
* Returns `{ kind: "timeout" }` rather than throwing when the lock cannot be
|
|
90
|
+
* taken: the caller is a read verb, and a read that cannot refresh should tell
|
|
91
|
+
* the user what happened, not take an exception out of a lock file.
|
|
92
|
+
*
|
|
93
|
+
* The lock is released in a `finally`, so a throwing callback still frees it.
|
|
94
|
+
*/
|
|
95
|
+
export declare function withRefreshLock<T>(home: string, fn: () => Promise<T>, now?: () => number): Promise<{
|
|
96
|
+
kind: "ok";
|
|
97
|
+
value: T;
|
|
98
|
+
} | {
|
|
99
|
+
kind: LockFailure;
|
|
100
|
+
}>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process single-flight for the refresh grant.
|
|
3
|
+
*
|
|
4
|
+
* ## ⚠ WHY THIS FILE EXISTS AT ALL — read before simplifying it away
|
|
5
|
+
*
|
|
6
|
+
* `vibecommit-mcp/src/oauth/refresh.ts` implements refresh-token FAMILY rotation
|
|
7
|
+
* with application-layer reuse detection: presenting a token that already has a
|
|
8
|
+
* child row is treated as a replay and **revokes the ENTIRE FAMILY** with
|
|
9
|
+
* `revoked_reason = 'reuse_detected'`. There is a database-level guard behind it
|
|
10
|
+
* too — a partial unique index on `parent_token_id`, so of two concurrent
|
|
11
|
+
* rotations from the same tail exactly one INSERT wins and the loser's 23505 is
|
|
12
|
+
* *also* read as reuse.
|
|
13
|
+
*
|
|
14
|
+
* Two terminals running a read at the same moment present the same tail token.
|
|
15
|
+
* That is not an edge case; it is Tuesday. Without a lock, one of them wins, the
|
|
16
|
+
* other is classed a replay, and **the user is signed out of every machine at
|
|
17
|
+
* once** — from doing nothing but running the same command twice.
|
|
18
|
+
*
|
|
19
|
+
* So the mutual exclusion has to be BETWEEN PROCESSES. An in-process mutex, a
|
|
20
|
+
* module-level promise, an `async` queue — every in-memory construct is the
|
|
21
|
+
* wrong shape here, because the two racers are two separate `vibecommit`
|
|
22
|
+
* invocations that share nothing but a filesystem.
|
|
23
|
+
*
|
|
24
|
+
* ## The loser waits and RE-READS — it does not queue up to present the old token
|
|
25
|
+
*
|
|
26
|
+
* `withRefreshLock` gives its callback the lock, nothing more. The callback's
|
|
27
|
+
* first act must be to re-read the session from disk, because by the time a
|
|
28
|
+
* waiter acquires the lock the winner has already rotated and saved: the tail
|
|
29
|
+
* the waiter loaded BEFORE waiting is now the parent of a live row, and
|
|
30
|
+
* presenting it is precisely the replay this file exists to prevent. See
|
|
31
|
+
* `signin.ts`'s `authorizedAccessToken`, which is the only caller and is written
|
|
32
|
+
* in exactly that order.
|
|
33
|
+
*
|
|
34
|
+
* ## The stale timeout is DERIVED, not guessed
|
|
35
|
+
*
|
|
36
|
+
* A crashed holder must not wedge every future read, so a lock older than
|
|
37
|
+
* `STALE_MS` is reclaimable. That number is not a feel for how long things take:
|
|
38
|
+
* this project has already paid for one wall-clock guess (`CR-126`/D83 found a
|
|
39
|
+
* `setTimeout(..., 60)` that was a guess at boot duration, sitting between p50
|
|
40
|
+
* and p90 and firing ~14% of the time idle and ~28% under load).
|
|
41
|
+
*
|
|
42
|
+
* The holder does exactly ONE thing while holding this lock: a single POST to
|
|
43
|
+
* the token endpoint, and that POST is bounded by an `AbortController` at
|
|
44
|
+
* `REFRESH_REQUEST_TIMEOUT_MS`. So the longest a HEALTHY holder can hold the
|
|
45
|
+
* lock is that timeout plus the file I/O either side of it, and `STALE_MS` is
|
|
46
|
+
* that bound plus a margin — an arithmetic consequence of the operation it
|
|
47
|
+
* bounds, which means changing the request timeout moves it automatically
|
|
48
|
+
* rather than leaving a stale constant behind.
|
|
49
|
+
*
|
|
50
|
+
* Reclaiming too EARLY is the dangerous direction: it puts two processes into
|
|
51
|
+
* the refresh at once, which is the family revoke. Reclaiming too LATE only
|
|
52
|
+
* makes a rare recovery slower. The margin is therefore generous on purpose.
|
|
53
|
+
*
|
|
54
|
+
* Nothing is imported or copied from the closed core (D60 §D1a): what crossed
|
|
55
|
+
* the boundary is the RULE that concurrent rotation is a revoke, which is a
|
|
56
|
+
* behavioural contract this client must obey, not an implementation.
|
|
57
|
+
*
|
|
58
|
+
* @provenance vibecommit-mcp src/oauth/refresh.ts — family-rotation reuse-detection rule, retyped
|
|
59
|
+
*/
|
|
60
|
+
import { randomBytes } from "node:crypto";
|
|
61
|
+
import { closeSync, linkSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import { rootDir } from "../paths.js";
|
|
64
|
+
/**
|
|
65
|
+
* The whole budget for one refresh POST. Also the ONLY thing done under the
|
|
66
|
+
* lock, which is what makes `STALE_MS` below derivable.
|
|
67
|
+
*/
|
|
68
|
+
export const REFRESH_REQUEST_TIMEOUT_MS = 10_000;
|
|
69
|
+
/**
|
|
70
|
+
* When a lock is old enough to be a corpse rather than a holder.
|
|
71
|
+
*
|
|
72
|
+
* `REFRESH_REQUEST_TIMEOUT_MS` bounds the healthy hold; the rest is margin for
|
|
73
|
+
* the read/write either side and for a machine under enough load that a process
|
|
74
|
+
* does not get scheduled promptly. Erring long is the cheap direction — see the
|
|
75
|
+
* module note.
|
|
76
|
+
*/
|
|
77
|
+
export const STALE_MS = REFRESH_REQUEST_TIMEOUT_MS + 5_000;
|
|
78
|
+
/**
|
|
79
|
+
* How long a waiter waits before giving up.
|
|
80
|
+
*
|
|
81
|
+
* Strictly greater than `STALE_MS`: a waiter that gave up sooner would abandon a
|
|
82
|
+
* refresh that a perfectly healthy holder was still allowed to be running, and
|
|
83
|
+
* the user would see a spurious failure while the thing they needed was in
|
|
84
|
+
* flight one process over.
|
|
85
|
+
*/
|
|
86
|
+
export const WAIT_TIMEOUT_MS = STALE_MS + 2_000;
|
|
87
|
+
/**
|
|
88
|
+
* Poll interval while waiting. Short enough that the handoff is imperceptible,
|
|
89
|
+
* long enough that waiting costs nothing: over a full 10s refresh this is a few
|
|
90
|
+
* hundred `readFileSync` calls on a file in the page cache.
|
|
91
|
+
*/
|
|
92
|
+
const POLL_MS = 20;
|
|
93
|
+
/** One lock for the refresh grant. Sits beside the session it protects. */
|
|
94
|
+
export function refreshLockPath(home) {
|
|
95
|
+
return join(rootDir(home), "refresh.lock");
|
|
96
|
+
}
|
|
97
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
98
|
+
setTimeout(resolve, ms);
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* The lock's contents: enough to tell one holder from another, and nothing
|
|
102
|
+
* secret. Never a token — a lock file has looser handling than a credential
|
|
103
|
+
* file by nature (it is created and destroyed constantly) and is exactly the
|
|
104
|
+
* wrong place to put a bearer secret.
|
|
105
|
+
*/
|
|
106
|
+
function stamp() {
|
|
107
|
+
// The nonce is what makes the stamp an IDENTITY rather than a description.
|
|
108
|
+
// `releaseIfMine` compares these bytes to decide whether the lock on disk is
|
|
109
|
+
// still ours, and pids are recycled — so without it, "same pid, same
|
|
110
|
+
// millisecond" would be a (remote) way to delete somebody else's lock.
|
|
111
|
+
return JSON.stringify({
|
|
112
|
+
pid: process.pid,
|
|
113
|
+
at: Date.now(),
|
|
114
|
+
nonce: randomBytes(8).toString("hex"),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function heldSince(raw) {
|
|
118
|
+
try {
|
|
119
|
+
const at = JSON.parse(raw).at;
|
|
120
|
+
return typeof at === "number" ? at : null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Try once to create the lock exclusively.
|
|
128
|
+
*
|
|
129
|
+
* `wx` is `O_CREAT | O_EXCL | O_WRONLY`, which the kernel makes atomic: of any
|
|
130
|
+
* number of processes calling this at the same instant, exactly one gets the
|
|
131
|
+
* file and the rest get EEXIST. That atomicity is the entire mutual exclusion —
|
|
132
|
+
* a "does it exist? then create it" pair would have a window between the two
|
|
133
|
+
* calls, and a window is all this needs to fail.
|
|
134
|
+
*/
|
|
135
|
+
function tryAcquire(path) {
|
|
136
|
+
const mine = stamp();
|
|
137
|
+
// ⚠ WRITE FIRST, PUBLISH SECOND — and this ordering is a BUG FIX, not style.
|
|
138
|
+
//
|
|
139
|
+
// The obvious implementation is `openSync(path, "wx")` then `writeFileSync`.
|
|
140
|
+
// That creates the lock EMPTY and fills it a moment later, and in that window
|
|
141
|
+
// a second process reads a zero-byte file. `heldSince` cannot date it, the
|
|
142
|
+
// wait loop's "an undatable lock was not written by this code, treat it as
|
|
143
|
+
// stale" branch fires, and the racer RECLAIMS A LOCK THAT WAS JUST TAKEN.
|
|
144
|
+
// Both processes then refresh, and the server revokes the family — the exact
|
|
145
|
+
// outcome this module exists to prevent, reached through the module itself.
|
|
146
|
+
//
|
|
147
|
+
// Measured, not theorised: the two-process case in `test/oauth-signin.test.ts`
|
|
148
|
+
// failed on 2 of 8 runs with the second child getting `expired`, which is the
|
|
149
|
+
// server's answer to a replayed token.
|
|
150
|
+
//
|
|
151
|
+
// `link()` is the fix because it is ATOMIC and it fails with EEXIST when the
|
|
152
|
+
// target exists. The temp file already holds the full stamp before the link
|
|
153
|
+
// publishes it, so the lock is never observable half-written. The temp name
|
|
154
|
+
// carries a random component as well as the pid: two processes must not
|
|
155
|
+
// collide on the staging file either.
|
|
156
|
+
const staging = `${path}.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
157
|
+
try {
|
|
158
|
+
// `wx` on the STAGING file also refuses to follow a symlink (`O_CREAT |
|
|
159
|
+
// O_EXCL`), so a pre-planted link cannot be written through.
|
|
160
|
+
const fd = openSync(staging, "wx", 0o600);
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(fd, mine);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
closeSync(fd);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
linkSync(staging, path);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Somebody else holds it. Ours never became visible.
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(staging);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// The lock path keeps the inode alive; a stranded staging file is inert.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Returned so the RELEASE can prove the lock it deletes is still ours.
|
|
187
|
+
return mine;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Reclaim a lock whose holder is gone.
|
|
191
|
+
*
|
|
192
|
+
* Compare-then-delete: the same bytes we saw when we judged it stale must still
|
|
193
|
+
* be there when we unlink. Without that a third process that acquired the lock
|
|
194
|
+
* in between would have it deleted out from under it.
|
|
195
|
+
*
|
|
196
|
+
* ⚠ Residual, stated rather than papered over: the compare and the unlink are
|
|
197
|
+
* not one atomic operation, so a sufficiently unlucky interleaving can still
|
|
198
|
+
* drop a fresh lock. Reaching it requires a process to crash mid-refresh, a
|
|
199
|
+
* second to wait out the full `STALE_MS`, and a third to acquire inside the
|
|
200
|
+
* microseconds between the re-read and the unlink. The cost of closing it
|
|
201
|
+
* properly is a directory-rename protocol, and this package's failure mode on
|
|
202
|
+
* that path is one avoidable re-sign-in, not data loss.
|
|
203
|
+
*/
|
|
204
|
+
function reclaimIfUnchanged(path, observed) {
|
|
205
|
+
try {
|
|
206
|
+
if (readFileSync(path, "utf8") !== observed)
|
|
207
|
+
return;
|
|
208
|
+
unlinkSync(path);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Gone already, or not ours to remove. Either way the next acquire decides.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Release, but ONLY if the lock on disk is still the one we took.
|
|
216
|
+
*
|
|
217
|
+
* ⚠ `/cso` finding, HIGH, and it is the family revoke arriving by the back door.
|
|
218
|
+
* An unconditional `unlink` on release is wrong whenever a holder outlived
|
|
219
|
+
* `STALE_MS`: a waiter reclaims the stale lock and starts its own refresh, and
|
|
220
|
+
* then the original holder wakes up and deletes THAT lock — letting a third
|
|
221
|
+
* process in while the second is mid-rotation. Two live refreshes is exactly the
|
|
222
|
+
* replay the server reads as `reuse_detected`.
|
|
223
|
+
*
|
|
224
|
+
* The obvious objection is that a healthy holder cannot exceed `STALE_MS`,
|
|
225
|
+
* because its one request is bounded by an `AbortController` well inside it.
|
|
226
|
+
* True — and irrelevant. **A laptop that sleeps mid-refresh does exceed it**, and
|
|
227
|
+
* so does any process the scheduler or a debugger suspends. Wall-clock bounds
|
|
228
|
+
* hold for code, not for the machine underneath it.
|
|
229
|
+
*
|
|
230
|
+
* Same compare-then-delete as the reclaim above, and the same stated residual:
|
|
231
|
+
* the read and the unlink are not one atomic operation.
|
|
232
|
+
*/
|
|
233
|
+
function releaseIfMine(path, mine) {
|
|
234
|
+
try {
|
|
235
|
+
if (readFileSync(path, "utf8") !== mine)
|
|
236
|
+
return;
|
|
237
|
+
unlinkSync(path);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Already gone. Nothing to undo.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Run `fn` with the refresh lock held.
|
|
245
|
+
*
|
|
246
|
+
* Returns `{ kind: "timeout" }` rather than throwing when the lock cannot be
|
|
247
|
+
* taken: the caller is a read verb, and a read that cannot refresh should tell
|
|
248
|
+
* the user what happened, not take an exception out of a lock file.
|
|
249
|
+
*
|
|
250
|
+
* The lock is released in a `finally`, so a throwing callback still frees it.
|
|
251
|
+
*/
|
|
252
|
+
export async function withRefreshLock(home, fn, now = Date.now) {
|
|
253
|
+
const path = refreshLockPath(home);
|
|
254
|
+
const deadline = now() + WAIT_TIMEOUT_MS;
|
|
255
|
+
let mine = tryAcquire(path);
|
|
256
|
+
while (mine === null) {
|
|
257
|
+
let raw;
|
|
258
|
+
try {
|
|
259
|
+
raw = readFileSync(path, "utf8");
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Released between our failed acquire and this read. Go straight round.
|
|
263
|
+
mine = tryAcquire(path);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const since = heldSince(raw);
|
|
267
|
+
// An unparseable or timestamp-less lock is treated as stale immediately: it
|
|
268
|
+
// was not written by this code, and waiting `STALE_MS` for a file nobody can
|
|
269
|
+
// date is waiting for nothing.
|
|
270
|
+
if (since === null || now() - since > STALE_MS) {
|
|
271
|
+
reclaimIfUnchanged(path, raw);
|
|
272
|
+
mine = tryAcquire(path);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (now() >= deadline)
|
|
276
|
+
return { kind: "timeout" };
|
|
277
|
+
await sleep(POLL_MS);
|
|
278
|
+
mine = tryAcquire(path);
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
return { kind: "ok", value: await fn() };
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
releaseIfMine(path, mine);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.js","sourceRoot":"","sources":["../../src/oauth/lock.ts"],"names":[],"mappings":"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"}
|