@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.
- package/INTEGRATION.md +209 -3
- package/README.md +23 -0
- package/dist/ai/api.d.ts +120 -0
- package/dist/ai/api.d.ts.map +1 -0
- package/dist/ai/api.js +446 -0
- package/dist/ai/api.js.map +1 -0
- package/dist/ai/card.d.ts +27 -0
- package/dist/ai/card.d.ts.map +1 -0
- package/dist/ai/card.js +84 -0
- package/dist/ai/card.js.map +1 -0
- package/dist/ai/contract.d.ts +364 -0
- package/dist/ai/contract.d.ts.map +1 -0
- package/dist/ai/contract.js +101 -0
- package/dist/ai/contract.js.map +1 -0
- package/dist/ai/display.d.ts +20 -0
- package/dist/ai/display.d.ts.map +1 -0
- package/dist/ai/display.js +35 -0
- package/dist/ai/display.js.map +1 -0
- package/dist/ai/index.d.ts +7 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +10 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/manifest.d.ts +25 -0
- package/dist/ai/manifest.d.ts.map +1 -0
- package/dist/ai/manifest.js +15 -0
- package/dist/ai/manifest.js.map +1 -0
- package/dist/ai/runner.d.ts +71 -0
- package/dist/ai/runner.d.ts.map +1 -0
- package/dist/ai/runner.js +246 -0
- package/dist/ai/runner.js.map +1 -0
- package/dist/ai/tools.d.ts +42 -0
- package/dist/ai/tools.d.ts.map +1 -0
- package/dist/ai/tools.js +117 -0
- package/dist/ai/tools.js.map +1 -0
- package/dist/audit/csv.d.ts +31 -0
- package/dist/audit/csv.d.ts.map +1 -0
- package/dist/audit/csv.js +39 -0
- package/dist/audit/csv.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +6 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/convex/functions.d.ts +39 -0
- package/dist/convex/functions.d.ts.map +1 -1
- package/dist/convex/functions.js +38 -6
- package/dist/convex/functions.js.map +1 -1
- package/dist/convex/index.d.ts +1 -1
- package/dist/convex/index.d.ts.map +1 -1
- package/dist/convex/index.js.map +1 -1
- package/dist/core/access.d.ts +14 -0
- package/dist/core/access.d.ts.map +1 -1
- package/dist/core/access.js +62 -0
- package/dist/core/access.js.map +1 -1
- package/dist/core/audit-module.d.ts +5 -0
- package/dist/core/audit-module.d.ts.map +1 -0
- package/dist/core/audit-module.js +25 -0
- package/dist/core/audit-module.js.map +1 -0
- package/dist/core/audit.d.ts +31 -0
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +46 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/catalog.d.ts +41 -0
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +45 -0
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/context.d.ts +8 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +4 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/notify.d.ts +37 -0
- package/dist/core/notify.d.ts.map +1 -0
- package/dist/core/notify.js +16 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/roles.d.ts.map +1 -1
- package/dist/core/roles.js +5 -1
- package/dist/core/roles.js.map +1 -1
- package/dist/core/system-modules.d.ts +4 -0
- package/dist/core/system-modules.d.ts.map +1 -1
- package/dist/core/system-modules.js +11 -0
- package/dist/core/system-modules.js.map +1 -1
- package/dist/finance/ai-tools.d.ts +55 -0
- package/dist/finance/ai-tools.d.ts.map +1 -0
- package/dist/finance/ai-tools.js +65 -0
- package/dist/finance/ai-tools.js.map +1 -0
- package/dist/finance/index.d.ts +87 -1
- package/dist/finance/index.d.ts.map +1 -1
- package/dist/finance/index.js +92 -38
- package/dist/finance/index.js.map +1 -1
- package/dist/finance/search.d.ts +15 -0
- package/dist/finance/search.d.ts.map +1 -0
- package/dist/finance/search.js +51 -0
- package/dist/finance/search.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/notifications/api.d.ts +130 -0
- package/dist/notifications/api.d.ts.map +1 -0
- package/dist/notifications/api.js +384 -0
- package/dist/notifications/api.js.map +1 -0
- package/dist/notifications/contract.d.ts +132 -0
- package/dist/notifications/contract.d.ts.map +1 -0
- package/dist/notifications/contract.js +59 -0
- package/dist/notifications/contract.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +9 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/search/index.d.ts +95 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +151 -0
- package/dist/search/index.js.map +1 -0
- package/package.json +41 -2
- package/src/ai/api.ts +535 -0
- package/src/ai/card.ts +91 -0
- package/src/ai/contract.ts +245 -0
- package/src/ai/display.ts +38 -0
- package/src/ai/index.ts +9 -0
- package/src/ai/manifest.ts +16 -0
- package/src/ai/runner.ts +275 -0
- package/src/ai/tools.ts +128 -0
- package/src/audit/csv.ts +60 -0
- package/src/audit/index.ts +7 -0
- package/src/convex/functions.ts +54 -7
- package/src/convex/index.ts +1 -1
- package/src/core/access.ts +55 -1
- package/src/core/audit-module.ts +26 -0
- package/src/core/audit.ts +68 -2
- package/src/core/catalog.ts +63 -0
- package/src/core/context.ts +13 -2
- package/src/core/index.ts +2 -0
- package/src/core/notify.ts +52 -0
- package/src/core/roles.ts +5 -1
- package/src/core/system-modules.ts +12 -0
- package/src/finance/ai-tools.ts +68 -0
- package/src/finance/index.ts +119 -42
- package/src/finance/search.ts +58 -0
- package/src/index.ts +2 -0
- package/src/modules/index.ts +3 -0
- package/src/notifications/api.ts +477 -0
- package/src/notifications/contract.ts +142 -0
- package/src/notifications/index.ts +8 -0
- package/src/search/index.ts +198 -0
package/src/ai/tools.ts
ADDED
|
@@ -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
|
+
}
|
package/src/audit/csv.ts
ADDED
|
@@ -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"
|
package/src/convex/functions.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
package/src/convex/index.ts
CHANGED
|
@@ -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"
|
package/src/core/access.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/catalog.ts
CHANGED
|
@@ -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),
|