Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
185 lines
6.8 KiB
JavaScript
185 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// S3: admin access-scopes SoT = directory_grants (full grant table), same as access-policy.
|
|
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1.
|
|
|
|
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const BASE = process.env.MNOTE_S3_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
|
const ACTOR_ID = process.env.MNOTE_S3_ACTOR_ID || "s3-grants-admin";
|
|
const OTHER_ID = process.env.MNOTE_S3_OTHER_ID || "s3-grants-other-user";
|
|
const AUTH = process.env.MNOTE_S3_AUTH || "Bearer s3-grants";
|
|
const ROOT =
|
|
process.env.MNOTE_S3_ROOT ||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-"));
|
|
const OTHER_ROOT =
|
|
process.env.MNOTE_S3_OTHER_ROOT ||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-other-"));
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
Authorization: AUTH,
|
|
"x-mnote-actor-id": ACTOR_ID,
|
|
"x-mnote-actor-type": "admin",
|
|
...(options.headers || {}),
|
|
};
|
|
const res = await fetch(url, { ...options, headers });
|
|
const text = await res.text();
|
|
let body = {};
|
|
try {
|
|
body = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
body = { raw: text };
|
|
}
|
|
return { status: res.status, body };
|
|
}
|
|
|
|
async function seed() {
|
|
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
seeds: [
|
|
{
|
|
kind: "setupWorkspace",
|
|
user_id: ACTOR_ID,
|
|
email: `${ACTOR_ID}@example.com`,
|
|
username: ACTOR_ID,
|
|
display_name: ACTOR_ID,
|
|
role: "admin",
|
|
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
|
workspace_id: `local-ws:${ACTOR_ID}:s3`,
|
|
workspace_name: "S3 grants admin",
|
|
root_uri: `file://${ROOT}`,
|
|
root_path: ROOT,
|
|
source_kind: "local_folder",
|
|
permission: "write",
|
|
capabilities: ["ai"],
|
|
grant_source: "s3_grants_smoke",
|
|
grant_created_by: ACTOR_ID,
|
|
},
|
|
{
|
|
kind: "setupWorkspace",
|
|
user_id: OTHER_ID,
|
|
email: `${OTHER_ID}@example.com`,
|
|
username: OTHER_ID,
|
|
display_name: OTHER_ID,
|
|
role: "user",
|
|
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
|
workspace_id: `local-ws:${OTHER_ID}:s3`,
|
|
workspace_name: "S3 grants other",
|
|
root_uri: `file://${OTHER_ROOT}`,
|
|
root_path: OTHER_ROOT,
|
|
source_kind: "local_folder",
|
|
permission: "write",
|
|
capabilities: ["ai"],
|
|
grant_source: "s3_grants_smoke_other",
|
|
grant_created_by: ACTOR_ID,
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
|
throw new Error(
|
|
"S3 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
|
);
|
|
}
|
|
assert(
|
|
seed.status === 200 && seed.body.ok === true,
|
|
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
|
);
|
|
}
|
|
|
|
function rootUris(allowedRoots) {
|
|
return (Array.isArray(allowedRoots) ? allowedRoots : [])
|
|
.map((r) => String(r.rootUri || r.root_uri || ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function userIds(allowedRoots) {
|
|
return (Array.isArray(allowedRoots) ? allowedRoots : [])
|
|
.map((r) => String(r.userId || r.user_id || ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function main() {
|
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
fs.mkdirSync(OTHER_ROOT, { recursive: true });
|
|
fs.writeFileSync(path.join(ROOT, "s3.md"), "# S3 grants\n", "utf8");
|
|
fs.writeFileSync(path.join(OTHER_ROOT, "other.md"), "# other\n", "utf8");
|
|
|
|
console.log(`\n🧪 S3 directory grants ≡ allowed roots (base=${BASE})\n`);
|
|
|
|
await seed();
|
|
|
|
const adminScopes = await fetchJson(`${BASE}/api/ai-admin/access-scopes`);
|
|
assert(
|
|
adminScopes.status === 200,
|
|
`admin access-scopes: ${adminScopes.status} ${JSON.stringify(adminScopes.body)}`,
|
|
);
|
|
assert(
|
|
String(adminScopes.body.sourceOfTruth || adminScopes.body.source_of_truth) === "directory_grants",
|
|
`sourceOfTruth expected directory_grants, got ${adminScopes.body.sourceOfTruth}`,
|
|
);
|
|
const adminUris = rootUris(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots);
|
|
const adminUsers = userIds(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots);
|
|
console.log(` admin scopes: ${adminUris.length} roots, users=${[...new Set(adminUsers)].join(",")}`);
|
|
|
|
// Admin SoT must include both admin actor grant and other user's grant (full table).
|
|
assert(
|
|
adminUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`),
|
|
`admin scopes missing admin root: ${JSON.stringify(adminUris)}`,
|
|
);
|
|
assert(
|
|
adminUris.some((u) => u.includes(OTHER_ROOT) || u === `file://${OTHER_ROOT}`),
|
|
`admin scopes missing OTHER user grant (must be full grant table, not actor-scoped): ${JSON.stringify(adminUris)}`,
|
|
);
|
|
assert(
|
|
adminUsers.includes(OTHER_ID) || adminUsers.some((u) => u.includes("s3-grants-other")),
|
|
`admin scopes should list other user_id: ${JSON.stringify(adminUsers)}`,
|
|
);
|
|
|
|
// User-facing scopes for admin actor should be actor-scoped (own grants only).
|
|
const userScopes = await fetchJson(`${BASE}/api/ai-settings/access-scopes`);
|
|
assert(userScopes.status === 200, `user access-scopes: ${userScopes.status}`);
|
|
assert(
|
|
String(userScopes.body.sourceOfTruth || userScopes.body.source_of_truth) === "directory_grants",
|
|
`user sourceOfTruth expected directory_grants`,
|
|
);
|
|
const userUris = rootUris(userScopes.body.allowedRoots || userScopes.body.allowed_roots);
|
|
assert(
|
|
userUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`),
|
|
`user scopes missing own root`,
|
|
);
|
|
// Own actor scopes should not include unrelated other-root as "mine" identity —
|
|
// but if system seeds extra grants for admin, still require admin endpoint ≥ user endpoint.
|
|
assert(
|
|
adminUris.length >= userUris.length,
|
|
`admin full table (${adminUris.length}) should cover at least actor scopes (${userUris.length})`,
|
|
);
|
|
console.log(` user (actor) scopes: ${userUris.length}; admin full: ${adminUris.length}`);
|
|
|
|
// Effective also declares directory_grants SoT.
|
|
const effective = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
|
assert(effective.status === 200, `effective: ${effective.status}`);
|
|
assert(
|
|
String(effective.body.sourceOfTruth || effective.body.source_of_truth) === "directory_grants",
|
|
`effective sourceOfTruth: ${effective.body.sourceOfTruth}`,
|
|
);
|
|
console.log(" effective.sourceOfTruth=directory_grants");
|
|
|
|
console.log("\n✅ S3 directory grants ≡ allowed roots smoke passed\n");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("\n❌ S3 smoke failed:", err.message || err);
|
|
process.exit(1);
|
|
});
|