@digimuza/system-core 0.2.0-rc.2 → 0.3.0-rc.1

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.
Files changed (151) hide show
  1. package/INTEGRATION.md +209 -3
  2. package/README.md +23 -0
  3. package/dist/ai/api.d.ts +120 -0
  4. package/dist/ai/api.d.ts.map +1 -0
  5. package/dist/ai/api.js +446 -0
  6. package/dist/ai/api.js.map +1 -0
  7. package/dist/ai/card.d.ts +27 -0
  8. package/dist/ai/card.d.ts.map +1 -0
  9. package/dist/ai/card.js +84 -0
  10. package/dist/ai/card.js.map +1 -0
  11. package/dist/ai/contract.d.ts +364 -0
  12. package/dist/ai/contract.d.ts.map +1 -0
  13. package/dist/ai/contract.js +101 -0
  14. package/dist/ai/contract.js.map +1 -0
  15. package/dist/ai/display.d.ts +20 -0
  16. package/dist/ai/display.d.ts.map +1 -0
  17. package/dist/ai/display.js +35 -0
  18. package/dist/ai/display.js.map +1 -0
  19. package/dist/ai/index.d.ts +7 -0
  20. package/dist/ai/index.d.ts.map +1 -0
  21. package/dist/ai/index.js +10 -0
  22. package/dist/ai/index.js.map +1 -0
  23. package/dist/ai/manifest.d.ts +25 -0
  24. package/dist/ai/manifest.d.ts.map +1 -0
  25. package/dist/ai/manifest.js +15 -0
  26. package/dist/ai/manifest.js.map +1 -0
  27. package/dist/ai/runner.d.ts +71 -0
  28. package/dist/ai/runner.d.ts.map +1 -0
  29. package/dist/ai/runner.js +246 -0
  30. package/dist/ai/runner.js.map +1 -0
  31. package/dist/ai/tools.d.ts +42 -0
  32. package/dist/ai/tools.d.ts.map +1 -0
  33. package/dist/ai/tools.js +117 -0
  34. package/dist/ai/tools.js.map +1 -0
  35. package/dist/audit/csv.d.ts +31 -0
  36. package/dist/audit/csv.d.ts.map +1 -0
  37. package/dist/audit/csv.js +39 -0
  38. package/dist/audit/csv.js.map +1 -0
  39. package/dist/audit/index.d.ts +5 -0
  40. package/dist/audit/index.d.ts.map +1 -0
  41. package/dist/audit/index.js +6 -0
  42. package/dist/audit/index.js.map +1 -0
  43. package/dist/convex/functions.d.ts +39 -0
  44. package/dist/convex/functions.d.ts.map +1 -1
  45. package/dist/convex/functions.js +38 -6
  46. package/dist/convex/functions.js.map +1 -1
  47. package/dist/convex/index.d.ts +1 -1
  48. package/dist/convex/index.d.ts.map +1 -1
  49. package/dist/convex/index.js.map +1 -1
  50. package/dist/core/access.d.ts +14 -0
  51. package/dist/core/access.d.ts.map +1 -1
  52. package/dist/core/access.js +62 -0
  53. package/dist/core/access.js.map +1 -1
  54. package/dist/core/audit-module.d.ts +5 -0
  55. package/dist/core/audit-module.d.ts.map +1 -0
  56. package/dist/core/audit-module.js +25 -0
  57. package/dist/core/audit-module.js.map +1 -0
  58. package/dist/core/audit.d.ts +31 -0
  59. package/dist/core/audit.d.ts.map +1 -1
  60. package/dist/core/audit.js +46 -2
  61. package/dist/core/audit.js.map +1 -1
  62. package/dist/core/catalog.d.ts +41 -0
  63. package/dist/core/catalog.d.ts.map +1 -1
  64. package/dist/core/catalog.js +45 -0
  65. package/dist/core/catalog.js.map +1 -1
  66. package/dist/core/context.d.ts +8 -2
  67. package/dist/core/context.d.ts.map +1 -1
  68. package/dist/core/context.js +4 -1
  69. package/dist/core/context.js.map +1 -1
  70. package/dist/core/index.d.ts +2 -0
  71. package/dist/core/index.d.ts.map +1 -1
  72. package/dist/core/index.js +2 -0
  73. package/dist/core/index.js.map +1 -1
  74. package/dist/core/notify.d.ts +37 -0
  75. package/dist/core/notify.d.ts.map +1 -0
  76. package/dist/core/notify.js +16 -0
  77. package/dist/core/notify.js.map +1 -0
  78. package/dist/core/roles.d.ts.map +1 -1
  79. package/dist/core/roles.js +5 -1
  80. package/dist/core/roles.js.map +1 -1
  81. package/dist/core/system-modules.d.ts +4 -0
  82. package/dist/core/system-modules.d.ts.map +1 -1
  83. package/dist/core/system-modules.js +11 -0
  84. package/dist/core/system-modules.js.map +1 -1
  85. package/dist/finance/ai-tools.d.ts +55 -0
  86. package/dist/finance/ai-tools.d.ts.map +1 -0
  87. package/dist/finance/ai-tools.js +65 -0
  88. package/dist/finance/ai-tools.js.map +1 -0
  89. package/dist/finance/index.d.ts +87 -1
  90. package/dist/finance/index.d.ts.map +1 -1
  91. package/dist/finance/index.js +92 -38
  92. package/dist/finance/index.js.map +1 -1
  93. package/dist/finance/search.d.ts +15 -0
  94. package/dist/finance/search.d.ts.map +1 -0
  95. package/dist/finance/search.js +51 -0
  96. package/dist/finance/search.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/modules/index.d.ts +1 -1
  102. package/dist/modules/index.d.ts.map +1 -1
  103. package/dist/modules/index.js +1 -1
  104. package/dist/modules/index.js.map +1 -1
  105. package/dist/notifications/api.d.ts +130 -0
  106. package/dist/notifications/api.d.ts.map +1 -0
  107. package/dist/notifications/api.js +384 -0
  108. package/dist/notifications/api.js.map +1 -0
  109. package/dist/notifications/contract.d.ts +132 -0
  110. package/dist/notifications/contract.d.ts.map +1 -0
  111. package/dist/notifications/contract.js +59 -0
  112. package/dist/notifications/contract.js.map +1 -0
  113. package/dist/notifications/index.d.ts +6 -0
  114. package/dist/notifications/index.d.ts.map +1 -0
  115. package/dist/notifications/index.js +9 -0
  116. package/dist/notifications/index.js.map +1 -0
  117. package/dist/search/index.d.ts +95 -0
  118. package/dist/search/index.d.ts.map +1 -0
  119. package/dist/search/index.js +151 -0
  120. package/dist/search/index.js.map +1 -0
  121. package/package.json +41 -2
  122. package/src/ai/api.ts +535 -0
  123. package/src/ai/card.ts +91 -0
  124. package/src/ai/contract.ts +245 -0
  125. package/src/ai/display.ts +38 -0
  126. package/src/ai/index.ts +9 -0
  127. package/src/ai/manifest.ts +16 -0
  128. package/src/ai/runner.ts +275 -0
  129. package/src/ai/tools.ts +128 -0
  130. package/src/audit/csv.ts +60 -0
  131. package/src/audit/index.ts +7 -0
  132. package/src/convex/functions.ts +54 -7
  133. package/src/convex/index.ts +1 -1
  134. package/src/core/access.ts +55 -1
  135. package/src/core/audit-module.ts +26 -0
  136. package/src/core/audit.ts +68 -2
  137. package/src/core/catalog.ts +63 -0
  138. package/src/core/context.ts +13 -2
  139. package/src/core/index.ts +2 -0
  140. package/src/core/notify.ts +52 -0
  141. package/src/core/roles.ts +5 -1
  142. package/src/core/system-modules.ts +12 -0
  143. package/src/finance/ai-tools.ts +68 -0
  144. package/src/finance/index.ts +119 -42
  145. package/src/finance/search.ts +58 -0
  146. package/src/index.ts +2 -0
  147. package/src/modules/index.ts +3 -0
  148. package/src/notifications/api.ts +477 -0
  149. package/src/notifications/contract.ts +142 -0
  150. package/src/notifications/index.ts +8 -0
  151. package/src/search/index.ts +198 -0
@@ -0,0 +1,128 @@
1
+ // Tools & permissions of the AI module (DIG-71): defining tools and agents, and
2
+ // running one tool call as the run's actor with the same guarantees as any
3
+ // other system-core function (permission, module enabled, role view, audit).
4
+ import type { z } from "zod"
5
+ import { rehydrateActor, type Actor, type ActorRef } from "../core/access.js"
6
+ import { recordAudit, type AuditInput } from "../core/audit.js"
7
+ import { PERMISSION_ID } from "../core/catalog.js"
8
+ import type { SystemContext } from "../core/context.js"
9
+ import { toSystemError } from "../core/errors.js"
10
+ import { authorizeJob } from "../core/jobs.js"
11
+ import { AI_AUDIT, type AiAgentDefinition, type AiTool, type ToolExecutionResult } from "./contract.js"
12
+
13
+ /** `module.verb_object`, e.g. `finance.list_invoices`. */
14
+ export const AI_TOOL_ID = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9_]*$/
15
+ export const AI_AGENT_ID = /^[a-z][a-z0-9-]{1,47}$/
16
+
17
+ const isZodSchema = (s: unknown): s is z.ZodType => !!s && typeof s === "object" && typeof (s as { safeParse?: unknown }).safeParse === "function"
18
+
19
+ /**
20
+ * Declares a tool. Identity at runtime; validates the definition (id format, permission format).
21
+ *
22
+ * Handler rule: check everything (existence, state, uniqueness, limits) BEFORE the
23
+ * first write. A failed handler's earlier writes are committed, because
24
+ * `executeAiTool` reports the failure as a result instead of throwing, so the
25
+ * mutation (and its `ai.tool.run` audit event) commits. The finance tools follow
26
+ * this: their shared invoice operations validate first, then insert/patch once.
27
+ *
28
+ * Conventions: `permission` is the owning module's own permission; anything that
29
+ * writes sets `requiresApproval: true`.
30
+ */
31
+ export function defineAiTool<I extends z.ZodType, O>(tool: AiTool<I, O>): AiTool<I, O> {
32
+ const id = typeof tool?.id === "string" ? tool.id : ""
33
+ if (!AI_TOOL_ID.test(id)) throw new Error(`defineAiTool: invalid tool id "${id}" (expected module.verb_object, e.g. finance.list_invoices)`)
34
+ if (typeof tool.permission !== "string" || !PERMISSION_ID.test(tool.permission)) {
35
+ throw new Error(`defineAiTool(${id}): invalid permission "${String(tool.permission)}" (expected module:resource:action)`)
36
+ }
37
+ if (typeof tool.description !== "string" || !tool.description.trim()) throw new Error(`defineAiTool(${id}): description is required`)
38
+ if (!isZodSchema(tool.input)) throw new Error(`defineAiTool(${id}): input must be a zod schema`)
39
+ if (typeof tool.handler !== "function") throw new Error(`defineAiTool(${id}): handler must be a function`)
40
+ return tool
41
+ }
42
+
43
+ /** Declares an agent. Identity at runtime; validates ids and that tool ids are unique. */
44
+ export function defineAiAgent<const A extends AiAgentDefinition>(agent: A): A {
45
+ const id = typeof agent?.id === "string" ? agent.id : ""
46
+ if (!AI_AGENT_ID.test(id)) throw new Error(`defineAiAgent: invalid agent id "${id}" (expected ${AI_AGENT_ID.source})`)
47
+ if (typeof agent.instructions !== "string" || !agent.instructions.trim()) throw new Error(`defineAiAgent(${id}): instructions are required`)
48
+ if (!Array.isArray(agent.tools)) throw new Error(`defineAiAgent(${id}): tools must be an array`)
49
+ const seen = new Set<string>()
50
+ for (const tool of agent.tools) {
51
+ if (seen.has(tool.id)) throw new Error(`defineAiAgent(${id}): duplicate tool id "${tool.id}"`)
52
+ seen.add(tool.id)
53
+ }
54
+ return agent
55
+ }
56
+
57
+ /** Field paths of the failed issues, never their values. */
58
+ function invalidFields(error: z.ZodError): string[] {
59
+ const paths = error.issues.map((i) => (i.path.length ? i.path.map((p) => String(p)).join(".") : "(root)"))
60
+ return [...new Set(paths)]
61
+ }
62
+
63
+ /**
64
+ * Runs one tool call as the run's actor, inside a mutation:
65
+ * validates `args` with the tool's zod schema (VALIDATION), re-authorizes the
66
+ * actor for `tool.permission` with system-core `authorizeJob` (FORBIDDEN when
67
+ * null: revoked key, suspended user, role view, disabled module…), runs the
68
+ * handler, maps SystemError to its code, and audits `ai.tool.run`.
69
+ *
70
+ * Handlers must validate before they write: the failure is returned (not
71
+ * thrown) so the audit is kept, which means the surrounding mutation also
72
+ * commits whatever the handler wrote before it failed.
73
+ */
74
+ export async function executeAiTool(input: {
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ db: any
77
+ sys: SystemContext
78
+ actorRef: ActorRef
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ tool: AiTool<any, any>
81
+ args: unknown
82
+ toolCallId: string
83
+ }): Promise<ToolExecutionResult> {
84
+ const { db, sys, actorRef, tool, args, toolCallId } = input
85
+ // Who the audit event names: the rehydrated actor if still valid, else "system".
86
+ let auditActor: Actor | "system" = (await rehydrateActor(sys, actorRef)) ?? "system"
87
+
88
+ const audit = (result: NonNullable<AuditInput["result"]>, reason?: string) => {
89
+ const event: AuditInput = { action: AI_AUDIT.toolRun, targetType: "ai_tool", targetId: tool.id, result, changes: { toolCallId } }
90
+ if (reason) event.reason = reason
91
+ return recordAudit(sys, auditActor, event)
92
+ }
93
+ const failure = async (code: string, message: string): Promise<ToolExecutionResult> => {
94
+ await audit(code === "FORBIDDEN" || code === "UNAUTHENTICATED" ? "denied" : "failed", code)
95
+ return { ok: false, code, message }
96
+ }
97
+
98
+ const parsed = tool.input.safeParse(args)
99
+ if (!parsed.success) {
100
+ const fields = invalidFields(parsed.error)
101
+ sys.logger.info("ai_tool_invalid_args", { toolId: tool.id, fields })
102
+ return failure("VALIDATION", `Invalid arguments for ${tool.id}: ${fields.join(", ")}`)
103
+ }
104
+
105
+ const auth = await authorizeJob(sys, actorRef, tool.permission)
106
+ if (!auth) return failure("FORBIDDEN", `Not allowed to use ${tool.id}.`)
107
+ auditActor = auth.actor
108
+
109
+ let result: unknown
110
+ try {
111
+ result = await tool.handler({ db, sys, actor: auth.actor }, parsed.data)
112
+ } catch (e) {
113
+ const se = toSystemError(e)
114
+ if (se) return failure(se.code, se.toPublic().message)
115
+ sys.logger.error("ai_tool_failed", { toolId: tool.id, error: e })
116
+ return failure("UNAVAILABLE", "The tool failed.")
117
+ }
118
+
119
+ let json: unknown
120
+ try {
121
+ json = result === undefined ? null : JSON.parse(JSON.stringify(result))
122
+ } catch (e) {
123
+ sys.logger.error("ai_tool_result_not_json", { toolId: tool.id, error: e })
124
+ return failure("UNAVAILABLE", "The tool returned an invalid result.")
125
+ }
126
+ await audit("success")
127
+ return { ok: true, result: json }
128
+ }
@@ -0,0 +1,60 @@
1
+ // CSV export of audit events (DIG-73). Pure and browser-safe: no Convex, no env.
2
+ // The UI pages through `listAudit`, calls `auditToCsv`, offers the download and
3
+ // records the export with `recordAuditExport` (audit action `audit.export`).
4
+
5
+ /** The fields of an audit event (`listAudit` items) the export reads. */
6
+ export interface AuditCsvEvent {
7
+ at: number
8
+ action: string
9
+ result: string
10
+ actorKind?: string
11
+ actorUserId?: string
12
+ apiKeyId?: string
13
+ targetType: string
14
+ targetId: string
15
+ operationId: string
16
+ changes?: Record<string, unknown>
17
+ reason?: string
18
+ }
19
+
20
+ /** Stable column order. */
21
+ export const AUDIT_CSV_COLUMNS = ["at", "action", "result", "actor", "apiKey", "target", "operationId", "details"] as const
22
+
23
+ export interface AuditCsvOptions {
24
+ /** Prepend a UTF-8 BOM so Excel reads Lithuanian letters correctly. Default false. */
25
+ bom?: boolean
26
+ }
27
+
28
+ /** Characters that make spreadsheet apps evaluate a cell as a formula. */
29
+ const FORMULA_START = /^[=+\-@\t\r]/
30
+
31
+ /** One CSV cell: neutralizes formula injection (leading `'`), then RFC 4180 quoting. */
32
+ export function csvCell(value: unknown): string {
33
+ let s = value === undefined || value === null ? "" : String(value)
34
+ if (FORMULA_START.test(s)) s = `'${s}`
35
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
36
+ }
37
+
38
+ function details(e: AuditCsvEvent): string {
39
+ const out: Record<string, unknown> = {}
40
+ if (e.changes !== undefined) out.changes = e.changes
41
+ if (e.reason !== undefined) out.reason = e.reason
42
+ return Object.keys(out).length ? JSON.stringify(out) : ""
43
+ }
44
+
45
+ /**
46
+ * Audit events as CSV (RFC 4180: CRLF line ends, quoted cells where needed),
47
+ * header row first. Columns: at (ISO 8601 UTC), action, result, actor (user ID,
48
+ * or `system` / `anonymous`), apiKey (key ID for agents), target
49
+ * (`type:id`), operationId, details (JSON of `changes` and `reason`).
50
+ * Cells starting with = + - @ tab or CR get a leading `'` (CSV formula injection).
51
+ */
52
+ export function auditToCsv(events: readonly AuditCsvEvent[], options: AuditCsvOptions = {}): string {
53
+ const rows: string[] = [AUDIT_CSV_COLUMNS.join(",")]
54
+ for (const e of events) {
55
+ const at = Number.isFinite(e.at) ? new Date(e.at).toISOString() : ""
56
+ const actor = e.actorUserId ?? e.actorKind ?? ""
57
+ rows.push([at, e.action, e.result, actor, e.apiKeyId ?? "", `${e.targetType}:${e.targetId}`, e.operationId, details(e)].map(csvCell).join(","))
58
+ }
59
+ return `${options.bom ? "" : ""}${rows.join("\r\n")}\r\n`
60
+ }
@@ -0,0 +1,7 @@
1
+ // `@digimuza/system-core/audit`: browser-safe audit helpers (DIG-73).
2
+ // The audit functions themselves (`listAudit`, `listAuditFacets`,
3
+ // `recordAuditExport`) are part of `system.functions` (`@digimuza/system-core/convex`).
4
+ export { auditToCsv, csvCell, AUDIT_CSV_COLUMNS, type AuditCsvEvent, type AuditCsvOptions } from "./csv.js"
5
+ export { auditModuleOf, CORE_AUDIT_PREFIXES } from "../core/audit-module.js"
6
+ export type { AuditEventRow } from "../core/store.js"
7
+ export type { AuditFacets, AuditFilter, AuditExportFilter } from "../core/audit.js"
@@ -21,7 +21,8 @@ import {
21
21
  type VerifiedIdentity,
22
22
  } from "../core/access.js"
23
23
  import * as apiKeys from "../core/api-keys.js"
24
- import { listAudit } from "../core/audit.js"
24
+ import { listAudit, listAuditFacets, recordAuditExport } from "../core/audit.js"
25
+ import type { Notifier } from "../core/notify.js"
25
26
  import { bindSystem, createSystem, type SystemContext, type SystemDefinition, type SystemOptions } from "../core/context.js"
26
27
  import { isSystemError, SystemError } from "../core/errors.js"
27
28
  import * as flags from "../core/feature-flags.js"
@@ -41,6 +42,15 @@ type Db = GenericDatabaseReader<any> | GenericDatabaseWriter<any>
41
42
  /** The subset of a Convex query/mutation ctx the system needs. Any app's ctx fits. */
42
43
  export interface DbCtx { db: Db; auth: Auth }
43
44
 
45
+ /**
46
+ * Installs notifications into the system (`notificationsHook()` from
47
+ * `@digimuza/system-core/notifications`). The Convex layer binds one notifier per
48
+ * transaction, so core services and modules can notify from any mutation.
49
+ */
50
+ export interface ConvexNotificationsHook {
51
+ bind(ctx: DbCtx, sys: SystemContext): Notifier
52
+ }
53
+
44
54
  export interface ConvexSystemOptions extends SystemOptions {
45
55
  /**
46
56
  * Module path (relative to `convex/`) where you export `system.functions`.
@@ -53,6 +63,8 @@ export interface ConvexSystemOptions extends SystemOptions {
53
63
  inviteDelivery?: invites.InviteDeliveryAdapter
54
64
  /** Builds the one-time invite link. */
55
65
  inviteUrl?: (token: string) => string
66
+ /** Notifications (DIG-74). Without it every `notifyIfInstalled` is a no-op. */
67
+ notifications?: ConvexNotificationsHook
56
68
  }
57
69
 
58
70
  export interface ContextOptions { apiKey?: string | undefined; operationId?: string | undefined }
@@ -109,7 +121,11 @@ export function defineConvexSystem(options: ConvexSystemOptions = {}) {
109
121
  /** Binds the system to this transaction and resolves the actor (session or API key). */
110
122
  async function context(ctx: DbCtx, opts: ContextOptions = {}): Promise<{ sys: SystemContext; actor: Actor | null }> {
111
123
  const operationId = opts.operationId && OPERATION_ID.test(opts.operationId) ? opts.operationId : undefined
112
- const sys = bindSystem(definition, createConvexStore(ctx.db), operationId ? { operationId } : {})
124
+ const hook = options.notifications
125
+ const sys = bindSystem(definition, createConvexStore(ctx.db), {
126
+ ...(operationId ? { operationId } : {}),
127
+ ...(hook ? { notifier: (s: SystemContext) => hook.bind(ctx, s) } : {}),
128
+ })
113
129
  // An explicit API key always wins: agents never inherit a browser session.
114
130
  const actor = opts.apiKey ? await resolveApiKeyActor(sys, opts.apiKey) : await resolveUserActor(sys, await verifiedIdentity(ctx))
115
131
  return { sys, actor }
@@ -172,6 +188,31 @@ export function defineConvexSystem(options: ConvexSystemOptions = {}) {
172
188
  const defined = <T extends Record<string, unknown>>(o: T) =>
173
189
  Object.fromEntries(Object.entries(o).filter(([, val]) => val !== undefined)) as { [K in keyof T]: Exclude<T[K], undefined> }
174
190
 
191
+ const auditFilterFields = {
192
+ from: v.optional(v.number()),
193
+ to: v.optional(v.number()),
194
+ actorUserId: v.optional(v.string()),
195
+ apiKeyId: v.optional(v.string()),
196
+ targetId: v.optional(v.string()),
197
+ filterOperationId: v.optional(v.string()),
198
+ action: v.optional(v.string()),
199
+ moduleId: v.optional(v.string()),
200
+ actionPrefix: v.optional(v.string()),
201
+ }
202
+ const auditFilterArg = v.object(auditFilterFields)
203
+ const auditFilter = (a: ObjectType<typeof auditFilterFields>) =>
204
+ defined({
205
+ from: a.from,
206
+ to: a.to,
207
+ actorUserId: a.actorUserId,
208
+ apiKeyId: a.apiKeyId,
209
+ targetId: a.targetId,
210
+ operationId: a.filterOperationId,
211
+ action: a.action,
212
+ moduleId: a.moduleId,
213
+ actionPrefix: a.actionPrefix,
214
+ })
215
+
175
216
  const subjectArg = v.union(v.object({ kind: v.literal("user"), userId: v.string() }), v.object({ kind: v.literal("agent"), apiKeyId: v.string() }))
176
217
  async function subjectActor(sys: SystemContext, ref: ActorRef): Promise<Actor> {
177
218
  if (ref.kind === "user") return ref
@@ -340,12 +381,16 @@ export function defineConvexSystem(options: ConvexSystemOptions = {}) {
340
381
  targetId: v.optional(v.string()),
341
382
  filterOperationId: v.optional(v.string()),
342
383
  action: v.optional(v.string()),
384
+ moduleId: v.optional(v.string()),
385
+ actionPrefix: v.optional(v.string()),
343
386
  },
344
- (sys, actor, a) =>
345
- listAudit(sys, actor, {
346
- ...pageReq(a),
347
- ...defined({ from: a.from, to: a.to, actorUserId: a.actorUserId, apiKeyId: a.apiKeyId, targetId: a.targetId, operationId: a.filterOperationId, action: a.action }),
348
- }),
387
+ (sys, actor, a) => listAudit(sys, actor, { ...pageReq(a), ...auditFilter(a) }),
388
+ ),
389
+ /** `{ actions, moduleIds, scanned, complete }` of the newest events, for the filter UI. */
390
+ listAuditFacets: query({}, (sys, actor) => listAuditFacets(sys, actor)),
391
+ /** Audits a CSV export (`audit.export`) after the UI paged through `listAudit`. */
392
+ recordAuditExport: mutation({ filter: auditFilterArg, count: v.number() }, (sys, actor, a) =>
393
+ recordAuditExport(sys, actor, { filter: auditFilter(a.filter), count: a.count }),
349
394
  ),
350
395
 
351
396
  // --- Deployment -------------------------------------------------------------------------
@@ -388,6 +433,8 @@ export function defineConvexSystem(options: ConvexSystemOptions = {}) {
388
433
  requirePermission,
389
434
  run,
390
435
  functions,
436
+ /** The installed notifications hook, if any (read by `defineNotifications`). */
437
+ notifications: options.notifications,
391
438
  }
392
439
  }
393
440
 
@@ -1,7 +1,7 @@
1
1
  // Convex integration: schema tables, store adapter, system functions and guards.
2
2
  export { systemTables } from "./schema.js"
3
3
  export { createConvexStore } from "./store.js"
4
- export { defaultIdentity, defineConvexSystem, toConvexError, type ConvexSystem, type ConvexSystemOptions, type ContextOptions, type DbCtx } from "./functions.js"
4
+ export { defaultIdentity, defineConvexSystem, toConvexError, type ConvexSystem, type ConvexSystemOptions, type ConvexNotificationsHook, type ContextOptions, type DbCtx } from "./functions.js"
5
5
 
6
6
  // Runtime-agnostic services, for custom Convex functions built on `system.context(ctx)`.
7
7
  export * from "../core/index.js"
@@ -1,7 +1,7 @@
1
1
  import { forbidden, SystemError, unauthenticated } from "./errors.js"
2
2
  import { constantTimeEqual, sha256Hex } from "./crypto.js"
3
3
  import { normalizeEmail, type SystemContext } from "./context.js"
4
- import { pairKey, type ApiKeyRow, type ModuleRow, type UserRow } from "./store.js"
4
+ import { pairKey, type ApiKeyRow, type ModuleRow, type Page, type UserRow } from "./store.js"
5
5
  import { WILDCARD_SCOPE } from "./system-modules.js"
6
6
 
7
7
  // --- Actors ------------------------------------------------------------------
@@ -270,6 +270,60 @@ export async function requireSuper(sys: SystemContext, actor: Actor | null): Pro
270
270
  return a
271
271
  }
272
272
 
273
+ // --- Reverse computation: who holds a permission (notifications) -------------
274
+
275
+ /** Most candidate users `usersWithPermission` evaluates (each costs a full `decide`). */
276
+ export const PERMISSION_HOLDERS_SCAN_MAX = 300
277
+
278
+ /**
279
+ * Active users who hold `permission` right now, by user ID, at most `limit`.
280
+ * Candidates come from the grant chain (permission → subrole → role → user)
281
+ * plus the deployment Super list; each is confirmed with `decide`, so module
282
+ * state, suspended accounts and a Super's role view apply exactly as for any
283
+ * other check. Bounded: at most PERMISSION_HOLDERS_SCAN_MAX candidates are
284
+ * evaluated (sorted by user ID, so the cut is deterministic).
285
+ */
286
+ export async function usersWithPermission(sys: SystemContext, permission: string, limit: number): Promise<{ userIds: string[]; truncated: boolean }> {
287
+ const perm = sys.def.catalog.permissions.get(permission)
288
+ if (!perm || limit <= 0) return { userIds: [], truncated: false }
289
+ const candidates = new Set<string>()
290
+ for (const email of sys.superEmails) for (const u of await sys.store.findBy("users", "email", email)) candidates.add(u.key)
291
+ const roleIds = new Set<string>()
292
+ for (const sp of await sys.store.findBy("subrolePermissions", "permissionId", permission)) {
293
+ const sub = await sys.store.get("subroles", sp.subroleId)
294
+ if (!sub || sub.archived || sub.moduleId !== perm.moduleId) continue
295
+ for (const rs of await sys.store.findBy("roleSubroles", "subroleId", sub.key)) roleIds.add(rs.roleId)
296
+ }
297
+ let truncated = false
298
+ // An implicit role (Profile) grants it to everyone: page through users, bounded.
299
+ if (implicitRoleIds(sys).some((id) => roleIds.has(id))) {
300
+ let cursor: string | null = null
301
+ do {
302
+ const page: Page<UserRow> = await sys.store.page("users", { cursor, limit: 200 })
303
+ for (const u of page.items) candidates.add(u.key)
304
+ cursor = page.cursor
305
+ } while (cursor && candidates.size < PERMISSION_HOLDERS_SCAN_MAX)
306
+ if (cursor) truncated = true
307
+ }
308
+ for (const roleId of roleIds) {
309
+ const role = await sys.store.get("roles", roleId)
310
+ if (!role || role.archived) continue
311
+ for (const ur of await sys.store.findBy("userRoles", "roleId", roleId)) candidates.add(ur.userId)
312
+ }
313
+ const sorted = [...candidates].sort()
314
+ if (sorted.length > PERMISSION_HOLDERS_SCAN_MAX) truncated = true
315
+ const userIds: string[] = []
316
+ for (const userId of sorted.slice(0, PERMISSION_HOLDERS_SCAN_MAX)) {
317
+ if (!(await decide(sys, { kind: "user", userId }, permission)).allowed) continue
318
+ if (userIds.length >= limit) {
319
+ truncated = true
320
+ break
321
+ }
322
+ userIds.push(userId)
323
+ }
324
+ return { userIds, truncated }
325
+ }
326
+
273
327
  // --- Forward computation (UI lists, delegation) -------------------------------
274
328
 
275
329
  /** Active, catalog-known permissions granted by the given roles. Ignores module enabled state. */
@@ -0,0 +1,26 @@
1
+ // Which module an audit action belongs to. Pure (browser-safe).
2
+ //
3
+ // Business modules name their actions `<moduleId>.<object>.<verb>` (finance.invoice.create,
4
+ // ai.tool.run), so the first segment is the module ID. The core's own actions use
5
+ // shorter object names (role.assign, api_key.create); they map to their system module.
6
+
7
+ /** First action segment → system module ID, for the core's own audit actions. */
8
+ export const CORE_AUDIT_PREFIXES: Readonly<Record<string, string>> = {
9
+ api_key: "api-keys",
10
+ auth: "auth",
11
+ catalog: "modules",
12
+ delegation: "roles",
13
+ flag: "feature-flags",
14
+ invite: "invites",
15
+ module: "modules",
16
+ role: "roles",
17
+ subrole: "roles",
18
+ super: "roles",
19
+ user: "users",
20
+ }
21
+
22
+ /** Module ID of an audit action: `finance.invoice.create` → `finance`, `role.assign` → `roles`. */
23
+ export function auditModuleOf(action: string): string {
24
+ const head = action.split(".", 1)[0] ?? action
25
+ return CORE_AUDIT_PREFIXES[head] ?? head
26
+ }
package/src/core/audit.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { requirePermission, type Actor } from "./access.js"
1
+ import { requireHuman, requirePermission, withOperation, type Actor } from "./access.js"
2
+ import { auditModuleOf } from "./audit-module.js"
3
+ import { validation } from "./errors.js"
2
4
  import { newId } from "./crypto.js"
3
5
  import { redact } from "./redact.js"
4
6
  import type { SystemContext } from "./context.js"
@@ -51,6 +53,10 @@ export interface AuditFilter {
51
53
  targetId?: string
52
54
  operationId?: string
53
55
  action?: string
56
+ /** Events of one module: `finance` matches `finance.*`; core actions map to their system module (`role.assign` → `roles`). */
57
+ moduleId?: string
58
+ /** Actions starting with this prefix, e.g. `finance.invoice.` or `ai.tool`. */
59
+ actionPrefix?: string
54
60
  cursor?: string | null
55
61
  limit?: number
56
62
  }
@@ -66,7 +72,9 @@ export async function listAudit(sys: SystemContext, actor: Actor | null, filter:
66
72
  (filter.apiKeyId === undefined || e.apiKeyId === filter.apiKeyId) &&
67
73
  (filter.targetId === undefined || e.targetId === filter.targetId) &&
68
74
  (filter.operationId === undefined || e.operationId === filter.operationId) &&
69
- (filter.action === undefined || e.action === filter.action)
75
+ (filter.action === undefined || e.action === filter.action) &&
76
+ (filter.moduleId === undefined || auditModuleOf(e.action) === filter.moduleId) &&
77
+ (filter.actionPrefix === undefined || e.action.startsWith(filter.actionPrefix))
70
78
 
71
79
  // Use an index when the filter allows it; otherwise scan pages.
72
80
  const indexed = filter.operationId
@@ -86,3 +94,61 @@ export async function listAudit(sys: SystemContext, actor: Actor | null, filter:
86
94
  const page = await sys.store.page("auditEvents", { cursor: filter.cursor ?? null, limit, order: "desc" })
87
95
  return { ...page, items: page.items.filter(match) }
88
96
  }
97
+
98
+ /** Newest events `listAuditFacets` scans. */
99
+ export const AUDIT_FACETS_SCAN_MAX = 1000
100
+
101
+ export interface AuditFacets {
102
+ /** Distinct actions among the scanned events, sorted. */
103
+ actions: string[]
104
+ /** Distinct module IDs (see `auditModuleOf`), sorted. */
105
+ moduleIds: string[]
106
+ /** Events scanned (newest first). */
107
+ scanned: number
108
+ /** True when every event was scanned (fewer than AUDIT_FACETS_SCAN_MAX exist). */
109
+ complete: boolean
110
+ }
111
+
112
+ /** Filter options for the audit UI: distinct actions and modules of the newest events. Same permission as `listAudit`. */
113
+ export async function listAuditFacets(sys: SystemContext, actor: Actor | null): Promise<AuditFacets> {
114
+ await requirePermission(sys, actor, P.auditRead)
115
+ const actions = new Set<string>()
116
+ let scanned = 0
117
+ let cursor: string | null = null
118
+ let complete = false
119
+ while (scanned < AUDIT_FACETS_SCAN_MAX) {
120
+ const page: Page<AuditEventRow> = await sys.store.page("auditEvents", { cursor, limit: 200, order: "desc" })
121
+ for (const e of page.items) actions.add(e.action)
122
+ scanned += page.items.length
123
+ cursor = page.cursor
124
+ if (!cursor) {
125
+ complete = true
126
+ break
127
+ }
128
+ }
129
+ const sorted = [...actions].sort()
130
+ return { actions: sorted, moduleIds: [...new Set(sorted.map(auditModuleOf))].sort(), scanned, complete }
131
+ }
132
+
133
+ /** Filters an export may carry (stored in the `audit.export` event). */
134
+ export type AuditExportFilter = Omit<AuditFilter, "cursor" | "limit">
135
+
136
+ export const AUDIT_EXPORT_MAX = 1_000_000
137
+
138
+ /**
139
+ * Records that the caller exported audit events (CSV built in the browser with
140
+ * `auditToCsv`). Same permission as reading; human sessions only.
141
+ */
142
+ export async function recordAuditExport(sys: SystemContext, actor: Actor | null, input: { filter: AuditExportFilter; count: number }): Promise<{ eventId: string }> {
143
+ const human = requireHuman(sys, actor)
144
+ await requirePermission(sys, human, P.auditRead)
145
+ if (!Number.isInteger(input.count) || input.count < 0 || input.count > AUDIT_EXPORT_MAX) throw withOperation(sys, validation("invalid_count", ["count"]))
146
+ // `apiKeyId` would be masked by `redact` (its name looks like a credential); a key ID is not one.
147
+ const filter = Object.fromEntries(
148
+ Object.entries(input.filter)
149
+ .filter(([, v]) => v !== undefined)
150
+ .map(([k, v]) => [k === "apiKeyId" ? "keyId" : k, v]),
151
+ )
152
+ const eventId = await recordAudit(sys, human, { action: "audit.export", targetType: "audit", targetId: "csv", changes: { filter, count: input.count } })
153
+ return { eventId }
154
+ }
@@ -32,6 +32,26 @@ export interface FeatureFlagDefinition {
32
32
  default: boolean
33
33
  }
34
34
 
35
+ /** `module.snake_case`, e.g. `finance.invoice_awaiting_approval`. */
36
+ export const NOTIFICATION_TYPE_ID = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9_]*$/
37
+
38
+ /**
39
+ * A kind of notification a module sends. Core stores the type and its JSON
40
+ * params, never pre-rendered text: the app renders titles (LT/EN) from params.
41
+ */
42
+ export interface NotificationTypeDefinition {
43
+ /** Stable `module.snake_case`; starts with `${moduleId}.`. */
44
+ id: string
45
+ /** Owning module. A type is visible to a user only while its module is enabled. */
46
+ moduleId: string
47
+ /** Channels used until the user sets a preference. */
48
+ defaultChannels: { inApp: boolean; email: boolean }
49
+ /** Fallback text for the preferences UI (the app translates by `id`). */
50
+ description?: string
51
+ /** Param names the type carries (documentation for the app's renderer). */
52
+ params?: readonly string[]
53
+ }
54
+
35
55
  export interface ModuleManifest {
36
56
  id: string
37
57
  kind: ModuleKind
@@ -44,6 +64,8 @@ export interface ModuleManifest {
44
64
  dependsOn?: readonly string[]
45
65
  /** Config keys the module registers (documentation / status). */
46
66
  configKeys?: readonly string[]
67
+ /** Notification types the module sends (`@digimuza/system-core/notifications`). */
68
+ notificationTypes?: readonly NotificationTypeDefinition[]
47
69
  }
48
70
 
49
71
  const describedItem = { name: z.string().min(1).max(120), description: z.string().min(1).max(500) }
@@ -62,6 +84,17 @@ export const manifestSchema = z.strictObject({
62
84
  .optional(),
63
85
  dependsOn: z.array(z.string().regex(MODULE_ID)).optional(),
64
86
  configKeys: z.array(z.string().min(1)).optional(),
87
+ notificationTypes: z
88
+ .array(
89
+ z.strictObject({
90
+ id: z.string().regex(NOTIFICATION_TYPE_ID),
91
+ moduleId: z.string().regex(MODULE_ID),
92
+ defaultChannels: z.strictObject({ inApp: z.boolean(), email: z.boolean() }),
93
+ description: z.string().min(1).max(500).optional(),
94
+ params: z.array(z.string().min(1).max(64)).optional(),
95
+ }),
96
+ )
97
+ .optional(),
65
98
  })
66
99
 
67
100
  export class CatalogError extends Error {
@@ -101,9 +134,30 @@ export function manifestProblems(manifest: unknown): string[] {
101
134
  if (flagIds.has(f.id)) problems.push(`${m.id}: duplicate feature flag ${f.id}`)
102
135
  flagIds.add(f.id)
103
136
  }
137
+ const typeIds = new Set<string>()
138
+ for (const t of m.notificationTypes ?? []) {
139
+ if (t.moduleId !== m.id) problems.push(`${m.id}: notification type ${t.id} belongs to module ${t.moduleId}`)
140
+ if (!t.id.startsWith(`${m.id}.`)) problems.push(`${m.id}: notification type ${t.id} must start with "${m.id}."`)
141
+ if (typeIds.has(t.id)) problems.push(`${m.id}: duplicate notification type ${t.id}`)
142
+ typeIds.add(t.id)
143
+ }
104
144
  return problems
105
145
  }
106
146
 
147
+ /**
148
+ * Declares a notification type. Validates eagerly (id format, module prefix)
149
+ * and returns a frozen copy. List it in the module manifest's `notificationTypes`.
150
+ */
151
+ export function defineNotificationType<const T extends NotificationTypeDefinition>(type: T): T {
152
+ const id = typeof type?.id === "string" ? type.id : ""
153
+ if (!NOTIFICATION_TYPE_ID.test(id)) throw new Error(`defineNotificationType: invalid id "${id}" (expected module.snake_case, e.g. finance.invoice_awaiting_approval)`)
154
+ if (typeof type.moduleId !== "string" || !MODULE_ID.test(type.moduleId)) throw new Error(`defineNotificationType(${id}): invalid moduleId`)
155
+ if (!id.startsWith(`${type.moduleId}.`)) throw new Error(`defineNotificationType(${id}): id must start with "${type.moduleId}."`)
156
+ const ch = type.defaultChannels
157
+ if (!ch || typeof ch.inApp !== "boolean" || typeof ch.email !== "boolean") throw new Error(`defineNotificationType(${id}): defaultChannels { inApp, email } are required`)
158
+ return deepFreeze(structuredClone(type))
159
+ }
160
+
107
161
  type DeepReadonly<T> = T extends readonly (infer U)[] ? readonly DeepReadonly<U>[] : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T
108
162
 
109
163
  function deepFreeze<T>(value: T): T {
@@ -130,6 +184,7 @@ export type PermissionOf<M extends { permissions: readonly { id: string }[] }> =
130
184
  export interface CatalogPermission extends PermissionDefinition { moduleId: string; humanOnly: boolean }
131
185
  export interface CatalogSubrole extends SubroleTemplate { moduleId: string }
132
186
  export interface CatalogFlag extends FeatureFlagDefinition { moduleId: string }
187
+ export type CatalogNotificationType = NotificationTypeDefinition
133
188
 
134
189
  export interface SystemRoleTemplate {
135
190
  id: string
@@ -147,6 +202,8 @@ export interface Catalog {
147
202
  readonly permissions: ReadonlyMap<string, CatalogPermission>
148
203
  readonly subroles: ReadonlyMap<string, CatalogSubrole>
149
204
  readonly flags: ReadonlyMap<string, CatalogFlag>
205
+ /** Notification types declared by the composed modules (not part of the catalog version). */
206
+ readonly notificationTypes: ReadonlyMap<string, CatalogNotificationType>
150
207
  readonly systemRoles: readonly SystemRoleTemplate[]
151
208
  /** Deterministic serialization of the catalog, for versioning. */
152
209
  readonly canonical: string
@@ -168,6 +225,7 @@ export function buildCatalog(
168
225
  const permissions = new Map<string, CatalogPermission>()
169
226
  const subroles = new Map<string, CatalogSubrole>()
170
227
  const flags = new Map<string, CatalogFlag>()
228
+ const notificationTypes = new Map<string, CatalogNotificationType>()
171
229
 
172
230
  for (const m of all) {
173
231
  problems.push(...manifestProblems(m))
@@ -188,6 +246,10 @@ export function buildCatalog(
188
246
  if (flags.has(f.id)) problems.push(`feature flag id collision: ${f.id}`)
189
247
  flags.set(f.id, { ...f, moduleId: m.id })
190
248
  }
249
+ for (const t of m.notificationTypes ?? []) {
250
+ if (notificationTypes.has(t.id)) problems.push(`notification type id collision: ${t.id}`)
251
+ notificationTypes.set(t.id, t)
252
+ }
191
253
  }
192
254
  for (const m of all) {
193
255
  for (const dep of m.dependsOn ?? []) if (!modules.has(dep)) problems.push(`${m.id}: depends on unknown module ${dep}`)
@@ -206,6 +268,7 @@ export function buildCatalog(
206
268
  permissions,
207
269
  subroles,
208
270
  flags,
271
+ notificationTypes,
209
272
  systemRoles,
210
273
  canonical,
211
274
  version: () => sha256Hex(canonical),