secure-vibe-runtime 0.0.0-stage → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # secure-vibe-runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The runtime security pieces of [Secure Vibe Coding OS](https://github.com/harperaa/secure-vibe-coding-OS) as one package, so every app built from the template gets security fixes by updating a dependency instead of copying files.
4
+
5
+ | Entry point | What it provides |
6
+ |---|---|
7
+ | `secure-vibe-runtime/secrets` | `ensureSecretsLoaded()`, `revalidateSecrets()`: Doppler runtime fetch with retry and a per-instance cache |
8
+ | `secure-vibe-runtime/csrf` | `generateCsrfToken()`, `validateCsrfToken()`, `generateUUID()`: HMAC tokens bound to a session; refuses to run without `CSRF_SECRET` |
9
+ | `secure-vibe-runtime/withCsrf` | `withCsrf(handler)`: route wrapper that validates the token and logs failures |
10
+ | `secure-vibe-runtime/withRateLimit` | `withRateLimit(handler)`: per-instance IP rate limit that fails closed on an unresolvable IP and logs refusals |
11
+ | `secure-vibe-runtime/convex` | `securityTables` (schema fragment), the validators, `logSecurity()` and the exact-counter helpers, `redactIdentifiers()` |
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install secure-vibe-runtime
17
+ ```
18
+
19
+ Peer dependencies: `next` and `convex`. The template already wires everything below; these notes are for an app that adds the package by hand.
20
+
21
+ ### Next.js side
22
+
23
+ ```ts
24
+ // instrumentation.ts
25
+ export async function register() {
26
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
27
+ const { ensureSecretsLoaded } = await import("secure-vibe-runtime/secrets");
28
+ await ensureSecretsLoaded();
29
+ }
30
+
31
+ // app/api/something/route.ts
32
+ import { withRateLimit } from "secure-vibe-runtime/withRateLimit";
33
+ import { withCsrf } from "secure-vibe-runtime/withCsrf";
34
+ export const POST = withRateLimit(withCsrf(handler));
35
+ ```
36
+
37
+ The wrappers write refusals to the Convex security log by calling the app's `security:logSecurityViolation` mutation with `SECURITY_LOG_KEY`. The app owns that mutation and the admin dashboard; see the template's `convex/security.ts`.
38
+
39
+ ### Convex side
40
+
41
+ ```ts
42
+ // convex/schema.ts
43
+ import { securityTables } from "secure-vibe-runtime/convex";
44
+ export default defineSchema({ users: ..., ...securityTables });
45
+
46
+ // any mutation
47
+ import { logSecurity } from "secure-vibe-runtime/convex";
48
+ await logSecurity(ctx, user?._id, "unauthorized_access", "high", { endpoint: "..." });
49
+ ```
50
+
51
+ Every write to `securityEvents` must go through `insertSecurityEvent`, `deleteSecurityEvent`, or `setSecurityEventRead` so the hourly counters stay exact. An app's `MutationCtx` is assignable to the package's `SecurityMutationCtx` without a cast.
52
+
53
+ ## Versioning
54
+
55
+ The package version tracks the template release it ships with. Breaking changes to the schema fragment or function contracts are called out in the template's release notes, together with the Convex deploy or rebuild step they need.
@@ -0,0 +1,4 @@
1
+ export * from "./securityTables";
2
+ export * from "./securityLogger";
3
+ export * from "./securityCounts";
4
+ export * from "./redact";
@@ -0,0 +1,4 @@
1
+ export * from "./securityTables";
2
+ export * from "./securityLogger";
3
+ export * from "./securityCounts";
4
+ export * from "./redact";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Redacts identifiers from text before it leaves the system.
3
+ *
4
+ * Used by the background-job example (convex/jobs.ts) as the server-side step
5
+ * that runs BEFORE any outside call: an AI model, a document processor, a
6
+ * third-party API. The browser is never trusted to do this. No Convex imports,
7
+ * so it is unit-tested directly (scripts/redact.test.mjs).
8
+ *
9
+ * What it catches, deliberately narrow and documented:
10
+ * - email addresses
11
+ * - US phone numbers (10 digits with common separators, optional +1)
12
+ * - US Social Security numbers (###-##-####)
13
+ * - record identifiers written as "MRN 123456", "patient id: 42", etc.
14
+ * Anything else your data protection rules require (names, dates of birth,
15
+ * free-text clinical detail) needs its own rule here. A redactor is a
16
+ * floor, not a guarantee; keep humans in the loop for anything regulated.
17
+ */
18
+ export declare function redactIdentifiers(text: string): {
19
+ text: string;
20
+ redactions: number;
21
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Redacts identifiers from text before it leaves the system.
3
+ *
4
+ * Used by the background-job example (convex/jobs.ts) as the server-side step
5
+ * that runs BEFORE any outside call: an AI model, a document processor, a
6
+ * third-party API. The browser is never trusted to do this. No Convex imports,
7
+ * so it is unit-tested directly (scripts/redact.test.mjs).
8
+ *
9
+ * What it catches, deliberately narrow and documented:
10
+ * - email addresses
11
+ * - US phone numbers (10 digits with common separators, optional +1)
12
+ * - US Social Security numbers (###-##-####)
13
+ * - record identifiers written as "MRN 123456", "patient id: 42", etc.
14
+ * Anything else your data protection rules require (names, dates of birth,
15
+ * free-text clinical detail) needs its own rule here. A redactor is a
16
+ * floor, not a guarantee; keep humans in the loop for anything regulated.
17
+ */
18
+ const RULES = [
19
+ { label: "EMAIL", pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
20
+ { label: "SSN", pattern: /\b\d{3}-\d{2}-\d{4}\b/g },
21
+ { label: "PHONE", pattern: /(?:\+?1[\s.-]?)?\(?\b\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/g },
22
+ { label: "RECORD_ID", pattern: /\b(?:MRN|patient(?:\s+id)?|record(?:\s+id)?)\s*[:#]?\s*\d{3,}\b/gi },
23
+ ];
24
+ export function redactIdentifiers(text) {
25
+ let redactions = 0;
26
+ let out = text;
27
+ for (const { label, pattern } of RULES) {
28
+ out = out.replace(pattern, () => {
29
+ redactions += 1;
30
+ return `[${label}]`;
31
+ });
32
+ }
33
+ return { text: out, redactions };
34
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure helpers for the security log's exact counters. No Convex imports, so
3
+ * this file is unit-tested directly with node:test (see securityCounts.test.ts).
4
+ *
5
+ * Why counters exist: the dashboard's summary tiles used to be computed from
6
+ * the newest SCAN_CEILING rows in the window, so "Total events" was a lower
7
+ * bound under load, not a count. Reading the whole table to count it is the
8
+ * failure mode the bound was added to avoid. Instead every insert, delete and
9
+ * read-state change updates one small bucket document per UTC hour, keyed by
10
+ * "eventType:severity". A 90-day window is then at most ~2,200 small reads,
11
+ * independent of how many events were logged.
12
+ */
13
+ export declare const HOUR_MS: number;
14
+ /** Start of the UTC hour that contains `timestamp`, in ms. */
15
+ export declare function hourStart(timestamp: number): number;
16
+ export declare function countKey(eventType: string, severity: string): string;
17
+ export declare function parseKey(key: string): {
18
+ eventType: string;
19
+ severity: string;
20
+ };
21
+ /** Add `delta` to `record[key]`, dropping the key when it reaches zero. */
22
+ export declare function bump(record: Record<string, number>, key: string, delta: number): Record<string, number>;
23
+ export type CountBucket = {
24
+ hour: number;
25
+ counts: Record<string, number>;
26
+ unread: Record<string, number>;
27
+ };
28
+ export type SecuritySummary = {
29
+ totalEvents: number;
30
+ unreadCount: number;
31
+ criticalCount: number;
32
+ highCount: number;
33
+ mediumCount: number;
34
+ lowCount: number;
35
+ eventTypeBreakdown: Record<string, number>;
36
+ };
37
+ /** Fold hourly buckets into the shape the dashboard tiles consume. */
38
+ export declare function summarize(buckets: CountBucket[], eventType?: string): SecuritySummary;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pure helpers for the security log's exact counters. No Convex imports, so
3
+ * this file is unit-tested directly with node:test (see securityCounts.test.ts).
4
+ *
5
+ * Why counters exist: the dashboard's summary tiles used to be computed from
6
+ * the newest SCAN_CEILING rows in the window, so "Total events" was a lower
7
+ * bound under load, not a count. Reading the whole table to count it is the
8
+ * failure mode the bound was added to avoid. Instead every insert, delete and
9
+ * read-state change updates one small bucket document per UTC hour, keyed by
10
+ * "eventType:severity". A 90-day window is then at most ~2,200 small reads,
11
+ * independent of how many events were logged.
12
+ */
13
+ export const HOUR_MS = 60 * 60 * 1000;
14
+ /** Start of the UTC hour that contains `timestamp`, in ms. */
15
+ export function hourStart(timestamp) {
16
+ return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
17
+ }
18
+ export function countKey(eventType, severity) {
19
+ return `${eventType}:${severity}`;
20
+ }
21
+ export function parseKey(key) {
22
+ const i = key.lastIndexOf(":");
23
+ return { eventType: key.slice(0, i), severity: key.slice(i + 1) };
24
+ }
25
+ /** Add `delta` to `record[key]`, dropping the key when it reaches zero. */
26
+ export function bump(record, key, delta) {
27
+ const next = { ...record };
28
+ const value = (next[key] ?? 0) + delta;
29
+ if (value <= 0)
30
+ delete next[key];
31
+ else
32
+ next[key] = value;
33
+ return next;
34
+ }
35
+ /** Fold hourly buckets into the shape the dashboard tiles consume. */
36
+ export function summarize(buckets, eventType) {
37
+ const summary = {
38
+ totalEvents: 0,
39
+ unreadCount: 0,
40
+ criticalCount: 0,
41
+ highCount: 0,
42
+ mediumCount: 0,
43
+ lowCount: 0,
44
+ eventTypeBreakdown: {},
45
+ };
46
+ for (const bucket of buckets) {
47
+ for (const [key, n] of Object.entries(bucket.counts)) {
48
+ const { eventType: type, severity } = parseKey(key);
49
+ if (eventType !== undefined && type !== eventType)
50
+ continue;
51
+ summary.totalEvents += n;
52
+ summary.eventTypeBreakdown[type] = (summary.eventTypeBreakdown[type] ?? 0) + n;
53
+ if (severity === "critical")
54
+ summary.criticalCount += n;
55
+ else if (severity === "high")
56
+ summary.highCount += n;
57
+ else if (severity === "medium")
58
+ summary.mediumCount += n;
59
+ else if (severity === "low")
60
+ summary.lowCount += n;
61
+ }
62
+ for (const [key, n] of Object.entries(bucket.unread)) {
63
+ if (eventType !== undefined && parseKey(key).eventType !== eventType)
64
+ continue;
65
+ summary.unreadCount += n;
66
+ }
67
+ }
68
+ return summary;
69
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Security Logger
3
+ *
4
+ * Unified helper for logging ALL security events.
5
+ * Used within mutations that return results instead of throwing.
6
+ * Direct DB insert ensures events persist (no scheduler, no separate transaction).
7
+ */
8
+ import { GenericId } from "convex/values";
9
+ import { SecurityEventDoc as SecurityEventRow, SecurityEventType, SecurityMetadata, SecurityMutationCtx as MutationCtx, SecuritySeverity } from "./securityTables";
10
+ export type { SecurityEventType, SecurityMetadata, SecuritySeverity } from "./securityTables";
11
+ type Id<T extends string> = GenericId<T>;
12
+ type Doc<T extends "securityEvents"> = T extends "securityEvents" ? SecurityEventRow : never;
13
+ /**
14
+ * All security event types that can be logged
15
+ */
16
+ /**
17
+ * Severity levels for security events
18
+ */
19
+ /**
20
+ * Metadata for security events
21
+ */
22
+ /**
23
+ * Log a security event directly to the database.
24
+ *
25
+ * This function is designed to be called from mutations that return
26
+ * result objects instead of throwing. Since the mutation won't throw
27
+ * after logging, the event will persist.
28
+ *
29
+ * @param ctx - Mutation context
30
+ * @param userId - User ID who owns this security event (optional for anonymous violations)
31
+ * @param eventType - Type of security event
32
+ * @param severity - Severity level of the event
33
+ * @param metadata - Additional event metadata
34
+ * @returns The ID of the created security event
35
+ */
36
+ export declare function logSecurity(ctx: MutationCtx, userId: Id<"users"> | undefined, eventType: SecurityEventType, severity: SecuritySeverity, metadata: SecurityMetadata): Promise<Id<"securityEvents">>;
37
+ type SecurityEventDoc = Omit<Doc<"securityEvents">, "_id" | "_creationTime">;
38
+ /** The in-progress rebuild marker, or null. One tiny read. */
39
+ export declare function activeRebuild(ctx: MutationCtx): Promise<{
40
+ _id: GenericId<"securityCountsRebuild">;
41
+ _creationTime: number;
42
+ runId: string;
43
+ upTo: number;
44
+ startedAt: number;
45
+ } | null>;
46
+ /**
47
+ * Adds `delta` to the hourly bucket for (timestamp, eventType, severity).
48
+ * `unreadDelta` moves the unread counter independently (read-state changes
49
+ * pass count 0). Empty buckets are deleted so retention leaves nothing behind.
50
+ *
51
+ * While a rebuild is running, a delete or read-state change on a row the
52
+ * rebuild has not scanned yet would adjust a counter that does not include
53
+ * that row, and the rebuild would then count the row in its new state: the
54
+ * result is off by one forever. So those adjustments (delta <= 0) on rows at
55
+ * or before the rebuild's upTo are refused until the rebuild finishes. Inserts
56
+ * are unaffected: new rows have timestamps after upTo. The rebuild's own
57
+ * +1 adjustments pass because delta > 0. Callers surface the error; the
58
+ * retention cron checks first and skips the run instead.
59
+ */
60
+ export declare function adjustSecurityCounts(ctx: MutationCtx, event: {
61
+ timestamp: number;
62
+ eventType: string;
63
+ severity: string;
64
+ }, delta: number, unreadDelta: number): Promise<void>;
65
+ export declare function insertSecurityEvent(ctx: MutationCtx, event: SecurityEventDoc): Promise<Id<"securityEvents">>;
66
+ export declare function deleteSecurityEvent(ctx: MutationCtx, event: Doc<"securityEvents">): Promise<void>;
67
+ export declare function setSecurityEventRead(ctx: MutationCtx, event: Doc<"securityEvents">, isRead: boolean): Promise<void>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Security Logger
3
+ *
4
+ * Unified helper for logging ALL security events.
5
+ * Used within mutations that return results instead of throwing.
6
+ * Direct DB insert ensures events persist (no scheduler, no separate transaction).
7
+ */
8
+ import { bump, countKey, hourStart } from "./securityCounts";
9
+ /**
10
+ * SECURITY: Sanitize email for logging - preserves domain but masks local part
11
+ * Example: john.doe@example.com -> j***e@example.com
12
+ */
13
+ function sanitizeEmailForLog(email) {
14
+ if (!email)
15
+ return undefined;
16
+ const atIndex = email.indexOf('@');
17
+ if (atIndex <= 0)
18
+ return '***@invalid';
19
+ const localPart = email.substring(0, atIndex);
20
+ const domain = email.substring(atIndex);
21
+ // For very short local parts, just mask completely
22
+ if (localPart.length <= 2) {
23
+ return `***${domain}`;
24
+ }
25
+ // Keep first and last character, mask the rest
26
+ return `${localPart[0]}***${localPart[localPart.length - 1]}${domain}`;
27
+ }
28
+ /**
29
+ * SECURITY: Sanitize name for logging - keeps first character and length indication
30
+ * Example: John Doe -> J*** (8 chars)
31
+ */
32
+ function sanitizeNameForLog(name) {
33
+ if (!name)
34
+ return undefined;
35
+ const trimmed = name.trim();
36
+ if (trimmed.length === 0)
37
+ return undefined;
38
+ // Keep first character and indicate length
39
+ return `${trimmed[0]}*** (${trimmed.length} chars)`;
40
+ }
41
+ /**
42
+ * SECURITY: Sanitize user ID for logging - partial redaction
43
+ * Example: usr_123456789 -> usr_1234***
44
+ */
45
+ function sanitizeIdForLog(id) {
46
+ if (!id)
47
+ return undefined;
48
+ if (id.length <= 8) {
49
+ return `${id.substring(0, 2)}***`;
50
+ }
51
+ // Keep first 8 characters, mask the rest
52
+ return `${id.substring(0, 8)}***`;
53
+ }
54
+ /**
55
+ * Maximum stored length for caller-supplied free-text log fields.
56
+ *
57
+ * 2 KB is far more than any genuine errorMessage or requestPayload excerpt needs
58
+ * for triage, and small enough that even sustained abuse grows the table slowly
59
+ * relative to Convex's per-transaction read ceiling.
60
+ */
61
+ const MAX_LOG_FIELD_CHARS = 2000;
62
+ /**
63
+ * Cap for every OTHER caller-supplied string (origin, endpoint, IP,
64
+ * fingerprint, action type, and the PII fields before masking). None of these
65
+ * has a legitimate value anywhere near this long; an origin or endpoint over
66
+ * 256 chars is itself evidence of abuse.
67
+ *
68
+ * Worst-case row size is what the dashboard's SCAN_CEILING and the retention
69
+ * cron's PRUNE_BATCH in convex/security.ts are derived from: two 2,000-char
70
+ * fields plus eight 256-char fields is under ~6 KB per row, so 1,000 rows
71
+ * stay under Convex's 8 MiB per-transaction read budget. Change these caps
72
+ * and those batch sizes together.
73
+ */
74
+ const MAX_LOG_SHORT_FIELD_CHARS = 256;
75
+ /**
76
+ * Caps a caller-supplied string so one caller cannot write unbounded rows.
77
+ *
78
+ * This exists because logSecurityViolation records violations before a session
79
+ * exists, gated only by a shared server-side key. If that key leaks, and
80
+ * without a cap, the audit table is an unbounded write primitive for anyone who
81
+ * knows the Convex deployment URL — and the failure mode is self-concealing, because the first
82
+ * thing that breaks is the dashboard used to notice it. A row of uncapped
83
+ * fields can approach Convex's 1 MiB document limit, and a handful of those
84
+ * exhausts the read budget of any query or cleanup that touches them.
85
+ *
86
+ * Truncation is marked so a reader can tell a clipped value from a short one.
87
+ */
88
+ function capForLog(value, max = MAX_LOG_FIELD_CHARS) {
89
+ if (!value)
90
+ return undefined;
91
+ if (value.length <= max)
92
+ return value;
93
+ return `${value.substring(0, max)}…[truncated ${value.length - max} chars]`;
94
+ }
95
+ const capShort = (value) => capForLog(value, MAX_LOG_SHORT_FIELD_CHARS);
96
+ /**
97
+ * All security event types that can be logged
98
+ */
99
+ /**
100
+ * Severity levels for security events
101
+ */
102
+ /**
103
+ * Metadata for security events
104
+ */
105
+ /**
106
+ * Log a security event directly to the database.
107
+ *
108
+ * This function is designed to be called from mutations that return
109
+ * result objects instead of throwing. Since the mutation won't throw
110
+ * after logging, the event will persist.
111
+ *
112
+ * @param ctx - Mutation context
113
+ * @param userId - User ID who owns this security event (optional for anonymous violations)
114
+ * @param eventType - Type of security event
115
+ * @param severity - Severity level of the event
116
+ * @param metadata - Additional event metadata
117
+ * @returns The ID of the created security event
118
+ */
119
+ export async function logSecurity(ctx, userId, eventType, severity, metadata) {
120
+ // SECURITY: Sanitize PII before logging
121
+ // Preserves enough info for security analysis while protecting user privacy
122
+ // Every field is size-capped because logSecurityViolation is gated only by a
123
+ // shared key. If it leaks and without caps, one caller can write arbitrarily large rows
124
+ // and grow the securityEvents table until the dashboard's reads (and the
125
+ // retention cron's deletes) exceed Convex's per-transaction limit —
126
+ // silencing the monitoring it is meant to feed. Truncate rather than
127
+ // reject: a clipped event is still evidence. The two free-text fields get
128
+ // the long cap; everything else the short one.
129
+ const sanitizedMetadata = {
130
+ endpoint: capShort(metadata.endpoint),
131
+ origin: capShort(metadata.origin),
132
+ ipAddress: capShort(metadata.ipAddress), // IPs are needed for security analysis
133
+ fingerprint: capShort(metadata.fingerprint), // Fingerprints are anonymized identifiers
134
+ errorMessage: capForLog(metadata.errorMessage),
135
+ // PII fields are capped first (a masked email still keeps its domain), then sanitized
136
+ endUserEmail: sanitizeEmailForLog(capShort(metadata.endUserEmail)),
137
+ endUserName: sanitizeNameForLog(capShort(metadata.endUserName)),
138
+ endUserId: sanitizeIdForLog(capShort(metadata.endUserId)),
139
+ actionType: capShort(metadata.actionType),
140
+ requestPayload: capForLog(metadata.requestPayload),
141
+ };
142
+ // Direct insert - no scheduler, no separate transaction
143
+ const eventId = await insertSecurityEvent(ctx, {
144
+ userId,
145
+ eventType,
146
+ severity,
147
+ metadata: sanitizedMetadata,
148
+ timestamp: Date.now(),
149
+ isRead: false,
150
+ });
151
+ // Also log to console for debugging (using sanitized metadata)
152
+ console.log(`[SECURITY EVENT] ${severity.toUpperCase()}: ${eventType} | ` +
153
+ `user=${userId} endpoint=${sanitizedMetadata.endpoint} ` +
154
+ (sanitizedMetadata.errorMessage ? `error="${sanitizedMetadata.errorMessage}"` : ""));
155
+ return eventId;
156
+ }
157
+ /** The in-progress rebuild marker, or null. One tiny read. */
158
+ export async function activeRebuild(ctx) {
159
+ return await ctx.db.query("securityCountsRebuild").first();
160
+ }
161
+ /**
162
+ * Adds `delta` to the hourly bucket for (timestamp, eventType, severity).
163
+ * `unreadDelta` moves the unread counter independently (read-state changes
164
+ * pass count 0). Empty buckets are deleted so retention leaves nothing behind.
165
+ *
166
+ * While a rebuild is running, a delete or read-state change on a row the
167
+ * rebuild has not scanned yet would adjust a counter that does not include
168
+ * that row, and the rebuild would then count the row in its new state: the
169
+ * result is off by one forever. So those adjustments (delta <= 0) on rows at
170
+ * or before the rebuild's upTo are refused until the rebuild finishes. Inserts
171
+ * are unaffected: new rows have timestamps after upTo. The rebuild's own
172
+ * +1 adjustments pass because delta > 0. Callers surface the error; the
173
+ * retention cron checks first and skips the run instead.
174
+ */
175
+ export async function adjustSecurityCounts(ctx, event, delta, unreadDelta) {
176
+ if (delta <= 0) {
177
+ const rebuild = await activeRebuild(ctx);
178
+ if (rebuild && event.timestamp <= rebuild.upTo) {
179
+ throw new Error("Security log counters are being rebuilt; retry this change in a moment.");
180
+ }
181
+ }
182
+ const hour = hourStart(event.timestamp);
183
+ const key = countKey(event.eventType, event.severity);
184
+ const bucket = await ctx.db
185
+ .query("securityEventCounts")
186
+ .withIndex("byHour", (q) => q.eq("hour", hour))
187
+ .unique();
188
+ const counts = bump(bucket?.counts ?? {}, key, delta);
189
+ const unread = bump(bucket?.unread ?? {}, key, unreadDelta);
190
+ if (bucket) {
191
+ if (Object.keys(counts).length === 0)
192
+ await ctx.db.delete(bucket._id);
193
+ else
194
+ await ctx.db.patch(bucket._id, { counts, unread });
195
+ }
196
+ else if (Object.keys(counts).length > 0) {
197
+ await ctx.db.insert("securityEventCounts", { hour, counts, unread });
198
+ }
199
+ }
200
+ export async function insertSecurityEvent(ctx, event) {
201
+ const id = await ctx.db.insert("securityEvents", event);
202
+ await adjustSecurityCounts(ctx, event, 1, event.isRead ? 0 : 1);
203
+ return id;
204
+ }
205
+ export async function deleteSecurityEvent(ctx, event) {
206
+ await ctx.db.delete(event._id);
207
+ await adjustSecurityCounts(ctx, event, -1, event.isRead ? 0 : -1);
208
+ }
209
+ export async function setSecurityEventRead(ctx, event, isRead) {
210
+ if (event.isRead === isRead)
211
+ return; // idempotent: no double-counting on repeat clicks
212
+ await ctx.db.patch(event._id, { isRead });
213
+ await adjustSecurityCounts(ctx, event, 0, isRead ? -1 : 1);
214
+ }