feat(arcrun): mira wiki page with tag filter + accumulated WIP

- landing/app/mira/wiki: tag=mira-wiki list now shows all wiki paragraphs
  (depends on KBDB tag filter exposed in matrix/kbdb commit, separate repo)
- landing: app/mira hub + feed split + various WIP from prior sessions
- registry/components: claude_api / kbdb_create_block / kbdb_get / km_writer /
  platform_crypto / auth_oauth2 contracts + main.go (accumulated)
- .component-builds: pkg-lock updates + index.ts adjustments (WIP)
- .agents/specs/arcrun/frontend-redesign: design notes
- docs/test_credentials, docs/user_requirements/arcrun-landing-page: WIP docs
- cypher-executor: auth-dispatcher / wasi-shim adjustments (WIP)

Includes accumulated work from prior sessions plus the wiki UI tag-filter
update that surfaces the AI-generated wiki paragraphs at /mira/wiki.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 16:52:01 +08:00
parent e8fca33f80
commit 519423cb0d
127 changed files with 23909 additions and 264 deletions
@@ -0,0 +1,25 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read the chat transcripts first.** There are 1 chat transcript(s) in `arcrun/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
**Find the primary design file under `arcrun/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `arcrun/README.md` — this file
- `arcrun/chats/` — conversation transcripts (read these!)
- `arcrun/project/` — the `arcrun` project files (HTML prototypes, assets, components)
@@ -0,0 +1,56 @@
// App root — screen switcher with persistent route
const { useState, useEffect } = React;
const SCREENS = [
{ id: 'landing', label: 'Landing' },
{ id: 'auth', label: 'Auth' },
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'keys', label: 'API Keys' },
{ id: 'workflow', label: 'Workflow' },
];
// Synonyms from sidebar ids
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
function App() {
const [screen, setScreen] = useState(() => {
const saved = localStorage.getItem('arcrun:screen');
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
});
useEffect(() => {
localStorage.setItem('arcrun:screen', screen);
window.scrollTo(0, 0);
}, [screen]);
const nav = (id) => {
const resolved = aliases[id] || id;
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
};
const Current = {
landing: Landing,
auth: Auth,
dashboard: Dashboard,
keys: ApiKeys,
workflow: WorkflowViewer,
}[screen];
return (
<div className="app">
<Current onNav={nav} />
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
{SCREENS.map(s => (
<button key={s.id}
className={screen === s.id ? 'active' : ''}
onClick={() => nav(s.id)}>
{s.label}
</button>
))}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,92 @@
// Top nav and sidebar
const TopNav = ({ onNav, current }) => {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo onClick={() => onNav('landing')} />
<div className="nav-links" style={{marginLeft: 20}}>
<a>Product</a>
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
</div>
</div>
<div className="flex gap-8" style={{alignItems: 'center'}}>
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
<button className="btn btn-primary" onClick={() => onNav('auth')}>
Get started <Icon name="arrow_right" size={14} />
</button>
</div>
</nav>
);
};
const Footer = ({ onNav }) => (
<footer className="footer">
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo size="sm" />
<span>© 2026 Arcrun Labs</span>
</div>
<div className="footer-links">
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
<a>Status</a>
<a>Privacy</a>
</div>
</footer>
);
// App shell with sidebar for logged-in screens
const Sidebar = ({ current, onNav }) => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
{ id: 'keys', label: 'API Keys', icon: 'key' },
{ id: 'docs', label: 'Docs', icon: 'book' },
];
const bottom = [
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
return (
<aside className="sidebar">
<div className="sidebar-head">
<Logo size="md" onClick={() => onNav('landing')} />
</div>
<div className="sidebar-section">Workspace</div>
{items.map(it => (
<div key={it.id}
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
{it.count != null && <span className="sb-count">{it.count}</span>}
</div>
))}
<div style={{flex: 1}} />
{bottom.map(it => (
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
</div>
))}
<div className="sidebar-foot">
<div className="avatar-circ">MR</div>
<div className="meta">
<div className="name">Maya Rivera</div>
<div className="email">maya@northwind.co</div>
</div>
</div>
</aside>
);
};
Object.assign(window, { TopNav, Footer, Sidebar });
@@ -0,0 +1,86 @@
// Shared primitives: icons, logo, etc.
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
const paths = {
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
plus: <path d="M12 5v14M5 12h14" />,
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
check: <path d="M20 6L9 17l-5-5" />,
close: <path d="M18 6L6 18M6 6l12 12" />,
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
chevron_right: <path d="M9 6l6 6-6 6" />,
chevron_down: <path d="M6 9l6 6 6-6" />,
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
{paths[name]}
</svg>
);
};
// Arcrun wordmark — custom "arc" glyph made of an arc stroke + ascending dot/node
const Logo = ({ size = 'md', onClick }) => {
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
return (
<div className="logo" onClick={onClick}>
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
<circle cx="19" cy="17" r="2.2" fill="white" />
</svg>
</span>
<span>Arcrun</span>
</div>
);
};
// App icon with gradient background
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
const tones = {
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
green: 'linear-gradient(135deg, #10B981, #22C55E)',
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
slate: 'linear-gradient(135deg, #475569, #334155)',
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
};
return (
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
{children}
</div>
);
};
Object.assign(window, { Icon, Logo, AppIcon });
@@ -0,0 +1,95 @@
# Chat
_Started 2026-04-23 04:37 UTC_
---
## User
## Assistant
I'll design Arcrun as a cohesive 5-screen system. Let me plan and build this as an interactive prototype with all screens navigable.
_[tool: update_todos]_
Let me plan the architecture. I'll build a single HTML file with React, using multiple JSX component files, with screen navigation via state.
Design system note: I'll use Inter, electric indigo #6366F1 primary, #0F0F0F bg, #1A1A1A cards, with subtle indigo→violet gradients on CTAs. Dark, minimal, developer-focused. "Arcrun" wordmark will be original — a stylized "↯" or arc mark paired with the name.
Let me start building.
_[tool: write_file]_
_[tool: write_file]_
Now the primitives and chrome components:
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
Now the 5 screens. Let me write them concisely.
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: update_todos]_
_[tool: register_assets]_
_[tool: done]_
_[tool: snip]_
_[tool: list_files]_
The file exists. The error may have been transient. Let me verify by checking the page loads.
_[tool: show_html]_
_[tool: snip]_
_[tool: done]_
The CSS exists but isn't loading through the preview path. Let me inline it to eliminate the dependency.
_[tool: read_file]_
_[tool: run_script]_
_[tool: snip]_
_[tool: done]_
_[tool: fork_verifier_agent]_
Built **Arcrun** as a cohesive 5-screen dark-mode prototype — Landing, Auth, API Keys, Dashboard, and Workflow Viewer — all navigable via the floating screen switcher at the bottom.
**Highlights**
- **Landing** — hero with grid/glow bg, two-path split (developer terminal w/ npm·pip·bun tabs + Claude-style chat preview showing an Arcrun tool call)
- **Auth** — centered card, Sign in / Sign up tabs, GitHub + Google OAuth, email + password
- **API Keys** — first-time key callout with copy + hide-again warning, full keys table with live status toggles
- **Dashboard** — sidebar shell, 3-col apps grid (+ empty-state card), workflows table below
- **Workflow Viewer** — full-bleed dark canvas, 8 connected nodes with left-to-right bezier arrows, selectable with detail panel (I/O schema, editable triplet config for the AI node), minimap + zoom controls, prominent "Edit in Claude" CTA
Screen state persists in localStorage so reloads land you where you were.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
const ApiKeys = ({ onNav }) => {
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
const [keys, setKeys] = React.useState([
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
]);
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
const copyKey = () => {
setNewKeyCopied(true);
setTimeout(() => setNewKeyCopied(false), 1800);
};
const toggleKey = (id) => {
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
};
return (
<div className="shell">
<Sidebar current="keys" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Workspace</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Settings</span>
</div>
<h1>API Keys</h1>
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
</div>
</div>
<div className="main-body" style={{maxWidth: 1080}}>
<div className="new-key-box">
<div className="warn-row">
<span className="warn-icon"><Icon name="warn" size={12} /></span>
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
</div>
<h3>Your new API key</h3>
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
<div className="key-display">
<span className="key-val">{newKey}</span>
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
{newKeyCopied ? 'Copied' : 'Copy'}
</button>
</div>
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes </span></span>
</div>
</div>
<div className="section-head">
<div>
<h2>All keys</h2>
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '32%'}}>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th>Status</th>
<th style={{width: 60, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
</td>
<td className="mono">{k.prefix}{k.id.slice(-4)}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
<td>
{k.revoked ? (
<span className="pill revoked"><span className="pdot" /> Revoked</span>
) : (
<div className="flex gap-8" style={{alignItems: 'center'}}>
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
</span>
</div>
)}
</td>
<td style={{textAlign: 'right'}}>
{!k.revoked && (
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
<Icon name="warn" size={12} />
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
</div>
</div>
</div>
</div>
);
};
window.ApiKeys = ApiKeys;
@@ -0,0 +1,90 @@
const Auth = ({ onNav }) => {
const [mode, setMode] = React.useState('signin');
const [email, setEmail] = React.useState('');
const [pw, setPw] = React.useState('');
const [remember, setRemember] = React.useState(true);
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
return (
<div className="auth-wrap">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
<Logo onClick={() => onNav('landing')} />
</div>
<div className="auth-card">
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
<div className="tabs">
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
</div>
<div className="oauth-row">
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
</button>
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
<Icon name="google" size={15} stroke={0} /> Continue with Google
</button>
</div>
<div className="divider">or continue with email</div>
<form onSubmit={submit}>
{mode === 'signup' && (
<div className="field">
<label>Full name</label>
<input className="input" type="text" placeholder="Maya Rivera" />
</div>
)}
<div className="field">
<label>Work email</label>
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="field">
<div className="field-row">
<label>Password</label>
{mode === 'signin' && <span className="link">Forgot password?</span>}
</div>
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
</div>
{mode === 'signin' && (
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
<div onClick={() => setRemember(!remember)}
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
background: remember ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
{remember && <Icon name="check" size={11} />}
</div>
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
</div>
)}
<button className="btn btn-primary auth-submit btn-lg" type="submit">
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
</button>
</form>
{mode === 'signup' && (
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
</p>
)}
<div className="auth-foot">
{mode === 'signin'
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
</div>
</div>
</div>
);
};
window.Auth = Auth;
@@ -0,0 +1,126 @@
const Dashboard = ({ onNav }) => {
const apps = [
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
];
const workflows = [
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
];
return (
<div className="shell">
<Sidebar current="dashboard" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Northwind</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Dashboard</span>
</div>
<h1>Welcome back, Maya</h1>
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
</div>
</div>
<div className="main-body">
{/* Apps grid */}
<div className="section-head">
<div>
<h2>My Apps</h2>
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
</div>
<span className="subtle">{apps.length} apps</span>
</div>
<div className="apps-grid">
{apps.map(a => (
<div key={a.id} className="app-card">
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
<h4>{a.name}</h4>
<p className="dsc">{a.desc}</p>
<div className="row">
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
<button className="chip-btn">
<Icon name="spark" size={11} /> Edit in Claude
</button>
</div>
</div>
))}
<div className="app-card app-empty">
<div className="plus"><Icon name="plus" size={16} /></div>
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
</div>
</div>
{/* Workflows */}
<div className="wf-table">
<div className="section-head">
<div>
<h2>My Workflows</h2>
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '34%'}}>Workflow</th>
<th>Nodes</th>
<th>Last modified</th>
<th>Activity</th>
<th>Status</th>
<th style={{width: 100, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{workflows.map(w => (
<tr key={w.id}>
<td>
<div className="wf-row-name">
<span className="dot" />
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
</div>
</td>
<td className="dim">{w.nodes}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
<td>
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
<span className="pdot" /> {w.status}
</span>
</td>
<td style={{textAlign: 'right'}}>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
window.Dashboard = Dashboard;
@@ -0,0 +1,168 @@
const Landing = ({ onNav }) => {
const [installer, setInstaller] = React.useState('npm');
const installCmds = {
npm: '$ npm install arcrun',
pip: '$ pip install arcrun',
bun: '$ bun add arcrun',
};
return (
<div>
<TopNav onNav={onNav} current="landing" />
<div className="container">
<section className="hero">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div className="hero-eyebrow">
<span className="dot" />
<span>Now in public beta MCP-native</span>
</div>
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code Arcrun handles auth, retries, and state.</p>
<div className="hero-ctas">
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
Start free <Icon name="arrow_right" size={15} />
</button>
<button className="btn btn-secondary btn-lg">
<Icon name="book" size={14} /> Read the docs
</button>
</div>
</section>
<section className="paths">
{/* Developer path */}
<div className="path-card">
<div className="path-label">
<Icon name="terminal" size={13} /> For Developers
</div>
<h3>Three lines, any runtime.</h3>
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
<div className="install-tabs">
{Object.keys(installCmds).map(k => (
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
))}
</div>
<div className="terminal" style={{marginBottom: 12}}>
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">terminal</div>
</div>
<div className="terminal-body">
<div><span className="dim">{installCmds[installer]}</span></div>
</div>
</div>
<div className="terminal">
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
</div>
<div className="terminal-body">
{installer === 'pip' ? (
<>
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
<div className="sp-4"/>
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
</>
) : (
<>
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
<div className="sp-4"/>
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
</>
)}
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
</div>
</div>
{/* Everyone path */}
<div className="path-card">
<div className="path-label">
<Icon name="spark" size={13} /> For Everyone
</div>
<h3>Talk to your workflows.</h3>
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages in plain English.</p>
<div className="chat-preview">
<div className="chat-head">
<span className="brand-dot">AI</span>
<span>Your assistant Arcrun connected</span>
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
</div>
<div className="chat-body">
<div className="chat-msg user">
<div className="avatar">M</div>
<div className="bubble">Send this week's customer digest to the revenue team.</div>
</div>
<div className="chat-msg ai">
<div className="avatar">A</div>
<div className="bubble">
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
<div className="tool-card">
<div className="tool-icon">AR</div>
<div className="tool-meta">
<div className="tool-name">arcrun · digest/weekly</div>
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
</div>
<span className="pill active"><span className="pdot" />running</span>
</div>
</div>
</div>
</div>
<div className="chat-input">
<span>Reply to your assistant…</span>
<span className="caret" />
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
</div>
</div>
</section>
<section className="strip">
<div className="cell">
<div className="ico"><Icon name="bolt" size={15} /></div>
<h4>Run anywhere</h4>
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="workflow" size={15} /></div>
<h4>Composable steps</h4>
<p>Model calls, HTTP, database, branching wire them visually or in code.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="key" size={15} /></div>
<h4>Scoped keys</h4>
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="eye" size={15} /></div>
<h4>Observable</h4>
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
</div>
</section>
</div>
<Footer onNav={onNav} />
</div>
);
};
window.Landing = Landing;
@@ -0,0 +1,255 @@
const WorkflowViewer = ({ onNav }) => {
const nodes = [
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
];
const edges = [
['trigger', 'fetch'],
['trigger', 'events'],
['fetch', 'summarize'],
['events', 'summarize'],
['summarize', 'filter'],
['summarize', 'log'],
['filter', 'slack'],
['filter', 'mail'],
];
const [selectedId, setSelectedId] = React.useState('summarize');
const [title, setTitle] = React.useState('digest/weekly');
const [zoom, setZoom] = React.useState(100);
const selected = nodes.find(n => n.id === selectedId);
// Edit triplet inline (for the summarize node's prompt config)
const [triplet, setTriplet] = React.useState({
model: 'claude-haiku-4-5',
temperature: '0.3',
prompt: 'Summarize this week\'s account activity for the revenue team.',
});
// Measure node widths for edge endpoint accuracy
const nodeRefs = React.useRef({});
const [sizes, setSizes] = React.useState({});
React.useEffect(() => {
const ns = {};
for (const n of nodes) {
const el = nodeRefs.current[n.id];
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
}
setSizes(ns);
}, []);
const getPort = (id, side) => {
const n = nodes.find(x => x.id === id);
const sz = sizes[id] || { w: 200, h: 60 };
return {
x: side === 'out' ? n.x + sz.w : n.x,
y: n.y + sz.h / 2,
};
};
return (
<div className="wf-viewer">
<div className="wf-topbar">
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
<Icon name="arrow_left" size={16} />
</div>
<Logo size="sm" onClick={() => onNav('landing')} />
<div className="sep" />
<div className="wf-breadcrumb">
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
<Icon name="chevron_right" size={11} />
<input
className="wf-title mono"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<span className="wf-saved">
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
Saved · 2m ago
</span>
<div className="spacer" />
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
<button className="wf-edit-in-claude">
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
</button>
</div>
<div className="wf-canvas">
<svg className="wf-edges" width="100%" height="100%">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
</marker>
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
</marker>
</defs>
{edges.map(([a, b], i) => {
const p1 = getPort(a, 'out');
const p2 = getPort(b, 'in');
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
const highlight = a === selectedId || b === selectedId;
return (
<path key={i} d={d}
stroke={highlight ? '#6366F1' : '#3a3a3a'}
strokeWidth={highlight ? 2 : 1.5}
fill="none"
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
opacity={highlight ? 0.95 : 0.6} />
);
})}
</svg>
<div className="wf-nodes">
{nodes.map(n => (
<div key={n.id}
ref={el => (nodeRefs.current[n.id] = el)}
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
style={{left: n.x, top: n.y}}
onClick={() => setSelectedId(n.id)}>
{n.inputs.length > 0 && <span className="port in" />}
{n.outputs.length > 0 && <span className="port out" />}
<div className="node-row-top">
<span className="node-icon" style={{background: n.tone}}>
<Icon name={n.icon} size={12} />
</span>
<span className="node-title">{n.title}</span>
<span className="node-badge">{n.badge}</span>
</div>
<div className="node-sub">{n.type}</div>
</div>
))}
</div>
{/* Detail panel */}
{selected && (
<div className="wf-detail">
<div className="dt-head">
<span className="dt-icon" style={{background: selected.tone}}>
<Icon name={selected.icon} size={15} />
</span>
<div className="dt-meta">
<h3>{selected.title}</h3>
<div className="dt-type">{selected.type}</div>
</div>
<button className="close-btn" onClick={() => setSelectedId(null)}>
<Icon name="close" size={14} />
</button>
</div>
<div className="dt-body">
<div className="dt-section">
<h4>Input schema</h4>
{selected.inputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs this is a trigger.</div>
) : selected.inputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
<div className="dt-section">
<h4>Output schema</h4>
{selected.outputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs terminal node.</div>
) : selected.outputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
{selected.id === 'summarize' && (
<div className="dt-section">
<h4>Configuration</h4>
<div className="triplet">
<div className="trow">
<div className="tkey">model</div>
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">temp</div>
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">prompt</div>
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
</div>
</div>
</div>
)}
<div className="dt-section">
<h4>Last run</h4>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
</div>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
</div>
</div>
</div>
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
<Icon name="spark" size={13} /> Edit this node in Claude
</button>
</div>
</div>
)}
{/* Minimap */}
<div className="wf-minimap">
<div className="mini-label">Overview</div>
{nodes.map(n => {
const sz = sizes[n.id] || {w: 180, h: 60};
return (
<div key={n.id} className="mini-box" style={{
left: 8 + (n.x / 1400) * 164,
top: 18 + (n.y / 500) * 80,
width: Math.max(6, (sz.w / 1400) * 164),
height: Math.max(4, (sz.h / 500) * 80),
opacity: selectedId === n.id ? 1 : 0.5,
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
}} />
);
})}
</div>
{/* Zoom controls */}
<div className="wf-controls">
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
<div className="zoom-val">{zoom}%</div>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
<button><Icon name="maximize" size={13} /></button>
</div>
</div>
</div>
);
};
window.WorkflowViewer = WorkflowViewer;
@@ -0,0 +1,300 @@
# Frontend Redesign — Design
> 讀此檔前請先讀 `requirements.md` 和 `design-source/index.html`。
> 視覺 spec 的 single source of truth 是 `design-source/`Claude Design 匯出的 HTML/JSX prototype)。
---
## 1. 架構總覽
```
landing/ (Next.js 15 App Router)
├── app/
│ ├── layout.tsx ← 全站 layoutnext/font + design tokens + 全域 CSS 匯入
│ ├── globals.css ← 匯入 design-tokens.cssTailwind @import
│ ├── design-tokens.css ← 新增:從 design-source 抽出的 CSS variables:root {...}
│ ├── page.tsx ← LandingRSC
│ ├── auth/
│ │ └── page.tsx ← Auth"use client"
│ ├── dashboard/
│ │ └── page.tsx ← Dashboard"use client",仍靠 middleware 保護)
│ ├── keys/
│ │ └── page.tsx ← API Keys"use client"
│ ├── workflows/
│ │ ├── page.tsx ← Workflows 清單(redirect 到 dashboard 的 table,本身極簡)
│ │ └── [name]/page.tsx ← Workflow Viewer"use client"
│ ├── integrations/page.tsx ← 保留現有
│ ├── api-docs/page.tsx ← 保留現有
│ └── login/page.tsx ← 保留現有(redirect /auth 同義;見 §9 遷移策略)
├── components/
│ ├── shell/
│ │ ├── Logo.tsx
│ │ ├── Icon.tsx
│ │ ├── TopNav.tsx
│ │ ├── Footer.tsx
│ │ └── Sidebar.tsx
│ ├── primitives/
│ │ ├── Button.tsx ← btn / btn-primary / btn-secondary / btn-ghost 對應 class
│ │ ├── Pill.tsx
│ │ ├── Toggle.tsx
│ │ ├── Terminal.tsx ← landing hero 右卡用
│ │ └── ChatPreview.tsx ← landing hero 右卡用
│ └── workflow/
│ ├── Canvas.tsx ← wf-viewer 本體(節點 + SVG edges
│ ├── NodeCard.tsx
│ ├── DetailPanel.tsx
│ ├── Minimap.tsx
│ └── ZoomControls.tsx
├── lib/
│ ├── api.ts ← typed fetch wrapperfetch ${API_BASE}${path}, credentials: 'include'
│ ├── workflows.ts ← listWorkflows / getWorkflow / getWorkflowYaml
│ ├── apiKeys.ts ← listKeys / createKey / patchKey / deleteKey
│ └── me.ts ← 已存在邏輯,集中到此
├── middleware.ts ← 擴展 matcher(加 /keys, /workflows/*
└── ...(既有 package.json / wrangler.toml 不變)
```
**路由對照設計稿的 5 screen**
| Screen | Route |
|---|---|
| Landing | `/` |
| Auth | `/auth`(新增;`/login` 保留並內部 `redirect('/auth')` |
| Dashboard | `/dashboard` |
| API Keys | `/keys` |
| Workflow Viewer | `/workflows/[name]` |
---
## 2. Design tokens 對應
設計稿所有 CSS 變數抄進 `app/design-tokens.css`**不解析、不改名**
```css
:root {
--bg: #0F0F0F;
--bg-1: #141414;
--card: #1A1A1A;
--card-2: #222222;
--line: #262626;
--line-2: #303030;
--text: #EDEDED;
--text-dim: #A0A0A0;
--text-mute: #6B6B6B;
--primary: #6366F1;
--primary-2: #8B5CF6;
--primary-soft: rgba(99, 102, 241, 0.12);
--primary-ring: rgba(99, 102, 241, 0.32);
--success: #22C55E;
--warn: #F59E0B;
--danger: #EF4444;
--gradient: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
--gradient-soft: linear-gradient(135deg, rgba(99,102,241,0.16) 0%, rgba(139,92,246,0.16) 100%);
}
```
並在 Tailwind v4 的 `@theme inline` block 內對應出:
```css
@theme inline {
--color-bg: var(--bg);
--color-card: var(--card);
--color-card-2: var(--card-2);
--color-line: var(--line);
--color-line-2: var(--line-2);
--color-text: var(--text);
--color-text-dim: var(--text-dim);
--color-text-mute: var(--text-mute);
--color-primary: var(--primary);
--color-primary-2: var(--primary-2);
}
```
這樣 JSX 裡可用 `bg-bg / text-text-dim / border-line`,又保留 CSS 變數語義。
**現有的 `--background: #0a0a0a` 要換成 `#0F0F0F`**(視覺 breaking change;受影響:所有沿用 `bg-[#0a0a0a]` 的 inline 值)。
---
## 3. 字型
```tsx
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
weight: ['300', '400', '500', '600', '700', '800'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
weight: ['400', '500', '600'],
});
// body class = `${inter.variable} ${mono.variable}`
```
`globals.css` 中的 `body { font-family: var(--font-inter), -apple-system, sans-serif; }``.mono` class 用 `font-family: var(--font-mono)`
**移除**
- `design-source/index.html` 第 7-9 行的 `<link rel="preconnect"> / <link href="fonts.googleapis.com">`(不寫入 production)。
- React / Babel standalone script 標籤(prototype 專用,不進 production)。
---
## 4. 元件 porting 規則
Claude Design 用了 `window.Icon / window.Logo / window.AppIcon / window.TopNav ...` 的 globals 風格 — 那是 prototype 專用。Port 到 Next.js 時:
1. 每個元件拆單檔、具名 export。
2. 用 Tailwind + `className` 模板字串;共用 variant(如 btn)用 `cva`-style helper 即可(自己寫 5 行的 `clsx`-alike 函式),**不引入 class-variance-authority / clsx 套件**(避免新依賴)。
3. Icon 的 `paths` 直接搬,但每個 icon 拆成自己的 functional component 或集中在一個 `<Icon name="..." />`(沿用 design source 的 pattern)。
4. SVG arc wordmark 的 logo 直接 port。
---
## 5. 各 screen 實作細節
### 5.1 Landing — `app/page.tsx`
- 結構:`<TopNav />` + `<Hero />` + `<Paths />` + `<Strip />` + `<Footer />`
- Heroheading、eyebrow、CTA、radial grid bg(純 CSS)。
- Paths 左卡(Developer):install tabs (`npm` / `pip` / `bun`) + 兩個 terminal block**code 範例用 dogfooding 範例**`acr` CLI),不留 `Arcrun` SDK 假 API。
- Paths 右卡(Everyone):chat preview 結構保留;assistant 對話中的 tool call 用「arcrun · digest/weekly」不動。
- Strip4 cell。
- `LandingClientTabs` 因為有 tabs state,需標 `"use client"`;外層保持 RSC。
### 5.2 Auth — `app/auth/page.tsx`
- `"use client"`。state`mode: 'signin' | 'signup'`, `email`, `pw`, `remember`
- Submit`fetch(${API_BASE}/auth/password-login, { method: 'POST', credentials: 'include' })`**若 cypher-executor 尚未支援 password auth,先顯示「Password 登入尚未開放,請用 OAuth」警示,不偽造成功流程**)。
- OAuth 按鈕:直接 `<a href={API_BASE}/auth/google/start?redirect=/dashboard>`,和現行 `/login` 同樣機制。
- 下標提示「By signing up, you agree to our Terms ...」保留 static 字串。
- 保留 `/login` 路由向後相容(RSC 裡 `redirect('/auth')`)。
### 5.3 Dashboard — `app/dashboard/page.tsx`
- `"use client"` 或 split(外層 RSC 抓 /me,內層 Client)。
-`<Sidebar current="dashboard" />` + main。
- 主要區塊:
- Main headbreadcrumb「{email 的 domain} Dashboard」、heading「Welcome back, {display_name}」、subtitle 顯示 app/workflow 總數(從 `/apps` + `/workflows` 計算;若 endpoint 404 → 顯示 `—`)。
- Apps Grid`/apps` 的結果渲染;每列永遠有一個 `app-empty` 卡(新建 CTA)。
- Workflows Table`/workflows` 的結果渲染;空時改為全寬「No workflows yet. Run `acr push` to add one.」內嵌指令框。
- 「Open app」「View」按鈕導向 `/workflows/[name]`
- 「Edit in Claude」按鈕本次不做動作,僅保 UIdisabled + tooltip「Coming soon」)。
### 5.4 API Keys — `app/keys/page.tsx`
- `"use client"`
- Fetch `/api-keys`:若回傳為空陣列但 `/me` 有 api_keyfallback 顯示 `/me.api_key` 為唯一一列(單 key 相容模式)。
- 頂部 new-key-box:只在「剛剛建立新 key」的一次性狀態顯示(`useState` + `sessionStorage` flagreload 後消失)。
- 表格、toggle、trash:對應 PATCH / DELETE。
- 「Create new key」按鈕:呼叫 `POST /api-keys`,拿到後打 highlight box。
- Revoke 警告文字維持設計稿「within 60 seconds」。
### 5.5 Workflow Viewer — `app/workflows/[name]/page.tsx`
- `"use client"`param `name` 來自動態路由。
- Mount 後呼叫 `GET /workflows/:name`:後端回傳 `{ name, nodes: Node[], edges: Edge[], yaml, last_run: {...} }`(若 endpoint 未實作 → 顯示「Workflow viewer 尚未啟用」empty state,不用假資料)。
- `<Canvas>` 內:
- SVG 的 `<marker>`, `<path>` 定義抄設計稿。
- Node 用絕對定位(x/y 直接用 API 資料;資料沒有 coord 時做自動 layout — 階段性做簡單 dagre-free 的「column by depth」排版,避免新依賴)。
- 點選節點 → 右側 detail panel 顯示 input/output schema;若 type 含 `ai.*`,顯示 triplet 編輯器(model / temp / prompt)— 編輯本次 **read-only**disabled input + 「Edit via acr CLI」提示)。
- 「Export YAML」按 `GET /workflows/:name/yaml``download` blob。
- 「Edit in Claude」:本次只開新 tab 到 `https://claude.ai/new?q=...`(文案「coming soon」按鈕),避免偽裝已整合。
- Zoom controls、minimap:純 UI`zoom` state 實際不套 transform(或簡單 `style={{ transform: scale(zoom/100) }}` 套在 `.wf-nodes` + svg)。
---
## 6. API wrapper`lib/api.ts`
```ts
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
export async function arcrunFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Accept': 'application/json', ...(init.headers ?? {}) },
...init,
});
if (res.status === 401 && typeof window !== 'undefined') {
window.location.href = `/auth?redirect=${encodeURIComponent(location.pathname)}`;
throw new Error('unauthenticated');
}
if (!res.ok) throw new Error(`arcrun ${path}: ${res.status}`);
return res.json() as Promise<T>;
}
```
所有頁面透過這個 wrapper。**禁止在 page.tsx 裡 hard-code `fetch('https://...')`**(測試可以 grep)。
---
## 7. Middleware
```ts
export const config = {
matcher: ['/dashboard/:path*', '/keys/:path*', '/workflows/:path*'],
};
```
現有邏輯(讀 `arcrun_session` cookie,沒有就 redirect `/login?redirect=...`)保留,`/login` 改為內部 redirect `/auth`
---
## 8. 不做的設計稿功能
| 設計元素 | 取捨 |
|---|---|
| 底部的 `proto-switch`5 個 screen 切換 pill | **刪**。那是 prototype 用的 demo 切換器,不進 production。 |
| Sidebar 的 `count` badge | 先保留;數字從 `/workflows` / `/apps` 的長度派生;無資料時藏起來。 |
| Sidebar bottom 的 avatar + "Maya Rivera / maya@northwind.co" | 換成 `{display_name} / {email}`(真資料)。 |
| Workflow Viewer 的 triplet 可編輯 | 本次 disabled,僅顯示。 |
| 「Edit in Claude」整合 | 按鈕保留,點擊開新 tab 到 claude.ai,不串 MCP/API。 |
| 多 workspace breadcrumb | 固定顯示用戶 email domain 或「Personal」。 |
---
## 9. 既有頁面遷移
| 既有 | 處理 |
|---|---|
| `/page.tsx` | **rewrite**:沿用設計稿結構,code demo 字串改為 `acr` 實際指令(現有的 `auth.bind(...)` 寫法可保留在 Python tab |
| `/login` | 改為 `redirect('/auth')`Next.js RSC redirect),保留舊連結相容 |
| `/dashboard` | **rewrite**:舊 dashboard 變成 API Keys 獨立頁 + 新 Dashboard 總覽。原本 dashboard 裡的 Key 卡片搬到 `/keys`。 |
| `/api-docs` | 不動 |
| `/integrations` | 不動;在 Dashboard Apps Grid 旁提供 link |
---
## 10. 開發順序(高度相依)
`tasks.md`。總則:
1. 先做 design tokens + shellLogo / Icon / Button / Sidebar / TopNav / Footer — 其他頁面都吃這些。
2. 然後 Landing(可直接驗證視覺基準)。
3. 然後 Auth(獨立)。
4. 然後 API Keys(後端依賴少)。
5. 然後 Dashboard(依賴 `/workflows` + `/apps`,若未實作先 empty state)。
6. 最後 Workflow Viewer(依賴最重,多 endpoint)。
---
## 11. 風險與未解
| 風險 | 緩解 |
|---|---|
| cypher-executor 尚未有 `/workflows`, `/apps`, `/api-keys` CRUD | 前端先做,統一走 404 → empty state;另開 task 去 cypher-executor SDD 增補。本次 SDD 不負責後端實作。 |
| Password auth 沒實作 | Auth 頁 email/password form 在 submit 時顯示「OAuth only」提示 |
| `acr push` 未記錄 node 座標 | Canvas 自動排版(by topological depth),不強制 YAML 載入 layout |
| `next-on-pages``"use client"` 大量頁面的 edge runtime 支援 | 本來就用 `next-on-pages`,問題不大;必要時 per-page `export const runtime = 'edge'` |
| 舊 `/dashboard` 的 bookmark 使用者 | 現行 `/dashboard` 的 Key 管理被搬走;保留 Key 區塊 + 顯示提示「New page: /keys」引導 |
---
## 12. 與封測的關係
此 SDD 的實作**不解除封測阻擋**(封測阻擋在 Credential Primitives WASM)。此重設計與 Phase 0.6 / 0.7 / 1-3 是並行軌道。richblack 可決定先後順序,但本 SDD 獨立可 ship。
@@ -0,0 +1,159 @@
# Frontend Redesign — Requirements
> 來源:Claude Design bundle `JAdpACs3cSyw_vN6Ketj1Q`(已歸檔於 `design-source/`)。
> 此 SDD 擴展 `../landing-page.md` 的範圍:landing 從「單頁 + OAuth + Dashboard」升級為「5 screen app shell」。
---
## 1. 背景
`arcrun/landing/` 目前只提供:
- `/` — Landing hero + code demo
- `/login` — Google / GitHub OAuth
- `/dashboard` — API Key 管理
- `/api-docs` — Swagger UI(外部)
- `/integrations` — 20 個 recipe 靜態清單
Claude Design 交付一套完整 5 screen 設計(Landing / Auth / API Keys / Dashboard / Workflow Viewer),與現有前端相比多了:
- **Dashboard 的 Apps Grid 與 Workflows Table**(現在沒有應用/workflow 清單 UI
- **API Keys 獨立頁**(現在和 dashboard 混在一起)
- **Workflow Viewer**node-based canvas,對應 YAML workflow 的視覺化(目前 acr push YAML 後只有 CLI 輸出)
---
## 2. User Stories
### US-1:新訪客認識 arcrun
- 作為沒用過 arcrun 的工程師,我要在 landing 看到兩條路(For Developers / For Everyone),5 秒內判斷這是否符合我要的用法。
- 驗收:hero + 雙 path card + feature strip 在首屏呈現;CTA「Start free」可點擊到 `/auth`
### US-2:取得 / 登入會員
- 作為訪客,我要用 GitHub / Google / Email 登入建立帳號。
- 驗收:`/auth` 支援 Sign in / Sign up tabs、GitHub + Google OAuth、Email + Password 表單。OAuth 成功後導向 `/dashboard`
### US-3:看見應用全貌
- 作為已登入用戶,我要在 Dashboard 看到我已部署的 Apppackaged workflow)和 Workflow 清單。
- 驗收:
- Apps Grid:每個 app 卡片顯示名稱、描述、「Open app」「Edit in Claude」。
- Workflows Table:顯示 workflow 名稱、節點數、最後修改、執行次數、狀態。
- 若 app / workflow 為空,顯示新建 CTA(非硬編 mock)。
### US-4:管理 API Keys
- 作為已登入用戶,我要建立、檢視、停用、刪除 API Key。
- 驗收:
- 剛產生的 Key 顯示在頂部高亮卡片(含警示文字「不會再顯示」)。
- 全部 Keys 在下方表格顯示(名稱、遮蔽後 key、建立時間、最後使用、狀態、toggle、trash)。
- Rotate / Revoke 立即生效(呼叫 cypher-executor)。
### US-5:檢視 Workflow graph
- 作為已登入用戶,我要點 workflow 進到 viewer,看到節點 DAG 與每個節點的 I/O schema 與參數。
- 驗收:
- Canvas 顯示節點(含 icon / type badge),節點以 bezier 曲線相連。
- 選中節點後右側面板顯示 input / output schema、configuration(針對 ai.completion 等節點顯示 triplet 編輯器)。
- 工具列含 Share / Export YAML / Edit in Claude。
- Minimap + zoom 控制顯示可用。
- 「Export YAML」呼叫 cypher-executor 取得該 workflow 的原始 YAML。
### US-6Dogfooding
- 作為 arcrun 核心維護者,我要前端所有與服務打交道的動作都透過 arcrun 自家 APIcypher-executor)完成,不依賴第三方 OAuth / workflow / backend 服務。
- 驗收(見 §6)。
---
## 3. 非功能需求
| 項目 | 規範 |
|---|---|
| 技術棧 | Next.js 15 App Router、React 19、Tailwind v4、TypeScript(沿用 `landing/` 現有堆疊) |
| 部署 | Cloudflare Pages`@cloudflare/next-on-pages`),沿用 `landing/wrangler.toml` |
| 字型 | Inter、JetBrains Mono — 用 `next/font/google`,不拉 unpkg / fonts.googleapis.com `<link>` |
| 依賴 | 僅 Next / React / Tailwind;禁止 tRPC、React Query、SWR、Auth.js/Clerk、ReactFlow/XYFlow、Radix、shadcn CLI install、animation libraryframer-motion)等第三方 |
| 狀態管理 | React 內建(useState / useReducer / Context);持久化用 `localStorage` 或 server session cookie |
| 國際化 | 延後;本次一律英文(與設計稿一致)。現有 `?lang=zh` 不擴展 |
| 無障礙 | 按鈕 `aria-label`、Form 控件有 `<label>`;鍵盤可完成登入 / 複製 key 流程 |
| 效能 | Landing 首屏無阻塞 JSRSC);Dashboard / Workflow Viewer 可為 Client Component |
---
## 4. 視覺基準
| 項目 | 值(source of truth`design-source/index.html` 的 CSS 變數) |
|---|---|
| 主背景 | `#0F0F0F`(現有是 `#0a0a0a` — 本次改為 `#0F0F0F` |
| Card | `#1A1A1A` |
| Line | `#262626` / `#303030` |
| Primary | `#6366F1`indigo`#8B5CF6`violet)漸層 |
| Text | `#EDEDED` / dim `#A0A0A0` / mute `#6B6B6B` |
| 字型 | Inter 300/400/500/600/700/800Mono 400/500/600 |
---
## 5. 範圍界線
### 納入
- 5 screenLanding、Auth、Dashboard、API Keys、Workflow Viewer
- LogoSVG arc wordmark)、Icon setinline SVG,從 primitives.jsx port
- TopNav / Footer / Sidebar 三個 shell 元件
### 不納入(本次 SDD
- **Multi-tenant workspace 切換**(設計稿有 "Northwind" breadcrumb,本次純顯示用戶 email
- **Multi-API-key CRUD 後端**cypher-executor 現只支援每帳號一把 key,多 key table 先以「目前只支援一把」狀態呈現 — 見 §6.2)
- **Workflow 編輯**(只做 read-only viewer;編輯仍走 acr CLI / YAML
- **即時執行狀態 stream**minimap / zoom 僅 UI,不做真實 pan-zoom transform
- **i18n 中英切換**、**Hall of Fame**、**Donate**
- **Swagger UI 頁(/api-docs** — 保留現狀
---
## 6. API 依賴(全部打 `cypher.arcrun.dev`
### 6.1 既有(已實作於 cypher-executor
- `GET /me` — 取得登入用戶 + api_keydriverdashboard 側欄 avatar / API Keys 頁的單把 key
- `PUT /me/api-key/rotate``DELETE /me/api-key` — Rotate / Revoke
- `GET /auth/google/start``GET /auth/github/start` — OAuth 起點
- `POST /auth/logout`
- `POST /webhooks/named/{name}/trigger` — 觸發(給 Landing 文案展示)
### 6.2 需新增的 endpoint(阻擋項;**本 SDD 只定義契約,cypher-executor 實作歸屬於另一個 task**
| Method | Path | 說明 | 用途 |
|---|---|---|---|
| GET | `/workflows` | 列出當前 api_key 名下的 workflow(名稱、nodes、modified、run_count、status | Dashboard / Workflows Table |
| GET | `/workflows/:name` | 取得 workflow 詳細(含 graph 節點 + edges + YAML | Workflow Viewer |
| GET | `/workflows/:name/yaml` | 下載 raw YAML | Workflow Viewer Export |
| GET | `/apps` | 列出 app= workflow 標上 icon/tone/description metadata | Dashboard / Apps Grid |
| GET | `/api-keys` | 列出多把 key(若後端仍是單把,回傳單元素 array) | API Keys 頁 |
| POST | `/api-keys` | 建立新 key | API Keys 頁「Create new key」 |
| PATCH | `/api-keys/:id` | `{ active: boolean }` 切換 | API Keys 頁 toggle |
| DELETE | `/api-keys/:id` | 刪除 | API Keys 頁 trash |
**在後端尚未實作前**:前端用型別化 fetch wrapper 封裝呼叫;遇到 404 顯示 empty state(而非假資料)。本 SDD 明確禁止 hard-code mock fixture。
### 6.3 登入前後可用的公開資訊
- Integrations 清單(20 個 recipe):現有 `/integrations` 頁已有靜態清單,繼續複用。
---
## 7. Dogfooding 紅線
| 禁止 | 用哪個 arcrun 內部替代 |
|---|---|
| Auth0 / Clerk / Supabase Auth | cypher-executor `/auth/*` + session cookie(現行 `arcrun_session` |
| Segment / PostHog | 不加,或用後續 arcrun `analytics` 零件 |
| Vercel KV / Upstash | Cloudflare KV(經 cypher-executor |
| 直接寫第三方 APISlack / Notion 等)作 dashboard demo | 透過 arcrun workflow + trigger 模擬 |
| ReactFlow / XYFlow | 純 SVG 手刻(設計稿本來就是手刻) |
---
## 8. 驗收總清單
- [ ] 5 個 screen 全部在 `/`, `/auth`, `/dashboard`, `/keys`, `/workflows/[id]` 可達。
- [ ] 設計稿的 spacing / color / border-radius 100% 對得上(以 `design-source/index.html` CSS 變數為準)。
- [ ] middleware 保護 `/dashboard`, `/keys`, `/workflows/*`(未登入 → `/auth?redirect=...`)。
- [ ] 只打 `cypher.arcrun.dev`(可透過 `NEXT_PUBLIC_API_BASE` override),grep 結果不含其他外部 API host。
- [ ] `package.json` 新增依賴 = 0(本次不引入新 npm 套件)。
- [ ] 無 mock 資料:若後端未實作,顯示 loading 或 empty state,不編假陣列給 UI。
- [ ] `pnpm build` 通過,`next-on-pages` 輸出無 edge-runtime 錯誤。
@@ -0,0 +1,140 @@
# Frontend Redesign — Tasks
> 進度來源:本檔。完成一項立刻 `[x]`,不批次。
> 本 SDD 建立於 2026-04-23richblack 尚未下令動工,**所有 task 預設 `[ ]`**。
---
## Phase 0 — SDD 建立(本次)
- [x] 取得 Claude Design bundle,歸檔至 `design-source/`
- [x] 撰寫 `requirements.md`
- [x] 撰寫 `design.md`
- [x] 撰寫 `tasks.md`(本檔)
- [ ] richblack review + 認可 → 開 Phase 1
**等 richblack 明確說「開始動工」之前,不觸 `landing/` 任何檔案。**
---
## Phase 1 — Tokens + Shell(無外部 API 依賴)
- [ ] 1.1 建 `landing/app/design-tokens.css`,抄 design-source CSS :root 變數
- [ ] 1.2 更新 `landing/app/globals.css``@import './design-tokens.css'``@theme inline` 對應 token → Tailwind color
- [ ] 1.3 `layout.tsx` 引入 `next/font/google` 的 Inter + JetBrains Monobody bg 改 `var(--bg)`
- [ ] 1.4 建 `landing/components/shell/Icon.tsx`(港設計稿 primitives.jsx 的所有 icon
- [ ] 1.5 建 `landing/components/shell/Logo.tsx`SVG arc wordmark
- [ ] 1.6 建 `landing/components/shell/TopNav.tsx`
- [ ] 1.7 建 `landing/components/shell/Footer.tsx`
- [ ] 1.8 建 `landing/components/shell/Sidebar.tsx`(含頭像、項目清單、登出按鈕)
- [ ] 1.9 建 `landing/components/primitives/Button.tsx`variants: primary / secondary / ghost / danger-ghost / sm / lg
- [ ] 1.10 建 `landing/components/primitives/Pill.tsx``Toggle.tsx`
- [ ] 1.11 Lint + build pass
---
## Phase 2 — Landing`/`
- [ ] 2.1 Rewrite `app/page.tsx`,結構照 design-source/screens/Landing.jsx
- [ ] 2.2 Heroheading / eyebrow / CTA / radial-grid bgCSS only
- [ ] 2.3 Paths 左卡:install tabs 元件(Client Component+ terminal code blocks(字串改 `acr` 實際指令)
- [ ] 2.4 Paths 右卡:chat preview(靜態)
- [ ] 2.5 Feature strip4 cell
- [ ] 2.6 TopNav / Footer 接上
- [ ] 2.7 Responsive(≤ 768px: paths 單欄、hero h1 縮 1 級)
- [ ] 2.8 視覺比對 design-source/index.html(截圖對比 / DOM spec 檢查)
---
## Phase 3 — Auth`/auth``/login` redirect
- [ ] 3.1 建 `app/auth/page.tsx`Client
- [ ] 3.2 Sign in / Sign up tabs + OAuth buttonsGoogle / GitHub)接既有 `/auth/google/start` / `/auth/github/start`
- [ ] 3.3 Email / Password form — submit 顯示「OAuth only」提示(待後端支援)
- [ ] 3.4 `/login` 頁改為 `redirect('/auth')`
- [ ] 3.5 視覺比對 design-source/screens/Auth.jsx
---
## Phase 4 — API Keys`/keys`
- [ ] 4.1 建 `lib/api.ts` fetch wrapper
- [ ] 4.2 建 `lib/apiKeys.ts`listKeys / createKey / patchKey / deleteKey)— 後端未實作時回 `[{ ...from /me }]` 的 fallback
- [ ] 4.3 建 `app/keys/page.tsx`:頂部 new-key-boxsessionStorage flag)、全表格
- [ ] 4.4 「Create new key」呼叫 `POST /api-keys`(後端未實作 → 顯示「coming soon」toast
- [ ] 4.5 Toggle active / trash 接 PATCH / DELETE
- [ ] 4.6 middleware.ts matcher 加 `/keys/:path*`
- [ ] 4.7 原 `/dashboard` 頁的 Key 卡區塊移除,放提示「API Keys 已搬到 /keys」
---
## Phase 5 — Dashboard`/dashboard`
- [ ] 5.1 建 `lib/workflows.ts``lib/apps.ts`
- [ ] 5.2 Rewrite `app/dashboard/page.tsx`(保留 /me session 檢查)
- [ ] 5.3 Main headbreadcrumb / greeting / summary counters
- [ ] 5.4 Apps Grid + empty-state 卡(`/apps` 404 → 只顯示 empty-state
- [ ] 5.5 Workflows Table`/workflows` 404 → 顯示「no workflows — acr push」CTA
- [ ] 5.6 Sidebar 接真實 useremail / display_name)、登出按鈕接 `/auth/logout`
- [ ] 5.7 視覺比對 design-source/screens/Dashboard.jsx
---
## Phase 6 — Workflow Viewer`/workflows/[name]`
- [ ] 6.1 建 `app/workflows/[name]/page.tsx`Client
- [ ] 6.2 Topbarback / logo / breadcrumb / title / saved indicator / share / export / edit-in-claude
- [ ] 6.3 `<Canvas>`SVG defs + 節點定位 + bezier edges;資料從 `GET /workflows/:name`
- [ ] 6.4 Auto-layouttopological depth → columns,同 depth 平均分配 y
- [ ] 6.5 NodeCard 點擊 → DetailPanel
- [ ] 6.6 DetailPanelinput / output schema、tripletdisabled)、last run stats(可選)
- [ ] 6.7 Export YAML`GET /workflows/:name/yaml` → blob download
- [ ] 6.8 Minimap(純顯示)、ZoomControls(簡單 scale
- [ ] 6.9 middleware.ts matcher 加 `/workflows/:path*`
---
## Phase 7 — 清理 + 收尾
- [ ] 7.1 刪除舊 `/dashboard` 不再用的 coderotate/revoke 若全搬到 /keys
- [ ] 7.2 Grep 檢查:除 `cypher.arcrun.dev` 外無任何第三方 API host
- [ ] 7.3 Grep 檢查:無 `mock` / `fixture` / 硬編的假資料陣列(`app-empty` 的字串常數除外)
- [ ] 7.4 Grep 檢查:無新增 npm 依賴(`git diff landing/package.json` 應只改版本,不加條目)
- [ ] 7.5 `pnpm build` 通過;`next-on-pages` 通過
- [ ] 7.6 local `pnpm dev` 手工巡覽 5 screen,每個截圖比對 design-source
- [ ] 7.7 更新 `.agents/specs/arcrun/arcrun.md`,加一段「CLI 1.2.0 搭配新 landing」之類的進度註記
- [ ] 7.8 richblack 認可 → 合併 / deploy
---
## 需要 cypher-executor 搭配的 endpoint(不屬於本 SDD
若 richblack 決定新 endpoint 要和前端同 PR 做:
- [ ] 後端:`GET /workflows`
- [ ] 後端:`GET /workflows/:name` + `/yaml`
- [ ] 後端:`GET /apps`
- [ ] 後端:`GET /api-keys``POST /api-keys``PATCH /api-keys/:id``DELETE /api-keys/:id`
否則:以 empty state 呈現,封測也能運作。
---
## KBDB 整合(配合 matrix/kbdb/.agents/specs/arcrun-key-auth/
Arcrun 用戶的 `ak_xxx` Key 同時可用於 KBDB(捆綁服務,自動開通)。
cypher-executor 需在以下時機呼叫 KBDB
- [ ] 後端:OAuth callback 成功 → `POST /partners`(建立 KBDB partner 記錄)
- [ ] 後端:`PUT /me/api-key/rotate` → 舊 partner revoke + 建新 partner 記錄
- [ ] 後端:`DELETE /me/api-key` → KBDB partner revoke
詳細設計見 `matrix/kbdb/.agents/specs/arcrun-key-auth/design.md`
---
## 目前狀態
- **進度**Phase 0 已完成(4/5;最後一項等 richblack 認可)。
- **阻擋**richblack 認可 + 「開始動工」指令。
- **未啟動**Phase 1-7 全部 `[ ]`
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "arcrun-auth-oauth2",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
@@ -0,0 +1,86 @@
/**
* arcrun auth_oauth2 Worker
*
* POST / → JSON input {action, api_key, service} → WASM (WASI preview1 stdin/stdout) → JSON output
*
* 額外 host functions(相較 auth_static_key):
* - kv_put:快取 access_token(短效,TTL 跟隨 expires_in
* - http_requestPOST token endpoint 換 access_token
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
createWasiShim,
createArcrunHostFunctions,
type ArcrunHostEnv,
} from '../../../cypher-executor/src/lib/wasi-shim';
type Env = ArcrunHostEnv;
const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'auth_oauth2' }));
app.post('/', async (c) => {
let input: Record<string, unknown>;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
const apiKey = typeof input.api_key === 'string' ? input.api_key : '';
if (!apiKey) {
return c.json({ success: false, error: 'api_key 必填' }, 400);
}
try {
const result = await runWasm(c.env, apiKey, input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
// ── WASM runner ──────────────────────────────────────────────────────────────
async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknown> {
const stdinData = JSON.stringify(input);
const hostFunctions = createArcrunHostFunctions(env, apiKey);
// 加入 http_requesttoken endpoint 用)
hostFunctions.http_request = async (url, method, headersJSON, body) => {
const headers: Record<string, string> = {};
try {
Object.assign(headers, JSON.parse(headersJSON || '{}'));
} catch { /* ignore */ }
const res = await fetch(url, {
method,
headers,
body: body || undefined,
});
return res.text();
};
const shim = createWasiShim(stdinData, hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,21 @@
name = "arcrun-auth-oauth2"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "auth_oauth2"
[[routes]]
pattern = "auth-oauth2.arcrun.dev/*"
zone_name = "arcrun.dev"
[[kv_namespaces]]
binding = "CREDENTIALS_KV"
id = "e7f4320f88d343f187e35e3543dd74c9"
[[kv_namespaces]]
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret put 設定
@@ -97,17 +97,7 @@ async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknow
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
+1 -11
View File
@@ -65,17 +65,7 @@ async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknow
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "arcrun-claude-api",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* arcrun WASM 零件 Worker (claude_api v2)
* POST / → JSON input → WASM (WASI preview1) → JSON output
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6
*
* v2 改打 Mira daemon (Hetzner) 而非直打 Anthropic Messages API
* 理由:OAuth token 在 Messages API 限制 system prompt → rate_limit
* Mira daemon 用 Claude Agent SDK,已內建 Mira persona
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
type Env = {
MIRA_TOKEN?: string; // Mira daemon Bearer token
MIRA_URL?: string; // 預設 https://mira.uncle6.me
COMPONENT_ID: string;
};
const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'claude_api', version: 'v2-mira-daemon' }));
app.post('/', async (c) => {
let input: Record<string, unknown>;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
// 用戶沒帶 token → 用 Worker secret fallback
if (!input.mira_token && c.env.MIRA_TOKEN) {
input.mira_token = c.env.MIRA_TOKEN;
}
if (!input.mira_url && c.env.MIRA_URL) {
input.mira_url = c.env.MIRA_URL;
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
return await res.text();
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
if (stderr) console.error('[claude_api wasm stderr]', stderr);
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,11 @@
name = "arcrun-claude-api"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "claude_api"
[[routes]]
pattern = "claude-api.arcrun.dev/*"
zone_name = "arcrun.dev"
+1 -11
View File
@@ -49,17 +49,7 @@ async function runWasm(input: unknown): Promise<unknown> {
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+1 -11
View File
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
+1 -11
View File
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
+6 -19
View File
@@ -3,12 +3,8 @@
* *
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output * POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
* *
* 方案 A:import cypher-executor/src/lib/wasi-shim.ts 的 `createWasiShim` * host function: http_requestfetch → 回 response body 原文)
* 提供單一 `http_request` host function(fetch → 回 response body 原文) * 此 Worker 不做 credential 注入,只提供通用 HTTP 能力
* 這個 Worker 不需要 crypto / kv_get(無 credential 解密職責)。
*
* Credential 注入路徑:cypher-executor auth-dispatcher 會先 POST 到對應 auth primitive Worker,
* 把 auth headers 合進本 Worker 的 input(例:`_auth_headers`)。
*/ */
import componentWasm from '../component.wasm' assert { type: 'webassembly' }; import componentWasm from '../component.wasm' assert { type: 'webassembly' };
@@ -57,7 +53,7 @@ async function runWasm(input: unknown): Promise<unknown> {
} }
} }
} catch { } catch {
// 忽略 header parse 錯誤 // ignore header parse errors
} }
} }
const init: RequestInit = { method, headers }; const init: RequestInit = { method, headers };
@@ -65,7 +61,6 @@ async function runWasm(input: unknown): Promise<unknown> {
init.body = body; init.body = body;
} }
const res = await fetch(url, init); const res = await fetch(url, init);
// WASM 端直接 json.Unmarshal 回傳內容,這裡回 response body 原文
return await res.text(); return await res.text();
}, },
}; };
@@ -77,19 +72,11 @@ async function runWasm(input: unknown): Promise<unknown> {
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
if (stderr) console.error('[http_request wasm stderr]', stderr);
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout); return JSON.parse(stdout);
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "arcrun-kbdb-create-block",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
@@ -0,0 +1,74 @@
/**
* arcrun WASM 零件 Worker (kbdb_create_block)
* POST / → JSON input → WASM (WASI preview1) → JSON output
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_create_block' }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
return await res.text();
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
if (stderr) console.error('[kbdb_create_block wasm stderr]', stderr);
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,11 @@
name = "arcrun-kbdb-create-block"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "kbdb_create_block"
[[routes]]
pattern = "kbdb-create-block.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "arcrun-kbdb-ingest",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
@@ -0,0 +1,82 @@
/**
* arcrun WASM 零件 Worker (kbdb_ingest)
*
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
*
* host function: http_request(用於呼叫 KBDB API
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_ingest' }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
// ── WASM runner ──────────────────────────────────────────────────────────────
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {
// ignore header parse errors
}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
return await res.text();
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
if (stderr) console.error('[kbdb_ingest wasm stderr]', stderr);
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,11 @@
name = "arcrun-kbdb-ingest"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "kbdb_ingest"
[[routes]]
pattern = "kbdb-ingest.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "arcrun-kbdb-patch-block",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
@@ -0,0 +1,80 @@
/**
* arcrun WASM 零件 Worker (kbdb_patch_block)
*
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
*
* host function: http_request(用於呼叫 KBDB PATCH /blocks/:id
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_patch_block' }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {
// ignore header parse errors
}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
return await res.text();
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
if (stderr) console.error('[kbdb_patch_block wasm stderr]', stderr);
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,11 @@
name = "arcrun-kbdb-patch-block"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "kbdb_patch_block"
[[routes]]
pattern = "kbdb-patch-block.arcrun.dev/*"
zone_name = "arcrun.dev"
+14
View File
@@ -0,0 +1,14 @@
{
"name": "arcrun-km-writer",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.14
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260420.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.83.0(@cloudflare/workers-types@4.20260420.1)
packages:
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
'@cloudflare/unenv-preset@2.16.0':
resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260415.1':
resolution: {integrity: sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
resolution: {integrity: sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260415.1':
resolution: {integrity: sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260415.1':
resolution: {integrity: sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260415.1':
resolution: {integrity: sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260420.1':
resolution: {integrity: sha512-DHT9JnSn9cIiCSdL76OxW+Xvc1+ml1CWzWvgVwreoHQ+E604aeFxPPHp9X7nE+XRWm2NH4l0OgtxUI5T/nuI3g==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.14:
resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260415.0:
resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==}
engines: {node: '>=18.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260415.1:
resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.83.0:
resolution: {integrity: sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==}
engines: {node: '>=20.3.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260415.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260415.1
'@cloudflare/workerd-darwin-64@1.20260415.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260415.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260415.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260415.1':
optional: true
'@cloudflare/workers-types@4.20260420.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.14: {}
kleur@4.1.5: {}
miniflare@4.20260415.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260415.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.7.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260415.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260415.1
'@cloudflare/workerd-darwin-arm64': 1.20260415.1
'@cloudflare/workerd-linux-64': 1.20260415.1
'@cloudflare/workerd-linux-arm64': 1.20260415.1
'@cloudflare/workerd-windows-64': 1.20260415.1
wrangler@4.83.0(@cloudflare/workers-types@4.20260420.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.2
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260415.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260415.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260420.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
+77
View File
@@ -0,0 +1,77 @@
/**
* arcrun API component Worker (km_writer)
*
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
*
* 提供 http_request host function,讓 WASM 零件呼叫 Mira /km/* API。
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'km_writer' }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {
// ignore header parse errors
}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
return await res.text();
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+11
View File
@@ -0,0 +1,11 @@
name = "arcrun-km-writer"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "km_writer"
[[routes]]
pattern = "km-writer.arcrun.dev/*"
zone_name = "arcrun.dev"
+1 -11
View File
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
@@ -0,0 +1,14 @@
{
"name": "arcrun-platform-crypto",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.17
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260505.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.87.0(@cloudflare/workers-types@4.20260505.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260430.1':
resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260430.1':
resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260430.1':
resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260430.1':
resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260505.1':
resolution: {integrity: sha512-Uz9D2hcwB4/pdnmCU7RsgknY8TQ5st0cQMMN6h/hvWt1TCt99GUkbi6dMgWdP7jXfIfh+S/EI5zQugI9RZn4Bw==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.17:
resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260430.0:
resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==}
engines: {node: '>=22.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260430.1:
resolution: {integrity: sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.87.0:
resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260430.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260430.1
'@cloudflare/workerd-darwin-64@1.20260430.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260430.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260430.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260430.1':
optional: true
'@cloudflare/workers-types@4.20260505.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.17: {}
kleur@4.1.5: {}
miniflare@4.20260430.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260430.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.7.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260430.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260430.1
'@cloudflare/workerd-darwin-arm64': 1.20260430.1
'@cloudflare/workerd-linux-64': 1.20260430.1
'@cloudflare/workerd-linux-arm64': 1.20260430.1
'@cloudflare/workerd-windows-64': 1.20260430.1
wrangler@4.87.0(@cloudflare/workers-types@4.20260505.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260430.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260430.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260505.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
@@ -0,0 +1,108 @@
/**
* arcrun platform_crypto Worker
*
* POST / → JSON input {action, ...} → JSON output
*
* Actions:
* generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
* encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}base64
* random_token — crypto random bytes → hex string
*
* 安全邊界:ENCRYPTION_KEY 只存在於 closure,永不進入外部(rule 02 §2.2)。
* 此 Worker 直接用 crypto.subtle 實作,不走 WASM runner。
* TinyGo WASM async host function 在 Cloudflare Workers 的 u6u namespace 不支援 Promise.
* WASM 零件 (registry/components/platform_crypto/) 保留作為 edge-Go 移植時的參考。
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Env = {
ENCRYPTION_KEY: string;
};
type Input = {
action: string;
email?: string;
plaintext?: string;
bytes?: number;
};
const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'platform_crypto' }));
app.post('/', async (c) => {
let input: Input;
try {
input = await c.req.json() as Input;
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return c.json({ success: false, error: 'ENCRYPTION_KEY not configured' }, 503);
}
try {
switch (input.action) {
case 'generate_api_key': {
if (!input.email) return c.json({ success: false, error: 'email 必填' }, 400);
const apiKey = await generateApiKey(input.email, encryptionKey);
return c.json({ success: true, api_key: apiKey });
}
case 'encrypt': {
if (!input.plaintext) return c.json({ success: false, error: 'plaintext 必填' }, 400);
const { encrypted, iv } = await aesEncrypt(input.plaintext, encryptionKey);
return c.json({ success: true, encrypted, iv });
}
case 'random_token': {
const numBytes = (input.bytes ?? 32) > 0 ? (input.bytes ?? 32) : 32;
const token = randomHex(numBytes);
return c.json({ success: true, token });
}
default:
return c.json({ success: false, error: `不支援的 action: ${input.action}` }, 400);
}
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
// ── Crypto implementations (rule 02 §2.2: crypto.subtle 只准在 wasi-shim.ts 或 platform_crypto) ──
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(email));
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
new TextEncoder().encode(plaintext),
);
const toB64 = (buf: ArrayBuffer | Uint8Array) =>
btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
function randomHex(numBytes: number): string {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,14 @@
name = "arcrun-platform-crypto"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "platform_crypto"
[[routes]]
pattern = "platform-crypto.arcrun.dev/*"
zone_name = "arcrun.dev"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+1 -11
View File
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
shim.imports, shim.imports,
); );
shim.setMemory(instance.exports.memory as WebAssembly.Memory); shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim(); const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output'); if (!stdout) throw new Error('WASM component produced no output');
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+7 -4
View File
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
const instance = await WebAssembly.instantiate(wasmModule, wasi); const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory; memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void; const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); ((fn: () => void) => () => Promise<void>) | undefined;
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
try { start(); } catch (e) { if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
try {
if (promising) { await promising(startFn)(); } else { startFn(); }
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
} }
+12
View File
@@ -249,6 +249,18 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm exec wrangler deploy run: pnpm exec wrangler deploy
# 部署成功後自動把 contract 灌進 component-registry index
# SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 2
# degraded mode:失敗只 warning 不擋部署
# 邏輯實作在 registry/scripts/register-component.sh(本地+CI 共用 SSOT
- name: Register component in registry
if: success()
run: |
set -uo pipefail
python3 -c "import yaml" 2>/dev/null || pip install --quiet pyyaml
bash registry/scripts/register-component.sh "${{ matrix.worker.name }}" || \
echo "::warning::Registry 註冊失敗(degraded mode"
# ── Job 2b:Tier 2 並行部署(orchestration Worker,可能有 service binding 相依於 tier1) ─ # ── Job 2b:Tier 2 並行部署(orchestration Worker,可能有 service binding 相依於 tier1) ─
# needs: deploy-tier1 → tier1 全綠才開始;首次部署時避免 service binding target 未存在。 # needs: deploy-tier1 → tier1 全綠才開始;首次部署時避免 service binding target 未存在。
deploy-tier2: deploy-tier2:
@@ -9,8 +9,8 @@
* - auth primitive WASM ( host function `crypto_decrypt` ) * - auth primitive WASM ( host function `crypto_decrypt` )
* - recipe primitive Worker+HTTP fetch * - recipe primitive Worker+HTTP fetch
* *
* (Phase 2) `auth_static_key` + `auth_service_account`, * `auth_static_key` + `auth_service_account` + `auth_oauth2`,
* Phase 4() `auth_oauth2` / `auth_mtls` * Phase 4 `auth_mtls`mTLS handshake Worker runtime
* *
* 執行時機:graph-executor runner , ctx : * 執行時機:graph-executor runner , ctx :
* 1. dispatcher( return enriched ctx) * 1. dispatcher( return enriched ctx)
@@ -22,7 +22,7 @@ import { resolveAuthRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader'; import { wasmWorkerUrl } from '../lib/component-loader';
/** 對應 Phase 1-4 會部署的 auth primitive Worker */ /** 對應 Phase 1-4 會部署的 auth primitive Worker */
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account']); const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
/** auth primitive 本身的 componentId(避免自引用) */ /** auth primitive 本身的 componentId(避免自引用) */
const AUTH_PRIMITIVE_IDS = new Set([ const AUTH_PRIMITIVE_IDS = new Set([
+81
View File
@@ -0,0 +1,81 @@
// KBDB Partner 同步工具
// Arcrun 用戶登入/rotate/revoke 時,同步更新 KBDB partner 記錄
// 讓 ak_xxx Key 可以直接存取 KBDB(不需要第二把 Key)
type KbdbEnv = {
KBDB_INTERNAL_TOKEN?: string;
KBDB_BASE_URL?: string;
};
function kbdbBase(env: KbdbEnv): string {
return (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
}
async function sha256Hex(input: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* KBDB Arcrun partner
* log Arcrun
*/
export async function ensureKbdbPartner(env: KbdbEnv, email: string, apiKey: string): Promise<void> {
const token = env.KBDB_INTERNAL_TOKEN;
if (!token) {
console.warn('[kbdb-partner] KBDB_INTERNAL_TOKEN not set, skipping');
return;
}
try {
const apiKeyHash = await sha256Hex(apiKey);
const base = kbdbBase(env);
const res = await fetch(`${base}/admin/partners/by-key-hash`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: `arcrun:${email}`,
org_namespace: `arcrun:${email}`,
api_key_hash: apiKeyHash,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(`[kbdb-partner] ensureKbdbPartner failed: ${res.status} ${body}`);
}
} catch (err) {
console.error('[kbdb-partner] ensureKbdbPartner error:', err);
}
}
/**
* KBDB partner 使 api_key_hash
* log
*/
export async function revokeKbdbPartner(env: KbdbEnv, oldApiKey: string): Promise<void> {
const token = env.KBDB_INTERNAL_TOKEN;
if (!token) return;
try {
const oldHash = await sha256Hex(oldApiKey);
const partnerId = `partner-arcrun-${oldHash.slice(0, 16)}`;
const base = kbdbBase(env);
const res = await fetch(`${base}/admin/partners/${partnerId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '');
console.error(`[kbdb-partner] revokeKbdbPartner failed: ${res.status} ${body}`);
}
} catch (err) {
console.error('[kbdb-partner] revokeKbdbPartner error:', err);
}
}
+336 -9
View File
@@ -35,6 +35,12 @@ export interface WasiShim {
getStderr(): string; getStderr(): string;
/** 注入 WebAssembly.Memoryinstantiate 後呼叫) */ /** 注入 WebAssembly.Memoryinstantiate 後呼叫) */
setMemory(memory: WebAssembly.Memory): void; setMemory(memory: WebAssembly.Memory): void;
/**
* WASM _start使 WebAssembly.promisingJSPI async host
* function suspend/resume JSPI fallback
* setMemory()
*/
run(instance: WebAssembly.Instance): Promise<void>;
} }
/** /**
@@ -50,10 +56,18 @@ export interface WasiHostFunctions {
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>; http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */ /** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
kv_get?: (key: string) => Promise<string | null>; kv_get?: (key: string) => Promise<string | null>;
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */ /** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>; crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */ /** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>; crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
/** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */
crypto_hmac_sha256?: (data: Uint8Array) => Promise<Uint8Array>;
/** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */
crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>;
/** crypto random bytes → hex string */
crypto_random_bytes?: (numBytes: number) => string;
} }
/** /**
@@ -157,6 +171,107 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
return WASI_ESUCCESS; return WASI_ESUCCESS;
} }
// ── Asyncify protocol ──────────────────────────────────────────────────────
// TinyGo WASI target 永遠使用 asyncify scheduler。Asyncify 讓 WASM 能在呼叫 host
// function 時「unwind」(保存 call stack),待 async 工作完成後再「rewind」(恢復)。
//
// 協議流程(每次 async host function 呼叫):
// 1. WASM 呼叫 host import(例如 http_request
// 2. Host 檢查 asyncify_get_state()
// - state=1Unwinding: 正在展開,host 應直接回傳 0(佔位值)
// - state=2Rewinding: 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult
// - state=0Normal: 正常執行,host 啟動 async 工作並呼叫 asyncify_start_unwind
// 3. WASM 的 _start 控制流回到 run()asyncify 讓 _start 提前返回)
// 4. run() await async 工作,呼叫 asyncify_start_rewind,再次呼叫 _start
// 5. WASM 從 host import 返回點繼續執行,host 回傳儲存的結果
//
// 注意:每次 _start 呼叫只能處理一個 async 中斷點。若 WASM 有多個連續的 async host call
// run() 會在 while 迴圈裡重複 rewind 直到 asyncify_get_state() == 0Normal)。
// Asyncify 資料緩衝區設定(TinyGo asyncify 用於保存 call stack
// 位址在 run() 中設定(WASM memory 末尾分配 1MB
let asyncifyDataPtr = 0;
const ASYNCIFY_BUF_SIZE = 1024 * 1024; // 1MB stack buffer
// 儲存 async host function 的結果和 Promise
let asyncifyPendingPromise: Promise<number> | null = null;
let asyncifyResult: number = 0;
// asyncify exportsrun() 設定後才可用)
let asyncifyExports: {
get_state: () => number;
start_unwind: (ptr: number) => void;
stop_unwind: () => void;
start_rewind: (ptr: number) => void;
stop_rewind: () => void;
} | null = null;
// JSPI helper:若環境支援 WebAssembly.Suspending,用它包裝 async import function
// 用於 scheduler=none 編譯的 WASM(無 asyncify exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function jspiSuspending<T extends (...args: any[]) => Promise<unknown>>(fn: T): T {
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
(new (fn: T) => T) | undefined;
return SuspendingCtor ? new SuspendingCtor(fn) : fn;
}
// 建立一個 asyncify-aware 的 host function wrapper
// 協議:Normal 時啟動 async 工作並呼叫 start_unwindRewinding 時回傳已存的結果
// 用於 scheduler=asyncify 編譯的 WASM(有 asyncify exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function asyncifyWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (...args: any[]): number => {
if (!memory) return 1;
const ax = asyncifyExports;
if (!ax) return 0; // asyncify 尚未初始化(sync fallback
const state = ax.get_state();
if (state === 2) {
// Rewinding:回傳上次 async 的真實結果
return asyncifyResult;
}
if (state === 1) {
// Unwinding 中:直接回傳 0WASM 在 unwind,不使用此值)
return 0;
}
// Normalstate=0):啟動 async 工作,觸發 asyncify unwind
asyncifyPendingPromise = fn(...args);
// asyncify_start_unwind 設定 WASM 內部 unwind flag
// host function 返回後 WASM 開始保存 call stack,最終 _start() 返回
ax.start_unwind(asyncifyDataPtr);
return 0; // WASM 忽略此值(正在 unwind
};
}
// 根據 WASM 是否有 asyncify exports 決定使用哪種包裝方式
// JSPI mode: scheduler=none WASM + WebAssembly.Suspending
// asyncify mode: scheduler=asyncify WASM + asyncify protocol
// 初始化時先用 asyncifyWraprun() 後若沒有 asyncify exports 就切換到 jspiSuspending
// 但因為 imports 在 instantiate 前就需要確定,這裡統一先用 asyncifyWrap
// run() 時若發現沒有 asyncify exports 且有 JSPI,則使用 JSPI 模式
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hostWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number | Promise<number> {
// 嘗試使用 JSPI Suspending(若環境支援)
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(new (fn: any) => any) | undefined;
if (SuspendingCtor) {
// JSPI 可用:包裝為 Suspending,讓 WASM 能 suspend 等待 async 結果
// 這適用於 scheduler=none 的 WASM(無 asyncify 干擾)
return new SuspendingCtor(fn);
}
// fallbackasyncify 協議(scheduler=asyncify WASM
return asyncifyWrap(fn);
}
const shim: WasiShim = { const shim: WasiShim = {
imports: { imports: {
wasi_snapshot_preview1: { fd_write, wasi_snapshot_preview1: { fd_write,
@@ -215,9 +330,10 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
}, },
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務 // u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
// .wasm 零件用 //go:wasmimport u6u <name> 宣告 // .wasm 零件用 //go:wasmimport u6u <name> 宣告
// 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議
u6u: { u6u: {
http_request: hostFunctions?.http_request http_request: hostFunctions?.http_request
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number, ? hostWrap(async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number, headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
outPtr: number, outLenPtr: number): Promise<number> => { outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1; if (!memory) return 1;
@@ -235,28 +351,50 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
} catch { } catch {
return 1; return 1;
} }
} })
: () => 1, : () => 1,
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key // kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
kv_get: hostFunctions?.kv_get kv_get: hostFunctions?.kv_get
? async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => { ? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1; if (!memory) { console.error('[kv_get] memory null'); return 1; }
const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen)); const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
console.error(`[kv_get] key="${key}" keyPtr=${keyPtr} keyLen=${keyLen} outPtr=${outPtr} outLenPtr=${outLenPtr}`);
try { try {
const result = await hostFunctions!.kv_get!(key); const result = await hostFunctions!.kv_get!(key);
console.error(`[kv_get] result=${result === null ? 'null' : result.slice(0, 80)}`);
if (result === null) return 2; if (result === null) return 2;
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result)); const encoded = new TextEncoder().encode(result);
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
console.error(`[kv_get] writeOut status=${status} encodedLen=${encoded.length} memBufLen=${memory.buffer.byteLength}`);
return status;
} catch (e) {
console.error(`[kv_get] error: ${e}`);
return 1;
}
})
: () => 1,
// kv_put(keyPtr, keyLen, valPtr, valLen, ttlSeconds) → 0 成功;1 錯誤
kv_put: hostFunctions?.kv_put
? hostWrap(async (keyPtr: number, keyLen: number, valPtr: number, valLen: number, ttlSeconds: number): Promise<number> => {
if (!memory) return 1;
const dec = new TextDecoder();
const key = dec.decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
const value = dec.decode(new Uint8Array(memory.buffer, valPtr, valLen));
try {
await hostFunctions!.kv_put!(key, value, ttlSeconds);
return 0;
} catch { } catch {
return 1; return 1;
} }
} })
: () => 1, : () => 1,
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功 // crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼) // 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
crypto_decrypt: hostFunctions?.crypto_decrypt crypto_decrypt: hostFunctions?.crypto_decrypt
? async (encPtr: number, encLen: number, ivPtr: number, ivLen: number, ? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
outPtr: number, outLenPtr: number): Promise<number> => { outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1; if (!memory) return 1;
const dec = new TextDecoder(); const dec = new TextDecoder();
@@ -268,12 +406,12 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
} catch { } catch {
return 1; return 1;
} }
} })
: () => 1, : () => 1,
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功 // crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256 crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
? async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number, ? hostWrap(async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
outPtr: number, outLenPtr: number): Promise<number> => { outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1; if (!memory) return 1;
// await 前複製 typed array(避免 memory grow 後 buffer 失效) // await 前複製 typed array(避免 memory grow 後 buffer 失效)
@@ -285,6 +423,53 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
} catch { } catch {
return 1; return 1;
} }
})
: () => 1,
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes
crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256
? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
try {
const sig = await hostFunctions!.crypto_hmac_sha256!(data);
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
} catch {
return 1;
}
})
: () => 1,
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt
? hostWrap(async (plaintextPtr: number, plaintextLen: number,
outEncPtr: number, outEncLenPtr: number,
outIvPtr: number, outIvLenPtr: number): Promise<number> => {
if (!memory) return 1;
const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen));
try {
const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext);
const encBytes = new TextEncoder().encode(encryptedB64);
const ivBytes = new TextEncoder().encode(ivB64);
const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes);
const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes);
return s1 !== 0 ? s1 : s2;
} catch {
return 1;
}
})
: () => 1,
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string
crypto_random_bytes: hostFunctions?.crypto_random_bytes
? (numBytes: number, outPtr: number, outLenPtr: number): number => {
if (!memory) return 1;
try {
const hexStr = hostFunctions!.crypto_random_bytes!(numBytes);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr));
} catch {
return 1;
}
} }
: () => 1, : () => 1,
}, },
@@ -294,6 +479,102 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
memory = mem; memory = mem;
}, },
async run(instance: WebAssembly.Instance): Promise<void> {
const exp = instance.exports as Record<string, unknown>;
const startFn = (exp._start ?? exp.main) as (() => void) | undefined;
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promisingFn = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
((fn: () => void) => () => Promise<void>) | undefined;
// 若環境支援 JSPICloudflare Workers 2025+),優先使用 WebAssembly.promising
// hostWrap() 已將 imports 包裝為 WebAssembly.Suspending,不需要 asyncify 協議
if (promisingFn) {
try {
await promisingFn(startFn)();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
return;
}
// JSPI 不可用:使用 asyncify 協議(需要 WASM 有 asyncify exports
const asyncifyGetState = exp.asyncify_get_state as (() => number) | undefined;
const asyncifyStartUnwind = exp.asyncify_start_unwind as ((ptr: number) => void) | undefined;
const asyncifyStopUnwind = exp.asyncify_stop_unwind as (() => void) | undefined;
const asyncifyStartRewind = exp.asyncify_start_rewind as ((ptr: number) => void) | undefined;
const asyncifyStopRewind = exp.asyncify_stop_rewind as (() => void) | undefined;
if (asyncifyGetState && asyncifyStartUnwind && asyncifyStopUnwind &&
asyncifyStartRewind && asyncifyStopRewind) {
asyncifyExports = {
get_state: asyncifyGetState,
start_unwind: asyncifyStartUnwind,
stop_unwind: asyncifyStopUnwind,
start_rewind: asyncifyStartRewind,
stop_rewind: asyncifyStopRewind,
};
const mallocFn = exp.malloc as ((size: number) => number) | undefined;
if (mallocFn && memory) {
const totalSize = ASYNCIFY_BUF_SIZE;
asyncifyDataPtr = mallocFn(totalSize);
const view = new DataView(memory.buffer);
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + totalSize, true);
} else if (memory) {
const memBytes = memory.buffer.byteLength;
asyncifyDataPtr = memBytes - ASYNCIFY_BUF_SIZE;
if (asyncifyDataPtr > 8) {
const view = new DataView(memory.buffer);
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + ASYNCIFY_BUF_SIZE, true);
}
}
}
// JSPI 不可用且無 asyncify exports:同步執行(host function 不能 async
if (!asyncifyExports) {
try { startFn(); } catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
return;
}
// 主執行迴圈:每次呼叫 _start,若 asyncify 捕捉到 pending promise 就 await 再 rewind
let rewinding = false;
while (true) {
asyncifyPendingPromise = null;
try {
if (rewinding) {
asyncifyExports.start_rewind(asyncifyDataPtr);
startFn();
asyncifyExports.stop_rewind();
} else {
startFn();
}
} catch (e) {
if (e instanceof Error && e.message === 'wasm exit: 0') break;
throw e;
}
// 若 asyncifyWrap 觸發了 unwind_start 會因 unwind 返回(沒有 exit
// asyncifyWrap 已呼叫 start_unwind,這裡只需 stop_unwind 並 await promise
if (asyncifyPendingPromise !== null) {
asyncifyExports.stop_unwind();
asyncifyResult = await asyncifyPendingPromise;
asyncifyPendingPromise = null;
rewinding = true;
continue;
}
// 沒有 pending promise 且沒有 exit → 正常完成
break;
}
},
getStdout(): string { getStdout(): string {
if (stdoutChunks.length === 0) return ''; if (stdoutChunks.length === 0) return '';
const total = stdoutChunks.reduce((n, c) => n + c.length, 0); const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
@@ -366,6 +647,20 @@ async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Pro
return null; return null;
} }
/**
* key KV oauth2 cache key access_token
* - `{apiKey}:oauth2:{service}:*` env.CREDENTIALS_KV
*/
async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, value: string, ttlSeconds: number): Promise<void> {
const oauth2Match = key.match(/^([^:]+):oauth2:.+$/);
if (oauth2Match && oauth2Match[1] === apiKey) {
const opts = ttlSeconds > 0 ? { expirationTtl: ttlSeconds } : undefined;
await env.CREDENTIALS_KV.put(key, value, opts);
return;
}
// 其他 key 前綴拒絕寫入(安全邊界)
}
/** /**
* AES-GCM encryption key env.ENCRYPTION_KEY function * AES-GCM encryption key env.ENCRYPTION_KEY function
* WASM base64 UTF-8 plaintext * WASM base64 UTF-8 plaintext
@@ -408,7 +703,39 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<
export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions { export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions {
return { return {
kv_get: (key: string) => routedKvGet(env, apiKey, key), kv_get: (key: string) => routedKvGet(env, apiKey, key),
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64), crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8), crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
}; };
} }
/**
* platform_crypto host functions
* apiKey KV routing
* ENCRYPTION_KEY closure WASM
*/
export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions {
const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf)));
return {
crypto_hmac_sha256: async (data: Uint8Array): Promise<Uint8Array> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(sig);
},
crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: string }> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext);
return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) };
},
crypto_random_bytes: (numBytes: number): string => {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
},
};
}
+80 -8
View File
@@ -9,6 +9,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { ensureKbdbPartner, revokeKbdbPartner } from '../lib/kbdb-partner';
export const authRouter = new Hono<{ Bindings: Bindings }>(); export const authRouter = new Hono<{ Bindings: Bindings }>();
@@ -60,6 +61,24 @@ async function generateApiKey(email: string, encryptionKey: string): Promise<str
return 'ak_' + hex.slice(0, 32); return 'ak_' + hex.slice(0, 32);
} }
/** AES-GCM 加密,回傳 {encrypted, iv}base64),與 SDK 格式相同 */
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
const key = `auth_recipe:${recipe.service}`;
const existing = await recipes.get(key);
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
}
/** 產生隨機 token(用於 session ID 和 state */ /** 產生隨機 token(用於 session ID 和 state */
function randomToken(bytes = 32): string { function randomToken(bytes = 32): string {
const arr = new Uint8Array(bytes); const arr = new Uint8Array(bytes);
@@ -129,7 +148,7 @@ authRouter.get('/auth/google/start', async (c) => {
scope: 'openid profile email', scope: 'openid profile email',
state, state,
access_type: 'offline', access_type: 'offline',
prompt: 'select_account', prompt: 'consent',
}); });
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302); return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
@@ -207,7 +226,7 @@ authRouter.get('/auth/callback', async (c) => {
}), }),
}); });
if (!tokenRes.ok) throw new Error('google token exchange failed'); if (!tokenRes.ok) throw new Error('google token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string }; const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
// Get user info // Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
@@ -222,6 +241,32 @@ authRouter.get('/auth/callback', async (c) => {
avatarUrl = userInfo.picture; avatarUrl = userInfo.picture;
providerId = userInfo.sub; providerId = userInfo.sub;
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
if (tokenData.refresh_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'google_user',
version: 1,
primitive: 'oauth2',
base_url: 'https://www.googleapis.com',
display_name: 'Google(用戶帳號)',
oauth2: {
token_endpoint: 'https://oauth2.googleapis.com/token',
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
},
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
});
}
} else { } else {
// GitHub: exchange code for token // GitHub: exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', { const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
@@ -238,7 +283,7 @@ authRouter.get('/auth/callback', async (c) => {
}), }),
}); });
if (!tokenRes.ok) throw new Error('github token exchange failed'); if (!tokenRes.ok) throw new Error('github token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string }; const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
// Get user info // Get user info
const userRes = await fetch('https://api.github.com/user', { const userRes = await fetch('https://api.github.com/user', {
@@ -274,6 +319,26 @@ authRouter.get('/auth/callback', async (c) => {
displayName = userInfo.name ?? userInfo.login; displayName = userInfo.name ?? userInfo.login;
avatarUrl = userInfo.avatar_url; avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id); providerId = String(userInfo.id);
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'github_user',
version: 1,
primitive: 'static_key',
base_url: 'https://api.github.com',
display_name: 'GitHub(用戶帳號)',
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
});
}
} }
// Upsert user record // Upsert user record
@@ -300,6 +365,9 @@ authRouter.get('/auth/callback', async (c) => {
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey); await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
} }
// 同步 KBDB partner 記錄(允許 ak_xxx 直接存取 KBDB
void ensureKbdbPartner(c.env, email, apiKey);
// Create session (TTL 7 days) // Create session (TTL 7 days)
const sessionId = randomToken(32); const sessionId = randomToken(32);
const session: SessionRecord = { const session: SessionRecord = {
@@ -317,7 +385,7 @@ authRouter.get('/auth/callback', async (c) => {
status: 302, status: 302,
headers: { headers: {
Location: `${landingOrigin}${redirectBack}`, Location: `${landingOrigin}${redirectBack}`,
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`, 'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
}, },
}); });
@@ -339,7 +407,7 @@ authRouter.post('/auth/logout', async (c) => {
status: 302, status: 302,
headers: { headers: {
Location: `${landingOrigin}/`, Location: `${landingOrigin}/`,
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0', 'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
}, },
}); });
}); });
@@ -379,8 +447,9 @@ authRouter.put('/me/api-key/rotate', async (c) => {
await c.env.USERS_KV.delete(`apikey:${oldKey}`); await c.env.USERS_KV.delete(`apikey:${oldKey}`);
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey); await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
// Invalidate all current sessions for this user (simple: sessions will re-auth on next request) // 更新 KBDB partner 記錄(舊 Key 撤銷,新 Key 建立)
// (Full invalidation would require listing all sessions, skip for now) void revokeKbdbPartner(c.env, oldKey);
void ensureKbdbPartner(c.env, user.email, newKey);
return c.json({ return c.json({
success: true, success: true,
@@ -400,6 +469,9 @@ authRouter.delete('/me/api-key', async (c) => {
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked)); await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
await c.env.USERS_KV.delete(`apikey:${user.api_key}`); await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
// 撤銷 KBDB partner 記錄
void revokeKbdbPartner(c.env, user.api_key);
// Clear session cookie // Clear session cookie
const sessId = getSessionId(c.req.raw); const sessId = getSessionId(c.req.raw);
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`); if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
@@ -408,7 +480,7 @@ authRouter.delete('/me/api-key', async (c) => {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0', 'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
}, },
}); });
}); });
+2
View File
@@ -102,6 +102,8 @@ service = "arcrun-ai-transform-run"
ENVIRONMENT = "production" ENVIRONMENT = "production"
# MULTI_TENANT = "true" # MULTI_TENANT = "true"
# ENCRYPTION_KEY 透過 wrangler secret set 設定 # ENCRYPTION_KEY 透過 wrangler secret set 設定
KBDB_BASE_URL = "https://kbdb.finally.click"
# KBDB_INTERNAL_TOKEN 透過 wrangler secret set 設定
[[routes]] [[routes]]
pattern = "cypher.arcrun.dev/*" pattern = "cypher.arcrun.dev/*"
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "stanley-494303",
"private_key_id": "2f8ae8098cc8e1097502bea9d19d347c2555f2e5",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCVobDw2q2isV5+\nOHJQ8BKFZUq3maeum/HtlYSxw0o5AMG3Rq2ZwxaMCIvCb0L1GJRidFhs+kEY34Mm\nWGTpuYHitl1mXY2R3P+JFYbji8xxJYX+CGeUwkWy4AKxr9rCQGQW5e/U8XiKOWcQ\nticSHwTYIINlEmwiwpGGsemCDi1XgIim6qP6gzWon4QqGpN732xUNB/mUUtE8bJT\naGTUX1SPGkjf3gelTyEW9HcrXqEXc9Q0W12AUkm0Ele2UkBdafA50UmqYOWnhkUM\nKPl+qzS++w2DFBUrdNqCdIdq1/6c8y8opUVK3vdkEB5oySANzVaSfHJtITTtFamL\npfd0+CozAgMBAAECggEAAneja/VPKQBKZ0PAWXv78jMqDj5RuNwIGzt8mqnLuYY9\nANarhNOQtZ0WdjhQyasuwd4v7xbD/V2s00m12vIc0C44UA3O3c7fxSo+X9Ytotf+\n46Ij2HIeUBrIMJc5FjxAP5hpt1Xb/6YpVZSLWbh6jfh18jQMOxkrk9BZ4gkgekbf\n2umGDh94qNQxGkVHDRp1eHPPK0nTxsAmlgoCRD3wZC+MfZ3+yuvop8A2JTK5Qzp9\nmBbxHOFiSDbekbXQ+mECD4NhUL4em9GnIL0PJZYQpSKLm9qorsOUG773O9DVjaf7\nEMnJO5X/evyeaLte+rz5NFmxJT3z0czfthq8cRZ8gQKBgQDEZclUDVxfpazkZ/3f\ngmU/ezXWZ7BjE25htPxmfl7pRNjXBcziye1ooIuErpbO1eezUPdS5DFTMwXx5R3P\nR49ogijf2NvBYkznJ0dXA9Z/RXqrVQXtu8AE6XFpQRkNrMCSdWLRXOOv6Mdwt+PC\nBw5QHl+L7LRBdnv2PgkdRR9BkwKBgQDDCqJs6ShjSQYQuX52c1rtdfxS3kEb201N\nksCR8D9woUmnljN5msf/7R+xj68TVVjD47zT2esmc4J/5VQFpgtiKkmeYQ1FSrCJ\nEss8dsDpx7WWM4TyMZRArKgT3t9Oye6ITasnU2UnWNUPmgxxlV2ZAyP399/UGCdR\noV21v1dY4QKBgFzqwyuO/qsJ2Rhe0s+SA5DbHAeKGvtk0A5N8DQViZXXSrfAOMT7\nP+UGP7vQVe0Zant9zOVcrLFuLFvbSBUvB/wryGbPVHjTpwqdnLTgTfT8zCKPITTu\nZNRuFYf0koPTvXANNWlUByzMdr8vYQQFDpJ9LDspC8cE5XUEuI8uirEdAoGAYwHe\n+dJRwjSrc4n1/EMKgUhHUfmoq29jimFYh7yhN5doQD4q2ywLIotIb2Y0xWepq6bL\nj+8rQ1WXhTzwrf1gAfDddhxmFCqZ+rsjmAngW8wZDaoRbrBkRYBfwdZ9HQ28nExw\n+YGH87VQUp8seewMm0PQ2mtln9CzBOkZHM2IlYECgYAl5JuN5iatSvOeErGRnM4Y\n6PqAHyZwUEFjROoL1gB583A19h8IbUyyzMlX5Q39fsM6my2r9lT6GR2HCyIQ7Gi4\nSet1NIM1hZaLjsMywB7dskU8RWzlQoaQTdLpt4SlYEs+/OpjhhFna4I3kYr2hwpy\n2ACng6qWC7/yzyT70ZGhUQ==\n-----END PRIVATE KEY-----\n",
"client_email": "n8n-stanley@stanley-494303.iam.gserviceaccount.com",
"client_id": "117786291283661214919",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/n8n-stanley%40stanley-494303.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
@@ -0,0 +1,56 @@
// App root screen switcher with persistent route
const { useState, useEffect } = React;
const SCREENS = [
{ id: 'landing', label: 'Landing' },
{ id: 'auth', label: 'Auth' },
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'keys', label: 'API Keys' },
{ id: 'workflow', label: 'Workflow' },
];
// Synonyms from sidebar ids
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
function App() {
const [screen, setScreen] = useState(() => {
const saved = localStorage.getItem('arcrun:screen');
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
});
useEffect(() => {
localStorage.setItem('arcrun:screen', screen);
window.scrollTo(0, 0);
}, [screen]);
const nav = (id) => {
const resolved = aliases[id] || id;
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
};
const Current = {
landing: Landing,
auth: Auth,
dashboard: Dashboard,
keys: ApiKeys,
workflow: WorkflowViewer,
}[screen];
return (
<div className="app">
<Current onNav={nav} />
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
{SCREENS.map(s => (
<button key={s.id}
className={screen === s.id ? 'active' : ''}
onClick={() => nav(s.id)}>
{s.label}
</button>
))}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,92 @@
// Top nav and sidebar
const TopNav = ({ onNav, current }) => {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo onClick={() => onNav('landing')} />
<div className="nav-links" style={{marginLeft: 20}}>
<a>Product</a>
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
</div>
</div>
<div className="flex gap-8" style={{alignItems: 'center'}}>
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
<button className="btn btn-primary" onClick={() => onNav('auth')}>
Get started <Icon name="arrow_right" size={14} />
</button>
</div>
</nav>
);
};
const Footer = ({ onNav }) => (
<footer className="footer">
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo size="sm" />
<span>© 2026 Arcrun Labs</span>
</div>
<div className="footer-links">
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
<a>Status</a>
<a>Privacy</a>
</div>
</footer>
);
// App shell with sidebar for logged-in screens
const Sidebar = ({ current, onNav }) => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
{ id: 'keys', label: 'API Keys', icon: 'key' },
{ id: 'docs', label: 'Docs', icon: 'book' },
];
const bottom = [
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
return (
<aside className="sidebar">
<div className="sidebar-head">
<Logo size="md" onClick={() => onNav('landing')} />
</div>
<div className="sidebar-section">Workspace</div>
{items.map(it => (
<div key={it.id}
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
{it.count != null && <span className="sb-count">{it.count}</span>}
</div>
))}
<div style={{flex: 1}} />
{bottom.map(it => (
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
</div>
))}
<div className="sidebar-foot">
<div className="avatar-circ">MR</div>
<div className="meta">
<div className="name">Maya Rivera</div>
<div className="email">maya@northwind.co</div>
</div>
</div>
</aside>
);
};
Object.assign(window, { TopNav, Footer, Sidebar });
@@ -0,0 +1,86 @@
// Shared primitives: icons, logo, etc.
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
const paths = {
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
plus: <path d="M12 5v14M5 12h14" />,
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
check: <path d="M20 6L9 17l-5-5" />,
close: <path d="M18 6L6 18M6 6l12 12" />,
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
chevron_right: <path d="M9 6l6 6-6 6" />,
chevron_down: <path d="M6 9l6 6 6-6" />,
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
{paths[name]}
</svg>
);
};
// Arcrun wordmark custom "arc" glyph made of an arc stroke + ascending dot/node
const Logo = ({ size = 'md', onClick }) => {
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
return (
<div className="logo" onClick={onClick}>
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
<circle cx="19" cy="17" r="2.2" fill="white" />
</svg>
</span>
<span>Arcrun</span>
</div>
);
};
// App icon with gradient background
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
const tones = {
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
green: 'linear-gradient(135deg, #10B981, #22C55E)',
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
slate: 'linear-gradient(135deg, #475569, #334155)',
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
};
return (
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
{children}
</div>
);
};
Object.assign(window, { Icon, Logo, AppIcon });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
const ApiKeys = ({ onNav }) => {
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
const [keys, setKeys] = React.useState([
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
]);
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
const copyKey = () => {
setNewKeyCopied(true);
setTimeout(() => setNewKeyCopied(false), 1800);
};
const toggleKey = (id) => {
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
};
return (
<div className="shell">
<Sidebar current="keys" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Workspace</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Settings</span>
</div>
<h1>API Keys</h1>
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
</div>
</div>
<div className="main-body" style={{maxWidth: 1080}}>
<div className="new-key-box">
<div className="warn-row">
<span className="warn-icon"><Icon name="warn" size={12} /></span>
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
</div>
<h3>Your new API key</h3>
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
<div className="key-display">
<span className="key-val">{newKey}</span>
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
{newKeyCopied ? 'Copied' : 'Copy'}
</button>
</div>
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes </span></span>
</div>
</div>
<div className="section-head">
<div>
<h2>All keys</h2>
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '32%'}}>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th>Status</th>
<th style={{width: 60, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
</td>
<td className="mono">{k.prefix}{k.id.slice(-4)}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
<td>
{k.revoked ? (
<span className="pill revoked"><span className="pdot" /> Revoked</span>
) : (
<div className="flex gap-8" style={{alignItems: 'center'}}>
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
</span>
</div>
)}
</td>
<td style={{textAlign: 'right'}}>
{!k.revoked && (
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
<Icon name="warn" size={12} />
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
</div>
</div>
</div>
</div>
);
};
window.ApiKeys = ApiKeys;
@@ -0,0 +1,90 @@
const Auth = ({ onNav }) => {
const [mode, setMode] = React.useState('signin');
const [email, setEmail] = React.useState('');
const [pw, setPw] = React.useState('');
const [remember, setRemember] = React.useState(true);
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
return (
<div className="auth-wrap">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
<Logo onClick={() => onNav('landing')} />
</div>
<div className="auth-card">
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
<div className="tabs">
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
</div>
<div className="oauth-row">
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
</button>
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
<Icon name="google" size={15} stroke={0} /> Continue with Google
</button>
</div>
<div className="divider">or continue with email</div>
<form onSubmit={submit}>
{mode === 'signup' && (
<div className="field">
<label>Full name</label>
<input className="input" type="text" placeholder="Maya Rivera" />
</div>
)}
<div className="field">
<label>Work email</label>
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="field">
<div className="field-row">
<label>Password</label>
{mode === 'signin' && <span className="link">Forgot password?</span>}
</div>
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
</div>
{mode === 'signin' && (
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
<div onClick={() => setRemember(!remember)}
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
background: remember ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
{remember && <Icon name="check" size={11} />}
</div>
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
</div>
)}
<button className="btn btn-primary auth-submit btn-lg" type="submit">
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
</button>
</form>
{mode === 'signup' && (
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
</p>
)}
<div className="auth-foot">
{mode === 'signin'
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
</div>
</div>
</div>
);
};
window.Auth = Auth;
@@ -0,0 +1,126 @@
const Dashboard = ({ onNav }) => {
const apps = [
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
];
const workflows = [
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
];
return (
<div className="shell">
<Sidebar current="dashboard" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Northwind</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Dashboard</span>
</div>
<h1>Welcome back, Maya</h1>
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
</div>
</div>
<div className="main-body">
{/* Apps grid */}
<div className="section-head">
<div>
<h2>My Apps</h2>
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
</div>
<span className="subtle">{apps.length} apps</span>
</div>
<div className="apps-grid">
{apps.map(a => (
<div key={a.id} className="app-card">
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
<h4>{a.name}</h4>
<p className="dsc">{a.desc}</p>
<div className="row">
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
<button className="chip-btn">
<Icon name="spark" size={11} /> Edit in Claude
</button>
</div>
</div>
))}
<div className="app-card app-empty">
<div className="plus"><Icon name="plus" size={16} /></div>
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
</div>
</div>
{/* Workflows */}
<div className="wf-table">
<div className="section-head">
<div>
<h2>My Workflows</h2>
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '34%'}}>Workflow</th>
<th>Nodes</th>
<th>Last modified</th>
<th>Activity</th>
<th>Status</th>
<th style={{width: 100, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{workflows.map(w => (
<tr key={w.id}>
<td>
<div className="wf-row-name">
<span className="dot" />
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
</div>
</td>
<td className="dim">{w.nodes}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
<td>
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
<span className="pdot" /> {w.status}
</span>
</td>
<td style={{textAlign: 'right'}}>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
window.Dashboard = Dashboard;
@@ -0,0 +1,168 @@
const Landing = ({ onNav }) => {
const [installer, setInstaller] = React.useState('npm');
const installCmds = {
npm: '$ npm install arcrun',
pip: '$ pip install arcrun',
bun: '$ bun add arcrun',
};
return (
<div>
<TopNav onNav={onNav} current="landing" />
<div className="container">
<section className="hero">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div className="hero-eyebrow">
<span className="dot" />
<span>Now in public beta MCP-native</span>
</div>
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code Arcrun handles auth, retries, and state.</p>
<div className="hero-ctas">
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
Start free <Icon name="arrow_right" size={15} />
</button>
<button className="btn btn-secondary btn-lg">
<Icon name="book" size={14} /> Read the docs
</button>
</div>
</section>
<section className="paths">
{/* Developer path */}
<div className="path-card">
<div className="path-label">
<Icon name="terminal" size={13} /> For Developers
</div>
<h3>Three lines, any runtime.</h3>
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
<div className="install-tabs">
{Object.keys(installCmds).map(k => (
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
))}
</div>
<div className="terminal" style={{marginBottom: 12}}>
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">terminal</div>
</div>
<div className="terminal-body">
<div><span className="dim">{installCmds[installer]}</span></div>
</div>
</div>
<div className="terminal">
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
</div>
<div className="terminal-body">
{installer === 'pip' ? (
<>
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
<div className="sp-4"/>
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
</>
) : (
<>
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
<div className="sp-4"/>
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
</>
)}
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
</div>
</div>
{/* Everyone path */}
<div className="path-card">
<div className="path-label">
<Icon name="spark" size={13} /> For Everyone
</div>
<h3>Talk to your workflows.</h3>
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages in plain English.</p>
<div className="chat-preview">
<div className="chat-head">
<span className="brand-dot">AI</span>
<span>Your assistant Arcrun connected</span>
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
</div>
<div className="chat-body">
<div className="chat-msg user">
<div className="avatar">M</div>
<div className="bubble">Send this week's customer digest to the revenue team.</div>
</div>
<div className="chat-msg ai">
<div className="avatar">A</div>
<div className="bubble">
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
<div className="tool-card">
<div className="tool-icon">AR</div>
<div className="tool-meta">
<div className="tool-name">arcrun · digest/weekly</div>
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
</div>
<span className="pill active"><span className="pdot" />running</span>
</div>
</div>
</div>
</div>
<div className="chat-input">
<span>Reply to your assistant</span>
<span className="caret" />
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
</div>
</div>
</section>
<section className="strip">
<div className="cell">
<div className="ico"><Icon name="bolt" size={15} /></div>
<h4>Run anywhere</h4>
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="workflow" size={15} /></div>
<h4>Composable steps</h4>
<p>Model calls, HTTP, database, branching wire them visually or in code.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="key" size={15} /></div>
<h4>Scoped keys</h4>
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="eye" size={15} /></div>
<h4>Observable</h4>
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
</div>
</section>
</div>
<Footer onNav={onNav} />
</div>
);
};
window.Landing = Landing;
@@ -0,0 +1,255 @@
const WorkflowViewer = ({ onNav }) => {
const nodes = [
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
];
const edges = [
['trigger', 'fetch'],
['trigger', 'events'],
['fetch', 'summarize'],
['events', 'summarize'],
['summarize', 'filter'],
['summarize', 'log'],
['filter', 'slack'],
['filter', 'mail'],
];
const [selectedId, setSelectedId] = React.useState('summarize');
const [title, setTitle] = React.useState('digest/weekly');
const [zoom, setZoom] = React.useState(100);
const selected = nodes.find(n => n.id === selectedId);
// Edit triplet inline (for the summarize node's prompt config)
const [triplet, setTriplet] = React.useState({
model: 'claude-haiku-4-5',
temperature: '0.3',
prompt: 'Summarize this week\'s account activity for the revenue team.',
});
// Measure node widths for edge endpoint accuracy
const nodeRefs = React.useRef({});
const [sizes, setSizes] = React.useState({});
React.useEffect(() => {
const ns = {};
for (const n of nodes) {
const el = nodeRefs.current[n.id];
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
}
setSizes(ns);
}, []);
const getPort = (id, side) => {
const n = nodes.find(x => x.id === id);
const sz = sizes[id] || { w: 200, h: 60 };
return {
x: side === 'out' ? n.x + sz.w : n.x,
y: n.y + sz.h / 2,
};
};
return (
<div className="wf-viewer">
<div className="wf-topbar">
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
<Icon name="arrow_left" size={16} />
</div>
<Logo size="sm" onClick={() => onNav('landing')} />
<div className="sep" />
<div className="wf-breadcrumb">
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
<Icon name="chevron_right" size={11} />
<input
className="wf-title mono"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<span className="wf-saved">
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
Saved · 2m ago
</span>
<div className="spacer" />
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
<button className="wf-edit-in-claude">
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
</button>
</div>
<div className="wf-canvas">
<svg className="wf-edges" width="100%" height="100%">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
</marker>
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
</marker>
</defs>
{edges.map(([a, b], i) => {
const p1 = getPort(a, 'out');
const p2 = getPort(b, 'in');
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
const highlight = a === selectedId || b === selectedId;
return (
<path key={i} d={d}
stroke={highlight ? '#6366F1' : '#3a3a3a'}
strokeWidth={highlight ? 2 : 1.5}
fill="none"
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
opacity={highlight ? 0.95 : 0.6} />
);
})}
</svg>
<div className="wf-nodes">
{nodes.map(n => (
<div key={n.id}
ref={el => (nodeRefs.current[n.id] = el)}
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
style={{left: n.x, top: n.y}}
onClick={() => setSelectedId(n.id)}>
{n.inputs.length > 0 && <span className="port in" />}
{n.outputs.length > 0 && <span className="port out" />}
<div className="node-row-top">
<span className="node-icon" style={{background: n.tone}}>
<Icon name={n.icon} size={12} />
</span>
<span className="node-title">{n.title}</span>
<span className="node-badge">{n.badge}</span>
</div>
<div className="node-sub">{n.type}</div>
</div>
))}
</div>
{/* Detail panel */}
{selected && (
<div className="wf-detail">
<div className="dt-head">
<span className="dt-icon" style={{background: selected.tone}}>
<Icon name={selected.icon} size={15} />
</span>
<div className="dt-meta">
<h3>{selected.title}</h3>
<div className="dt-type">{selected.type}</div>
</div>
<button className="close-btn" onClick={() => setSelectedId(null)}>
<Icon name="close" size={14} />
</button>
</div>
<div className="dt-body">
<div className="dt-section">
<h4>Input schema</h4>
{selected.inputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs this is a trigger.</div>
) : selected.inputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
<div className="dt-section">
<h4>Output schema</h4>
{selected.outputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs terminal node.</div>
) : selected.outputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
{selected.id === 'summarize' && (
<div className="dt-section">
<h4>Configuration</h4>
<div className="triplet">
<div className="trow">
<div className="tkey">model</div>
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">temp</div>
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">prompt</div>
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
</div>
</div>
</div>
)}
<div className="dt-section">
<h4>Last run</h4>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
</div>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
</div>
</div>
</div>
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
<Icon name="spark" size={13} /> Edit this node in Claude
</button>
</div>
</div>
)}
{/* Minimap */}
<div className="wf-minimap">
<div className="mini-label">Overview</div>
{nodes.map(n => {
const sz = sizes[n.id] || {w: 180, h: 60};
return (
<div key={n.id} className="mini-box" style={{
left: 8 + (n.x / 1400) * 164,
top: 18 + (n.y / 500) * 80,
width: Math.max(6, (sz.w / 1400) * 164),
height: Math.max(4, (sz.h / 500) * 80),
opacity: selectedId === n.id ? 1 : 0.5,
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
}} />
);
})}
</div>
{/* Zoom controls */}
<div className="wf-controls">
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
<div className="zoom-val">{zoom}%</div>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
<button><Icon name="maximize" size={13} /></button>
</div>
</div>
</div>
);
};
window.WorkflowViewer = WorkflowViewer;
File diff suppressed because it is too large Load Diff
+3 -15
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import SiteNav from '../components/SiteNav';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
@@ -25,7 +26,7 @@ export default function ApiDocsPage() {
const SwaggerUIBundle = (window as unknown as { SwaggerUIBundle: (opts: unknown) => void }).SwaggerUIBundle; const SwaggerUIBundle = (window as unknown as { SwaggerUIBundle: (opts: unknown) => void }).SwaggerUIBundle;
if (!SwaggerUIBundle || !containerRef.current) return; if (!SwaggerUIBundle || !containerRef.current) return;
SwaggerUIBundle({ SwaggerUIBundle({
url: `${API_BASE}/docs`, url: `${API_BASE}/openapi.json`,
dom_id: '#swagger-ui', dom_id: '#swagger-ui',
presets: [(window as unknown as { SwaggerUIBundle: { presets: { apis: unknown } } }).SwaggerUIBundle.presets.apis], presets: [(window as unknown as { SwaggerUIBundle: { presets: { apis: unknown } } }).SwaggerUIBundle.presets.apis],
layout: 'BaseLayout', layout: 'BaseLayout',
@@ -51,20 +52,7 @@ export default function ApiDocsPage() {
return ( return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]"> <div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
{/* Nav */} <SiteNav currentPath="/api-docs" />
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/integrations" className="text-[#666] hover:text-white transition-colors">Integrations</Link>
<Link href="/dashboard" className="text-[#666] hover:text-white transition-colors">Dashboard</Link>
<Link href="/login"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md text-sm font-medium transition-colors">
Get API Key
</Link>
</div>
</nav>
{/* Header */} {/* Header */}
<div className="max-w-5xl mx-auto px-6 py-8"> <div className="max-w-5xl mx-auto px-6 py-8">
+90
View File
@@ -0,0 +1,90 @@
'use client';
// 九宮格 App Launcher(受 Google Apps menu 啟發)
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
// 非白名單 user 看到 mira 等受限 app 顯示為灰色 + tooltip「即將開放」
import { useEffect, useRef, useState } from 'react';
import { MATRIX_APPS, isAppAccessible, type AppEntry } from './apps';
export default function AppLauncher({ userEmail }: { userEmail: string | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-label="切換應用"
className="flex items-center justify-center w-9 h-9 rounded-md text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors"
>
{/* 九宮格 icon */}
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<circle cx="3" cy="3" r="1.5" /><circle cx="9" cy="3" r="1.5" /><circle cx="15" cy="3" r="1.5" />
<circle cx="3" cy="9" r="1.5" /><circle cx="9" cy="9" r="1.5" /><circle cx="15" cy="9" r="1.5" />
<circle cx="3" cy="15" r="1.5" /><circle cx="9" cy="15" r="1.5" /><circle cx="15" cy="15" r="1.5" />
</svg>
</button>
{open && (
<div
className="absolute right-0 mt-2 w-72 bg-[#0f0f0f] border border-[#222] rounded-lg shadow-xl p-2 z-50"
role="menu"
>
<div className="grid grid-cols-3 gap-1">
{MATRIX_APPS.map(app => (
<AppTile key={app.id} app={app} userEmail={userEmail} onClose={() => setOpen(false)} />
))}
</div>
</div>
)}
</div>
);
}
function AppTile({
app,
userEmail,
onClose,
}: {
app: AppEntry;
userEmail: string | null;
onClose: () => void;
}) {
const accessible = isAppAccessible(app, userEmail);
const tooltip = !accessible ? (app.locked_tooltip ?? '即將開放') : (app.description ?? '');
if (!accessible) {
return (
<div
title={tooltip}
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md cursor-not-allowed opacity-40"
>
<span className="text-2xl grayscale">{app.icon ?? '📦'}</span>
<span className="text-xs text-[#666] text-center">{app.name}</span>
</div>
);
}
return (
<a
href={app.url}
onClick={onClose}
title={tooltip}
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md hover:bg-[#1a1a1a] transition-colors text-[#ccc] hover:text-white"
>
<span className="text-2xl">{app.icon ?? '📦'}</span>
<span className="text-xs text-center">{app.name}</span>
</a>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import AppLauncher from './AppLauncher';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type NavUser = {
display_name: string;
email: string;
avatar_url?: string;
};
export default function SiteNav({ currentPath }: { currentPath?: string }) {
const [user, setUser] = useState<NavUser | null | undefined>(undefined);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => r.ok ? r.json() as Promise<NavUser> : null)
.then(u => setUser(u))
.catch(() => setUser(null));
}, []);
const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.href = '/';
};
const linkCls = (path: string) =>
`transition-colors text-sm ${currentPath === path ? 'text-white' : 'text-[#666] hover:text-white'}`;
return (
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/integrations" className={linkCls('/integrations')}>Integrations</Link>
<Link href="/api-docs" className={linkCls('/api-docs')}>API</Link>
<a
href="https://github.com/richblack/arcrun"
target="_blank"
rel="noopener noreferrer"
className="text-[#666] hover:text-white transition-colors"
>
GitHub
</a>
{user === undefined ? (
// Loading — placeholder to prevent layout shift
<div className="w-20 h-7" />
) : user ? (
<>
<AppLauncher userEmail={user.email} />
<Link href="/dashboard" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
{user.avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar_url} alt="" width={26} height={26} className="rounded-full" />
)}
<span className="text-[#aaa]">{user.display_name}</span>
</Link>
<button
onClick={logout}
className="text-[#555] hover:text-[#888] transition-colors cursor-pointer"
>
</button>
</>
) : (
<Link
href="/login"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md font-medium transition-colors"
>
Get API Key
</Link>
)}
</div>
</nav>
);
}
+54
View File
@@ -0,0 +1,54 @@
// Matrix App Launcher 九宮格清單
// 來源:matrix/identity/.agents/specs/identity/apps.jsonv0 過渡複製,未來 v1 抽進 @matrix/identity-ui
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
export type AppEntry = {
id: string;
name: string;
url: string;
icon?: string;
description?: string;
access?: 'public' | 'allowlist';
allowlist_emails?: string[];
locked_tooltip?: string;
};
export const MATRIX_APPS: AppEntry[] = [
{
id: 'arcrun',
name: 'Arcrun',
url: 'https://arcrun.dev',
icon: '🔄',
description: '工作流引擎與零件平台',
},
{
id: 'dashboard',
name: 'Dashboard',
url: 'https://arcrun.dev/dashboard',
icon: '🔑',
description: 'API Key 管理',
},
{
id: 'integrations',
name: 'Integrations',
url: 'https://arcrun.dev/integrations',
icon: '🧩',
description: '服務目錄',
},
{
id: 'mira',
name: 'Mira',
url: 'https://arcrun.dev/mira',
icon: '🌊',
description: '個人化 KM 河道',
access: 'allowlist',
allowlist_emails: ['leo21c@gmail.com'],
locked_tooltip: '即將開放',
},
];
export function isAppAccessible(app: AppEntry, userEmail: string | null): boolean {
if (app.access !== 'allowlist') return true;
if (!userEmail) return false;
return (app.allowlist_emails ?? []).includes(userEmail);
}
+2 -21
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import SiteNav from '../components/SiteNav';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
@@ -89,11 +90,6 @@ export default function DashboardPage() {
} }
}; };
const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.href = '/';
};
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"> <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
@@ -115,22 +111,7 @@ export default function DashboardPage() {
return ( return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]"> <div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
{/* Nav */} <SiteNav currentPath="/dashboard" />
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
<div className="flex items-center gap-4">
{user.avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar_url} alt="" width={28} height={28} className="rounded-full" />
)}
<span className="text-[#666] text-sm">{user.email}</span>
<button onClick={logout} className="text-[#555] hover:text-[#888] text-sm transition-colors cursor-pointer">
</button>
</div>
</nav>
<main className="max-w-2xl mx-auto px-6 py-12"> <main className="max-w-2xl mx-auto px-6 py-12">
<h1 className="text-2xl font-bold text-white mb-1">{user.display_name}</h1> <h1 className="text-2xl font-bold text-white mb-1">{user.display_name}</h1>
+2 -13
View File
@@ -1,6 +1,7 @@
export const runtime = 'edge'; export const runtime = 'edge';
import Link from 'next/link'; import Link from 'next/link';
import SiteNav from '../components/SiteNav';
type Recipe = { type Recipe = {
id: string; id: string;
@@ -64,19 +65,7 @@ async function IntegrationsContent({
return ( return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]"> <div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
{/* Nav */} <SiteNav currentPath="/integrations" />
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/api-docs" className="text-[#666] hover:text-white transition-colors">API</Link>
<Link href="/login"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md text-sm font-medium transition-colors">
Get API Key
</Link>
</div>
</nav>
<div className="max-w-5xl mx-auto px-6 py-12"> <div className="max-w-5xl mx-auto px-6 py-12">
{/* Header */} {/* Header */}
+63
View File
@@ -0,0 +1,63 @@
'use client';
// Mira 共用 Markdown 渲染器(河道 + Wiki 共用)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.7
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownView({ text }: { text: string }) {
const cleaned = useMemo(() => stripLogseqMeta(text), [text]);
return (
<div className="mira-md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...rest }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="wiki-link"
{...rest}
>
{children}
</a>
),
// 圖片不直接 inline 顯示(避免大圖打亂 feed),改成連結
img: ({ src, alt }) => {
const href = typeof src === 'string' ? src : '';
return href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="wiki-link"
style={{ fontStyle: 'italic' }}
>
🖼 {alt || 'image'}
</a>
) : null;
},
}}
>
{cleaned}
</ReactMarkdown>
</div>
);
}
// Strip Logseq 專屬語法
// - 屬性行:`xxx:: yyy`、`collapsed:: true`、`id:: ...`、`logseq.order-list-type:: ...`
// - block ref`((uuid))` 暫時保留為純文字
export function stripLogseqMeta(text: string): string {
return text
.split('\n')
.filter((line) => {
const trimmed = line.trimStart();
if (/^[a-zA-Z][a-zA-Z0-9_.-]*::\s/.test(trimmed)) return false;
return true;
})
.join('\n');
}
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
'use client';
// Mira 子應用 layout
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.5
// 規範:白名單 user 進得去;非白名單 user 看到「即將開放」頁
// middleware 已做未登入跳 /loginredirect=/mira 檢查(不在這裡重做)
import { useEffect, useState } from 'react';
import SiteNav from '../components/SiteNav';
import { MATRIX_APPS } from '../components/apps';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type Me = { email: string; display_name: string; api_key: string };
const MIRA = MATRIX_APPS.find(a => a.id === 'mira');
const ALLOWED = new Set(MIRA?.allowlist_emails ?? []);
export default function MiraLayout({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<Me | null | undefined>(undefined);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => r.ok ? r.json() as Promise<Me> : null)
.then(u => setMe(u))
.catch(() => setMe(null));
}, []);
if (me === undefined) {
return (
<>
<SiteNav currentPath="/mira" />
<div className="flex-1 flex items-center justify-center text-[#666]"></div>
</>
);
}
if (me === null) {
// 理論上 middleware 已擋住,但保險
if (typeof window !== 'undefined') {
window.location.href = '/login?redirect=/mira';
}
return null;
}
if (!ALLOWED.has(me.email)) {
return (
<>
<SiteNav currentPath="/mira" />
<BetaBlocked email={me.email} />
</>
);
}
return (
<>
<SiteNav currentPath="/mira" />
{children}
</>
);
}
function BetaBlocked({ email }: { email: string }) {
return (
<main className="flex-1 flex items-center justify-center px-6">
<div className="max-w-md text-center space-y-4">
<div className="text-6xl mb-2">🌊</div>
<h1 className="text-3xl font-bold text-white">Mira </h1>
<p className="text-[#888] leading-relaxed">
Mira arcrun KM
</p>
<p className="text-sm text-[#555]">
<span className="font-mono text-[#888]">{email}</span>
</p>
<div className="pt-4">
<a
href="/dashboard"
className="inline-block bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2 rounded-md text-sm font-medium transition-colors"
>
Dashboard
</a>
</div>
</div>
</main>
);
}
+757
View File
@@ -0,0 +1,757 @@
/* Mira 社群貼文式 KM 河道
* 視覺參考Facebook / X / LinkedIn
* Scope .mira-app 容器內避免汙染 landing Tailwind 樣式
*/
.mira-app {
/* Surfaces */
--mira-bg-0: #0e0e0d;
--mira-bg-1: #1a1a18;
--mira-bg-2: #252320;
--mira-bg-3: #302d28;
--mira-line: #2e2c27;
--mira-line-soft: #1f1d1a;
/* Text — 高對比 */
--mira-text-1: #f0eee8;
--mira-text-2: #c9c4b8;
--mira-text-3: #9c968a;
--mira-text-4: #6c685e;
--mira-accent: oklch(0.78 0.13 75);
--mira-accent-soft: oklch(0.78 0.13 75 / 0.16);
--mira-accent-line: oklch(0.78 0.13 75 / 0.4);
--mira-conflict: oklch(0.72 0.14 30);
--mira-conflict-soft: oklch(0.72 0.14 30 / 0.13);
--mira-src-logseq: oklch(0.75 0.08 250);
--mira-src-mobile: oklch(0.78 0.07 160);
--mira-src-tg: oklch(0.78 0.08 220);
--mira-src-rss: oklch(0.78 0.08 30);
--mira-src-ai: oklch(0.82 0.10 95);
--mira-radius-sm: 6px;
--mira-radius: 10px;
--mira-radius-lg: 14px;
--mira-font-zh: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif;
--mira-font-en: "Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mira-font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace;
background: var(--mira-bg-0);
color: var(--mira-text-1);
font-family: var(--mira-font-zh);
/* 不啟用 palt:保留中文標點全寬,避免標點看起來只有半寬 */
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
letter-spacing: 0.01em;
line-height: 1.65;
min-height: calc(100vh - 73px);
}
.mira-app .mira-num,
.mira-app .mira-en {
font-family: var(--mira-font-en);
letter-spacing: 0;
}
.mira-app .mira-content {
max-width: 920px;
margin: 0 auto;
padding: 24px 16px 80px;
}
@media (max-width: 640px) {
.mira-app .mira-content {
padding: 12px 0 60px;
}
}
/* ── card 卡片 ── */
.mira-app .mira-card {
background: var(--mira-bg-1);
border: 1px solid var(--mira-line);
border-radius: var(--mira-radius);
margin-bottom: 12px;
/* 不要 overflow: hidden — 會 clip ⋮ menu dropdown */
position: relative;
}
@media (max-width: 640px) {
.mira-app .mira-card {
border-radius: 0;
border-left: none;
border-right: none;
margin-bottom: 8px;
}
}
/* ── composer 寫貼文 ── */
.mira-app .mira-composer-card {
padding: 14px 16px;
}
.mira-app .mira-composer-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.mira-app .mira-composer-textarea {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: inherit;
font-size: 16px;
line-height: 1.65;
color: var(--mira-text-1);
padding: 8px 0;
}
.mira-app .mira-composer-textarea::placeholder {
color: var(--mira-text-3);
}
.mira-app .mira-composer-actions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 10px;
margin-top: 8px;
border-top: 1px solid var(--mira-line-soft);
margin-left: 52px;
flex-wrap: wrap;
}
/* ── avatar ── */
.mira-app .mira-avatar {
border-radius: 50%;
flex-shrink: 0;
background: var(--mira-bg-2);
object-fit: cover;
}
.mira-app .mira-avatar-fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--mira-bg-2), var(--mira-bg-3));
color: var(--mira-text-1);
font-weight: 600;
font-family: var(--mira-font-en);
}
/* ── post 貼文 ── */
.mira-app .mira-post {
display: flex;
flex-direction: column;
}
.mira-app .mira-post-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px 10px;
}
.mira-app .mira-post-author {
flex: 1;
min-width: 0;
}
.mira-app .mira-post-name {
font-size: 14.5px;
font-weight: 600;
color: var(--mira-text-1);
line-height: 1.3;
}
.mira-app .mira-post-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mira-text-3);
font-family: var(--mira-font-en);
margin-top: 2px;
}
/* ── post body 內文 ── */
.mira-app .mira-post-body {
padding: 0 16px 12px;
}
.mira-app .mira-post-content {
font-size: 15px;
line-height: 1.7;
color: var(--mira-text-1);
word-break: break-word;
white-space: normal;
}
.mira-app .mira-post-content-full {
/* 展開模式:顯示每個 sub-block 為獨立行,可編輯 */
font-size: 14.5px;
}
/* react-markdown render 樣式 */
.mira-app .mira-md > *:first-child { margin-top: 0; }
.mira-app .mira-md > *:last-child { margin-bottom: 0; }
.mira-app .mira-md p {
margin: 0 0 8px;
line-height: 1.7;
}
.mira-app .mira-md h1,
.mira-app .mira-md h2,
.mira-app .mira-md h3,
.mira-app .mira-md h4,
.mira-app .mira-md h5,
.mira-app .mira-md h6 {
margin: 14px 0 6px;
font-weight: 700;
color: var(--mira-text-1);
line-height: 1.3;
}
.mira-app .mira-md h1 { font-size: 1.5em; }
.mira-app .mira-md h2 { font-size: 1.3em; }
.mira-app .mira-md h3 { font-size: 1.15em; font-weight: 600; }
.mira-app .mira-md h4,
.mira-app .mira-md h5,
.mira-app .mira-md h6 { font-size: 1em; font-weight: 600; }
.mira-app .mira-md ul,
.mira-app .mira-md ol {
margin: 4px 0 8px;
padding-left: 22px;
}
.mira-app .mira-md ul {
list-style: disc;
}
.mira-app .mira-md ul ul {
list-style: circle;
}
.mira-app .mira-md ul ul ul {
list-style: square;
}
.mira-app .mira-md ol {
list-style: decimal;
}
.mira-app .mira-md ul ul,
.mira-app .mira-md ul ol,
.mira-app .mira-md ol ul,
.mira-app .mira-md ol ol {
margin: 4px 0;
}
.mira-app .mira-md li {
margin: 2px 0;
line-height: 1.7;
}
/* GFM task list 的 li 不顯示 bulletcheckbox 取代)*/
.mira-app .mira-md li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -22px;
}
.mira-app .mira-md li > p {
margin: 0;
}
.mira-app .mira-md blockquote {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid var(--mira-line);
color: var(--mira-text-2);
background: var(--mira-bg-2);
border-radius: 0 4px 4px 0;
}
.mira-app .mira-md blockquote p:last-child { margin-bottom: 0; }
.mira-app .mira-md hr {
border: none;
border-top: 1px solid var(--mira-line);
margin: 16px 0;
}
.mira-app .mira-md code {
font-family: var(--mira-font-mono);
font-size: 0.9em;
background: var(--mira-bg-2);
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--mira-line-soft);
}
.mira-app .mira-md pre {
margin: 8px 0;
padding: 12px;
background: var(--mira-bg-2);
border: 1px solid var(--mira-line-soft);
border-radius: 6px;
overflow-x: auto;
}
.mira-app .mira-md pre code {
background: transparent;
border: none;
padding: 0;
font-size: 0.85em;
line-height: 1.6;
}
.mira-app .mira-md table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
font-size: 0.95em;
}
.mira-app .mira-md th,
.mira-app .mira-md td {
border: 1px solid var(--mira-line);
padding: 6px 10px;
text-align: left;
}
.mira-app .mira-md th {
background: var(--mira-bg-2);
color: var(--mira-text-1);
font-weight: 600;
}
.mira-app .mira-md tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
.mira-app .mira-md strong { color: var(--mira-text-1); font-weight: 700; }
.mira-app .mira-md em { font-style: italic; }
.mira-app .mira-md del { color: var(--mira-text-3); }
/* GFM task list */
.mira-app .mira-md input[type="checkbox"] {
margin-right: 6px;
vertical-align: middle;
}
/* ── show more ── */
.mira-app .mira-show-more {
background: transparent;
border: none;
color: var(--mira-accent);
font-size: 14px;
cursor: pointer;
padding: 4px 0;
margin-top: 4px;
font-family: inherit;
}
.mira-app .mira-show-more:hover {
text-decoration: underline;
}
/* ── footer 互動列 ── */
.mira-app .mira-post-footer {
display: flex;
align-items: center;
border-top: 1px solid var(--mira-line-soft);
padding: 4px 8px;
margin: 0 8px;
}
.mira-app .mira-action-btn {
background: transparent;
border: none;
color: var(--mira-text-2);
font-size: 13.5px;
font-family: inherit;
padding: 8px 14px;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.12s, color 0.12s;
}
.mira-app .mira-action-btn:hover,
.mira-app .mira-action-btn:active {
background: var(--mira-bg-2);
color: var(--mira-text-1);
}
.mira-app .mira-action-count {
font-size: 12px;
color: var(--mira-text-3);
font-family: var(--mira-font-en);
}
/* ── replies 對整篇的留言 ── */
.mira-app .mira-post-replies {
background: var(--mira-bg-0);
padding: 8px 16px;
border-top: 1px solid var(--mira-line-soft);
display: flex;
flex-direction: column;
gap: 6px;
}
.mira-app .mira-show-replies {
background: transparent;
border: none;
color: var(--mira-text-2);
font-size: 13px;
cursor: pointer;
padding: 6px 4px;
text-align: left;
font-family: inherit;
font-weight: 500;
}
.mira-app .mira-show-replies:hover {
text-decoration: underline;
}
.mira-app .mira-reply-composer {
display: flex;
gap: 10px;
padding: 10px 16px;
border-top: 1px solid var(--mira-line-soft);
background: var(--mira-bg-0);
align-items: flex-start;
}
/* ── reply line ── */
.mira-app .mira-reply-line {
margin-bottom: 6px;
padding: 8px 10px;
background: var(--mira-bg-1);
border-radius: 16px;
display: flex;
align-items: flex-start;
gap: 8px;
position: relative;
}
.mira-app .mira-reply-line.is-ai {
background: var(--mira-accent-soft);
}
/* reply line 內的 ⋮ 永遠顯示(取代 hover only*/
.mira-app .mira-reply-line .mira-menu-wrap {
opacity: 1;
}
.mira-app .mira-reply-icon {
font-size: 13px;
color: var(--mira-text-2);
flex-shrink: 0;
margin-top: 2px;
}
.mira-app .mira-reply-line.is-ai .mira-reply-icon {
color: var(--mira-accent);
}
.mira-app .mira-reply-content {
font-size: 13.5px;
line-height: 1.6;
color: var(--mira-text-1);
word-break: break-word;
white-space: normal;
}
.mira-app .mira-reply-nested {
margin-left: 20px;
margin-top: 4px;
padding-left: 8px;
border-left: 2px solid var(--mira-line-soft);
}
/* ── source tag ── */
.mira-app .src-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
font-family: var(--mira-font-en);
letter-spacing: 0.04em;
background: var(--mira-bg-2);
color: var(--mira-text-2);
border: 1px solid var(--mira-line);
}
.mira-app .src-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mira-text-3);
}
.mira-app .src-tag.logseq .src-dot { background: var(--mira-src-logseq); }
.mira-app .src-tag.mobile .src-dot { background: var(--mira-src-mobile); }
.mira-app .src-tag.tg .src-dot { background: var(--mira-src-tg); }
.mira-app .src-tag.rss .src-dot { background: var(--mira-src-rss); }
.mira-app .src-tag.ai .src-dot { background: var(--mira-src-ai); }
/* ── wiki link ── */
.mira-app .wiki-link {
color: var(--mira-accent);
cursor: pointer;
text-decoration: none;
border-bottom: 1px dashed var(--mira-accent-line);
padding-bottom: 1px;
}
.mira-app .wiki-link:hover {
background: var(--mira-accent-soft);
}
/* ── empty state ── */
.mira-app .empty-state {
text-align: center;
padding: 80px 20px;
color: var(--mira-text-2);
font-size: 13px;
}
/* ── icon button ── */
.mira-app .mira-icon-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 50%;
cursor: pointer;
color: var(--mira-text-2);
background: transparent;
border: none;
font-size: 14px;
line-height: 1;
padding: 0;
transition: background 0.12s, color 0.12s;
flex-shrink: 0;
}
.mira-app .mira-icon-btn:hover,
.mira-app .mira-icon-btn:active {
background: var(--mira-bg-2);
color: var(--mira-text-1);
}
.mira-app .mira-icon-btn-vertical {
font-size: 20px;
font-weight: 700;
letter-spacing: 0;
}
/* ── ⋮ menu (dropdown) ── */
.mira-app .mira-menu-wrap {
position: relative;
}
.mira-app .mira-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
min-width: 160px;
background: var(--mira-bg-1);
border: 1px solid var(--mira-line);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
padding: 4px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 1px;
}
.mira-app .mira-menu-item {
background: transparent;
border: none;
text-align: left;
padding: 10px 14px;
border-radius: 5px;
color: var(--mira-text-1);
font-size: 14px;
cursor: pointer;
font-family: inherit;
transition: background 0.12s;
}
.mira-app .mira-menu-item:hover,
.mira-app .mira-menu-item:active {
background: var(--mira-bg-2);
}
.mira-app .mira-menu-item.danger {
color: var(--mira-conflict);
}
.mira-app .mira-menu-item.danger:hover {
background: var(--mira-conflict-soft);
}
/* ── block-lineFullContent 模式)── */
.mira-app .mira-block-line {
position: relative;
}
.mira-app .mira-block-line .mira-menu-wrap {
opacity: 0;
transition: opacity 0.12s;
}
.mira-app .mira-block-line:hover .mira-menu-wrap {
opacity: 1;
}
@media (hover: none) {
/* 觸控裝置一律顯示 ⋮ */
.mira-app .mira-block-line .mira-menu-wrap {
opacity: 1;
}
}
.mira-app .mira-menu-wrap:has(.mira-menu) { opacity: 1 !important; }
/* ── edit textarea ── */
.mira-app .mira-edit-textarea {
width: 100%;
background: var(--mira-bg-0);
border: 1px solid var(--mira-accent-line);
border-radius: 8px;
padding: 10px 12px;
font-family: inherit;
font-size: 14px;
line-height: 1.7;
color: var(--mira-text-1);
resize: vertical;
outline: none;
}
.mira-app .mira-edit-textarea:focus {
border-color: var(--mira-accent);
}
.mira-app .mira-edit-textarea::placeholder {
color: var(--mira-text-3);
}
.mira-app .mira-edit-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
}
/* ── markdown toolbar ── */
.mira-app .mira-md-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: var(--mira-bg-2);
border: 1px solid var(--mira-line);
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
flex-wrap: wrap;
}
/* compact 模式:給 PostComposer 的 actions 列 inline 用 */
.mira-app .mira-md-toolbar.compact {
padding: 0;
background: transparent;
border: none;
gap: 2px;
}
.mira-app .mira-md-btn {
background: transparent;
border: none;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 5px;
color: var(--mira-text-2);
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: background 0.12s, color 0.12s;
padding: 0;
line-height: 1;
}
.mira-app .mira-md-btn:hover,
.mira-app .mira-md-btn:active {
background: var(--mira-bg-3);
color: var(--mira-text-1);
}
.mira-app .mira-md-btn em {
font-style: italic;
font-family: serif;
}
.mira-app .mira-md-hint {
margin-left: auto;
font-size: 11px;
color: var(--mira-text-3);
font-family: var(--mira-font-en);
}
/* toolbar 之後的 textarea 接續成一塊 */
.mira-app .mira-md-toolbar + .mira-edit-textarea {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: none;
}
/* ── kbd 提示 ── */
.mira-app .mira-kbd {
font-size: 11px;
color: var(--mira-text-3);
font-family: var(--mira-font-en);
}
/* ── messages ── */
.mira-app .mira-msg-error {
font-size: 12px;
color: var(--mira-conflict);
}
.mira-app .mira-error {
padding: 16px;
color: var(--mira-conflict);
font-size: 14px;
}
/* ── buttons ── */
.mira-app .mira-btn-primary {
background: var(--mira-accent);
border: none;
color: var(--mira-bg-0);
font-size: 13px;
padding: 8px 18px;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
font-family: inherit;
transition: opacity 0.12s, background 0.12s;
}
.mira-app .mira-btn-primary:hover {
opacity: 0.9;
}
.mira-app .mira-btn-primary:disabled,
.mira-app .mira-btn-primary.disabled {
background: var(--mira-bg-3);
color: var(--mira-text-3);
cursor: not-allowed;
opacity: 1;
}
.mira-app .mira-btn-ghost {
background: transparent;
border: 1px solid var(--mira-line);
color: var(--mira-text-2);
font-size: 12px;
padding: 6px 14px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
}
.mira-app .mira-btn-ghost:hover {
background: var(--mira-bg-2);
color: var(--mira-text-1);
}
/* ── Mira 思考中(AI reply pending)── */
.mira-app .mira-thinking {
opacity: 0.7;
}
.mira-app .mira-thinking-dots::after {
content: '⋯';
display: inline-block;
animation: mira-think-dots 1.4s infinite;
}
@keyframes mira-think-dots {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
/* ── skeleton loader ── */
.mira-app .mira-skel {
height: 60px;
background: linear-gradient(
90deg,
var(--mira-bg-1) 0%,
var(--mira-bg-2) 50%,
var(--mira-bg-1) 100%
);
background-size: 200% 100%;
animation: mira-skel-shimmer 1.4s ease-in-out infinite;
border-radius: 6px;
}
@keyframes mira-skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── rel time ── */
.mira-app .mira-rel-time {
cursor: default;
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
// Mira 首頁(Profile / KM Hub
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2
// 這頁是 leo 的 KM 首頁,列出所有同層子頁的入口
// 河道 / Wiki 是同層子頁,不是父子關係
import Link from 'next/link';
import './mira.css';
type Entry = {
href: string;
emoji: string;
title: string;
desc: string;
status?: 'live' | 'wip' | 'planned';
};
const ENTRIES: Entry[] = [
{
href: '/mira/feed',
emoji: '🌊',
title: '河道',
desc: '時間軸式貼文流,所有 source 進來的內容都在這',
status: 'live',
},
{
href: '/mira/wiki',
emoji: '📚',
title: 'Wiki',
desc: 'leo 的個人觀點累積(Karpathy LLM Wiki 風格)',
status: 'live',
},
{
href: '/mira/plan',
emoji: '📋',
title: '計劃',
desc: 'Task 列表 + 狀態切換 + workflow 觸發',
status: 'planned',
},
{
href: '/mira/dissent',
emoji: '⚔️',
title: '異見牆',
desc: 'Triplet 列表(含 ai-idea / 矛盾標記)',
status: 'planned',
},
];
export default function MiraHubPage() {
return (
<main className="mira-app">
<div className="mira-content">
<header style={{ padding: '32px 0 24px' }}>
<h1 style={{ fontSize: 32, fontWeight: 700, color: '#fff', margin: 0 }}>
🦔 Mira
</h1>
<p style={{ color: '#888', fontSize: 15, marginTop: 6 }}>
leo KM
</p>
</header>
<div style={{ display: 'grid', gap: 12, paddingBottom: 40 }}>
{ENTRIES.map((e) => (
<EntryCard key={e.href} entry={e} />
))}
</div>
</div>
</main>
);
}
function EntryCard({ entry }: { entry: Entry }) {
const isClickable = entry.status === 'live';
const card = (
<div
style={{
padding: '18px 20px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: 8,
opacity: isClickable ? 1 : 0.5,
cursor: isClickable ? 'pointer' : 'not-allowed',
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => {
if (isClickable) e.currentTarget.style.borderColor = '#444';
}}
onMouseLeave={(e) => {
if (isClickable) e.currentTarget.style.borderColor = '#2a2a2a';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
<span style={{ fontSize: 24 }}>{entry.emoji}</span>
<span style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}>
{entry.title}
</span>
{entry.status === 'planned' && (
<span
style={{
fontSize: 11,
padding: '2px 8px',
background: '#2a2a3a',
color: '#aab',
borderRadius: 3,
}}
>
</span>
)}
</div>
<div style={{ color: '#888', fontSize: 14, marginLeft: 36 }}>{entry.desc}</div>
</div>
);
return isClickable ? (
<Link href={entry.href} style={{ textDecoration: 'none' }}>
{card}
</Link>
) : (
card
);
}

Some files were not shown because too many files have changed in this diff Show More