Jelajahi Sumber

feat: add configurable SaaS platform backend

gangvy 1 bulan lalu
induk
melakukan
fc7b345ea5

+ 8 - 0
.env.example

@@ -10,6 +10,14 @@ PARSE_APP_ID=
 PARSE_MASTER_KEY=
 PARSE_MAINTENANCE_KEY=
 PARSE_SERVER_URL=http://127.0.0.1:4400/parse
+API_AUTH_MODE=parse
+LOCAL_AUTH_USER_ID=local-admin
+LOCAL_AUTH_USER_EMAIL=local-admin@localhost
+LOCAL_AUTH_USER_NAME=Local Admin
+SAAS_DEFAULT_WORKSPACE_ID=demashi
+SAAS_BOOTSTRAP_ADMIN_USER_ID=
+SAAS_BOOTSTRAP_ADMIN_EMAIL=
+SAAS_BOOTSTRAP_ADMIN_NAME=
 FMODE_BASE_URL=https://server.fmode.cn/api/voc-e-commerce
 FMODE_API_KEY=
 FMODE_TIMEOUT_MS=30000

+ 60 - 4
README.md

@@ -18,6 +18,10 @@ Completed on 2026-07-23:
 - Deterministic identities for products, relations, reviews, and daily sync requests.
 - Bounded batch importer for the normalized Demashi dataset.
 - Database-free local demo runtime for frontend integration before infrastructure is provisioned.
+- Workspace membership and `owner` / `admin` / `analyst` / `viewer` authorization.
+- Parse session authentication for production and a fixed local identity for database-free development.
+- Cursor-paginated product, relation, review, import, sync-job, analysis, action, alert, and audit APIs.
+- Pending analysis requests, action workflows, alerts, and auditable write operations without fabricated AI output.
 
 The current case import resolves to 2,817 operating products, 9,717 daily metrics, 40 relations, and 38 internal `relation_stub` records required to preserve foreign keys. Stub records are excluded from the frontend product list and are never presented as collected product facts.
 
@@ -28,8 +32,10 @@ The worker calls JD product-detail and review paths only through the company `/a
 ```text
 Saas-voc frontend
   -> saas-voc-server
-      -> dedicated Parse application
-      -> dedicated PostgreSQL database (voc schema)
+      -> shared API and authorization contract
+          -> local in-memory repository (development)
+          -> dedicated PostgreSQL repository (production, voc schema)
+      -> dedicated Parse application (production authentication)
       -> sync queue
           -> existing /api/voc-e-commerce gateway
 ```
@@ -69,6 +75,8 @@ npm run start:local
 
 Local demo `sync` requests create queryable in-memory completion records that validate whether requested products exist in the packaged dataset. They do not collect external data or persist anything. Production collection and persistence remain in `npm run dev`.
 
+Local mode injects the fixed `local-admin` owner. It is intentionally database-free and must not be internet-facing.
+
 ### Database-backed runtime
 
 Local database:
@@ -94,6 +102,16 @@ Import the current normalized case after migration:
 npm run import:dataset -- "E:\workspace\Saas-voc\src\assets\data\demashi-summary.json"
 ```
 
+`001_initial_domain.sql` creates the domestic VOC catalog and sync tables. `002_saas_platform.sql` adds members, analysis runs, actions, alerts, and audit history. The migration runner records each file in `voc.schema_migration` and executes each unapplied migration in its own transaction.
+
+### Authentication and first administrator
+
+Use `API_AUTH_MODE=parse` in production. API clients send either `X-Parse-Session-Token` or `Authorization: Bearer <session-token>`. The backend validates the session through its own `/parse/users/me` endpoint and never sends a Parse master or maintenance key to the browser.
+
+For the first production start, set `SAAS_BOOTSTRAP_ADMIN_USER_ID` to the Parse user object id and optionally set its email and display name. The service upserts that user as owner of `SAAS_DEFAULT_WORKSPACE_ID`. Clear the bootstrap variables after successful verification so a later restart cannot silently restore that account's owner role.
+
+`API_AUTH_MODE=disabled` is available for isolated database-backed development only. It uses `LOCAL_AUTH_USER_ID`, `LOCAL_AUTH_USER_EMAIL`, and `LOCAL_AUTH_USER_NAME`, and must not be enabled in a public environment.
+
 Default local API address:
 
 ```text
@@ -126,6 +144,29 @@ GET /api/domestic-voc/jobs/{jobId}
 GET /api/domestic-voc/snapshot?workspaceId=demashi&platform=jd
 ```
 
+Catalog and operational APIs:
+
+```text
+GET /api/domestic-voc/products
+GET /api/domestic-voc/products/{productId}
+GET /api/domestic-voc/products/{productId}/reviews
+GET /api/domestic-voc/relations
+GET /api/domestic-voc/jobs
+GET /api/domestic-voc/jobs/{jobId}/events
+
+GET /api/saas/context
+GET /api/saas/workspaces
+GET|PUT /api/saas/workspaces/{workspaceId}/members[/userId]
+GET /api/saas/workspaces/{workspaceId}/data-sources
+GET /api/saas/workspaces/{workspaceId}/imports
+GET|POST /api/saas/workspaces/{workspaceId}/analyses
+GET|POST|PATCH /api/saas/workspaces/{workspaceId}/actions[/actionId]
+GET|POST|PATCH /api/saas/workspaces/{workspaceId}/alerts[/alertId]
+GET /api/saas/workspaces/{workspaceId}/audit
+```
+
+List endpoints use opaque cursor pagination (`limit` plus optional `cursor`). Analysis creation returns a truthful `pending` record; an analysis worker is not included yet.
+
 An empty database returns a valid empty dataset. It does not invent reviews, ratings, sentiment, pain points, or AI output.
 
 ## Database rules
@@ -135,9 +176,24 @@ An empty database returns a valid empty dataset. It does not invent reviews, rat
 - Foreign-key columns and common workspace/date filters are indexed.
 - Workers claim jobs with `FOR UPDATE SKIP LOCKED`.
 - External HTTP requests must run outside database transactions.
+- Workspace membership is checked on every domestic and SaaS business route.
+- Disabled workspaces cannot pass authorization even when a membership row remains active.
+- Owner and admin can manage members and read audit history; analyst can run sync/analysis and manage actions/alerts; viewer is read-only.
+- Member, sync, analysis, action, and alert writes append an audit entry.
 - Production deployments must use a pooled, least-privilege runtime role. Migration credentials should be separate from runtime credentials.
 - The HTTP server never runs DDL automatically; migrations are an explicit deployment step.
 
+## Infrastructure handoff
+
+When the dedicated database and server are available, no route or frontend contract rewrite is required:
+
+1. Set `DATABASE_URL` to the least-privilege runtime role and `MIGRATION_DATABASE_URL` to the schema owner.
+2. Set independent Parse keys, `PARSE_SERVER_URL`, and `API_AUTH_MODE=parse`.
+3. Set the company relay base URL/key, worker limits, and exact frontend `CORS_ORIGINS`.
+4. Run `npm ci`, `npm run migrate`, then `npm run import:dataset -- <dataset-path>`.
+5. Bootstrap the first owner, start the service, verify `/health`, `/api/saas/context`, snapshot totals, and one bounded sync job.
+6. Clear the bootstrap administrator variables and place TLS/same-origin proxying in front of `/parse` and `/api`.
+
 ## Security status
 
 `parse-server@9.10.0` replaces the initially evaluated v8 line, removing all high and critical audit findings. A `ws@8.21.0` override is retained because Parse currently pins an older vulnerable patch. The remaining audit findings are moderate transitive dependencies in Parse push/Firebase and redirect support; push and LiveQuery are not configured in this template. Re-run `npm audit --omit=dev` before each deployment and do not use `npm audit fix --force`, which currently proposes an unsafe Parse downgrade.
@@ -153,12 +209,12 @@ npm audit --omit=dev
 Current result:
 
 - TypeScript build: passed.
-- Unit, adapter, worker, local-demo, and HTTP contract tests: 20 passed.
+- Unit, adapter, worker, local-demo, authentication, RBAC, cursor, workflow, audit, and HTTP contract tests: 25 passed on Node.js 22.13.0.
 - Production dependency audit: 0 critical, 0 high, 13 moderate.
 - Company gateway health: HTTP 200 on 2026-07-23.
 - Credentialed live JD product-detail request: passed through the company gateway for product `11266507445`; the adapter extracted the product id, title, and brand from the live double-`data` envelope.
 - Credentialed live JD review request: authenticated successfully and returned an empty first page with next-page metadata. A non-empty review sample is still required before review evidence can be accepted.
 - The live credential was process-only: it was not written to an environment file, source file, fixture, log, or Git history. No supplier endpoint was contacted directly.
-- PostgreSQL integration: pending a newly provisioned database; the cross-border production database is intentionally excluded.
+- PostgreSQL integration: pending a newly provisioned database. The local Docker CLI is installed but its engine was unavailable on 2026-07-23; no existing database was contacted.
 
 See `TASKS.md` for the implementation sequence and acceptance boundary.

+ 9 - 3
TASKS.md

@@ -15,8 +15,13 @@ Status date: 2026-07-23
 - [x] Add a database-free local-demo server for frontend integration using the normalized Demashi snapshot.
 - [x] Add Fmode timeout/retry/authentication behavior behind a server-only client.
 - [x] Add unit tests and compile under strict TypeScript settings.
+- [x] Add a shared platform repository contract with local-memory and PostgreSQL implementations.
+- [x] Add Parse-session authentication, workspace membership, and four-role RBAC.
+- [x] Add cursor-paginated product, relation, review, job, import, and platform workflow APIs.
+- [x] Add analysis requests, action workflows, alerts, and audit history without fake AI results.
+- [x] Cover local platform flows, viewer write denial, Parse session behavior, pagination, and audit writes.
 - [ ] Run migrations against a newly provisioned PostgreSQL database.
-- [ ] Smoke-test the HTTP service under the supported Node 22 runtime.
+- [x] Smoke-test the local HTTP service under the supported Node 22 runtime.
 
 ## Phase 2 - case-data persistence
 
@@ -46,7 +51,8 @@ Status date: 2026-07-23
 - [ ] Provision a dedicated PostgreSQL database and least-privilege runtime role.
 - [ ] Provision independent Parse application credentials.
 - [ ] Put TLS and same-origin reverse proxying in front of `/parse` and `/api/domestic-voc`.
-- [ ] Add authentication/authorization before enabling the frontend `AuthGuard`.
+- [x] Add backend authentication, workspace authorization, and role permission boundaries.
+- [ ] Enable and integrate the frontend `AuthGuard` after Parse credentials and the first user are provisioned.
 - [ ] Add backup, restore, retention, and failed-job replay procedures.
 - [ ] Resolve or formally accept remaining moderate Parse transitive advisories.
 - [ ] Complete end-to-end acceptance with Demashi product detail and real review evidence.
@@ -54,7 +60,7 @@ Status date: 2026-07-23
 ## Explicitly deferred
 
 - Multi-platform adapters beyond JD.
-- Multi-tenant billing and complex role management.
+- Multi-tenant billing, SSO provisioning, and custom roles beyond the four built-in roles.
 - Scheduled full-catalog crawling.
 - AI reports, sentiment, pain points, or recommendations without review evidence.
 - Any reuse of the cross-border production database or credentials.

+ 114 - 0
migrations/002_saas_platform.sql

@@ -0,0 +1,114 @@
+CREATE TABLE IF NOT EXISTS voc.workspace_member (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  user_external_id text NOT NULL,
+  email text NOT NULL DEFAULT '',
+  display_name text NOT NULL DEFAULT '',
+  role text NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'analyst', 'viewer')),
+  status text NOT NULL DEFAULT 'active' CHECK (status IN ('invited', 'active', 'disabled')),
+  invited_at timestamptz,
+  last_seen_at timestamptz,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  updated_at timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (workspace_id, user_external_id)
+);
+
+CREATE INDEX IF NOT EXISTS workspace_member_user_active_idx
+  ON voc.workspace_member (user_external_id, workspace_id)
+  WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS workspace_member_workspace_role_idx
+  ON voc.workspace_member (workspace_id, role, id);
+
+CREATE TABLE IF NOT EXISTS voc.analysis_run (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  analysis_type text NOT NULL CHECK (analysis_type IN ('voice', 'pain_point', 'feature', 'scenario', 'risk', 'report')),
+  target_kind text NOT NULL DEFAULT 'workspace' CHECK (target_kind IN ('workspace', 'category', 'product')),
+  target_key text NOT NULL DEFAULT '',
+  status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'partial', 'failed', 'cancelled')),
+  input jsonb NOT NULL DEFAULT '{}'::jsonb,
+  result jsonb,
+  evidence_count integer NOT NULL DEFAULT 0 CHECK (evidence_count >= 0),
+  requested_by_external_id text NOT NULL,
+  error_summary text,
+  requested_at timestamptz NOT NULL DEFAULT now(),
+  started_at timestamptz,
+  completed_at timestamptz,
+  updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS analysis_run_workspace_status_requested_idx
+  ON voc.analysis_run (workspace_id, status, requested_at DESC, id DESC);
+CREATE INDEX IF NOT EXISTS analysis_run_workspace_target_idx
+  ON voc.analysis_run (workspace_id, target_kind, target_key, requested_at DESC, id DESC);
+
+CREATE TABLE IF NOT EXISTS voc.action_item (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  source_analysis_id bigint REFERENCES voc.analysis_run(id) ON DELETE SET NULL,
+  action_type text NOT NULL DEFAULT 'general' CHECK (action_type IN ('general', 'experience', 'product', 'strategy', 'data_quality')),
+  title text NOT NULL,
+  description text NOT NULL DEFAULT '',
+  priority text NOT NULL DEFAULT 'medium' CHECK (priority IN ('critical', 'high', 'medium', 'low')),
+  status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'planned', 'in_progress', 'blocked', 'completed', 'cancelled')),
+  product_key text,
+  assignee_external_id text,
+  due_at timestamptz,
+  created_by_external_id text NOT NULL,
+  completed_at timestamptz,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS action_item_workspace_status_priority_idx
+  ON voc.action_item (workspace_id, status, priority, id DESC);
+CREATE INDEX IF NOT EXISTS action_item_workspace_assignee_open_idx
+  ON voc.action_item (workspace_id, assignee_external_id, id DESC)
+  WHERE status IN ('open', 'planned', 'in_progress', 'blocked');
+CREATE INDEX IF NOT EXISTS action_item_source_analysis_idx
+  ON voc.action_item (source_analysis_id)
+  WHERE source_analysis_id IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS voc.alert (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  alert_type text NOT NULL DEFAULT 'voc_risk' CHECK (alert_type IN ('voc_risk', 'sentiment_spike', 'data_quality', 'sync_failure', 'quota')),
+  severity text NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
+  status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved', 'dismissed')),
+  product_key text,
+  title text NOT NULL,
+  summary text NOT NULL DEFAULT '',
+  evidence jsonb NOT NULL DEFAULT '[]'::jsonb,
+  detected_at timestamptz NOT NULL DEFAULT now(),
+  acknowledged_by_external_id text,
+  acknowledged_at timestamptz,
+  resolved_at timestamptz,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS alert_workspace_status_detected_idx
+  ON voc.alert (workspace_id, status, detected_at DESC, id DESC);
+CREATE INDEX IF NOT EXISTS alert_workspace_product_open_idx
+  ON voc.alert (workspace_id, product_key, detected_at DESC, id DESC)
+  WHERE status IN ('open', 'acknowledged');
+
+CREATE TABLE IF NOT EXISTS voc.audit_log (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  actor_external_id text NOT NULL,
+  action text NOT NULL,
+  entity_type text NOT NULL,
+  entity_public_id text,
+  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS audit_log_workspace_created_idx
+  ON voc.audit_log (workspace_id, created_at DESC, id DESC);
+CREATE INDEX IF NOT EXISTS audit_log_workspace_entity_idx
+  ON voc.audit_log (workspace_id, entity_type, entity_public_id, id DESC)
+  WHERE entity_public_id IS NOT NULL;

+ 16 - 1
src/app.ts

@@ -3,15 +3,21 @@ import express, { type ErrorRequestHandler, type RequestHandler } from 'express'
 import type { Pool } from 'pg';
 import { ZodError } from 'zod';
 import type { AppConfig } from './config/env.js';
+import { ApiError } from './http/api-error.js';
 import { createDomesticVocRouter } from './modules/domestic-voc/routes.js';
 import { SyncJobRepository } from './modules/domestic-voc/repositories/sync-job.repository.js';
 import { SnapshotService } from './modules/domestic-voc/services/snapshot.service.js';
 import { SyncService } from './modules/domestic-voc/services/sync.service.js';
+import { createAuthenticationMiddleware, createAuthenticator, WorkspaceAccessService } from './modules/saas-platform/auth.js';
+import type { PlatformRepository } from './modules/saas-platform/domain.js';
+import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
+import { createSaasPlatformRouter } from './modules/saas-platform/routes.js';
 
 export function createApp(input: {
   config: AppConfig;
   pool: Pool;
   parseApp?: RequestHandler;
+  platformRepository?: PlatformRepository;
 }) {
   const app = express();
   app.disable('x-powered-by');
@@ -56,16 +62,25 @@ export function createApp(input: {
 
   if (input.parseApp) app.use('/parse', input.parseApp);
 
+  const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool);
+  const access = new WorkspaceAccessService(platform);
+  app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
+
   const jobs = new SyncJobRepository(input.pool);
   const sync = new SyncService(jobs);
   const snapshot = new SnapshotService(input.pool);
-  app.use('/api/domestic-voc', createDomesticVocRouter({ jobs, sync, snapshot }));
+  app.use('/api/domestic-voc', createDomesticVocRouter({ jobs, sync, snapshot, catalog: platform, access }));
+  app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
 
   app.use((_request, response) => {
     response.status(404).json({ error: 'not_found' });
   });
 
   const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => {
+    if (error instanceof ApiError) {
+      response.status(error.status).json({ error: error.code });
+      return;
+    }
     if (error instanceof ZodError) {
       response.status(400).json({
         error: 'invalid_request',

+ 33 - 0
src/config/env.ts

@@ -13,6 +13,14 @@ const environmentSchema = z.object({
   PARSE_MASTER_KEY: z.string().min(32, 'PARSE_MASTER_KEY must contain at least 32 characters'),
   PARSE_MAINTENANCE_KEY: z.string().min(32, 'PARSE_MAINTENANCE_KEY must contain at least 32 characters'),
   PARSE_SERVER_URL: z.url(),
+  API_AUTH_MODE: z.enum(['disabled', 'parse']).default('parse'),
+  LOCAL_AUTH_USER_ID: z.string().min(1).default('local-admin'),
+  LOCAL_AUTH_USER_EMAIL: z.string().default('local-admin@localhost'),
+  LOCAL_AUTH_USER_NAME: z.string().min(1).default('Local Admin'),
+  SAAS_DEFAULT_WORKSPACE_ID: z.string().min(1).default('demashi'),
+  SAAS_BOOTSTRAP_ADMIN_USER_ID: z.string().min(1).optional(),
+  SAAS_BOOTSTRAP_ADMIN_EMAIL: z.string().default(''),
+  SAAS_BOOTSTRAP_ADMIN_NAME: z.string().default(''),
   FMODE_BASE_URL: z.url(),
   FMODE_API_KEY: z.string().min(1, 'FMODE_API_KEY is required'),
   FMODE_TIMEOUT_MS: z.coerce.number().int().min(100).max(120_000).default(30_000),
@@ -40,6 +48,16 @@ export type AppConfig = {
     maintenanceKey: string;
     serverUrl: string;
   };
+  auth: {
+    mode: 'disabled' | 'parse';
+    localUserId: string;
+    localUserEmail: string;
+    localUserName: string;
+    defaultWorkspaceId: string;
+    bootstrapAdminUserId: string;
+    bootstrapAdminEmail: string;
+    bootstrapAdminName: string;
+  };
   fmode: {
     baseUrl: string;
     apiKey: string;
@@ -72,6 +90,11 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
   }
 
   const value = result.data;
+  if (value.NODE_ENV === 'production' && value.API_AUTH_MODE === 'disabled') {
+    throw new EnvironmentConfigurationError([
+      'API_AUTH_MODE: disabled authentication is not allowed in production',
+    ]);
+  }
   return {
     nodeEnv: value.NODE_ENV,
     host: value.HOST,
@@ -89,6 +112,16 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
       maintenanceKey: value.PARSE_MAINTENANCE_KEY,
       serverUrl: value.PARSE_SERVER_URL,
     },
+    auth: {
+      mode: value.API_AUTH_MODE,
+      localUserId: value.LOCAL_AUTH_USER_ID,
+      localUserEmail: value.LOCAL_AUTH_USER_EMAIL,
+      localUserName: value.LOCAL_AUTH_USER_NAME,
+      defaultWorkspaceId: value.SAAS_DEFAULT_WORKSPACE_ID,
+      bootstrapAdminUserId: value.SAAS_BOOTSTRAP_ADMIN_USER_ID ?? '',
+      bootstrapAdminEmail: value.SAAS_BOOTSTRAP_ADMIN_EMAIL,
+      bootstrapAdminName: value.SAAS_BOOTSTRAP_ADMIN_NAME,
+    },
     fmode: {
       baseUrl: value.FMODE_BASE_URL.replace(/\/+$/, ''),
       apiKey: value.FMODE_API_KEY,

+ 10 - 0
src/http/api-error.ts

@@ -0,0 +1,10 @@
+export class ApiError extends Error {
+  constructor(
+    public readonly status: number,
+    public readonly code: string,
+    message = code,
+  ) {
+    super(message);
+    this.name = 'ApiError';
+  }
+}

+ 19 - 1
src/local-app.ts

@@ -6,6 +6,10 @@ import { LocalSnapshotService } from './modules/domestic-voc/local/local-snapsho
 import { LocalSyncJobStore } from './modules/domestic-voc/local/local-sync-job.store.js';
 import { createDomesticVocRouter } from './modules/domestic-voc/routes.js';
 import { SyncService } from './modules/domestic-voc/services/sync.service.js';
+import { ApiError } from './http/api-error.js';
+import { createAuthenticationMiddleware, DisabledAuthenticator, WorkspaceAccessService } from './modules/saas-platform/auth.js';
+import { LocalPlatformRepository } from './modules/saas-platform/local-platform.repository.js';
+import { createSaasPlatformRouter } from './modules/saas-platform/routes.js';
 
 export function createLocalDemoApp(input: {
   dataset: DomesticDataset;
@@ -22,6 +26,15 @@ export function createLocalDemoApp(input: {
   app.use(express.json({ limit: '1mb' }));
 
   const jobs = new LocalSyncJobStore(input.dataset);
+  const localPrincipal = {
+    userId: 'local-admin',
+    email: 'local-admin@localhost',
+    displayName: 'Local Admin',
+    authMode: 'disabled' as const,
+  };
+  const platform = new LocalPlatformRepository(input.dataset, jobs, localPrincipal);
+  const access = new WorkspaceAccessService(platform);
+  app.use('/api', createAuthenticationMiddleware(new DisabledAuthenticator(localPrincipal)));
   const sync = new SyncService(jobs);
   const snapshot = new LocalSnapshotService(input.dataset);
 
@@ -41,13 +54,18 @@ export function createLocalDemoApp(input: {
     });
   });
 
-  app.use('/api/domestic-voc', createDomesticVocRouter({ jobs, sync, snapshot }));
+  app.use('/api/domestic-voc', createDomesticVocRouter({ jobs, sync, snapshot, catalog: platform, access }));
+  app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
 
   app.use((_request, response) => {
     response.status(404).json({ error: 'not_found' });
   });
 
   const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => {
+    if (error instanceof ApiError) {
+      response.status(error.status).json({ error: error.code });
+      return;
+    }
     if (error instanceof ZodError) {
       response.status(400).json({
         error: 'invalid_request',

+ 6 - 0
src/modules/domestic-voc/local/local-sync-job.store.ts

@@ -50,4 +50,10 @@ export class LocalSyncJobStore implements SyncJobStore {
   async findByPublicId(publicId: string): Promise<SyncJobRecord | null> {
     return this.jobs.get(publicId) ?? null;
   }
+
+  listByWorkspace(workspaceId: string): SyncJobRecord[] {
+    return [...this.jobs.values()]
+      .filter((job) => job.workspaceId === workspaceId)
+      .sort((left, right) => right.requestedAt.localeCompare(left.requestedAt));
+  }
 }

+ 101 - 0
src/modules/domestic-voc/routes.ts

@@ -2,6 +2,9 @@ import { Router } from 'express';
 import { z } from 'zod';
 import type { SyncJobStore } from './repositories/sync-job.repository.js';
 import type { DomesticDataset } from '../../types/domestic-dataset.js';
+import type { PlatformRepository } from '../saas-platform/domain.js';
+import type { WorkspaceAccessService } from '../saas-platform/auth.js';
+import { getPrincipal } from '../saas-platform/auth.js';
 import type { SyncService } from './services/sync.service.js';
 
 export interface DomesticSnapshotProvider {
@@ -25,12 +28,15 @@ export function createDomesticVocRouter(dependencies: {
   jobs: SyncJobStore;
   sync: SyncService;
   snapshot: DomesticSnapshotProvider;
+  catalog: PlatformRepository;
+  access: WorkspaceAccessService;
 }): Router {
   const router = Router();
 
   router.post('/sync', async (request, response, next) => {
     try {
       const input = syncRequestSchema.parse(request.body);
+      await dependencies.access.require(request, input.workspaceId, 'data:sync');
       const headerIdempotencyKey = request.header('Idempotency-Key')?.trim();
       const { idempotencyKey: bodyIdempotencyKey, ...requiredInput } = input;
       const idempotencyKey = headerIdempotencyKey || bodyIdempotencyKey;
@@ -43,6 +49,18 @@ export function createDomesticVocRouter(dependencies: {
         response.status(404).json({ error: 'workspace_not_found' });
         return;
       }
+      await dependencies.catalog.appendAudit({
+        workspaceId: input.workspaceId,
+        actorUserId: getPrincipal(request).userId,
+        action: 'sync.requested',
+        entityType: 'sync_job',
+        entityId: job.id,
+        metadata: {
+          platform: input.platform,
+          scopes: input.scopes,
+          productCount: input.productIds.length,
+        },
+      });
       response.status(202).json({ job });
     } catch (error) {
       next(error);
@@ -57,6 +75,7 @@ export function createDomesticVocRouter(dependencies: {
         response.status(404).json({ error: 'job_not_found' });
         return;
       }
+      await dependencies.access.require(request, job.workspaceId, 'workspace:read');
       response.json({ job });
     } catch (error) {
       next(error);
@@ -66,6 +85,7 @@ export function createDomesticVocRouter(dependencies: {
   router.get('/snapshot', async (request, response, next) => {
     try {
       const query = snapshotQuerySchema.parse(request.query);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
       const snapshot = await dependencies.snapshot.getSnapshot(query.workspaceId, query.platform);
       if (!snapshot) {
         response.status(404).json({ error: 'workspace_not_found' });
@@ -77,5 +97,86 @@ export function createDomesticVocRouter(dependencies: {
     }
   });
 
+  const catalogQuerySchema = snapshotQuerySchema.extend({
+    limit: z.coerce.number().int().min(1).max(100).default(25),
+    cursor: z.string().max(500).optional(),
+  });
+
+  router.get('/products', async (request, response, next) => {
+    try {
+      const query = catalogQuerySchema.extend({
+        search: z.string().max(200).default(''),
+        role: z.enum(['own', 'competitor']).optional(),
+        category: z.string().max(200).default(''),
+      }).parse(request.query);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
+      response.json(await dependencies.catalog.listProducts({
+        workspaceId: query.workspaceId,
+        platform: query.platform,
+        limit: query.limit,
+        cursor: query.cursor ?? null,
+        search: query.search,
+        ...(query.role ? { role: query.role } : {}),
+        category: query.category,
+      }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/products/:productId/reviews', async (request, response, next) => {
+    try {
+      const query = catalogQuerySchema.parse(request.query);
+      const productId = z.string().min(1).max(200).parse(request.params.productId);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
+      response.json(await dependencies.catalog.listReviews({
+        workspaceId: query.workspaceId, platform: query.platform, productId,
+        limit: query.limit, cursor: query.cursor ?? null,
+      }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/products/:productId', async (request, response, next) => {
+    try {
+      const query = snapshotQuerySchema.parse(request.query);
+      const productId = z.string().min(1).max(200).parse(request.params.productId);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
+      const product = await dependencies.catalog.getProduct(query.workspaceId, query.platform, productId);
+      if (!product) {
+        response.status(404).json({ error: 'product_not_found' });
+        return;
+      }
+      response.json({ product });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/relations', async (request, response, next) => {
+    try {
+      const query = catalogQuerySchema.parse(request.query);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
+      response.json(await dependencies.catalog.listRelations({
+        workspaceId: query.workspaceId, platform: query.platform,
+        limit: query.limit, cursor: query.cursor ?? null,
+      }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/jobs', async (request, response, next) => {
+    try {
+      const query = catalogQuerySchema.extend({ status: z.string().max(40).default('') }).parse(request.query);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
+      response.json(await dependencies.catalog.listJobs({
+        workspaceId: query.workspaceId, limit: query.limit, cursor: query.cursor ?? null, status: query.status,
+      }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/jobs/:id/events', async (request, response, next) => {
+    try {
+      const query = snapshotQuerySchema.pick({ workspaceId: true }).parse(request.query);
+      const id = z.uuid().parse(request.params.id);
+      await dependencies.access.require(request, query.workspaceId, 'workspace:read');
+      response.json({ items: await dependencies.catalog.listJobEvents(query.workspaceId, id) });
+    } catch (error) { next(error); }
+  });
+
   return router;
 }

+ 111 - 0
src/modules/saas-platform/auth.ts

@@ -0,0 +1,111 @@
+import type { NextFunction, Request, RequestHandler, Response } from 'express';
+import type { AppConfig } from '../../config/env.js';
+import { ApiError } from '../../http/api-error.js';
+import type {
+  AuthenticatedPrincipal,
+  PlatformRepository,
+  WorkspacePermission,
+  WorkspaceRole,
+} from './domain.js';
+
+const requestPrincipal = new WeakMap<Request, AuthenticatedPrincipal>();
+
+export interface RequestAuthenticator {
+  authenticate(request: Request): Promise<AuthenticatedPrincipal | null>;
+}
+
+export class DisabledAuthenticator implements RequestAuthenticator {
+  constructor(private readonly principal: AuthenticatedPrincipal) {}
+
+  async authenticate(): Promise<AuthenticatedPrincipal> {
+    return this.principal;
+  }
+}
+
+export class ParseSessionAuthenticator implements RequestAuthenticator {
+  constructor(
+    private readonly parse: AppConfig['parse'],
+    private readonly fetchImplementation: typeof fetch = globalThis.fetch,
+  ) {}
+
+  async authenticate(request: Request): Promise<AuthenticatedPrincipal | null> {
+    const sessionToken = this.readSessionToken(request);
+    if (!sessionToken) return null;
+    try {
+      const response = await this.fetchImplementation(`${this.parse.serverUrl.replace(/\/+$/, '')}/users/me`, {
+        headers: {
+          Accept: 'application/json',
+          'X-Parse-Application-Id': this.parse.appId,
+          'X-Parse-Session-Token': sessionToken,
+        },
+        signal: AbortSignal.timeout(10_000),
+      });
+      if (!response.ok) return null;
+      const user = await response.json() as Record<string, unknown>;
+      const userId = typeof user.objectId === 'string' ? user.objectId : '';
+      if (!userId) return null;
+      const email = typeof user.email === 'string' ? user.email : '';
+      const displayName = [user.name, user.nickname, user.username]
+        .find((value): value is string => typeof value === 'string' && Boolean(value.trim())) ?? email;
+      return { userId, email, displayName, authMode: 'parse' };
+    } catch {
+      throw new ApiError(503, 'authentication_unavailable');
+    }
+  }
+
+  private readSessionToken(request: Request): string {
+    const parseHeader = request.header('X-Parse-Session-Token')?.trim();
+    if (parseHeader) return parseHeader;
+    const authorization = request.header('Authorization')?.trim() ?? '';
+    return authorization.toLowerCase().startsWith('bearer ') ? authorization.slice(7).trim() : '';
+  }
+}
+
+export function createAuthenticator(config: AppConfig): RequestAuthenticator {
+  if (config.auth.mode === 'disabled') {
+    return new DisabledAuthenticator({
+      userId: config.auth.localUserId,
+      email: config.auth.localUserEmail,
+      displayName: config.auth.localUserName,
+      authMode: 'disabled',
+    });
+  }
+  return new ParseSessionAuthenticator(config.parse);
+}
+
+export function createAuthenticationMiddleware(authenticator: RequestAuthenticator): RequestHandler {
+  return async (request: Request, _response: Response, next: NextFunction) => {
+    try {
+      const principal = await authenticator.authenticate(request);
+      if (!principal) throw new ApiError(401, 'authentication_required');
+      requestPrincipal.set(request, principal);
+      next();
+    } catch (error) {
+      next(error);
+    }
+  };
+}
+
+export function getPrincipal(request: Request): AuthenticatedPrincipal {
+  const principal = requestPrincipal.get(request);
+  if (!principal) throw new ApiError(401, 'authentication_required');
+  return principal;
+}
+
+const ROLE_PERMISSIONS: Record<WorkspaceRole, ReadonlySet<WorkspacePermission>> = {
+  owner: new Set(['workspace:read', 'member:manage', 'data:sync', 'analysis:run', 'action:write', 'alert:write', 'audit:read']),
+  admin: new Set(['workspace:read', 'member:manage', 'data:sync', 'analysis:run', 'action:write', 'alert:write', 'audit:read']),
+  analyst: new Set(['workspace:read', 'data:sync', 'analysis:run', 'action:write', 'alert:write']),
+  viewer: new Set(['workspace:read']),
+};
+
+export class WorkspaceAccessService {
+  constructor(private readonly repository: PlatformRepository) {}
+
+  async require(request: Request, workspaceId: string, permission: WorkspacePermission): Promise<void> {
+    const principal = getPrincipal(request);
+    const membership = await this.repository.getMembership(workspaceId, principal.userId);
+    if (!membership || membership.status !== 'active') throw new ApiError(403, 'workspace_access_denied');
+    if (!ROLE_PERMISSIONS[membership.role].has(permission)) throw new ApiError(403, 'workspace_permission_denied');
+  }
+}

+ 175 - 0
src/modules/saas-platform/domain.ts

@@ -0,0 +1,175 @@
+import type { DomesticDataset, DomesticProduct, DomesticProductRelation, DomesticReview } from '../../types/domestic-dataset.js';
+import type { SyncJobRecord } from '../domestic-voc/repositories/sync-job.repository.js';
+
+export type WorkspaceRole = 'owner' | 'admin' | 'analyst' | 'viewer';
+export type WorkspacePermission =
+  | 'workspace:read'
+  | 'member:manage'
+  | 'data:sync'
+  | 'analysis:run'
+  | 'action:write'
+  | 'alert:write'
+  | 'audit:read';
+
+export interface AuthenticatedPrincipal {
+  userId: string;
+  email: string;
+  displayName: string;
+  authMode: 'disabled' | 'parse';
+}
+
+export interface WorkspaceSummary {
+  id: string;
+  name: string;
+  caseName: string;
+  status: 'active' | 'disabled';
+  role: WorkspaceRole;
+}
+
+export interface WorkspaceMember {
+  id: string;
+  workspaceId: string;
+  userId: string;
+  email: string;
+  displayName: string;
+  role: WorkspaceRole;
+  status: 'invited' | 'active' | 'disabled';
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface CursorPage<T> {
+  items: T[];
+  nextCursor: string | null;
+}
+
+export interface DomesticProductDetail extends DomesticProduct {
+  reviews: { count: number; averageRating: number };
+}
+
+export interface DataSourceSummary {
+  id: string;
+  workspaceId: string;
+  platform: string;
+  kind: string;
+  status: string;
+  lastCheckedAt: string | null;
+  credentialStorage: 'environment' | 'external_secret';
+}
+
+export interface ImportBatchSummary {
+  id: string;
+  workspaceId: string;
+  platform: string;
+  sourceKind: string;
+  sourceFile: string | null;
+  status: string;
+  totalRows: number;
+  successRows: number;
+  failedRows: number;
+  createdAt: string;
+  completedAt: string | null;
+}
+
+export type AnalysisRunStatus = 'pending' | 'processing' | 'completed' | 'partial' | 'failed' | 'cancelled';
+export interface AnalysisRun {
+  id: string;
+  workspaceId: string;
+  analysisType: 'voice' | 'pain_point' | 'feature' | 'scenario' | 'risk' | 'report';
+  targetKind: 'workspace' | 'category' | 'product';
+  targetKey: string;
+  status: AnalysisRunStatus;
+  input: Record<string, unknown>;
+  result: Record<string, unknown> | null;
+  evidenceCount: number;
+  requestedBy: string;
+  errorSummary: string | null;
+  requestedAt: string;
+  startedAt: string | null;
+  completedAt: string | null;
+}
+
+export interface ActionItem {
+  id: string;
+  workspaceId: string;
+  actionType: 'general' | 'experience' | 'product' | 'strategy' | 'data_quality';
+  title: string;
+  description: string;
+  priority: 'critical' | 'high' | 'medium' | 'low';
+  status: 'open' | 'planned' | 'in_progress' | 'blocked' | 'completed' | 'cancelled';
+  productKey: string | null;
+  assigneeUserId: string | null;
+  dueAt: string | null;
+  createdBy: string;
+  completedAt: string | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface AlertItem {
+  id: string;
+  workspaceId: string;
+  alertType: 'voc_risk' | 'sentiment_spike' | 'data_quality' | 'sync_failure' | 'quota';
+  severity: 'critical' | 'high' | 'medium' | 'low';
+  status: 'open' | 'acknowledged' | 'resolved' | 'dismissed';
+  productKey: string | null;
+  title: string;
+  summary: string;
+  evidence: unknown[];
+  detectedAt: string;
+  acknowledgedBy: string | null;
+  acknowledgedAt: string | null;
+  resolvedAt: string | null;
+}
+
+export interface AuditEntry {
+  id: string;
+  workspaceId: string;
+  actorUserId: string;
+  action: string;
+  entityType: string;
+  entityId: string | null;
+  metadata: Record<string, unknown>;
+  createdAt: string;
+}
+
+export interface PlatformRepository {
+  getDatasetSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null>;
+  listWorkspaces(userId: string): Promise<WorkspaceSummary[]>;
+  getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null>;
+  listMembers(workspaceId: string): Promise<WorkspaceMember[]>;
+  upsertMember(input: {
+    workspaceId: string;
+    userId: string;
+    email: string;
+    displayName: string;
+    role: WorkspaceRole;
+    status: WorkspaceMember['status'];
+  }): Promise<WorkspaceMember | null>;
+  listProducts(input: {
+    workspaceId: string;
+    platform: string;
+    limit: number;
+    cursor: string | null;
+    search: string;
+    role?: DomesticProduct['role'];
+    category: string;
+  }): Promise<CursorPage<DomesticProduct>>;
+  getProduct(workspaceId: string, platform: string, productId: string): Promise<DomesticProductDetail | null>;
+  listReviews(input: { workspaceId: string; platform: string; productId: string; limit: number; cursor: string | null }): Promise<CursorPage<DomesticReview>>;
+  listRelations(input: { workspaceId: string; platform: string; limit: number; cursor: string | null }): Promise<CursorPage<DomesticProductRelation>>;
+  listJobs(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<SyncJobRecord>>;
+  listJobEvents(workspaceId: string, jobId: string): Promise<Array<{ id: string; level: string; type: string; message: string; details: Record<string, unknown>; createdAt: string }>>;
+  listDataSources(workspaceId: string): Promise<DataSourceSummary[]>;
+  listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>>;
+  listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<AnalysisRun>>;
+  createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun>;
+  listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<ActionItem>>;
+  createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem>;
+  updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null>;
+  listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<AlertItem>>;
+  createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem>;
+  updateAlert(workspaceId: string, id: string, patch: { status: AlertItem['status']; actorUserId: string }): Promise<AlertItem | null>;
+  listAudit(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<AuditEntry>>;
+  appendAudit(input: Omit<AuditEntry, 'id' | 'createdAt'>): Promise<void>;
+}

+ 298 - 0
src/modules/saas-platform/local-platform.repository.ts

@@ -0,0 +1,298 @@
+import { randomUUID } from 'node:crypto';
+import { ApiError } from '../../http/api-error.js';
+import type { DomesticDataset, DomesticProduct } from '../../types/domestic-dataset.js';
+import type { LocalSyncJobStore } from '../domestic-voc/local/local-sync-job.store.js';
+import type {
+  ActionItem,
+  AlertItem,
+  AnalysisRun,
+  AuditEntry,
+  CursorPage,
+  DataSourceSummary,
+  DomesticProductDetail,
+  ImportBatchSummary,
+  PlatformRepository,
+  WorkspaceMember,
+  WorkspaceRole,
+  WorkspaceSummary,
+} from './domain.js';
+import { decodeCursor, pageFromSortedItems } from './pagination.js';
+
+function paginate<T>(items: T[], limit: number, cursor: string | null, getId: (item: T) => string): CursorPage<T> {
+  const decoded = decodeCursor(cursor);
+  if (cursor && !decoded) throw new ApiError(400, 'invalid_cursor');
+  const cursorIndex = decoded ? items.findIndex((item) => getId(item) === decoded.id) : -1;
+  if (decoded && cursorIndex < 0) throw new ApiError(400, 'invalid_cursor');
+  const start = cursorIndex + 1;
+  return pageFromSortedItems(items.slice(start, start + limit + 1), limit, getId);
+}
+
+export class LocalPlatformRepository implements PlatformRepository {
+  private readonly members = new Map<string, WorkspaceMember>();
+  private readonly analyses: AnalysisRun[] = [];
+  private readonly actions: ActionItem[] = [];
+  private readonly alerts: AlertItem[] = [];
+  private readonly audit: AuditEntry[] = [];
+  private auditSequence = 0;
+
+  constructor(
+    private readonly dataset: DomesticDataset,
+    private readonly jobs: LocalSyncJobStore,
+    principal: { userId: string; email: string; displayName: string },
+    private readonly now: () => Date = () => new Date(),
+  ) {
+    const timestamp = now().toISOString();
+    const member: WorkspaceMember = {
+      id: 'local-owner',
+      workspaceId: 'demashi',
+      userId: principal.userId,
+      email: principal.email,
+      displayName: principal.displayName,
+      role: 'owner',
+      status: 'active',
+      createdAt: timestamp,
+      updatedAt: timestamp,
+    };
+    this.members.set(this.memberKey(member.workspaceId, member.userId), member);
+  }
+
+  async getDatasetSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
+    return workspaceId === 'demashi' && platform === this.dataset.platform ? this.dataset : null;
+  }
+
+  async listWorkspaces(userId: string): Promise<WorkspaceSummary[]> {
+    const member = this.members.get(this.memberKey('demashi', userId));
+    if (!member || member.status !== 'active') return [];
+    return [{ id: 'demashi', name: 'Demashi JD VOC', caseName: this.dataset.caseName, status: 'active', role: member.role }];
+  }
+
+  async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
+    return this.members.get(this.memberKey(workspaceId, userId)) ?? null;
+  }
+
+  async listMembers(workspaceId: string): Promise<WorkspaceMember[]> {
+    return [...this.members.values()].filter((member) => member.workspaceId === workspaceId);
+  }
+
+  async upsertMember(input: {
+    workspaceId: string;
+    userId: string;
+    email: string;
+    displayName: string;
+    role: WorkspaceRole;
+    status: WorkspaceMember['status'];
+  }): Promise<WorkspaceMember | null> {
+    if (input.workspaceId !== 'demashi') return null;
+    const key = this.memberKey(input.workspaceId, input.userId);
+    const existing = this.members.get(key);
+    const timestamp = this.now().toISOString();
+    const member: WorkspaceMember = {
+      id: existing?.id ?? randomUUID(),
+      workspaceId: input.workspaceId,
+      userId: input.userId,
+      email: input.email,
+      displayName: input.displayName,
+      role: input.role,
+      status: input.status,
+      createdAt: existing?.createdAt ?? timestamp,
+      updatedAt: timestamp,
+    };
+    this.members.set(key, member);
+    return member;
+  }
+
+  async listProducts(input: {
+    workspaceId: string;
+    platform: string;
+    limit: number;
+    cursor: string | null;
+    search: string;
+    role?: DomesticProduct['role'];
+    category: string;
+  }): Promise<CursorPage<DomesticProduct>> {
+    if (input.workspaceId !== 'demashi' || input.platform !== this.dataset.platform) return { items: [], nextCursor: null };
+    const search = input.search.toLocaleLowerCase();
+    const products = this.dataset.products
+      .filter((product) => !input.role || product.role === input.role)
+      .filter((product) => !input.category || [product.category1, product.category2, product.category3].includes(input.category))
+      .filter((product) => !search || [product.productId, product.model, product.title, product.brand]
+        .some((value) => value.toLocaleLowerCase().includes(search)))
+      .sort((left, right) => left.productKey.localeCompare(right.productKey));
+    return paginate(products, input.limit, input.cursor, (product) => product.productKey);
+  }
+
+  async getProduct(workspaceId: string, platform: string, productId: string): Promise<DomesticProductDetail | null> {
+    if (workspaceId !== 'demashi' || platform !== this.dataset.platform) return null;
+    const product = this.dataset.products.find((item) => item.productId === productId);
+    if (!product) return null;
+    const reviews = this.dataset.reviews.filter((review) => review.productId === productId);
+    return {
+      ...product,
+      reviews: {
+        count: reviews.length,
+        averageRating: reviews.length ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length : 0,
+      },
+    };
+  }
+
+  async listReviews(input: { workspaceId: string; platform: string; productId: string; limit: number; cursor: string | null }) {
+    if (input.workspaceId !== 'demashi' || input.platform !== this.dataset.platform) return { items: [], nextCursor: null };
+    const reviews = this.dataset.reviews
+      .filter((review) => review.productId === input.productId)
+      .sort((left, right) => left.reviewId.localeCompare(right.reviewId));
+    return paginate(reviews, input.limit, input.cursor, (review) => review.reviewId);
+  }
+
+  async listRelations(input: { workspaceId: string; platform: string; limit: number; cursor: string | null }) {
+    if (input.workspaceId !== 'demashi' || input.platform !== this.dataset.platform) return { items: [], nextCursor: null };
+    const relations = [...this.dataset.relations].sort((left, right) => left.relationKey.localeCompare(right.relationKey));
+    return paginate(relations, input.limit, input.cursor, (relation) => relation.relationKey);
+  }
+
+  async listJobs(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const jobs = this.jobs.listByWorkspace(input.workspaceId).filter((job) => !input.status || job.status === input.status);
+    return paginate(jobs, input.limit, input.cursor, (job) => job.id);
+  }
+
+  async listJobEvents(): Promise<[]> {
+    return [];
+  }
+
+  async listDataSources(workspaceId: string): Promise<DataSourceSummary[]> {
+    if (workspaceId !== 'demashi') return [];
+    return [{
+      id: 'local-jd',
+      workspaceId,
+      platform: this.dataset.platform,
+      kind: 'fmode_gateway',
+      status: 'configured',
+      lastCheckedAt: null,
+      credentialStorage: 'environment',
+    }];
+  }
+
+  async listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>> {
+    if (input.workspaceId !== 'demashi') return { items: [], nextCursor: null };
+    const item: ImportBatchSummary = {
+      id: 'local-dataset',
+      workspaceId: input.workspaceId,
+      platform: this.dataset.platform,
+      sourceKind: 'normalized_snapshot',
+      sourceFile: this.dataset.source.sourceFile,
+      status: 'completed',
+      totalRows: this.dataset.summary.metricRows + this.dataset.products.length + this.dataset.relations.length + this.dataset.reviews.length,
+      successRows: this.dataset.summary.metricRows + this.dataset.products.length + this.dataset.relations.length + this.dataset.reviews.length,
+      failedRows: 0,
+      createdAt: this.dataset.generatedAt,
+      completedAt: this.dataset.generatedAt,
+    };
+    return paginate([item], input.limit, input.cursor, (batch) => batch.id);
+  }
+
+  async listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const items = this.analyses
+      .filter((item) => item.workspaceId === input.workspaceId && (!input.status || item.status === input.status))
+      .sort((left, right) => right.requestedAt.localeCompare(left.requestedAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
+    const run: AnalysisRun = {
+      ...input,
+      status: 'pending',
+      result: null,
+      evidenceCount: 0,
+      errorSummary: null,
+      requestedAt: this.now().toISOString(),
+      startedAt: null,
+      completedAt: null,
+    };
+    this.analyses.unshift(run);
+    return run;
+  }
+
+  async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const items = this.actions
+      .filter((item) => item.workspaceId === input.workspaceId && (!input.status || item.status === input.status))
+      .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
+    const timestamp = this.now().toISOString();
+    const action: ActionItem = {
+      ...input,
+      completedAt: input.status === 'completed' ? timestamp : null,
+      createdAt: timestamp,
+      updatedAt: timestamp,
+    };
+    this.actions.unshift(action);
+    return action;
+  }
+
+  async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
+    const index = this.actions.findIndex((item) => item.workspaceId === workspaceId && item.id === id);
+    if (index < 0) return null;
+    const current = this.actions[index]!;
+    const updated: ActionItem = {
+      ...current,
+      ...patch,
+      completedAt: patch.status === 'completed'
+        ? current.completedAt ?? this.now().toISOString()
+        : patch.status ? null : current.completedAt,
+      updatedAt: this.now().toISOString(),
+    };
+    this.actions[index] = updated;
+    return updated;
+  }
+
+  async listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const items = this.alerts
+      .filter((item) => item.workspaceId === input.workspaceId && (!input.status || item.status === input.status))
+      .sort((left, right) => right.detectedAt.localeCompare(left.detectedAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem> {
+    const alert: AlertItem = {
+      ...input,
+      detectedAt: this.now().toISOString(),
+      acknowledgedBy: null,
+      acknowledgedAt: null,
+      resolvedAt: null,
+    };
+    this.alerts.unshift(alert);
+    return alert;
+  }
+
+  async updateAlert(workspaceId: string, id: string, patch: { status: AlertItem['status']; actorUserId: string }): Promise<AlertItem | null> {
+    const index = this.alerts.findIndex((item) => item.workspaceId === workspaceId && item.id === id);
+    if (index < 0) return null;
+    const current = this.alerts[index]!;
+    const timestamp = this.now().toISOString();
+    const updated: AlertItem = {
+      ...current,
+      status: patch.status,
+      acknowledgedBy: patch.status === 'acknowledged' ? patch.actorUserId : current.acknowledgedBy,
+      acknowledgedAt: patch.status === 'acknowledged' ? timestamp : current.acknowledgedAt,
+      resolvedAt: patch.status === 'resolved' ? timestamp : current.resolvedAt,
+    };
+    this.alerts[index] = updated;
+    return updated;
+  }
+
+  async listAudit(input: { workspaceId: string; limit: number; cursor: string | null }) {
+    const items = this.audit
+      .filter((item) => item.workspaceId === input.workspaceId)
+      .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async appendAudit(input: Omit<AuditEntry, 'id' | 'createdAt'>): Promise<void> {
+    this.audit.unshift({ ...input, id: String(++this.auditSequence), createdAt: this.now().toISOString() });
+  }
+
+  private memberKey(workspaceId: string, userId: string): string {
+    return `${workspaceId}:${userId}`;
+  }
+}

+ 25 - 0
src/modules/saas-platform/pagination.ts

@@ -0,0 +1,25 @@
+export interface IdCursor {
+  id: string;
+}
+
+export function encodeCursor(cursor: IdCursor): string {
+  return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
+}
+
+export function decodeCursor(value: string | null | undefined): IdCursor | null {
+  if (!value) return null;
+  try {
+    const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as { id?: unknown };
+    return typeof parsed.id === 'string' && parsed.id ? { id: parsed.id } : null;
+  } catch {
+    return null;
+  }
+}
+
+export function pageFromSortedItems<T>(items: T[], limit: number, getId: (item: T) => string): { items: T[]; nextCursor: string | null } {
+  const page = items.slice(0, limit);
+  return {
+    items: page,
+    nextCursor: items.length > limit && page.length ? encodeCursor({ id: getId(page[page.length - 1]!) }) : null,
+  };
+}

+ 628 - 0
src/modules/saas-platform/postgres-platform.repository.ts

@@ -0,0 +1,628 @@
+import type { Queryable } from '../../db/types.js';
+import { ApiError } from '../../http/api-error.js';
+import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../../types/domestic-dataset.js';
+import { SnapshotService } from '../domestic-voc/services/snapshot.service.js';
+import type {
+  ActionItem,
+  AlertItem,
+  AnalysisRun,
+  AuditEntry,
+  CursorPage,
+  DataSourceSummary,
+  DomesticProductDetail,
+  ImportBatchSummary,
+  PlatformRepository,
+  WorkspaceMember,
+  WorkspaceRole,
+  WorkspaceSummary,
+} from './domain.js';
+import { decodeCursor, encodeCursor } from './pagination.js';
+
+function numberValue(value: unknown): number {
+  const parsed = Number(value ?? 0);
+  return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function iso(value: Date | string | null): string | null {
+  return value ? new Date(value).toISOString() : null;
+}
+
+function numericCursor(value: string | null, fallback: string): string {
+  if (!value) return fallback;
+  const decoded = decodeCursor(value);
+  if (!decoded || !/^\d+$/.test(decoded.id)) throw new ApiError(400, 'invalid_cursor');
+  try {
+    const id = BigInt(decoded.id);
+    if (id < 1n || id > 9_223_372_036_854_775_807n) throw new Error('out of range');
+  } catch {
+    throw new ApiError(400, 'invalid_cursor');
+  }
+  return decoded.id;
+}
+
+interface ProductRow {
+  cursor_id: string;
+  platform: string;
+  product_id: string;
+  product_key: string;
+  role: 'own' | 'competitor';
+  brand: string;
+  title: string;
+  model: string;
+  category_1: string;
+  category_2: string;
+  category_3: string;
+  source: string;
+  relation_count: string | number;
+  gmv: string | number;
+  sold_units: string | number;
+  transaction_orders: string | number;
+  transaction_customers: string | number;
+  impressions: string | number;
+  clicks: string | number;
+  views: string | number;
+  visitors: string | number;
+  cart_units: string | number;
+  order_amount: string | number;
+  order_units: string | number;
+  order_count: string | number;
+  refund_amount: string | number;
+  refund_units: string | number;
+  refund_orders: string | number;
+}
+
+function metricSummary(row: ProductRow): DomesticMetricSummary {
+  const summary = {
+    gmv: numberValue(row.gmv),
+    soldUnits: numberValue(row.sold_units),
+    transactionOrders: numberValue(row.transaction_orders),
+    transactionCustomers: numberValue(row.transaction_customers),
+    impressions: numberValue(row.impressions),
+    clicks: numberValue(row.clicks),
+    views: numberValue(row.views),
+    visitors: numberValue(row.visitors),
+    cartUnits: numberValue(row.cart_units),
+    orderAmount: numberValue(row.order_amount),
+    orderUnits: numberValue(row.order_units),
+    orderCount: numberValue(row.order_count),
+    refundAmount: numberValue(row.refund_amount),
+    refundUnits: numberValue(row.refund_units),
+    refundOrders: numberValue(row.refund_orders),
+  };
+  return {
+    ...summary,
+    conversionRate: summary.visitors ? summary.transactionOrders / summary.visitors : 0,
+    clickThroughRate: summary.impressions ? summary.clicks / summary.impressions : 0,
+    averageUnitPrice: summary.soldUnits ? summary.gmv / summary.soldUnits : 0,
+    refundToGmvRate: summary.gmv ? summary.refundAmount / summary.gmv : 0,
+  };
+}
+
+function mapProduct(row: ProductRow): DomesticProduct {
+  return {
+    platform: row.platform,
+    productId: row.product_id,
+    productKey: row.product_key,
+    asin: row.product_id,
+    role: row.role,
+    brand: row.brand,
+    title: row.title,
+    model: row.model,
+    category1: row.category_1,
+    category2: row.category_2,
+    category3: row.category_3,
+    source: row.source,
+    relationCount: numberValue(row.relation_count),
+    summary: metricSummary(row),
+    trend: [],
+  };
+}
+
+const PRODUCT_SELECT = `
+  SELECT product.id::text AS cursor_id, product.platform, product.product_id, product.product_key,
+         product.role, product.brand, product.title, product.model, product.category_1,
+         product.category_2, product.category_3, product.source,
+         COALESCE(relation.relation_count, 0) AS relation_count,
+         COALESCE(metric.gmv, 0) AS gmv,
+         COALESCE(metric.sold_units, 0) AS sold_units,
+         COALESCE(metric.transaction_orders, 0) AS transaction_orders,
+         COALESCE(metric.transaction_customers, 0) AS transaction_customers,
+         COALESCE(metric.impressions, 0) AS impressions,
+         COALESCE(metric.clicks, 0) AS clicks,
+         COALESCE(metric.views, 0) AS views,
+         COALESCE(metric.visitors, 0) AS visitors,
+         COALESCE(metric.cart_units, 0) AS cart_units,
+         COALESCE(metric.order_amount, 0) AS order_amount,
+         COALESCE(metric.order_units, 0) AS order_units,
+         COALESCE(metric.order_count, 0) AS order_count,
+         COALESCE(metric.refund_amount, 0) AS refund_amount,
+         COALESCE(metric.refund_units, 0) AS refund_units,
+         COALESCE(metric.refund_orders, 0) AS refund_orders
+  FROM voc.product product
+  JOIN voc.workspace workspace ON workspace.id = product.workspace_id
+  LEFT JOIN LATERAL (
+    SELECT SUM(gmv) AS gmv, SUM(sold_units) AS sold_units,
+           SUM(transaction_orders) AS transaction_orders,
+           SUM(transaction_customers) AS transaction_customers,
+           SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(views) AS views,
+           SUM(visitors) AS visitors, SUM(cart_units) AS cart_units,
+           SUM(order_amount) AS order_amount, SUM(order_units) AS order_units,
+           SUM(order_count) AS order_count, SUM(refund_amount) AS refund_amount,
+           SUM(refund_units) AS refund_units, SUM(refund_orders) AS refund_orders
+    FROM voc.daily_metric WHERE product_id = product.id
+  ) metric ON true
+  LEFT JOIN LATERAL (
+    SELECT COUNT(*) AS relation_count FROM voc.product_relation WHERE own_product_id = product.id
+  ) relation ON true
+`;
+
+export class PostgresPlatformRepository implements PlatformRepository {
+  private readonly snapshot: SnapshotService;
+
+  constructor(private readonly database: Queryable) {
+    this.snapshot = new SnapshotService(database);
+  }
+
+  async getDatasetSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
+    return this.snapshot.getSnapshot(workspaceId, platform);
+  }
+
+  async bootstrapAdmin(workspaceId: string, principal: { userId: string; email: string; displayName: string }): Promise<void> {
+    await this.upsertMember({ workspaceId, ...principal, role: 'owner', status: 'active' });
+  }
+
+  async listWorkspaces(userId: string): Promise<WorkspaceSummary[]> {
+    const result = await this.database.query<{
+      public_id: string; name: string; case_name: string; status: 'active' | 'disabled'; role: WorkspaceRole;
+    }>(`
+      SELECT workspace.public_id, workspace.name, workspace.case_name, workspace.status, member.role
+      FROM voc.workspace_member member
+      JOIN voc.workspace workspace ON workspace.id = member.workspace_id
+      WHERE member.user_external_id = $1 AND member.status = 'active'
+      ORDER BY workspace.id
+    `, [userId]);
+    return result.rows.map((row) => ({ id: row.public_id, name: row.name, caseName: row.case_name, status: row.status, role: row.role }));
+  }
+
+  async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
+    const result = await this.database.query<{
+      id: string; public_id: string; user_external_id: string; email: string; display_name: string;
+      role: WorkspaceRole; status: WorkspaceMember['status']; created_at: Date | string; updated_at: Date | string;
+    }>(`
+      SELECT member.id::text, workspace.public_id, member.user_external_id, member.email,
+             member.display_name, member.role, member.status, member.created_at, member.updated_at
+      FROM voc.workspace_member member
+      JOIN voc.workspace workspace ON workspace.id = member.workspace_id
+      WHERE workspace.public_id = $1 AND workspace.status = 'active' AND member.user_external_id = $2
+    `, [workspaceId, userId]);
+    const row = result.rows[0];
+    return row ? this.mapMember(row) : null;
+  }
+
+  async listMembers(workspaceId: string): Promise<WorkspaceMember[]> {
+    const result = await this.database.query<{
+      id: string; public_id: string; user_external_id: string; email: string; display_name: string;
+      role: WorkspaceRole; status: WorkspaceMember['status']; created_at: Date | string; updated_at: Date | string;
+    }>(`
+      SELECT member.id::text, workspace.public_id, member.user_external_id, member.email,
+             member.display_name, member.role, member.status, member.created_at, member.updated_at
+      FROM voc.workspace_member member
+      JOIN voc.workspace workspace ON workspace.id = member.workspace_id
+      WHERE workspace.public_id = $1
+      ORDER BY member.id
+    `, [workspaceId]);
+    return result.rows.map((row) => this.mapMember(row));
+  }
+
+  async upsertMember(input: {
+    workspaceId: string; userId: string; email: string; displayName: string; role: WorkspaceRole; status: WorkspaceMember['status'];
+  }): Promise<WorkspaceMember | null> {
+    const result = await this.database.query<{
+      id: string; public_id: string; user_external_id: string; email: string; display_name: string;
+      role: WorkspaceRole; status: WorkspaceMember['status']; created_at: Date | string; updated_at: Date | string;
+    }>(`
+      WITH target AS (
+        SELECT id, public_id FROM voc.workspace WHERE public_id = $1 AND status = 'active'
+      ), saved AS (
+        INSERT INTO voc.workspace_member (workspace_id, user_external_id, email, display_name, role, status, invited_at)
+        SELECT id, $2, $3, $4, $5, $6, CASE WHEN $6 = 'invited' THEN now() ELSE NULL END FROM target
+        ON CONFLICT (workspace_id, user_external_id) DO UPDATE SET
+          email = EXCLUDED.email,
+          display_name = EXCLUDED.display_name,
+          role = EXCLUDED.role,
+          status = EXCLUDED.status,
+          updated_at = now()
+        RETURNING *
+      )
+      SELECT saved.id::text, target.public_id, saved.user_external_id, saved.email, saved.display_name,
+             saved.role, saved.status, saved.created_at, saved.updated_at
+      FROM saved JOIN target ON target.id = saved.workspace_id
+    `, [input.workspaceId, input.userId, input.email, input.displayName, input.role, input.status]);
+    const row = result.rows[0];
+    return row ? this.mapMember(row) : null;
+  }
+
+  async listProducts(input: {
+    workspaceId: string; platform: string; limit: number; cursor: string | null; search: string; role?: DomesticProduct['role']; category: string;
+  }): Promise<CursorPage<DomesticProduct>> {
+    const cursorId = numericCursor(input.cursor, '0');
+    const result = await this.database.query<ProductRow>(`${PRODUCT_SELECT}
+      WHERE workspace.public_id = $1 AND product.platform = $2 AND product.id > $3::bigint
+        AND product.source NOT IN ('relation_stub', 'sync_stub')
+        AND ($4::text = '' OR product.role = $4)
+        AND ($5::text = '' OR product.category_1 = $5 OR product.category_2 = $5 OR product.category_3 = $5)
+        AND ($6::text = '' OR product.product_id ILIKE '%' || $6 || '%' OR product.model ILIKE '%' || $6 || '%'
+             OR product.title ILIKE '%' || $6 || '%' OR product.brand ILIKE '%' || $6 || '%')
+      ORDER BY product.id
+      LIMIT $7
+    `, [input.workspaceId, input.platform, cursorId, input.role ?? '', input.category, input.search, input.limit + 1]);
+    const hasMore = result.rows.length > input.limit;
+    const selected = result.rows.slice(0, input.limit);
+    return {
+      items: selected.map(mapProduct),
+      nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
+    };
+  }
+
+  async getProduct(workspaceId: string, platform: string, productId: string): Promise<DomesticProductDetail | null> {
+    const result = await this.database.query<ProductRow>(`${PRODUCT_SELECT}
+      WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
+        AND product.source NOT IN ('relation_stub', 'sync_stub')
+    `, [workspaceId, platform, productId]);
+    const row = result.rows[0];
+    if (!row) return null;
+    const [trend, reviews] = await Promise.all([
+      this.database.query<{ metric_date: Date | string; gmv: string | number; sold_units: string | number; transaction_orders: string | number }>(`
+        SELECT metric.metric_date, SUM(metric.gmv) AS gmv, SUM(metric.sold_units) AS sold_units,
+               SUM(metric.transaction_orders) AS transaction_orders
+        FROM voc.daily_metric metric
+        JOIN voc.product product ON product.id = metric.product_id
+        JOIN voc.workspace workspace ON workspace.id = metric.workspace_id
+        WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
+        GROUP BY metric.metric_date ORDER BY metric.metric_date
+      `, [workspaceId, platform, productId]),
+      this.database.query<{ review_count: string | number; average_rating: string | number }>(`
+        SELECT COUNT(*) AS review_count, COALESCE(AVG(review.rating), 0) AS average_rating
+        FROM voc.review review
+        JOIN voc.product product ON product.id = review.product_id
+        JOIN voc.workspace workspace ON workspace.id = review.workspace_id
+        WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
+      `, [workspaceId, platform, productId]),
+    ]);
+    const review = reviews.rows[0];
+    return {
+      ...mapProduct(row),
+      trend: trend.rows.map((item) => ({
+        date: new Date(item.metric_date).toISOString().slice(0, 10),
+        gmv: numberValue(item.gmv),
+        soldUnits: numberValue(item.sold_units),
+        transactionOrders: numberValue(item.transaction_orders),
+      })),
+      reviews: { count: numberValue(review?.review_count), averageRating: numberValue(review?.average_rating) },
+    };
+  }
+
+  async listReviews(input: { workspaceId: string; platform: string; productId: string; limit: number; cursor: string | null }) {
+    const cursorId = numericCursor(input.cursor, '0');
+    const result = await this.database.query<{
+      cursor_id: string; source_review_id: string | null; review_key: string; rating: string | number | null;
+      content: string; review_date: Date | string | null;
+    }>(`
+      SELECT review.id::text AS cursor_id, review.source_review_id, review.review_key, review.rating,
+             review.content, review.review_date
+      FROM voc.review review
+      JOIN voc.product product ON product.id = review.product_id
+      JOIN voc.workspace workspace ON workspace.id = review.workspace_id
+      WHERE workspace.public_id = $1 AND review.platform = $2 AND product.product_id = $3 AND review.id > $4::bigint
+      ORDER BY review.id LIMIT $5
+    `, [input.workspaceId, input.platform, input.productId, cursorId, input.limit + 1]);
+    const rows = result.rows;
+    const hasMore = rows.length > input.limit;
+    const selected = rows.slice(0, input.limit);
+    return {
+      items: selected.map((row) => ({
+        productId: input.productId,
+        reviewId: row.source_review_id || row.review_key,
+        rating: numberValue(row.rating),
+        content: row.content,
+        ...(row.review_date ? { reviewDate: new Date(row.review_date).toISOString() } : {}),
+      })),
+      nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
+    };
+  }
+
+  async listRelations(input: { workspaceId: string; platform: string; limit: number; cursor: string | null }) {
+    const cursorId = numericCursor(input.cursor, '0');
+    const result = await this.database.query<{
+      cursor_id: string; relation_key: string; own_product_key: string; own_product_id: string;
+      competitor_product_key: string; competitor_product_id: string; competitor_brand: string; category: string;
+    }>(`
+      SELECT relation.id::text AS cursor_id, relation.relation_key,
+             own.product_key AS own_product_key, own.product_id AS own_product_id,
+             competitor.product_key AS competitor_product_key, competitor.product_id AS competitor_product_id,
+             competitor.brand AS competitor_brand, relation.category
+      FROM voc.product_relation relation
+      JOIN voc.workspace workspace ON workspace.id = relation.workspace_id
+      JOIN voc.product own ON own.id = relation.own_product_id
+      JOIN voc.product competitor ON competitor.id = relation.competitor_product_id
+      WHERE workspace.public_id = $1 AND relation.platform = $2 AND relation.id > $3::bigint
+      ORDER BY relation.id LIMIT $4
+    `, [input.workspaceId, input.platform, cursorId, input.limit + 1]);
+    const hasMore = result.rows.length > input.limit;
+    const selected = result.rows.slice(0, input.limit);
+    return {
+      items: selected.map((row) => ({
+        relationKey: row.relation_key,
+        ownProductKey: row.own_product_key,
+        ownProductId: row.own_product_id,
+        competitorProductKey: row.competitor_product_key,
+        competitorProductId: row.competitor_product_id,
+        competitorBrand: row.competitor_brand,
+        category: row.category,
+      })),
+      nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
+    };
+  }
+
+  async listJobs(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<{
+      cursor_id: string; public_id: string; workspace_public_id: string; platform: string; status: string;
+      scopes: string[]; product_ids: string[]; progress: number; attempts: number; max_attempts: number;
+      error_summary: string | null; requested_at: Date | string; started_at: Date | string | null; completed_at: Date | string | null;
+    }>(`
+      SELECT job.id::text AS cursor_id, job.public_id, workspace.public_id AS workspace_public_id,
+             job.platform, job.status, job.scopes, job.product_ids, job.progress, job.attempts,
+             job.max_attempts, job.error_summary, job.requested_at, job.started_at, job.completed_at
+      FROM voc.sync_job job JOIN voc.workspace workspace ON workspace.id = job.workspace_id
+      WHERE workspace.public_id = $1 AND job.id < $2::bigint AND ($3::text = '' OR job.status = $3)
+      ORDER BY job.id DESC LIMIT $4
+    `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
+    const hasMore = result.rows.length > input.limit;
+    const selected = result.rows.slice(0, input.limit);
+    return {
+      items: selected.map((row) => ({
+        id: row.public_id, workspaceId: row.workspace_public_id, platform: row.platform, status: row.status,
+        scopes: row.scopes, productIds: row.product_ids, progress: row.progress, attempts: row.attempts,
+        maxAttempts: row.max_attempts, errorSummary: row.error_summary,
+        requestedAt: new Date(row.requested_at).toISOString(), startedAt: iso(row.started_at), completedAt: iso(row.completed_at),
+      })),
+      nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
+    };
+  }
+
+  async listJobEvents(workspaceId: string, jobId: string) {
+    const result = await this.database.query<{
+      id: string; level: string; event_type: string; message: string; details: Record<string, unknown>; created_at: Date | string;
+    }>(`
+      SELECT event.id::text, event.level, event.event_type, event.message, event.details, event.created_at
+      FROM voc.sync_job_event event
+      JOIN voc.sync_job job ON job.id = event.sync_job_id
+      JOIN voc.workspace workspace ON workspace.id = job.workspace_id
+      WHERE workspace.public_id = $1 AND job.public_id = $2
+      ORDER BY event.id
+    `, [workspaceId, jobId]);
+    return result.rows.map((row) => ({ id: row.id, level: row.level, type: row.event_type, message: row.message, details: row.details, createdAt: new Date(row.created_at).toISOString() }));
+  }
+
+  async listDataSources(workspaceId: string): Promise<DataSourceSummary[]> {
+    const result = await this.database.query<{
+      id: string; public_id: string; platform: string; connection_kind: string; status: string;
+      metadata: Record<string, unknown>; last_checked_at: Date | string | null;
+    }>(`
+      SELECT source.id::text, workspace.public_id, source.platform, source.connection_kind, source.status,
+             source.metadata, source.last_checked_at
+      FROM voc.source_connection source JOIN voc.workspace workspace ON workspace.id = source.workspace_id
+      WHERE workspace.public_id = $1 ORDER BY source.id
+    `, [workspaceId]);
+    return result.rows.map((row) => ({
+      id: row.id, workspaceId: row.public_id, platform: row.platform, kind: row.connection_kind,
+      status: row.status, lastCheckedAt: iso(row.last_checked_at),
+      credentialStorage: row.metadata.credentialStorage === 'external_secret' ? 'external_secret' : 'environment',
+    }));
+  }
+
+  async listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>> {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<{
+      cursor_id: string; public_id: string; workspace_public_id: string; platform: string; source_kind: string;
+      source_file: string | null; status: string; total_rows: number; success_rows: number; failed_rows: number;
+      created_at: Date | string; completed_at: Date | string | null;
+    }>(`
+      SELECT batch.id::text AS cursor_id, batch.public_id, workspace.public_id AS workspace_public_id,
+             batch.platform, batch.source_kind, batch.source_file, batch.status, batch.total_rows,
+             batch.success_rows, batch.failed_rows, batch.created_at, batch.completed_at
+      FROM voc.import_batch batch JOIN voc.workspace workspace ON workspace.id = batch.workspace_id
+      WHERE workspace.public_id = $1 AND batch.id < $2::bigint
+      ORDER BY batch.id DESC LIMIT $3
+    `, [input.workspaceId, cursorId, input.limit + 1]);
+    const hasMore = result.rows.length > input.limit;
+    const selected = result.rows.slice(0, input.limit);
+    return {
+      items: selected.map((row) => ({
+        id: row.public_id, workspaceId: row.workspace_public_id, platform: row.platform,
+        sourceKind: row.source_kind, sourceFile: row.source_file, status: row.status,
+        totalRows: row.total_rows, successRows: row.success_rows, failedRows: row.failed_rows,
+        createdAt: new Date(row.created_at).toISOString(), completedAt: iso(row.completed_at),
+      })),
+      nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
+    };
+  }
+
+  async listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<any>(`
+      SELECT run.id::text AS cursor_id, run.*, workspace.public_id AS workspace_public_id
+      FROM voc.analysis_run run JOIN voc.workspace workspace ON workspace.id = run.workspace_id
+      WHERE workspace.public_id = $1 AND run.id < $2::bigint AND ($3::text = '' OR run.status = $3)
+      ORDER BY run.id DESC LIMIT $4
+    `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
+    return this.mappedPage(result.rows, input.limit, (row) => this.mapAnalysis(row));
+  }
+
+  async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
+    const result = await this.database.query<any>(`
+      INSERT INTO voc.analysis_run (public_id, workspace_id, analysis_type, target_kind, target_key, input, requested_by_external_id)
+      SELECT $2, workspace.id, $3, $4, $5, $6::jsonb, $7 FROM voc.workspace workspace WHERE workspace.public_id = $1
+      RETURNING *, $1::text AS workspace_public_id
+    `, [input.workspaceId, input.id, input.analysisType, input.targetKind, input.targetKey, JSON.stringify(input.input), input.requestedBy]);
+    const row = result.rows[0];
+    if (!row) throw new Error('Workspace not found while creating analysis');
+    return this.mapAnalysis(row);
+  }
+
+  async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<any>(`
+      SELECT action.id::text AS cursor_id, action.*, workspace.public_id AS workspace_public_id
+      FROM voc.action_item action JOIN voc.workspace workspace ON workspace.id = action.workspace_id
+      WHERE workspace.public_id = $1 AND action.id < $2::bigint AND ($3::text = '' OR action.status = $3)
+      ORDER BY action.id DESC LIMIT $4
+    `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
+    return this.mappedPage(result.rows, input.limit, (row) => this.mapAction(row));
+  }
+
+  async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
+    const result = await this.database.query<any>(`
+      INSERT INTO voc.action_item (
+        public_id, workspace_id, action_type, title, description, priority, status,
+        product_key, assignee_external_id, due_at, created_by_external_id, completed_at
+      )
+      SELECT $2, workspace.id, $3, $4, $5, $6, $7, $8, $9, $10, $11,
+             CASE WHEN $7 = 'completed' THEN now() ELSE NULL END
+      FROM voc.workspace workspace WHERE workspace.public_id = $1
+      RETURNING *, $1::text AS workspace_public_id
+    `, [input.workspaceId, input.id, input.actionType, input.title, input.description, input.priority,
+      input.status, input.productKey, input.assigneeUserId, input.dueAt, input.createdBy]);
+    const row = result.rows[0];
+    if (!row) throw new Error('Workspace not found while creating action');
+    return this.mapAction(row);
+  }
+
+  async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
+    const result = await this.database.query<any>(`
+      UPDATE voc.action_item action SET
+        title = COALESCE($3, action.title), description = COALESCE($4, action.description),
+        priority = COALESCE($5, action.priority), status = COALESCE($6, action.status),
+        assignee_external_id = CASE WHEN $7::boolean THEN $8 ELSE action.assignee_external_id END,
+        due_at = CASE WHEN $9::boolean THEN $10::timestamptz ELSE action.due_at END,
+        completed_at = CASE WHEN $6 = 'completed' THEN COALESCE(action.completed_at, now())
+                            WHEN $6 IS NOT NULL THEN NULL ELSE action.completed_at END,
+        updated_at = now()
+      FROM voc.workspace workspace
+      WHERE action.workspace_id = workspace.id AND workspace.public_id = $1 AND action.public_id = $2
+      RETURNING action.*, workspace.public_id AS workspace_public_id, action.id::text AS cursor_id
+    `, [workspaceId, id, patch.title ?? null, patch.description ?? null, patch.priority ?? null, patch.status ?? null,
+      Object.hasOwn(patch, 'assigneeUserId'), patch.assigneeUserId ?? null,
+      Object.hasOwn(patch, 'dueAt'), patch.dueAt ?? null]);
+    return result.rows[0] ? this.mapAction(result.rows[0]) : null;
+  }
+
+  async listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<any>(`
+      SELECT alert.id::text AS cursor_id, alert.*, workspace.public_id AS workspace_public_id
+      FROM voc.alert alert JOIN voc.workspace workspace ON workspace.id = alert.workspace_id
+      WHERE workspace.public_id = $1 AND alert.id < $2::bigint AND ($3::text = '' OR alert.status = $3)
+      ORDER BY alert.id DESC LIMIT $4
+    `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
+    return this.mappedPage(result.rows, input.limit, (row) => this.mapAlert(row));
+  }
+
+  async createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem> {
+    const result = await this.database.query<any>(`
+      INSERT INTO voc.alert (public_id, workspace_id, alert_type, severity, status, product_key, title, summary, evidence)
+      SELECT $2, workspace.id, $3, $4, $5, $6, $7, $8, $9::jsonb
+      FROM voc.workspace workspace WHERE workspace.public_id = $1
+      RETURNING *, $1::text AS workspace_public_id
+    `, [input.workspaceId, input.id, input.alertType, input.severity, input.status, input.productKey,
+      input.title, input.summary, JSON.stringify(input.evidence)]);
+    const row = result.rows[0];
+    if (!row) throw new Error('Workspace not found while creating alert');
+    return this.mapAlert(row);
+  }
+
+  async updateAlert(workspaceId: string, id: string, patch: { status: AlertItem['status']; actorUserId: string }): Promise<AlertItem | null> {
+    const result = await this.database.query<any>(`
+      UPDATE voc.alert alert SET status = $3,
+        acknowledged_by_external_id = CASE WHEN $3 = 'acknowledged' THEN $4 ELSE alert.acknowledged_by_external_id END,
+        acknowledged_at = CASE WHEN $3 = 'acknowledged' THEN COALESCE(alert.acknowledged_at, now()) ELSE alert.acknowledged_at END,
+        resolved_at = CASE WHEN $3 = 'resolved' THEN COALESCE(alert.resolved_at, now())
+                           WHEN $3 IN ('open', 'acknowledged') THEN NULL ELSE alert.resolved_at END,
+        updated_at = now()
+      FROM voc.workspace workspace
+      WHERE alert.workspace_id = workspace.id AND workspace.public_id = $1 AND alert.public_id = $2
+      RETURNING alert.*, workspace.public_id AS workspace_public_id, alert.id::text AS cursor_id
+    `, [workspaceId, id, patch.status, patch.actorUserId]);
+    return result.rows[0] ? this.mapAlert(result.rows[0]) : null;
+  }
+
+  async listAudit(input: { workspaceId: string; limit: number; cursor: string | null }) {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<any>(`
+      SELECT audit.id::text AS cursor_id, audit.*, workspace.public_id AS workspace_public_id
+      FROM voc.audit_log audit JOIN voc.workspace workspace ON workspace.id = audit.workspace_id
+      WHERE workspace.public_id = $1 AND audit.id < $2::bigint
+      ORDER BY audit.id DESC LIMIT $3
+    `, [input.workspaceId, cursorId, input.limit + 1]);
+    return this.mappedPage(result.rows, input.limit, (row) => ({
+      id: row.cursor_id, workspaceId: row.workspace_public_id, actorUserId: row.actor_external_id,
+      action: row.action, entityType: row.entity_type, entityId: row.entity_public_id,
+      metadata: row.metadata ?? {}, createdAt: new Date(row.created_at).toISOString(),
+    } as AuditEntry));
+  }
+
+  async appendAudit(input: Omit<AuditEntry, 'id' | 'createdAt'>): Promise<void> {
+    await this.database.query(`
+      INSERT INTO voc.audit_log (workspace_id, actor_external_id, action, entity_type, entity_public_id, metadata)
+      SELECT workspace.id, $2, $3, $4, $5, $6::jsonb FROM voc.workspace workspace WHERE workspace.public_id = $1
+    `, [input.workspaceId, input.actorUserId, input.action, input.entityType, input.entityId, JSON.stringify(input.metadata)]);
+  }
+
+  private mapMember(row: any): WorkspaceMember {
+    return {
+      id: row.id, workspaceId: row.public_id, userId: row.user_external_id, email: row.email,
+      displayName: row.display_name, role: row.role, status: row.status,
+      createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
+    };
+  }
+
+  private mapAnalysis(row: any): AnalysisRun {
+    return {
+      id: row.public_id, workspaceId: row.workspace_public_id, analysisType: row.analysis_type,
+      targetKind: row.target_kind, targetKey: row.target_key, status: row.status, input: row.input ?? {},
+      result: row.result ?? null, evidenceCount: row.evidence_count, requestedBy: row.requested_by_external_id,
+      errorSummary: row.error_summary, requestedAt: new Date(row.requested_at).toISOString(),
+      startedAt: iso(row.started_at), completedAt: iso(row.completed_at),
+    };
+  }
+
+  private mapAction(row: any): ActionItem {
+    return {
+      id: row.public_id, workspaceId: row.workspace_public_id, actionType: row.action_type,
+      title: row.title, description: row.description, priority: row.priority, status: row.status,
+      productKey: row.product_key, assigneeUserId: row.assignee_external_id, dueAt: iso(row.due_at),
+      createdBy: row.created_by_external_id, completedAt: iso(row.completed_at),
+      createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
+    };
+  }
+
+  private mapAlert(row: any): AlertItem {
+    return {
+      id: row.public_id, workspaceId: row.workspace_public_id, alertType: row.alert_type,
+      severity: row.severity, status: row.status, productKey: row.product_key,
+      title: row.title, summary: row.summary, evidence: Array.isArray(row.evidence) ? row.evidence : [],
+      detectedAt: new Date(row.detected_at).toISOString(), acknowledgedBy: row.acknowledged_by_external_id,
+      acknowledgedAt: iso(row.acknowledged_at), resolvedAt: iso(row.resolved_at),
+    };
+  }
+
+  private mappedPage<T>(rows: any[], limit: number, map: (row: any) => T): CursorPage<T> {
+    const hasMore = rows.length > limit;
+    const selected = rows.slice(0, limit);
+    return {
+      items: selected.map(map),
+      nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1].cursor_id }) : null,
+    };
+  }
+}

+ 260 - 0
src/modules/saas-platform/routes.ts

@@ -0,0 +1,260 @@
+import { randomUUID } from 'node:crypto';
+import { Router } from 'express';
+import { z } from 'zod';
+import { ApiError } from '../../http/api-error.js';
+import type { ActionItem, AlertItem, PlatformRepository, WorkspaceMember } from './domain.js';
+import { getPrincipal, WorkspaceAccessService } from './auth.js';
+
+const pageQuerySchema = z.object({
+  limit: z.coerce.number().int().min(1).max(100).default(25),
+  cursor: z.string().max(500).optional(),
+  status: z.string().max(40).default(''),
+});
+
+const memberSchema = z.object({
+  email: z.string().email().or(z.literal('')).default(''),
+  displayName: z.string().max(200).default(''),
+  role: z.enum(['owner', 'admin', 'analyst', 'viewer']),
+  status: z.enum(['invited', 'active', 'disabled']).default('active'),
+});
+
+const analysisSchema = z.object({
+  analysisType: z.enum(['voice', 'pain_point', 'feature', 'scenario', 'risk', 'report']),
+  targetKind: z.enum(['workspace', 'category', 'product']).default('workspace'),
+  targetKey: z.string().max(300).default(''),
+  input: z.record(z.string(), z.unknown()).default({}),
+});
+
+const actionCreateSchema = z.object({
+  actionType: z.enum(['general', 'experience', 'product', 'strategy', 'data_quality']).default('general'),
+  title: z.string().min(1).max(300),
+  description: z.string().max(10_000).default(''),
+  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
+  status: z.enum(['open', 'planned', 'in_progress', 'blocked', 'completed', 'cancelled']).default('open'),
+  productKey: z.string().max(300).nullable().default(null),
+  assigneeUserId: z.string().max(200).nullable().default(null),
+  dueAt: z.iso.datetime().nullable().default(null),
+});
+
+const actionPatchSchema = actionCreateSchema.pick({
+  title: true,
+  description: true,
+  priority: true,
+  status: true,
+  assigneeUserId: true,
+  dueAt: true,
+}).partial().refine((value) => Object.keys(value).length > 0, 'At least one field is required');
+
+const alertCreateSchema = z.object({
+  alertType: z.enum(['voc_risk', 'sentiment_spike', 'data_quality', 'sync_failure', 'quota']).default('voc_risk'),
+  severity: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
+  status: z.literal('open').default('open'),
+  productKey: z.string().max(300).nullable().default(null),
+  title: z.string().min(1).max(300),
+  summary: z.string().max(10_000).default(''),
+  evidence: z.array(z.unknown()).max(100).default([]),
+});
+
+const alertPatchSchema = z.object({
+  status: z.enum(['open', 'acknowledged', 'resolved', 'dismissed']),
+});
+
+export function createSaasPlatformRouter(input: {
+  repository: PlatformRepository;
+  access: WorkspaceAccessService;
+}): Router {
+  const router = Router();
+
+  router.get('/context', async (request, response, next) => {
+    try {
+      const principal = getPrincipal(request);
+      const workspaces = await input.repository.listWorkspaces(principal.userId);
+      response.json({
+        principal,
+        workspaces,
+        capabilities: {
+          workspaceMembers: true,
+          cursorCatalog: true,
+          syncJobs: true,
+          analysisRuns: true,
+          actionWorkflow: true,
+          alerts: true,
+          auditLog: true,
+        },
+      });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces', async (request, response, next) => {
+    try {
+      response.json({ items: await input.repository.listWorkspaces(getPrincipal(request).userId) });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/members', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      await input.access.require(request, workspaceId, 'member:manage');
+      response.json({ items: await input.repository.listMembers(workspaceId) });
+    } catch (error) { next(error); }
+  });
+
+  router.put('/workspaces/:workspaceId/members/:userId', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const userId = z.string().min(1).max(200).parse(request.params.userId);
+      const body = memberSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'member:manage');
+      const member = await input.repository.upsertMember({ workspaceId, userId, ...body });
+      if (!member) throw new ApiError(404, 'workspace_not_found');
+      await audit(input.repository, request, workspaceId, 'member.upserted', 'workspace_member', member.id, {
+        targetUserId: userId, role: member.role, status: member.status,
+      });
+      response.json({ member });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/data-sources', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      response.json({ items: await input.repository.listDataSources(workspaceId) });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/imports', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const page = pageQuerySchema.parse(request.query);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      response.json(await input.repository.listImports({ workspaceId, limit: page.limit, cursor: page.cursor ?? null }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/analyses', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const page = pageQuerySchema.parse(request.query);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      response.json(await input.repository.listAnalyses({ workspaceId, limit: page.limit, cursor: page.cursor ?? null, status: page.status }));
+    } catch (error) { next(error); }
+  });
+
+  router.post('/workspaces/:workspaceId/analyses', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const body = analysisSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'analysis:run');
+      const principal = getPrincipal(request);
+      const analysis = await input.repository.createAnalysis({
+        id: randomUUID(), workspaceId, ...body, requestedBy: principal.userId,
+      });
+      await audit(input.repository, request, workspaceId, 'analysis.created', 'analysis_run', analysis.id, {
+        analysisType: analysis.analysisType, targetKind: analysis.targetKind, targetKey: analysis.targetKey,
+      });
+      response.status(202).json({ analysis });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/actions', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const page = pageQuerySchema.parse(request.query);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      response.json(await input.repository.listActions({ workspaceId, limit: page.limit, cursor: page.cursor ?? null, status: page.status }));
+    } catch (error) { next(error); }
+  });
+
+  router.post('/workspaces/:workspaceId/actions', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const body = actionCreateSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'action:write');
+      const principal = getPrincipal(request);
+      const action = await input.repository.createAction({ id: randomUUID(), workspaceId, ...body, createdBy: principal.userId });
+      await audit(input.repository, request, workspaceId, 'action.created', 'action_item', action.id, { priority: action.priority });
+      response.status(201).json({ action });
+    } catch (error) { next(error); }
+  });
+
+  router.patch('/workspaces/:workspaceId/actions/:id', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const id = z.uuid().parse(request.params.id);
+      const body = actionPatchSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'action:write');
+      const definedPatch = Object.fromEntries(
+        Object.entries(body).filter((entry) => entry[1] !== undefined),
+      ) as Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>;
+      const action = await input.repository.updateAction(workspaceId, id, definedPatch);
+      if (!action) throw new ApiError(404, 'action_not_found');
+      await audit(input.repository, request, workspaceId, 'action.updated', 'action_item', id, { fields: Object.keys(body) });
+      response.json({ action });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/alerts', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const page = pageQuerySchema.parse(request.query);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      response.json(await input.repository.listAlerts({ workspaceId, limit: page.limit, cursor: page.cursor ?? null, status: page.status }));
+    } catch (error) { next(error); }
+  });
+
+  router.post('/workspaces/:workspaceId/alerts', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const body = alertCreateSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'alert:write');
+      const alert = await input.repository.createAlert({ id: randomUUID(), workspaceId, ...body });
+      await audit(input.repository, request, workspaceId, 'alert.created', 'alert', alert.id, { severity: alert.severity });
+      response.status(201).json({ alert });
+    } catch (error) { next(error); }
+  });
+
+  router.patch('/workspaces/:workspaceId/alerts/:id', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const id = z.uuid().parse(request.params.id);
+      const body = alertPatchSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'alert:write');
+      const alert = await input.repository.updateAlert(workspaceId, id, {
+        status: body.status as AlertItem['status'], actorUserId: getPrincipal(request).userId,
+      });
+      if (!alert) throw new ApiError(404, 'alert_not_found');
+      await audit(input.repository, request, workspaceId, 'alert.updated', 'alert', id, { status: body.status });
+      response.json({ alert });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/audit', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const page = pageQuerySchema.parse(request.query);
+      await input.access.require(request, workspaceId, 'audit:read');
+      response.json(await input.repository.listAudit({ workspaceId, limit: page.limit, cursor: page.cursor ?? null }));
+    } catch (error) { next(error); }
+  });
+
+  return router;
+}
+
+async function audit(
+  repository: PlatformRepository,
+  request: Parameters<typeof getPrincipal>[0],
+  workspaceId: string,
+  action: string,
+  entityType: string,
+  entityId: string,
+  metadata: Record<string, unknown>,
+): Promise<void> {
+  await repository.appendAudit({
+    workspaceId,
+    actorUserId: getPrincipal(request).userId,
+    action,
+    entityType,
+    entityId,
+    metadata,
+  });
+}

+ 14 - 0
src/server.ts

@@ -9,15 +9,29 @@ import { startSyncWorker } from './modules/domestic-voc/jobs/sync-worker.js';
 import { VocIngestionRepository } from './modules/domestic-voc/repositories/voc-ingestion.repository.js';
 import { JdSyncService } from './modules/domestic-voc/services/jd-sync.service.js';
 import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.js';
+import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
 
 async function main(): Promise<void> {
   const config = loadConfig();
   const pool = createDatabasePool(config);
   const parseServer = await createParseServer(config);
+  const platformRepository = new PostgresPlatformRepository(pool);
+  const bootstrapUserId = config.auth.bootstrapAdminUserId
+    || (config.auth.mode === 'disabled' ? config.auth.localUserId : '');
+  if (bootstrapUserId) {
+    await platformRepository.bootstrapAdmin(config.auth.defaultWorkspaceId, {
+      userId: bootstrapUserId,
+      email: config.auth.bootstrapAdminEmail
+        || (config.auth.mode === 'disabled' ? config.auth.localUserEmail : ''),
+      displayName: config.auth.bootstrapAdminName
+        || (config.auth.mode === 'disabled' ? config.auth.localUserName : bootstrapUserId),
+    });
+  }
   const app = createApp({
     config,
     pool,
     parseApp: parseServer.app as unknown as RequestHandler,
+    platformRepository,
   });
   const server = createServer(app);
   const gateway = new FmodeVocEcommerceClient(config.fmode);

+ 17 - 1
test/app.test.ts

@@ -14,6 +14,7 @@ const config = loadConfig({
   PARSE_MASTER_KEY: 'a'.repeat(32),
   PARSE_MAINTENANCE_KEY: 'b'.repeat(32),
   PARSE_SERVER_URL: 'http://127.0.0.1:4400/parse',
+  API_AUTH_MODE: 'disabled',
   FMODE_BASE_URL: 'http://127.0.0.1:3000/api/voc-e-commerce',
   FMODE_API_KEY: 'test-key',
   CORS_ORIGINS: 'http://127.0.0.1:4300',
@@ -45,6 +46,22 @@ function createMockPool(): Pool {
           rowCount: 1,
         };
       }
+      if (text.includes('FROM voc.workspace_member')) {
+        return {
+          rows: [{
+            id: '1',
+            public_id: 'demashi',
+            user_external_id: 'local-admin',
+            email: 'local-admin@localhost',
+            display_name: 'Local Admin',
+            role: 'owner',
+            status: 'active',
+            created_at: '2026-07-23T00:00:00.000Z',
+            updated_at: '2026-07-23T00:00:00.000Z',
+          }],
+          rowCount: 1,
+        };
+      }
       if (text.includes('FROM voc.workspace')) {
         return { rows: [{ id: '1', public_id: 'demashi', case_name: 'Demashi' }], rowCount: 1 };
       }
@@ -82,4 +99,3 @@ test('HTTP surface exposes health, empty snapshot, and sync queue contract', asy
     await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
   }
 });
-

+ 69 - 0
test/auth.test.ts

@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import type { Request } from 'express';
+import test from 'node:test';
+import { ApiError } from '../src/http/api-error.js';
+import { ParseSessionAuthenticator } from '../src/modules/saas-platform/auth.js';
+
+const parseConfig = {
+  appId: 'saas-voc-test-app',
+  masterKey: 'master-key-that-must-not-be-forwarded',
+  maintenanceKey: 'maintenance-key-that-must-not-be-forwarded',
+  serverUrl: 'http://127.0.0.1:4400/parse',
+};
+
+function requestWithHeaders(values: Record<string, string>): Request {
+  const normalized = Object.fromEntries(Object.entries(values).map(([key, value]) => [key.toLowerCase(), value]));
+  return {
+    header(name: string) {
+      return normalized[name.toLowerCase()];
+    },
+  } as Request;
+}
+
+test('Parse session authentication returns a principal without forwarding privileged keys', async () => {
+  const sessionToken = 'session-token-only-for-user-auth';
+  let requestedUrl = '';
+  let requestedHeaders = new Headers();
+  const fetchImplementation: typeof fetch = async (input, init) => {
+    requestedUrl = String(input);
+    requestedHeaders = new Headers(init?.headers);
+    return new Response(JSON.stringify({
+      objectId: 'parse-user-1',
+      email: 'analyst@example.test',
+      name: 'VOC Analyst',
+    }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+  };
+  const authenticator = new ParseSessionAuthenticator(parseConfig, fetchImplementation);
+
+  const principal = await authenticator.authenticate(requestWithHeaders({ Authorization: `Bearer ${sessionToken}` }));
+
+  assert.deepEqual(principal, {
+    userId: 'parse-user-1',
+    email: 'analyst@example.test',
+    displayName: 'VOC Analyst',
+    authMode: 'parse',
+  });
+  assert.equal(requestedUrl, 'http://127.0.0.1:4400/parse/users/me');
+  assert.ok(!requestedUrl.includes(sessionToken));
+  assert.equal(requestedHeaders.get('X-Parse-Application-Id'), parseConfig.appId);
+  assert.equal(requestedHeaders.get('X-Parse-Session-Token'), sessionToken);
+  assert.equal(requestedHeaders.get('X-Parse-Master-Key'), null);
+  assert.ok(![...requestedHeaders.values()].includes(parseConfig.masterKey));
+  assert.ok(![...requestedHeaders.values()].includes(parseConfig.maintenanceKey));
+});
+
+test('Parse session authentication rejects missing or invalid sessions and surfaces outages', async () => {
+  const unauthorized = new ParseSessionAuthenticator(parseConfig, async () => new Response(null, { status: 401 }));
+  assert.equal(await unauthorized.authenticate(requestWithHeaders({})), null);
+  assert.equal(await unauthorized.authenticate(requestWithHeaders({ 'X-Parse-Session-Token': 'invalid' })), null);
+
+  const unavailable = new ParseSessionAuthenticator(parseConfig, async () => {
+    throw new Error('connection refused');
+  });
+  await assert.rejects(
+    unavailable.authenticate(requestWithHeaders({ 'X-Parse-Session-Token': 'session' })),
+    (error: unknown) => error instanceof ApiError
+      && error.status === 503
+      && error.code === 'authentication_unavailable',
+  );
+});

+ 11 - 0
test/env.test.ts

@@ -12,6 +12,7 @@ const validEnvironment: NodeJS.ProcessEnv = {
   PARSE_MASTER_KEY: 'a'.repeat(32),
   PARSE_MAINTENANCE_KEY: 'b'.repeat(32),
   PARSE_SERVER_URL: 'http://127.0.0.1:4400/parse',
+  API_AUTH_MODE: 'disabled',
   FMODE_BASE_URL: 'http://127.0.0.1:3000/api/voc-e-commerce',
   FMODE_API_KEY: 'test-key',
   CORS_ORIGINS: 'http://127.0.0.1:4300',
@@ -48,5 +49,15 @@ test('loadConfig normalizes URLs and CORS origins', () => {
   assert.equal(config.database.migrationUrl, validEnvironment.MIGRATION_DATABASE_URL);
   assert.equal(config.worker.enabled, true);
   assert.equal(config.worker.reviewMaxPages, 1);
+  assert.equal(config.auth.mode, 'disabled');
+  assert.equal(config.auth.defaultWorkspaceId, 'demashi');
   assert.deepEqual(config.corsOrigins, ['http://127.0.0.1:4300', 'http://localhost:4200']);
 });
+
+test('loadConfig rejects disabled authentication in production', () => {
+  assert.throws(
+    () => loadConfig({ ...validEnvironment, NODE_ENV: 'production', API_AUTH_MODE: 'disabled' }),
+    (error: unknown) => error instanceof EnvironmentConfigurationError
+      && error.message.includes('disabled authentication is not allowed in production'),
+  );
+});

+ 302 - 0
test/saas-platform.test.ts

@@ -0,0 +1,302 @@
+import assert from 'node:assert/strict';
+import type { AddressInfo } from 'node:net';
+import test from 'node:test';
+import express, { type ErrorRequestHandler } from 'express';
+import { ZodError } from 'zod';
+import { ApiError } from '../src/http/api-error.js';
+import { createLocalDemoApp } from '../src/local-app.js';
+import { LocalSyncJobStore } from '../src/modules/domestic-voc/local/local-sync-job.store.js';
+import {
+  createAuthenticationMiddleware,
+  type RequestAuthenticator,
+  WorkspaceAccessService,
+} from '../src/modules/saas-platform/auth.js';
+import { LocalPlatformRepository } from '../src/modules/saas-platform/local-platform.repository.js';
+import { createSaasPlatformRouter } from '../src/modules/saas-platform/routes.js';
+import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../src/types/domestic-dataset.js';
+
+const emptyMetrics: DomesticMetricSummary = {
+  gmv: 0,
+  soldUnits: 0,
+  transactionOrders: 0,
+  transactionCustomers: 0,
+  impressions: 0,
+  clicks: 0,
+  views: 0,
+  visitors: 0,
+  cartUnits: 0,
+  orderAmount: 0,
+  orderUnits: 0,
+  orderCount: 0,
+  refundAmount: 0,
+  refundUnits: 0,
+  refundOrders: 0,
+  conversionRate: 0,
+  clickThroughRate: 0,
+  averageUnitPrice: 0,
+  refundToGmvRate: 0,
+};
+
+function product(productId: string, role: DomesticProduct['role']): DomesticProduct {
+  return {
+    platform: 'jd',
+    productId,
+    productKey: `jd:${productId}`,
+    asin: productId,
+    role,
+    brand: role === 'own' ? 'Demashi' : 'Competitor',
+    title: `Product ${productId}`,
+    model: `M-${productId}`,
+    category1: 'Commercial appliance',
+    category2: 'Kitchen equipment',
+    category3: 'Oven',
+    source: 'test',
+    relationCount: role === 'own' ? 2 : 0,
+    summary: emptyMetrics,
+    trend: [],
+  };
+}
+
+const dataset: DomesticDataset = {
+  schemaVersion: 1,
+  generatedAt: '2026-07-23T00:00:00.000Z',
+  caseName: 'Demashi',
+  platform: 'jd',
+  source: {
+    sourceFile: 'demashi-summary.json',
+    sourceHash: 'test',
+    sheets: [],
+    dateRange: { start: '2026-07-01', end: '2026-07-23' },
+  },
+  summary: {
+    metricRows: 0,
+    metricProducts: 3,
+    mappingRows: 2,
+    relations: 2,
+    uniqueCompetitorProducts: 2,
+    category2Count: 1,
+    category3Count: 1,
+    reviewCount: 0,
+  },
+  dailyTotals: [],
+  products: [product('1001', 'own'), product('1002', 'competitor'), product('1003', 'competitor')],
+  mappingGroups: [],
+  relations: [
+    {
+      relationKey: 'jd:1001:1002',
+      ownProductKey: 'jd:1001',
+      ownProductId: '1001',
+      competitorProductKey: 'jd:1002',
+      competitorProductId: '1002',
+      competitorBrand: 'Competitor',
+      category: 'Oven',
+    },
+    {
+      relationKey: 'jd:1001:1003',
+      ownProductKey: 'jd:1001',
+      ownProductId: '1001',
+      competitorProductKey: 'jd:1003',
+      competitorProductId: '1003',
+      competitorBrand: 'Competitor',
+      category: 'Oven',
+    },
+  ],
+  reviews: [],
+  quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
+};
+
+async function listen(app: ReturnType<typeof express>) {
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
+    const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
+  });
+  const address = server.address() as AddressInfo;
+  return {
+    baseUrl: `http://127.0.0.1:${address.port}`,
+    close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
+  };
+}
+
+async function json(response: Response): Promise<Record<string, any>> {
+  return await response.json() as Record<string, any>;
+}
+
+test('local SaaS APIs cover context, cursor catalogs, workflows, and audit history', async () => {
+  const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'] });
+  const server = await listen(app);
+
+  try {
+    const contextResponse = await fetch(`${server.baseUrl}/api/saas/context`);
+    assert.equal(contextResponse.status, 200);
+    const context = await json(contextResponse);
+    assert.equal(context.principal.userId, 'local-admin');
+    assert.equal(context.workspaces[0].role, 'owner');
+
+    const firstProductsResponse = await fetch(`${server.baseUrl}/api/domestic-voc/products?limit=2`);
+    assert.equal(firstProductsResponse.status, 200);
+    const firstProducts = await json(firstProductsResponse);
+    assert.equal(firstProducts.items.length, 2);
+    assert.equal(typeof firstProducts.nextCursor, 'string');
+
+    const secondProductsResponse = await fetch(
+      `${server.baseUrl}/api/domestic-voc/products?limit=2&cursor=${encodeURIComponent(firstProducts.nextCursor)}`,
+    );
+    assert.equal(secondProductsResponse.status, 200);
+    const secondProducts = await json(secondProductsResponse);
+    assert.equal(secondProducts.items.length, 1);
+    assert.equal(secondProducts.nextCursor, null);
+
+    const invalidCursor = await fetch(`${server.baseUrl}/api/domestic-voc/products?cursor=not-a-cursor`);
+    assert.equal(invalidCursor.status, 400);
+    assert.equal((await json(invalidCursor)).error, 'invalid_cursor');
+
+    const firstRelations = await json(await fetch(`${server.baseUrl}/api/domestic-voc/relations?limit=1`));
+    assert.equal(firstRelations.items.length, 1);
+    assert.equal(typeof firstRelations.nextCursor, 'string');
+
+    const memberResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/viewer-user`, {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        email: 'viewer@example.test',
+        displayName: 'Viewer',
+        role: 'viewer',
+        status: 'active',
+      }),
+    });
+    assert.equal(memberResponse.status, 200);
+    assert.equal((await json(memberResponse)).member.role, 'viewer');
+
+    const analysisResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ analysisType: 'voice', targetKind: 'workspace', input: { platform: 'jd' } }),
+    });
+    assert.equal(analysisResponse.status, 202);
+    assert.equal((await json(analysisResponse)).analysis.status, 'pending');
+    const analyses = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`));
+    assert.equal(analyses.items.length, 1);
+
+    const actionResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ title: 'Verify review source', priority: 'high' }),
+    });
+    assert.equal(actionResponse.status, 201);
+    const action = (await json(actionResponse)).action;
+    const completedActionResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions/${action.id}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ status: 'completed' }),
+    });
+    assert.equal(completedActionResponse.status, 200);
+    assert.equal((await json(completedActionResponse)).action.status, 'completed');
+    const completedActions = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions?status=completed`));
+    assert.equal(completedActions.items.length, 1);
+    assert.equal(typeof completedActions.items[0].completedAt, 'string');
+
+    const alertResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/alerts`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ title: 'Review evidence unavailable', alertType: 'data_quality', severity: 'high' }),
+    });
+    assert.equal(alertResponse.status, 201);
+    const alert = (await json(alertResponse)).alert;
+    const resolvedAlertResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/alerts/${alert.id}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ status: 'resolved' }),
+    });
+    assert.equal(resolvedAlertResponse.status, 200);
+    assert.equal(typeof (await json(resolvedAlertResponse)).alert.resolvedAt, 'string');
+
+    const syncResponse = await fetch(`${server.baseUrl}/api/domestic-voc/sync`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'platform-audit-sync-1001' },
+      body: JSON.stringify({ productIds: ['1001'] }),
+    });
+    assert.equal(syncResponse.status, 202);
+
+    const auditResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=100`);
+    assert.equal(auditResponse.status, 200);
+    const auditActions = (await json(auditResponse)).items.map((item: { action: string }) => item.action);
+    assert.ok(auditActions.includes('member.upserted'));
+    assert.ok(auditActions.includes('analysis.created'));
+    assert.ok(auditActions.includes('action.updated'));
+    assert.ok(auditActions.includes('alert.updated'));
+    assert.ok(auditActions.includes('sync.requested'));
+  } finally {
+    await server.close();
+  }
+});
+
+test('viewer membership can read but cannot call write or member-management routes', async () => {
+  const jobs = new LocalSyncJobStore(dataset);
+  const repository = new LocalPlatformRepository(dataset, jobs, {
+    userId: 'local-admin',
+    email: 'local-admin@localhost',
+    displayName: 'Local Admin',
+  });
+  await repository.upsertMember({
+    workspaceId: 'demashi',
+    userId: 'viewer-user',
+    email: 'viewer@example.test',
+    displayName: 'Viewer',
+    role: 'viewer',
+    status: 'active',
+  });
+
+  const authenticator: RequestAuthenticator = {
+    async authenticate(request) {
+      const userId = request.header('X-Test-User') || 'local-admin';
+      return { userId, email: `${userId}@example.test`, displayName: userId, authMode: 'disabled' };
+    },
+  };
+  const app = express();
+  app.use(express.json());
+  app.use('/api', createAuthenticationMiddleware(authenticator));
+  app.use('/api/saas', createSaasPlatformRouter({
+    repository,
+    access: new WorkspaceAccessService(repository),
+  }));
+  const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => {
+    if (error instanceof ApiError) {
+      response.status(error.status).json({ error: error.code });
+      return;
+    }
+    if (error instanceof ZodError) {
+      response.status(400).json({ error: 'invalid_request' });
+      return;
+    }
+    response.status(500).json({ error: 'internal_error' });
+  };
+  app.use(errorHandler);
+  const server = await listen(app);
+  const viewerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'viewer-user' };
+
+  try {
+    const readable = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, { headers: viewerHeaders });
+    assert.equal(readable.status, 200);
+
+    const writes = [
+      fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
+        method: 'POST', headers: viewerHeaders, body: JSON.stringify({ analysisType: 'voice' }),
+      }),
+      fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+        method: 'POST', headers: viewerHeaders, body: JSON.stringify({ title: 'Forbidden' }),
+      }),
+      fetch(`${server.baseUrl}/api/saas/workspaces/demashi/alerts`, {
+        method: 'POST', headers: viewerHeaders, body: JSON.stringify({ title: 'Forbidden' }),
+      }),
+      fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/new-user`, {
+        method: 'PUT', headers: viewerHeaders,
+        body: JSON.stringify({ role: 'viewer', status: 'active' }),
+      }),
+    ];
+    for (const response of await Promise.all(writes)) {
+      assert.equal(response.status, 403);
+      assert.equal((await json(response)).error, 'workspace_permission_denied');
+    }
+  } finally {
+    await server.close();
+  }
+});