feat: SaaS foundation for CrowdSight

Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
This commit is contained in:
Kunthawat Greethong
2026-08-31 13:05:21 +07:00
parent 89d04e795b
commit 8b84378fe1
165 changed files with 15884 additions and 4001 deletions

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')
const parseJson = relativePath => JSON.parse(read(relativePath))
const flattenKeys = (value, prefix = '') => {
const keys = []
for (const [key, child] of Object.entries(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) {
keys.push(...flattenKeys(child, fullKey))
} else {
keys.push(fullKey)
}
}
return keys.sort()
}
test('locale registry exposes only Thai and English', () => {
const languages = parseJson('locales/languages.json')
assert.deepEqual(Object.keys(languages).sort(), ['en', 'th'])
assert.equal(languages.en.label, 'English')
assert.equal(languages.th.label, 'ไทย')
})
test('Thai and English translation dictionaries have the same keys', () => {
assert.deepEqual(
flattenKeys(parseJson('locales/en.json')),
flattenKeys(parseJson('locales/th.json')),
)
})
test('frontend i18n uses Thai as the safe default and fallback', () => {
const source = read('frontend/src/i18n/index.js')
assert.match(source, /DEFAULT_LOCALE\s*=\s*['"]th['"]/)
assert.match(source, /fallbackLocale:\s*DEFAULT_LOCALE/)
assert.doesNotMatch(source, /\|\|\s*['"]zh['"]|fallbackLocale:\s*['"]zh['"]/)
})
test('HTML metadata has no Chinese locale or font dependency', () => {
const html = read('frontend/index.html')
assert.match(html, /<html\s+lang=["']th["']/)
assert.doesNotMatch(html, /lang=["']zh|Noto Sans SC|预测|社交媒体|中文/)
})
test('production build text artifacts contain no CJK code points', () => {
const distRoot = path.join(repoRoot, 'frontend/dist')
const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico'])
const textFiles = []
const visit = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
visit(entryPath)
} else if (!binaryExtensions.has(path.extname(entry.name).toLowerCase())) {
textFiles.push(entryPath)
}
}
}
visit(distRoot)
for (const filePath of textFiles) {
const content = fs.readFileSync(filePath, 'utf8')
assert.doesNotMatch(content, /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/, filePath)
}
})