#!/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); });