Module: database/repositories/activityRepo

Activity log CRUD backed by SQLite.

Source:

Methods

(static) clearByWorkspaceId(workspaceId) → {number}

Delete all activities in a workspace.

Parameters:
Name Type Description
workspaceId string
Source:
Returns:

Number of deleted rows.

Type
number

(static) computePrevHash(previousHash, row) → {string}

SEC-007: Compute the next prevHash from the previous row's hash and the current row's content. Exported for tests and re-used by verifyAuditChain so a single implementation is the source of truth (any drift between INSERT and verify breaks the chain immediately rather than silently).

prevHash_i = sha256(prevHash_{i-1} ++ JSON.stringify(rowMinusHash_i))

The leading hash for the very first row is the empty string.

Parameters:
Name Type Description
previousHash string | null
row Object
Source:
Returns:

64-char lowercase hex digest.

Type
string

(static) countDistinctTestIds(filters) → {number}

Count DISTINCT testId across activity rows matching the filter (AUTO-003b).

Used by the approval-stats 7-day revert-rate calculation, which asks "how many distinct tests were auto-approved in the window?" and "how many distinct tests were revoked in the window?" — set sizes, not row counts, because a test that auto-approved twice in the window should still count as one.

Previously computed by fetching up to 10,000 rows via getFiltered and building two Sets in JS; at ~1 KB per row that's ~10 MB of transferred data per project per call. This query returns a single integer, and the activities(type, projectId, createdAt) access pattern is index-friendly on both adapters.

The metaIsAutoApproved filter matches the JSON-encoded flag meta.wasAutoApproved = true via a portable LIKE on the serialised meta TEXT column (migration 018). LIKE is case-sensitive on SQLite and case-insensitive on PostgreSQL (the adapter rewrites LIKE→ILIKE) — fine here because logActivity always writes the lowercase "wasAutoApproved":true shape, so case-variation isn't possible on real data. Using LIKE instead of json_extract keeps the query portable across the SQLite/PostgreSQL adapters without a dialect branch (INF-001).

Parameters:
Name Type Description
filters Object
Properties
Name Type Attributes Description
type string

— required, exact match on activities.type.

projectId string <optional>

— scope to project.

workspaceId string <optional>

— scope to workspace (ACL).

after string <optional>

— ISO timestamp lower bound (inclusive).

before string <optional>

— ISO timestamp upper bound (exclusive).

metaIsAutoApproved boolean <optional>

— match rows whose meta column encodes { ..., "wasAutoApproved": true }. Used to filter revoke rows down to "was the revoked test originally auto-approved?" without reading 10k rows into memory.

Source:
Returns:

Count of distinct non-null testId values among matching rows.

Type
number

(static) countFiltered(filtersopt) → {number}

Count activities with optional workspace/project scope.

Parameters:
Name Type Attributes Description
filters Object <optional>
Properties
Name Type Attributes Description
workspaceId string <optional>
projectId string <optional>
Source:
Returns:
Type
number

(static) create(activity)

Create an activity entry.

Dedup (SEC-007)

For high-volume read-shaped events (audit.read, audit.export, auth.login.failed), consecutive identical events within the dedup window collapse into a single row with count++ and lastAt = now. Matches Splunk / CloudTrail / Auth0 industry practice — every event is still counted (anti-exfiltration signals intact) but the feed isn't dominated by duplicate noise. PCI-DSS 10.5.3 permits this provided attribution is preserved. Window is AUDIT_DEDUP_WINDOW_SEC (default 60, 0 disables).

Dedup is automatically DISABLED when AUDIT_HASH_CHAIN=true — mutating lastAt/count on a persisted row would invalidate its prevHash and break the verification walk. With chain on, every event is its own row (the chain is the higher-priority compliance signal).

When AUDIT_HASH_CHAIN=true, computes prevHash from the last row in the same workspace and stores it in the same transaction as the INSERT. Otherwise prevHash is persisted as null (chain disabled — default).

Parameters:
Name Type Description
activity Object

— { id, type, projectId, projectName, testId, testName, detail, status, createdAt, userId?, userName?, workspaceId?, meta?, ipAddress?, userAgent?, prevHash? }

Source:

(static) deleteByProjectId(projectId) → {number}

Delete all activities for a project.

Parameters:
Name Type Description
projectId string
Source:
Returns:

Number of deleted rows.

Type
number

(static) getAll() → {Array.<Object>}

Get all activities.

Source:
Returns:
Type
Array.<Object>

(static) getAllAsDict() → {Object.<string, Object>}

Get all activities as a dictionary keyed by ID.

Source:
Returns:
Type
Object.<string, Object>

(static) getByTypes(types, optsopt) → {Array.<Object>}

Get activities filtered by type for dashboard analytics. Only returns type, status, createdAt — skips detail, names, etc.

Parameters:
Name Type Attributes Description
types Array.<string>

— Activity types to include.

opts Object <optional>
Properties
Name Type Attributes Description
workspaceId string <optional>

— Optional workspace scope.

Source:
Returns:
Type
Array.<Object>

(static) getFiltered(filtersopt) → {Array.<Object>}

Get filtered activities.

after / before accept ISO-8601 strings (matching the column's storage format). The comparison is lexicographic on ISO strings, which is correct for the YYYY-MM-DDTHH:MM:SS.sssZ shape Date.toISOString() produces.

offset pairs with limit for cursor-style "Load more" — clients pass the count of rows they've already rendered. Combined with the default ORDER BY createdAt DESC, this gives a stable forward window even when new rows arrive between fetches (the ordering key is the row's own timestamp, so a new row only shifts the cursor on the first page, not subsequent pages).

Parameters:
Name Type Attributes Description
filters Object <optional>
Properties
Name Type Attributes Default Description
type string <optional>
projectId string <optional>
workspaceId string <optional>

— Scope to workspace (ACL-001).

after string <optional>

— ISO timestamp; only rows with createdAt >= after are returned. Powers the AUTO-003b approvals timeline date-range picker (This week / Last 30 days / Custom).

before string <optional>

— ISO timestamp; only rows with createdAt < before are returned. Pairs with after for bounded ranges; either bound is optional.

limit number <optional>
200
offset number <optional>

— Skip the first N rows of the result set; used by paginated UIs (Load more) to fetch the next page.

Source:
Returns:
Type
Array.<Object>

(static) getWorkspaceAuditLog(workspaceId, filtersopt) → {Object}

SEC-007: workspace-scoped audit-log fetch with cursor pagination (ENH-010).

Cursor pagination — vs. LIMIT/OFFSET — is stable under concurrent writes: a new row arriving between two pages cannot shift the window because the cursor is the row's own createdAt, not its ordinal position. The cursor is opaque to the caller (an ISO string), so the API surface can later change to a composite cursor without a breaking change.

The ordering tuple is (createdAt DESC, id DESC) — matches the INSERT- side tiebreaker in create() so sub-millisecond bursts stay deterministic.

Filters are AND-combined and all parameterised. types is an array; an empty array means "no type filter".

Parameters:
Name Type Attributes Description
workspaceId string

— Required. The authenticated workspace; the route handler MUST pass req.workspaceId, not a user-supplied URL param, so cross-workspace reads are impossible.

filters Object <optional>
Properties
Name Type Attributes Default Description
userId string <optional>
types Array.<string> <optional>
dateFrom string <optional>

— ISO 8601 lower bound (inclusive).

dateTo string <optional>

— ISO 8601 upper bound (inclusive).

ipAddress string <optional>
cursor string <optional>

— Opaque cursor from the previous page's nextCursor. Internally the last row's createdAt. Pass undefined (or omit) for the first page.

limit number <optional>
200

— Hard-capped at 1000 by the caller to bound memory.

Source:
Returns:

Rows in newest- first order; nextCursor is the timestamp to pass to the next call, or null when there are no more rows.

Type
Object

(static) purgeOlderThan(days) → {number}

SEC-007: retention sweep — delete activity rows older than days days.

Called from the scheduler's daily retention task. The cutoff is computed in JS (ISO 8601 UTC) and passed as a parameterised string so the comparison stays lexicographic — matching the column's storage format and the existing after / before filters in getFiltered.

Caller is responsible for the SOC-2 floor (≥ 90 days or 0 to disable); this function simply does what it's told. Boot-time validation in backend/src/index.js rejects AUDIT_RETENTION_DAYS values below 90.

Parameters:
Name Type Description
days number

— Retention window in days. Must be > 0; rows with createdAt < (now - days) are deleted. Callers that want to disable the sweep entirely should not invoke this function at all (the scheduler honours AUDIT_RETENTION_DAYS=0 by skipping).

Source:
Returns:

Number of deleted rows.

Type
number

(static) verifyAuditChain(workspaceId) → {Object}

SEC-007: Walk the audit-log chain for a workspace in chronological order and verify each row's prevHash matches the recomputed sha256(prev.prevHash + JSON.stringify(rowMinusHash(row))).

Uses the shared computePrevHash helper so any drift between the INSERT path and the verification path is impossible — both call the same function.

Returns { verified: true, total, chainStartedAt } on a clean walk, or { verified: false, firstBrokenRowId, total } at the first mismatch. An empty workspace is trivially verified.

Pre-chain rows (prevHash IS NULL)

When AUDIT_HASH_CHAIN=true is enabled on a deployment with pre-existing activity rows, those legacy rows have prevHash = null and were never hash-linked. Reporting a tamper break at the first such row would be a false positive — the rows aren't tampered, they predate the feature. So the verifier skips the leading prevHash IS NULL rows and begins the walk at the first row with a non-null prevHash. chainStartedAt surfaces the createdAt of that first chained row so operators can see how much history is outside the cryptographic coverage.

Caller is expected to gate this behind AUDIT_HASH_CHAIN=true — when the chain is disabled, every row has prevHash = null and the walk would skip everything (returning { verified: true, total, chainStartedAt: null }). The route handler in backend/src/routes/system.js short-circuits to { chainDisabled: true } in that case to avoid the empty-walk ambiguity.

Parameters:
Name Type Description
workspaceId string
Source:
Returns:

{ verified: boolean, firstBrokenRowId?: string, total: number, chainStartedAt?: string|null }

Type
Object

(inner) hydrate(row) → {Object}

Re-parse the JSON meta column into a plain object for callers. Tolerant of legacy rows where the column is null/empty/non-JSON — those rows predate migration 018 and surface as meta: null.

Parameters:
Name Type Description
row Object
Source:
Returns:
Type
Object