@openparachute/vault 0.6.4-rc.9 → 0.6.5-rc.1
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/README.md +6 -4
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +313 -316
- package/core/src/notes.ts +73 -57
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/store.ts +79 -11
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/txn.test.ts +161 -0
- package/core/src/txn.ts +78 -0
- package/core/src/types.ts +24 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +198 -32
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +4 -4
- package/src/self-register.ts +1 -2
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
package/src/routing.test.ts
CHANGED
|
@@ -1956,6 +1956,131 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1956
1956
|
expect(res.status).toBe(200);
|
|
1957
1957
|
});
|
|
1958
1958
|
|
|
1959
|
+
// ----- vault#283: Schema editor read endpoints -------------------------
|
|
1960
|
+
|
|
1961
|
+
test("POST /api/tags/:name/conformance → counts notes violating a proposed spec", async () => {
|
|
1962
|
+
createVault("journal");
|
|
1963
|
+
const store = getVaultStore("journal");
|
|
1964
|
+
await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
|
|
1965
|
+
await store.createNote("missing", { tags: ["task"], metadata: {} });
|
|
1966
|
+
const admin = await createAdminToken("journal");
|
|
1967
|
+
|
|
1968
|
+
const path = "/vault/journal/api/tags/task/conformance";
|
|
1969
|
+
const res = await route(
|
|
1970
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1971
|
+
method: "POST",
|
|
1972
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
1973
|
+
body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
|
|
1974
|
+
}),
|
|
1975
|
+
path,
|
|
1976
|
+
);
|
|
1977
|
+
expect(res.status).toBe(200);
|
|
1978
|
+
const body = (await res.json()) as {
|
|
1979
|
+
tag: string;
|
|
1980
|
+
total_notes: number;
|
|
1981
|
+
violating_notes: number;
|
|
1982
|
+
checked_fields: string[];
|
|
1983
|
+
};
|
|
1984
|
+
expect(body.tag).toBe("task");
|
|
1985
|
+
expect(body.total_notes).toBe(2);
|
|
1986
|
+
expect(body.violating_notes).toBe(1);
|
|
1987
|
+
expect(body.checked_fields).toEqual(["status"]);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
test("POST /api/tags/:name/conformance is read-only — a read token may run it", async () => {
|
|
1991
|
+
createVault("journal");
|
|
1992
|
+
const store = getVaultStore("journal");
|
|
1993
|
+
await store.createNote("missing", { tags: ["task"], metadata: {} });
|
|
1994
|
+
const readToken = await mintToken("journal", {
|
|
1995
|
+
permission: "read",
|
|
1996
|
+
scopes: ["vault:read"],
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
const path = "/vault/journal/api/tags/task/conformance";
|
|
2000
|
+
const res = await route(
|
|
2001
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2002
|
+
method: "POST",
|
|
2003
|
+
headers: { authorization: `Bearer ${readToken}`, "content-type": "application/json" },
|
|
2004
|
+
body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
|
|
2005
|
+
}),
|
|
2006
|
+
path,
|
|
2007
|
+
);
|
|
2008
|
+
expect(res.status).toBe(200);
|
|
2009
|
+
const body = (await res.json()) as { violating_notes: number };
|
|
2010
|
+
expect(body.violating_notes).toBe(1);
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
test("PUT /api/tags/:name with replace_fields drops omitted fields (no merge resurrection)", async () => {
|
|
2014
|
+
createVault("journal");
|
|
2015
|
+
const store = getVaultStore("journal");
|
|
2016
|
+
await store.upsertTagRecord("task", {
|
|
2017
|
+
fields: { status: { type: "string" }, due: { type: "string" } },
|
|
2018
|
+
});
|
|
2019
|
+
const admin = await createAdminToken("journal");
|
|
2020
|
+
|
|
2021
|
+
const path = "/vault/journal/api/tags/task";
|
|
2022
|
+
// Send only `status` WITH replace_fields → `due` must be dropped.
|
|
2023
|
+
const res = await route(
|
|
2024
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2025
|
+
method: "PUT",
|
|
2026
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
2027
|
+
body: JSON.stringify({ fields: { status: { type: "string" } }, replace_fields: true }),
|
|
2028
|
+
}),
|
|
2029
|
+
path,
|
|
2030
|
+
);
|
|
2031
|
+
expect(res.status).toBe(200);
|
|
2032
|
+
const rec = await store.getTagRecord("task");
|
|
2033
|
+
expect(Object.keys(rec?.fields ?? {})).toEqual(["status"]);
|
|
2034
|
+
expect(rec?.fields?.due).toBeUndefined();
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
test("PUT /api/tags/:name WITHOUT replace_fields preserves omitted fields (merge — MCP contract)", async () => {
|
|
2038
|
+
createVault("journal");
|
|
2039
|
+
const store = getVaultStore("journal");
|
|
2040
|
+
await store.upsertTagRecord("task", {
|
|
2041
|
+
fields: { status: { type: "string" }, due: { type: "string" } },
|
|
2042
|
+
});
|
|
2043
|
+
const admin = await createAdminToken("journal");
|
|
2044
|
+
|
|
2045
|
+
const path = "/vault/journal/api/tags/task";
|
|
2046
|
+
// Send only `status` WITHOUT the flag → `due` preserved (partial update).
|
|
2047
|
+
const res = await route(
|
|
2048
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2049
|
+
method: "PUT",
|
|
2050
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
2051
|
+
body: JSON.stringify({ fields: { status: { type: "string", enum: ["a"] } } }),
|
|
2052
|
+
}),
|
|
2053
|
+
path,
|
|
2054
|
+
);
|
|
2055
|
+
expect(res.status).toBe(200);
|
|
2056
|
+
const rec = await store.getTagRecord("task");
|
|
2057
|
+
expect(Object.keys(rec?.fields ?? {}).sort()).toEqual(["due", "status"]);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
test("GET /api/tags/:name/effective → surfaces inherited fields + parents", async () => {
|
|
2061
|
+
createVault("journal");
|
|
2062
|
+
const store = getVaultStore("journal");
|
|
2063
|
+
// dev declares a field; dev/log inherits it via parent_names.
|
|
2064
|
+
await store.upsertTagRecord("dev", {
|
|
2065
|
+
fields: { area: { type: "string" } },
|
|
2066
|
+
});
|
|
2067
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
2068
|
+
const admin = await createAdminToken("journal");
|
|
2069
|
+
|
|
2070
|
+
const path = "/vault/journal/api/tags/dev%2Flog/effective";
|
|
2071
|
+
const res = await route(authed(admin, "GET", path), path);
|
|
2072
|
+
expect(res.status).toBe(200);
|
|
2073
|
+
const body = (await res.json()) as {
|
|
2074
|
+
name: string;
|
|
2075
|
+
parents: string[];
|
|
2076
|
+
effective_parents: string[];
|
|
2077
|
+
effective_fields: Record<string, { type?: string }>;
|
|
2078
|
+
};
|
|
2079
|
+
expect(body.parents).toEqual(["dev"]);
|
|
2080
|
+
expect(body.effective_parents).toContain("dev");
|
|
2081
|
+
expect(body.effective_fields.area?.type).toBe("string");
|
|
2082
|
+
});
|
|
2083
|
+
|
|
1959
2084
|
test("POST /api/tags/:name/rename → 200 cascades token allowlists (vault#240)", async () => {
|
|
1960
2085
|
createVault("journal");
|
|
1961
2086
|
const store = getVaultStore("journal");
|
package/src/routing.ts
CHANGED
|
@@ -831,9 +831,17 @@ export async function route(
|
|
|
831
831
|
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
832
832
|
// `vault:<vaultName>:<verb>` from hub JWTs) are handled by
|
|
833
833
|
// `hasScopeForVault`.
|
|
834
|
-
|
|
834
|
+
//
|
|
835
|
+
// Read-only POST carve-out (vault#283): the conformance check is a POST
|
|
836
|
+
// (it carries a proposed-fields body) but mutates nothing — it counts
|
|
837
|
+
// notes. Gate it on `read`, not `write`, so a read-scoped caller can
|
|
838
|
+
// preview a schema tightening without holding a write credential.
|
|
839
|
+
const apiSubpath = apiMatch[1] ?? "";
|
|
840
|
+
const isReadOnlyPost =
|
|
841
|
+
req.method === "POST" && /^\/tags\/[^/]+\/conformance$/.test(apiSubpath);
|
|
842
|
+
const requiredVerb = isReadOnlyPost ? "read" : verbForMethod(req.method);
|
|
835
843
|
if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
|
|
836
|
-
const requiredApiScope = scopeForMethod(req.method);
|
|
844
|
+
const requiredApiScope = isReadOnlyPost ? SCOPE_READ : scopeForMethod(req.method);
|
|
837
845
|
return Response.json(
|
|
838
846
|
{
|
|
839
847
|
error: "Forbidden",
|
|
@@ -62,17 +62,17 @@ function captureLogs(): {
|
|
|
62
62
|
|
|
63
63
|
describe("self-register", () => {
|
|
64
64
|
test("buildVaultServicePaths — no vaults yet → empty paths (no phantom /vault/default, #478)", () => {
|
|
65
|
-
expect(buildVaultServicePaths(undefined, []
|
|
65
|
+
expect(buildVaultServicePaths(undefined, [])).toEqual([]);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("buildVaultServicePaths — default vault sorts first", () => {
|
|
69
69
|
expect(
|
|
70
|
-
buildVaultServicePaths("default", ["alpha", "default", "beta"]
|
|
70
|
+
buildVaultServicePaths("default", ["alpha", "default", "beta"]),
|
|
71
71
|
).toEqual(["/vault/default", "/vault/alpha", "/vault/beta"]);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("buildVaultServicePaths — no default → map by listed order", () => {
|
|
75
|
-
expect(buildVaultServicePaths(undefined, ["alpha", "beta"]
|
|
75
|
+
expect(buildVaultServicePaths(undefined, ["alpha", "beta"])).toEqual([
|
|
76
76
|
"/vault/alpha",
|
|
77
77
|
"/vault/beta",
|
|
78
78
|
]);
|
|
@@ -80,7 +80,7 @@ describe("self-register", () => {
|
|
|
80
80
|
|
|
81
81
|
test("buildVaultServicePaths — default points to missing vault → ignore default", () => {
|
|
82
82
|
expect(
|
|
83
|
-
buildVaultServicePaths("missing", ["alpha", "beta"]
|
|
83
|
+
buildVaultServicePaths("missing", ["alpha", "beta"]),
|
|
84
84
|
).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
85
85
|
});
|
|
86
86
|
|
package/src/self-register.ts
CHANGED
|
@@ -69,7 +69,6 @@ import { listVaults, readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
|
69
69
|
export function buildVaultServicePaths(
|
|
70
70
|
defaultVault: string | undefined,
|
|
71
71
|
vaults: readonly string[],
|
|
72
|
-
fallbackFromManifest: readonly string[],
|
|
73
72
|
): string[] {
|
|
74
73
|
if (vaults.length === 0) return [];
|
|
75
74
|
if (defaultVault && vaults.includes(defaultVault)) {
|
|
@@ -165,7 +164,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
165
164
|
return { status: "failed", reason: msg };
|
|
166
165
|
}
|
|
167
166
|
|
|
168
|
-
const paths = buildVaultServicePaths(globalConfig.default_vault, vaults
|
|
167
|
+
const paths = buildVaultServicePaths(globalConfig.default_vault, vaults);
|
|
169
168
|
const port = globalConfig.port ?? DEFAULT_PORT;
|
|
170
169
|
|
|
171
170
|
// Derive the health path from the primary path (paths[0]) rather than
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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
|
|
166
|
-
//
|
|
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
|
-
|
|
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})
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
package/src/storage.test.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storage upload
|
|
2
|
+
* Storage upload policy tests (issue #127 origin; vault#517 deny-list).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `/storage
|
|
7
|
-
*
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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)}
|