feat: finalize mindspace service extraction phase a

This commit is contained in:
john
2026-07-03 08:40:28 +08:00
parent 506a551438
commit ec5b1a6e3f
32 changed files with 2834 additions and 490 deletions
+26 -7
View File
@@ -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,
+18
View File
@@ -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');
});
@@ -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=<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=<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=<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=<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=<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=<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=<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=<user-id> npm run compare:local-remote`
- `MINDSPACE_LOOPBACK_USER_ID=<user-id> npm run compare:write-local-remote`
- `MINDSPACE_LOOPBACK_USER_ID=<user-id> npm run compare:asset-local-remote`
- `MINDSPACE_LOOPBACK_USER_ID=<user-id> npm run compare:conversation-package-local-remote`
- `MINDSPACE_LOOPBACK_USER_ID=<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
@@ -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,
})) ?? []
);
};
}
@@ -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'), '<html>ok</html>', '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'), '<html>ok</html>', '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');
});
+89
View File
@@ -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,
};
}
+38
View File
@@ -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);
});
+157
View File
@@ -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;
},
};
}
+201
View File
@@ -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'),
'<html><body>ok</body></html>',
'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'],
]);
});
+40
View File
@@ -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 (/(?:\?|&|&amp;)public_token=/.test(query)) return value;
const separator = query ? (query.includes('&amp;') ? '&amp;' : '&') : '?';
return `${base}${query}${separator}public_token=${publicAssetToken(assetId, secret)}`;
},
);
}
+38
View File
@@ -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 = [
'<img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=2">',
'<img src="https://m.tkmind.cn/api/mindspace/v1/assets/asset-2/download?inline=1&amp;v=3">',
].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&amp;v=3&amp;public_token=[A-Za-z0-9_-]+/,
);
});
test('appendPublicAssetTokens preserves existing public tokens', () => {
const html = '<img src="/api/mindspace/v1/assets/asset-1/download?inline=1&public_token=keep-me">';
assert.equal(appendPublicAssetTokens(html, SECRET), html);
});
+82
View File
@@ -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,
};
}
}
+123
View File
@@ -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: '<html><body>demo</body></html>',
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}<!--og:${meta.pageUrl}-->`,
injectWechatShareBridge: (value, meta) => `${value}<!--wechat:${meta.pageUrl}-->`,
injectPublicFileShareButton: (value) => ({ html: `${value}<!--share-->`, 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: '<html></html>',
embed: true,
context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' },
preparePublicationHtmlForEmbed: (value) => `${value}<!--embed-->`,
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: [] }));
});
+53
View File
@@ -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,
};
+46
View File
@@ -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',
);
});
+221
View File
@@ -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,
};
+122
View File
@@ -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,
});
});
+173
View File
@@ -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,
};
+135
View File
@@ -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/,
);
});
+121
View File
@@ -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,
}),
};
}
+133
View File
@@ -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',
);
});
+101
View File
@@ -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;
}
+53
View File
@@ -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;
}
+59
View File
@@ -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,
});
});
+1 -1
View File
@@ -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",
+182 -464
View File
@@ -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();
// 无状态前端节点(如 105MindSpace 经 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 (.../<uuid> or .../<uuid>/) 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 <base>.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);
}
+2 -3
View File
@@ -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]);
+2 -3
View File
@@ -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]);
+6
View File
@@ -68,6 +68,12 @@ export function buildAbsoluteAssetImageUrl(
return `${window.location.origin}${buildAssetImageUrl(asset)}`;
}
export function buildAbsoluteAssetDownloadUrl(
asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>,
): 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';
+17 -11
View File
@@ -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);
+55
View File
@@ -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 });
+2 -1
View File
@@ -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/<userId>/ */