Skip to content

Commit 8416e4f

Browse files
authored
Merge pull request #11 from Kilo-Org/feat/openclaw-advisor-url
feat(openclaw-security-advisor) adds unique signup path
2 parents acd6a9a + d1c49dc commit 8416e4f

3 files changed

Lines changed: 117 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ KiloCode account:
132132
To run a security checkup, connect your KiloCode account.
133133
134134
1. Open this URL in your browser:
135-
https://app.kilo.ai/device-auth?code=XXXX-XXXX
135+
https://app.kilo.ai/openclaw-advisor?code=XXXX-XXXX
136136
137137
2. Enter this code: XXXX-XXXX
138138

src/auth/device-auth.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ export type DeviceAuthPollResult =
5454
/**
5555
* Create a device auth request and return the code + URL for the user to visit.
5656
* Call this once, show the result to the user, then poll with pollDeviceAuth().
57+
*
58+
* The server returns a generic `/device-auth?code=...` URL in `verificationUrl`,
59+
* built from APP_URL (the user-facing host, e.g. https://app.kilo.ai in prod).
60+
* We rewrite only the PATH to `/openclaw-advisor?code=...`, keeping the origin
61+
* authoritative. Rebuilding the URL from `apiBase` would be wrong in production,
62+
* where the API host (https://api.kilo.ai) and the app host (https://app.kilo.ai)
63+
* are different — the user needs the app host to land on the signup flow.
64+
*
65+
* The cloud side uses the `/openclaw-advisor` path prefix to attribute Security
66+
* Advisor signups and layer a per-product signup bonus on top of the standard
67+
* welcome credits. Old plugin builds keep working against the server — they just
68+
* land on the generic `/device-auth` URL and don't qualify for the bonus, which
69+
* is the intended behavior.
5770
*/
5871
export async function startDeviceAuth(
5972
apiBase: string,
@@ -69,10 +82,12 @@ export async function startDeviceAuth(
6982
);
7083
}
7184
const data = (await resp.json()) as DeviceAuthInitResponse;
85+
const advisorUrl = new URL(data.verificationUrl);
86+
advisorUrl.pathname = "/openclaw-advisor";
7287
return {
7388
kind: "started",
7489
code: data.code,
75-
verificationUrl: data.verificationUrl,
90+
verificationUrl: advisorUrl.toString(),
7691
expiresIn: data.expiresIn,
7792
};
7893
}

test/device-auth.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2+
import { startDeviceAuth } from "../src/auth/device-auth";
3+
4+
let originalFetch: typeof globalThis.fetch;
5+
6+
beforeEach(() => {
7+
originalFetch = globalThis.fetch;
8+
});
9+
10+
afterEach(() => {
11+
globalThis.fetch = originalFetch;
12+
});
13+
14+
function stubFetch(response: unknown, { ok = true, status = 200 } = {}): void {
15+
const stub: typeof fetch = async () =>
16+
new Response(JSON.stringify(response), {
17+
status,
18+
headers: { "Content-Type": "application/json" },
19+
});
20+
// ok is derived from status in Response, so we rely on status here.
21+
// Allow callers to force ok=false by passing status>=400.
22+
void ok;
23+
globalThis.fetch = stub;
24+
}
25+
26+
describe("startDeviceAuth", () => {
27+
test("rewrites the path on the server-provided URL to /openclaw-advisor", async () => {
28+
stubFetch({
29+
code: "ABCD-1234",
30+
verificationUrl: "https://app.kilo.ai/device-auth?code=ABCD-1234",
31+
expiresIn: 600,
32+
});
33+
34+
const result = await startDeviceAuth("https://app.kilo.ai");
35+
36+
expect(result.kind).toBe("started");
37+
expect(result.code).toBe("ABCD-1234");
38+
expect(result.verificationUrl).toBe(
39+
"https://app.kilo.ai/openclaw-advisor?code=ABCD-1234",
40+
);
41+
expect(result.expiresIn).toBe(600);
42+
});
43+
44+
test("preserves the server-provided origin, not apiBase, when they differ (prod)", async () => {
45+
// Regression: in production, apiBase is the API host (api.kilo.ai) but
46+
// the server builds verificationUrl from APP_URL (app.kilo.ai). Rebuilding
47+
// the link from apiBase would send users to a nonexistent endpoint.
48+
stubFetch({
49+
code: "QWER-7890",
50+
verificationUrl: "https://app.kilo.ai/device-auth?code=QWER-7890",
51+
expiresIn: 600,
52+
});
53+
54+
const result = await startDeviceAuth("https://api.kilo.ai");
55+
56+
expect(result.verificationUrl).toBe(
57+
"https://app.kilo.ai/openclaw-advisor?code=QWER-7890",
58+
);
59+
});
60+
61+
test("preserves the dev-loop origin (host.docker.internal / localhost)", async () => {
62+
stubFetch({
63+
code: "WXYZ-5678",
64+
verificationUrl:
65+
"http://host.docker.internal:3000/device-auth?code=WXYZ-5678",
66+
expiresIn: 600,
67+
});
68+
69+
const result = await startDeviceAuth("http://host.docker.internal:3000");
70+
71+
expect(result.verificationUrl).toBe(
72+
"http://host.docker.internal:3000/openclaw-advisor?code=WXYZ-5678",
73+
);
74+
});
75+
76+
test("preserves the ?code= query verbatim from the server-provided URL", async () => {
77+
// The server is the source of truth for the query string. We only swap
78+
// the pathname; we never reconstruct the query from the bare `code` field.
79+
stubFetch({
80+
code: "UVWX-9999",
81+
verificationUrl:
82+
"https://app.kilo.ai/device-auth?code=UVWX-9999&state=extra",
83+
expiresIn: 600,
84+
});
85+
86+
const result = await startDeviceAuth("https://api.kilo.ai");
87+
88+
expect(result.verificationUrl).toBe(
89+
"https://app.kilo.ai/openclaw-advisor?code=UVWX-9999&state=extra",
90+
);
91+
});
92+
93+
test("throws a descriptive error when the server rejects the request", async () => {
94+
stubFetch({}, { status: 500 });
95+
96+
await expect(startDeviceAuth("https://app.kilo.ai")).rejects.toThrow(
97+
/HTTP 500/,
98+
);
99+
});
100+
});

0 commit comments

Comments
 (0)