diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml index 13f867e3..9da0cde0 100644 --- a/.github/workflows/platform-smoke.yml +++ b/.github/workflows/platform-smoke.yml @@ -10,7 +10,7 @@ on: - "scripts/native-deps-smoke.mjs" - "scripts/verify-libsql-vector.mjs" - "scripts/smoke-test.mjs" - - "scripts/copy-web-assets.mjs" + - "web/**" - ".github/workflows/platform-smoke.yml" workflow_dispatch: @@ -40,6 +40,10 @@ jobs: - name: Install source dependencies run: bun install + - name: Install web dependencies + working-directory: web + run: bun install + - name: Typecheck source run: bun run typecheck diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index bef5050b..58aa0da2 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -2,6 +2,8 @@ **Scope:** `tickernelz/opencode-mem` @ commit `0998c69` (main). Local HTTP API, CORS policy, secret handling, SQLite layer, web UI, config/JSONC parsing, migration/cleanup services, dependencies. Read-only audit followed by targeted fixes for the three highest-severity findings. +**Note (GUI rewrite):** The Memory Explorer UI is now a Vite-bundled React 19 app under `web/` (served from `dist/web`). The previous CDN-loaded vanilla UI (`src/web/*`) and the interim Svelte UI are gone. Findings 3–4 below describe the pre-rewrite UI; current mitigations are noted under each. + ## Findings ### 1. CRITICAL — Path traversal via unauthenticated `containerTag` (fixed) @@ -16,21 +18,21 @@ A request such as `POST /api/memories {"content":"x","containerTag":"project_x_. CORS was being used as a substitute for authentication in `src/services/cors.ts`, but `isAllowedBrowserOrigin()` returned `true` whenever no `Origin` header was present — which is the case for `curl`, other local processes, and any non-browser client. Every `/api/*` handler (read/write/delete memories, full user-profile CRUD, migrations) had no session/token check at all. If `webServerHost` is set to `0.0.0.0` (a documented config option), this is reachable from the whole LAN with no auth. -**Fix:** added `src/services/auth-token.ts` — a random 256-bit token generated on first run, persisted to `~/.opencode-mem/.auth-token` (mode `0600`), required via the `x-opencode-mem-token` header on every `/api/*` request in both `web-server.ts` and `web-server-worker.ts`. The token is injected into the server-rendered `index.html` (`window.__OPENCODE_MEM_TOKEN__`) so the bundled web UI keeps working transparently (`app.js:fetchAPI` now sends the header), while a malicious cross-origin web page cannot read it (opaque/no-cors responses). The internal `checkServerAvailable()` health-check call was updated to send the token too, so the existing takeover/health-check logic still works. +**Fix:** added `src/services/auth-token.ts` — a random 256-bit token generated on first run, persisted to `~/.opencode-mem/.auth-token` (mode `0600`), required via the `x-opencode-mem-token` header on every `/api/*` request in `web-server.ts`. The token is injected into the server-rendered `index.html` (`window.__OPENCODE_MEM_TOKEN__`) so the bundled web UI keeps working transparently (`web/src/lib/api.ts` sends the header), while a malicious cross-origin web page cannot read it (opaque/no-cors responses). The internal `checkServerAvailable()` health-check call was updated to send the token too, so the existing takeover/health-check logic still works. This does not fully replace a "don't expose to `0.0.0.0` without more" warning — see recommendation below — but it closes the CSRF-style "any web page or generic local process can drive the API" gap the CORS check alone did not. -### 3. HIGH — Stored XSS via unescaped `profile.displayName` (fixed) +### 3. HIGH — Stored XSS via unescaped `profile.displayName` (fixed; superseded by React UI) -`renderUserProfile()` in `src/web/app.js` injected `profile.displayName` into `innerHTML` with no `escapeHtml()` call, unlike every other user-influenced field rendered elsewhere in the same file. `displayName` originates from `userNameOverride` in a project's `.opencode/opencode-mem.jsonc`, which is loaded automatically, with no confirmation, whenever that project is opened — so a malicious repo can plant a payload that later executes in the local Web UI when the profile tab is viewed, with same-origin `fetch()` access to the (now-authenticated, but still same-origin) API. +Historically, `renderUserProfile()` in the vanilla UI injected `profile.displayName` into `innerHTML` with no escaping. `displayName` originates from `userNameOverride` in a project's `.opencode/opencode-mem.jsonc`. -The existing `tests/web-memorytype-xss.test.ts` covers a different field (`memoryType`) that was already escaped correctly; it did not cover this sink. +**Current mitigation:** React JSX text nodes auto-escape; shared `escapeHtml()` in `web/src/lib/html.ts` covers any HTML string sinks. Markdown content goes through DOMPurify. Regression tests: `tests/web-userprofile-xss.test.ts`, `tests/web-memorytype-xss.test.ts`. -**Fix:** wrapped the value in `escapeHtml()`. Regression test: `tests/web-userprofile-xss.test.ts` (fails against the pre-fix code, passes after). +### 4. MEDIUM — Unpinned, non-SRI third-party scripts (fixed by bundling) -### 4. MEDIUM — Unpinned, non-SRI third-party scripts (not fixed — recommendation only) +Historically, the vanilla `index.html` loaded scripts from CDNs (`unpkg` / `jsdelivr`) without SRI. -`src/web/index.html` loads `lucide@latest` and `jsonrepair@latest` (no version pin) plus two pinned-but-no-`integrity` scripts from `unpkg.com`/`cdn.jsdelivr.net`. Recommend pinning exact versions and adding Subresource Integrity hashes, or self-hosting alongside the already-vendored `app.js`/`i18n.js`/`styles.css`. +**Current mitigation:** UI dependencies are installed and bundled by Vite into `dist/web` (no runtime CDN scripts). ### 5. LOW / informational — Gemini API key in URL query string (not fixed) @@ -46,11 +48,9 @@ The existing `tests/web-memorytype-xss.test.ts` covers a different field (`memor ## Remaining recommendations (not implemented in this patch) -- Pin and add SRI to the CDN-loaded scripts in `src/web/index.html` (Finding 4). - Consider warning (or refusing) at startup when `webServerHost` is set to a non-loopback address without additional network-level protection, since the token-based fix here raises the bar but a determined local/LAN attacker who can read `~/.opencode-mem/.auth-token` (or intercept the injected ` - - - - - - -
- - -
-
-

┌─ OPENCODE MEMORY EXPLORER ─┐

- - - - -
-
- Total: 0 - -
-
- -
- - -
- -
-
- - -
- -
- - - -
- -
- - -
- - -
- - - -
-
-

└─ PROJECT MEMORIES (0) ──

- -
- -
-
Initializing...
-
- - -
- - - -
-

└─ ADD NEW MEMORY ──

-
-
-
- - -
- -
- - -
- -
- - -
-
- -
- - -
- - -
-
-
- - - - - - - - - - - - - - - - diff --git a/src/web/styles.css b/src/web/styles.css deleted file mode 100644 index 4e71d14d..00000000 --- a/src/web/styles.css +++ /dev/null @@ -1,2310 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; - background: #0a0a0a; - color: #e0e0e0; - line-height: 1.6; - padding: 20px; - font-size: 14px; -} - -.container { - max-width: 1400px; - margin: 0 auto; -} - -.header { - border: 1px solid #333; - padding: 20px; - margin-bottom: 20px; - background: #111; -} - -.header-top { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.title { - color: #00ff00; - font-size: 18px; - font-weight: normal; - letter-spacing: 1px; - margin: 0; -} - -.github-link { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 8px; - border: 1px solid #333; - background: #0a0a0a; - color: #00ff00; - text-decoration: none; - transition: all 0.2s; -} - -.github-link:hover { - background: #00ff00; - color: #0a0a0a; - border-color: #00ff00; -} - -.github-link .icon { - width: 20px; - height: 20px; -} - -.stats-bar { - display: flex; - gap: 20px; - font-size: 12px; - color: #888; - margin-top: 10px; -} - -.stats-bar span { - padding: 4px 8px; - border: 1px solid #333; -} - -.controls { - border: 1px solid #333; - padding: 20px; - margin-bottom: 20px; - background: #111; - display: flex; - flex-wrap: wrap; - gap: 15px; - align-items: flex-end; -} - -.filter-group, -.search-group { - display: flex; - flex-direction: column; - gap: 5px; -} - -.filter-group label { - font-size: 11px; - color: #888; - text-transform: uppercase; -} - -select, -input[type="text"], -textarea { - background: #0a0a0a; - border: 1px solid #333; - color: #e0e0e0; - padding: 8px 12px; - font-family: inherit; - font-size: 13px; - outline: none; - transition: border-color 0.2s; -} - -select:focus, -input[type="text"]:focus, -textarea:focus { - border-color: #00ff00; -} - -select { - cursor: pointer; - min-width: 150px; -} - -.search-group { - flex: 1; - min-width: 300px; -} - -.search-group input { - width: 100%; -} - -.search-group { - flex-direction: row; - align-items: stretch; -} - -.search-group input { - flex: 1; - border-right: none; -} - -button { - background: #0a0a0a; - border: 1px solid #333; - color: #00ff00; - padding: 8px 16px; - font-family: inherit; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -button:hover:not(:disabled) { - background: #00ff00; - color: #0a0a0a; - border-color: #00ff00; -} - -button:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.btn-primary { - background: #00ff00; - color: #0a0a0a; - border-color: #00ff00; -} - -.btn-primary:hover { - background: #00cc00; - border-color: #00cc00; -} - -.btn-secondary { - background: #333; - color: #e0e0e0; -} - -.btn-secondary:hover { - background: #444; -} - -.bulk-actions { - display: flex; - gap: 10px; - align-items: center; - padding: 10px; - border: 1px solid #ff4444; - background: rgba(255, 68, 68, 0.1); -} - -.bulk-actions span { - color: #ff4444; - font-size: 12px; -} - -.bulk-actions button { - border-color: #ff4444; - color: #ff4444; -} - -.bulk-actions button:hover { - background: #ff4444; - color: #0a0a0a; -} - -.maintenance-actions { - display: flex; - gap: 10px; -} - -.btn-maintenance { - border-color: #ffaa00; - color: #ffaa00; - padding: 8px 16px; - font-size: 12px; -} - -.btn-maintenance:hover { - background: #ffaa00; - color: #0a0a0a; -} - -.migration-section { - border: 2px solid #ff4444; - padding: 20px; - margin-bottom: 20px; - background: rgba(255, 68, 68, 0.05); - border-radius: 4px; -} - -.migration-warning { - display: flex; - align-items: center; - gap: 10px; - color: #ff4444; - font-weight: bold; - margin-bottom: 15px; - font-size: 14px; -} - -.migration-warning .icon { - width: 20px; - height: 20px; - flex-shrink: 0; -} - -.migration-info { - margin-bottom: 15px; - padding: 12px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid #333; - border-radius: 4px; -} - -.migration-info label { - display: flex; - align-items: center; - gap: 10px; - color: #e0e0e0; - font-size: 13px; - cursor: pointer; - user-select: none; -} - -.migration-info input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; - accent-color: #ff4444; - flex-shrink: 0; -} - -.migration-actions { - display: flex; - gap: 12px; -} - -.btn-migration { - flex: 1; - padding: 12px 16px; - background: #0a0a0a; - border: 1px solid #ff4444; - color: #ff4444; - cursor: pointer; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - font-size: 13px; - font-weight: bold; - transition: all 0.2s; -} - -.btn-migration:hover:not(:disabled) { - background: #ff4444; - color: #0a0a0a; - border-color: #ff4444; -} - -.btn-migration:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.btn-migration .icon { - width: 16px; - height: 16px; -} - -.combined-card { - border: 1px solid #333; - margin-bottom: 20px; - background: #0a0a0a; -} - -.combined-card.selected { - border-color: #00ff00; -} - -.combined-prompt-section { - padding: 15px; - background: rgba(0, 204, 255, 0.05); - border-bottom: 1px dashed #333; -} - -.combined-header { - display: flex; - justify-content: space-between; - margin-bottom: 10px; -} - -.combined-divider { - display: flex; - justify-content: center; - align-items: center; - height: 20px; - background: #111; - border-top: 1px solid #222; - border-bottom: 1px solid #222; -} - -.divider-icon { - width: 14px; - height: 14px; - color: #666; -} - -.combined-memory-section { - padding: 15px; - background: rgba(0, 255, 0, 0.02); -} - -.badge-memory { - color: #00ff00; - border-color: #00ff00; - font-weight: bold; -} - -.memories-section { - border: 1px solid #333; - padding: 20px; - margin-bottom: 20px; - background: #111; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - padding-bottom: 10px; - border-bottom: 1px solid #333; -} - -.section-header h2 { - color: #00ff00; - font-size: 14px; - font-weight: normal; -} - -.pagination { - display: flex; - gap: 10px; - align-items: center; -} - -.pagination span { - font-size: 12px; - color: #888; - min-width: 100px; - text-align: center; -} - -.pagination button { - padding: 4px 12px; - font-size: 12px; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 36px; -} - -#pagination-bottom { - margin-top: 15px; - padding-top: 15px; - border-top: 1px solid #333; - justify-content: center; -} - -.pagination-bar { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-top: 10px; - padding-top: 8px; - border-top: 1px solid var(--border, #333); -} - -.pagination-bar .pagination-info { - font-size: 11px; - color: var(--text-secondary, #888); - margin-right: 4px; -} - -.pagination-bar .btn-page { - background: var(--bg-secondary, #2a2a2a); - border: 1px solid var(--border, #444); - color: var(--text-primary, #eee); - font-size: 12px; - padding: 2px 8px; - border-radius: 4px; - cursor: pointer; -} - -.pagination-bar .btn-page:hover:not([disabled]) { - background: var(--accent, #4a90d9); - color: #fff; -} - -.pagination-bar .btn-page[disabled] { - opacity: 0.35; - cursor: default; -} - -.pagination-bar .btn-page.active { - background: var(--accent, #4a90d9); - color: #fff; - border-color: var(--accent, #4a90d9); -} - -.memories-list { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 200px; - max-height: 600px; - overflow-y: auto; - padding-right: 10px; -} - -.memories-list::-webkit-scrollbar { - width: 8px; -} - -.memories-list::-webkit-scrollbar-track { - background: #0a0a0a; -} - -.memories-list::-webkit-scrollbar-thumb { - background: #333; - border-radius: 4px; -} - -.memories-list::-webkit-scrollbar-thumb:hover { - background: #444; -} - -.memory-card { - border: 1px solid #333; - padding: 15px; - background: #0a0a0a; - transition: border-color 0.2s; - position: relative; -} - -.memory-card:hover { - border-color: #00ff00; -} - -.memory-card.selected { - border-color: #00ff00; - background: rgba(0, 255, 0, 0.05); -} - -.tags-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 8px 0; -} - -.tag-badge { - background: rgba(0, 255, 0, 0.1); - color: #00ff00; - border: 1px solid rgba(0, 255, 0, 0.3); - padding: 2px 8px; - font-size: 11px; - border-radius: 12px; - white-space: nowrap; -} - -.migration-progress-body { - padding: 20px; -} - -.progress-bar-container { - width: 100%; - height: 8px; - background: #222; - border-radius: 4px; - margin: 15px 0; - overflow: hidden; -} - -.progress-bar { - height: 100%; - background: #00ff00; - transition: width 0.3s ease; -} - -.migration-note { - font-size: 12px; - color: #888; - margin-top: 10px; -} - -#tag-migration-actions { - padding: 20px; - border-top: 1px solid #333; -} - -.memory-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 10px; - flex-wrap: wrap; - gap: 10px; -} - -.memory-meta { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.memory-display-name { - color: #00ff00; - font-weight: bold; - font-size: 13px; -} - -.memory-subtitle { - color: #888; - font-size: 11px; - margin-left: 5px; -} - -.memory-checkbox { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: #00ff00; -} - -.badge { - font-size: 10px; - padding: 3px 8px; - border: 1px solid #333; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.badge-user { - color: #00ccff; - border-color: #00ccff; -} - -.badge-project { - color: #ffaa00; - border-color: #ffaa00; -} - -.badge-type { - color: #888; -} - -.badge-pinned { - color: #ff00ff; - border-color: #ff00ff; - font-weight: bold; -} - -.memory-card.pinned { - border-left: 3px solid #ff00ff; -} - -.memory-content { - color: #e0e0e0; - margin: 10px 0; - word-wrap: break-word; -} - -.memory-footer { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid #222; - font-size: 11px; - color: #666; -} - -.memory-actions { - display: flex; - gap: 5px; -} - -.memory-actions button { - padding: 4px 10px; - font-size: 11px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; -} - -.btn-edit { - border-color: #00ccff; - color: #00ccff; -} - -.btn-edit:hover { - background: #00ccff; - color: #0a0a0a; -} - -.btn-delete { - border-color: #ff4444; - color: #ff4444; -} - -.btn-delete:hover { - background: #ff4444; - color: #0a0a0a; -} - -.btn-pin { - border-color: #ff00ff; - color: #ff00ff; - font-size: 14px; - padding: 4px 8px; - min-width: 32px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.btn-pin:hover { - background: #ff00ff; - color: #0a0a0a; -} - -.btn-pin.pinned { - background: #ff00ff; - color: #0a0a0a; -} - -.btn-pin.pinned .icon { - fill: #0a0a0a; -} - -.btn-edit, -.btn-delete { - min-width: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; -} - -.similarity-score { - color: #00ff00; - font-weight: bold; -} - -.loading, -.empty-state, -.error-state { - text-align: center; - padding: 40px; - color: #666; - font-size: 13px; -} - -.error-state { - color: #ff4444; -} - -.add-section { - border: 1px solid #333; - padding: 20px; - background: #111; -} - -.add-section h2 { - color: #00ff00; - font-size: 14px; - font-weight: normal; - margin-bottom: 15px; -} - -.form-row { - display: flex; - gap: 15px; - margin-bottom: 15px; - flex-wrap: wrap; -} - -.form-group { - display: flex; - flex-direction: column; - gap: 5px; - flex: 1; - min-width: 200px; -} - -.form-group label { - font-size: 11px; - color: #888; - text-transform: uppercase; -} - -textarea { - resize: vertical; - min-height: 80px; -} - -.modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-content { - background: #111; - border: 1px solid #00ff00; - padding: 0; - max-width: 600px; - width: 90%; - max-height: 80vh; - overflow-y: auto; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px; - border-bottom: 1px solid #333; -} - -.modal-header h3 { - color: #00ff00; - font-size: 16px; - font-weight: normal; -} - -.modal-close { - background: none; - border: 1px solid #ff4444; - color: #ff4444; - font-size: 20px; - padding: 0; - width: 30px; - height: 30px; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.modal-close .icon { - width: 18px; - height: 18px; -} - -.modal-close:hover { - color: #fff; - background: #ff4444; -} - -.modal form { - padding: 20px; -} - -.modal-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 20px; -} - -.toast { - position: fixed; - bottom: 20px; - right: 20px; - background: #111; - border: 1px solid #00ff00; - color: #e0e0e0; - padding: 15px 20px; - font-size: 13px; - z-index: 2000; - animation: slideIn 0.3s ease; - max-width: 400px; -} - -.toast.error { - border-color: #ff4444; -} - -.toast.success { - border-color: #00ff00; -} - -@keyframes slideIn { - from { - transform: translateX(400px); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -.hidden { - display: none !important; -} - -#refresh-indicator { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.icon { - width: 16px; - height: 16px; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - fill: none; - vertical-align: middle; - display: inline-block; -} - -.icon-sm { - width: 14px; - height: 14px; -} - -.icon-md { - width: 16px; - height: 16px; -} - -.icon-lg { - width: 20px; - height: 20px; -} - -.icon-spin { - animation: spin 1s linear infinite; -} - -.icon-filled { - fill: currentColor; -} - -button .icon { - margin-right: 4px; -} - -button:only-child .icon, -.btn-pin .icon, -.btn-edit .icon, -.btn-delete .icon, -.modal-close .icon, -#search-btn .icon, -#clear-search-btn .icon, -.pagination button .icon { - margin-right: 0; -} - -@media (max-width: 768px) { - body { - padding: 10px; - font-size: 12px; - } - - .header-top { - flex-direction: row; - justify-content: space-between; - align-items: center; - } - - .title { - font-size: 14px; - } - - .github-link { - padding: 6px; - } - - .github-link .icon { - width: 18px; - height: 18px; - } - - .controls { - flex-direction: column; - align-items: stretch; - } - - .filter-group, - .search-group { - width: 100%; - } - - .section-header { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .pagination { - width: 100%; - justify-content: center; - } - - .form-row { - flex-direction: column; - } - - .memory-header { - flex-direction: column; - gap: 10px; - } - - .memory-footer { - flex-direction: column; - gap: 10px; - align-items: flex-start; - } -} - -.scope-tabs { - display: flex; - gap: 0; - margin-bottom: 20px; - border: 1px solid #333; - background: #111; -} - -.tab-btn { - flex: 1; - padding: 15px 20px; - background: #0a0a0a; - border: none; - border-right: 1px solid #333; - color: #666; - font-size: 13px; - font-weight: bold; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: all 0.2s; - cursor: pointer; -} - -.tab-btn:last-child { - border-right: none; -} - -.tab-btn:hover { - background: #111; - color: #888; -} - -.tab-btn.active { - background: #00ff00; - color: #0a0a0a; - border-color: #00ff00; -} - -.tab-btn .icon { - width: 18px; - height: 18px; -} - -.badge-linked { - color: #ff00ff; - border-color: #ff00ff; - font-weight: bold; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.6; - } -} - -.link-indicator { - text-align: center; - padding: 8px; - margin-top: 10px; - border-top: 1px dashed #ff00ff; - color: #ff00ff; - font-size: 11px; - font-weight: bold; - letter-spacing: 1px; -} - -.prompt-card { - border: 1px solid #333; - border-left: 3px solid #00ccff; - padding: 15px; - background: rgba(0, 204, 255, 0.03); - transition: all 0.2s; - position: relative; -} - -.prompt-card:hover { - border-color: #00ccff; - background: rgba(0, 204, 255, 0.08); -} - -.prompt-card.selected { - border-color: #00ccff; - background: rgba(0, 204, 255, 0.1); -} - -.prompt-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(0, 204, 255, 0.2); -} - -.prompt-header .meta { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.badge-prompt { - color: #00ccff; - border-color: #00ccff; - font-weight: bold; - font-size: 10px; -} - -.prompt-content { - color: #ccc; - font-style: italic; - line-height: 1.6; - padding: 10px 0; - font-size: 13px; - word-wrap: break-word; -} - -.prompt-date { - font-size: 11px; - color: #666; -} - -.prompt-actions { - display: flex; - gap: 5px; -} - -.prompt-actions button { - padding: 4px 10px; - font-size: 11px; - white-space: nowrap; -} - -.memory-card { - border-left: 3px solid #00ff00; -} - -.markdown-content { - line-height: 1.8; - color: #e0e0e0; -} - -.markdown-content h1, -.markdown-content h2, -.markdown-content h3 { - color: #00ff00; - font-weight: bold; - margin: 16px 0 8px 0; - border-bottom: 1px solid #333; - padding-bottom: 4px; -} - -.markdown-content h1 { - font-size: 18px; -} -.markdown-content h2 { - font-size: 16px; -} -.markdown-content h3 { - font-size: 14px; -} - -.markdown-content p { - margin: 8px 0; -} - -.markdown-content ul, -.markdown-content ol { - margin: 8px 0; - padding-left: 24px; -} - -.markdown-content li { - margin: 4px 0; -} - -.markdown-content code { - background: #1a1a1a; - border: 1px solid #333; - padding: 2px 6px; - border-radius: 3px; - font-family: "JetBrains Mono", monospace; - font-size: 12px; - color: #00ccff; -} - -.markdown-content pre { - background: #1a1a1a; - border: 1px solid #333; - padding: 12px; - border-radius: 4px; - overflow-x: auto; - margin: 12px 0; -} - -.markdown-content pre code { - background: none; - border: none; - padding: 0; - color: #e0e0e0; -} - -.markdown-content blockquote { - border-left: 3px solid #00ff00; - padding-left: 12px; - margin: 12px 0; - color: #888; - font-style: italic; -} - -.markdown-content table { - border-collapse: collapse; - width: 100%; - margin: 12px 0; -} - -.markdown-content th, -.markdown-content td { - border: 1px solid #333; - padding: 8px 12px; - text-align: left; -} - -.markdown-content th { - background: #1a1a1a; - color: #00ff00; - font-weight: bold; -} - -.markdown-content a { - color: #00ccff; - text-decoration: none; - border-bottom: 1px dotted #00ccff; -} - -.markdown-content a:hover { - color: #00ff00; - border-bottom-color: #00ff00; -} - -.markdown-content strong { - color: #00ff00; - font-weight: bold; -} - -.markdown-content em { - color: #ffaa00; - font-style: italic; -} - -.markdown-content del { - color: #666; - text-decoration: line-through; -} - -.markdown-content hr { - border: none; - border-top: 1px solid #333; - margin: 16px 0; -} - -html { - scroll-behavior: smooth; -} - -button:focus-visible, -input:focus-visible, -select:focus-visible, -textarea:focus-visible { - outline: 2px solid #00ff00; - outline-offset: 2px; -} - -.memory-card::before, -.prompt-card::before { - content: ""; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(0, 255, 0, 0.05), transparent); - transition: left 0.5s; -} - -.memory-card:hover::before { - left: 100%; -} - -.prompt-card::before { - background: linear-gradient(90deg, transparent, rgba(0, 204, 255, 0.05), transparent); -} - -.prompt-card:hover::before { - left: 100%; -} - -.toast { - animation: slideInBounce 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); -} - -@keyframes slideInBounce { - 0% { - transform: translateX(400px); - opacity: 0; - } - 60% { - transform: translateX(-20px); - opacity: 1; - } - 80% { - transform: translateX(10px); - } - 100% { - transform: translateX(0); - } -} - -.profile-section { - border: 1px solid #333; - padding: 20px; - margin-bottom: 20px; - background: #111; -} - -.profile-content { - min-height: 300px; -} - -.profile-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 15px; - margin-bottom: 20px; - border: 1px solid #333; - background: #111; - border-left: 3px solid #00ccff; -} - -.profile-info h3 { - color: #00ccff; - font-size: 16px; - margin: 0 0 10px 0; - letter-spacing: 0.5px; -} - -.profile-stats { - display: flex; - gap: 15px; -} - -.stat-pill { - display: flex; - flex-direction: column; -} - -.stat-pill .label { - font-size: 9px; - color: #666; - text-transform: uppercase; -} - -.stat-pill .value { - font-size: 12px; - color: #ccc; - font-family: monospace; -} - -.dashboard-grid { - display: flex; - flex-direction: column; - gap: 20px; -} - -.dashboard-section { - background: #111; - border: 1px solid #333; - padding: 15px; - display: flex; - flex-direction: column; -} - -.dashboard-section.full-width { - width: 100%; -} - -.dashboard-section h4 { - color: #888; - font-size: 11px; - margin-bottom: 15px; - padding-bottom: 8px; - border-bottom: 1px solid #222; - display: flex; - align-items: center; - gap: 6px; - letter-spacing: 1px; -} - -.dashboard-section h4 .count { - background: #222; - color: #ccc; - padding: 1px 6px; - border-radius: 10px; - font-size: 9px; - margin-left: auto; -} - -.cards-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 10px; -} - -.compact-card { - background: #0a0a0a; - border: 1px solid #2a2a2a; - padding: 10px; - transition: all 0.2s ease; - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.compact-card:hover { - border-color: #444; - transform: translateY(-1px); -} - -.preference-card { - border-top: 2px solid #00ccff; -} - -.pattern-card { - border-top: 2px solid #ffaa00; -} - -.card-top { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 8px; -} - -.category-tag { - font-size: 9px; - text-transform: uppercase; - color: #666; - background: #161616; - padding: 2px 6px; - border-radius: 2px; - border: 1px solid #222; -} - -.confidence-ring { - font-size: 9px; - color: #00ccff; - font-weight: bold; -} - -.card-body { - margin-bottom: 8px; -} - -.card-text { - font-size: 12px; - color: #d0d0d0; - line-height: 1.4; -} - -.card-footer { - border-top: 1px solid #1a1a1a; - padding-top: 6px; - margin-top: auto; - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: #555; -} -.workflow-footer { - border-top: 1px solid #1a1a1a; - padding-top: 6px; - margin-top: 8px; - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: #555; -} - -.evidence-count { - display: inline-flex; - align-items: center; - cursor: help; - color: #666; - font-size: 11px; -} - -.evidence-sep { - color: #444; -} - -.evidence-toggle { - font-size: 11px; - color: #555; - cursor: help; - display: inline-flex; - align-items: center; - gap: 3px; -} - -.evidence-toggle:hover { - color: #888; -} - -.evidence-count:hover { - color: #888; -} - -.workflows-grid { - display: flex; - flex-direction: column; - gap: 10px; -} - -.workflow-row { - background: #0a0a0a; - border: 1px solid #2a2a2a; - padding: 12px; -} - -.workflow-title { - font-size: 12px; - color: #ffcc00; - font-weight: bold; - margin-bottom: 10px; -} - -.workflow-steps-horizontal { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.step-node { - display: flex; - align-items: center; - background: #111; - border: 1px solid #333; - border-radius: 4px; - padding: 4px 8px; - gap: 6px; -} - -.step-idx { - font-size: 9px; - background: #222; - color: #888; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; -} - -.step-content { - font-size: 11px; - color: #bbb; -} - -.step-arrow { - width: 12px; - height: 12px; - color: #444; -} - -.icon-xs { - width: 10px; - height: 10px; -} - -.changelog-list { - padding: 20px; - max-height: 500px; - overflow-y: auto; -} - -.changelog-item { - border: 1px solid #333; - padding: 15px; - margin-bottom: 10px; - background: #0a0a0a; - transition: border-color 0.2s; -} - -.changelog-item:hover { - border-color: #00ccff; -} - -.changelog-header { - display: flex; - gap: 10px; - align-items: center; - margin-bottom: 8px; - flex-wrap: wrap; -} - -.changelog-version { - background: #00ccff; - color: #0a0a0a; - padding: 4px 10px; - border-radius: 3px; - font-size: 11px; - font-weight: bold; -} - -.changelog-type { - color: #888; - font-size: 11px; - text-transform: uppercase; - border: 1px solid #333; - padding: 4px 8px; -} - -.changelog-date { - color: #666; - font-size: 11px; - margin-left: auto; -} - -.changelog-summary { - color: #ccc; - font-size: 12px; - line-height: 1.6; -} - -.icon-large { - width: 48px; - height: 48px; - margin-bottom: 15px; - opacity: 0.5; -} - -/* Profile item actions */ -.card-actions { - display: flex; - gap: 2px; - margin-right: 8px; -} - -.btn-icon { - background: none; - border: none; - cursor: pointer; - padding: 2px 4px; - border-radius: 3px; - color: #666; - transition: - color 0.15s, - background 0.15s; -} - -.btn-icon:hover { - color: #00ff00; - background: rgba(0, 255, 0, 0.08); -} - -.btn-delete-profile-item:hover { - color: #ff4444; - background: rgba(255, 50, 50, 0.08); -} - -.card-top { - display: flex; - align-items: center; - gap: 8px; -} - -.workflow-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.workflow-header .card-actions { - flex-shrink: 0; -} - -@media (max-width: 768px) { - .profile-header { - flex-direction: column; - gap: 15px; - } - - .skill-level { - grid-template-columns: 1fr; - } - - .workflow-step { - flex-direction: column; - align-items: flex-start; - } -} - -.profile-meta .separator { - margin: 0 8px; - color: #666; -} - -.badge .icon-sm { - width: 12px; - height: 12px; - margin-right: 4px; - vertical-align: middle; -} - -.link-indicator .icon-sm { - width: 14px; - height: 14px; - vertical-align: middle; -} - -.category-badge { - background: #00ccff; - color: #0a0a0a; - padding: 4px 10px; - border-radius: 3px; - font-size: 11px; - font-weight: bold; -} - -.lang-toggle { - background: #0a0a0a; - border: 1px solid #00ff00; - color: #00ff00; - padding: 4px 12px; - font-family: inherit; - font-size: 12px; - font-weight: bold; - cursor: pointer; - transition: all 0.2s; - letter-spacing: 1px; -} -.lang-toggle:hover { - background: #00ff00; - color: #0a0a0a; -} - -/* AI Cleanup Modal */ -.cleanup-modal-content { - max-width: 700px; - max-height: 80vh; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.cleanup-loading { - text-align: center; - padding: 40px; - color: #888; -} - -.cleanup-loading i { - display: block; - margin-bottom: 10px; -} - -.cleanup-summary { - padding: 12px 16px; - background: #1a1a2e; - border-radius: 6px; - margin-bottom: 16px; - color: #00ff00; - font-weight: 600; -} - -.cleanup-section { - margin-bottom: 16px; -} - -.cleanup-section h4 { - color: #888; - margin-bottom: 8px; - font-size: 0.85rem; - text-transform: uppercase; - letter-spacing: 1px; -} - -.diff-group { - margin-bottom: 12px; - border-bottom: 1px solid #2a2a4a; - padding-bottom: 8px; -} - -.diff-item { - padding: 6px 10px; - border-radius: 4px; - font-size: 0.85rem; - line-height: 1.5; -} - -.diff-marker { - font-weight: 700; - margin-right: 6px; -} - -.kept-item { - background: rgba(0, 255, 0, 0.05); -} - -.kept-item .diff-marker { - color: #00ff00; -} - -.removed-item { - background: rgba(255, 50, 50, 0.05); - color: #999; - text-decoration: line-through; -} - -.removed-item .diff-marker { - color: #ff3232; -} - -/* Profile item modal */ -.profile-item-modal-content { - max-width: 480px; -} - -#profile-item-form .form-group { - margin-bottom: 12px; -} - -#profile-item-form label { - display: block; - font-size: 0.8rem; - color: #888; - margin-bottom: 4px; -} - -#profile-item-form input, -#profile-item-form textarea { - width: 100%; - background: #1a1a2e; - border: 1px solid #333; - color: #c0e0c0; - padding: 8px; - border-radius: 4px; - font-family: inherit; -} - -#profile-item-form input:disabled, -#profile-item-form textarea:disabled { - opacity: 0.6; -} - -#steps-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 8px; -} - -.step-row { - display: flex; - align-items: center; - gap: 6px; -} - -.step-num { - color: var(--text-secondary); - font-size: 12px; - width: 20px; - text-align: center; - flex-shrink: 0; -} - -.step-input { - flex: 1; - padding: 6px 8px; - font-size: 13px; - border: 1px solid var(--border-light); - border-radius: 4px; - background: var(--bg-input); - color: var(--text-primary); - font-family: inherit; -} - -.step-input:focus { - outline: none; - border-color: var(--accent-blue); -} - -.btn-remove-step { - background: none; - border: none; - color: var(--text-muted); - font-size: 16px; - cursor: pointer; - padding: 0 4px; - line-height: 1; -} - -.btn-remove-step:hover { - color: #cc2222; -} - -.btn-primary.danger { - background: #cc2222; - border-color: #ff4444; - color: #fff; -} - -.btn-primary.danger:hover { - background: #dd3333; -} - -.diff-reason { - display: block; - color: #666; - font-size: 0.75rem; - margin-top: 2px; - text-decoration: none; -} - -.profile-actions { - display: flex; - gap: 8px; -} - -/* V2 Diff Layout */ -.cleanup-diff-v2 { - overflow-y: auto; - max-height: 55vh; -} - -.cleanup-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid #2a2a4a; - margin-bottom: 12px; - font-size: 0.85rem; - color: #aaa; - position: sticky; - top: 0; - background: #0d0d1a; - z-index: 1; -} - -.toolbar-actions { - display: flex; - gap: 12px; -} - -.btn-link { - background: none; - border: none; - color: #00ff00; - cursor: pointer; - font-size: 0.8rem; - padding: 2px 0; -} -.btn-link:hover { - text-decoration: underline; -} - -.section-divider { - font-size: 0.8rem; - color: #888; - text-transform: uppercase; - letter-spacing: 1px; - margin: 16px 0 8px; - padding-bottom: 6px; - border-bottom: 1px solid #1a1a2e; -} -.section-divider i { - vertical-align: middle; - margin-right: 4px; -} - -.diff-card { - border-radius: 6px; - padding: 12px; - margin-bottom: 10px; -} - -.merge-card { - border-left: 3px solid #00ff00; - background: rgba(0, 255, 0, 0.02); -} - -.remove-card { - border-left: 3px solid #ff4444; - background: rgba(255, 50, 50, 0.02); -} - -.diff-card-check { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - cursor: pointer; -} - -.diff-card-check input[type="checkbox"] { - accent-color: #00ff00; - width: 16px; - height: 16px; -} - -.remove-card .diff-card-check input[type="checkbox"] { - accent-color: #ff4444; -} - -.check-label { - font-size: 0.8rem; - font-weight: 600; - color: #00ff00; -} - -.remove-card .check-label { - color: #ff4444; -} - -.merge-body { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 10px; - align-items: flex-start; -} - -.merge-before, -.merge-after { - background: #111122; - border-radius: 4px; - padding: 8px; - font-size: 0.8rem; - line-height: 1.5; - color: #c0e0c0; -} - -.merge-source { - padding: 4px 0; - border-bottom: 1px solid #1a1a2e; - color: #999; - text-decoration: line-through; -} -.merge-source:last-child { - border-bottom: none; -} - -.type-badge { - display: inline-block; - background: #1a1a2e; - color: #888; - font-size: 0.65rem; - font-weight: 600; - padding: 1px 5px; - border-radius: 3px; - margin-right: 4px; - vertical-align: middle; - text-decoration: none; - text-transform: uppercase; -} - -.merge-arrow { - align-self: center; - color: #00ff00; - font-size: 0.9rem; - padding-top: 12px; -} - -.remove-body { - margin-left: 26px; -} - -.remove-desc { - color: #c0e0c0; - font-size: 0.85rem; - text-decoration: line-through; - margin-bottom: 4px; -} - -.remove-reason { - color: #ff8888; - font-size: 0.7rem; -} - -.collapse-header { - cursor: pointer; - font-size: 0.8rem; - color: #888; - text-transform: uppercase; - letter-spacing: 1px; - margin-top: 12px; - user-select: none; -} -.collapse-header:hover { - color: #aaa; -} -.collapse-header i { - vertical-align: middle; - margin-left: 4px; - transition: transform 0.2s; -} - -.cleanup-kept-list { - margin-top: 4px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4px; -} - -.kept-item { - background: rgba(0, 255, 0, 0.03); - padding: 6px 8px; - border-radius: 3px; - font-size: 0.78rem; - color: #aaa; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.kept-item::before { - content: "✓ "; - color: #00ff00; - font-weight: 700; -} - -/* Inline steps in AI cleanup diff */ -.cleanup-steps { - margin-top: 4px; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 2px; -} - -.step-inline { - display: inline-flex; - align-items: center; - background: rgba(255, 255, 255, 0.06); - border-radius: 3px; - padding: 1px 5px 1px 2px; - font-size: 0.7rem; - color: #aaa; -} - -.step-inline-num { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.1); - font-size: 0.6rem; - margin-right: 3px; - flex-shrink: 0; -} - -.step-arrow-inline { - color: #555; - font-size: 0.7rem; - margin: 0 1px; -} - -/* ============================================ - AI Cleanup selection UI - ============================================ */ -.cleanup-select-header { - flex-shrink: 0; - padding: 12px 0 8px; -} -.cleanup-select-header h3 { - font-size: 14px; - margin-bottom: 8px; - color: var(--text-primary, #eee); -} -.cleanup-select-actions { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin-bottom: 8px; -} -.cleanup-select-actions .btn { - font-size: 11px; - padding: 3px 10px; -} -.cleanup-select-grid { - flex: 1; - overflow-y: auto; - border: 1px solid var(--border, #333); - border-radius: 6px; - padding: 4px; - min-height: 0; -} - -#cleanup-sections { - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; -} -.cleanup-cat-group { - margin-bottom: 4px; -} -.cleanup-cat-header { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: var(--bg-secondary, #222); - border-radius: 4px; - position: sticky; - top: 0; - z-index: 1; -} -.cleanup-cat-header .cat-count { - font-size: 11px; - color: var(--text-secondary, #888); -} -.cleanup-item-row { - display: flex; - align-items: center; - gap: 6px; - padding: 3px 8px; - cursor: pointer; - font-size: 12px; - border-radius: 3px; -} -.cleanup-item-row:hover { - background: var(--bg-hover, #2a2a2a); -} -.cleanup-item-row input[type="checkbox"] { - flex-shrink: 0; -} -.cleanup-item-type { - font-size: 10px; - font-weight: 600; - color: var(--accent, #4a90d9); - width: 14px; - text-align: center; - flex-shrink: 0; -} -.cleanup-item-desc { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-primary, #ddd); -} -.cleanup-item-stats { - font-size: 10px; - color: var(--text-secondary, #888); - flex-shrink: 0; - white-space: nowrap; -} -.cleanup-select-footer { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 0 0; -} -.cleanup-select-footer span { - font-size: 12px; - color: var(--text-secondary, #888); -} - -.auth-warning { - display: flex; - align-items: flex-start; - gap: 12px; - border: 1px solid #ffcc00; - background: rgba(255, 204, 0, 0.08); - color: #ffcc00; - padding: 12px 16px; - margin-bottom: 16px; - font-size: 12px; - line-height: 1.5; -} -.auth-warning.hidden { - display: none; -} -.auth-warning-icon { - font-size: 18px; - line-height: 1; - flex-shrink: 0; -} -.auth-warning-icon svg { - width: 18px; - height: 18px; - stroke: #ffcc00; -} -.auth-warning-body { - display: flex; - flex-direction: column; - gap: 4px; -} -.auth-warning-text { - color: #e0e0e0; -} diff --git a/tests/shared-api-types.test.ts b/tests/shared-api-types.test.ts new file mode 100644 index 00000000..68b567cb --- /dev/null +++ b/tests/shared-api-types.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { + ApiResultSchema, + MemoryItemSchema, + UserProfileSchema, + type ApiResult, + type MemoryItem, + type UserProfile, +} from "../src/shared/api/index.js"; + +describe("shared API contracts", () => { + it("parses ApiResult envelope", () => { + const parsed = ApiResultSchema.parse({ success: true, data: { ok: 1 } }); + expect(parsed.success).toBe(true); + const typed: ApiResult<{ ok: number }> = { success: true, data: { ok: 1 } }; + expect(typed.data?.ok).toBe(1); + }); + + it("accepts MemoryItem shape used by the web UI", () => { + const item: MemoryItem = { + id: "m1", + type: "memory", + content: "hello", + createdAt: new Date().toISOString(), + isPinned: true, + }; + expect(MemoryItemSchema.parse(item).id).toBe("m1"); + }); + + it("accepts UserProfile shape used by the web UI", () => { + const profile: UserProfile = { + exists: true, + userId: "u@example.com", + profileData: { preferences: [{ description: "prefers TS" }] }, + }; + expect(UserProfileSchema.parse(profile).exists).toBe(true); + }); +}); diff --git a/tests/web-memorytype-xss.test.ts b/tests/web-memorytype-xss.test.ts index 863a1ea4..084b1980 100644 --- a/tests/web-memorytype-xss.test.ts +++ b/tests/web-memorytype-xss.test.ts @@ -1,125 +1,18 @@ import { describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; -import { Script, createContext } from "node:vm"; - -type RenderMemoryCard = (memory: MemoryFixture) => string; -type RenderCombinedCard = (pair: CombinedCardFixture) => string; - -type MemoryFixture = { - id: string; - content: string; - memoryType: string; - tags: readonly string[]; - createdAt: string; - displayName: string; - isPinned: boolean; -}; - -type PromptFixture = { - id: string; - content: string; - createdAt: string; -}; - -type CombinedCardFixture = { - memory: MemoryFixture; - prompt: PromptFixture; -}; - -type RenderHarness = { - renderMemoryCard: RenderMemoryCard; - renderCombinedCard: RenderCombinedCard; -}; +import { escapeHtml } from "../web/src/lib/html.ts"; const MALICIOUS_MEMORY_TYPE = ''; -function loadRenderHarness(): RenderHarness { - const source = readFileSync(new URL("../src/web/app.js", import.meta.url), "utf-8"); - const context = createContext({ - console, - DOMPurify: { sanitize: (html: string) => html }, - getLanguage: () => "en", - lucide: { createIcons: () => undefined }, - marked: { parse: (markdown: string) => markdown, setOptions: () => undefined }, - t: (key: string) => key, - document: { - addEventListener: () => undefined, - createElement: () => { - let text = ""; - return { - set textContent(value: string) { - text = value; - }, - get innerHTML() { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'"); - }, - }; - }, - }, - }); - - new Script( - `${source}\n;globalThis.__renderMemoryCard = renderMemoryCard;\n;globalThis.__renderCombinedCard = renderCombinedCard;` - ).runInContext(context); - - return { - renderMemoryCard: context.__renderMemoryCard as RenderMemoryCard, - renderCombinedCard: context.__renderCombinedCard as RenderCombinedCard, - }; -} - -function createMemory(): MemoryFixture { - return { - id: "mem_xss_repro", - content: "benign content", - memoryType: MALICIOUS_MEMORY_TYPE, - tags: [], - createdAt: "2026-06-22T20:21:11.419Z", - displayName: "XSS repro memory", - isPinned: false, - }; -} - -function expectEscapedMemoryType(html: string): void { - expect(html).not.toContain(" { - it("escapes memoryType in standalone memory cards", () => { +describe("web memoryType XSS helpers", () => { + it("escapes memoryType HTML event-handler payloads", () => { // Given: a stored memory with an HTML event-handler payload in memoryType. - const { renderMemoryCard } = loadRenderHarness(); - const memory = createMemory(); - - // When: the Web UI renders the standalone memory card. - const html = renderMemoryCard(memory); - - // Then: the payload is displayed as badge text, not parsed as executable HTML. - expectEscapedMemoryType(html); - }); - - it("escapes memoryType in combined prompt-memory cards", () => { - // Given: a stored memory linked to a prompt with an HTML payload in memoryType. - const { renderCombinedCard } = loadRenderHarness(); - const memory = createMemory(); - - // When: the Web UI renders the combined prompt-memory card. - const html = renderCombinedCard({ - memory, - prompt: { - id: "prompt_xss_repro", - content: "remember this", - createdAt: "2026-06-22T20:20:00.000Z", - }, - }); + // The React MemoryCard renders memoryType as text (JSX-escaped); escapeHtml + // is the shared defense for any HTML string sinks. + const escaped = escapeHtml(MALICIOUS_MEMORY_TYPE); // Then: the payload is displayed as badge text, not parsed as executable HTML. - expectEscapedMemoryType(html); + expect(escaped).not.toContain(" void; - state: { userProfile: unknown }; - getHtml: () => string; -} { - const source = readFileSync(new URL("../src/web/app.js", import.meta.url), "utf-8"); - - let capturedHtml = ""; - const profileContainer = { - set innerHTML(value: string) { - capturedHtml = value; - }, - get innerHTML() { - return capturedHtml; - }, - }; - - const escapingDiv = () => { - let text = ""; - return { - set textContent(value: string) { - text = value; - }, - get innerHTML() { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'"); - }, - }; - }; - - const context = createContext({ - console, - DOMPurify: { sanitize: (html: string) => html }, - getLanguage: () => "en", - lucide: { createIcons: () => undefined }, - jsonrepair: undefined, - marked: { parse: (markdown: string) => markdown, setOptions: () => undefined }, - t: (key: string) => key, - document: { - addEventListener: () => undefined, - getElementById: (id: string) => (id === "profile-content" ? profileContainer : undefined), - createElement: () => escapingDiv(), - }, - }); - - new Script( - `${source}\n;globalThis.__renderUserProfile = renderUserProfile;\n;globalThis.__state = state;` - ).runInContext(context); - - return { - renderUserProfile: context.__renderUserProfile as () => void, - state: context.__state as { userProfile: unknown }, - getHtml: () => capturedHtml, - }; -} - -describe("web user-profile rendering", () => { - it("escapes displayName in the profile header", () => { +describe("web user-profile XSS helpers", () => { + it("escapes displayName HTML event-handler payloads", () => { // Given: a stored user profile whose displayName came from an untrusted // per-project userNameOverride and contains an HTML event-handler payload. - const { renderUserProfile, state, getHtml } = loadRenderHarness(); - state.userProfile = { - exists: true, - version: 1, - totalPromptsAnalyzed: 0, - lastAnalyzedAt: "2026-06-22T20:21:11.419Z", - userId: "user_xss_repro", - displayName: MALICIOUS_DISPLAY_NAME, - profileData: {}, - }; - - // When: the Web UI renders the User Profile tab. - renderUserProfile(); - - // Then: the payload is displayed as header text, not parsed as executable HTML. - const html = getHtml(); - expect(html).not.toContain("= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" }, "bundled": true }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + } +} diff --git a/web/components.json b/web/components.json new file mode 100644 index 00000000..753a2f0c --- /dev/null +++ b/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "lib": "$lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..a4404e36 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + OpenCode Memory Explorer + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..12a8690f --- /dev/null +++ b/web/package.json @@ -0,0 +1,43 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "check": "tsc -b --pretty false" + }, + "dependencies": { + "dompurify": "^3.4.12", + "jsonrepair": "^3.15.0", + "marked": "^18.0.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "sonner": "^2.0.6", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", + "typescript": "~6.0.3", + "vite": "^8.1.5" + } +} diff --git a/src/web/favicon.ico b/web/public/favicon.ico similarity index 100% rename from src/web/favicon.ico rename to web/public/favicon.ico diff --git a/web/public/opencode-mem-icon.png b/web/public/opencode-mem-icon.png new file mode 100644 index 00000000..665a2d98 Binary files /dev/null and b/web/public/opencode-mem-icon.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 00000000..3a16d331 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,379 @@ +import { useEffect, useState, type MouseEvent } from "react"; +import { AlertTriangle, Loader, Menu, Plus, RefreshCw, Search, Trash, X } from "lucide-react"; +import { useMemoriesExplorer } from "@/hooks/useMemoriesExplorer"; +import { useUserProfile } from "@/hooks/useUserProfile"; +import { AiCleanupDialog } from "$lib/components/explorer/AiCleanupDialog"; +import { AppSidebar } from "$lib/components/explorer/AppSidebar"; +import { EditMemoryDialog } from "$lib/components/explorer/EditMemoryDialog"; +import { MemoryList } from "$lib/components/explorer/MemoryList"; +import { ProfileView } from "$lib/components/explorer/ProfileView"; +import { TagMigrationDialog } from "$lib/components/explorer/TagMigrationDialog"; +import { Alert, AlertDescription } from "$lib/components/ui/alert"; +import { Button } from "$lib/components/ui/button"; +import { Checkbox } from "$lib/components/ui/checkbox"; +import { Input } from "$lib/components/ui/input"; +import { Label } from "$lib/components/ui/label"; +import { Toaster } from "$lib/components/ui/sonner"; +import { Textarea } from "$lib/components/ui/textarea"; +import { cycleLanguage, getLanguage, useI18n } from "$lib/i18n"; +import { initRouter, navigate, ROUTES, useAppView } from "$lib/router"; + +const MEMORY_TYPES = [ + "", + "feature", + "bug-fix", + "refactor", + "architecture", + "rule", + "documentation", + "discussion", + "analysis", + "configuration", +] as const; + +export default function App() { + const { t } = useI18n(); + const currentView = useAppView(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [langLabel, setLangLabel] = useState(getLanguage().toUpperCase()); + + const explorer = useMemoriesExplorer(); + const profile = useUserProfile(); + + useEffect(() => { + const stopRouter = initRouter(); + void (async () => { + await explorer.loadTags(); + await explorer.loadMemories(); + await explorer.loadStats(); + await explorer.checkMigrationStatus(); + await explorer.checkAuthWarning(); + })(); + const refreshTimer = setInterval(() => { + void explorer.loadStats(); + if (!explorer.isSearching && currentView === "project") { + void explorer.loadMemories(); + } + }, 30000); + return () => { + stopRouter(); + clearInterval(refreshTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only init + }, []); + + useEffect(() => { + if (currentView === "profile") { + void profile.loadUserProfile(); + } + }, [currentView, profile.loadUserProfile]); + + function onLangToggle() { + setLangLabel(cycleLanguage().toUpperCase()); + void explorer.loadMemories(); + void explorer.loadStats(); + if (currentView === "profile") void profile.loadUserProfile(); + } + + function onHomeClick(event: MouseEvent) { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + navigate(ROUTES.home); + } + + return ( + <> + + +
+ + +
+
+ + + {t("brand")} + +
+ +
+ {explorer.showAuthWarning ? ( + + + {t("auth-warning-text")} + + ) : null} + +
+

+ {currentView === "project" ? t("tab-project") : t("tab-profile")} +

+ {currentView === "project" ? ( +
+ {t("text-total", { count: explorer.statsTotal })} + {explorer.refreshing ? : null} +
+ ) : null} +
+ + {currentView === "project" ? ( + <> +
+
+ + +
+ +
+
+ + explorer.setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && explorer.performSearch()} + /> +
+ + {explorer.isSearching ? ( + + ) : null} +
+ +
+ + +
+ + {explorer.selectedIds.size > 0 ? ( +
+ + {t("text-selected", { count: explorer.selectedIds.size })} + + + + +
+ ) : null} +
+ + {explorer.migrationNeeded ? ( + + + +

{explorer.migrationMessage || t("migration-mismatch")}

+ +
+ + +
+
+
+ ) : null} + + { + const next = explorer.currentPage + delta; + explorer.setCurrentPage(next); + void explorer.loadMemories({ page: next }); + }} + onPin={explorer.pinMemory} + onUnpin={explorer.unpinMemory} + onEdit={explorer.openEdit} + onDeleteMemory={explorer.deleteMemory} + onDeletePrompt={explorer.deletePrompt} + /> + +
+

{t("section-add")}

+
+
+
+ + +
+
+ + +
+
+ + explorer.setAddTags(e.target.value)} + /> +
+
+
+ +