feat: finalize mindspace service extraction phase a
This commit is contained in:
+26
-7
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function normalizeSecret(secret) {
|
||||
return String(secret ?? '');
|
||||
}
|
||||
|
||||
export function publicAssetToken(assetId, secret) {
|
||||
const normalizedAssetId = String(assetId ?? '').trim();
|
||||
if (!normalizedAssetId) return '';
|
||||
return crypto
|
||||
.createHmac('sha256', normalizeSecret(secret))
|
||||
.update(`mindspace-public-asset:v1:${normalizedAssetId}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
export function verifyPublicAssetToken(assetId, token, secret) {
|
||||
const value = Array.isArray(token) ? token[0] : token;
|
||||
const provided = String(value ?? '').trim();
|
||||
if (!provided) return false;
|
||||
const expected = publicAssetToken(assetId, secret);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return (
|
||||
providedBuffer.length === expectedBuffer.length &&
|
||||
crypto.timingSafeEqual(providedBuffer, expectedBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
export function appendPublicAssetTokens(html, secret) {
|
||||
const source = String(html ?? '');
|
||||
if (!source || !source.includes('/api/mindspace/v1/assets/')) return source;
|
||||
return source.replace(
|
||||
/((?:https?:\/\/[^"'<>\\\s]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download)(\?[^"'<>\\\s)]*)?/gi,
|
||||
(value, base, assetId, query = '') => {
|
||||
if (/(?:\?|&|&)public_token=/.test(query)) return value;
|
||||
const separator = query ? (query.includes('&') ? '&' : '&') : '?';
|
||||
return `${base}${query}${separator}public_token=${publicAssetToken(assetId, secret)}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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&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&v=3&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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: [] }));
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -31,33 +31,52 @@ import {
|
||||
} from './user-auth.mjs';
|
||||
import { createWikiAuth } from './wiki-auth.mjs';
|
||||
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
|
||||
import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
|
||||
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs';
|
||||
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
||||
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
|
||||
import { attachRequestId, sendData, sendError } from './api-response.mjs';
|
||||
import { createNotificationDispatcher } from './notification-dispatcher.mjs';
|
||||
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
|
||||
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
|
||||
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
|
||||
import { createMindSpaceService } from './mindspace.mjs';
|
||||
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
|
||||
import { createAssetService } from './mindspace-assets.mjs';
|
||||
import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||||
import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs';
|
||||
import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||||
import {
|
||||
createDownloadConversationPackageArtifactHandler,
|
||||
createDownloadConversationPackageManifestHandler,
|
||||
createGetConversationPackageHandler,
|
||||
} from './mindspace-conversation-package-routes.mjs';
|
||||
import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs';
|
||||
import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs';
|
||||
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
|
||||
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
|
||||
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
|
||||
import { createAssetAgentService } from './mindspace-asset-agent.mjs';
|
||||
import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs';
|
||||
import {
|
||||
assertMindSpaceServerAdapterContract,
|
||||
createMindSpaceServerAdapter,
|
||||
} from './mindspace-server-adapter.mjs';
|
||||
import {
|
||||
resolveMindSpacePublicRequest,
|
||||
} from './mindspace-public-route.mjs';
|
||||
import {
|
||||
buildPublishedHtmlViewContext,
|
||||
resolvePublicRequestOrigin,
|
||||
} from './mindspace-public-page-context.mjs';
|
||||
import {
|
||||
appendPublicAssetTokens,
|
||||
verifyPublicAssetToken,
|
||||
} from './mindspace-public-asset-token.mjs';
|
||||
import {
|
||||
decorateMindSpacePublishedHtml,
|
||||
handleMindSpaceLongImageDownload,
|
||||
} from './mindspace-public-delivery.mjs';
|
||||
import {
|
||||
buildMindSpacePublicRoutePath,
|
||||
buildMindSpacePublicUrlForUser,
|
||||
resolveMindSpaceRuntimeConfig,
|
||||
resolveMindSpacePublishRoot,
|
||||
resolveMindSpaceServerRuntimeOptions,
|
||||
resolveMindSpaceUserPublishDir,
|
||||
} from './mindspace-runtime-config.mjs';
|
||||
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
|
||||
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
|
||||
import {
|
||||
createPublicationService,
|
||||
publicationInternals,
|
||||
rewriteWorkspacePublicAssetReferences,
|
||||
} from './mindspace-publications.mjs';
|
||||
@@ -82,8 +101,6 @@ import {
|
||||
publishedPageCspForEmbed,
|
||||
stripPublicationHtmlCspMeta,
|
||||
} from './plaza-embed.mjs';
|
||||
import { createCleanupService } from './mindspace-cleanup.mjs';
|
||||
import { createAgentJobService } from './mindspace-agent-jobs.mjs';
|
||||
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
|
||||
import {
|
||||
analyzeChatMessageForSave,
|
||||
@@ -101,7 +118,6 @@ import {
|
||||
resolveStaticHtmlContent,
|
||||
} from './mindspace-chat-save.mjs';
|
||||
import {
|
||||
collectOwnPublicHtmlArtifactRefs,
|
||||
materializePublicHtmlWritesFromSessionEvent,
|
||||
normalizePublicHtmlRelativePath,
|
||||
syncPublicHtmlAfterFinish,
|
||||
@@ -188,6 +204,7 @@ const API_TARGETS = parseApiTargets();
|
||||
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
|
||||
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
|
||||
const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env);
|
||||
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
|
||||
const WECHAT_MP_CONFIG = loadWechatMpConfig();
|
||||
// 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0,
|
||||
@@ -249,9 +266,9 @@ const jsonUnlessMultipart = (req, res, next) => {
|
||||
};
|
||||
const rawUploadBody = express.raw({
|
||||
type: 'application/octet-stream',
|
||||
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
limit: mindSpaceServerRuntime.maxFileBytes,
|
||||
});
|
||||
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
|
||||
const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db'));
|
||||
|
||||
let legacyAuth = null;
|
||||
if (ACCESS_PASSWORD && !isDatabaseConfigured()) {
|
||||
@@ -313,43 +330,12 @@ async function bootstrapUserAuth() {
|
||||
});
|
||||
feedbackService = createFeedbackService(pool);
|
||||
mindSpace = createMindSpaceService(pool, {
|
||||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
|
||||
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
|
||||
monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000),
|
||||
maxFileBytes: mindSpaceServerRuntime.maxFileBytes,
|
||||
aiDailyLimit: mindSpaceServerRuntime.aiDailyLimit,
|
||||
publicPageLimit: mindSpaceServerRuntime.publicPageLimit,
|
||||
monthlyViewLimit: mindSpaceServerRuntime.monthlyViewLimit,
|
||||
scheduleService,
|
||||
});
|
||||
const mindSpaceStorageRoot =
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||||
mindSpaceServiceFacade = createMindSpaceServiceFacade({
|
||||
storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot),
|
||||
publicBaseUrl: resolvePublicBaseUrl(),
|
||||
conversationPackageBackfill: ({ user, sessionId }) =>
|
||||
backfillConversationPackageArtifacts({
|
||||
pool,
|
||||
registry: mindSpaceConversationPackageRegistry,
|
||||
storageRoot: mindSpaceStorageRoot,
|
||||
h5Root: __dirname,
|
||||
user,
|
||||
sessionId,
|
||||
}),
|
||||
conversationPackagePublicHtmlHydrator: hydratePublicHtmlArtifactsForConversationPackage,
|
||||
});
|
||||
mindSpaceConversationPackageRegistry = createConversationPackageRegistry({
|
||||
store: createConversationPackageStore(pool),
|
||||
service: mindSpaceServiceFacade,
|
||||
});
|
||||
mindSpaceAssets = createAssetService(pool, {
|
||||
h5Root: __dirname,
|
||||
storageRoot: mindSpaceStorageRoot,
|
||||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
|
||||
});
|
||||
mindSpacePages = createPageService(pool, {
|
||||
h5Root: __dirname,
|
||||
storageRoot: mindSpaceStorageRoot,
|
||||
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
|
||||
});
|
||||
const resolveUserIdForAgentSession = async (sessionId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
|
||||
@@ -357,30 +343,36 @@ async function bootstrapUserAuth() {
|
||||
);
|
||||
return rows[0]?.user_id ?? null;
|
||||
};
|
||||
mindSpacePageLiveEdit = createPageLiveEditService({
|
||||
pageService: mindSpacePages,
|
||||
resolveUserIdForAgentSession,
|
||||
});
|
||||
mindSpaceAssetAgent = createAssetAgentService({
|
||||
assetService: mindSpaceAssets,
|
||||
resolveUserIdForAgentSession,
|
||||
});
|
||||
mindSpacePublications = createPublicationService(pool, {
|
||||
const mindSpaceRuntimeAdapter = assertMindSpaceServerAdapterContract(
|
||||
createMindSpaceServerAdapter({
|
||||
pool,
|
||||
h5Root: __dirname,
|
||||
storageRoot: mindSpaceStorageRoot,
|
||||
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
|
||||
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
|
||||
});
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications();
|
||||
if (result.cleaned > 0) {
|
||||
console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Publication Cleanup Error]', err instanceof Error ? err.message : err);
|
||||
env: process.env,
|
||||
maxFileBytes: mindSpaceServerRuntime.maxFileBytes,
|
||||
publicPageLimit: mindSpaceServerRuntime.publicPageLimit,
|
||||
resolveUserIdForAgentSession,
|
||||
resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId),
|
||||
registerPublicHtmlArtifactsForConversation,
|
||||
remote: mindSpaceServerRuntime.remote,
|
||||
logger: console,
|
||||
}),
|
||||
);
|
||||
mindSpaceRuntimeAdapter.assertReady?.();
|
||||
mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade;
|
||||
mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry;
|
||||
mindSpaceAssets = mindSpaceRuntimeAdapter.assetService;
|
||||
mindSpacePages = mindSpaceRuntimeAdapter.pageService;
|
||||
mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService;
|
||||
mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService;
|
||||
mindSpacePublications = mindSpaceRuntimeAdapter.publicationService;
|
||||
const resolveUserIdByDirKey = async (dirKey) => {
|
||||
let userId = dirKey;
|
||||
if (!PUBLISH_KEY_UUID.test(dirKey)) {
|
||||
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [dirKey]);
|
||||
userId = rows[0]?.id;
|
||||
}
|
||||
}, 60 * 1000);
|
||||
return userId ?? null;
|
||||
};
|
||||
await ensureAlgorithmConfig(pool);
|
||||
const plazaAlgorithmConfig = await loadAlgorithmConfig(pool);
|
||||
plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
|
||||
@@ -423,11 +415,7 @@ async function bootstrapUserAuth() {
|
||||
recalculateHotScores,
|
||||
writebackPublications,
|
||||
});
|
||||
mindSpaceCleanup = createCleanupService(pool, {
|
||||
storageRoot:
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||||
h5Root: __dirname,
|
||||
});
|
||||
mindSpaceCleanup = mindSpaceRuntimeAdapter.cleanupService;
|
||||
await ensurePlanCatalogSchema(pool);
|
||||
const planCatalogService = createPlanCatalogService(pool);
|
||||
subscriptionService = createSubscriptionService(pool, {
|
||||
@@ -452,12 +440,7 @@ async function bootstrapUserAuth() {
|
||||
if (wechatOAuthService.enabled) {
|
||||
console.log('WeChat OAuth login enabled');
|
||||
}
|
||||
mindSpaceAgentJobs = createAgentJobService(pool, {
|
||||
pageService: mindSpacePages,
|
||||
storageRoot:
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||||
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
});
|
||||
mindSpaceAgentJobs = mindSpaceRuntimeAdapter.agentJobService;
|
||||
// Shared experience store (etat C): retrieval before / recording after each
|
||||
// agent job, so all instances learn from one another. Gated so it can be
|
||||
// disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is
|
||||
@@ -465,7 +448,7 @@ async function bootstrapUserAuth() {
|
||||
// the MySQL business DB is untouched. Falls back to MySQL keyword store if PG
|
||||
// init fails (e.g. driver missing) so a misconfig never blocks startup.
|
||||
let experienceService = null;
|
||||
if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== 'false') {
|
||||
if (mindSpaceServerRuntime.experienceEnabled) {
|
||||
if (process.env.EXPERIENCE_PG_URL) {
|
||||
try {
|
||||
const { createPgExperienceService } = await import('./experience-service-pg.mjs');
|
||||
@@ -497,88 +480,21 @@ async function bootstrapUserAuth() {
|
||||
// can run this loop without double-processing) and runs them via the runner.
|
||||
// Opt-in per instance: must NOT run on the 105 stateless front (see
|
||||
// docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED.
|
||||
if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === 'true') {
|
||||
const workerConcurrency = Math.max(
|
||||
1,
|
||||
Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2),
|
||||
);
|
||||
const workerPollMs = Math.max(
|
||||
200,
|
||||
Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000),
|
||||
);
|
||||
const workerStaleMs = Math.max(
|
||||
10_000,
|
||||
Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000),
|
||||
);
|
||||
let inFlight = 0;
|
||||
let draining = false;
|
||||
const drainQueue = async () => {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
try {
|
||||
while (inFlight < workerConcurrency) {
|
||||
const claim = await mindSpaceAgentJobs.claimNextJob();
|
||||
if (!claim) break;
|
||||
inFlight += 1;
|
||||
void mindSpaceAgentRunner
|
||||
.runJob(claim.jobId, claim)
|
||||
.catch((error) => {
|
||||
console.error('Agent worker job failed:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight -= 1;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Agent worker drain failed:', error);
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
};
|
||||
const workerTimer = setInterval(() => {
|
||||
void drainQueue();
|
||||
}, workerPollMs);
|
||||
const reaperTimer = setInterval(() => {
|
||||
void mindSpaceAgentJobs
|
||||
.reapStaleJobs(workerStaleMs)
|
||||
.then((reaped) => {
|
||||
if (reaped > 0) {
|
||||
console.warn(`Agent worker reaped ${reaped} stale running job(s)`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Agent worker reaper failed:', error);
|
||||
});
|
||||
}, Math.min(workerStaleMs, 60_000));
|
||||
workerTimer.unref?.();
|
||||
reaperTimer.unref?.();
|
||||
console.log(
|
||||
`Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)`,
|
||||
);
|
||||
}
|
||||
if (WORKSPACE_MAINTENANCE_ENABLED) {
|
||||
startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR));
|
||||
startWorkspaceAssetSyncWatcher({
|
||||
publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR),
|
||||
syncUserWorkspaceByDirKey: async (dirKey, options) => {
|
||||
let userId = dirKey;
|
||||
if (!PUBLISH_KEY_UUID.test(dirKey)) {
|
||||
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
|
||||
dirKey,
|
||||
]);
|
||||
userId = rows[0]?.id;
|
||||
}
|
||||
if (!userId) return;
|
||||
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
|
||||
},
|
||||
});
|
||||
void mindSpaceAssets.expireStaleUploads().catch(() => {});
|
||||
setInterval(() => {
|
||||
void mindSpaceAssets?.expireStaleUploads().catch(() => {});
|
||||
}, 5 * 60 * 1000).unref?.();
|
||||
} else {
|
||||
console.log('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)');
|
||||
}
|
||||
mindSpaceRuntimeAdapter.startBackgroundJobs({
|
||||
publicationCleanupIntervalMs: 60 * 1000,
|
||||
agentWorker: mindSpaceServerRuntime.agentWorker,
|
||||
agentRunner: mindSpaceAgentRunner,
|
||||
workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED,
|
||||
publishRoot: mindSpaceServerRuntime.publishRoot,
|
||||
startWorkspaceThumbnailWatcher,
|
||||
startWorkspaceAssetSyncWatcher,
|
||||
syncUserWorkspaceByDirKey: async (dirKey, options) => {
|
||||
const userId = await resolveUserIdByDirKey(dirKey);
|
||||
if (!userId) return;
|
||||
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
|
||||
},
|
||||
expireStaleUploadsIntervalMs: 5 * 60 * 1000,
|
||||
});
|
||||
await userAuth.ensureAdminUser();
|
||||
llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget: API_TARGET,
|
||||
@@ -2369,7 +2285,7 @@ api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => {
|
||||
cleanup();
|
||||
res.end();
|
||||
}
|
||||
}, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000)));
|
||||
}, mindSpaceServerRuntime.agentWorker.ssePollMs);
|
||||
// Comment line keeps proxies from closing an idle connection.
|
||||
const keepAliveTimer = setInterval(() => {
|
||||
if (!closed) res.write(': keep-alive\n\n');
|
||||
@@ -2693,7 +2609,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
|
||||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const assetId = req.params.assetId;
|
||||
const currentUserId = req.currentUser?.id ?? null;
|
||||
const currentUserId = req.currentUser?.id ?? req.userSession?.userId ?? null;
|
||||
let allowedByPublication = false;
|
||||
let publicationAccessMode = null;
|
||||
if (!currentUserId) {
|
||||
@@ -2732,6 +2648,10 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
|
||||
allowedByPublication = true;
|
||||
publicationAccessMode = 'time_limited';
|
||||
}
|
||||
if (!allowedByPublication && verifyPublicAssetToken(assetId, req.query.public_token, INTERNAL_AGENT_SECRET)) {
|
||||
allowedByPublication = true;
|
||||
publicationAccessMode = 'signed-public-token';
|
||||
}
|
||||
if (!allowedByPublication && isPublicPageReferrer) {
|
||||
allowedByPublication = true;
|
||||
publicationAccessMode = 'public-page-referrer';
|
||||
@@ -2744,7 +2664,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
|
||||
? await mindSpaceAssets.readAsset(currentUserId, assetId)
|
||||
: await mindSpaceAssets.readPublicAsset(assetId);
|
||||
await mindSpaceAudit?.write({
|
||||
userId: req.currentUser?.id ?? null,
|
||||
userId: currentUserId,
|
||||
action: 'asset.download',
|
||||
objectType: 'asset',
|
||||
objectId: assetId,
|
||||
@@ -2949,6 +2869,7 @@ const pageSyncInFlight = new Map();
|
||||
|
||||
async function syncUserGeneratedPages(userId) {
|
||||
if (!mindSpacePages || !authPool || !userId) return;
|
||||
if (mindSpaceServerRuntime.adapterKind === 'remote') return;
|
||||
|
||||
let inFlight = pageSyncInFlight.get(userId);
|
||||
if (!inFlight) {
|
||||
@@ -2957,7 +2878,7 @@ async function syncUserGeneratedPages(userId) {
|
||||
pageService: mindSpacePages,
|
||||
assetService: mindSpaceAssets,
|
||||
userId,
|
||||
publishDir: resolvePublishDir(__dirname, { id: userId }),
|
||||
publishDir: resolveMindSpaceUserPublishDir(__dirname, { id: userId }),
|
||||
syncWorkspaceAssets:
|
||||
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
|
||||
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
|
||||
@@ -2993,8 +2914,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
||||
});
|
||||
|
||||
if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) {
|
||||
const storageRoot =
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||||
const { storageRoot } = resolveMindSpaceRuntimeConfig(h5Root, process.env);
|
||||
let htmlContent = resolvedHtml.content;
|
||||
|
||||
const repaired = await repairMissingHtmlAssetReferences({
|
||||
@@ -3021,7 +2941,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
||||
}
|
||||
|
||||
if (htmlContent !== resolvedHtml.content) {
|
||||
const publishDir = resolvePublishDir(h5Root, { id: user.id });
|
||||
const publishDir = resolveMindSpaceUserPublishDir(h5Root, { id: user.id });
|
||||
await fsPromises.writeFile(
|
||||
path.join(publishDir, resolvedHtml.relativePath),
|
||||
htmlContent,
|
||||
@@ -3133,100 +3053,15 @@ async function registerPublicHtmlArtifactsForConversation({
|
||||
relativePaths = [],
|
||||
artifactRefs = [],
|
||||
}) {
|
||||
if (
|
||||
!mindSpaceConversationPackageRegistry ||
|
||||
!user?.id ||
|
||||
!publishDir ||
|
||||
!sessionId ||
|
||||
((!Array.isArray(relativePaths) || relativePaths.length === 0) &&
|
||||
(!Array.isArray(artifactRefs) || artifactRefs.length === 0))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const publishRoot = path.resolve(publishDir);
|
||||
const packageTitle = String(title ?? '').trim() || null;
|
||||
const existingManifest = await mindSpaceConversationPackageRegistry
|
||||
.readManifestForSession({ userId: user.id, sessionId })
|
||||
.catch(() => null);
|
||||
const packageRecord = existingManifest?.packageId && existingManifest.title
|
||||
? { id: existingManifest.packageId }
|
||||
: await mindSpaceConversationPackageRegistry.ensurePackage({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
title: packageTitle ?? existingManifest?.title ?? null,
|
||||
});
|
||||
const recorded = [];
|
||||
const now = Date.now();
|
||||
const refs =
|
||||
Array.isArray(artifactRefs) && artifactRefs.length > 0
|
||||
? artifactRefs
|
||||
: relativePaths.map((relativePath) => ({ relativePath }));
|
||||
for (const ref of refs) {
|
||||
const relativePath = typeof ref === 'string' ? ref : ref?.relativePath;
|
||||
const normalized = normalizePublicHtmlRelativePath(relativePath);
|
||||
if (!normalized || !normalized.toLowerCase().endsWith('.html')) continue;
|
||||
const absolutePath = path.resolve(publishRoot, normalized);
|
||||
if (absolutePath !== publishRoot && !absolutePath.startsWith(`${publishRoot}${path.sep}`)) continue;
|
||||
let stat = null;
|
||||
try {
|
||||
stat = await fsPromises.stat(absolutePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${user.id}:${sessionId}:${normalized}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
const artifactId = `ca_public_html_${hash}`;
|
||||
await mindSpaceConversationPackageRegistry.recordArtifact({
|
||||
id: artifactId,
|
||||
packageId: packageRecord.id,
|
||||
artifactKind: 'public_html',
|
||||
role: 'assistant',
|
||||
messageId: typeof ref?.messageId === 'string' && ref.messageId.trim() ? ref.messageId.trim() : null,
|
||||
displayName: path.posix.basename(normalized),
|
||||
mimeType: 'text/html',
|
||||
sizeBytes: stat.size,
|
||||
canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), user.id, normalized),
|
||||
sortOrder: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : now,
|
||||
now,
|
||||
});
|
||||
recorded.push({ artifactId, relativePath: normalized });
|
||||
}
|
||||
if (recorded.length > 0) {
|
||||
await mindSpaceConversationPackageRegistry.writeManifestForSession({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
return recorded;
|
||||
} catch (error) {
|
||||
console.warn('[MindSpace] failed to record public html artifacts:', error?.message ?? error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydratePublicHtmlArtifactsForConversationPackage({
|
||||
user,
|
||||
sessionId,
|
||||
}) {
|
||||
if (!sessionSnapshotService?.isEnabled?.() || !user?.id || !sessionId) return [];
|
||||
const publishDir = resolvePublishDir(__dirname, { id: user.id });
|
||||
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
|
||||
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
|
||||
const artifactRefs = collectOwnPublicHtmlArtifactRefs({
|
||||
messages,
|
||||
currentUser: user,
|
||||
publishDir,
|
||||
});
|
||||
return registerPublicHtmlArtifactsForConversation({
|
||||
return registerPublicHtmlArtifactsForConversationPackage({
|
||||
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
|
||||
h5Root: __dirname,
|
||||
env: process.env,
|
||||
user,
|
||||
publishDir,
|
||||
sessionId,
|
||||
title: snapshot?.session?.name,
|
||||
title,
|
||||
relativePaths,
|
||||
artifactRefs,
|
||||
});
|
||||
}
|
||||
@@ -3256,7 +3091,7 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
|
||||
if (!bundle.resolvedHtml) {
|
||||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||||
}
|
||||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
|
||||
const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath);
|
||||
const title =
|
||||
bundle.previewTitle ||
|
||||
@@ -3272,8 +3107,7 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
|
||||
bundle.resolvedHtml.content,
|
||||
{ title, subtitle, force: true },
|
||||
).catch(() => {});
|
||||
const storageRoot =
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||||
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
|
||||
const resolveAssetDataUri = createAssetDataUriResolver(
|
||||
authPool,
|
||||
storageRoot,
|
||||
@@ -3314,7 +3148,7 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
|
||||
} = bundle;
|
||||
let thumbnailReady = false;
|
||||
if (resolvedHtml?.content && resolvedHtml.relativePath) {
|
||||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
|
||||
try {
|
||||
await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, {
|
||||
title: previewTitle || resolvedHtml.suggestedTitle,
|
||||
@@ -3367,8 +3201,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
||||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||||
}
|
||||
|
||||
const storageRoot =
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||||
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
|
||||
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
|
||||
authPool,
|
||||
storageRoot,
|
||||
@@ -3376,7 +3209,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
||||
bundle.resolvedHtml.content,
|
||||
);
|
||||
|
||||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
|
||||
const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared');
|
||||
await fsPromises.mkdir(sharedDir, { recursive: true });
|
||||
|
||||
@@ -3388,7 +3221,12 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
||||
await fsPromises.writeFile(destPath, sharedHtml, 'utf8');
|
||||
|
||||
const publishKey = req.currentUser.id;
|
||||
const publicUrl = buildPublicUrl(resolvePublicBaseUrl(), publishKey, sharedRelativePath);
|
||||
const publicUrl = buildMindSpacePublicUrlForUser({
|
||||
h5Root: __dirname,
|
||||
env: process.env,
|
||||
user: publishKey,
|
||||
relativePath: sharedRelativePath,
|
||||
});
|
||||
|
||||
return res.status(201).json({ data: { publicUrl, filename } });
|
||||
} catch (error) {
|
||||
@@ -3540,7 +3378,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
|
||||
}
|
||||
|
||||
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
|
||||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
|
||||
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
|
||||
title: pageInput.title,
|
||||
subtitle: pageInput.summary,
|
||||
@@ -4650,7 +4488,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
if (!owns) {
|
||||
return res.status(403).json({ message: '无权访问该会话' });
|
||||
}
|
||||
const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id });
|
||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });
|
||||
const syncPublicHtmlDuringStream = (event) => {
|
||||
materializePublicHtmlWritesFromSessionEvent(event, { publishDir });
|
||||
};
|
||||
@@ -4878,12 +4716,11 @@ function removeQueryParam(url, key) {
|
||||
}
|
||||
|
||||
function resolveRequestOrigin(req) {
|
||||
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
|
||||
if (!host) return '';
|
||||
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
|
||||
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
|
||||
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
|
||||
return `${proto}://${host}`;
|
||||
return resolvePublicRequestOrigin({
|
||||
hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '',
|
||||
forwardedProto: req.headers['x-forwarded-proto'] || '',
|
||||
protocol: req.protocol,
|
||||
});
|
||||
}
|
||||
|
||||
function detectPublishedPageTitle(html) {
|
||||
@@ -5532,42 +5369,22 @@ app.get('/s/:token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/;
|
||||
|
||||
async function resolvePublishDirKey(segment) {
|
||||
const lower = String(segment ?? '').trim().toLowerCase();
|
||||
if (!lower) return null;
|
||||
if (PUBLISH_KEY_UUID.test(lower)) return lower;
|
||||
if (USERNAME_SLUG.test(lower)) {
|
||||
if (authPool) {
|
||||
const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]);
|
||||
if (rows[0]?.id) return String(rows[0].id).toLowerCase();
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
|
||||
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
|
||||
*/
|
||||
async function sendLongImageDownloadIfRequested(req, res, filePath) {
|
||||
if (!isLongImageDownloadRequest(req.query)) return false;
|
||||
const longImagePath = longImagePathForHtml(filePath);
|
||||
try {
|
||||
await renderLongImage({ htmlPath: filePath, outputPath: longImagePath });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.download(longImagePath, path.basename(longImagePath), (err) => {
|
||||
if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' });
|
||||
});
|
||||
} catch (error) {
|
||||
res
|
||||
.status(500)
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(`长图生成失败:${error?.message || '未知错误'}`);
|
||||
}
|
||||
return true;
|
||||
const origin = resolveRequestOrigin(req);
|
||||
const currentUrl = origin ? `${origin}${req.originalUrl || req.url || ''}` : null;
|
||||
return handleMindSpaceLongImageDownload({
|
||||
query: req.query,
|
||||
filePath,
|
||||
pageUrl: currentUrl ? removeQueryParam(removeQueryParam(currentUrl, 'download'), 'export') : null,
|
||||
res,
|
||||
isLongImageDownloadRequest,
|
||||
longImagePathForHtml,
|
||||
renderLongImage,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPublishFile(req, res, filePath) {
|
||||
@@ -5585,138 +5402,82 @@ async function sendPublishFile(req, res, filePath) {
|
||||
res.status(404).json({ message: '文件不存在' });
|
||||
return;
|
||||
}
|
||||
html = appendPublicAssetTokens(html, INTERNAL_AGENT_SECRET);
|
||||
const embed = isPlazaEmbedRequest(req.query);
|
||||
if (embed) {
|
||||
html = preparePublicationHtmlForEmbed(html);
|
||||
allowPlazaEmbedFrame(res);
|
||||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
||||
}
|
||||
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
|
||||
// Share cards (esp. WeChat) require https og:image. The edge only serves public domains
|
||||
// over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a
|
||||
// public host we force https and ignore the (wrong) forwarded scheme. Loopback/LAN stays http.
|
||||
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
|
||||
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
|
||||
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
|
||||
const origin = host ? `${proto}://${host}` : '';
|
||||
const cleanPath = req.originalUrl.split('?')[0].split('#')[0];
|
||||
// Distinguish an explicit file URL (.../space.html) from a directory that resolves to
|
||||
// index.html (.../<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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>/ */
|
||||
|
||||
Reference in New Issue
Block a user