@openparachute/vault 0.6.4-rc.9 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Storage upload allowlist tests (issue #127).
2
+ * Storage upload policy tests (issue #127 origin; vault#517 deny-list).
3
3
  *
4
- * The allowlist guards `POST /api/storage/upload` against turning user
5
- * uploads into XSS vectors when the asset is later served back from
6
- * `/storage/`. We pin both the accepted set and the deliberate exclusions
7
- * so a future widening doesn't quietly let SVG/HTML in.
4
+ * `POST /api/storage/upload` accepts ANY file EXCEPT the active-content set a
5
+ * browser can execute when the asset is served back same-origin from
6
+ * `/storage/` (.svg/.html/.htm/.xhtml/.xml/.js/.mjs/.cjs/.css). A knowledge
7
+ * vault stores arbitrary files ebooks, office docs, datasets, archives,
8
+ * binaries — so the long tail (.epub/.csv/.zip/.exe/…) is accepted, not
9
+ * rejected. We pin the accepted breadth AND the deliberate blocklist so a
10
+ * future edit can't quietly let SVG/HTML in (or quietly start rejecting docs).
11
+ * Two guards keep served files inert: every non-curated type serves as
12
+ * application/octet-stream, and the GET serve path pins
13
+ * `X-Content-Type-Options: nosniff`.
8
14
  */
9
15
 
10
16
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
@@ -91,6 +97,43 @@ describe("storage upload allowlist", () => {
91
97
  }
92
98
  });
93
99
 
100
+ test("accepts .epub — the reported gap (ebooks are knowledge content)", async () => {
101
+ const res = await handleStorage(uploadRequest("book.epub", "application/epub+zip"), "/upload", "default", uploadStore);
102
+ expect(res.status).toBe(201);
103
+ const body = (await res.json()) as { mimeType: string; path: string };
104
+ expect(body.mimeType).toBe("application/epub+zip");
105
+ expect(body.path).toMatch(/\.epub$/);
106
+ });
107
+
108
+ test("accepts common document / text / data / archive types", async () => {
109
+ for (const [name, mime, expected] of [
110
+ ["notes.txt", "text/plain", "text/plain; charset=utf-8"],
111
+ ["readme.md", "text/markdown", "text/markdown; charset=utf-8"],
112
+ ["data.csv", "text/csv", "text/csv; charset=utf-8"],
113
+ ["data.json", "application/json", "application/json; charset=utf-8"],
114
+ ["doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
115
+ ["archive.zip", "application/zip", "application/zip"],
116
+ ["clip.mov", "video/quicktime", "video/quicktime"],
117
+ ["photo.heic", "image/heic", "image/heic"],
118
+ ] as const) {
119
+ const res = await handleStorage(uploadRequest(name, mime), "/upload", "default", uploadStore);
120
+ expect(res.status).toBe(201);
121
+ const body = (await res.json()) as { mimeType: string };
122
+ expect(body.mimeType).toBe(expected);
123
+ }
124
+ });
125
+
126
+ test("accepts arbitrary / unknown files — served as octet-stream (download), never run", async () => {
127
+ // Deny-list policy (vault#517): anything not in the active-content blocklist
128
+ // is accepted. Unknown/no-MIME extensions serve as octet-stream — a download.
129
+ for (const name of ["deck.pages", "tool.exe", "data.bin", "Makefile", "archive.tar.gz"] as const) {
130
+ const res = await handleStorage(uploadRequest(name, "application/octet-stream"), "/upload", "default", uploadStore);
131
+ expect(res.status).toBe(201);
132
+ const body = (await res.json()) as { mimeType: string };
133
+ expect(body.mimeType).toBe("application/octet-stream");
134
+ }
135
+ });
136
+
94
137
  test("rejects .svg — XSS vector via inline <script> (#127)", async () => {
95
138
  const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default", uploadStore);
96
139
  expect(res.status).toBe(400);
@@ -105,9 +148,31 @@ describe("storage upload allowlist", () => {
105
148
  expect(body.error).toContain(".html");
106
149
  });
107
150
 
108
- test("rejects unknown extensions (default-deny)", async () => {
109
- const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default", uploadStore);
110
- expect(res.status).toBe(400);
151
+ test("rejects the active-content set (.js/.mjs/.cjs/.xhtml/.htm/.xml/.css/.shtml)", async () => {
152
+ for (const [name, mime] of [
153
+ ["script.js", "text/javascript"],
154
+ ["mod.mjs", "text/javascript"],
155
+ ["mod.cjs", "text/javascript"],
156
+ ["page.xhtml", "application/xhtml+xml"],
157
+ ["page.xht", "application/xhtml+xml"],
158
+ ["page.htm", "text/html"],
159
+ ["page.shtml", "text/html"],
160
+ ["feed.xml", "application/xml"],
161
+ ["style.css", "text/css"],
162
+ ] as const) {
163
+ const res = await handleStorage(uploadRequest(name, mime), "/upload", "default", uploadStore);
164
+ expect(res.status).toBe(400);
165
+ const body = (await res.json()) as { error: string };
166
+ expect(body.error).toContain("not allowed");
167
+ }
168
+ });
169
+
170
+ test("trailing-dot / trailing-space can't slip a blocked type past the guard", async () => {
171
+ // extname("evil.html.") === "." — normalize the trailing run first.
172
+ for (const name of ["evil.html.", "evil.svg ", "evil.js."] as const) {
173
+ const res = await handleStorage(uploadRequest(name, "text/plain"), "/upload", "default", uploadStore);
174
+ expect(res.status).toBe(400);
175
+ }
111
176
  });
112
177
  });
113
178
 
@@ -166,6 +231,8 @@ describe("storage GET tag-scope enforcement", () => {
166
231
  const res = await handleStorage(getReq(inScopePath), `/${inScopePath}`, VAULT, store, ctx);
167
232
  expect(res.status).toBe(200);
168
233
  expect(res.headers.get("Content-Type")).toBe("application/pdf");
234
+ // Served bytes pin nosniff so a browser can't sniff them into an active type.
235
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
169
236
  expect((await res.arrayBuffer()).byteLength).toBe(4);
170
237
  });
171
238
 
@@ -1 +1 @@
1
- :root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;gap:1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h2{margin:0}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}.vault-row-group{margin-bottom:.5rem}.vault-row-group .vault-row{margin-bottom:0}.vault-row-actions{display:flex;align-items:center;gap:.5rem;flex-shrink:0}.vault-usage{font-size:.85rem}.field-error{color:#b3402a;font-size:.85rem;margin-top:.35rem}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr));gap:1rem;font-size:.92rem}.stat{background:var(--bg-soft);border-radius:8px;padding:.85rem 1rem}.stat .label{color:var(--fg-muted);font-size:.82rem;margin-bottom:.2rem}.stat .value{font-size:1.4rem;font-weight:500;font-family:var(--font-mono)}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select{font:inherit;background:#fff;color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:.5rem .7rem;width:100%;max-width:28rem}input:focus,select:focus{outline:2px solid var(--accent-soft);border-color:var(--accent)}.form-row{margin-bottom:1rem}.form-row label{display:block;font-size:.88rem;color:var(--fg-muted);margin-bottom:.3rem}.actions{display:flex;gap:.5rem;margin-top:1rem}.manage-list{margin:0;padding-left:1.1rem;font-size:.95rem}.manage-list li{margin-bottom:.4rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem}.mcp-connect-card h3{margin:0 0 .4rem;font-size:1rem;font-weight:500}.mcp-connect-card>p{margin-top:0}.mcp-connect-card .token-box{display:flex;align-items:center;gap:.5rem;margin:.35rem 0 .25rem}.mcp-connect-card .token-box code{flex:1;font-size:.85rem;padding:.55rem .7rem;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;word-break:break-all;-webkit-user-select:all;user-select:all}.mcp-field{margin-top:.9rem}.mcp-field-label{display:block;font-size:.82rem;font-weight:600;color:var(--fg-muted)}.mcp-field .dim{margin:.3rem 0 0}.mcp-docs-link{margin:.9rem 0 0}.token-list{display:flex;flex-direction:column;gap:.5rem}.token-row{display:flex;align-items:flex-start;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px}.token-row .body{flex:1;min-width:0}.token-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:.3rem}.token-row .meta{font-size:.85rem;margin-bottom:.2rem}.token-row .actions{margin-top:0;flex-shrink:0}.scope-tag{font-size:.78rem;padding:.1em .4em;margin-right:.3em}.tag-pill{background:var(--accent-soft, rgba(120, 120, 200, .12))}.tag-picker{display:flex;flex-wrap:wrap;gap:.4rem .8rem;margin-top:.35rem;max-height:9rem;overflow-y:auto;padding:.4rem .5rem;border:1px solid var(--border, #d8d8de);border-radius:4px}.tag-checkbox{display:inline-flex;align-items:center;gap:.3rem;font-size:.85rem;cursor:pointer;-webkit-user-select:none;user-select:none}.tag-checkbox input[type=checkbox]{margin:0}.preset-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:.75rem;margin-top:.35rem}.preset-card{background:var(--bg-soft);color:var(--fg);border:1px solid var(--border);border-radius:8px;padding:.85rem 1rem;text-align:left;display:flex;flex-direction:column;gap:.25rem;cursor:pointer;transition:border-color .15s ease,background .15s ease}.preset-card:hover{border-color:var(--accent);background:#fff}.preset-card:disabled{opacity:.5;cursor:not-allowed}.preset-card strong{font-size:.95rem;font-weight:500}.preset-card .dim{font-size:.8rem;line-height:1.35}.radio-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem;font-size:.92rem;cursor:pointer}.radio-row input[type=radio]{margin:0}.info-banner{background:var(--accent-soft);border:1px solid var(--accent);color:var(--accent-hover);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000059;display:flex;align-items:center;justify-content:center;z-index:100;padding:1rem}.modal{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:1.5rem;width:min(520px,100%);max-height:90vh;overflow-y:auto;box-shadow:0 10px 40px #0003}.device-code-row{display:flex;align-items:center;gap:.75rem;margin:.75rem 0}.device-code{font-size:1.4rem;letter-spacing:.1em;padding:.6rem 1rem;background:var(--bg-soft);border-radius:8px;border:1px solid var(--border);font-family:ui-monospace,SF Mono,Menlo,monospace;-webkit-user-select:all;user-select:all;flex:1;text-align:center}.repo-list{display:flex;flex-direction:column;gap:.35rem;max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:.4rem;background:var(--bg-soft)}.repo-row{background:var(--bg);border:1px solid transparent;border-radius:6px;padding:.55rem .75rem;font-size:.92rem;display:flex;align-items:baseline;justify-content:space-between;gap:1rem;cursor:pointer;text-align:left;color:inherit;transition:border-color .12s ease,background .12s ease}.repo-row:hover{border-color:var(--accent);background:#fff}.repo-row:disabled{opacity:.5;cursor:not-allowed}
1
+ :root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;gap:1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h2{margin:0}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}.vault-row-group{margin-bottom:.5rem}.vault-row-group .vault-row{margin-bottom:0}.vault-row-actions{display:flex;align-items:center;gap:.5rem;flex-shrink:0}.vault-usage{font-size:.85rem}.field-error{color:#b3402a;font-size:.85rem;margin-top:.35rem}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr));gap:1rem;font-size:.92rem}.stat{background:var(--bg-soft);border-radius:8px;padding:.85rem 1rem}.stat .label{color:var(--fg-muted);font-size:.82rem;margin-bottom:.2rem}.stat .value{font-size:1.4rem;font-weight:500;font-family:var(--font-mono)}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select{font:inherit;background:#fff;color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:.5rem .7rem;width:100%;max-width:28rem}input:focus,select:focus{outline:2px solid var(--accent-soft);border-color:var(--accent)}.form-row{margin-bottom:1rem}.form-row label{display:block;font-size:.88rem;color:var(--fg-muted);margin-bottom:.3rem}.actions{display:flex;gap:.5rem;margin-top:1rem}.manage-list{margin:0;padding-left:1.1rem;font-size:.95rem}.manage-list li{margin-bottom:.4rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem}.mcp-connect-card h3{margin:0 0 .4rem;font-size:1rem;font-weight:500}.mcp-connect-card>p{margin-top:0}.mcp-connect-card .token-box{display:flex;align-items:center;gap:.5rem;margin:.35rem 0 .25rem}.mcp-connect-card .token-box code{flex:1;font-size:.85rem;padding:.55rem .7rem;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;word-break:break-all;-webkit-user-select:all;user-select:all}.mcp-field{margin-top:.9rem}.mcp-field-label{display:block;font-size:.82rem;font-weight:600;color:var(--fg-muted)}.mcp-field .dim{margin:.3rem 0 0}.mcp-docs-link{margin:.9rem 0 0}.token-list{display:flex;flex-direction:column;gap:.5rem}.token-row{display:flex;align-items:flex-start;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px}.token-row .body{flex:1;min-width:0}.token-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:.3rem}.token-row .meta{font-size:.85rem;margin-bottom:.2rem}.token-row .actions{margin-top:0;flex-shrink:0}.scope-tag{font-size:.78rem;padding:.1em .4em;margin-right:.3em}.tag-pill{background:var(--accent-soft, rgba(120, 120, 200, .12))}.tag-picker{display:flex;flex-wrap:wrap;gap:.4rem .8rem;margin-top:.35rem;max-height:9rem;overflow-y:auto;padding:.4rem .5rem;border:1px solid var(--border, #d8d8de);border-radius:4px}.tag-checkbox{display:inline-flex;align-items:center;gap:.3rem;font-size:.85rem;cursor:pointer;-webkit-user-select:none;user-select:none}.tag-checkbox input[type=checkbox]{margin:0}.preset-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:.75rem;margin-top:.35rem}.preset-card{background:var(--bg-soft);color:var(--fg);border:1px solid var(--border);border-radius:8px;padding:.85rem 1rem;text-align:left;display:flex;flex-direction:column;gap:.25rem;cursor:pointer;transition:border-color .15s ease,background .15s ease}.preset-card:hover{border-color:var(--accent);background:#fff}.preset-card:disabled{opacity:.5;cursor:not-allowed}.preset-card strong{font-size:.95rem;font-weight:500}.preset-card .dim{font-size:.8rem;line-height:1.35}.radio-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem;font-size:.92rem;cursor:pointer}.radio-row input[type=radio]{margin:0}.info-banner{background:var(--accent-soft);border:1px solid var(--accent);color:var(--accent-hover);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000059;display:flex;align-items:center;justify-content:center;z-index:100;padding:1rem}.modal{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:1.5rem;width:min(520px,100%);max-height:90vh;overflow-y:auto;box-shadow:0 10px 40px #0003}.device-code-row{display:flex;align-items:center;gap:.75rem;margin:.75rem 0}.device-code{font-size:1.4rem;letter-spacing:.1em;padding:.6rem 1rem;background:var(--bg-soft);border-radius:8px;border:1px solid var(--border);font-family:ui-monospace,SF Mono,Menlo,monospace;-webkit-user-select:all;user-select:all;flex:1;text-align:center}.repo-list{display:flex;flex-direction:column;gap:.35rem;max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:.4rem;background:var(--bg-soft)}.repo-row{background:var(--bg);border:1px solid transparent;border-radius:6px;padding:.55rem .75rem;font-size:.92rem;display:flex;align-items:baseline;justify-content:space-between;gap:1rem;cursor:pointer;text-align:left;color:inherit;transition:border-color .12s ease,background .12s ease}.repo-row:hover{border-color:var(--accent);background:#fff}.repo-row:disabled{opacity:.5;cursor:not-allowed}.schema-layout{display:grid;grid-template-columns:minmax(180px,240px) 1fr;gap:1.25rem;align-items:start}@media(max-width:720px){.schema-layout{grid-template-columns:1fr}}.schema-taglist{border:1px solid var(--border);border-radius:10px;padding:.5rem;background:var(--bg-soft);max-height:70vh;overflow-y:auto}.tag-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.25rem}.tag-list-item{width:100%;display:flex;flex-direction:column;gap:.2rem;align-items:flex-start;background:transparent;border:1px solid transparent;border-radius:7px;padding:.4rem .5rem;cursor:pointer;text-align:left;color:inherit;font:inherit}.tag-list-item:hover{background:#fff;border-color:var(--border)}.tag-list-item.selected{background:var(--accent-soft);border-color:var(--accent)}.tag-list-item .tag-name{font-size:.9rem}.tag-meta{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}.badge{display:inline-block;font-size:.7rem;line-height:1.4;padding:0 .4rem;border-radius:999px;background:var(--accent-soft);color:var(--accent);border:1px solid var(--accent)}.badge-strict{background:var(--error-soft);color:var(--error);border-color:var(--error)}.schema-detail{border:1px solid var(--border);border-radius:10px;padding:1rem 1.25rem;min-width:0}.schema-fields,.schema-parents{border:1px solid var(--border);border-radius:8px;padding:.75rem;margin:1rem 0}.schema-fields legend,.schema-parents legend{font-weight:600;font-size:.9rem;padding:0 .4rem}.field-row{border-bottom:1px solid var(--border-light);padding:.5rem 0}.field-row:last-of-type{border-bottom:none}.field-row-main{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}.field-name-input{flex:1 1 140px;min-width:120px}.field-row-main select{flex:0 0 auto;width:auto}.field-flag{display:inline-flex;align-items:center;gap:.25rem;font-size:.85rem;white-space:nowrap}.field-flag input{width:auto;margin:0}.field-remove{padding:.2rem .5rem;line-height:1}.field-row-extra{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.4rem}.field-enum-input,.field-desc-input{flex:1 1 200px;min-width:160px}.field-codeclarers{margin:.35rem 0 0}.field-codeclarers code{margin-left:.25rem}.parent-grid{display:flex;flex-wrap:wrap;gap:.4rem}.parent-chip{display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .55rem;font-size:.85rem}.parent-chip input{width:auto;margin:0}.schema-table{width:100%;border-collapse:collapse;font-size:.88rem}.schema-table th,.schema-table td{text-align:left;padding:.3rem .5rem;border-bottom:1px solid var(--border-light)}.schema-table th{font-weight:600;color:var(--fg-muted)}.schema-table td code{margin-right:.3rem}.schema-table .tag-self{background:var(--accent-soft);border-radius:4px;padding:0 .2rem}.schema-table .row-this-tag td{background:var(--accent-soft)}