Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,17 +337,18 @@ dfx canister --ic call 73mez-iiaaa-aaaaq-aaasq-cai icrc1_balance_of '(record {ow

Or purchase them from MEXC or swap at https://app.icpswap.com/ .

#### 3. Internet Identity Flow (`--ii`, CLI only)
#### 3. Internet Identity Flow (`icp-cli`, recommended)

If you prefer browser login instead of a Keychain-backed dfx identity:

```bash
cargo run -- --ii login
cargo run -- --ii list
icp identity link ii <name> --host https://memory.kinic.xyz
cargo run -- --ic --identity <name> list
```

Delegations are stored at `~/.config/kinic/identity.json` with a default TTL of 6 hours.
The login flow uses a local callback on port `8620`.
Requires `icp-cli` 0.2.4 or newer. When the delegation expires, refresh it with `icp identity login <name>`.

Legacy `kinic-cli --ii login` still works for CLI-only use, but it derives from the locally hosted login origin and stores delegations at `~/.config/kinic/identity.json`.

#### 4. Deploy and Use Memory from Python

Expand Down
3 changes: 3 additions & 0 deletions apps/kinic-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"typecheck": "tsc --project tsconfig.json --noEmit"
},
"dependencies": {
"@dfinity/agent": "^2.4.1",
"@dfinity/auth-client": "^2.4.1",
"@dfinity/identity": "^2.4.1",
"@kinic/kinic-share": "workspace:*",
"@tailwindcss/postcss": "^4.2.2",
"class-variance-authority": "^0.7.1",
Expand Down
2 changes: 2 additions & 0 deletions apps/kinic-portal/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { Route, Routes } from "react-router";
import type { PortalRuntimeConfig } from "./runtime-config";
import { CliLoginPage } from "./routes/cli-login-page";
import { HomePage } from "./routes/home-page";
import { MemoryPage } from "./routes/memory-page";
import { NotFoundPage } from "./routes/not-found-page";
Expand All @@ -12,6 +13,7 @@ export function App({ config }: { config: PortalRuntimeConfig }) {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/cli-login" element={<CliLoginPage />} />
<Route path="/m/:memoryId" element={<MemoryPage config={config} />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
Expand Down
121 changes: 121 additions & 0 deletions apps/kinic-portal/src/routes/cli-login-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { AnonymousIdentity } from "@dfinity/agent";
import { describe, expect, it, vi } from "vitest";
import {
type CliLoginAuthClient,
isAllowedLocalCallback,
parseCliLoginHash,
sendCliLoginDelegation,
} from "./cli-login-page";

describe("cli login params", () => {
it("returns empty for direct visits without hash params", () => {
const result = parseCliLoginHash("");

expect(result).toEqual({ kind: "empty" });
});

it("accepts localhost callback hashes from icp-cli", () => {
const result = parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback");

expect(result).toEqual({
kind: "ok",
params: {
publicKey: "abc",
callback: "http://127.0.0.1:1234/callback",
},
});
});

it("accepts ipv6 loopback callbacks", () => {
const result = parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2F%5B%3A%3A1%5D%3A1234%2Fcallback");

expect(result).toEqual({
kind: "ok",
params: {
publicKey: "abc",
callback: "http://[::1]:1234/callback",
},
});
});

it("rejects missing public keys", () => {
const result = parseCliLoginHash("#callback=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback");

expect(result.kind).toBe("error");
});

it("rejects missing callbacks", () => {
const result = parseCliLoginHash("#public_key=abc");

expect(result.kind).toBe("error");
});

it("rejects remote callbacks", () => {
expect(isAllowedLocalCallback("https://memory.kinic.xyz/callback")).toBe(false);
expect(isAllowedLocalCallback("http://localhost:1234/callback")).toBe(false);
expect(parseCliLoginHash("#public_key=abc&callback=https%3A%2F%2Fmemory.kinic.xyz%2Fcallback").kind).toBe("error");
expect(parseCliLoginHash("#public_key=abc&callback=http%3A%2F%2Flocalhost%3A1234%2Fcallback").kind).toBe("error");
});
});

describe("cli login flow", () => {
it("logs out after a successful callback", async () => {
const logout = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const fetchCallback: typeof fetch = async () => new Response(null, { status: 200 });

await sendCliLoginDelegation(params(), {
createAuthClient: async () => authClient(logout),
loginClient: async () => undefined,
createDelegation: async () => ({ ok: true }),
fetchCallback,
});

expect(logout).toHaveBeenCalledOnce();
});

it("logs out after a callback network failure", async () => {
const logout = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const fetchCallback: typeof fetch = async () => {
throw new Error("network failed");
};

await expect(sendCliLoginDelegation(params(), {
createAuthClient: async () => authClient(logout),
loginClient: async () => undefined,
createDelegation: async () => ({ ok: true }),
fetchCallback,
})).rejects.toThrow("network failed");
expect(logout).toHaveBeenCalledOnce();
});

it("logs out after a non-2xx callback and preserves the callback error", async () => {
const logout = vi.fn<() => Promise<void>>().mockRejectedValue(new Error("logout failed"));
const fetchCallback: typeof fetch = async () => new Response(null, {
status: 500,
statusText: "Nope",
});

await expect(sendCliLoginDelegation(params(), {
createAuthClient: async () => authClient(logout),
loginClient: async () => undefined,
createDelegation: async () => ({ ok: true }),
fetchCallback,
})).rejects.toThrow("Callback failed: 500 Nope");
expect(logout).toHaveBeenCalledOnce();
});
});

function authClient(logout: () => Promise<void>): CliLoginAuthClient {
return {
getIdentity: () => new AnonymousIdentity(),
login: async () => undefined,
logout,
};
}

function params() {
return {
publicKey: "abc",
callback: "http://127.0.0.1:8620/callback",
};
}
Loading