From 66c530b1ed971cea50b3b6dae199bd10eb5a916b Mon Sep 17 00:00:00 2001 From: john Date: Thu, 2 Jul 2026 21:04:49 +0800 Subject: [PATCH] Add MindSpace canonical URL facade --- docs/mindspace-service-contract.md | 1 + mindspace-canonical-url.mjs | 95 ++++++++++++++++++++++++++++++ mindspace-canonical-url.test.mjs | 70 ++++++++++++++++++++++ package.json | 2 +- 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 mindspace-canonical-url.mjs create mode 100644 mindspace-canonical-url.test.mjs diff --git a/docs/mindspace-service-contract.md b/docs/mindspace-service-contract.md index 56a4399..7321125 100644 --- a/docs/mindspace-service-contract.md +++ b/docs/mindspace-service-contract.md @@ -93,6 +93,7 @@ validatePublicBackingObject({ publicationId }) 规则: - 新代码不得直接拼 `/MindSpace//...`。 +- 当前分支新增的 `mindspace-canonical-url.mjs` 是 P1 facade 起点;后续旧 helper 迁移到它后,再接入 backing object 校验。 - 旧 URL helper 暂时保留为 compatibility layer。 - public URL 必须能反查到 publication 或 asset。 - URL 返回前应校验 backing object 存在,或明确返回 pending 状态。 diff --git a/mindspace-canonical-url.mjs b/mindspace-canonical-url.mjs new file mode 100644 index 0000000..13b3e03 --- /dev/null +++ b/mindspace-canonical-url.mjs @@ -0,0 +1,95 @@ +export const MINDSPACE_PUBLIC_ROOT = 'MindSpace'; +export const MINDSPACE_PUBLIC_ZONE = 'public'; + +function urlError(message, code, details = {}) { + return Object.assign(new Error(message), { code, ...details }); +} + +function requiredSegment(value, field) { + const normalized = String(value ?? '').trim(); + if (!normalized) { + throw urlError(`${field} is required`, 'invalid_mindspace_url_segment', { field }); + } + if (normalized.includes('/') || normalized.includes('\\') || normalized.includes('\0')) { + throw urlError(`${field} must be a single URL segment`, 'invalid_mindspace_url_segment', { + field, + }); + } + return normalized; +} + +export function normalizeMindSpacePublicBaseUrl(publicBaseUrl) { + const normalized = String(publicBaseUrl ?? '').trim().replace(/\/+$/, ''); + if (!normalized) { + throw urlError('publicBaseUrl is required', 'invalid_mindspace_public_base_url'); + } + return normalized; +} + +export function normalizeMindSpacePublicRelativePath(relativePath) { + const raw = String(relativePath ?? '').trim(); + if (!raw) { + throw urlError('relativePath is required', 'invalid_mindspace_public_relative_path'); + } + if (raw.includes('\0') || raw.includes('\\')) { + throw urlError('relativePath must use URL-style forward slashes', 'invalid_mindspace_public_relative_path'); + } + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(raw) || raw.startsWith('//')) { + throw urlError('relativePath must not be an absolute URL', 'invalid_mindspace_public_relative_path'); + } + + const clean = raw.replace(/^\/+/, ''); + const parts = clean.split('/').filter(Boolean); + if (parts.length === 0 || parts.includes('..')) { + throw urlError('relativePath must not traverse parent directories', 'invalid_mindspace_public_relative_path'); + } + return parts.join('/'); +} + +export function createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath }) { + const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl); + const owner = requiredSegment(ownerKey, 'ownerKey'); + const relative = normalizeMindSpacePublicRelativePath(relativePath); + const encodedRelative = relative.split('/').map(encodeURIComponent).join('/'); + return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/${encodedRelative}`; +} + +export function createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey, filename }) { + const clean = normalizeMindSpacePublicRelativePath(filename); + const relative = clean.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`) + ? clean + : `${MINDSPACE_PUBLIC_ZONE}/${clean}`; + return createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath: relative }); +} + +export function parseMindSpacePublicUrl(inputUrl) { + const parsed = new URL(String(inputUrl ?? ''), 'https://mindspace.invalid'); + const parts = parsed.pathname.split('/').filter(Boolean).map(decodeURIComponent); + const rootIndex = parts.indexOf(MINDSPACE_PUBLIC_ROOT); + if (rootIndex < 0 || parts.length < rootIndex + 3) { + throw urlError('URL is not a MindSpace public URL', 'not_mindspace_public_url'); + } + + const ownerKey = parts[rootIndex + 1]; + const relativePath = normalizeMindSpacePublicRelativePath(parts.slice(rootIndex + 2).join('/')); + return { + ownerKey, + relativePath, + isPublicZone: relativePath === MINDSPACE_PUBLIC_ZONE || relativePath.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`), + filename: relativePath.split('/').at(-1) ?? '', + }; +} + +export function canonicalizeMindSpacePublicUrl(inputUrl, { publicBaseUrl }) { + const parsed = parseMindSpacePublicUrl(inputUrl); + return createMindSpacePublicUrl({ + publicBaseUrl, + ownerKey: parsed.ownerKey, + relativePath: parsed.relativePath, + }); +} + +export const mindspaceCanonicalUrlInternals = { + requiredSegment, + urlError, +}; diff --git a/mindspace-canonical-url.test.mjs b/mindspace-canonical-url.test.mjs new file mode 100644 index 0000000..758958a --- /dev/null +++ b/mindspace-canonical-url.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + canonicalizeMindSpacePublicUrl, + createMindSpacePublicPageUrl, + createMindSpacePublicUrl, + normalizeMindSpacePublicRelativePath, + parseMindSpacePublicUrl, +} from './mindspace-canonical-url.mjs'; + +test('createMindSpacePublicUrl encodes owner and relative path segments', () => { + assert.equal( + createMindSpacePublicUrl({ + publicBaseUrl: 'https://m.tkmind.cn/', + ownerKey: 'user 1', + relativePath: 'public/report 1.html', + }), + 'https://m.tkmind.cn/MindSpace/user%201/public/report%201.html', + ); +}); + +test('createMindSpacePublicPageUrl always places html pages under public zone', () => { + assert.equal( + createMindSpacePublicPageUrl({ + publicBaseUrl: 'https://m.tkmind.cn', + ownerKey: 'user-1', + filename: 'report.html', + }), + 'https://m.tkmind.cn/MindSpace/user-1/public/report.html', + ); + assert.equal( + createMindSpacePublicPageUrl({ + publicBaseUrl: 'https://m.tkmind.cn', + ownerKey: 'user-1', + filename: 'public/report.html', + }), + 'https://m.tkmind.cn/MindSpace/user-1/public/report.html', + ); +}); + +test('normalizeMindSpacePublicRelativePath rejects absolute URLs and traversal', () => { + assert.equal(normalizeMindSpacePublicRelativePath('/public/a.html'), 'public/a.html'); + for (const value of ['https://example.com/a.html', '//example.com/a.html', '../a.html', 'public/../a.html', 'a\\b']) { + assert.throws( + () => normalizeMindSpacePublicRelativePath(value), + (error) => error.code === 'invalid_mindspace_public_relative_path', + ); + } +}); + +test('parseMindSpacePublicUrl extracts owner and relative path', () => { + assert.deepEqual( + parseMindSpacePublicUrl('https://m.tkmind.cn/MindSpace/user-1/public/report.html?x=1'), + { + ownerKey: 'user-1', + relativePath: 'public/report.html', + isPublicZone: true, + filename: 'report.html', + }, + ); +}); + +test('canonicalizeMindSpacePublicUrl rewrites only the host base', () => { + assert.equal( + canonicalizeMindSpacePublicUrl('https://old.example/MindSpace/user-1/public/report.html', { + publicBaseUrl: 'https://m.tkmind.cn', + }), + 'https://m.tkmind.cn/MindSpace/user-1/public/report.html', + ); +}); diff --git a/package.json b/package.json index af7b50c..4fdecc4 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build:portal-runtime": "node scripts/build-portal-runtime.mjs", "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", - "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-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-schema.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-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-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-schema.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",