@openparachute/vault 0.6.4-rc.8 → 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.
package/src/server.ts CHANGED
@@ -153,17 +153,23 @@ if (process.env.VAULT_AUTH_TOKEN?.trim()) {
153
153
  // logic lives in auth.ts (import-safe + unit-tested); see warnLegacyGlobalApiKeys.
154
154
  warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
155
155
 
156
- // Auto-init: create a default vault if none exist (first run in Docker).
157
- // The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
158
- // falls back to "default". Hub's first-boot wizard (hub#267) passes through
159
- // an operator-chosen name via this env var.
156
+ // First-boot vault creation ONLY when PARACHUTE_VAULT_NAME is explicitly
157
+ // set in the environment. (#478 Part 2)
158
+ //
159
+ // The hub setup wizard and `parachute init --vault-name <name>` both spawn
160
+ // the vault server with PARACHUTE_VAULT_NAME=<operator-chosen-name>; that
161
+ // env var is the load-bearing signal that an external orchestrator has
162
+ // decided what the first vault should be called. With the env var unset the
163
+ // server boots with zero vaults and stays that way — no silent "default"
164
+ // — so a fresh install without a wizard can't accidentally squash an
165
+ // explicit later `parachute-vault create <name>` call.
160
166
  //
161
167
  // Gated on the `auto_create: false` marker (2026-06-09 hub-module-boundary
162
168
  // migration): `parachute-vault remove` writes it when the operator deletes
163
169
  // their LAST vault, so an explicit empty-the-server action isn't silently
164
170
  // undone by a resurrection with fresh credentials on the next boot. Fresh
165
- // installs have no config.yaml — no marker — so Docker first-run still
166
- // auto-creates.
171
+ // installs have no config.yaml — no marker — so the env-var-driven path
172
+ // still works on a clean box.
167
173
  if (listVaults().length === 0) {
168
174
  const globalConfig = readGlobalConfig();
169
175
  if (!bootAutoCreateAllowed(globalConfig)) {
@@ -173,43 +179,57 @@ if (listVaults().length === 0) {
173
179
  } else if (!globalConfig.default_vault) {
174
180
  const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
175
181
  if (firstBoot.source === "env") {
176
- console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
182
+ // PARACHUTE_VAULT_NAME explicitly set + valid — create the named vault.
183
+ console.log(`[vault first-boot] PARACHUTE_VAULT_NAME=${firstBoot.name} — creating vault`);
184
+ const vaultName = firstBoot.name;
185
+ const { fullKey, keyId } = generateApiKey();
186
+ writeVaultConfig({
187
+ name: vaultName,
188
+ api_keys: [{
189
+ id: keyId,
190
+ label: "default",
191
+ scope: "write",
192
+ key_hash: hashKey(fullKey),
193
+ created_at: new Date().toISOString(),
194
+ }],
195
+ created_at: new Date().toISOString(),
196
+ });
197
+ globalConfig.default_vault = vaultName;
198
+ if (!globalConfig.api_keys?.length) {
199
+ globalConfig.api_keys = [{
200
+ id: keyId,
201
+ label: "default",
202
+ scope: "write",
203
+ key_hash: hashKey(fullKey),
204
+ created_at: new Date().toISOString(),
205
+ }];
206
+ }
207
+ writeGlobalConfig(globalConfig);
208
+ console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
209
+ // Seed the in-vault onboarding guide so a connected AI can self-orient and
210
+ // help set the vault up — same as the `create`/`init` CLI path. Idempotent
211
+ // + best-effort (never fails first boot). Mirrors createVault() in cli.ts.
212
+ await seedOnboardingNotesBestEffort(getVaultStore(vaultName));
177
213
  } else if (firstBoot.source === "env-invalid") {
214
+ // PARACHUTE_VAULT_NAME was set but failed validation — do NOT silently
215
+ // fall back to "default". Log the problem and stay empty so the operator
216
+ // sees the misconfiguration instead of a phantom vault.
178
217
  console.warn(
179
- `[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}); falling back to "default"`,
218
+ `[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}) server starting with zero vaults. Fix the env var, or create one via the admin wizard, parachute init --vault-name <name>, or parachute-vault create <name>.`,
180
219
  );
181
220
  } else {
182
- console.log("[vault first-boot] using default name (no PARACHUTE_VAULT_NAME set)");
183
- }
184
- const vaultName = firstBoot.name;
185
- const { fullKey, keyId } = generateApiKey();
186
- writeVaultConfig({
187
- name: vaultName,
188
- api_keys: [{
189
- id: keyId,
190
- label: "default",
191
- scope: "write",
192
- key_hash: hashKey(fullKey),
193
- created_at: new Date().toISOString(),
194
- }],
195
- created_at: new Date().toISOString(),
196
- });
197
- globalConfig.default_vault = vaultName;
198
- if (!globalConfig.api_keys?.length) {
199
- globalConfig.api_keys = [{
200
- id: keyId,
201
- label: "default",
202
- scope: "write",
203
- key_hash: hashKey(fullKey),
204
- created_at: new Date().toISOString(),
205
- }];
221
+ // PARACHUTE_VAULT_NAME unset stay empty. No silent "default".
222
+ console.log(
223
+ '[vault first-boot] no vaults and PARACHUTE_VAULT_NAME unset — staying empty. Create one via the admin wizard, parachute init --vault-name <name>, or parachute-vault create <name>.',
224
+ );
206
225
  }
207
- writeGlobalConfig(globalConfig);
208
- console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
209
- // Seed the in-vault onboarding guide so a connected AI can self-orient and
210
- // help set the vault up same as the `create`/`init` CLI path. Idempotent
211
- // + best-effort (never fails first boot). Mirrors createVault() in cli.ts.
212
- await seedOnboardingNotesBestEffort(getVaultStore(vaultName));
226
+ } else {
227
+ // Stale config: default_vault is set but no vault DB exists on disk, so
228
+ // first-boot create is skipped. Without a log line the server would boot
229
+ // empty and silent here, which is confusing. (#478 Part 2)
230
+ console.warn(
231
+ '[vault first-boot] config.yaml has default_vault set but no vaults exist on disk — skipping first-boot create. Create one with: parachute-vault create <name>',
232
+ );
213
233
  }
214
234
  }
215
235
 
@@ -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
 
@@ -141,13 +141,12 @@ describe("vault remove — last-vault auto_create marker", () => {
141
141
  // resurrection of a freshly-credentialed "default".
142
142
  expect(bootAutoCreateAllowed(config)).toBe(false);
143
143
 
144
- // services.json was still refreshed: with zero vaults the row falls back
145
- // to the manifest's canonical paths the SAME row a subsequent boot's
146
- // selfRegister writes, so CLI-remove and boot agree on the zero-vault
147
- // registration shape (and the hub still sees the module as installed).
144
+ // services.json was still refreshed: with zero vaults the row carries
145
+ // paths: [] (#478) — the row stays present so the hub sees the module as
146
+ // installed, but advertises no /vault/<name> path (no phantom default).
148
147
  const vault = readServices(home).find((s) => s.name === "parachute-vault");
149
148
  expect(vault).toBeDefined();
150
- expect(vault!.paths).toEqual(["/vault/default"]);
149
+ expect(vault!.paths).toEqual([]);
151
150
  });
152
151
 
153
152
  test("removing a NON-last vault does not write the marker", () => {
@@ -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)}