diff --git a/capabilities.mjs b/capabilities.mjs index b464132..6be04ae 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -265,11 +265,25 @@ function mergeDeveloperTools(capabilities) { return tools; } +function resolveSandboxMcpLocalRoot(sandboxMcp) { + const localRoot = sandboxMcp?.workspaceRoot || sandboxMcp?.sandboxRoot || ''; + return localRoot ? String(localRoot) : ''; +} + +function resolveSandboxMcpCompatRoot(sandboxMcp) { + const compatRoot = sandboxMcp?.sandboxRoot || sandboxMcp?.workspaceRoot || ''; + return compatRoot ? String(compatRoot) : ''; +} + function sandboxMcpEnvs(sandboxMcp, mcpTools) { + const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); + const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); const envs = { - SANDBOX_ROOT: sandboxMcp.sandboxRoot, ALLOWED_TOOLS: mcpTools.join(','), }; + if (compatRoot) envs.SANDBOX_ROOT = compatRoot; + if (sandboxMcp.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot; + if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef; if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; for (const key of [ 'DATABASE_URL', @@ -289,9 +303,11 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) { * Build goose agent/start extension_overrides from resolved capability flags. * Returns null when the caller should use server defaults (admin / unrestricted). * - * sandboxMcp: { serverPath, sandboxRoot, nodeExecPath? } - * When provided for a static_publish user, the built-in developer extension is replaced - * by a sandboxed stdio MCP that enforces filesystem boundaries at the OS level. + * sandboxMcp: { serverPath, sandboxRoot?, workspaceRoot?, workspaceRef?, nodeExecPath? } + * `sandboxRoot` remains the legacy local-path compatibility field. + * `workspaceRoot` is the preferred local adapter field for future MindSpace runtime extraction. + * When a local root is available for a static_publish user, the built-in developer extension + * is replaced by a sandboxed stdio MCP that enforces filesystem boundaries at the OS level. */ export function buildAgentExtensionPolicy( capabilities, @@ -303,7 +319,9 @@ export function buildAgentExtensionPolicy( const extensions = []; if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) { - if (sandboxMcp?.serverPath && sandboxMcp?.sandboxRoot) { + const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); + const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); + if (sandboxMcp?.serverPath && localRoot) { // Sandboxed stdio MCP: enforces SANDBOX_ROOT at the OS level. // Replaces the built-in developer extension so path traversal is impossible. const mcpTools = sandboxMcpTools(capabilities); @@ -316,8 +334,9 @@ export function buildAgentExtensionPolicy( display_name: 'sandbox-fs', bundled: false, cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), - // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs - args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], + // Legacy MCP still accepts a local filesystem root as argv[2] even when the source + // capability is named workspaceRoot instead of sandboxRoot. + args: [sandboxMcp.serverPath, compatRoot], // envs (goosed field name) as belt-and-suspenders backup envs: sandboxMcpEnvs(sandboxMcp, mcpTools), available_tools: mcpTools, diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 3d06a17..d00d73c 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -283,3 +283,21 @@ test('sandboxMcp honors container node executable override', () => { const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); assert.equal(sandboxExt?.cmd, '/usr/local/bin/node'); }); + +test('sandboxMcp can use workspaceRoot as the local runtime root compatibility field', () => { + const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; + const policy = buildAgentExtensionPolicy(caps, { + sandboxMcp: { + serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', + workspaceRoot: '/opt/h5/MindSpace/abc123', + workspaceRef: 'mindspace://users/abc123/workspace', + }, + }); + + const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); + assert.ok(sandboxExt); + assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); + assert.equal(sandboxExt.envs.SANDBOX_ROOT, '/opt/h5/MindSpace/abc123'); + assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_ROOT, '/opt/h5/MindSpace/abc123'); + assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/abc123/workspace'); +}); diff --git a/docs/mindspace-seamless-migration-runbook.md b/docs/mindspace-seamless-migration-runbook.md new file mode 100644 index 0000000..7d5229c --- /dev/null +++ b/docs/mindspace-seamless-migration-runbook.md @@ -0,0 +1,324 @@ +# MindSpace Seamless Migration Runbook + +## Scope + +This runbook defines the no-loss migration path for extracting MindSpace from the Memind monolith. + +Current execution scope: + +- Phase A only +- local standalone service on the same machine +- no 103 usage +- no 105 rollout yet + +## Invariants + +- Existing Memind local behavior remains the baseline. +- `local` and `remote` adapter modes must stay switchable through env only. +- User-facing functionality must not regress when `remote` points to the local standalone service. +- Production release rules stay unchanged: merge to `main`, pass `bash scripts/check-release-ready.sh`, release from verified `main`. + +## Phase A Goal + +Run MindSpace as a standalone local service while Memind uses `MINDSPACE_SERVER_ADAPTER=remote`, with no intentional feature loss versus the current local adapter path. + +Phase A is complete when: + +- standalone MindSpace service uses `/Users/john/Project/MindSpace/runtime` by default +- Memind can use `MINDSPACE_SERVER_ADAPTER=remote` against the standalone service +- `npm run verify:phase-a-local` passes for a real user with existing data +- Memind remote-mode HTTP page list, page create, and page delete pass through the standalone service +- temporary verification data and login sessions are cleaned up +- remaining Memind-local responsibilities are documented instead of hidden + +## Current data-root policy + +- New default standalone data root: `/Users/john/Project/MindSpace/runtime` +- Old Memind directories are treated as migration source and rollback source +- Data-root migration is executed by script, not by ad hoc manual copying + +## Phase A Components + +### Memind + +- Shared adapter contract +- Local and remote adapter selection +- Remote transport with auth token, timeout, and configurable operation path +- Remote mode skips Memind-side generated-page workspace sync; background jobs stay on the standalone MindSpace runtime + +### Standalone MindSpace service + +Path: +- `/Users/john/Project/MindSpace` + +Responsibilities: +- bootstrap DB-backed local MindSpace services +- expose `POST /mindspace/v1/adapter/:binding/:method` +- expose `/health` and `/mindspace/v1/contract` +- manage publication cleanup +- manage optional workspace maintenance +- manage optional agent worker +- read existing session snapshots for conversation-package public-html hydration + +### Standalone data-root overrides + +Use these when you intentionally want a runtime root other than the default `/Users/john/Project/MindSpace/runtime`: + +- `MINDSPACE_SERVICE_H5_ROOT` +- `MINDSPACE_STORAGE_ROOT` +- `H5_USERS_ROOT` + +## Start Order + +### 1. Start standalone MindSpace service + +```bash +cd /Users/john/Project/MindSpace +npm run migrate:data-root -- --yes +MINDSPACE_MEMIND_ROOT=../Memind \ +MINDSPACE_REMOTE_AUTH_TOKEN=local-dev-token \ +MINDSPACE_SERVICE_PORT=19081 \ +MINDSPACE_SERVICE_AGENT_WORKER_ENABLED=true \ +node server.mjs +``` + +### 1b. Start standalone service against a new data root + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_MEMIND_ROOT=../Memind \ +MINDSPACE_SERVICE_H5_ROOT=/Users/john/Project/MindSpace/runtime \ +MINDSPACE_STORAGE_ROOT=/Users/john/Project/MindSpace/runtime/data/mindspace \ +H5_USERS_ROOT=/Users/john/Project/MindSpace/runtime/users \ +MINDSPACE_REMOTE_AUTH_TOKEN=local-dev-token \ +MINDSPACE_SERVICE_PORT=19081 \ +MINDSPACE_SERVICE_AGENT_WORKER_ENABLED=true \ +node server.mjs +``` + +### 2. Point Memind at the standalone service + +```bash +export MINDSPACE_SERVER_ADAPTER=remote +export MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 +export MINDSPACE_REMOTE_AUTH_TOKEN=local-dev-token +``` + +### 3. Start Memind normally + +Use the standard local startup flow already used in this repo. + +## Validation + +### Phase A local gate + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 \ +MINDSPACE_REMOTE_AUTH_TOKEN=local-dev-token \ +npm run verify:phase-a-local +``` + +Expected result: + +- standalone service is healthy, either reused if already running or started by the verifier +- RPC unit tests pass +- infra smoke, read parity, page write parity, asset upload parity, and conversation package parity all pass +- Memind remote-mode HTTP page list, page create, and page delete pass +- final read parity still reports no drift after write-path validation +- any service started by the verifier is stopped before exit + +Current verified local user: +- `1c99b83b-0454-474f-a5d2-129d34506a32` + +Latest verified totals from the Phase A gate: +- pages: `302 / 302` +- assets: `100 / 100` +- jobs: `0 / 0` +- drift counters: `onlyLocal = 0`, `onlyRemote = 0`, `changed = 0` + +### Memind remote-mode HTTP gate + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 \ +MEMIND_REMOTE_MODE_BASE_URL=http://127.0.0.1:18082 \ +MINDSPACE_REMOTE_AUTH_TOKEN=local-dev-token \ +npm run verify:memind-remote-mode +``` + +Expected result: + +- standalone MindSpace service is healthy +- Memind starts with `MINDSPACE_SERVER_ADAPTER=remote` +- a short-lived local login session can access Memind `/auth/status` +- Memind HTTP page list, page create, and page delete succeed through the remote adapter +- temporary login session is revoked and temporary page is deleted before exit + +### Infra smoke + +```bash +cd /Users/john/Project/MindSpace +node scripts/loopback-smoke.mjs +``` + +### User-scoped smoke + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_AUTH_TOKEN=local-dev-token \ +node scripts/loopback-smoke.mjs +``` + +Expected result: + +- `asset count`, `page count`, `job count` return real values for a user with existing MindSpace data +- empty collections are acceptable only when the chosen user truly has no stored data + +### Local vs remote comparison + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 \ +node scripts/compare-local-remote.mjs +``` + +Expected result: + +- page totals match +- asset totals match +- job totals match +- `onlyLocal`, `onlyRemote`, `changed` all stay `0` for stable read surfaces + +### Write-path parity + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 \ +node scripts/compare-write-local-remote.mjs +``` + +Expected result: + +- temporary local and remote pages are both created successfully +- `create -> publish -> offline -> delete` produces no semantic diff between local and remote comparisons +- temporary validation data is cleaned up before the script exits + +### Asset upload parity + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 \ +node scripts/compare-asset-local-remote.mjs +``` + +Expected result: + +- temporary local and remote text uploads both complete successfully +- `createUpload -> writeUploadContent -> completeUpload -> readAsset -> deleteAsset` produces no semantic diff +- temporary validation assets are deleted before the script exits + +### Conversation package parity + +```bash +cd /Users/john/Project/MindSpace +MINDSPACE_LOOPBACK_USER_ID= \ +MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:19081 \ +node scripts/compare-conversation-package-local-remote.mjs +``` + +Expected result: + +- temporary local and remote packages are both created successfully +- object write, artifact registration, manifest write, manifest file contents, and prepare-read outputs produce no semantic diff +- temporary validation package rows and storage objects are removed before the script exits + +### Manual UX validation + +- asset list and upload +- page list and page render +- page publish and public page access +- agent job create, run, and completion +- conversation package read for sessions that already have snapshots + +## Phase A Boundary Audit + +The following MindSpace surfaces are now covered by the standalone service adapter and Phase A gate: + +- page list/read/create/publish/offline/delete +- asset list/upload/write/complete/read/delete +- conversation package object write, artifact registration, manifest write, and prepare-read +- remote RPC transport including JSON-serialized `Buffer` arguments +- Memind HTTP page list/create/delete while `MINDSPACE_SERVER_ADAPTER=remote` + +The following responsibilities intentionally remain Memind-local in Phase A: + +- user auth, wallet/quota/category APIs, audit writes, and subscription integration still run in Memind +- public route HTTP serving is still mounted by Memind, although publication resolution and page rendering use MindSpace service APIs +- chat Finish public HTML materialization still writes to the Memind workspace path to preserve the verified `edit_file` regression guard +- chat-save preview/thumbnail flows still read workspace HTML from the Memind workspace path +- Goose/session workspace capability is still issued from Memind user auth and exposes the local workspace path plus `workspaceRef` + +Remote-mode guardrails added during Phase A: + +- Memind remote mode no longer runs local generated-page workspace sync before page listing +- remote adapter background jobs stay on the standalone MindSpace runtime +- Phase A verification uses short-lived login sessions and revokes them before exit + +Known Phase A residual risks: + +- real browser UI coverage is still manual; the automated HTTP gate covers Memind API behavior, not a full browser session +- full agent job execution and Goose workspace writes are not yet a standalone-service-only contract +- complete production separation still needs process supervision, TLS/domain routing, monitoring, and deployment rollback work in later phases + +## Rollback + +If any remote-path issue appears locally: + +```bash +unset MINDSPACE_SERVER_ADAPTER +unset MINDSPACE_REMOTE_BASE_URL +unset MINDSPACE_REMOTE_AUTH_TOKEN +``` + +Memind will fall back to the local adapter path. + +## Remaining Phase A hardening + +- add browser-level remote-mode smoke once UI automation credentials are available +- add a full agent job execution smoke that verifies workspace writes against the intended service runtime +- decide when chat Finish public HTML materialization moves from Memind to the standalone service + +## Data migration outline + +When you decide to move local data into the standalone root, use this order: + +1. Stop local writes or switch Memind back to `local` +2. Run `npm run migrate:data-root -- --yes` +3. Start standalone service +4. Run + - `npm run smoke:loopback` + - `MINDSPACE_LOOPBACK_USER_ID= npm run compare:local-remote` + - `MINDSPACE_LOOPBACK_USER_ID= npm run compare:write-local-remote` + - `MINDSPACE_LOOPBACK_USER_ID= npm run compare:asset-local-remote` + - `MINDSPACE_LOOPBACK_USER_ID= npm run compare:conversation-package-local-remote` + - `MINDSPACE_LOOPBACK_USER_ID= npm run verify:memind-remote-mode` +5. Only keep the new root if parity remains clean + +Rollback: + +- stop standalone service +- point Memind back to `local` or stop the standalone service +- if needed, start standalone with `MINDSPACE_SERVICE_H5_ROOT=/Users/john/Project/Memind` +- old Memind data root becomes active again immediately + +## Deferred + +- Phase C `105` integration +- Phase D production cutover diff --git a/mindspace-conversation-package-public-html.mjs b/mindspace-conversation-package-public-html.mjs new file mode 100644 index 0000000..b519b68 --- /dev/null +++ b/mindspace-conversation-package-public-html.mjs @@ -0,0 +1,132 @@ +import crypto from 'node:crypto'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + collectOwnPublicHtmlArtifactRefs, + normalizePublicHtmlRelativePath, +} from './mindspace-public-finish-sync.mjs'; +import { + buildMindSpacePublicUrlForUser, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; + +export async function registerPublicHtmlArtifactsForConversationPackage({ + conversationPackageRegistry, + h5Root, + env = process.env, + user, + publishDir, + sessionId, + title = null, + relativePaths = [], + artifactRefs = [], +} = {}) { + if ( + !conversationPackageRegistry || + !user?.id || + !publishDir || + !sessionId || + ((!Array.isArray(relativePaths) || relativePaths.length === 0) && + (!Array.isArray(artifactRefs) || artifactRefs.length === 0)) + ) { + return []; + } + try { + const publishRoot = path.resolve(publishDir); + const packageTitle = String(title ?? '').trim() || null; + const existingManifest = await conversationPackageRegistry + .readManifestForSession({ userId: user.id, sessionId }) + .catch(() => null); + const packageRecord = + existingManifest?.packageId && existingManifest.title + ? { id: existingManifest.packageId } + : await conversationPackageRegistry.ensurePackage({ + userId: user.id, + sessionId, + title: packageTitle ?? existingManifest?.title ?? null, + }); + const recorded = []; + const now = Date.now(); + const refs = + Array.isArray(artifactRefs) && artifactRefs.length > 0 + ? artifactRefs + : relativePaths.map((relativePath) => ({ relativePath })); + for (const ref of refs) { + const relativePath = typeof ref === 'string' ? ref : ref?.relativePath; + const normalized = normalizePublicHtmlRelativePath(relativePath); + if (!normalized || !normalized.toLowerCase().endsWith('.html')) continue; + const absolutePath = path.resolve(publishRoot, normalized); + if (absolutePath !== publishRoot && !absolutePath.startsWith(`${publishRoot}${path.sep}`)) continue; + let stat = null; + try { + stat = await fsPromises.stat(absolutePath); + } catch { + continue; + } + if (!stat.isFile()) continue; + const hash = crypto + .createHash('sha256') + .update(`${user.id}:${sessionId}:${normalized}`) + .digest('hex') + .slice(0, 16); + const artifactId = `ca_public_html_${hash}`; + await conversationPackageRegistry.recordArtifact({ + id: artifactId, + packageId: packageRecord.id, + artifactKind: 'public_html', + role: 'assistant', + messageId: + typeof ref?.messageId === 'string' && ref.messageId.trim() ? ref.messageId.trim() : null, + displayName: path.posix.basename(normalized), + mimeType: 'text/html', + sizeBytes: stat.size, + canonicalUrl: buildMindSpacePublicUrlForUser({ + h5Root, + env, + user, + relativePath: normalized, + }), + sortOrder: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : now, + now, + }); + recorded.push({ artifactId, relativePath: normalized }); + } + if (recorded.length > 0) { + await conversationPackageRegistry.writeManifestForSession({ + userId: user.id, + sessionId, + }); + } + return recorded; + } catch (error) { + console.warn('[MindSpace] failed to record public html artifacts:', error?.message ?? error); + return []; + } +} + +export function createConversationPackagePublicHtmlHydrator({ + h5Root, + resolveSessionSnapshot, + registerPublicHtmlArtifactsForConversation, +} = {}) { + return async function hydrateConversationPackagePublicHtmlArtifacts({ user, sessionId }) { + if (!user?.id || !sessionId || typeof resolveSessionSnapshot !== 'function') return []; + const snapshot = await resolveSessionSnapshot(sessionId).catch(() => null); + const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; + const publishDir = resolveMindSpaceUserPublishDir(h5Root, { id: user.id }); + const artifactRefs = collectOwnPublicHtmlArtifactRefs({ + messages, + currentUser: user, + publishDir, + }); + return ( + (await registerPublicHtmlArtifactsForConversation?.({ + user, + publishDir, + sessionId, + title: snapshot?.session?.name, + artifactRefs, + })) ?? [] + ); + }; +} diff --git a/mindspace-conversation-package-public-html.test.mjs b/mindspace-conversation-package-public-html.test.mjs new file mode 100644 index 0000000..e9818e1 --- /dev/null +++ b/mindspace-conversation-package-public-html.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + createConversationPackagePublicHtmlHydrator, + registerPublicHtmlArtifactsForConversationPackage, +} from './mindspace-conversation-package-public-html.mjs'; + +test('registerPublicHtmlArtifactsForConversationPackage records existing public html files', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-public-html-helper-')); + const publishDir = path.join(h5Root, 'MindSpace', 'user-1'); + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public', 'landing.html'), 'ok', 'utf8'); + + const recorded = []; + const registry = { + async readManifestForSession() { + return null; + }, + async ensurePackage() { + return { id: 'pkg-1' }; + }, + async recordArtifact(payload) { + recorded.push(payload); + }, + async writeManifestForSession() { + return { ok: true }; + }, + }; + + const result = await registerPublicHtmlArtifactsForConversationPackage({ + conversationPackageRegistry: registry, + h5Root, + env: { H5_PUBLIC_BASE_URL: 'https://example.com' }, + user: { id: 'user-1' }, + publishDir, + sessionId: 'session-1', + relativePaths: ['public/landing.html'], + }); + + assert.equal(result.length, 1); + assert.equal(recorded.length, 1); + assert.equal(recorded[0].packageId, 'pkg-1'); + assert.equal(recorded[0].canonicalUrl, 'https://example.com/MindSpace/user-1/public/landing.html'); +}); + +test('createConversationPackagePublicHtmlHydrator resolves snapshot artifacts through shared registration helper', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-public-html-hydrator-')); + const publishDir = path.join(h5Root, 'MindSpace', 'user-1'); + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public', 'landing.html'), 'ok', 'utf8'); + + const calls = []; + const hydrator = createConversationPackagePublicHtmlHydrator({ + h5Root, + resolveSessionSnapshot: async () => ({ + session: { name: 'Chat publish' }, + messages: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'See public/landing.html' }], + }, + ], + }), + registerPublicHtmlArtifactsForConversation: async (payload) => { + calls.push(payload); + return payload.artifactRefs; + }, + }); + + const result = await hydrator({ + user: { id: 'user-1' }, + sessionId: 'session-1', + }); + + assert.equal(result.length, 1); + assert.equal(calls.length, 1); + assert.equal(calls[0].publishDir, publishDir); + assert.equal(calls[0].artifactRefs[0].relativePath, 'public/landing.html'); +}); diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs new file mode 100644 index 0000000..ef9fa03 --- /dev/null +++ b/mindspace-local-runtime-services.mjs @@ -0,0 +1,89 @@ +import { createAssetService } from './mindspace-assets.mjs'; +import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs'; +import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs'; +import { createCleanupService } from './mindspace-cleanup.mjs'; +import { createAgentJobService } from './mindspace-agent-jobs.mjs'; +import { createAssetAgentService } from './mindspace-asset-agent.mjs'; +import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; +import { createPageService } from './mindspace-pages.mjs'; +import { createPublicationService } from './mindspace-publications.mjs'; +import { createMindSpaceLocalRuntime } from './mindspace-runtime-config.mjs'; + +export function createMindSpaceLocalRuntimeServices({ + pool, + h5Root, + env = process.env, + maxFileBytes, + publicPageLimit, + resolveUserIdForAgentSession, + conversationPackageBackfill, + conversationPackagePublicHtmlHydrator, + logger = console, +} = {}) { + let conversationPackageRegistry = null; + const runtime = createMindSpaceLocalRuntime({ + h5Root, + env, + logger, + conversationPackageBackfill: ({ user, sessionId }) => + conversationPackageBackfill?.({ + user, + sessionId, + storageRoot: runtime.storageRoot, + registry: conversationPackageRegistry, + }), + conversationPackagePublicHtmlHydrator, + }); + + conversationPackageRegistry = createConversationPackageRegistry({ + store: createConversationPackageStore(pool), + service: runtime.serviceFacade, + }); + + const assetService = createAssetService(pool, { + h5Root, + storageRoot: runtime.storageRoot, + maxFileBytes, + conversationPackageRegistry, + }); + const pageService = createPageService(pool, { + h5Root, + storageRoot: runtime.storageRoot, + conversationPackageRegistry, + }); + const publicationService = createPublicationService(pool, { + h5Root, + storageRoot: runtime.storageRoot, + publicPageLimit, + conversationPackageRegistry, + }); + const cleanupService = createCleanupService(pool, { + storageRoot: runtime.storageRoot, + h5Root, + }); + const agentJobService = createAgentJobService(pool, { + pageService, + storageRoot: runtime.storageRoot, + maxOutputBytes: maxFileBytes, + }); + const pageLiveEditService = createPageLiveEditService({ + pageService, + resolveUserIdForAgentSession, + }); + const assetAgentService = createAssetAgentService({ + assetService, + resolveUserIdForAgentSession, + }); + + return { + ...runtime, + conversationPackageRegistry, + assetService, + pageService, + publicationService, + cleanupService, + agentJobService, + pageLiveEditService, + assetAgentService, + }; +} diff --git a/mindspace-local-runtime-services.test.mjs b/mindspace-local-runtime-services.test.mjs new file mode 100644 index 0000000..7043309 --- /dev/null +++ b/mindspace-local-runtime-services.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMindSpaceLocalRuntimeServices } from './mindspace-local-runtime-services.mjs'; + +test('createMindSpaceLocalRuntimeServices assembles local runtime-backed service bindings', () => { + const pool = { + query: async () => { + throw new Error('not implemented in unit test'); + }, + }; + const services = createMindSpaceLocalRuntimeServices({ + pool, + h5Root: '/tmp/h5', + env: { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + maxFileBytes: 8192, + publicPageLimit: 8, + resolveUserIdForAgentSession: async () => 'user-1', + conversationPackageBackfill: () => [], + conversationPackagePublicHtmlHydrator: () => [], + logger: { info() {}, warn() {}, error() {} }, + }); + + assert.equal(services.storageRoot, '/srv/mindspace-data'); + assert.equal(services.publicBaseUrl, 'https://example.com'); + assert.equal(services.storageAdapter.kind, 'local-fs'); + assert.ok(services.serviceFacade); + assert.ok(services.conversationPackageRegistry); + assert.ok(services.assetService); + assert.ok(services.pageService); + assert.ok(services.publicationService); + assert.ok(services.cleanupService); + assert.ok(services.agentJobService); + assert.ok(services.pageLiveEditService); + assert.ok(services.assetAgentService); +}); diff --git a/mindspace-local-server-adapter.mjs b/mindspace-local-server-adapter.mjs new file mode 100644 index 0000000..da8a237 --- /dev/null +++ b/mindspace-local-server-adapter.mjs @@ -0,0 +1,157 @@ +import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs'; +import { createConversationPackagePublicHtmlHydrator } from './mindspace-conversation-package-public-html.mjs'; +import { createMindSpaceLocalRuntimeServices } from './mindspace-local-runtime-services.mjs'; + +export function createMindSpaceLocalServerAdapter({ + pool, + h5Root, + env = process.env, + maxFileBytes, + publicPageLimit, + resolveUserIdForAgentSession, + resolveSessionSnapshot, + registerPublicHtmlArtifactsForConversation, + logger = console, + setIntervalFn = setInterval, +} = {}) { + const services = createMindSpaceLocalRuntimeServices({ + pool, + h5Root, + env, + maxFileBytes, + publicPageLimit, + resolveUserIdForAgentSession, + conversationPackageBackfill: ({ user, sessionId, storageRoot, registry }) => + backfillConversationPackageArtifacts({ + pool, + registry, + storageRoot, + h5Root, + user, + sessionId, + }), + conversationPackagePublicHtmlHydrator: createConversationPackagePublicHtmlHydrator({ + h5Root, + resolveSessionSnapshot, + registerPublicHtmlArtifactsForConversation, + }), + logger, + }); + + return { + ...services, + kind: 'local', + implementationStatus: 'ready', + assertReady() { + return true; + }, + startPublicationCleanup(intervalMs = 60 * 1000) { + if (!services.publicationService || typeof setIntervalFn !== 'function') return null; + const timer = setIntervalFn(async () => { + try { + const result = await services.publicationService.cleanupExpiredUnconfirmedPublications(); + if (result.cleaned > 0) { + logger.log?.( + `[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`, + ); + } + } catch (error) { + logger.error?.('[Publication Cleanup Error]', error instanceof Error ? error.message : error); + } + }, intervalMs); + timer?.unref?.(); + return timer; + }, + startAgentWorker({ + enabled = false, + concurrency = 1, + pollMs = 1000, + staleMs = 5 * 60 * 1000, + runner, + } = {}) { + if (!enabled || !services.agentJobService || !runner || typeof setIntervalFn !== 'function') return null; + let inFlight = 0; + let draining = false; + const drainQueue = async () => { + if (draining) return; + draining = true; + try { + while (inFlight < concurrency) { + const claim = await services.agentJobService.claimNextJob(); + if (!claim) break; + inFlight += 1; + void runner + .runJob(claim.jobId, claim) + .catch((error) => { + logger.error?.('Agent worker job failed:', error); + }) + .finally(() => { + inFlight -= 1; + }); + } + } catch (error) { + logger.error?.('Agent worker drain failed:', error); + } finally { + draining = false; + } + }; + const workerTimer = setIntervalFn(() => { + void drainQueue(); + }, pollMs); + const reaperTimer = setIntervalFn(() => { + void services.agentJobService + .reapStaleJobs(staleMs) + .then((reaped) => { + if (reaped > 0) { + logger.warn?.(`Agent worker reaped ${reaped} stale running job(s)`); + } + }) + .catch((error) => { + logger.error?.('Agent worker reaper failed:', error); + }); + }, Math.min(staleMs, 60_000)); + workerTimer?.unref?.(); + reaperTimer?.unref?.(); + logger.log?.(`Agent job worker enabled (concurrency=${concurrency}, poll=${pollMs}ms)`); + return { workerTimer, reaperTimer }; + }, + startBackgroundJobs({ + publicationCleanupIntervalMs = 60 * 1000, + agentWorker, + agentRunner, + workspaceMaintenanceEnabled = false, + startWorkspaceThumbnailWatcher, + startWorkspaceAssetSyncWatcher, + publishRoot, + syncUserWorkspaceByDirKey, + expireStaleUploadsIntervalMs = 5 * 60 * 1000, + } = {}) { + const backgroundJobs = { + publicationCleanup: this.startPublicationCleanup(publicationCleanupIntervalMs), + agentWorker: this.startAgentWorker({ + ...agentWorker, + runner: agentRunner, + }), + workspaceMaintenance: null, + }; + if (!workspaceMaintenanceEnabled) { + logger.log?.('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)'); + return backgroundJobs; + } + startWorkspaceThumbnailWatcher?.(publishRoot); + startWorkspaceAssetSyncWatcher?.({ + publishRoot, + syncUserWorkspaceByDirKey, + }); + void services.assetService?.expireStaleUploads?.().catch(() => {}); + backgroundJobs.workspaceMaintenance = { + expireStaleUploadsTimer: + setIntervalFn(() => { + void services.assetService?.expireStaleUploads?.().catch(() => {}); + }, expireStaleUploadsIntervalMs), + }; + backgroundJobs.workspaceMaintenance.expireStaleUploadsTimer?.unref?.(); + return backgroundJobs; + }, + }; +} diff --git a/mindspace-local-server-adapter.test.mjs b/mindspace-local-server-adapter.test.mjs new file mode 100644 index 0000000..1be1ed7 --- /dev/null +++ b/mindspace-local-server-adapter.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createMindSpaceLocalServerAdapter } from './mindspace-local-server-adapter.mjs'; + +test('createMindSpaceLocalServerAdapter wires backfill and public html hydrator through local adapter seams', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-local-server-adapter-')); + await fs.mkdir(path.join(h5Root, 'MindSpace', 'user-1', 'public', 'landing'), { recursive: true }); + await fs.writeFile( + path.join(h5Root, 'MindSpace', 'user-1', 'public', 'landing', 'index.html'), + 'ok', + 'utf8', + ); + const snapshots = new Map([ + [ + 'session-1', + { + session: { name: 'Published from chat' }, + messages: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Preview ready at public/landing/index.html', + }, + ], + }, + ], + }, + ], + ]); + const pool = { + query: async () => { + throw new Error('not implemented in unit test'); + }, + }; + const registered = []; + const adapter = createMindSpaceLocalServerAdapter({ + pool, + h5Root, + env: { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + maxFileBytes: 8192, + publicPageLimit: 8, + resolveUserIdForAgentSession: async () => 'user-1', + resolveSessionSnapshot: async (sessionId) => snapshots.get(sessionId) ?? null, + registerPublicHtmlArtifactsForConversation: async (payload) => { + registered.push(payload); + return payload.artifactRefs; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + + const hydrated = await adapter.serviceFacade.prepareConversationPackageRead({ + user: { id: 'user-1' }, + sessionId: 'session-1', + }); + + assert.ok(adapter.conversationPackageRegistry); + assert.equal(adapter.storageRoot, '/srv/mindspace-data'); + assert.ok(Array.isArray(hydrated)); + assert.equal(hydrated.length, 1); + assert.equal(registered.length, 1); + assert.equal(registered[0].publishDir, path.join(h5Root, 'MindSpace', 'user-1')); + assert.equal(registered[0].title, 'Published from chat'); + assert.equal(registered[0].artifactRefs[0].relativePath, 'public/landing/index.html'); +}); + +test('createMindSpaceLocalServerAdapter starts publication cleanup and agent worker timers through adapter helpers', async () => { + const intervals = []; + const loggerCalls = []; + const adapter = createMindSpaceLocalServerAdapter({ + pool: { + query: async () => { + throw new Error('not implemented in unit test'); + }, + }, + h5Root: '/tmp/h5', + env: { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + maxFileBytes: 8192, + publicPageLimit: 8, + resolveUserIdForAgentSession: async () => 'user-1', + logger: { + log: (...args) => loggerCalls.push(['log', ...args]), + warn: (...args) => loggerCalls.push(['warn', ...args]), + error: (...args) => loggerCalls.push(['error', ...args]), + }, + setIntervalFn: (fn, ms) => { + const timer = { fn, ms, unref() {} }; + intervals.push(timer); + return timer; + }, + }); + + adapter.publicationService.cleanupExpiredUnconfirmedPublications = async () => ({ cleaned: 2 }); + adapter.agentJobService.claimNextJob = async () => null; + adapter.agentJobService.reapStaleJobs = async () => 1; + + const publicationTimer = adapter.startPublicationCleanup(); + const workerTimers = adapter.startAgentWorker({ + enabled: true, + concurrency: 2, + pollMs: 1500, + staleMs: 90_000, + runner: { + runJob: async () => {}, + }, + }); + + assert.equal(publicationTimer.ms, 60_000); + assert.equal(workerTimers.workerTimer.ms, 1500); + assert.equal(workerTimers.reaperTimer.ms, 60_000); + + await publicationTimer.fn(); + await workerTimers.workerTimer.fn(); + await workerTimers.reaperTimer.fn(); + + assert.equal(intervals.length, 3); + assert.ok( + loggerCalls.some( + ([level, message]) => + level === 'log' && String(message).includes('Auto-privatized 2 expired unconfirmed publications'), + ), + ); + assert.ok( + loggerCalls.some( + ([level, message]) => + level === 'log' && String(message).includes('Agent job worker enabled (concurrency=2, poll=1500ms)'), + ), + ); + assert.ok( + loggerCalls.some( + ([level, message]) => + level === 'warn' && String(message).includes('Agent worker reaped 1 stale running job(s)'), + ), + ); +}); + +test('createMindSpaceLocalServerAdapter startBackgroundJobs aggregates startup side effects', async () => { + const intervals = []; + const workspaceCalls = []; + const adapter = createMindSpaceLocalServerAdapter({ + pool: { + query: async () => { + throw new Error('not implemented in unit test'); + }, + }, + h5Root: '/tmp/h5', + env: { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + maxFileBytes: 8192, + publicPageLimit: 8, + resolveUserIdForAgentSession: async () => 'user-1', + logger: { log() {}, warn() {}, error() {} }, + setIntervalFn: (fn, ms) => { + const timer = { fn, ms, unref() {} }; + intervals.push(timer); + return timer; + }, + }); + + adapter.publicationService.cleanupExpiredUnconfirmedPublications = async () => ({ cleaned: 0 }); + adapter.agentJobService.claimNextJob = async () => null; + adapter.agentJobService.reapStaleJobs = async () => 0; + adapter.assetService.expireStaleUploads = async () => {}; + + const jobs = adapter.startBackgroundJobs({ + publicationCleanupIntervalMs: 61_000, + agentWorker: { + enabled: true, + concurrency: 2, + pollMs: 1500, + staleMs: 90_000, + }, + agentRunner: { runJob: async () => {} }, + workspaceMaintenanceEnabled: true, + publishRoot: '/tmp/h5/MindSpace', + startWorkspaceThumbnailWatcher: (root) => workspaceCalls.push(['thumb', root]), + startWorkspaceAssetSyncWatcher: (payload) => workspaceCalls.push(['asset', payload.publishRoot]), + syncUserWorkspaceByDirKey: async () => {}, + expireStaleUploadsIntervalMs: 30_000, + }); + + assert.equal(jobs.publicationCleanup.ms, 61_000); + assert.equal(jobs.agentWorker.workerTimer.ms, 1500); + assert.equal(jobs.workspaceMaintenance.expireStaleUploadsTimer.ms, 30_000); + assert.deepEqual(workspaceCalls, [ + ['thumb', '/tmp/h5/MindSpace'], + ['asset', '/tmp/h5/MindSpace'], + ]); +}); diff --git a/mindspace-public-asset-token.mjs b/mindspace-public-asset-token.mjs new file mode 100644 index 0000000..1d0b2a5 --- /dev/null +++ b/mindspace-public-asset-token.mjs @@ -0,0 +1,40 @@ +import crypto from 'node:crypto'; + +function normalizeSecret(secret) { + return String(secret ?? ''); +} + +export function publicAssetToken(assetId, secret) { + const normalizedAssetId = String(assetId ?? '').trim(); + if (!normalizedAssetId) return ''; + return crypto + .createHmac('sha256', normalizeSecret(secret)) + .update(`mindspace-public-asset:v1:${normalizedAssetId}`) + .digest('base64url'); +} + +export function verifyPublicAssetToken(assetId, token, secret) { + const value = Array.isArray(token) ? token[0] : token; + const provided = String(value ?? '').trim(); + if (!provided) return false; + const expected = publicAssetToken(assetId, secret); + const providedBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(expected); + return ( + providedBuffer.length === expectedBuffer.length && + crypto.timingSafeEqual(providedBuffer, expectedBuffer) + ); +} + +export function appendPublicAssetTokens(html, secret) { + const source = String(html ?? ''); + if (!source || !source.includes('/api/mindspace/v1/assets/')) return source; + return source.replace( + /((?:https?:\/\/[^"'<>\\\s]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download)(\?[^"'<>\\\s)]*)?/gi, + (value, base, assetId, query = '') => { + if (/(?:\?|&|&)public_token=/.test(query)) return value; + const separator = query ? (query.includes('&') ? '&' : '&') : '?'; + return `${base}${query}${separator}public_token=${publicAssetToken(assetId, secret)}`; + }, + ); +} diff --git a/mindspace-public-asset-token.test.mjs b/mindspace-public-asset-token.test.mjs new file mode 100644 index 0000000..49cec9f --- /dev/null +++ b/mindspace-public-asset-token.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + appendPublicAssetTokens, + publicAssetToken, + verifyPublicAssetToken, +} from './mindspace-public-asset-token.mjs'; + +const SECRET = 'internal-agent-secret-for-tests'; + +test('public asset token round-trips for the matching asset id', () => { + const token = publicAssetToken('asset-1', SECRET); + assert.equal(verifyPublicAssetToken('asset-1', token, SECRET), true); + assert.equal(verifyPublicAssetToken('asset-2', token, SECRET), false); +}); + +test('appendPublicAssetTokens signs public asset download urls without clobbering existing query params', () => { + const html = [ + '', + '', + ].join(''); + + const signed = appendPublicAssetTokens(html, SECRET); + + assert.match( + signed, + /asset-1\/download\?inline=1&v=2&public_token=[A-Za-z0-9_-]+/, + ); + assert.match( + signed, + /asset-2\/download\?inline=1&v=3&public_token=[A-Za-z0-9_-]+/, + ); +}); + +test('appendPublicAssetTokens preserves existing public tokens', () => { + const html = ''; + assert.equal(appendPublicAssetTokens(html, SECRET), html); +}); diff --git a/mindspace-public-delivery.mjs b/mindspace-public-delivery.mjs new file mode 100644 index 0000000..8f96024 --- /dev/null +++ b/mindspace-public-delivery.mjs @@ -0,0 +1,82 @@ +import path from 'node:path'; + +export async function handleMindSpaceLongImageDownload({ + query, + filePath, + pageUrl = null, + res, + isLongImageDownloadRequest, + longImagePathForHtml, + renderLongImage, +} = {}) { + if (!isLongImageDownloadRequest?.(query)) return false; + const longImagePath = longImagePathForHtml(filePath); + try { + await renderLongImage(pageUrl + ? { url: pageUrl, outputPath: longImagePath } + : { htmlPath: filePath, outputPath: longImagePath }); + res.set('Cache-Control', 'no-store'); + res.download(longImagePath, path.basename(longImagePath), (err) => { + if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' }); + }); + } catch (error) { + res + .status(500) + .type('text/plain; charset=utf-8') + .send(`长图生成失败:${error?.message || '未知错误'}`); + } + return true; +} + +export function decorateMindSpacePublishedHtml({ + html, + embed = false, + context, + userAgent = '', + preparePublicationHtmlForEmbed, + injectOgTags, + injectWechatShareBridge, + injectPublicFileShareButton, + publishedPageCsp, + isWechatUserAgent, +} = {}) { + let nextHtml = html; + let allowEmbedFrame = false; + + if (embed) { + nextHtml = preparePublicationHtmlForEmbed(nextHtml); + allowEmbedFrame = true; + } + + try { + nextHtml = injectOgTags(nextHtml, { + origin: context.origin, + pageUrl: context.pageUrl, + pageDirUrl: context.pageDirUrl, + fallbackImageUrl: context.fallbackImageUrl, + }); + const wechatShare = !embed && isWechatUserAgent(userAgent || ''); + if (wechatShare) { + nextHtml = injectWechatShareBridge(nextHtml, { pageUrl: context.pageUrl }); + } + const shareInjection = !embed + ? injectPublicFileShareButton(nextHtml) + : { html: nextHtml, scriptHashes: [] }; + nextHtml = shareInjection.html; + return { + html: nextHtml, + csp: publishedPageCsp(nextHtml, { + embed, + wechatShare, + scriptHashes: shareInjection.scriptHashes, + }), + allowEmbedFrame, + }; + } catch { + return { + html: nextHtml, + csp: publishedPageCsp(nextHtml, { embed }), + allowEmbedFrame, + }; + } +} diff --git a/mindspace-public-delivery.test.mjs b/mindspace-public-delivery.test.mjs new file mode 100644 index 0000000..7a9d1be --- /dev/null +++ b/mindspace-public-delivery.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { decorateMindSpacePublishedHtml, handleMindSpaceLongImageDownload } from './mindspace-public-delivery.mjs'; + +test('handleMindSpaceLongImageDownload renders and downloads when requested', async () => { + const calls = []; + const res = { + headersSent: false, + set(key, value) { + calls.push(['set', key, value]); + return this; + }, + download(filePath, filename, cb) { + calls.push(['download', filePath, filename]); + cb?.(); + }, + status(code) { + calls.push(['status', code]); + return this; + }, + type(value) { + calls.push(['type', value]); + return this; + }, + send(value) { + calls.push(['send', value]); + return this; + }, + json(value) { + calls.push(['json', value]); + return this; + }, + }; + + const handled = await handleMindSpaceLongImageDownload({ + query: { download: 'long-image' }, + filePath: '/tmp/report.html', + pageUrl: 'https://mm.tkmind.cn/MindSpace/u/public/report.html', + res, + isLongImageDownloadRequest: () => true, + longImagePathForHtml: () => '/tmp/report.long.png', + renderLongImage: async (...args) => { + calls.push(['render', ...args]); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls[0], ['render', { + url: 'https://mm.tkmind.cn/MindSpace/u/public/report.html', + outputPath: '/tmp/report.long.png', + }]); + assert.deepEqual(calls[1], ['set', 'Cache-Control', 'no-store']); + assert.deepEqual(calls[2], ['download', '/tmp/report.long.png', 'report.long.png']); +}); + +test('handleMindSpaceLongImageDownload falls back to local html path without page url', async () => { + const calls = []; + const res = { + headersSent: false, + set() { return this; }, + download() {}, + status() { return this; }, + type() { return this; }, + send() { return this; }, + }; + + await handleMindSpaceLongImageDownload({ + query: { download: 'long-image' }, + filePath: '/tmp/report.html', + res, + isLongImageDownloadRequest: () => true, + longImagePathForHtml: () => '/tmp/report.long.png', + renderLongImage: async (...args) => { + calls.push(['render', ...args]); + }, + }); + + assert.deepEqual(calls[0], ['render', { htmlPath: '/tmp/report.html', outputPath: '/tmp/report.long.png' }]); +}); + +test('decorateMindSpacePublishedHtml returns decorated html and csp', () => { + const result = decorateMindSpacePublishedHtml({ + html: 'demo', + embed: false, + context: { + origin: 'https://mm.tkmind.cn', + pageUrl: 'https://mm.tkmind.cn/MindSpace/u/public/report.html', + pageDirUrl: 'https://mm.tkmind.cn/MindSpace/u/public/', + fallbackImageUrl: 'https://mm.tkmind.cn/MindSpace/u/public/report.thumbnail.png', + }, + userAgent: 'MicroMessenger', + preparePublicationHtmlForEmbed: (value) => value, + injectOgTags: (value, meta) => `${value}`, + injectWechatShareBridge: (value, meta) => `${value}`, + injectPublicFileShareButton: (value) => ({ html: `${value}`, scriptHashes: ['abc'] }), + publishedPageCsp: (_value, options) => JSON.stringify(options), + isWechatUserAgent: () => true, + }); + + assert.match(result.html, /og:/); + assert.match(result.html, /wechat:/); + assert.match(result.html, /share/); + assert.equal(result.allowEmbedFrame, false); + assert.equal(result.csp, JSON.stringify({ embed: false, wechatShare: true, scriptHashes: ['abc'] })); +}); + +test('decorateMindSpacePublishedHtml supports embed mode without share injection', () => { + const result = decorateMindSpacePublishedHtml({ + html: '', + embed: true, + context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' }, + preparePublicationHtmlForEmbed: (value) => `${value}`, + injectOgTags: (value) => value, + injectWechatShareBridge: (value) => value, + injectPublicFileShareButton: (value) => ({ html: value, scriptHashes: [] }), + publishedPageCsp: (_value, options) => JSON.stringify(options), + isWechatUserAgent: () => false, + }); + + assert.match(result.html, /embed/); + assert.equal(result.allowEmbedFrame, true); + assert.equal(result.csp, JSON.stringify({ embed: true, wechatShare: false, scriptHashes: [] })); +}); diff --git a/mindspace-public-page-context.mjs b/mindspace-public-page-context.mjs new file mode 100644 index 0000000..d181aaf --- /dev/null +++ b/mindspace-public-page-context.mjs @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const LOCAL_HOST_PATTERN = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i; + +export function resolvePublicRequestOrigin({ hostHeader = '', forwardedProto = '', protocol = 'http' } = {}) { + const host = String(hostHeader ?? '').split(',')[0].trim(); + if (!host) return ''; + const fwdProto = String(forwardedProto ?? '').split(',')[0].trim(); + const isLocalHost = LOCAL_HOST_PATTERN.test(host); + const resolvedProtocol = isLocalHost ? fwdProto || protocol || 'http' : 'https'; + return `${resolvedProtocol}://${host}`; +} + +export function buildPublishedHtmlViewContext({ + origin = '', + requestPath = '', + filePath = '', + thumbnailPngPathForSvg = (value) => value, + fileExists = fs.existsSync, +} = {}) { + const cleanPath = String(requestPath ?? '').split('?')[0].split('#')[0]; + const servedName = path.basename(String(filePath ?? '')); + const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? ''); + const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase(); + const pageUrl = !origin + ? '' + : `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}`; + const pageDirUrl = !origin + ? '' + : isImplicitIndex + ? `${origin}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}` + : `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`; + + let fallbackImageUrl = ''; + const svgSibling = String(filePath ?? '').replace(/\.[^./]+$/, '.thumbnail.svg'); + if (pageDirUrl && fileExists(svgSibling)) { + const pngName = path.basename(thumbnailPngPathForSvg(svgSibling)); + fallbackImageUrl = `${pageDirUrl}${pngName}`; + } + + return { + origin, + cleanPath, + pageUrl, + pageDirUrl, + fallbackImageUrl, + }; +} + +export const mindspacePublicPageContextInternals = { + LOCAL_HOST_PATTERN, +}; diff --git a/mindspace-public-page-context.test.mjs b/mindspace-public-page-context.test.mjs new file mode 100644 index 0000000..12c6cd8 --- /dev/null +++ b/mindspace-public-page-context.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPublishedHtmlViewContext, resolvePublicRequestOrigin } from './mindspace-public-page-context.mjs'; + +test('resolvePublicRequestOrigin prefers https for public hosts and forwarded proto for local hosts', () => { + assert.equal( + resolvePublicRequestOrigin({ hostHeader: 'mm.tkmind.cn', forwardedProto: 'http', protocol: 'http' }), + 'https://mm.tkmind.cn', + ); + assert.equal( + resolvePublicRequestOrigin({ hostHeader: '127.0.0.1:8081', forwardedProto: 'http', protocol: 'https' }), + 'http://127.0.0.1:8081', + ); +}); + +test('buildPublishedHtmlViewContext builds file and directory page urls', () => { + const direct = buildPublishedHtmlViewContext({ + origin: 'https://mm.tkmind.cn', + requestPath: '/MindSpace/user-1/public/report.html?x=1', + filePath: '/tmp/MindSpace/user-1/public/report.html', + }); + assert.equal(direct.pageUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/report.html'); + assert.equal(direct.pageDirUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/'); + + const implicit = buildPublishedHtmlViewContext({ + origin: 'https://mm.tkmind.cn', + requestPath: '/MindSpace/user-1/public/report', + filePath: '/tmp/MindSpace/user-1/public/index.html', + }); + assert.equal(implicit.pageUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/report/'); + assert.equal(implicit.pageDirUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/report/'); +}); + +test('buildPublishedHtmlViewContext adds thumbnail png fallback when svg sibling exists', () => { + const context = buildPublishedHtmlViewContext({ + origin: 'https://mm.tkmind.cn', + requestPath: '/MindSpace/user-1/public/report.html', + filePath: '/tmp/MindSpace/user-1/public/report.html', + fileExists: (value) => value.endsWith('.thumbnail.svg'), + thumbnailPngPathForSvg: (value) => value.replace('.svg', '.png'), + }); + assert.equal( + context.fallbackImageUrl, + 'https://mm.tkmind.cn/MindSpace/user-1/public/report.thumbnail.png', + ); +}); diff --git a/mindspace-public-route.mjs b/mindspace-public-route.mjs new file mode 100644 index 0000000..7025b55 --- /dev/null +++ b/mindspace-public-route.mjs @@ -0,0 +1,221 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + buildMindSpacePublicRoutePath, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; +import { PUBLIC_ZONE_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs'; + +const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/; +const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i; + +export async function resolveMindSpacePublishOwnerKey(segment, resolveUsernameToUserId) { + const lower = String(segment ?? '').trim().toLowerCase(); + if (!lower) return null; + if (PUBLISH_KEY_UUID.test(lower)) return lower; + if (!USERNAME_SLUG.test(lower)) return null; + const resolved = await resolveUsernameToUserId?.(lower); + return resolved ? String(resolved).toLowerCase() : lower; +} + +export function decodeMindSpaceRouteSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +export function resolveMindSpacePublicRouteTarget({ h5Root, ownerKey, parts = [] }) { + const targetDir = resolveMindSpaceUserPublishDir(h5Root, { id: ownerKey }); + const resolvedRoot = path.resolve(targetDir); + const resolvedPath = path.resolve(path.join(targetDir, ...parts)); + return { + targetDir, + resolvedRoot, + resolvedPath, + insideRoot: + resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`), + }; +} + +export function buildMindSpaceCanonicalRedirect(ownerKey, parts = []) { + return buildMindSpacePublicRoutePath(ownerKey, parts); +} + +export function resolveMindSpaceHtmlFallbackRedirect({ ownerKey, targetDir, resolvedRoot, rest = [] }) { + if (rest.length === 1 && String(rest[0]).toLowerCase().endsWith('.html')) { + const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); + if ( + publicFallback.startsWith(`${resolvedRoot}${path.sep}`) && + fs.existsSync(publicFallback) && + fs.statSync(publicFallback).isFile() + ) { + return buildMindSpacePublicRoutePath(ownerKey, [PUBLIC_ZONE_DIR, rest[0]]); + } + } + return null; +} + +export function resolveMindSpaceSimilarHtmlRedirect({ ownerKey, relativePath }) { + if (!relativePath) return null; + return buildMindSpacePublicRoutePath(ownerKey, String(relativePath).split('/')); +} + +export async function recoverMisplacedMindSpacePublicHtml({ + h5Root, + targetDir, + resolvedRoot, + rest = [], + ensureThumbnail, + logger = console, +} = {}) { + if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null; + const filename = rest[1]; + if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null; + + const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename); + if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null; + const candidates = [ + path.resolve(h5Root, filename), + path.resolve(h5Root, PUBLIC_ZONE_DIR, filename), + ]; + for (const candidate of candidates) { + if (candidate === destination) continue; + if (!candidate.startsWith(`${path.resolve(h5Root)}${path.sep}`)) continue; + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(candidate, destination); + await ensureThumbnail?.(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`); + logger?.warn?.( + `[MindSpace] recovered misplaced public HTML ${path.relative(h5Root, candidate)} -> ${path.relative(h5Root, destination)}`, + ); + return destination; + } + return null; +} + +export async function resolveMindSpacePublicRequest({ + h5Root, + requestPath = '', + resolveUsernameToUserId, + resolveClosestHtmlRelativePath, + ensureThumbnail, + logger = console, +} = {}) { + const parts = String(requestPath || '') + .split('/') + .filter(Boolean); + if (parts.length < 1) { + return { action: 'not_found', reason: 'missing_path' }; + } + + const ownerKey = await resolveMindSpacePublishOwnerKey(parts[0], resolveUsernameToUserId); + if (!ownerKey) { + return { action: 'not_found', reason: 'missing_owner' }; + } + + if (parts[0].toLowerCase() !== ownerKey && parts[0].trim()) { + return { + action: 'redirect', + status: 301, + location: buildMindSpaceCanonicalRedirect(ownerKey, parts.slice(1)), + ownerKey, + }; + } + + const rest = parts.slice(1).map(decodeMindSpaceRouteSegment); + const routeTarget = resolveMindSpacePublicRouteTarget({ + h5Root, + ownerKey, + parts: rest, + }); + const { targetDir, resolvedRoot, resolvedPath } = routeTarget; + + if (!routeTarget.insideRoot) { + return { action: 'forbidden', reason: 'outside_publish_root', ownerKey }; + } + + if (!fs.existsSync(targetDir)) { + return { action: 'not_found', reason: 'missing_owner_dir', ownerKey }; + } + + if (!fs.existsSync(resolvedPath)) { + const publicFallbackRedirect = resolveMindSpaceHtmlFallbackRedirect({ + ownerKey, + targetDir, + resolvedRoot, + rest, + }); + if (publicFallbackRedirect) { + return { + action: 'redirect', + status: 301, + location: publicFallbackRedirect, + ownerKey, + }; + } + + if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) { + const similarRelativePath = await resolveClosestHtmlRelativePath?.( + targetDir, + `${PUBLIC_ZONE_DIR}/${rest[1]}`, + ); + if (similarRelativePath) { + return { + action: 'redirect', + status: 301, + location: resolveMindSpaceSimilarHtmlRedirect({ + ownerKey, + relativePath: similarRelativePath, + }), + ownerKey, + }; + } + } + + const recoveredPublicHtml = await recoverMisplacedMindSpacePublicHtml({ + h5Root, + targetDir, + resolvedRoot, + rest, + ensureThumbnail, + logger, + }); + if (recoveredPublicHtml) { + return { + action: 'serve', + filePath: recoveredPublicHtml, + ownerKey, + targetDir, + }; + } + + return { action: 'not_found', reason: 'missing_file', ownerKey }; + } + + if (fs.statSync(resolvedPath).isDirectory()) { + const indexPath = path.join(resolvedPath, 'index.html'); + if (fs.existsSync(indexPath)) { + return { + action: 'serve', + filePath: indexPath, + ownerKey, + targetDir, + }; + } + return { action: 'not_found', reason: 'missing_directory_index', ownerKey }; + } + + return { + action: 'serve', + filePath: resolvedPath, + ownerKey, + targetDir, + }; +} + +export const mindspacePublicRouteInternals = { + USERNAME_SLUG, + MISPLACED_PUBLIC_HTML_NAME, +}; diff --git a/mindspace-public-route.test.mjs b/mindspace-public-route.test.mjs new file mode 100644 index 0000000..af1f143 --- /dev/null +++ b/mindspace-public-route.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildMindSpaceCanonicalRedirect, + decodeMindSpaceRouteSegment, + recoverMisplacedMindSpacePublicHtml, + resolveMindSpacePublicRequest, + resolveMindSpaceHtmlFallbackRedirect, + resolveMindSpacePublishOwnerKey, + resolveMindSpacePublicRouteTarget, + resolveMindSpaceSimilarHtmlRedirect, +} from './mindspace-public-route.mjs'; + +test('resolveMindSpacePublishOwnerKey keeps uuid and resolves usernames', async () => { + assert.equal( + await resolveMindSpacePublishOwnerKey('123e4567-e89b-12d3-a456-426614174000'), + '123e4567-e89b-12d3-a456-426614174000', + ); + assert.equal( + await resolveMindSpacePublishOwnerKey('John', async (username) => `${username}-id`), + 'john-id', + ); + assert.equal(await resolveMindSpacePublishOwnerKey('../bad', async () => 'x'), null); +}); + +test('public route target resolves inside publish root', () => { + const result = resolveMindSpacePublicRouteTarget({ + h5Root: '/tmp/h5', + ownerKey: 'user-1', + parts: ['public', 'report.html'], + }); + assert.equal(result.targetDir, path.resolve('/tmp/h5/MindSpace/user-1')); + assert.equal(result.insideRoot, true); + assert.equal(result.resolvedPath, path.resolve('/tmp/h5/MindSpace/user-1/public/report.html')); +}); + +test('canonical redirect helpers normalize route paths', () => { + assert.equal(buildMindSpaceCanonicalRedirect('user-1', ['public', 'a b.html']), '/MindSpace/user-1/public/a%20b.html'); + assert.equal(resolveMindSpaceSimilarHtmlRedirect({ ownerKey: 'user-1', relativePath: 'public/a b.html' }), '/MindSpace/user-1/public/a%20b.html'); +}); + +test('decodeMindSpaceRouteSegment safely decodes segments', () => { + assert.equal(decodeMindSpaceRouteSegment('report%201.html'), 'report 1.html'); + assert.equal(decodeMindSpaceRouteSegment('%E4%B8%AD%E6%96%87'), '中文'); +}); + +test('resolveMindSpaceHtmlFallbackRedirect points html to public zone sibling', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-public-route-')); + const targetDir = path.join(root, 'MindSpace', 'user-1'); + const publicDir = path.join(targetDir, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'report.html'), 'ok'); + const redirect = resolveMindSpaceHtmlFallbackRedirect({ + ownerKey: 'user-1', + targetDir, + resolvedRoot: path.resolve(targetDir), + rest: ['report.html'], + }); + assert.equal(redirect, '/MindSpace/user-1/public/report.html'); +}); + +test('recoverMisplacedMindSpacePublicHtml copies misplaced file into public zone', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-public-recover-')); + const targetDir = path.join(root, 'MindSpace', 'user-1'); + fs.mkdirSync(path.join(root, 'public'), { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(root, 'public', 'report.html'), 'demo', { encoding: 'utf8', flag: 'w' }); + let ensured = null; + const recovered = await recoverMisplacedMindSpacePublicHtml({ + h5Root: root, + targetDir, + resolvedRoot: path.resolve(targetDir), + rest: ['public', 'report.html'], + ensureThumbnail: async (...args) => { + ensured = args; + }, + logger: { warn() {} }, + }); + assert.equal(recovered, path.join(targetDir, 'public', 'report.html')); + assert.equal(fs.existsSync(recovered), true); + assert.deepEqual(ensured, [targetDir, 'public/report.html']); +}); + +test('resolveMindSpacePublicRequest coordinates canonical redirect, serve, and not found', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-public-request-')); + const ownerKey = '123e4567-e89b-12d3-a456-426614174000'; + const targetDir = path.join(root, 'MindSpace', ownerKey); + fs.mkdirSync(path.join(targetDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'public', 'report.html'), 'demo'); + + const redirected = await resolveMindSpacePublicRequest({ + h5Root: root, + requestPath: '/John/public/report.html', + resolveUsernameToUserId: async () => ownerKey, + }); + assert.deepEqual(redirected, { + action: 'redirect', + status: 301, + location: `/MindSpace/${ownerKey}/public/report.html`, + ownerKey, + }); + + const served = await resolveMindSpacePublicRequest({ + h5Root: root, + requestPath: `/${ownerKey}/public/report.html`, + }); + assert.equal(served.action, 'serve'); + assert.equal(served.filePath, path.join(targetDir, 'public', 'report.html')); + + const missing = await resolveMindSpacePublicRequest({ + h5Root: root, + requestPath: `/${ownerKey}/public/missing.html`, + }); + assert.deepEqual(missing, { + action: 'not_found', + reason: 'missing_file', + ownerKey, + }); +}); diff --git a/mindspace-remote-server-adapter.mjs b/mindspace-remote-server-adapter.mjs new file mode 100644 index 0000000..09c0fbf --- /dev/null +++ b/mindspace-remote-server-adapter.mjs @@ -0,0 +1,173 @@ +import { + MINDSPACE_SERVER_ADAPTER_BINDINGS, + MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, +} from './mindspace-server-adapter-contract.mjs'; + +function trimTrailingSlash(value) { + return String(value ?? '').trim().replace(/\/+$/, ''); +} + +function resolveRemoteAdapterEndpoint(endpoint) { + const base = trimTrailingSlash(endpoint); + return base || ''; +} + +function normalizeOperationBasePath(value) { + const raw = String(value ?? '').trim(); + if (!raw) return '/mindspace/v1/adapter'; + return `/${raw.replace(/^\/+/, '').replace(/\/+$/, '')}`; +} + +function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath = '/mindspace/v1/adapter') { + const base = resolveRemoteAdapterEndpoint(endpoint); + if (!base) { + throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL'); + } + const normalizedBasePath = normalizeOperationBasePath(operationBasePath); + return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`; +} + +async function parseRemoteOperationResponse(response, bindingKey, method) { + if (!response.ok) { + const bodyText = await response.text().catch(() => ''); + throw new Error( + `MindSpace remote adapter ${bindingKey}.${method}() failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`, + ); + } + if (response.status === 204) return null; + const text = await response.text(); + if (!text) return null; + return JSON.parse(text); +} + +async function invokeRemoteOperation({ + endpoint, + operationBasePath, + authToken, + timeoutMs, + bindingKey, + method, + args, + fetchFn, +}) { + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = + controller && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? setTimeout(() => controller.abort(new Error('MindSpace remote adapter request timed out')), timeoutMs) + : null; + try { + const response = await fetchFn(buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, + body: JSON.stringify({ + args, + }), + signal: controller?.signal, + }); + return parseRemoteOperationResponse(response, bindingKey, method); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +function createRemoteBindingProxy({ + endpoint, + bindingKey, + fetchFn, + authToken, + timeoutMs, + operationBasePath, +}) { + const methods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey] ?? []; + const methodSet = new Set(methods); + const proxyTarget = {}; + for (const method of methods) { + proxyTarget[method] = async (...args) => + invokeRemoteOperation({ + endpoint, + operationBasePath, + authToken, + timeoutMs, + bindingKey, + method, + args, + fetchFn, + }); + } + return new Proxy(proxyTarget, { + get(target, prop) { + if (prop === Symbol.toStringTag) return 'MindSpaceRemoteBindingProxy'; + if (typeof prop === 'string' && !methodSet.has(prop)) { + throw new Error(`MindSpace remote adapter binding "${bindingKey}" does not expose ${bindingKey}.${prop}()`); + } + return target[prop]; + }, + }); +} + +export function createMindSpaceRemoteServerAdapter({ + logger = console, + endpoint = '', + authToken = '', + operationBasePath = '/mindspace/v1/adapter', + timeoutMs = 15_000, + fetchFn = globalThis.fetch?.bind(globalThis), +} = {}) { + const endpointUrl = resolveRemoteAdapterEndpoint(endpoint); + const normalizedOperationBasePath = normalizeOperationBasePath(operationBasePath); + const invoke = fetchFn + ? fetchFn + : async () => { + throw new Error('MindSpace remote server adapter requires a fetch implementation'); + }; + const bindings = Object.fromEntries( + MINDSPACE_SERVER_ADAPTER_BINDING_KEYS.map((bindingKey) => [ + bindingKey, + createRemoteBindingProxy({ + endpoint: endpointUrl, + bindingKey, + fetchFn: invoke, + authToken, + timeoutMs, + operationBasePath: normalizedOperationBasePath, + }), + ]), + ); + + return { + kind: 'remote', + implementationStatus: 'scaffold', + endpoint: endpointUrl, + operationBasePath: normalizedOperationBasePath, + timeoutMs, + storageRoot: null, + publicBaseUrl: null, + storageAdapter: null, + ...bindings, + assertReady() { + if (!endpointUrl) { + throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL'); + } + return true; + }, + startBackgroundJobs() { + logger.log?.( + `MindSpace remote server adapter enabled${endpointUrl ? ` (${endpointUrl})` : ''}; background jobs stay on the remote runtime`, + ); + return { + publicationCleanup: null, + agentWorker: null, + workspaceMaintenance: null, + }; + }, + }; +} + +export const mindspaceRemoteServerAdapterInternals = { + buildRemoteOperationUrl, + invokeRemoteOperation, + normalizeOperationBasePath, +}; diff --git a/mindspace-remote-server-adapter.test.mjs b/mindspace-remote-server-adapter.test.mjs new file mode 100644 index 0000000..9105074 --- /dev/null +++ b/mindspace-remote-server-adapter.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createMindSpaceRemoteServerAdapter, + mindspaceRemoteServerAdapterInternals, +} from './mindspace-remote-server-adapter.mjs'; + +test('buildRemoteOperationUrl normalizes endpoint and encodes operation path', () => { + assert.equal( + mindspaceRemoteServerAdapterInternals.buildRemoteOperationUrl( + 'https://mindspace.example.com/', + 'assetService', + 'renderAssetPreview', + '/internal/mindspace-adapter', + ), + 'https://mindspace.example.com/internal/mindspace-adapter/assetService/renderAssetPreview', + ); +}); + +test('createMindSpaceRemoteServerAdapter validates endpoint at startup', () => { + const adapter = createMindSpaceRemoteServerAdapter({ + fetchFn: async () => { + throw new Error('should not fetch'); + }, + }); + assert.throws(() => adapter.assertReady(), /MINDSPACE_REMOTE_BASE_URL/); +}); + +test('createMindSpaceRemoteServerAdapter proxies server binding methods through fetch transport', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + operationBasePath: '/internal/mindspace-adapter/', + timeoutMs: 3000, + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ ok: true, source: 'remote' }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + + const result = await adapter.assetService.renderAssetPreview({ userId: 'u1', assetId: 'a1' }); + + assert.deepEqual(result, { ok: true, source: 'remote' }); + assert.equal(calls.length, 1); + assert.equal( + calls[0].url, + 'https://mindspace.example.com/internal/mindspace-adapter/assetService/renderAssetPreview', + ); + assert.equal(calls[0].init.method, 'POST'); + assert.equal(calls[0].init.headers.Authorization, 'Bearer secret-token'); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [{ userId: 'u1', assetId: 'a1' }], + }); +}); + +test('createMindSpaceRemoteServerAdapter exposes conversation package read methods', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ packageId: 'cp-session-1' }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + + await adapter.conversationPackageRegistry.readManifestForSession({ + userId: 'user-1', + sessionId: 'session-1', + }); + await adapter.conversationPackageRegistry.readArtifactObject({ + userId: 'user-1', + sessionId: 'session-1', + artifactId: 'artifact-1', + }); + + assert.match(calls[0].url, /conversationPackageRegistry\/readManifestForSession$/); + assert.match(calls[1].url, /conversationPackageRegistry\/readArtifactObject$/); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [{ userId: 'user-1', sessionId: 'session-1' }], + }); + assert.deepEqual(JSON.parse(calls[1].init.body), { + args: [{ userId: 'user-1', sessionId: 'session-1', artifactId: 'artifact-1' }], + }); + assert.equal(calls[0].init.headers.Authorization, 'Bearer secret-token'); + assert.equal(calls[1].init.headers.Authorization, 'Bearer secret-token'); +}); + +test('createMindSpaceRemoteServerAdapter rejects unknown binding methods', () => { + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com', + fetchFn: async () => ({ ok: true, status: 204, text: async () => '' }), + logger: { log() {}, warn() {}, error() {} }, + }); + + assert.throws(() => adapter.assetService.notARealMethod, /does not expose/); +}); + +test('createMindSpaceRemoteServerAdapter surfaces remote conversation package errors with binding context', async () => { + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async () => ({ + ok: false, + status: 502, + async text() { + return 'upstream manifest missing'; + }, + }), + logger: { log() {}, warn() {}, error() {} }, + }); + + await assert.rejects( + () => adapter.conversationPackageRegistry.readManifestForSession({ + userId: 'user-1', + sessionId: 'session-1', + }), + /conversationPackageRegistry\.readManifestForSession\(\) failed with 502: upstream manifest missing/, + ); +}); diff --git a/mindspace-runtime-config.mjs b/mindspace-runtime-config.mjs new file mode 100644 index 0000000..24a2e6b --- /dev/null +++ b/mindspace-runtime-config.mjs @@ -0,0 +1,121 @@ +import path from 'node:path'; +import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; +import { createMindSpaceServiceFacade } from './mindspace-service.mjs'; +import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs'; +import { + buildPublicUrl, + PUBLISH_ROOT_DIR, + resolveGoosedSandboxRoot, + resolvePublicBaseUrl, + resolvePublishDir, +} from './user-publish.mjs'; + +export function resolveMindSpaceStorageRoot(h5Root, env = process.env) { + return path.resolve(env.MINDSPACE_STORAGE_ROOT ?? path.join(h5Root, 'data', 'mindspace')); +} + +export function resolveMindSpaceRuntimeConfig(h5Root, env = process.env) { + return { + storageRoot: resolveMindSpaceStorageRoot(h5Root, env), + publicBaseUrl: resolvePublicBaseUrl(env), + }; +} + +export function resolveMindSpaceServerRuntimeOptions(h5Root, env = process.env) { + const runtime = resolveMindSpaceRuntimeConfig(h5Root, env); + return { + ...runtime, + adapterKind: String(env.MINDSPACE_SERVER_ADAPTER ?? 'local').trim().toLowerCase() || 'local', + publishRoot: resolveMindSpacePublishRoot(h5Root), + maxFileBytes: Number(env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), + aiDailyLimit: Number(env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10), + publicPageLimit: Number(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5), + monthlyViewLimit: Number(env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000), + experienceEnabled: env.MINDSPACE_EXPERIENCE_ENABLED !== 'false', + agentWorker: { + enabled: env.MINDSPACE_AGENT_WORKER_ENABLED === 'true', + concurrency: Math.max(1, Number(env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2)), + pollMs: Math.max(200, Number(env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000)), + staleMs: Math.max(10_000, Number(env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000)), + ssePollMs: Math.max(500, Number(env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000)), + }, + remote: { + baseUrl: String(env.MINDSPACE_REMOTE_BASE_URL ?? '').trim(), + authToken: String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim(), + operationBasePath: String(env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter') + .trim(), + timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 15_000)), + }, + }; +} + +export function resolveMindSpacePublishRoot(h5Root) { + return path.resolve(h5Root, PUBLISH_ROOT_DIR); +} + +export function resolveMindSpaceUserPublishDir(h5Root, user) { + return path.resolve(resolvePublishDir(h5Root, user)); +} + +export function buildMindSpacePublicRoutePath(ownerKey, segments = []) { + const userId = String(ownerKey ?? '').trim(); + if (!userId) { + throw new Error('MindSpace public route requires an owner key'); + } + const cleanSegments = Array.isArray(segments) + ? segments.map((segment) => encodeURIComponent(String(segment ?? ''))) + : []; + return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(userId)}${cleanSegments.length ? `/${cleanSegments.join('/')}` : '/'}`; +} + +export function buildMindSpacePublicUrlForUser({ h5Root, env = process.env, user, relativePath = '' } = {}) { + const ownerKey = + typeof user === 'string' || typeof user === 'number' + ? String(user) + : String(user?.id ?? '').trim(); + if (!ownerKey) { + throw new Error('MindSpace public URL requires a user id'); + } + const { publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env); + return buildPublicUrl(publicBaseUrl, ownerKey, relativePath); +} + +export function buildMindSpaceWorkspaceRef(user) { + const ownerKey = + typeof user === 'string' || typeof user === 'number' + ? String(user) + : String(user?.id ?? '').trim(); + if (!ownerKey) { + throw new Error('MindSpace workspace ref requires a user id'); + } + return `mindspace://users/${encodeURIComponent(ownerKey)}/workspace`; +} + +export function resolveMindSpaceAgentWorkspaceCapability({ h5Root, env = process.env, user } = {}) { + return { + workspaceRoot: resolveMindSpaceUserPublishDir(h5Root, user), + sandboxRoot: resolveGoosedSandboxRoot(h5Root, user, env), + workspaceRef: buildMindSpaceWorkspaceRef(user), + }; +} + +export function createMindSpaceLocalRuntime({ + h5Root, + env = process.env, + conversationPackageBackfill, + conversationPackagePublicHtmlHydrator, + logger = console, +} = {}) { + const config = resolveMindSpaceRuntimeConfig(h5Root, env); + return { + ...config, + storageAdapter: createLocalMindSpaceStorageAdapter(config.storageRoot), + serviceFacade: createMindSpaceServiceFacade({ + storageAdapter: createLocalMindSpaceStorageAdapter(config.storageRoot), + publicBaseUrl: config.publicBaseUrl, + conversationPackageBackfill, + conversationPackagePublicHtmlHydrator, + logger, + }), + }; +} diff --git a/mindspace-runtime-config.test.mjs b/mindspace-runtime-config.test.mjs new file mode 100644 index 0000000..618dca5 --- /dev/null +++ b/mindspace-runtime-config.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { + buildMindSpacePublicRoutePath, + buildMindSpaceWorkspaceRef, + buildMindSpacePublicUrlForUser, + createMindSpaceLocalRuntime, + resolveMindSpaceAgentWorkspaceCapability, + resolveMindSpacePublishRoot, + resolveMindSpaceRuntimeConfig, + resolveMindSpaceServerRuntimeOptions, + resolveMindSpaceStorageRoot, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; + +test('resolveMindSpaceStorageRoot uses env override when present', () => { + const root = resolveMindSpaceStorageRoot('/tmp/h5', { MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data' }); + assert.equal(root, path.resolve('/srv/mindspace-data')); +}); + +test('resolveMindSpaceRuntimeConfig returns storage root and public base url', () => { + const config = resolveMindSpaceRuntimeConfig('/tmp/h5', { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com/', + }); + assert.deepEqual(config, { + storageRoot: path.resolve('/srv/mindspace-data'), + publicBaseUrl: 'https://example.com', + }); +}); + +test('resolveMindSpaceServerRuntimeOptions centralizes runtime roots, limits, and worker settings', () => { + const config = resolveMindSpaceServerRuntimeOptions('/tmp/h5', { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com/', + MINDSPACE_SERVER_ADAPTER: 'remote', + MINDSPACE_MAX_FILE_BYTES: '8192', + MINDSPACE_FREE_AI_DAILY_LIMIT: '12', + MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8', + MINDSPACE_FREE_MONTHLY_VIEW_LIMIT: '2048', + MINDSPACE_EXPERIENCE_ENABLED: 'false', + MINDSPACE_AGENT_WORKER_ENABLED: 'true', + MINDSPACE_AGENT_WORKER_CONCURRENCY: '0', + MINDSPACE_AGENT_WORKER_POLL_MS: '150', + MINDSPACE_AGENT_WORKER_STALE_MS: '5000', + MINDSPACE_AGENT_SSE_POLL_MS: '120', + MINDSPACE_REMOTE_BASE_URL: 'https://mindspace.example.com/', + MINDSPACE_REMOTE_AUTH_TOKEN: 'secret-token', + MINDSPACE_REMOTE_OPERATION_BASE_PATH: '/internal/mindspace-adapter', + MINDSPACE_REMOTE_TIMEOUT_MS: '500', + }); + assert.deepEqual(config, { + storageRoot: path.resolve('/srv/mindspace-data'), + publicBaseUrl: 'https://example.com', + adapterKind: 'remote', + publishRoot: path.resolve('/tmp/h5/MindSpace'), + maxFileBytes: 8192, + aiDailyLimit: 12, + publicPageLimit: 8, + monthlyViewLimit: 2048, + experienceEnabled: false, + agentWorker: { + enabled: true, + concurrency: 1, + pollMs: 200, + staleMs: 10_000, + ssePollMs: 500, + }, + remote: { + baseUrl: 'https://mindspace.example.com/', + authToken: 'secret-token', + operationBasePath: '/internal/mindspace-adapter', + timeoutMs: 1000, + }, + }); +}); + +test('runtime config resolves publish roots through a single helper', () => { + assert.equal(resolveMindSpacePublishRoot('/tmp/h5'), path.resolve('/tmp/h5/MindSpace')); + assert.equal( + resolveMindSpaceUserPublishDir('/tmp/h5', { id: 'user-1' }), + path.resolve('/tmp/h5/MindSpace/user-1'), + ); +}); + +test('buildMindSpacePublicUrlForUser uses runtime public base url config', () => { + assert.equal( + buildMindSpacePublicUrlForUser({ + h5Root: '/tmp/h5', + env: { H5_PUBLIC_BASE_URL: 'https://example.com/' }, + user: { id: 'user-1' }, + relativePath: 'public/report.html', + }), + 'https://example.com/MindSpace/user-1/public/report.html', + ); +}); + +test('buildMindSpacePublicRoutePath normalizes route assembly', () => { + assert.equal(buildMindSpacePublicRoutePath('user-1'), '/MindSpace/user-1/'); + assert.equal( + buildMindSpacePublicRoutePath('user-1', ['public', 'report 1.html']), + '/MindSpace/user-1/public/report%201.html', + ); +}); + +test('agent workspace capability keeps legacy sandbox root and adds future workspace ref', () => { + const capability = resolveMindSpaceAgentWorkspaceCapability({ + h5Root: '/tmp/h5', + env: { GOOSED_SANDBOX_PUBLISH_ROOT: '/srv/goosed-mindspace' }, + user: { id: 'user-1' }, + }); + assert.equal(capability.workspaceRoot, path.resolve('/tmp/h5/MindSpace/user-1')); + assert.equal(capability.sandboxRoot, path.resolve('/srv/goosed-mindspace/user-1')); + assert.equal(capability.workspaceRef, buildMindSpaceWorkspaceRef({ id: 'user-1' })); +}); + +test('createMindSpaceLocalRuntime builds a local storage-backed facade', async () => { + const runtime = createMindSpaceLocalRuntime({ + h5Root: '/tmp/h5', + env: { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + }); + assert.equal(runtime.storageRoot, path.resolve('/srv/mindspace-data')); + assert.equal(runtime.publicBaseUrl, 'https://example.com'); + assert.equal(runtime.storageAdapter.kind, 'local-fs'); + assert.equal( + runtime.serviceFacade.createPublicPageUrl({ ownerKey: 'user-1', filename: 'report.html' }), + 'https://example.com/MindSpace/user-1/public/report.html', + ); +}); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs new file mode 100644 index 0000000..da8e2bd --- /dev/null +++ b/mindspace-server-adapter-contract.mjs @@ -0,0 +1,101 @@ +export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ + serviceFacade: Object.freeze(['prepareConversationPackageRead']), + conversationPackageRegistry: Object.freeze([ + 'ensurePackage', + 'putObjectForSession', + 'recordArtifact', + 'readArtifactObject', + 'readManifestForSession', + 'writeManifestForSession', + ]), + assetService: Object.freeze([ + 'cancelUpload', + 'claimUploadArtifactsForConversation', + 'completeUpload', + 'createChatAsset', + 'createUpload', + 'deleteAsset', + 'listAssets', + 'readAsset', + 'readPublicAsset', + 'renderAssetPreview', + 'renderAssetThumbnail', + 'syncWorkspaceAssets', + 'writeUploadContent', + 'expireStaleUploads', + ]), + pageService: Object.freeze([ + 'createFromChat', + 'createPage', + 'deletePage', + 'findPageByRelativePath', + 'findPageBySourceAsset', + 'getDeletePreview', + 'getPage', + 'listPages', + 'listVersions', + 'localizePrivateResources', + 'redactPage', + 'regenerateThumbnail', + 'renderDraftPreview', + 'renderPreview', + 'renderThumbnail', + 'rewriteHtmlDownloadLinksForPage', + 'updatePage', + 'uploadThumbnail', + ]), + pageLiveEditService: Object.freeze(['applyAgentPatch', 'bindSession', 'getRevisionSnapshot']), + assetAgentService: Object.freeze(['applyAgentDelete']), + publicationService: Object.freeze([ + 'check', + 'cleanupExpiredUnconfirmedPublications', + 'getCurrent', + 'getPublicHomepage', + 'getStats', + 'offline', + 'publish', + 'resolvePrivateLink', + 'resolvePublic', + 'updatePublicationStatus', + ]), + cleanupService: Object.freeze(['listCandidates', 'runCleanup']), + agentJobService: Object.freeze([ + 'cancelJob', + 'claimJob', + 'claimNextJob', + 'completeJob', + 'createJob', + 'getAssetForJob', + 'getJob', + 'heartbeat', + 'listJobs', + 'reapStaleJobs', + 'retryJob', + ]), +}); + +export const MINDSPACE_SERVER_ADAPTER_BINDING_KEYS = Object.freeze( + Object.keys(MINDSPACE_SERVER_ADAPTER_BINDINGS), +); + +export function assertMindSpaceServerAdapterBindingContract(bindingKey, service) { + const methods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey]; + if (!methods) { + throw new Error(`Unknown MindSpace server adapter binding: ${bindingKey}`); + } + for (const method of methods) { + if (typeof service?.[method] !== 'function') { + throw new Error( + `MindSpace server adapter binding "${bindingKey}" is missing method ${bindingKey}.${method}()`, + ); + } + } + return service; +} + +export function assertMindSpaceServerAdapterBindings(bindings) { + for (const bindingKey of MINDSPACE_SERVER_ADAPTER_BINDING_KEYS) { + assertMindSpaceServerAdapterBindingContract(bindingKey, bindings?.[bindingKey]); + } + return bindings; +} diff --git a/mindspace-server-adapter.mjs b/mindspace-server-adapter.mjs new file mode 100644 index 0000000..5c11eeb --- /dev/null +++ b/mindspace-server-adapter.mjs @@ -0,0 +1,53 @@ +import { createMindSpaceLocalServerAdapter } from './mindspace-local-server-adapter.mjs'; +import { createMindSpaceRemoteServerAdapter } from './mindspace-remote-server-adapter.mjs'; +import { + assertMindSpaceServerAdapterBindings, + MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, +} from './mindspace-server-adapter-contract.mjs'; + +const VALID_ADAPTER_KINDS = new Set(['local', 'remote']); + +export function resolveMindSpaceServerAdapterKind(env = process.env) { + const raw = String(env.MINDSPACE_SERVER_ADAPTER ?? 'local').trim().toLowerCase(); + if (!VALID_ADAPTER_KINDS.has(raw)) { + throw new Error( + `Unsupported MINDSPACE_SERVER_ADAPTER "${raw}". Expected one of: ${[...VALID_ADAPTER_KINDS].join(', ')}`, + ); + } + return raw; +} + +export function createMindSpaceServerAdapter(options = {}) { + const env = options.env ?? process.env; + const kind = resolveMindSpaceServerAdapterKind(env); + const adapter = + kind === 'remote' + ? createMindSpaceRemoteServerAdapter({ + logger: options.logger, + endpoint: options.remote?.baseUrl ?? env.MINDSPACE_REMOTE_BASE_URL, + authToken: options.remote?.authToken, + operationBasePath: options.remote?.operationBasePath, + timeoutMs: options.remote?.timeoutMs, + fetchFn: options.fetchFn, + }) + : createMindSpaceLocalServerAdapter(options); + return { + ...adapter, + kind, + }; +} + +export function assertMindSpaceServerAdapterContract(adapter) { + const bindings = {}; + for (const key of MINDSPACE_SERVER_ADAPTER_BINDING_KEYS) { + if (!(key in (adapter ?? {}))) { + throw new Error(`MindSpace server adapter is missing required binding: ${key}`); + } + bindings[key] = adapter[key]; + } + assertMindSpaceServerAdapterBindings(bindings); + if (typeof adapter?.startBackgroundJobs !== 'function') { + throw new Error('MindSpace server adapter must expose startBackgroundJobs()'); + } + return adapter; +} diff --git a/mindspace-server-adapter.test.mjs b/mindspace-server-adapter.test.mjs new file mode 100644 index 0000000..32eb248 --- /dev/null +++ b/mindspace-server-adapter.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertMindSpaceServerAdapterContract, + createMindSpaceServerAdapter, + resolveMindSpaceServerAdapterKind, +} from './mindspace-server-adapter.mjs'; + +test('resolveMindSpaceServerAdapterKind defaults to local and rejects unknown kinds', () => { + assert.equal(resolveMindSpaceServerAdapterKind({}), 'local'); + assert.equal(resolveMindSpaceServerAdapterKind({ MINDSPACE_SERVER_ADAPTER: 'REMOTE' }), 'remote'); + assert.throws( + () => resolveMindSpaceServerAdapterKind({ MINDSPACE_SERVER_ADAPTER: 'legacy' }), + /Unsupported MINDSPACE_SERVER_ADAPTER/, + ); +}); + +test('createMindSpaceServerAdapter returns a contract-complete local adapter by default', () => { + const adapter = createMindSpaceServerAdapter({ + pool: { + query: async () => { + throw new Error('not implemented in unit test'); + }, + }, + h5Root: '/tmp/h5', + env: { + MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data', + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + maxFileBytes: 4096, + publicPageLimit: 3, + resolveUserIdForAgentSession: async () => 'user-1', + logger: { log() {}, warn() {}, error() {} }, + }); + + assert.equal(adapter.kind, 'local'); + assert.equal(adapter.implementationStatus, 'ready'); + assert.equal(assertMindSpaceServerAdapterContract(adapter), adapter); +}); + +test('createMindSpaceServerAdapter exposes a remote stub that fails fast when selected', () => { + const adapter = createMindSpaceServerAdapter({ + env: { + MINDSPACE_SERVER_ADAPTER: 'remote', + MINDSPACE_REMOTE_BASE_URL: 'https://mindspace.example.com', + }, + logger: { log() {}, warn() {}, error() {} }, + }); + + assert.equal(adapter.kind, 'remote'); + assert.equal(adapter.implementationStatus, 'scaffold'); + assert.equal(assertMindSpaceServerAdapterContract(adapter), adapter); + assert.equal(adapter.assertReady(), true); + assert.deepEqual(adapter.startBackgroundJobs(), { + publicationCleanup: null, + agentWorker: null, + workspaceMaintenance: null, + }); +}); diff --git a/package.json b/package.json index 5bfc591..c430aec 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only", "check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", diff --git a/server.mjs b/server.mjs index 01b072d..03a846e 100644 --- a/server.mjs +++ b/server.mjs @@ -31,33 +31,52 @@ import { } from './user-auth.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; import { isLocalDevHostname } from './scripts/local-test-config.mjs'; -import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs'; +import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs'; import { createNotificationDispatcher } from './notification-dispatcher.mjs'; import { createMindSpaceAuditWriter } from './mindspace-audit.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; -import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; +import { createMindSpaceService } from './mindspace.mjs'; import { ensureMindSpaceConfig } from './mindspace-config.mjs'; -import { createAssetService } from './mindspace-assets.mjs'; -import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; -import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs'; +import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { createDownloadConversationPackageArtifactHandler, createDownloadConversationPackageManifestHandler, createGetConversationPackageHandler, } from './mindspace-conversation-package-routes.mjs'; -import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs'; -import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs'; -import { createMindSpaceServiceFacade } from './mindspace-service.mjs'; -import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs'; -import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; -import { createAssetAgentService } from './mindspace-asset-agent.mjs'; +import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs'; +import { + assertMindSpaceServerAdapterContract, + createMindSpaceServerAdapter, +} from './mindspace-server-adapter.mjs'; +import { + resolveMindSpacePublicRequest, +} from './mindspace-public-route.mjs'; +import { + buildPublishedHtmlViewContext, + resolvePublicRequestOrigin, +} from './mindspace-public-page-context.mjs'; +import { + appendPublicAssetTokens, + verifyPublicAssetToken, +} from './mindspace-public-asset-token.mjs'; +import { + decorateMindSpacePublishedHtml, + handleMindSpaceLongImageDownload, +} from './mindspace-public-delivery.mjs'; +import { + buildMindSpacePublicRoutePath, + buildMindSpacePublicUrlForUser, + resolveMindSpaceRuntimeConfig, + resolveMindSpacePublishRoot, + resolveMindSpaceServerRuntimeOptions, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { - createPublicationService, publicationInternals, rewriteWorkspacePublicAssetReferences, } from './mindspace-publications.mjs'; @@ -82,8 +101,6 @@ import { publishedPageCspForEmbed, stripPublicationHtmlCspMeta, } from './plaza-embed.mjs'; -import { createCleanupService } from './mindspace-cleanup.mjs'; -import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs'; import { analyzeChatMessageForSave, @@ -101,7 +118,6 @@ import { resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { - collectOwnPublicHtmlArtifactRefs, materializePublicHtmlWritesFromSessionEvent, normalizePublicHtmlRelativePath, syncPublicHtmlAfterFinish, @@ -188,6 +204,7 @@ const API_TARGETS = parseApiTargets(); const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; +const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env); const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; const WECHAT_MP_CONFIG = loadWechatMpConfig(); // 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0, @@ -249,9 +266,9 @@ const jsonUnlessMultipart = (req, res, next) => { }; const rawUploadBody = express.raw({ type: 'application/octet-stream', - limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), + limit: mindSpaceServerRuntime.maxFileBytes, }); -const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db')); +const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db')); let legacyAuth = null; if (ACCESS_PASSWORD && !isDatabaseConfigured()) { @@ -313,43 +330,12 @@ async function bootstrapUserAuth() { }); feedbackService = createFeedbackService(pool); mindSpace = createMindSpaceService(pool, { - maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), - aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10), - publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5), - monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000), + maxFileBytes: mindSpaceServerRuntime.maxFileBytes, + aiDailyLimit: mindSpaceServerRuntime.aiDailyLimit, + publicPageLimit: mindSpaceServerRuntime.publicPageLimit, + monthlyViewLimit: mindSpaceServerRuntime.monthlyViewLimit, scheduleService, }); - const mindSpaceStorageRoot = - process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); - mindSpaceServiceFacade = createMindSpaceServiceFacade({ - storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot), - publicBaseUrl: resolvePublicBaseUrl(), - conversationPackageBackfill: ({ user, sessionId }) => - backfillConversationPackageArtifacts({ - pool, - registry: mindSpaceConversationPackageRegistry, - storageRoot: mindSpaceStorageRoot, - h5Root: __dirname, - user, - sessionId, - }), - conversationPackagePublicHtmlHydrator: hydratePublicHtmlArtifactsForConversationPackage, - }); - mindSpaceConversationPackageRegistry = createConversationPackageRegistry({ - store: createConversationPackageStore(pool), - service: mindSpaceServiceFacade, - }); - mindSpaceAssets = createAssetService(pool, { - h5Root: __dirname, - storageRoot: mindSpaceStorageRoot, - maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), - conversationPackageRegistry: mindSpaceConversationPackageRegistry, - }); - mindSpacePages = createPageService(pool, { - h5Root: __dirname, - storageRoot: mindSpaceStorageRoot, - conversationPackageRegistry: mindSpaceConversationPackageRegistry, - }); const resolveUserIdForAgentSession = async (sessionId) => { const [rows] = await pool.query( `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, @@ -357,30 +343,36 @@ async function bootstrapUserAuth() { ); return rows[0]?.user_id ?? null; }; - mindSpacePageLiveEdit = createPageLiveEditService({ - pageService: mindSpacePages, - resolveUserIdForAgentSession, - }); - mindSpaceAssetAgent = createAssetAgentService({ - assetService: mindSpaceAssets, - resolveUserIdForAgentSession, - }); - mindSpacePublications = createPublicationService(pool, { + const mindSpaceRuntimeAdapter = assertMindSpaceServerAdapterContract( + createMindSpaceServerAdapter({ + pool, h5Root: __dirname, - storageRoot: mindSpaceStorageRoot, - publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5), - conversationPackageRegistry: mindSpaceConversationPackageRegistry, - }); - setInterval(async () => { - try { - const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications(); - if (result.cleaned > 0) { - console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`); - } - } catch (err) { - console.error('[Publication Cleanup Error]', err instanceof Error ? err.message : err); + env: process.env, + maxFileBytes: mindSpaceServerRuntime.maxFileBytes, + publicPageLimit: mindSpaceServerRuntime.publicPageLimit, + resolveUserIdForAgentSession, + resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), + registerPublicHtmlArtifactsForConversation, + remote: mindSpaceServerRuntime.remote, + logger: console, + }), + ); + mindSpaceRuntimeAdapter.assertReady?.(); + mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade; + mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry; + mindSpaceAssets = mindSpaceRuntimeAdapter.assetService; + mindSpacePages = mindSpaceRuntimeAdapter.pageService; + mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService; + mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService; + mindSpacePublications = mindSpaceRuntimeAdapter.publicationService; + const resolveUserIdByDirKey = async (dirKey) => { + let userId = dirKey; + if (!PUBLISH_KEY_UUID.test(dirKey)) { + const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [dirKey]); + userId = rows[0]?.id; } - }, 60 * 1000); + return userId ?? null; + }; await ensureAlgorithmConfig(pool); const plazaAlgorithmConfig = await loadAlgorithmConfig(pool); plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool); @@ -423,11 +415,7 @@ async function bootstrapUserAuth() { recalculateHotScores, writebackPublications, }); - mindSpaceCleanup = createCleanupService(pool, { - storageRoot: - process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), - h5Root: __dirname, - }); + mindSpaceCleanup = mindSpaceRuntimeAdapter.cleanupService; await ensurePlanCatalogSchema(pool); const planCatalogService = createPlanCatalogService(pool); subscriptionService = createSubscriptionService(pool, { @@ -452,12 +440,7 @@ async function bootstrapUserAuth() { if (wechatOAuthService.enabled) { console.log('WeChat OAuth login enabled'); } - mindSpaceAgentJobs = createAgentJobService(pool, { - pageService: mindSpacePages, - storageRoot: - process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), - maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), - }); + mindSpaceAgentJobs = mindSpaceRuntimeAdapter.agentJobService; // Shared experience store (etat C): retrieval before / recording after each // agent job, so all instances learn from one another. Gated so it can be // disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is @@ -465,7 +448,7 @@ async function bootstrapUserAuth() { // the MySQL business DB is untouched. Falls back to MySQL keyword store if PG // init fails (e.g. driver missing) so a misconfig never blocks startup. let experienceService = null; - if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== 'false') { + if (mindSpaceServerRuntime.experienceEnabled) { if (process.env.EXPERIENCE_PG_URL) { try { const { createPgExperienceService } = await import('./experience-service-pg.mjs'); @@ -497,88 +480,21 @@ async function bootstrapUserAuth() { // can run this loop without double-processing) and runs them via the runner. // Opt-in per instance: must NOT run on the 105 stateless front (see // docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED. - if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === 'true') { - const workerConcurrency = Math.max( - 1, - Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2), - ); - const workerPollMs = Math.max( - 200, - Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000), - ); - const workerStaleMs = Math.max( - 10_000, - Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000), - ); - let inFlight = 0; - let draining = false; - const drainQueue = async () => { - if (draining) return; - draining = true; - try { - while (inFlight < workerConcurrency) { - const claim = await mindSpaceAgentJobs.claimNextJob(); - if (!claim) break; - inFlight += 1; - void mindSpaceAgentRunner - .runJob(claim.jobId, claim) - .catch((error) => { - console.error('Agent worker job failed:', error); - }) - .finally(() => { - inFlight -= 1; - }); - } - } catch (error) { - console.error('Agent worker drain failed:', error); - } finally { - draining = false; - } - }; - const workerTimer = setInterval(() => { - void drainQueue(); - }, workerPollMs); - const reaperTimer = setInterval(() => { - void mindSpaceAgentJobs - .reapStaleJobs(workerStaleMs) - .then((reaped) => { - if (reaped > 0) { - console.warn(`Agent worker reaped ${reaped} stale running job(s)`); - } - }) - .catch((error) => { - console.error('Agent worker reaper failed:', error); - }); - }, Math.min(workerStaleMs, 60_000)); - workerTimer.unref?.(); - reaperTimer.unref?.(); - console.log( - `Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)`, - ); - } - if (WORKSPACE_MAINTENANCE_ENABLED) { - startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR)); - startWorkspaceAssetSyncWatcher({ - publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR), - syncUserWorkspaceByDirKey: async (dirKey, options) => { - let userId = dirKey; - if (!PUBLISH_KEY_UUID.test(dirKey)) { - const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ - dirKey, - ]); - userId = rows[0]?.id; - } - if (!userId) return; - await mindSpaceAssets.syncWorkspaceAssets(userId, options); - }, - }); - void mindSpaceAssets.expireStaleUploads().catch(() => {}); - setInterval(() => { - void mindSpaceAssets?.expireStaleUploads().catch(() => {}); - }, 5 * 60 * 1000).unref?.(); - } else { - console.log('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)'); - } + mindSpaceRuntimeAdapter.startBackgroundJobs({ + publicationCleanupIntervalMs: 60 * 1000, + agentWorker: mindSpaceServerRuntime.agentWorker, + agentRunner: mindSpaceAgentRunner, + workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED, + publishRoot: mindSpaceServerRuntime.publishRoot, + startWorkspaceThumbnailWatcher, + startWorkspaceAssetSyncWatcher, + syncUserWorkspaceByDirKey: async (dirKey, options) => { + const userId = await resolveUserIdByDirKey(dirKey); + if (!userId) return; + await mindSpaceAssets.syncWorkspaceAssets(userId, options); + }, + expireStaleUploadsIntervalMs: 5 * 60 * 1000, + }); await userAuth.ensureAdminUser(); llmProviderService = createLlmProviderService(pool, { apiTarget: API_TARGET, @@ -2369,7 +2285,7 @@ api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => { cleanup(); res.end(); } - }, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000))); + }, mindSpaceServerRuntime.agentWorker.ssePollMs); // Comment line keeps proxies from closing an idle connection. const keepAliveTimer = setInterval(() => { if (!closed) res.write(': keep-alive\n\n'); @@ -2693,7 +2609,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { const assetId = req.params.assetId; - const currentUserId = req.currentUser?.id ?? null; + const currentUserId = req.currentUser?.id ?? req.userSession?.userId ?? null; let allowedByPublication = false; let publicationAccessMode = null; if (!currentUserId) { @@ -2732,6 +2648,10 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { allowedByPublication = true; publicationAccessMode = 'time_limited'; } + if (!allowedByPublication && verifyPublicAssetToken(assetId, req.query.public_token, INTERNAL_AGENT_SECRET)) { + allowedByPublication = true; + publicationAccessMode = 'signed-public-token'; + } if (!allowedByPublication && isPublicPageReferrer) { allowedByPublication = true; publicationAccessMode = 'public-page-referrer'; @@ -2744,7 +2664,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { ? await mindSpaceAssets.readAsset(currentUserId, assetId) : await mindSpaceAssets.readPublicAsset(assetId); await mindSpaceAudit?.write({ - userId: req.currentUser?.id ?? null, + userId: currentUserId, action: 'asset.download', objectType: 'asset', objectId: assetId, @@ -2949,6 +2869,7 @@ const pageSyncInFlight = new Map(); async function syncUserGeneratedPages(userId) { if (!mindSpacePages || !authPool || !userId) return; + if (mindSpaceServerRuntime.adapterKind === 'remote') return; let inFlight = pageSyncInFlight.get(userId); if (!inFlight) { @@ -2957,7 +2878,7 @@ async function syncUserGeneratedPages(userId) { pageService: mindSpacePages, assetService: mindSpaceAssets, userId, - publishDir: resolvePublishDir(__dirname, { id: userId }), + publishDir: resolveMindSpaceUserPublishDir(__dirname, { id: userId }), syncWorkspaceAssets: mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) @@ -2993,8 +2914,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { }); if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { - const storageRoot = - process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); + const { storageRoot } = resolveMindSpaceRuntimeConfig(h5Root, process.env); let htmlContent = resolvedHtml.content; const repaired = await repairMissingHtmlAssetReferences({ @@ -3021,7 +2941,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { } if (htmlContent !== resolvedHtml.content) { - const publishDir = resolvePublishDir(h5Root, { id: user.id }); + const publishDir = resolveMindSpaceUserPublishDir(h5Root, { id: user.id }); await fsPromises.writeFile( path.join(publishDir, resolvedHtml.relativePath), htmlContent, @@ -3133,100 +3053,15 @@ async function registerPublicHtmlArtifactsForConversation({ relativePaths = [], artifactRefs = [], }) { - if ( - !mindSpaceConversationPackageRegistry || - !user?.id || - !publishDir || - !sessionId || - ((!Array.isArray(relativePaths) || relativePaths.length === 0) && - (!Array.isArray(artifactRefs) || artifactRefs.length === 0)) - ) { - return []; - } - try { - const publishRoot = path.resolve(publishDir); - const packageTitle = String(title ?? '').trim() || null; - const existingManifest = await mindSpaceConversationPackageRegistry - .readManifestForSession({ userId: user.id, sessionId }) - .catch(() => null); - const packageRecord = existingManifest?.packageId && existingManifest.title - ? { id: existingManifest.packageId } - : await mindSpaceConversationPackageRegistry.ensurePackage({ - userId: user.id, - sessionId, - title: packageTitle ?? existingManifest?.title ?? null, - }); - const recorded = []; - const now = Date.now(); - const refs = - Array.isArray(artifactRefs) && artifactRefs.length > 0 - ? artifactRefs - : relativePaths.map((relativePath) => ({ relativePath })); - for (const ref of refs) { - const relativePath = typeof ref === 'string' ? ref : ref?.relativePath; - const normalized = normalizePublicHtmlRelativePath(relativePath); - if (!normalized || !normalized.toLowerCase().endsWith('.html')) continue; - const absolutePath = path.resolve(publishRoot, normalized); - if (absolutePath !== publishRoot && !absolutePath.startsWith(`${publishRoot}${path.sep}`)) continue; - let stat = null; - try { - stat = await fsPromises.stat(absolutePath); - } catch { - continue; - } - if (!stat.isFile()) continue; - const hash = crypto - .createHash('sha256') - .update(`${user.id}:${sessionId}:${normalized}`) - .digest('hex') - .slice(0, 16); - const artifactId = `ca_public_html_${hash}`; - await mindSpaceConversationPackageRegistry.recordArtifact({ - id: artifactId, - packageId: packageRecord.id, - artifactKind: 'public_html', - role: 'assistant', - messageId: typeof ref?.messageId === 'string' && ref.messageId.trim() ? ref.messageId.trim() : null, - displayName: path.posix.basename(normalized), - mimeType: 'text/html', - sizeBytes: stat.size, - canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), user.id, normalized), - sortOrder: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : now, - now, - }); - recorded.push({ artifactId, relativePath: normalized }); - } - if (recorded.length > 0) { - await mindSpaceConversationPackageRegistry.writeManifestForSession({ - userId: user.id, - sessionId, - }); - } - return recorded; - } catch (error) { - console.warn('[MindSpace] failed to record public html artifacts:', error?.message ?? error); - return []; - } -} - -async function hydratePublicHtmlArtifactsForConversationPackage({ - user, - sessionId, -}) { - if (!sessionSnapshotService?.isEnabled?.() || !user?.id || !sessionId) return []; - const publishDir = resolvePublishDir(__dirname, { id: user.id }); - const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null); - const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; - const artifactRefs = collectOwnPublicHtmlArtifactRefs({ - messages, - currentUser: user, - publishDir, - }); - return registerPublicHtmlArtifactsForConversation({ + return registerPublicHtmlArtifactsForConversationPackage({ + conversationPackageRegistry: mindSpaceConversationPackageRegistry, + h5Root: __dirname, + env: process.env, user, publishDir, sessionId, - title: snapshot?.session?.name, + title, + relativePaths, artifactRefs, }); } @@ -3256,7 +3091,7 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } - const publishDir = resolvePublishDir(__dirname, req.currentUser); + const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath); const title = bundle.previewTitle || @@ -3272,8 +3107,7 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { bundle.resolvedHtml.content, { title, subtitle, force: true }, ).catch(() => {}); - const storageRoot = - process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); + const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); const resolveAssetDataUri = createAssetDataUriResolver( authPool, storageRoot, @@ -3314,7 +3148,7 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { } = bundle; let thumbnailReady = false; if (resolvedHtml?.content && resolvedHtml.relativePath) { - const publishDir = resolvePublishDir(__dirname, req.currentUser); + const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); try { await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, { title: previewTitle || resolvedHtml.suggestedTitle, @@ -3367,8 +3201,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } - const storageRoot = - process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); + const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); const { html: localizedHtml } = await inlinePrivateAssetsInHtml( authPool, storageRoot, @@ -3376,7 +3209,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { bundle.resolvedHtml.content, ); - const publishDir = resolvePublishDir(__dirname, req.currentUser); + const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared'); await fsPromises.mkdir(sharedDir, { recursive: true }); @@ -3388,7 +3221,12 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { await fsPromises.writeFile(destPath, sharedHtml, 'utf8'); const publishKey = req.currentUser.id; - const publicUrl = buildPublicUrl(resolvePublicBaseUrl(), publishKey, sharedRelativePath); + const publicUrl = buildMindSpacePublicUrlForUser({ + h5Root: __dirname, + env: process.env, + user: publishKey, + relativePath: sharedRelativePath, + }); return res.status(201).json({ data: { publicUrl, filename } }); } catch (error) { @@ -3540,7 +3378,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { } if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) { - const publishDir = resolvePublishDir(__dirname, req.currentUser); + const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { title: pageInput.title, subtitle: pageInput.summary, @@ -4650,7 +4488,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } - const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id }); + const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id }); const syncPublicHtmlDuringStream = (event) => { materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); }; @@ -4878,12 +4716,11 @@ function removeQueryParam(url, key) { } function resolveRequestOrigin(req) { - const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim(); - if (!host) return ''; - const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host); - const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim(); - const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https'; - return `${proto}://${host}`; + return resolvePublicRequestOrigin({ + hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '', + forwardedProto: req.headers['x-forwarded-proto'] || '', + protocol: req.protocol, + }); } function detectPublishedPageTitle(html) { @@ -5532,42 +5369,22 @@ app.get('/s/:token', async (req, res) => { } }); -const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/; - -async function resolvePublishDirKey(segment) { - const lower = String(segment ?? '').trim().toLowerCase(); - if (!lower) return null; - if (PUBLISH_KEY_UUID.test(lower)) return lower; - if (USERNAME_SLUG.test(lower)) { - if (authPool) { - const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]); - if (rows[0]?.id) return String(rows[0].id).toLowerCase(); - } - return lower; - } - return null; -} - /** * Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover. * Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile. */ async function sendLongImageDownloadIfRequested(req, res, filePath) { - if (!isLongImageDownloadRequest(req.query)) return false; - const longImagePath = longImagePathForHtml(filePath); - try { - await renderLongImage({ htmlPath: filePath, outputPath: longImagePath }); - res.set('Cache-Control', 'no-store'); - res.download(longImagePath, path.basename(longImagePath), (err) => { - if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' }); - }); - } catch (error) { - res - .status(500) - .type('text/plain; charset=utf-8') - .send(`长图生成失败:${error?.message || '未知错误'}`); - } - return true; + const origin = resolveRequestOrigin(req); + const currentUrl = origin ? `${origin}${req.originalUrl || req.url || ''}` : null; + return handleMindSpaceLongImageDownload({ + query: req.query, + filePath, + pageUrl: currentUrl ? removeQueryParam(removeQueryParam(currentUrl, 'download'), 'export') : null, + res, + isLongImageDownloadRequest, + longImagePathForHtml, + renderLongImage, + }); } async function sendPublishFile(req, res, filePath) { @@ -5585,138 +5402,82 @@ async function sendPublishFile(req, res, filePath) { res.status(404).json({ message: '文件不存在' }); return; } + html = appendPublicAssetTokens(html, INTERNAL_AGENT_SECRET); const embed = isPlazaEmbedRequest(req.query); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); } - const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim(); - // Share cards (esp. WeChat) require https og:image. The edge only serves public domains - // over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a - // public host we force https and ignore the (wrong) forwarded scheme. Loopback/LAN stays http. - const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host); - const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim(); - const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https'; - const origin = host ? `${proto}://${host}` : ''; - const cleanPath = req.originalUrl.split('?')[0].split('#')[0]; - // Distinguish an explicit file URL (.../space.html) from a directory that resolves to - // index.html (.../ or ...//) so relative covers and the thumbnail resolve correctly. - const servedName = path.basename(filePath); - const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? ''); - const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase(); - const pageUrl = origin - ? `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}` - : ''; - const pageDirUrl = !origin - ? '' - : isImplicitIndex - ? `${origin}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}` - : `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`; - // Guaranteed fallback cover: the page's feed thumbnail, served on demand as PNG. - let fallbackImageUrl = ''; - const svgSibling = filePath.replace(/\.[^./]+$/, '.thumbnail.svg'); - if (pageDirUrl && fs.existsSync(svgSibling)) { - const pngName = path.basename(thumbnailPngPathForSvg(svgSibling)); - fallbackImageUrl = `${pageDirUrl}${pngName}`; - } - try { - html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl }); - const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); - if (wechatShare) { - html = injectWechatShareBridge(html, { pageUrl }); - } - const shareInjection = !embed - ? injectPublicFileShareButton(html) - : { html, scriptHashes: [] }; - html = shareInjection.html; - res.set('Content-Security-Policy', publishedPageCsp(html, { - embed, - wechatShare, - scriptHashes: shareInjection.scriptHashes, - })); - } catch { - // On any parse failure, fall back to the original HTML — never break page delivery. - res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); + const context = buildPublishedHtmlViewContext({ + origin: resolveRequestOrigin(req), + requestPath: req.originalUrl || req.url || '', + filePath, + thumbnailPngPathForSvg, + }); + const decorated = decorateMindSpacePublishedHtml({ + html, + embed, + context, + userAgent: req.get('user-agent') || '', + preparePublicationHtmlForEmbed, + injectOgTags, + injectWechatShareBridge, + injectPublicFileShareButton, + publishedPageCsp, + isWechatUserAgent, + }); + html = decorated.html; + if (decorated.allowEmbedFrame) { + allowPlazaEmbedFrame(res); } + res.set('Content-Security-Policy', decorated.csp); res.set('Content-Type', 'text/html; charset=utf-8'); res.send(html); } -const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i; - -async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) { - if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null; - const filename = rest[1]; - if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null; - - const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename); - if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null; - const candidates = [ - path.resolve(__dirname, filename), - path.resolve(__dirname, PUBLIC_ZONE_DIR, filename), - ]; - for (const candidate of candidates) { - if (candidate === destination) continue; - if (!candidate.startsWith(`${__dirname}${path.sep}`)) continue; - if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue; - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.copyFileSync(candidate, destination); - await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => {}); - console.warn( - `[MindSpace] recovered misplaced public HTML ${path.relative(__dirname, candidate)} -> ${path.relative(__dirname, destination)}`, - ); - return destination; - } - return null; -} - -function decodePathSegment(segment) { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } -} - async function serveUserPublishFile(req, res, next) { - const parts = req.path.split('/').filter(Boolean); - if (parts.length < 1) { - res.status(404).json({ message: '未找到页面' }); + const result = await resolveMindSpacePublicRequest({ + h5Root: __dirname, + requestPath: req.path, + resolveUsernameToUserId: async (username) => { + if (!authPool) return null; + const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [username]); + return rows[0]?.id ? String(rows[0].id) : null; + }, + resolveClosestHtmlRelativePath, + ensureThumbnail: async (publishDir, relativePath) => { + await ensureWorkspaceHtmlThumbnail(publishDir, relativePath).catch(() => {}); + }, + logger: console, + }); + + if (result.action === 'redirect') { + res.redirect(result.status ?? 301, result.location); return; } - const dirKey = await resolvePublishDirKey(parts[0]); - if (!dirKey) { - res.status(404).json({ message: '未找到页面' }); - return; - } - - if (parts[0].toLowerCase() !== dirKey && USERNAME_SLUG.test(parts[0])) { - const rest = parts.slice(1).map(encodeURIComponent).join('/'); - const target = `/${PUBLISH_ROOT_DIR}/${dirKey}${rest ? `/${rest}` : '/'}`; - res.redirect(301, target); - return; - } - - const [username, ...rest] = [dirKey, ...parts.slice(1).map(decodePathSegment)]; - const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username); - const resolvedRoot = path.resolve(targetDir); - const filePath = path.join(targetDir, ...rest); - const resolvedPath = path.resolve(filePath); - - if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`) && resolvedPath !== resolvedRoot) { + if (result.action === 'forbidden') { res.status(403).json({ message: '禁止访问' }); return; } - if (!fs.existsSync(targetDir)) { - res.status(404).json({ message: '用户不存在' }); + if (result.action === 'not_found') { + if (result.reason === 'missing_owner_dir') { + res.status(404).json({ message: '用户不存在' }); + return; + } + if (result.reason === 'missing_directory_index') { + res.status(404).json({ message: '目录中没有 index.html' }); + return; + } + res.status(404).json({ message: '文件不存在' }); return; } // On-demand cover: rasterize .thumbnail.svg → .thumbnail.png the first time a // forwarded link's og:image is fetched (and refresh it when the SVG changes). + const resolvedPath = result.filePath; if (/\.thumbnail\.png$/i.test(resolvedPath)) { const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); if (fs.existsSync(svgSibling)) { @@ -5729,49 +5490,6 @@ async function serveUserPublishFile(req, res, next) { } } - if (!fs.existsSync(resolvedPath)) { - if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) { - const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); - if ( - publicFallback.startsWith(`${resolvedRoot}${path.sep}`) && - fs.existsSync(publicFallback) && - fs.statSync(publicFallback).isFile() - ) { - const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`; - res.redirect(301, canonical); - return; - } - } - if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) { - const similarRelativePath = await resolveClosestHtmlRelativePath(targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`); - if (similarRelativePath) { - const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${similarRelativePath - .split('/') - .map((part) => encodeURIComponent(part)) - .join('/')}`; - res.redirect(301, canonical); - return; - } - } - const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest); - if (recoveredPublicHtml) { - await sendPublishFile(req, res, recoveredPublicHtml); - return; - } - res.status(404).json({ message: '文件不存在' }); - return; - } - - if (fs.statSync(resolvedPath).isDirectory()) { - const indexPath = path.join(resolvedPath, 'index.html'); - if (fs.existsSync(indexPath)) { - await sendPublishFile(req, res, indexPath); - return; - } - res.status(404).json({ message: '目录中没有 index.html' }); - return; - } - await sendPublishFile(req, res, resolvedPath); } diff --git a/src/hooks/usePageEditSubChat.ts b/src/hooks/usePageEditSubChat.ts index 473ca87..69afde6 100644 --- a/src/hooks/usePageEditSubChat.ts +++ b/src/hooks/usePageEditSubChat.ts @@ -20,7 +20,7 @@ import { buildUserAddressPrefix } from '../utils/userAddress'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, } from '../utils/imageUpload'; -import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards'; +import { buildAbsoluteAssetDownloadUrl } from '../utils/mindspaceCards'; import { resolveAgentRunOptions } from '../utils/agentRunMode'; import { buildUserMessage, @@ -142,10 +142,9 @@ export function usePageEditSubChat({ sessionId: sessionRef.current?.id ?? null, messageId: options.messageId ?? null, }); - return buildAbsoluteAssetImageUrl({ + return buildAbsoluteAssetDownloadUrl({ id: asset.id, updatedAt: asset.updatedAt, - publicUrl: asset.publicUrl, }); }, [resolveChatImageUploadCategoryId]); diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 60b48ce..8bcf742 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -51,7 +51,7 @@ import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, } from '../utils/imageUpload'; -import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards'; +import { buildAbsoluteAssetDownloadUrl } from '../utils/mindspaceCards'; import { resolveAgentRunOptions } from '../utils/agentRunMode'; import { buildUserMessage, @@ -585,10 +585,9 @@ export function useTKMindChat( sessionId: sessionRef.current?.id ?? null, messageId: options.messageId ?? null, }); - return buildAbsoluteAssetImageUrl({ + return buildAbsoluteAssetDownloadUrl({ id: asset.id, updatedAt: asset.updatedAt, - publicUrl: asset.publicUrl, }); }, [resolveChatImageUploadCategoryId]); diff --git a/src/utils/mindspaceCards.ts b/src/utils/mindspaceCards.ts index e3710a0..2fcde28 100644 --- a/src/utils/mindspaceCards.ts +++ b/src/utils/mindspaceCards.ts @@ -68,6 +68,12 @@ export function buildAbsoluteAssetImageUrl( return `${window.location.origin}${buildAssetImageUrl(asset)}`; } +export function buildAbsoluteAssetDownloadUrl( + asset: Pick, +): string { + return `${window.location.origin}/api/mindspace/v1/assets/${asset.id}/download?inline=1&viewer=0&v=${asset.updatedAt}`; +} + /** Chrome blocks PDF rendering inside any sandboxed iframe. */ export function assetPreviewUsesSandbox(mimeType: string) { return mimeType !== 'application/pdf'; diff --git a/user-auth.mjs b/user-auth.mjs index 9986581..8fed207 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -25,20 +25,21 @@ import { resolvePolicies, } from './policies.mjs'; import { - buildPublicUrl, ensurePublishSkillInstalled, ensureUserPublishLayout, ensureWorkspaceHintsInstalled, PUBLISH_SKILL_NAME, - resolvePublicBaseUrl, resolveLegacyPublishDir, - resolveGoosedSandboxRoot, } from './user-publish.mjs'; import { ensureUserSpaceLayout, isPathInsideUserWorkspace, - resolveMindspaceStorageRoot, } from './user-space.mjs'; +import { + buildMindSpacePublicUrlForUser, + resolveMindSpaceAgentWorkspaceCapability, + resolveMindSpaceRuntimeConfig, +} from './mindspace-runtime-config.mjs'; import { ensureUserMemoryProfile } from './user-memory-profile.mjs'; import { applySkillGrantsToCapabilities, @@ -125,8 +126,7 @@ export function createUserAuth(pool, options = {}) { const usersRoot = path.resolve(options.usersRoot ?? '/tmp/tkmind_go_users'); const h5Root = path.resolve(options.h5Root ?? path.join(usersRoot, '..')); const env = options.env ?? process.env; - const storageRoot = resolveMindspaceStorageRoot(h5Root, env); - const publicBaseUrl = resolvePublicBaseUrl(env); + const { storageRoot, publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env); const skillCatalog = listPlatformSkillCatalog(h5Root); const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500); const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100); @@ -228,7 +228,7 @@ export function createUserAuth(pool, options = {}) { return { ...base, publishSlug: publishKey, - publishUrl: buildPublicUrl(publicBaseUrl, publishKey), + publishUrl: buildMindSpacePublicUrlForUser({ h5Root, env, user: publishKey }), publishSkillName: PUBLISH_SKILL_NAME, }; }; @@ -1826,17 +1826,23 @@ export function createUserAuth(pool, options = {}) { let sandboxMcp = null; if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) { try { - const layout = await publishLayoutFor(user, { migrateLegacy: false }); + const workspaceCapability = resolveMindSpaceAgentWorkspaceCapability({ + h5Root, + env, + user, + }); sandboxMcp = { // When goosed runs in a container its filesystem is split from the portal's, // so the host paths the portal would otherwise send (node binary, MCP script) // are not resolvable. These env overrides let the portal send container-canonical // paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to // the portal's own paths, so behavior is unchanged. - serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), - sandboxRoot: resolveGoosedSandboxRoot(h5Root, user), + serverPath: resolveSandboxMcpServerPath(env.GOOSED_MCP_SERVER_PATH), + sandboxRoot: workspaceCapability.sandboxRoot, + workspaceRoot: workspaceCapability.workspaceRoot, + workspaceRef: workspaceCapability.workspaceRef, userId: user.id, - nodeExecPath: process.env.GOOSED_MCP_NODE_PATH, + nodeExecPath: env.GOOSED_MCP_NODE_PATH, }; } catch (err) { console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message); diff --git a/user-auth.test.mjs b/user-auth.test.mjs index c547215..6a7db89 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -100,6 +100,29 @@ function createAdminLayoutPool(adminRow) { }; } +function createAgentPolicyPool(userRow) { + return { + async query(sql, params = []) { + if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) { + return [[userRow]]; + } + if (sql.includes('FROM h5_assets a') && sql.includes('JOIN h5_space_categories')) { + return [[]]; + } + if (sql.includes('FROM h5_user_capability_grants')) return [[]]; + if (sql.includes('FROM h5_user_policy_grants')) return [[]]; + if (sql.includes('FROM h5_user_skill_grants')) return [[]]; + if (sql.includes('UPDATE h5_users SET workspace_root = ?')) { + userRow.workspace_root = params[0]; + return [[]]; + } + if (sql.includes('DELETE FROM h5_user_path_grants')) return [[]]; + if (sql.includes('INSERT INTO h5_user_path_grants')) return [[]]; + return [[]]; + }, + }; +} + test('login rate limits repeated failures', async () => { const auth = createUserAuth(createAuthPool(null), { loginMaxFailures: 2, @@ -267,6 +290,38 @@ test('ensureAdminUser repairs existing admin workspace without password env', as assert.equal(adminRow.workspace_root, path.join(root, 'MindSpace', adminRow.id)); }); +test('agent session policy preserves sandbox root and exposes workspace ref metadata', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-agent-policy-')); + const userRow = { + id: 'user-1', + username: 'john', + slug: 'john', + email: 'john@example.com', + display_name: 'John', + role: 'user', + status: 'active', + plan_type: 'free', + workspace_root: path.join(root, 'legacy-root'), + quota_bytes: 1024, + used_bytes: 0, + reserved_bytes: 0, + balance_cents: 100, + tokens_used: 0, + }; + const auth = createUserAuth(createAgentPolicyPool(userRow), { + h5Root: root, + persistSessions: false, + env: { GOOSED_SANDBOX_PUBLISH_ROOT: '/srv/goosed-mindspace' }, + }); + + const policy = await auth.getAgentSessionPolicy(userRow.id); + const sandboxFs = policy.extensionOverrides.find((item) => item.name === 'sandbox-fs'); + assert.ok(sandboxFs); + assert.equal(sandboxFs.envs.SANDBOX_ROOT, path.resolve('/srv/goosed-mindspace/user-1')); + assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_ROOT, path.resolve(root, 'MindSpace', 'user-1')); + assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/user-1/workspace'); +}); + test('admin capabilities include granted platform skills', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-admin-skills-')); await fs.mkdir(path.join(root, 'skills', 'product-campaign-page'), { recursive: true }); diff --git a/user-space.mjs b/user-space.mjs index e68c36b..f800699 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -1,13 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { SYSTEM_CATEGORIES } from './mindspace.mjs'; +import { resolveMindSpaceStorageRoot as resolveRuntimeMindSpaceStorageRoot } from './mindspace-runtime-config.mjs'; import { PUBLISH_ROOT_DIR, resolvePublishDir, resolveUserAddressName } from './user-publish.mjs'; /** 用户 MindSpace 下的分区子目录(与上传分类一致) */ export const UPLOAD_ZONE_CODES = ['oa', 'public']; export function resolveMindspaceStorageRoot(h5Root, env = process.env) { - return path.resolve(env.MINDSPACE_STORAGE_ROOT ?? path.join(h5Root, 'data', 'mindspace')); + return resolveRuntimeMindSpaceStorageRoot(h5Root, env); } /** Agent 工作区 = MindSpace// */