Skip to content
Merged
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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,21 @@ The callback verifies browser state and PKCE, issues the existing HttpOnly porta
session cookie, and clears an old RPX cookie when the account changes. RPX browser
authorization preserves its local return path through signup/login. Existing UAC
proxy settings still support session validation and RPX exchange. Desktop account
selection and the KA dashboard are separate next-task work.
selection uses the backend's separate public device flow. Web login keeps the
credential flow.

Global profile editing is available at `/dash/account`. `/api/account/profile`
proxies the centrally stored KA name/avatar with the service identity and session
pinned server-side. Profile changes refresh on focus and while visible; drafts
survive errors and concurrent edits require an explicit refresh. The account
navigation badge uses that same profile. `/api/account/manage` opens the configured
central dashboard for service disconnects, account-wide managed storage, and
signout. A portal-only session may need to sign in to the authority first.

Validation:

```bash
npm run typecheck
node scripts/kastrick-login-smoke.cjs
node scripts/kastrick-profile-smoke.cjs
```
6 changes: 6 additions & 0 deletions app/api/account/manage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { loginConfig } from "@/lib/kastrick-login";
export function GET() {
try { return NextResponse.redirect(new URL("/account", loginConfig().authority)); }
catch { return NextResponse.json({ ok: false, message: "Kastrick Account management is temporarily unavailable." }, { status: 503 }); }
}
27 changes: 27 additions & 0 deletions app/api/account/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { loginApi, DEVELOPER_SESSION_COOKIE } from "@/lib/kastrick-login";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function dispatch(request: NextRequest, update: boolean) {
if (update && request.headers.get("origin") !== request.nextUrl.origin) return NextResponse.json({ ok: false, message: "Reload this page before saving." }, { status: 403 });
const token = request.cookies.get(DEVELOPER_SESSION_COOKIE)?.value;
if (!token) return NextResponse.json({ ok: false, message: "Sign in to edit your Kastrick profile." }, { status: 401 });
let edits: Record<string, unknown> = {};
if (update) {
try {
const text = await request.text(); if (Buffer.byteLength(text, "utf8") > 2048) throw new Error("large profile");
const payload = JSON.parse(text);
if (!payload || typeof payload.displayName !== "string" || payload.displayName.length > 160 || typeof payload.avatarTone !== "string" || !Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) throw new Error("invalid profile");
edits = { displayName: payload.displayName, avatarTone: payload.avatarTone, expectedRevision: payload.expectedRevision };
} catch { return NextResponse.json({ ok: false, message: "Enter a display name and choose an avatar color." }, { status: 400 }); }
}
try {
// Ignore browser-supplied identities/tokens: the service and session come
// exclusively from deployment configuration and the HttpOnly cookie.
const result = await loginApi("profile", { action: update ? "updateProfile" : "getProfile", sessionToken: token, ...edits });
const message = result.status === 409 ? "Your profile changed in another service. Refresh it before saving; your input has been kept." : result.status === 401 ? "Your session expired. Sign in again." : result.status === 400 ? "Use a name of 1–80 characters and choose an avatar color." : "Kastrick accounts are temporarily unavailable.";
return NextResponse.json(result.ok ? { ok: true, profile: result.body.profile, avatarUrl: result.body.avatarUrl, storage: result.body.storage } : { ok: false, message }, { status: result.ok ? 200 : result.status >= 400 ? result.status : 502, headers: { "Cache-Control": "no-store" } });
} catch { return NextResponse.json({ ok: false, message: "Kastrick accounts are temporarily unavailable." }, { status: 503 }); }
}
export function GET(request: NextRequest) { return dispatch(request, false); }
export function PATCH(request: NextRequest) { return dispatch(request, true); }
4 changes: 4 additions & 0 deletions app/dash/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { KastrickProfileEditor } from "@/components/KastrickProfile";
export default function AccountPage() {
return <section><span className="kicker">KA · Kastrick Account</span><h1>Your profile, everywhere.</h1><p>Profile changes sync with Engine Studio and your connected Kastrick apps.</p><KastrickProfileEditor /></section>;
}
17 changes: 17 additions & 0 deletions components/KastrickProfile.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.profile { padding: 16px; color: inherit; border: 1px solid var(--border, #51435e); border-radius: 12px; font-size: 12px; }
.profile header { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; }
.profile header img { flex: none; border-radius: 14px; }
.profile h3 { margin: 0 0 5px; font-size: 16px; }
.profile p { margin: 8px 0; line-height: 1.6; overflow-wrap: anywhere; }
.profile header p { font-size: 11px; }
.profile form { display: grid; gap: 9px; }
.profile label { font-weight: 600; margin-top: 4px; }
.profile input, .profile select { min-height: 42px; width: 100%; border: 1px solid var(--border, #51435e); border-radius: 7px; background: var(--input-bg, transparent); color: inherit; padding: 10px; font: inherit; }
.profile select option { background: #1b1525; color: #f6edff; }
.profile button { min-height: 44px; border: 1px solid #c4a7ee; border-radius: 8px; margin-top: 10px; background: #d6c0ff; color: #21132f; font: inherit; font-weight: 650; cursor: pointer; }
.profile button:disabled { opacity: .6; cursor: default; }
.profile a { display: inline-block; min-height: 44px; padding: 14px 0 0; color: inherit; text-decoration: underline; }
.profile :focus-visible { outline: 2px solid #bda1ea; outline-offset: 3px; }
.badge { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
.badge img { border-radius: 7px; flex: none; }
.badge span { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
69 changes: 69 additions & 0 deletions components/KastrickProfile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { PROFILE_UPDATED_EVENT, readKastrickProfile, updateKastrickProfile, type KastrickProfileReply } from "@/lib/ka-profile";
import styles from "./KastrickProfile.module.css";

/** Refresh on focus/visibility and while visible, without replacing an owner's
* unsaved draft. The draft retains its original revision for conflict checking. */
export function KastrickProfileEditor() {
const id = useId();
const [account, setAccount] = useState<KastrickProfileReply | null>(null);
const [name, setName] = useState(""); const [tone, setTone] = useState("violet");
const [busy, setBusy] = useState(false); const [message, setMessage] = useState("Loading your Kastrick profile…");
const dirty = useRef(false); const saving = useRef(false); const draftRevision = useRef(0);
const refreshRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
useEffect(() => {
const controller = new AbortController(); let refreshing = false;
async function refresh(force = false) {
if (saving.current || refreshing) return;
refreshing = true;
try {
const body = await readKastrickProfile(controller.signal);
if (controller.signal.aborted || saving.current) return;
setAccount(body);
if (!dirty.current || force) { setName(body.profile.displayName); setTone(body.profile.avatarTone); draftRevision.current = body.profile.revision; dirty.current = false; setMessage(force ? "Profile refreshed." : ""); }
} catch (error) { if (!controller.signal.aborted) setMessage(error instanceof Error ? error.message : "Couldn’t refresh your profile."); }
finally { refreshing = false; }
}
refreshRef.current = refresh;
const update = () => { if (!document.hidden) void refresh(); };
void refresh(); window.addEventListener("focus", update); window.addEventListener(PROFILE_UPDATED_EVENT, update); document.addEventListener("visibilitychange", update);
const timer = window.setInterval(update, 30000);
return () => { controller.abort(); window.clearInterval(timer); window.removeEventListener("focus", update); window.removeEventListener(PROFILE_UPDATED_EVENT, update); document.removeEventListener("visibilitychange", update); };
}, []);
async function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); if (saving.current) return; saving.current = true; setBusy(true);
try {
const body = await updateKastrickProfile(name, tone, draftRevision.current);
setAccount(body); setName(body.profile.displayName); setTone(body.profile.avatarTone); draftRevision.current = body.profile.revision; dirty.current = false;
setMessage("Profile saved across Kastrick. Other services refresh it when you return to them."); window.dispatchEvent(new Event(PROFILE_UPDATED_EVENT));
} catch (error) { setMessage(error instanceof Error ? error.message : "Couldn’t save your profile."); }
finally { saving.current = false; setBusy(false); }
}
function reload() {
if (dirty.current && !window.confirm("Replace unsaved edits with your current Kastrick profile?")) return;
void refreshRef.current(true);
}
return (
<section className={styles.profile} aria-labelledby={`${id}-title`}>
<header>{account ? <img src={account.avatarUrl} width="48" height="48" alt="Generated account avatar" /> : null}<div><h3 id={`${id}-title`}>Kastrick Account</h3><p>{account?.profile.email || "One profile across Kastrick services."}</p></div></header>
<form onSubmit={event => void save(event)}>
<label htmlFor={`${id}-name`}>Display name</label><input id={`${id}-name`} value={name} onChange={event => { dirty.current = true; setName(event.target.value); }} maxLength={80} required disabled={busy || !account} autoComplete="nickname" />
<label htmlFor={`${id}-tone`}>Generated avatar color</label><select id={`${id}-tone`} value={tone} onChange={event => { dirty.current = true; setTone(event.target.value); }} disabled={busy || !account}><option value="violet">Violet</option><option value="cyan">Cyan</option><option value="rose">Rose</option><option value="amber">Amber</option></select>
<button type="submit" disabled={busy || !account}>{busy ? "Saving…" : "Save Kastrick profile"}</button><button type="button" disabled={busy} onClick={reload}>Refresh profile</button>
</form>
{message ? <p role="status">{message}</p> : null}
<a href="/api/account/manage" target="_blank" rel="noopener noreferrer">Manage connected services &amp; storage ↗</a>
</section>
);
}
export function KastrickProfileBadge() {
const [account, setAccount] = useState<KastrickProfileReply | null>(null);
useEffect(() => {
const controller = new AbortController(); let refreshing = false;
async function refresh() { if (refreshing || document.hidden) return; refreshing = true; try { const body = await readKastrickProfile(controller.signal); if (!controller.signal.aborted) setAccount(body); } catch { /* The service's existing session UI owns authentication failures. */ } finally { refreshing = false; } }
void refresh(); window.addEventListener("focus", refresh); window.addEventListener(PROFILE_UPDATED_EVENT, refresh); document.addEventListener("visibilitychange", refresh); const timer = window.setInterval(refresh, 30000);
return () => { controller.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener(PROFILE_UPDATED_EVENT, refresh); document.removeEventListener("visibilitychange", refresh); };
}, []);
return <span className={styles.badge}>{account ? <img src={account.avatarUrl} width="24" height="24" alt="" /> : null}<span>{account?.profile.displayName || "Kastrick Account"}</span></span>;
}
5 changes: 3 additions & 2 deletions components/dash-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Link from "next/link";
import { usePathname } from "next/navigation";
import { KastrickProfileBadge } from "@/components/KastrickProfile";
import { ThemeToggle } from "@/components/theme-toggle";

const mainItems = [
Expand All @@ -11,7 +12,7 @@ const mainItems = [
["Upload New Package", "/dash/new-package", "+"],
];

const reserved = new Set(["packages", "deployments", "new-package"]);
const reserved = new Set(["packages", "deployments", "new-package", "account"]);

export function DashShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
Expand Down Expand Up @@ -55,7 +56,7 @@ export function DashShell({ children }: { children: React.ReactNode }) {
<div className="dash-main">
<header className="dash-topbar">
<div><span className="dash-mobile-title">Developer Console</span>{packageName && <span className="dash-package-context">/ {packageName}</span>}</div>
<button className="account-pill">Kate <span>⌄</span></button>
<Link className="account-pill" href="/dash/account"><KastrickProfileBadge /><span>↗</span></Link>
</header>
<main className="dash-content">{children}</main>
</div>
Expand Down
25 changes: 25 additions & 0 deletions lib/ka-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Browser profile requests go to the service server. No session/client token
* crosses this boundary; the shared authority stores the canonical profile. */
export interface KastrickProfile {
userId: string;
displayName: string;
email: string | null;
avatarSeed: string;
avatarTone: string;
revision: number;
}
export interface KastrickProfileReply { profile: KastrickProfile; avatarUrl: string }
export const PROFILE_UPDATED_EVENT = "kastrick-profile-updated";
async function profileRequest(init?: RequestInit): Promise<KastrickProfileReply> {
const response = await fetch("/api/account/profile", { ...init, cache: "no-store" });
const result = await response.json();
if (!response.ok || result.ok !== true) throw new Error(result.message || "Couldn’t load your Kastrick profile.");
if (!result.profile || typeof result.profile.displayName !== "string" || !Number.isSafeInteger(result.profile.revision) || typeof result.avatarUrl !== "string") throw new Error("Kastrick returned an invalid profile.");
const avatar = new URL(result.avatarUrl);
if (!["https:", "http:"].includes(avatar.protocol) || avatar.username || avatar.password) throw new Error("Kastrick returned an invalid avatar.");
return { profile: result.profile, avatarUrl: avatar.href };
}
export function readKastrickProfile(signal?: AbortSignal) { return profileRequest({ signal }); }
export function updateKastrickProfile(displayName: string, avatarTone: string, expectedRevision: number) {
return profileRequest({ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName, avatarTone, expectedRevision }) });
}
4 changes: 2 additions & 2 deletions lib/kastrick-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ export const transactionCookieOptions = {
};

/** Only the service server holds its client credential and PKCE verifier. */
export async function loginApi(action: "start" | "exchange", body: Record<string, unknown>) {
export async function loginApi(action: "start" | "exchange" | "profile", body: Record<string, unknown>) {
const config = loginConfig();
const response = await fetch(new URL(`/v1/login/${action}`, config.authority), {
method: "POST",
headers: { Authorization: `Bearer ${config.clientToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, redirectUri: config.redirectUri }),
body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, ...(action === "profile" ? {} : { redirectUri: config.redirectUri }) }),
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(30_000),
Expand Down
45 changes: 45 additions & 0 deletions scripts/kastrick-profile-smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Test the real route handlers with an isolated upstream stub. The short
// browser return code must never become a browser-readable account bearer.
const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
const Module = require("node:module");
const root = path.resolve(__dirname, "..");
const originalLoad = Module._load;
Module._load = function (name, parent, isMain) {
if (name === "server-only") return {};
if (name.startsWith("@/")) name = path.join(root, name.slice(2));
return originalLoad.call(this, name, parent, isMain);
};
require.extensions[".ts"] = (module, filename) => {
module._compile(ts.transpileModule(fs.readFileSync(filename, "utf8"), {
fileName: filename,
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, esModuleInterop: true },
}).outputText, filename);
};
const assert = require("node:assert/strict");
const { NextRequest } = require("next/server");
const { GET, PATCH } = require("../app/api/account/profile/route.ts");
process.env.KASTRICK_LOGIN_API_URL = "http://localhost:4100";
process.env.DEVELOPER_PORTAL_LOGIN_REDIRECT_URI = "http://localhost:3100/api/account/callback";
process.env.DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN = "test-only-client-credential-with-32-characters";
const origin = "http://localhost:3100";
const savedFetch = global.fetch;
let calls = [];
let status = 200;
global.fetch = async (url, init) => { calls.push(JSON.parse(init.body)); return new Response(JSON.stringify(status === 200 ? { ok: true, profile: { displayName: "Ada", revision: 2 }, avatarUrl: "http://localhost:4100/avatars/test" } : { ok: false, error: "profile_changed" }), { status }); };
async function main() {
assert.equal((await GET(new NextRequest(`${origin}/api/account/profile`))).status, 401);
assert.equal(calls.length, 0);
const headers = { origin, cookie: "rbe_developer_session=server-session", "content-type": "application/json" };
let response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Ada", avatarTone: "cyan", expectedRevision: 1, sessionToken: "attacker-session", serviceId: "other", userId: "victim" }) }));
assert.equal(response.status, 200); assert.equal(calls[0].sessionToken, "server-session"); assert.equal(calls[0].serviceId, "developer_portal"); assert.equal(calls[0].userId, undefined); assert.equal(calls[0].expectedRevision, 1); assert.deepEqual(Object.keys(calls[0]).sort(), ["action", "avatarTone", "displayName", "expectedRevision", "serviceId", "sessionToken"].sort());
calls = [];
response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers: { ...headers, origin: "https://evil.example" }, body: "{}" }));
assert.equal(response.status, 403); assert.equal(calls.length, 0);
status = 409;
response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Draft", avatarTone: "rose", expectedRevision: 1 }) }));
assert.equal(response.status, 409); assert.match((await response.json()).message, /changed in another service/);
console.log("KA profile proxy cookie identity, CSRF origin and stale-revision checks passed.");
}
main().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => global.fetch = savedFetch);