docs: add Claude AI assistant rules and configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s

Add comprehensive rule documentation for AI-assisted development covering
authentication surfaces, outbound-call safety invariants, and other project
conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
2026-07-28 19:01:38 -04:00
parent 016d8be71d
commit 4a3c14d4af
40 changed files with 2851 additions and 202 deletions

View File

@@ -13,6 +13,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"@types/node": "^25.8.0",
"daisyui": "^5.0.0",
"svelte": "^5.25.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
@@ -1262,6 +1263,16 @@
"node": ">= 0.6"
}
},
"node_modules/daisyui": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -15,6 +15,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"@types/node": "^25.8.0",
"daisyui": "^5.0.0",
"svelte": "^5.25.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",

View File

@@ -1 +1,4 @@
@import 'tailwindcss';
@plugin 'daisyui' {
themes: light --default, dark --prefersdark;
}

View File

@@ -0,0 +1,5 @@
import { auth } from '$lib/auth.svelte';
// Runs once, before the app mounts: capture the Casdoor callback token from
// the URL fragment before any /auth/me call or render.
auth.captureFragmentToken();

View File

@@ -1,4 +1,5 @@
import type {
AccessToken,
CallHistoryRow,
CallSummary,
DeviceStatus,
@@ -10,19 +11,12 @@ import type {
} from './types';
// ---------------------------------------------------------------
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
// Kept in localStorage; a 401 prompts once and retries.
// Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
// a PAT) in localStorage via the auth store. On a 401 we attempt one
// silent refresh and retry; a second failure logs out.
// ---------------------------------------------------------------
const TOKEN_KEY = 'hold-slayer-token';
function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? '';
}
export function setToken(value: string): void {
localStorage.setItem(TOKEN_KEY, value);
}
import { auth, getToken } from './auth.svelte';
function withAuth(init: RequestInit): RequestInit {
const token = getToken();
@@ -36,10 +30,11 @@ function withAuth(init: RequestInit): RequestInit {
async function request(path: string, init: RequestInit = {}): Promise<Response> {
let res = await fetch(path, withAuth(init));
if (res.status === 401) {
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
if (supplied !== null && supplied.trim()) {
setToken(supplied.trim());
const refreshed = await auth.trySilentRefresh();
if (refreshed) {
res = await fetch(path, withAuth(init));
} else {
auth.setUnauthenticated();
}
}
return res;
@@ -89,8 +84,10 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
}
export function recordingUrl(callId: string): string {
// <audio> can't send headers, so the token rides as a query param
// (accepted server-side alongside the Authorization header).
// <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
// rides as a query param — the same narrow fallback the WebSocket uses,
// accepted server-side alongside the Authorization header. The proactive
// refresh timer keeps the stored JWT valid, so it's fresh at click time.
const token = getToken();
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
return `/api/v1/calls/${callId}/recording${suffix}`;
@@ -137,11 +134,35 @@ export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
// ---------------------------------------------------------------
// Personal Access Tokens (owner-only) — for MCP/CLI clients.
// ---------------------------------------------------------------
export async function fetchTokens(): Promise<AccessToken[]> {
return get<AccessToken[]>('/api/v1/tokens');
}
export async function createToken(name: string): Promise<AccessToken> {
const res = await request('/api/v1/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<AccessToken>;
}
export async function revokeToken(tokenId: string): Promise<void> {
const res = await request(`/api/v1/tokens/${tokenId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export function connectEventStream(
onEvent: (e: GatewayEvent) => void,
onClose: () => void,
): () => void {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// Browsers can't set headers on WS connects — the token rides as ?token=.
const token = getToken();
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);

View File

@@ -0,0 +1,146 @@
import type { User } from './types';
const TOKEN_KEY = 'hold-slayer-token';
// 'denied' = authenticated with Casdoor but not the owner of this gateway.
export type AuthStatus = 'loading' | 'authed' | 'unauthenticated' | 'denied';
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) || '';
}
function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
class AuthStore {
status = $state<AuthStatus>('loading');
user = $state<User | null>(null);
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
get isOwner(): boolean {
return this.user?.is_owner ?? false;
}
/**
* Capture a `#token=...` fragment left by the Casdoor callback, persist it,
* and scrub it from the URL. Runs before the first /auth/me call.
*/
captureFragmentToken() {
const hash = window.location.hash;
if (hash.startsWith('#token=')) {
const token = hash.slice(7);
if (token) setToken(token);
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
/** Determine auth state on boot. */
async init(): Promise<void> {
const token = getToken();
if (!token) {
// No token: maybe SSO is disabled (dev mode) — /auth/me succeeds tokenless.
try {
const res = await fetch('/auth/me');
if (res.ok) {
this.applyUser(await res.json());
return;
}
} catch {
/* fall through to unauthenticated */
}
this.status = 'unauthenticated';
return;
}
try {
const res = await fetch('/auth/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
// Token invalid — try silent refresh once, then retry.
const refreshed = await this.trySilentRefresh();
if (refreshed) return this.init();
clearToken();
this.status = 'unauthenticated';
return;
}
this.applyUser(await res.json());
this.scheduleTokenRefresh(token);
} catch {
this.status = 'unauthenticated';
}
}
/** Set user + status from an /auth/me payload. Non-owners are denied. */
private applyUser(user: User) {
this.user = user;
this.status = user.is_owner ? 'authed' : 'denied';
}
setUnauthenticated() {
clearToken();
this.user = null;
this.status = 'unauthenticated';
}
/** Silent token refresh via a hidden iframe + postMessage. */
trySilentRefresh(): Promise<boolean> {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = '/auth/silent-refresh';
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
window.removeEventListener('message', onMessage);
iframe.remove();
};
const onMessage = (event: MessageEvent) => {
if (!event.data || event.data.type !== 'hold-slayer-refresh') return;
cleanup();
if (event.data.token) {
setToken(event.data.token);
this.scheduleTokenRefresh(event.data.token);
resolve(true);
} else {
resolve(false);
}
};
window.addEventListener('message', onMessage);
document.body.appendChild(iframe);
setTimeout(() => {
cleanup();
resolve(false);
}, 10000);
});
}
/** Proactively refresh 5 minutes before JWT expiry. PATs are skipped. */
scheduleTokenRefresh(token: string) {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000;
const refreshIn = Math.max(exp - Date.now() - 5 * 60 * 1000, 30 * 1000);
this.refreshTimer = setTimeout(async () => {
const ok = await this.trySilentRefresh();
if (!ok) this.setUnauthenticated();
}, refreshIn);
} catch {
// Not a JWT (e.g. a PAT) — no refresh needed.
}
}
}
export const auth = new AuthStore();

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { auth } from '$lib/auth.svelte';
</script>
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div class="card bg-base-200 w-96 shadow-xl">
<div class="card-body items-center gap-4 text-center">
<h1 class="text-2xl font-bold">Not authorized</h1>
<p class="text-sm opacity-70">
Hold Slayer is a single-operator gateway. You're signed in as
<span class="font-medium">{auth.user?.display_name ?? auth.user?.name}</span>,
but this gateway is reserved for its owner.
</p>
<a href="/auth/logout" class="btn btn-outline w-full">Sign out</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
// A full-screen sign-in gate. The anchor is a real navigation to the
// server-side /auth/login route (which 302s to Casdoor), not a fetch.
</script>
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div class="card bg-base-200 w-80 shadow-xl">
<div class="card-body items-center gap-4 text-center">
<h1 class="flex items-center justify-center gap-2 text-3xl font-bold">
<span class="text-orange-500">🔥</span>
Hold Slayer
</h1>
<p class="text-sm opacity-60">Sign in to the gateway</p>
<a href="/auth/login" class="btn btn-primary w-full">Sign in with SSO</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { AccessToken } from '$lib/types';
import { createToken, fetchTokens, revokeToken } from '$lib/api';
let { open = $bindable(false) }: { open?: boolean } = $props();
let tokens = $state<AccessToken[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let newName = $state('');
let creating = $state(false);
// The plaintext of a just-created token — shown once, never re-fetchable.
let created = $state<AccessToken | null>(null);
async function load() {
loading = true;
error = null;
try {
tokens = await fetchTokens();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
$effect(() => {
if (open) {
created = null;
void load();
}
});
async function create() {
if (!newName.trim()) return;
creating = true;
error = null;
try {
created = await createToken(newName.trim());
newName = '';
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
async function revoke(id: string) {
error = null;
try {
await revokeToken(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
function mcpConfig(plaintext: string): string {
const url = `${location.origin}/mcp`;
return JSON.stringify(
{
mcpServers: {
'hold-slayer': {
type: 'streamable-http',
url,
headers: { Authorization: `Bearer ${plaintext}` }
}
}
},
null,
2
);
}
function copy(text: string) {
void navigator.clipboard.writeText(text);
}
</script>
{#if open}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">API Tokens</h3>
<p class="py-1 text-sm opacity-60">
Personal access tokens for MCP/CLI clients (Claude Desktop, Cline). The
plaintext is shown once — store it now.
</p>
{#if error}
<div class="alert alert-error my-2 text-sm">{error}</div>
{/if}
{#if created?.token}
<div class="alert alert-success my-3 flex-col items-start gap-2">
<span class="font-medium">Token created — copy it now, it won't be shown again.</span>
<code class="bg-base-300 w-full break-all rounded p-2 text-xs">{created.token}</code>
<div class="flex gap-2">
<button class="btn btn-xs" onclick={() => copy(created!.token!)}>Copy token</button>
<button class="btn btn-xs" onclick={() => copy(mcpConfig(created!.token!))}
>Copy MCP config</button
>
</div>
</div>
{/if}
<div class="my-3 flex gap-2">
<input
class="input input-bordered flex-1"
placeholder="Token name (e.g. Claude Desktop)"
bind:value={newName}
onkeydown={(e) => e.key === 'Enter' && create()}
/>
<button class="btn btn-primary" disabled={creating || !newName.trim()} onclick={create}>
{creating ? 'Creating…' : 'Create'}
</button>
</div>
{#if loading}
<div class="py-4 text-center opacity-60">Loading…</div>
{:else if tokens.length === 0}
<div class="py-4 text-center opacity-60">No tokens yet.</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Last used</th>
<th></th>
</tr>
</thead>
<tbody>
{#each tokens as t (t.id)}
<tr class:opacity-50={t.revoked_at}>
<td>{t.name}</td>
<td><code class="text-xs">{t.token_prefix}</code></td>
<td class="text-xs">{t.last_used_at?.slice(0, 10) ?? '—'}</td>
<td class="text-right">
{#if t.revoked_at}
<span class="badge badge-ghost badge-sm">revoked</span>
{:else}
<button class="btn btn-ghost btn-xs text-error" onclick={() => revoke(t.id)}>
Revoke
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="modal-action">
<button class="btn" onclick={() => (open = false)}>Close</button>
</div>
</div>
<button class="modal-backdrop" onclick={() => (open = false)} aria-label="Close"></button>
</div>
{/if}

View File

@@ -1,3 +1,23 @@
export interface User {
id: string;
name: string;
display_name: string | null;
email: string | null;
is_owner: boolean;
}
export interface AccessToken {
id: string;
name: string;
token_prefix: string;
created_at: string | null;
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
// Present only in the create response — the plaintext, shown once.
token?: string;
}
export interface GatewayStatus {
name: string;
version: string;

View File

@@ -2,9 +2,15 @@
import '../app.css';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { auth } from '$lib/auth.svelte';
import LoginScreen from '$lib/components/LoginScreen.svelte';
import DeniedScreen from '$lib/components/DeniedScreen.svelte';
import TokensModal from '$lib/components/TokensModal.svelte';
let { children } = $props();
let tokensOpen = $state(false);
type ThemeOverride = 'dark' | 'light' | null;
let override = $state<ThemeOverride>(null);
let systemDark = $state(true);
@@ -12,7 +18,10 @@
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
$effect(() => {
// Keep both theming systems in sync: Tailwind `dark:` variant (.dark class)
// for the existing pages, and DaisyUI `data-theme` for the SSO components.
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
});
function toggleTheme() {
@@ -27,6 +36,8 @@
}
onMount(() => {
void auth.init();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
systemDark = mq.matches;
@@ -49,40 +60,63 @@
];
</script>
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
>
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-orange-500 text-lg leading-none">🔥</span>
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
</div>
<nav class="flex gap-1 ml-2">
{#each nav as item}
<a
href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href
? 'bg-orange-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
{#if auth.status === 'loading'}
<div class="fixed inset-0 flex items-center justify-center bg-slate-50 dark:bg-gray-950">
<span class="loading loading-spinner loading-lg text-orange-500"></span>
</div>
{:else if auth.status === 'unauthenticated'}
<LoginScreen />
{:else if auth.status === 'denied'}
<DeniedScreen />
{:else}
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
>
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-orange-500 text-lg leading-none">🔥</span>
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
</div>
<nav class="flex gap-1 ml-2">
{#each nav as item}
<a
href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href
? 'bg-orange-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
>
{item.label}
</a>
{/each}
</nav>
<div class="ml-auto flex items-center gap-2">
<button
onclick={toggleTheme}
class="text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{item.label}
</a>
{/each}
</nav>
<button
onclick={toggleTheme}
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? 'Light' : 'Dark'}
</button>
</div>
</header>
{isDark ? 'Light' : 'Dark'}
</button>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm">
{auth.user?.display_name ?? auth.user?.name ?? 'Owner'}
</button>
<ul class="dropdown-content menu bg-base-200 rounded-box z-20 w-48 p-2 shadow">
<li><button onclick={() => (tokensOpen = true)}>API Tokens</button></li>
<li><a href="/auth/logout">Sign out</a></li>
</ul>
</div>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6">
{@render children()}
</main>
</div>
<main class="mx-auto max-w-7xl px-4 py-6">
{@render children()}
</main>
</div>
<TokensModal bind:open={tokensOpen} />
{/if}