diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4688d27b..25a72899 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,34 @@ WBS-estimation readiness analysis. - `wbs.json`: seed data in the user-specified JSON array format. +## SaaS bounded contexts and persistence + +- **Tenant and Access** owns workspaces, memberships, authentication, and RBAC. +- **Project Planning** owns projects, revisions, baselines, comments, sprints, + attachments, shares, and schedule-control state. +- **Audit Trail** owns append-only enterprise compliance evidence. Its durable + SQLite relation is `audit_events`, with semantic persistence vocabulary such + as `audit_event_id`, `audit_action`, and `audit_metadata_json`; the principal + tenant query index is `audit_events_org_event_idx` on + `(org_id, audit_event_id)`. +- **Integration** owns webhooks, Clearfolio attachment conversion, and + contextual-orchestrator briefing boundaries. + +The Audit Trail HTTP/CSV/export compatibility surface predates the persistence +rename and continues to expose wire fields such as `id`, `action`, and `meta`. +Those generic external names are isolated in explicit SQL aliases at the web +adapter boundary. Production writes and durable reads use the semantic +`audit_events` vocabulary directly. + +Startup migration treats a historical durable `audit_log` table as a legacy +compatibility source only. The migration creates the semantic authority first, +validates that legacy and semantic column sets are not ambiguous, refuses to +merge two populated authorities, copies legacy rows under `BEGIN IMMEDIATE`, +drops the old table/indexes in the same transaction, and rolls back on failure. +The relation remains append-only and normalized as one audit event per row; +foreign-key semantics, the org-scoped hot read path, UPSERT behavior (none), +and runtime read/write topology are otherwise unchanged. + ## CI and security structure - `.github/workflows/pages.yml`: GitHub Pages deployment workflow for the diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..a31b05ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Migrated Audit Trail persistence from the generic durable + `audit_log.id/action/meta` vocabulary to + `audit_events.audit_event_id/audit_action/audit_metadata_json` and the + `audit_events_org_event_idx` tenant-read index. Existing audit HTTP, CSV, and + workspace-export wire fields remain compatible through explicit web-adapter + aliases, while startup migration copies and removes legacy persistence in one + fail-closed transaction. - Switched the repository-local OpenCode development configuration from GitHub Models to an NVIDIA NIM-only candidate set while preserving organization-level review-workflow ownership in `ContextualWisdomLab/.github`. diff --git a/docs/doctoring/audit-persistence-semantic-naming.md b/docs/doctoring/audit-persistence-semantic-naming.md new file mode 100644 index 00000000..b84f60b3 --- /dev/null +++ b/docs/doctoring/audit-persistence-semantic-naming.md @@ -0,0 +1,69 @@ +# Audit Trail persistence semantic naming + +## Decision + +ScopeWeave owns the Audit Trail persistence vocabulary. Durable SQLite names +therefore describe the bounded-context meaning rather than relying on generic +single-word identifiers. + +| Legacy durable name | Semantic durable name | +| --- | --- | +| `audit_log` | `audit_events` | +| `id` | `audit_event_id` | +| `action` | `audit_action` | +| `meta` | `audit_metadata_json` | +| `idx_audit_org` | `audit_events_org_event_idx` | + +The existing `/api/orgs/:id/audit` JSON contract, audit CSV columns, and +workspace-export audit records predate this persistence repair. Their historical +`id`, `action`, and `meta` fields remain compatibility surface names and are +produced only by explicit SQL aliases in `server/app.mjs`. Internal writes and +reads use `audit_events`, `audit_event_id`, `audit_action`, and +`audit_metadata_json` directly. No persistent compatibility view is retained. + +## Bounded-context rationale + +The Audit Trail is append-only compliance evidence scoped to an organization. +`audit_event_id` identifies one recorded audit event, `audit_action` records the +business/security action, and `audit_metadata_json` stores action-specific +structured metadata. `audit_events_org_event_idx(org_id, audit_event_id)` names +and serves the tenant-scoped reverse-event query used by the audit endpoint. +These terms are part of the Audit Trail ubiquitous language rather than generic +storage vocabulary. + +## Migration and rollback safety + +Startup creates the semantic `audit_events` authority, then inspects a historical +`main.audit_log` only as a legacy migration source. Migration: + +1. verifies `audit_log` is a table and that its column set is either the original + `id/action/meta` shape or the short-lived semantic-column intermediate shape; +2. fails closed on mixed/ambiguous columns or when both old and new authorities + already contain data; +3. starts `BEGIN IMMEDIATE`, copies rows to `audit_events`, removes legacy audit + indexes and the legacy table, and commits as one transaction; and +4. rolls back the transaction and propagates the causal failure if any copy or + DDL operation fails. + +The new relation preserves the existing `org_id` and nullable `user_id` foreign +keys, one-row-per-audit-event 3NF shape, and append-only behavior. There is no +Audit Trail UPSERT path. The migration adds no partitioning and no read/write +split; its stronger write lock exists only during startup migration. The hot +runtime read remains tenant-scoped by `(org_id, audit_event_id)`. + +## Executable evidence + +The repair branch was cut from protected +`develop@2c328875e00e86537df3e965170be80532571cad`. TDD introduced +`tests/unit/audit-log-database-naming.test.mjs` before the production repair. +The current test contract covers fresh semantic storage, absence of durable or +temporary `audit_log`, data-preserving migration from a realistic legacy store, +semantic index creation, and idempotent reopen without duplicate audit events. +`tests/api/smoke.mjs` additionally proves that the established HTTP +`id/action/meta` fields remain present while internal semantic persistence field +names do not leak to the wire contract. Source and compatibility repairs were +present together by `59e1e19eb1ba363efc789368b2d2217b6794b61d`; documentation +commits follow on the same ordinary, non-force PR history. + +Fresh required GitHub verification must attach to the final unchanged PR head; +predecessor, base, or model-only evidence is not a merge signal. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..b7d1f466 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,113 @@ +# ScopeWeave product and technical gap baseline + +## Buyer outcome and product responsibility + +ScopeWeave is the project-planning and WBS product boundary: teams create and +maintain project plans, collaborate under tenant-scoped access control, measure +schedule/cost progress, exchange planning artifacts, and retain enterprise audit +evidence. The repository owns the browser planner plus its SaaS API/persistence +implementation. Shared LLM policy remains delegated to +`ContextualWisdomLab/contextual-orchestrator`; Clearfolio document conversion is +an integration boundary rather than ScopeWeave-owned document rendering. + +The current naming-repair priority is the **Audit Trail** persistence contract, +because compliance evidence is durable, tenant-scoped, security-sensitive, and +buyer-visible through audit/export APIs. A persistence rename therefore has +higher migration and compatibility risk than a local implementation-variable +rename. + +## DDD context map and ubiquitous language + +| Bounded context | Owned concepts | Important invariants | +| --- | --- | --- | +| Tenant and Access | workspace, membership, authenticated principal, access role | project/audit reads remain tenant scoped; owner/admin manage audit access | +| Project Planning | project plan, task hierarchy, revision, baseline, sprint | optimistic project versions remain linear; planning records stay within the owning workspace | +| Audit Trail | audit event, audit action, audit target, audit metadata | append-only evidence; every durable audit identifier is semantically specific; organization filtering is indexed | +| Integration | webhook delivery, attachment conversion, AI briefing | external/vendor schemas are translated at adapters and do not define internal ubiquitous language | + +Audit Trail aggregate/repository language is `audit_event`, not generic +`record`/`item`/`event` in persistence code. The durable relation is +`audit_events`; its key is `audit_event_id`, business action is `audit_action`, +and structured detail is `audit_metadata_json`. + +## Persistence model and migration boundary + +Relevant ERD slice: + +```text +users(user_id compatibility column today: id) + 1 ─────< audit_events.user_id (nullable, ON DELETE SET NULL) + +orgs(org_id compatibility column today: id) + 1 ─────< audit_events.org_id (required, ON DELETE CASCADE) + + audit_events + ├─ audit_event_id PK + ├─ org_id FK + ├─ user_id FK nullable + ├─ audit_action + ├─ target_type + ├─ target_id + ├─ audit_metadata_json + └─ created_at + + audit_events_org_event_idx(org_id, audit_event_id) +``` + +This PR intentionally repairs only the verified Audit Trail ownership slice; it +does not mechanically rename every legacy table in the repository. Historical +`audit_log(id, action, meta)` is accepted only as a startup migration source. +Migration uses `BEGIN IMMEDIATE`, refuses ambiguous column sets and dual +populated authorities, copies rows into `audit_events`, removes legacy audit +indexes/table atomically, and rolls back on failure. Foreign-key semantics and +3NF remain unchanged. Audit writes are append-only, so no UPSERT path changes. +The existing tenant hot path remains supported by +`audit_events_org_event_idx(org_id, audit_event_id)`; no partitioning or runtime +read/write split is introduced. + +## Compatibility and naming-contract status + +The established `/api/orgs/:id/audit` JSON payload, audit CSV, and workspace +export retain historical wire names such as `id`, `action`, and `meta`. Those +fields are compatibility surface names and are isolated at explicit SQL aliases +inside the HTTP/export adapter. Durable storage and production audit writes use +semantic multiword names directly. Tests reject leakage of +`audit_event_id`/`audit_action`/`audit_metadata_json` into the established HTTP +payload while also rejecting generic audit persistence columns. + +Organization-wide naming is not declared complete for this repository. The +current repair is deliberately bounded to the audited high-leverage persistence +contract; other legacy persistence/API names require separate ownership and +migration evidence before change. Idiomatic multiword camelCase/PascalCase names +are not naming defects. + +## Security, test, and operability baseline + +- Protected development base inspected before the repair: + `develop@2c328875e00e86537df3e965170be80532571cad`. +- Required repository contexts observed at that base included `unit-and-api`, + `cloud-e2e`, JavaScript/TypeScript CodeQL, Python CodeQL, and `property fuzz`. +- The naming regression was introduced before production repair. Current unit + coverage verifies fresh semantic storage, absence of persistent/runtime + `audit_log`, realistic legacy data migration, row preservation, semantic index + creation, and idempotent reopen. +- API smoke coverage verifies the historical audit wire fields remain present, + semantic persistence names do not leak, and the CSV formula-injection fixture + writes through the semantic durable relation. +- The source + consumer compatibility repair was present together by + `59e1e19eb1ba363efc789368b2d2217b6794b61d`; subsequent commits update + architecture/change/doctoring evidence on the same non-force history. +- Final merge evidence must come from the unchanged current PR head with all live + required checks terminal-success and independent current approval. Base or + predecessor checks are not transferable. + +## Current gaps and next leverage order + +1. Complete fresh exact-head verification and independent review for the Audit + Trail migration before merge. +2. Continue persistence-first naming audit in ScopeWeave, prioritizing durable + public/domain contracts over local variables. Each subsequent table or API + slice requires its own bounded-context rationale, migration/compatibility + plan, and executable consumer coverage. +3. After repository-local high-risk contracts are exhausted, return to shared + cross-repository schemas/libraries before lower-scope implementation names. diff --git a/package.json b/package.json index 8cefdc74..9e25ad50 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/audit-log-database-naming.test.mjs && node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", diff --git a/server/app.mjs b/server/app.mjs index c432a84f..6fa0cf38 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -12,13 +12,13 @@ import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client -const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); +const getOrg = (organizationId) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(organizationId); // Append-only audit trail. Never throws into the request path. -function logAudit(orgId, userId, action, targetType, targetId, meta) { +function recordAuditEvent(organizationId, actorUserId, auditAction, targetType, targetId, auditMetadata) { try { - db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') - .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); + db.prepare('INSERT INTO audit_events(org_id,user_id,audit_action,target_type,target_id,audit_metadata_json) VALUES(?,?,?,?,?,?)') + .run(organizationId, actorUserId ?? null, auditAction, targetType ?? null, targetId != null ? String(targetId) : null, auditMetadata ? JSON.stringify(auditMetadata) : null); } catch { /* audit must not break the operation */ } } @@ -222,7 +222,7 @@ app.post('/api/orgs', requireAuth, async (c) => { db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(oid, uid, 'org.create', 'org', oid, { name }); + recordAuditEvent(oid, uid, 'org.create', 'org', oid, { name }); return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); }); @@ -249,7 +249,7 @@ app.post('/api/projects', requireAuth, async (c) => { } const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); metrics.projectsCreated++; - logAudit(org.id, uid, 'project.create', 'project', id, { name }); + recordAuditEvent(org.id, uid, 'project.create', 'project', id, { name }); return c.json({ id, name, version: 1 }); }); @@ -275,7 +275,7 @@ app.put('/api/projects/:id', requireAuth, async (c) => { db.prepare( "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); - logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); + recordAuditEvent(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); // Revision history: snapshot every save, keep the last 20 per project. try { db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') @@ -314,7 +314,7 @@ app.post('/api/projects/:id/comments', requireAuth, async (c) => { if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') .run(p.id, String(taskId || ''), uid, text)); - logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); + recordAuditEvent(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); return c.json({ id: cid }); }); @@ -367,7 +367,7 @@ app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') .run(id, version, r.name, r.base_date, r.tasks_json, uid); } catch { /* history must not break restore */ } - logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); + recordAuditEvent(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); broadcast(id, { type: 'update', version, by: uid }); return c.json({ version }); }); @@ -464,7 +464,7 @@ app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') .run(c.req.param('inviteId'), orgId); if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); + recordAuditEvent(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); return c.json({ ok: true }); }); @@ -483,7 +483,7 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { const token = randomBytes(24).toString('base64url'); db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') .run(orgId, email, inviteRole, token, uid); - logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); + recordAuditEvent(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); return c.json({ token, email, role: inviteRole }); }); @@ -498,7 +498,7 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); } db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); - logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); + recordAuditEvent(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); } db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); @@ -518,7 +518,7 @@ app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { if (!target) return c.json({ error: 'not found' }, 404); if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); - logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); + recordAuditEvent(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); return c.json({ userId: Number(targetId), role: newRole }); }); @@ -532,7 +532,7 @@ app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { if (!target) return c.json({ error: 'not found' }, 404); if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); - logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); + recordAuditEvent(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); return c.json({ ok: true }); }); @@ -545,7 +545,7 @@ app.post('/api/orgs/:id/leave', requireAuth, (c) => { if (!role) return c.json({ error: 'not found' }, 404); if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); - logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); + recordAuditEvent(orgId, uid, 'member.leave', 'user', uid, { role }); return c.json({ ok: true }); }); @@ -566,7 +566,7 @@ app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); + recordAuditEvent(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); return c.json({ ok: true, newOwnerId: Number(userId) }); }); @@ -578,7 +578,7 @@ app.patch('/api/orgs/:id', requireAuth, async (c) => { const { name } = await c.req.json().catch(() => ({})); if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); - logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); + recordAuditEvent(orgId, uid, 'org.rename', 'org', orgId, { name }); return c.json({ id: Number(orgId), name: String(name).trim() }); }); @@ -620,7 +620,7 @@ app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { const orgId = c.req.param('id'); if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + recordAuditEvent(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); deliver(orgId, 'billing.upgrade', { plan: 'pro' }); return c.json({ plan: 'pro' }); }); @@ -656,14 +656,19 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { const uid = c.get('user').sub; const orgId = c.req.param('id'); if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const limit = Math.min(Number(c.req.query('limit')) || 100, 500); - const rows = db.prepare( - `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, - a.created_at AS createdAt, u.email AS actorEmail - FROM audit_log a LEFT JOIN users u ON u.id = a.user_id - WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` - ).all(orgId, limit); - const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + const auditLimit = Math.min(Number(c.req.query('limit')) || 100, 500); + const auditRows = db.prepare( + `SELECT a.audit_event_id AS id, a.audit_action AS action, + a.target_type AS targetType, a.target_id AS targetId, + a.audit_metadata_json AS meta, a.created_at AS createdAt, + u.email AS actorEmail + FROM audit_events a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.audit_event_id DESC LIMIT ?` + ).all(orgId, auditLimit); + const auditEvents = auditRows.map((auditRow) => ({ + ...auditRow, + meta: auditRow.meta ? JSON.parse(auditRow.meta) : null, + })); if (c.req.query('format') === 'csv') { // Compliance deliverable. Formula-injection-safe: values that (after optional // leading whitespace) start with = + - @ | are prefixed with ' so @@ -676,15 +681,23 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { }; const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; const lines = [header.join(',')]; - for (const e of events) { - lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + for (const auditEvent of auditEvents) { + lines.push([ + auditEvent.id, + auditEvent.createdAt, + auditEvent.actorEmail, + auditEvent.action, + auditEvent.targetType, + auditEvent.targetId, + auditEvent.meta ? JSON.stringify(auditEvent.meta) : '', + ].map(csvCell).join(',')); } return c.text(lines.join('\r\n') + '\r\n', 200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, }); } - return c.json({ events }); + return c.json({ events: auditEvents }); }); // Full workspace export (owner only) — data portability / GDPR. Everything the @@ -700,14 +713,17 @@ app.get('/api/orgs/:id/export', requireAuth, (c) => { const projects = db.prepare( 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); - const audit = db.prepare( - 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' - ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); - logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + const auditEvents = db.prepare( + 'SELECT audit_action AS action, target_type AS targetType, target_id AS targetId, audit_metadata_json AS meta, created_at AS createdAt FROM audit_events WHERE org_id = ? ORDER BY audit_event_id' + ).all(orgId).map((auditEventRow) => ({ + ...auditEventRow, + meta: auditEventRow.meta ? JSON.parse(auditEventRow.meta) : null, + })); + recordAuditEvent(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); return c.json({ exportedAt: new Date().toISOString(), org: { id: org.id, name: org.name, plan: org.plan }, - members, projects, audit, + members, projects, audit: auditEvents, }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); }); @@ -751,7 +767,7 @@ app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { const secret = `whsec_${randomBytes(24).toString('base64url')}`; const evs = Array.isArray(events) ? events.join(',') : (events || '*'); const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + recordAuditEvent(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification }); @@ -776,7 +792,7 @@ app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { const secret = `whsec_${randomBytes(24).toString('base64url')}`; const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + recordAuditEvent(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); return c.json({ id: Number(c.req.param('whId')), secret }); // shown once }); @@ -999,7 +1015,7 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { service: 'scopeweave', account: String(p.org_id), }); - logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + recordAuditEvent(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); return c.json({ analysis }); } catch (e) { return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); @@ -1056,7 +1072,7 @@ app.post('/api/projects/:id/attachments', requireAuth, async (c) => { const aid = rowid(db.prepare( 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); - logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + recordAuditEvent(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); return c.json({ id: aid, status: job.status }); }); @@ -1119,7 +1135,7 @@ app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { if (!a) return c.json({ error: 'not found' }, 404); if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); - logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + recordAuditEvent(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); return c.json({ ok: true }); }); @@ -1144,7 +1160,7 @@ app.post('/api/projects/:id/shares', requireAuth, (c) => { if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); const token = randomBytes(18).toString('base64url'); db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); - logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + recordAuditEvent(p.org_id, uid, 'share.create', 'project', p.id, {}); return c.json({ token, url: `/?share=${token}` }); }); @@ -1166,7 +1182,7 @@ app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') .run(c.req.param('sid'), p.id); if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + recordAuditEvent(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); return c.json({ ok: true }); }); @@ -1220,7 +1236,7 @@ app.post('/api/projects/:id/archive', requireAuth, async (c) => { const { archived } = await c.req.json().catch(() => ({})); const flag = archived === false ? 0 : 1; db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); - logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + recordAuditEvent(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); return c.json({ id: p.id, archived: Boolean(flag) }); }); @@ -1239,7 +1255,7 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); metrics.projectsCreated++; - logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + recordAuditEvent(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); return c.json({ id: nid, name: newName, version: 1 }); }); @@ -1257,7 +1273,7 @@ app.post('/api/projects/:id/sprints', requireAuth, async (c) => { const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); - logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + recordAuditEvent(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); return c.json({ id: sid, name: String(name).trim() }); }); @@ -1292,7 +1308,7 @@ app.post('/api/projects/:id/baselines', requireAuth, async (c) => { const { name } = await c.req.json().catch(() => ({})); const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); - logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + recordAuditEvent(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); return c.json({ id: bid, name: name || 'Baseline' }); }); @@ -1332,7 +1348,7 @@ app.delete('/api/projects/:id', requireAuth, (c) => { if (!p) return c.json({ error: 'not found' }, 404); if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); db.prepare('DELETE FROM projects WHERE id = ?').run(id); - logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + recordAuditEvent(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); deliver(p.org_id, 'project.delete', { projectId: Number(id) }); return c.json({ ok: true }); }); @@ -1370,7 +1386,7 @@ app.delete('/api/account', requireAuth, async (c) => { } db.exec('BEGIN'); try { - db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit_events db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..8992ccf0 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -77,17 +77,16 @@ CREATE TABLE IF NOT EXISTS webhook_deliveries ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_wh_deliveries ON webhook_deliveries(webhook_id, id); -CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY, +CREATE TABLE IF NOT EXISTS audit_events ( + audit_event_id INTEGER PRIMARY KEY, org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL, + audit_action TEXT NOT NULL, target_type TEXT, target_id TEXT, - meta TEXT, + audit_metadata_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(org_id, id); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -172,6 +171,80 @@ CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id); CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); `); +function readMainTableColumnNames(tableName) { + return new Set( + db.prepare(`PRAGMA main.table_info('${tableName}')`) + .all() + .map((columnRecord) => columnRecord.name), + ); +} + +function migrateAuditPersistenceNames() { + const legacyAuditTable = db.prepare( + "SELECT type FROM main.sqlite_master WHERE name = 'audit_log'", + ).get(); + if (!legacyAuditTable) return; + if (legacyAuditTable.type !== 'table') { + throw new Error('Audit persistence migration expected audit_log to be a table'); + } + + const legacyAuditColumnNames = readMainTableColumnNames('audit_log'); + const hasLegacyColumnSet = ['id', 'action', 'meta'].every( + (legacyColumnName) => legacyAuditColumnNames.has(legacyColumnName), + ); + const hasSemanticColumnSet = ['audit_event_id', 'audit_action', 'audit_metadata_json'].every( + (semanticColumnName) => legacyAuditColumnNames.has(semanticColumnName), + ); + if (hasLegacyColumnSet === hasSemanticColumnSet) { + throw new Error('Audit persistence migration found an ambiguous legacy column set'); + } + + const existingAuditEventCount = Number(db.prepare( + 'SELECT COUNT(*) AS audit_event_count FROM audit_events', + ).get().audit_event_count); + if (existingAuditEventCount !== 0) { + throw new Error('Audit persistence migration refuses to merge two populated authorities'); + } + + db.exec('BEGIN IMMEDIATE'); + try { + if (hasLegacyColumnSet) { + db.exec(` + INSERT INTO audit_events( + audit_event_id, org_id, user_id, audit_action, target_type, target_id, + audit_metadata_json, created_at + ) + SELECT id, org_id, user_id, action, target_type, target_id, meta, created_at + FROM audit_log + `); + } else { + db.exec(` + INSERT INTO audit_events( + audit_event_id, org_id, user_id, audit_action, target_type, target_id, + audit_metadata_json, created_at + ) + SELECT audit_event_id, org_id, user_id, audit_action, target_type, target_id, + audit_metadata_json, created_at + FROM audit_log + `); + } + db.exec('DROP INDEX IF EXISTS idx_audit_org'); + db.exec('DROP INDEX IF EXISTS audit_log_org_event_idx'); + db.exec('DROP TABLE audit_log'); + db.exec('COMMIT'); + } catch (migrationError) { + try { db.exec('ROLLBACK'); } catch { /* preserve the causal migration failure */ } + throw migrationError; + } +} + +migrateAuditPersistenceNames(); + +db.exec(` +CREATE INDEX IF NOT EXISTS audit_events_org_event_idx + ON audit_events(org_id, audit_event_id); +`); + // Migration for pre-existing DBs: add token_version if missing (idempotent). try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..50819244 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -247,6 +247,17 @@ for (const act of ['project.create', 'project.update', 'member.invite', 'member. } assert.ok(audit.some((e) => e.actorEmail === 'a@b.com'), 'audit resolves actor email'); assert.ok(audit.some((e) => e.meta && typeof e.meta === 'object'), 'audit meta is structured'); +const auditWireEvent = audit[0]; +for (const wireFieldName of ['id', 'action', 'meta']) { + assert.ok(Object.hasOwn(auditWireEvent, wireFieldName), `audit wire contract retains ${wireFieldName}`); +} +for (const internalFieldName of ['audit_event_id', 'audit_action', 'audit_metadata_json']) { + assert.equal( + Object.hasOwn(auditWireEvent, internalFieldName), + false, + `audit wire contract must not leak ${internalFieldName}`, + ); +} // non-member cannot read the audit trail r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'outsider@x.com', password: 'password123' }) }); const oauth = { authorization: `Bearer ${(await r.json()).token}` }; @@ -516,13 +527,14 @@ assert.equal((await req('/api/auth/login', { method: 'POST', body: body({ email: // ---- Audit CSV export ---- // Plant a formula-injection payload with leading whitespace (the historic bypass -// of /^[=+\-@|]/). action is free text in the audit log; the CSV cell guard -// must neutralize it. +// of /^[=+\-@|]/). audit_action is free text in the audit event store; the CSV +// cell guard must neutralize it. { const { db } = await import('../../server/db.mjs'); db.prepare( - `INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) - VALUES(?,?,?,?,?,?)` + `INSERT INTO audit_events( + org_id, user_id, audit_action, target_type, target_id, audit_metadata_json + ) VALUES(?,?,?,?,?,?)` ).run(orgAId, null, ' =cmd|"/c calc"', 'probe', 'csv-inject', null); } r = await req(`/api/orgs/${orgAId}/audit?format=csv`, { headers: auth }); diff --git a/tests/unit/audit-log-database-naming.test.mjs b/tests/unit/audit-log-database-naming.test.mjs new file mode 100644 index 00000000..67a481f6 --- /dev/null +++ b/tests/unit/audit-log-database-naming.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +const REQUIRED_AUDIT_EVENT_COLUMNS = new Set([ + 'audit_event_id', + 'org_id', + 'user_id', + 'audit_action', + 'target_type', + 'target_id', + 'audit_metadata_json', + 'created_at', +]); +const LEGACY_AUDIT_COLUMNS = ['id', 'action', 'meta']; + +function readAuditEventColumnNames(databaseConnection) { + return new Set( + databaseConnection + .prepare("PRAGMA main.table_info('audit_events')") + .all() + .map((columnRecord) => columnRecord.name), + ); +} + +function assertSemanticAuditPersistence(databaseConnection) { + const auditEventColumnNames = readAuditEventColumnNames(databaseConnection); + for (const requiredColumnName of REQUIRED_AUDIT_EVENT_COLUMNS) { + assert.ok( + auditEventColumnNames.has(requiredColumnName), + `audit_events must expose semantic column ${requiredColumnName}`, + ); + } + for (const legacyColumnName of LEGACY_AUDIT_COLUMNS) { + assert.equal( + auditEventColumnNames.has(legacyColumnName), + false, + `audit_events must not retain generic legacy column ${legacyColumnName}`, + ); + } + + const durableAuditLogObject = databaseConnection + .prepare("SELECT type FROM main.sqlite_master WHERE name = 'audit_log'") + .get(); + assert.equal(durableAuditLogObject, undefined, 'legacy audit_log must not remain durable'); + const temporaryAuditLogObject = databaseConnection + .prepare("SELECT type FROM temp.sqlite_temp_master WHERE name = 'audit_log'") + .get(); + assert.equal( + temporaryAuditLogObject, + undefined, + 'legacy audit_log must not remain as a runtime database object', + ); +} + +async function importDatabaseModule(databasePath, importLabel) { + process.env.SCOPEWEAVE_DB = databasePath; + return import(`../../server/db.mjs?audit-semantic-naming=${importLabel}`); +} + +const temporaryDirectory = await mkdtemp(join(tmpdir(), 'scopeweave-audit-naming-')); +try { + const freshDatabasePath = join(temporaryDirectory, 'fresh.db'); + const freshDatabaseModule = await importDatabaseModule(freshDatabasePath, 'fresh'); + assertSemanticAuditPersistence(freshDatabaseModule.db); + + freshDatabaseModule.db.exec(` + INSERT INTO users(id, email, password_hash, name) + VALUES(3, 'audit-fresh@example.com', 'test-hash', 'Audit Fresh'); + INSERT INTO orgs(id, name, owner_id) + VALUES(5, 'Audit Fresh Org', 3); + `); + freshDatabaseModule.db.prepare(` + INSERT INTO audit_events( + org_id, user_id, audit_action, target_type, target_id, audit_metadata_json + ) VALUES(?, ?, ?, ?, ?, ?) + `).run(5, 3, 'project.create', 'project', '7', '{"projectName":"P1"}'); + const durableAuditEvent = freshDatabaseModule.db.prepare(` + SELECT audit_event_id, audit_action, audit_metadata_json + FROM audit_events WHERE org_id = ? + `).get(5); + assert.deepEqual(durableAuditEvent, { + audit_event_id: 1, + audit_action: 'project.create', + audit_metadata_json: '{"projectName":"P1"}', + }); + freshDatabaseModule.db.close(); + + const legacyDatabasePath = join(temporaryDirectory, 'legacy.db'); + const legacyDatabaseConnection = new DatabaseSync(legacyDatabasePath); + legacyDatabaseConnection.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + token_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO users(id, email, password_hash, name) + VALUES(29, 'legacy-actor@example.com', 'test-hash', 'Legacy Actor'); + INSERT INTO orgs(id, name, owner_id) + VALUES(23, 'Legacy Org', 29); + + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + meta TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX idx_audit_org ON audit_log(org_id, id); + INSERT INTO audit_log( + id, org_id, user_id, action, target_type, target_id, meta, created_at + ) VALUES ( + 17, 23, 29, 'project.update', 'project', '31', '{"version":4}', + '2026-09-02T00:00:00Z' + ); + `); + legacyDatabaseConnection.close(); + + const migratedDatabaseModule = await importDatabaseModule(legacyDatabasePath, 'legacy'); + assertSemanticAuditPersistence(migratedDatabaseModule.db); + const migratedAuditEvent = migratedDatabaseModule.db + .prepare(` + SELECT audit_event_id, org_id, user_id, audit_action, target_type, target_id, + audit_metadata_json, created_at + FROM audit_events + `) + .get(); + assert.deepEqual(migratedAuditEvent, { + audit_event_id: 17, + org_id: 23, + user_id: 29, + audit_action: 'project.update', + target_type: 'project', + target_id: '31', + audit_metadata_json: '{"version":4}', + created_at: '2026-09-02T00:00:00Z', + }); + + const auditIndexNames = migratedDatabaseModule.db + .prepare("SELECT name FROM main.sqlite_master WHERE type = 'index' AND tbl_name = 'audit_events'") + .all() + .map((indexRecord) => indexRecord.name); + assert.ok(auditIndexNames.includes('audit_events_org_event_idx')); + assert.equal(auditIndexNames.includes('idx_audit_org'), false); + migratedDatabaseModule.db.close(); + + const reopenedDatabaseModule = await importDatabaseModule(legacyDatabasePath, 'reopened'); + assertSemanticAuditPersistence(reopenedDatabaseModule.db); + assert.equal( + reopenedDatabaseModule.db.prepare('SELECT COUNT(*) AS audit_event_count FROM audit_events').get() + .audit_event_count, + 1, + 'reopening a migrated store must not duplicate audit events', + ); + reopenedDatabaseModule.db.close(); +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }); +} + +console.log('✓ audit-log semantic database naming tests passed');