Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
08f82ec
Add kinic portal web workspace
humandebri Apr 16, 2026
e02964d
Validate public memory IDs before routing
humandebri Apr 17, 2026
becb78e
Add portal summaries and stabilize remote MCP
humandebri Apr 17, 2026
8e3897b
No findings
humandebri Apr 17, 2026
48d047e
Fix portal review findings
humandebri Apr 17, 2026
565dea4
Fix remote MCP tool result finalization and cleanup build info
humandebri Apr 18, 2026
2031b32
Relax portal prompt output contract
humandebri Apr 18, 2026
8d7b155
Restore localhost OGP origin and fix verify:cf
humandebri Apr 20, 2026
61f4a89
{"findings":[{"title":"[P2] Narrow owner filtering to the launcher canid
humandebri Apr 20, 2026
cd1865c
Refactor portal OGP and public-memory runtime helpers
humandebri Apr 20, 2026
3a55dd2
2026-04-20 portal public-api split groundwork
humandebri Apr 21, 2026
c448766
2026-04-21 replace portal next shell with vite worker
humandebri Apr 21, 2026
306647b
Optimize memory OGP metadata caching
humandebri Apr 21, 2026
263ed9c
Harden kinic-portal dev and deploy contract
humandebri Apr 21, 2026
56a9893
cleanup portal client remnants
humandebri Apr 21, 2026
f622e69
improve portal memory metadata
humandebri Apr 21, 2026
e216185
Fix public memory fallback remount and docs
humandebri Apr 23, 2026
aadc420
Refine ChatGPT memory prompt instructions
humandebri Apr 23, 2026
ccfe24c
Harden memory sharing and OGP cache validation
humandebri Apr 26, 2026
85c312c
Add OpenAI Apps challenge endpoint
humandebri Apr 27, 2026
c83d463
Prevent oversized remote MCP queries from echoing back
humandebri May 6, 2026
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
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ target/

# environment variables
.env
.env.local
.dev.vars
.dev.vars.*

# python packaging
.venv/
Expand All @@ -32,5 +35,13 @@ pylate-index/
.vscode/

#web
.next/
node_modules/
.pnpm-store/
coverage/
.playwright-cli/
apps/kinic-portal/.wrangler/
apps/kinic-portal/.cache/
apps/kinic-portal/dist/
apps/kinic-portal/.playwright-cli/
apps/kinic-portal/workers/*/.wrangler/
*.tsbuildinfo
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Python bindings for the Kinic CLI core, enabling you to build AI agents with ver
**For the wizards building trustless agents** - no more lobotomized summons that reset on every quest.

Looking for the docs? See `docs/cli.md` for the command-line interface and `docs/tui.md` for the terminal UI.
Web portal docs: `apps/kinic-portal/docs/kinic-portal.md`.

Web workspace root: `apps/kinic-portal`.

Made with ❤️ by [ICME Labs](https://blog.icme.io/).

Expand Down
21 changes: 21 additions & 0 deletions apps/kinic-portal/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
59 changes: 59 additions & 0 deletions apps/kinic-portal/components/memory-access-denied.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Where: public memory fallback view for anonymous access denial.
// What: explains that anonymous users cannot read the target memory canister.
// Why: 403 should be explicit instead of collapsing into a generic not-found page.

import { AlertCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

export function MemoryAccessDenied({ memoryId }: { memoryId?: string }) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-5 pb-20 pt-6 md:px-6 md:pb-24">
<section className="hero-wash rounded-[32px] border border-border px-6 py-8 md:px-10 md:py-12">
<div className="max-w-4xl space-y-5">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">Access Denied</Badge>
<Badge variant="outline">Anonymous</Badge>
</div>
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Public Memory Access
</p>
<h1 className="text-[clamp(2.6rem,6vw,4.2rem)] font-semibold leading-[1.05] tracking-[-0.04em] text-foreground">
Anonymous access is blocked.
</h1>
<p className="max-w-3xl text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
Anonymous reads are denied for this memory canister.
</p>
</div>
</section>

<section className="mt-10 grid gap-5 md:grid-cols-[minmax(0,1.15fr)_minmax(280px,0.85fr)]">
<Card>
<CardHeader className="gap-3">
<Badge variant="secondary" className="w-fit">Advisory</Badge>
<CardTitle className="flex items-center gap-3">
<AlertCircle className="size-5" />
Access required
</CardTitle>
<CardDescription>Ask the owner to grant anonymous read access.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
{memoryId ? <p className="font-mono text-foreground/80">{memoryId}</p> : null}
<p>The shared URL can exist, but the public page does not render when anonymous `get_name()` fails.</p>
</CardContent>
</Card>

<Card className="shadow-none">
<CardHeader className="gap-3">
<Badge variant="secondary" className="w-fit">Resolution</Badge>
<CardTitle>What to do next</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-7 text-muted-foreground">
<p>Ask the owner to grant the `anonymous reader` role.</p>
<p>Once the public condition is satisfied, the same URL will expose details and read-only chat.</p>
</CardContent>
</Card>
</section>
</main>
);
}
48 changes: 48 additions & 0 deletions apps/kinic-portal/components/memory-not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Where: public memory fallback view for missing or non-public memories.
// What: explains that the target shared memory does not exist or is no longer public.
// Why: the portal page no longer returns server-side 404 once the shell is static.

import { SearchX } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

export function MemoryNotFound({ memoryId }: { memoryId?: string }) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-5 pb-20 pt-6 md:px-6 md:pb-24">
<section className="hero-wash rounded-[32px] border border-border px-6 py-8 md:px-10 md:py-12">
<div className="max-w-4xl space-y-5">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">Not Found</Badge>
<Badge variant="outline">Public Memory</Badge>
</div>
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Public Memory Access
</p>
<h1 className="text-[clamp(2.6rem,6vw,4.2rem)] font-semibold leading-[1.05] tracking-[-0.04em] text-foreground">
Shared memory not found.
</h1>
<p className="max-w-3xl text-base leading-7 text-muted-foreground md:text-lg md:leading-8">
The target memory does not exist or is no longer available for public reading.
</p>
</div>
</section>

<section className="mt-10 grid gap-5 md:grid-cols-[minmax(0,1.15fr)_minmax(280px,0.85fr)]">
<Card>
<CardHeader className="gap-3">
<Badge variant="secondary" className="w-fit">Status</Badge>
<CardTitle className="flex items-center gap-3">
<SearchX className="size-5" />
Memory unavailable
</CardTitle>
<CardDescription>The shared URL no longer resolves to a public memory canister.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
{memoryId ? <p className="font-mono text-foreground/80">{memoryId}</p> : null}
<p>Verify the canister id and confirm the memory is still deployed on the selected IC network.</p>
</CardContent>
</Card>
</section>
</main>
);
}
25 changes: 25 additions & 0 deletions apps/kinic-portal/components/memory-stat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Where: composed UI component for shared memory metadata stats.
// What: wraps one labeled value in a card-shaped stat tile.
// Why: memory and landing pages should reuse one stat presentation instead of duplicating markup.

import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";

export function MemoryStat({
label,
value,
className,
}: {
label: string;
value: string;
className?: string;
}) {
return (
<Card className={cn("grid min-w-0 gap-2 rounded-2xl px-4 py-4 shadow-none", className)}>
<span className="font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
<strong className="overflow-x-auto whitespace-nowrap font-mono text-sm font-medium text-foreground [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{value}
</strong>
</Card>
);
}
142 changes: 142 additions & 0 deletions apps/kinic-portal/components/memory-summary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// @vitest-environment jsdom

import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemorySummary } from "./memory-summary";

type DeferredResponse = {
promise: Promise<Response>;
resolve: (response: Response) => void;
reject: (error: unknown) => void;
};

function deferredResponse(): DeferredResponse {
let resolve!: (response: Response) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<Response>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}

function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: { "content-type": "application/json", ...init?.headers },
});
}

describe("MemorySummary", () => {
const fetchMock = vi.fn<typeof fetch>();

beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
Object.defineProperty(window.navigator, "language", {
configurable: true,
value: "en-US",
});
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("fetches one summary on first render", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
summary: "Alpha summary",
}),
);

render(<MemorySummary memoryId="alpha" />);

await screen.findByText("Alpha summary");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith("/api/public/memories/alpha/summary?language=en-US", {
method: "GET",
signal: expect.any(AbortSignal),
});
});

it("refetches and resets to loading when memoryId changes", async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
summary: "Alpha summary",
}),
)
.mockResolvedValueOnce(
jsonResponse({
summary: "Beta summary",
}),
);

const view = render(<MemorySummary memoryId="alpha" />);

await screen.findByText("Alpha summary");

view.rerender(<MemorySummary memoryId="beta" />);

expect(screen.queryByText("Alpha summary")).toBeNull();
await screen.findByText("Beta summary");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/public/memories/beta/summary?language=en-US", {
method: "GET",
signal: expect.any(AbortSignal),
});
});

it("ignores the stale response after navigating to another memory", async () => {
const alpha = deferredResponse();
const beta = deferredResponse();
fetchMock
.mockImplementationOnce((_input, init) => {
const signal = init?.signal;
signal?.addEventListener("abort", () => {
alpha.reject(new DOMException("Aborted", "AbortError"));
});
return alpha.promise;
})
.mockImplementationOnce(() => beta.promise);

const view = render(<MemorySummary memoryId="alpha" />);
view.rerender(<MemorySummary memoryId="beta" />);

await act(async () => {
beta.resolve(
jsonResponse({
summary: "Beta summary",
}),
);
});

await screen.findByText("Beta summary");
expect(screen.queryByText("Alpha summary")).toBeNull();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

it("recovers from an error after navigating to another memory", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: "summary unavailable" }, { status: 502 }))
.mockResolvedValueOnce(
jsonResponse({
summary: "Recovered summary",
}),
);

const view = render(<MemorySummary memoryId="alpha" />);

await screen.findByText("Summary unavailable right now.");

view.rerender(<MemorySummary memoryId="beta" />);

expect(screen.queryByText("Summary unavailable right now.")).toBeNull();
await screen.findByText("Recovered summary");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
Loading