@cosmicdrift/kumiko-samples 0.383.1 → 0.385.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/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/event-classification.ts +83 -0
- package/packages/bundled-features/src/audit/feature.ts +3 -2
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
- package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
- package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
- package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
- package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
- package/packages/bundled-features/src/audit/screens.ts +32 -4
- package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
- package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
- package/packages/bundled-features/src/audit/web/constants.ts +11 -0
- package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
- package/packages/bundled-features/src/auth-email-password/password-policy.ts +196 -2087
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +9 -12
- package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
- package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
- package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +1 -1
- package/packages/bundled-features/src/custom-fields/db/queries/projection.ts +1 -1
- package/packages/bundled-features/src/file-derivatives/feature.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/feature.ts +34 -127
- package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
- package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
- package/packages/bundled-features/src/jobs/screens.ts +262 -0
- package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
- package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
- package/packages/bundled-features/src/ledger/web/index.ts +1 -1
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/changes.json +7 -0
- package/packages/bundled-features/src/personal-access-tokens/i18n.ts +4 -0
- package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -0
- package/packages/bundled-features/src/secrets/i18n.ts +0 -3
- package/packages/bundled-features/src/sessions/changes.json +6 -0
- package/packages/bundled-features/src/sessions/constants.ts +3 -1
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
- package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
- package/packages/bundled-features/src/sessions/screens.ts +23 -8
- package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
- package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
- package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
- package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
- package/packages/bundled-features/src/sessions/web/index.ts +1 -0
- package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
- package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
- package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +6 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
- package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
- package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
- package/samples/apps/use-all-bundled/bin/main.ts +7 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +19 -1
- package/samples/apps/use-all-bundled/kumiko/schema.ts +3 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +4 -2
- package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
- package/samples/apps/use-all-bundled/src/app/pat-scope-labels-feature.ts +21 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +5 -0
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
- package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
- package/samples/recipes/designer-demo/src/feature.ts +6 -2
- package/samples/recipes/i18n/README.md +15 -0
- package/samples/recipes/i18n/src/feature.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.385.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.385.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
"./presets": "./src/presets/index.ts",
|
|
123
123
|
"./secrets": "./src/secrets/index.ts",
|
|
124
124
|
"./sessions": "./src/sessions/index.ts",
|
|
125
|
+
"./sessions/web": "./src/sessions/web/index.ts",
|
|
125
126
|
"./auth-mfa": "./src/auth-mfa/index.ts",
|
|
126
127
|
"./auth-mfa-user-data": "./src/auth-mfa-user-data/index.ts",
|
|
127
128
|
"./auth-mfa/web": "./src/auth-mfa/web/index.ts",
|
|
@@ -594,6 +595,10 @@
|
|
|
594
595
|
"types": "./dist/sessions/index.d.ts",
|
|
595
596
|
"default": "./dist/sessions/index.js"
|
|
596
597
|
},
|
|
598
|
+
"./sessions/web": {
|
|
599
|
+
"types": "./dist/sessions/web/index.d.ts",
|
|
600
|
+
"default": "./dist/sessions/web/index.js"
|
|
601
|
+
},
|
|
597
602
|
"./auth-mfa": {
|
|
598
603
|
"types": "./dist/auth-mfa/index.d.ts",
|
|
599
604
|
"default": "./dist/auth-mfa/index.js"
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.384.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Audit log shows a label for every event type and hides technical entries",
|
|
6
|
+
"detail": "Events that are not entity lifecycle events get a readable label (for example \"Custom field value set\") or are classified technical (delivery attempts, counters, tenant-destruction stages) and hidden by default behind \"Show technical entries\". New event types of bundled features need an entry in the audit classification; a test over all registered events fails without one. Apps that add their own event types are unaffected, they keep the entity and action wording or the raw type."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.383.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Events that are not entity lifecycle events (`<entity>.created` etc.) need an explicit
|
|
2
|
+
// audit stance: a label key for the log, or "technical" to hide them behind the facet.
|
|
3
|
+
// The boot-registry test fails for a bundled event type that has neither.
|
|
4
|
+
|
|
5
|
+
import { WORKFLOW_SYSTEM_EVENT_DEFS } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { ESCAPE_HATCH_USED_EVENT } from "./escape-hatch-audit-sink.ts";
|
|
7
|
+
|
|
8
|
+
export const TECHNICAL_EVENT = "technical" as const;
|
|
9
|
+
|
|
10
|
+
export type AuditEventStance = typeof TECHNICAL_EVENT | { readonly labelKey: string };
|
|
11
|
+
|
|
12
|
+
const label = (name: string): AuditEventStance => ({ labelKey: `audit.log.event.${name}` });
|
|
13
|
+
|
|
14
|
+
export const AUDIT_EVENT_STANCES: Readonly<Record<string, AuditEventStance>> = {
|
|
15
|
+
[ESCAPE_HATCH_USED_EVENT]: TECHNICAL_EVENT,
|
|
16
|
+
"delivery:event:attempt": TECHNICAL_EVENT,
|
|
17
|
+
"delivery:event:attempt-address-erased": TECHNICAL_EVENT,
|
|
18
|
+
"cap-counter:event:rolling-incremented": TECHNICAL_EVENT,
|
|
19
|
+
"cap-counter:event:rolling-released": TECHNICAL_EVENT,
|
|
20
|
+
"inbound-mail-foundation:event:inbound-message-received": TECHNICAL_EVENT,
|
|
21
|
+
"inbound-mail-foundation:event:mail-thread-updated": TECHNICAL_EVENT,
|
|
22
|
+
"document-ingest-foundation:event:document-ingest-requested": TECHNICAL_EVENT,
|
|
23
|
+
"document-ingest-foundation:event:document-ingest-skipped": TECHNICAL_EVENT,
|
|
24
|
+
"tenant-lifecycle:event:tenant-destruction-stage-started": TECHNICAL_EVENT,
|
|
25
|
+
"tenant-lifecycle:event:tenant-destruction-stage-succeeded": TECHNICAL_EVENT,
|
|
26
|
+
"tenant-lifecycle:event:tenant-destruction-stage-progressed": TECHNICAL_EVENT,
|
|
27
|
+
"tenant-lifecycle:event:tenant-destruction-stage-failed": TECHNICAL_EVENT,
|
|
28
|
+
"tenant-lifecycle:event:tenant-destruction-stage-abandoned": TECHNICAL_EVENT,
|
|
29
|
+
|
|
30
|
+
"custom-fields:event:custom-field-set": label("custom-field-set"),
|
|
31
|
+
"custom-fields:event:custom-field-cleared": label("custom-field-cleared"),
|
|
32
|
+
"custom-fields:event:field-definition-deleted": label("field-definition-deleted"),
|
|
33
|
+
"secrets:event:read": label("secret-read"),
|
|
34
|
+
"sessions:event:session-revoked": label("session-revoked"),
|
|
35
|
+
"crypto-shredding:event:subject-forgotten": label("subject-forgotten"),
|
|
36
|
+
"crypto-shredding:event:forget-denied": label("forget-denied"),
|
|
37
|
+
"personal-access-tokens:event:pat-revoked": label("pat-revoked"),
|
|
38
|
+
"inbound-mail-foundation:event:mail-account-connected": label("mail-account-connected"),
|
|
39
|
+
"inbound-mail-foundation:event:mail-account-updated": label("mail-account-updated"),
|
|
40
|
+
"inbound-mail-foundation:event:mail-account-disconnected": label("mail-account-disconnected"),
|
|
41
|
+
"billing-foundation:event:subscription-created": label("subscription-created"),
|
|
42
|
+
"billing-foundation:event:subscription-updated": label("subscription-updated"),
|
|
43
|
+
"billing-foundation:event:subscription-canceled": label("subscription-canceled"),
|
|
44
|
+
"billing-foundation:event:invoice-paid": label("invoice-paid"),
|
|
45
|
+
"billing-foundation:event:invoice-payment-failed": label("invoice-payment-failed"),
|
|
46
|
+
"billing-foundation:event:payment-received": label("payment-received"),
|
|
47
|
+
"billing-foundation:event:checkout-consent-recorded": label("checkout-consent-recorded"),
|
|
48
|
+
"billing-foundation:event:contract-confirmation-issued": label("contract-confirmation-issued"),
|
|
49
|
+
"billing-foundation:event:contract-termination-requested": label(
|
|
50
|
+
"contract-termination-requested",
|
|
51
|
+
),
|
|
52
|
+
"billing-foundation:event:contract-termination-declared": label("contract-termination-declared"),
|
|
53
|
+
"billing-foundation:event:contract-termination-unmatched": label(
|
|
54
|
+
"contract-termination-unmatched",
|
|
55
|
+
),
|
|
56
|
+
"billing-foundation:event:contract-termination-revoked": label("contract-termination-revoked"),
|
|
57
|
+
"tenant-lifecycle:event:destruction-requested": label("destruction-requested"),
|
|
58
|
+
"tenant-lifecycle:event:destruction-cancelled": label("destruction-cancelled"),
|
|
59
|
+
"tenant-lifecycle:event:tenant-destruction-started": label("tenant-destruction-started"),
|
|
60
|
+
"tenant-lifecycle:event:tenant-destruction-completed": label("tenant-destruction-completed"),
|
|
61
|
+
"tenant-lifecycle:event:tenant-destruction-failed": label("tenant-destruction-failed"),
|
|
62
|
+
"tenant-handover:event:claimed": label("tenant-handover-claimed"),
|
|
63
|
+
"waitlist:event:exported": label("waitlist-exported"),
|
|
64
|
+
"waitlist:event:purged": label("waitlist-purged"),
|
|
65
|
+
"feature-toggles:event:toggle-set": label("feature-toggle-set"),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Framework system events (kumiko:system:*) are engine plumbing, never audit-worthy on their own.
|
|
69
|
+
export const FRAMEWORK_SYSTEM_EVENT_TYPES: readonly string[] = [
|
|
70
|
+
...WORKFLOW_SYSTEM_EVENT_DEFS.keys(),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const TECHNICAL_EVENT_TYPES: readonly string[] = [
|
|
74
|
+
...Object.entries(AUDIT_EVENT_STANCES)
|
|
75
|
+
.filter(([, stance]) => stance === TECHNICAL_EVENT)
|
|
76
|
+
.map(([type]) => type),
|
|
77
|
+
...FRAMEWORK_SYSTEM_EVENT_TYPES,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export function eventLabelKeyOf(type: string): string | null {
|
|
81
|
+
const stance = AUDIT_EVENT_STANCES[type];
|
|
82
|
+
return stance === undefined || stance === TECHNICAL_EVENT ? null : stance.labelKey;
|
|
83
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
AUDIT_LOG_COLUMNS,
|
|
18
18
|
AUDIT_LOG_DATE_RANGE_FACET,
|
|
19
19
|
AUDIT_LOG_DETAIL_FIELD_LABELS,
|
|
20
|
+
AUDIT_LOG_TECHNICAL_FACET,
|
|
20
21
|
AUDIT_LOG_TENANT_FACET,
|
|
21
22
|
auditLogDetailLayout,
|
|
22
23
|
} from "./screens.ts";
|
|
@@ -111,7 +112,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
111
112
|
columns: AUDIT_LOG_COLUMNS,
|
|
112
113
|
searchable: true,
|
|
113
114
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
114
|
-
facets: [AUDIT_LOG_DATE_RANGE_FACET],
|
|
115
|
+
facets: [AUDIT_LOG_DATE_RANGE_FACET, AUDIT_LOG_TECHNICAL_FACET],
|
|
115
116
|
rowActions: [
|
|
116
117
|
{
|
|
117
118
|
kind: "navigate",
|
|
@@ -143,7 +144,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
143
144
|
query: AuditQueries.listAllTenants,
|
|
144
145
|
columns: AUDIT_LOG_ALL_TENANTS_COLUMNS,
|
|
145
146
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
146
|
-
facets: [AUDIT_LOG_DATE_RANGE_FACET, AUDIT_LOG_TENANT_FACET],
|
|
147
|
+
facets: [AUDIT_LOG_DATE_RANGE_FACET, AUDIT_LOG_TENANT_FACET, AUDIT_LOG_TECHNICAL_FACET],
|
|
147
148
|
rowActions: [
|
|
148
149
|
{
|
|
149
150
|
kind: "navigate",
|
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
// REDACTED_PII_PLACEHOLDER before returning (payload and metadata), since the API leak guard refuses ciphertext.
|
|
7
7
|
|
|
8
8
|
import { selectMany, type TenantDb, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
10
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
11
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
11
12
|
import * as z from "zod";
|
|
12
13
|
import { redactPiiCiphertext } from "../../shared/index.ts";
|
|
14
|
+
import { eventLabelKeyOf, TECHNICAL_EVENT_TYPES } from "../event-classification.ts";
|
|
15
|
+
import {
|
|
16
|
+
AUDIT_EVENT_ACTIONS,
|
|
17
|
+
type AuditEventAction,
|
|
18
|
+
INCLUDE_TECHNICAL_FILTER,
|
|
19
|
+
} from "../web/constants.ts";
|
|
13
20
|
|
|
14
21
|
const MAX_LIMIT = 100;
|
|
15
22
|
|
|
@@ -31,6 +38,21 @@ export const auditListPayloadShape = {
|
|
|
31
38
|
to: z.iso.datetime().optional(),
|
|
32
39
|
} as const;
|
|
33
40
|
|
|
41
|
+
// The technical-entries facet rides on the `type` column (facets need a declared column).
|
|
42
|
+
export const technicalEntriesFilterSchema = z.strictObject({
|
|
43
|
+
field: z.literal("type"),
|
|
44
|
+
op: z.literal("in"),
|
|
45
|
+
value: z.array(z.literal(INCLUDE_TECHNICAL_FILTER)).max(1),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function includesTechnicalEntries(
|
|
49
|
+
filters: readonly { readonly field: string; readonly value: readonly unknown[] }[] | undefined,
|
|
50
|
+
): boolean {
|
|
51
|
+
return (
|
|
52
|
+
filters?.some((f) => f.field === "type" && f.value.includes(INCLUDE_TECHNICAL_FILTER)) ?? false
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
export const auditEventIdSchema = z
|
|
35
57
|
.string()
|
|
36
58
|
.regex(/^[1-9]\d{0,17}$/, "id must be a positive integer");
|
|
@@ -69,6 +91,7 @@ export type AuditListFilters = {
|
|
|
69
91
|
to?: string | undefined;
|
|
70
92
|
cursor?: string | undefined;
|
|
71
93
|
before?: string | undefined;
|
|
94
|
+
includeTechnical?: boolean | undefined;
|
|
72
95
|
};
|
|
73
96
|
|
|
74
97
|
/** `tenantCondition` is a tenant id, or an operator object for reads across tenants. */
|
|
@@ -81,7 +104,12 @@ export function buildAuditWhere(
|
|
|
81
104
|
if (p.aggregateType) where["aggregateType"] = p.aggregateType;
|
|
82
105
|
if (p.aggregateId) where["aggregateId"] = p.aggregateId;
|
|
83
106
|
if (p.eventType) where["type"] = p.eventType;
|
|
84
|
-
else if (p.search
|
|
107
|
+
else if (p.search || !p.includeTechnical) {
|
|
108
|
+
where["type"] = {
|
|
109
|
+
...(p.search && { like: `%${escapeLikePattern(p.search)}%` }),
|
|
110
|
+
...(!p.includeTechnical && { notIn: TECHNICAL_EVENT_TYPES }),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
85
113
|
if (p.userId) where["createdBy"] = p.userId;
|
|
86
114
|
const range = buildDateRange(p.from, p.to);
|
|
87
115
|
if (range) where["createdAt"] = range;
|
|
@@ -105,7 +133,29 @@ type AuditEventRow = {
|
|
|
105
133
|
createdBy: string;
|
|
106
134
|
};
|
|
107
135
|
|
|
108
|
-
|
|
136
|
+
function eventActionOf(type: string): AuditEventAction | null {
|
|
137
|
+
const suffix = type.slice(type.lastIndexOf(".") + 1);
|
|
138
|
+
return type.includes(".") ? (AUDIT_EVENT_ACTIONS.find((a) => a === suffix) ?? null) : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Entity events carry the entity name as aggregateType; the noun key is the
|
|
142
|
+
// renderer's own `<feature>:entity:<entity>:noun` convention.
|
|
143
|
+
export type EntityNounKeyResolver = (aggregateType: string) => string | null;
|
|
144
|
+
|
|
145
|
+
export function createEntityNounKeyResolver(registry: Registry | undefined): EntityNounKeyResolver {
|
|
146
|
+
const featureByEntity = new Map<string, string>();
|
|
147
|
+
for (const [featureName, feature] of registry?.features ?? []) {
|
|
148
|
+
for (const entityName of Object.keys(feature.entities ?? {})) {
|
|
149
|
+
featureByEntity.set(entityName, featureName);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return (aggregateType) => {
|
|
153
|
+
const featureName = featureByEntity.get(aggregateType);
|
|
154
|
+
return featureName === undefined ? null : `${featureName}:entity:${aggregateType}:noun`;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function serializeAuditEvent(r: AuditEventRow, nounKeyOf: EntityNounKeyResolver) {
|
|
109
159
|
return {
|
|
110
160
|
id: String(r.id),
|
|
111
161
|
tenantId: r.tenantId,
|
|
@@ -113,6 +163,9 @@ export function serializeAuditEvent(r: AuditEventRow) {
|
|
|
113
163
|
aggregateType: r.aggregateType,
|
|
114
164
|
version: r.version,
|
|
115
165
|
type: r.type,
|
|
166
|
+
eventAction: eventActionOf(r.type),
|
|
167
|
+
eventLabelKey: eventLabelKeyOf(r.type),
|
|
168
|
+
entityNounKey: nounKeyOf(r.aggregateType),
|
|
116
169
|
payload: redactPiiCiphertext(r.payload),
|
|
117
170
|
metadata: redactPiiCiphertext(r.metadata),
|
|
118
171
|
createdAt: r.createdAt,
|
|
@@ -120,10 +173,14 @@ export function serializeAuditEvent(r: AuditEventRow) {
|
|
|
120
173
|
};
|
|
121
174
|
}
|
|
122
175
|
|
|
123
|
-
export async function loadAuditEvent(
|
|
176
|
+
export async function loadAuditEvent(
|
|
177
|
+
db: TenantDb,
|
|
178
|
+
where: WhereObject,
|
|
179
|
+
nounKeyOf: EntityNounKeyResolver,
|
|
180
|
+
) {
|
|
124
181
|
const rows = await selectMany<AuditEventRow>(db, eventsTable, where, { limit: 1 });
|
|
125
182
|
const row = rows[0];
|
|
126
|
-
return row === undefined ? null : serializeAuditEvent(row);
|
|
183
|
+
return row === undefined ? null : serializeAuditEvent(row, nounKeyOf);
|
|
127
184
|
}
|
|
128
185
|
|
|
129
186
|
export async function listAuditEvents(
|
|
@@ -131,6 +188,7 @@ export async function listAuditEvents(
|
|
|
131
188
|
where: WhereObject,
|
|
132
189
|
p: { limit: number; sort?: "createdAt" | "type" | undefined },
|
|
133
190
|
direction: "asc" | "desc",
|
|
191
|
+
nounKeyOf: EntityNounKeyResolver,
|
|
134
192
|
) {
|
|
135
193
|
const rows = await selectMany<AuditEventRow>(db, eventsTable, where, {
|
|
136
194
|
// The id cursor only resumes an id-ordered listing. created_at comes from the
|
|
@@ -145,7 +203,7 @@ export async function listAuditEvents(
|
|
|
145
203
|
: [{ col: "id", direction }],
|
|
146
204
|
limit: p.limit,
|
|
147
205
|
});
|
|
148
|
-
const serialised = rows.map(serializeAuditEvent);
|
|
206
|
+
const serialised = rows.map((row) => serializeAuditEvent(row, nounKeyOf));
|
|
149
207
|
const last = serialised[serialised.length - 1];
|
|
150
208
|
// An id cursor cannot resume a type-ordered listing, so that sort is one page.
|
|
151
209
|
const nextId = serialised.length === p.limit && last && p.sort !== "type" ? last.id : null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
-
import { auditEventIdSchema, loadAuditEvent } from "./audit-events.ts";
|
|
5
|
+
import { auditEventIdSchema, createEntityNounKeyResolver, loadAuditEvent } from "./audit-events.ts";
|
|
6
6
|
import { requireAuditSystemDb } from "./audit-system-db.ts";
|
|
7
7
|
import { allTenantsCondition, assertMayReadAllTenants } from "./resolve-audit-tenant.ts";
|
|
8
8
|
|
|
@@ -18,9 +18,10 @@ export const detailsAllTenantsQuery = defineQueryHandler({
|
|
|
18
18
|
ctx.systemDb,
|
|
19
19
|
"audit:query:details-all-tenants",
|
|
20
20
|
).acknowledgeCrossTenant("SystemAdmin reads one audit event of any tenant");
|
|
21
|
-
return loadAuditEvent(
|
|
22
|
-
|
|
23
|
-
id: BigInt(query.payload.id),
|
|
24
|
-
|
|
21
|
+
return loadAuditEvent(
|
|
22
|
+
db,
|
|
23
|
+
{ tenantId: allTenantsCondition(undefined), id: BigInt(query.payload.id) },
|
|
24
|
+
createEntityNounKeyResolver(ctx.registry),
|
|
25
|
+
);
|
|
25
26
|
},
|
|
26
27
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
import { AUDIT_SCOPE_VALUES } from "../web/constants.ts";
|
|
9
|
-
import { auditEventIdSchema, loadAuditEvent } from "./audit-events.ts";
|
|
9
|
+
import { auditEventIdSchema, createEntityNounKeyResolver, loadAuditEvent } from "./audit-events.ts";
|
|
10
10
|
import { requireAuditSystemDb } from "./audit-system-db.ts";
|
|
11
11
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.ts";
|
|
12
12
|
|
|
@@ -24,10 +24,16 @@ export const detailsQuery = defineQueryHandler({
|
|
|
24
24
|
const db = requireAuditSystemDb(ctx.systemDb, "audit:query:details").assertTenantMatch(
|
|
25
25
|
query.user.tenantId,
|
|
26
26
|
);
|
|
27
|
-
return loadAuditEvent(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
return loadAuditEvent(
|
|
28
|
+
db,
|
|
29
|
+
{
|
|
30
|
+
tenantId: scopeFilter.tenantId,
|
|
31
|
+
...(scopeFilter.aggregateType !== undefined && {
|
|
32
|
+
aggregateType: scopeFilter.aggregateType,
|
|
33
|
+
}),
|
|
34
|
+
id: BigInt(query.payload.id),
|
|
35
|
+
},
|
|
36
|
+
createEntityNounKeyResolver(ctx.registry),
|
|
37
|
+
);
|
|
32
38
|
},
|
|
33
39
|
});
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
AUDIT_DATE_RANGE_CHECK,
|
|
8
8
|
auditListPayloadShape,
|
|
9
9
|
buildAuditWhere,
|
|
10
|
+
createEntityNounKeyResolver,
|
|
11
|
+
includesTechnicalEntries,
|
|
10
12
|
listAuditEvents,
|
|
13
|
+
technicalEntriesFilterSchema,
|
|
11
14
|
} from "./audit-events.ts";
|
|
12
15
|
import { requireAuditSystemDb } from "./audit-system-db.ts";
|
|
13
16
|
import { allTenantsCondition, assertMayReadAllTenants } from "./resolve-audit-tenant.ts";
|
|
@@ -15,33 +18,38 @@ import { allTenantsCondition, assertMayReadAllTenants } from "./resolve-audit-te
|
|
|
15
18
|
const MAX_TENANT_FILTER_IDS = 100;
|
|
16
19
|
|
|
17
20
|
// The reference facet on the tenant column sends exactly this shape.
|
|
18
|
-
const
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
.max(
|
|
21
|
+
const tenantIdFilterSchema = z.strictObject({
|
|
22
|
+
field: z.literal("tenantId"),
|
|
23
|
+
op: z.literal("in"),
|
|
24
|
+
value: z.array(z.uuid()).max(MAX_TENANT_FILTER_IDS),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const auditFiltersSchema = z
|
|
28
|
+
.array(z.union([tenantIdFilterSchema, technicalEntriesFilterSchema]))
|
|
29
|
+
.max(2);
|
|
27
30
|
|
|
28
31
|
export const listAllTenantsQuery = defineQueryHandler({
|
|
29
32
|
name: "list-all-tenants",
|
|
30
33
|
description:
|
|
31
34
|
"Lists audit-trail events of every tenant newest-first with cursor paging, each row carrying its tenantId; filterable by tenant, aggregate type, aggregate id, event type, actor and time range. SystemAdmin only; events of the system tenant are not included.",
|
|
32
35
|
schema: z
|
|
33
|
-
.object({ ...auditListPayloadShape, filters:
|
|
36
|
+
.object({ ...auditListPayloadShape, filters: auditFiltersSchema.optional() })
|
|
34
37
|
.refine(AUDIT_DATE_RANGE_CHECK.isValid, AUDIT_DATE_RANGE_CHECK.params),
|
|
35
38
|
access: { roles: access.systemAdmin },
|
|
36
39
|
handler: async (query, ctx) => {
|
|
37
40
|
assertMayReadAllTenants(query.user);
|
|
38
41
|
const p = query.payload;
|
|
39
42
|
const direction = p.sortDirection ?? "desc";
|
|
40
|
-
const
|
|
43
|
+
const requestedTenantIds = p.filters?.find((f) => f.field === "tenantId")?.value;
|
|
44
|
+
const where = buildAuditWhere(
|
|
45
|
+
allTenantsCondition(requestedTenantIds),
|
|
46
|
+
{ ...p, includeTechnical: includesTechnicalEntries(p.filters) },
|
|
47
|
+
direction,
|
|
48
|
+
);
|
|
41
49
|
const db = requireAuditSystemDb(
|
|
42
50
|
ctx.systemDb,
|
|
43
51
|
"audit:query:list-all-tenants",
|
|
44
52
|
).acknowledgeCrossTenant("SystemAdmin reads the audit trail of every tenant");
|
|
45
|
-
return listAuditEvents(db, where, p, direction);
|
|
53
|
+
return listAuditEvents(db, where, p, direction, createEntityNounKeyResolver(ctx.registry));
|
|
46
54
|
},
|
|
47
55
|
});
|
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
AUDIT_DATE_RANGE_CHECK,
|
|
17
17
|
auditListPayloadShape,
|
|
18
18
|
buildAuditWhere,
|
|
19
|
+
createEntityNounKeyResolver,
|
|
20
|
+
includesTechnicalEntries,
|
|
19
21
|
listAuditEvents,
|
|
22
|
+
technicalEntriesFilterSchema,
|
|
20
23
|
} from "./audit-events.ts";
|
|
21
24
|
import { requireAuditSystemDb } from "./audit-system-db.ts";
|
|
22
25
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.ts";
|
|
@@ -26,7 +29,11 @@ export const listQuery = defineQueryHandler({
|
|
|
26
29
|
description:
|
|
27
30
|
'Lists the tenant\'s audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when. A SystemAdmin can set scope "system" to read the app-instance system events (e.g. app.started) instead of the own tenant.',
|
|
28
31
|
schema: z
|
|
29
|
-
.object({
|
|
32
|
+
.object({
|
|
33
|
+
...auditListPayloadShape,
|
|
34
|
+
scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
|
|
35
|
+
filters: z.array(technicalEntriesFilterSchema).max(1).optional(),
|
|
36
|
+
})
|
|
30
37
|
.refine(AUDIT_DATE_RANGE_CHECK.isValid, AUDIT_DATE_RANGE_CHECK.params),
|
|
31
38
|
access: { roles: access.admin },
|
|
32
39
|
handler: async (query, ctx) => {
|
|
@@ -35,12 +42,16 @@ export const listQuery = defineQueryHandler({
|
|
|
35
42
|
const direction = p.sortDirection ?? "desc";
|
|
36
43
|
const where = buildAuditWhere(
|
|
37
44
|
scopeFilter.tenantId,
|
|
38
|
-
{
|
|
45
|
+
{
|
|
46
|
+
...p,
|
|
47
|
+
aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
|
|
48
|
+
includeTechnical: includesTechnicalEntries(p.filters),
|
|
49
|
+
},
|
|
39
50
|
direction,
|
|
40
51
|
);
|
|
41
52
|
const db = requireAuditSystemDb(ctx.systemDb, "audit:query:list").assertTenantMatch(
|
|
42
53
|
query.user.tenantId,
|
|
43
54
|
);
|
|
44
|
-
return listAuditEvents(db, where, p, direction);
|
|
55
|
+
return listAuditEvents(db, where, p, direction, createEntityNounKeyResolver(ctx.registry));
|
|
45
56
|
},
|
|
46
57
|
});
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import type { EditLayout, ListColumnSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import {
|
|
7
|
+
AUDIT_EVENT_CELL_COMPONENT,
|
|
8
|
+
AUDIT_OBJECT_CELL_COMPONENT,
|
|
9
|
+
INCLUDE_TECHNICAL_FILTER,
|
|
10
|
+
} from "./web/constants.ts";
|
|
6
11
|
|
|
7
12
|
const TENANT_REFERENCE = { refEntity: "tenant:tenant", refLabelField: "name" } as const;
|
|
8
13
|
const ACTOR_REFERENCE = { refEntity: "user:user", refLabelField: "displayName" } as const;
|
|
@@ -17,18 +22,32 @@ const TENANT_COLUMN = {
|
|
|
17
22
|
label: i18nKey("audit.log.col.tenant"),
|
|
18
23
|
...TENANT_REFERENCE,
|
|
19
24
|
} as const satisfies ListColumnSpec;
|
|
20
|
-
const
|
|
25
|
+
const EVENT_RENDERER = { react: { __component: AUDIT_EVENT_CELL_COMPONENT } } as const;
|
|
26
|
+
const OBJECT_RENDERER = { react: { __component: AUDIT_OBJECT_CELL_COMPONENT } } as const;
|
|
27
|
+
|
|
28
|
+
const TYPE_COLUMN = {
|
|
29
|
+
field: "type",
|
|
30
|
+
label: i18nKey("audit.log.col.type"),
|
|
31
|
+
renderer: EVENT_RENDERER,
|
|
32
|
+
} as const satisfies ListColumnSpec;
|
|
33
|
+
const OBJECT_COLUMN = {
|
|
34
|
+
field: "aggregateType",
|
|
35
|
+
label: i18nKey("audit.log.col.aggregateType"),
|
|
36
|
+
renderer: OBJECT_RENDERER,
|
|
37
|
+
hideOnNarrow: true,
|
|
38
|
+
} as const satisfies ListColumnSpec;
|
|
21
39
|
const ACTOR_COLUMN = {
|
|
22
40
|
field: "createdBy",
|
|
23
41
|
label: i18nKey("audit.log.col.actor"),
|
|
24
42
|
...ACTOR_REFERENCE,
|
|
25
43
|
} as const satisfies ListColumnSpec;
|
|
26
44
|
|
|
27
|
-
export const AUDIT_LOG_COLUMNS = [WHEN_COLUMN, TYPE_COLUMN, ACTOR_COLUMN] as const;
|
|
45
|
+
export const AUDIT_LOG_COLUMNS = [WHEN_COLUMN, TYPE_COLUMN, OBJECT_COLUMN, ACTOR_COLUMN] as const;
|
|
28
46
|
export const AUDIT_LOG_ALL_TENANTS_COLUMNS = [
|
|
29
47
|
WHEN_COLUMN,
|
|
30
48
|
TENANT_COLUMN,
|
|
31
49
|
TYPE_COLUMN,
|
|
50
|
+
OBJECT_COLUMN,
|
|
32
51
|
ACTOR_COLUMN,
|
|
33
52
|
] as const;
|
|
34
53
|
|
|
@@ -39,6 +58,15 @@ export const AUDIT_LOG_DATE_RANGE_FACET = {
|
|
|
39
58
|
params: { from: "from", to: "to" },
|
|
40
59
|
} as const;
|
|
41
60
|
|
|
61
|
+
export const AUDIT_LOG_TECHNICAL_FACET = {
|
|
62
|
+
type: "select",
|
|
63
|
+
field: "type",
|
|
64
|
+
label: i18nKey("audit.log.facet.technical"),
|
|
65
|
+
options: [
|
|
66
|
+
{ value: INCLUDE_TECHNICAL_FILTER, label: i18nKey("audit.log.facet.technical.include") },
|
|
67
|
+
],
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
42
70
|
export const AUDIT_LOG_TENANT_FACET = {
|
|
43
71
|
type: "reference",
|
|
44
72
|
field: "tenantId",
|
|
@@ -63,10 +91,10 @@ export function auditLogDetailLayout(options: { readonly withTenant: boolean }):
|
|
|
63
91
|
sections: [
|
|
64
92
|
{
|
|
65
93
|
fields: [
|
|
66
|
-
"type",
|
|
94
|
+
{ field: "type", renderer: EVENT_RENDERER },
|
|
67
95
|
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
68
96
|
...(options.withTenant ? [{ field: "tenantId", ...TENANT_REFERENCE }] : []),
|
|
69
|
-
"aggregateType",
|
|
97
|
+
{ field: "aggregateType", renderer: OBJECT_RENDERER },
|
|
70
98
|
"aggregateId",
|
|
71
99
|
{ field: "createdBy", ...ACTOR_REFERENCE },
|
|
72
100
|
"id",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Column renderers of the audit log, registered via `auditClient()`'s `columnRenderers`
|
|
2
|
+
// (screens.ts references them by name). The list frame stays the generic projectionList.
|
|
3
|
+
|
|
4
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { auditEntityLabel, isAuditEventAction } from "./audit-event-labels.ts";
|
|
7
|
+
|
|
8
|
+
export function AuditEventCell({ value, row }: ColumnRendererProps): ReactNode {
|
|
9
|
+
const t = useTranslation();
|
|
10
|
+
const rawType = typeof value === "string" ? value : "";
|
|
11
|
+
const labelKey = row["eventLabelKey"];
|
|
12
|
+
if (typeof labelKey === "string") return t(labelKey);
|
|
13
|
+
const action = row["eventAction"];
|
|
14
|
+
const entity = auditEntityLabel(t, row);
|
|
15
|
+
if (!isAuditEventAction(action) || entity === null) return rawType;
|
|
16
|
+
return t("audit.log.event", { entity, action: t(`audit.log.action.${action}`) });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AuditObjectCell({ row }: ColumnRendererProps): ReactNode {
|
|
20
|
+
const t = useTranslation();
|
|
21
|
+
return auditEntityLabel(t, row) ?? "";
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { AUDIT_EVENT_ACTIONS, type AuditEventAction } from "./constants.ts";
|
|
3
|
+
|
|
4
|
+
type Translate = ReturnType<typeof useTranslation>;
|
|
5
|
+
|
|
6
|
+
export function isAuditEventAction(value: unknown): value is AuditEventAction {
|
|
7
|
+
return AUDIT_EVENT_ACTIONS.some((action) => action === value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function humanizeEntityName(aggregateType: string): string {
|
|
11
|
+
const spaced = aggregateType.replaceAll("-", " ").replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
12
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Singular entity noun: the entity's own `:noun` key, then the audit log's label for bundled entities, then the humanized aggregate type. */
|
|
16
|
+
export function auditEntityLabel(
|
|
17
|
+
t: Translate,
|
|
18
|
+
row: Readonly<Record<string, unknown>>,
|
|
19
|
+
): string | null {
|
|
20
|
+
const aggregateType = row["aggregateType"];
|
|
21
|
+
if (typeof aggregateType !== "string") return null;
|
|
22
|
+
const nounKey = row["entityNounKey"];
|
|
23
|
+
if (typeof nounKey === "string") {
|
|
24
|
+
const noun = t(nounKey, { count: 1 });
|
|
25
|
+
if (noun !== nounKey) return noun;
|
|
26
|
+
}
|
|
27
|
+
const bundledKey = `audit.log.entity.${aggregateType}`;
|
|
28
|
+
const bundledLabel = t(bundledKey);
|
|
29
|
+
return bundledLabel === bundledKey ? humanizeEntityName(aggregateType) : bundledLabel;
|
|
30
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
|
-
import {
|
|
3
|
+
import { AuditEventCell, AuditObjectCell } from "./audit-event-cell.tsx";
|
|
4
|
+
import {
|
|
5
|
+
AUDIT_EVENT_CELL_COMPONENT,
|
|
6
|
+
AUDIT_FEATURE,
|
|
7
|
+
AUDIT_OBJECT_CELL_COMPONENT,
|
|
8
|
+
} from "./constants.ts";
|
|
4
9
|
import { defaultTranslations } from "./default-translations.ts";
|
|
5
10
|
|
|
6
11
|
export type AuditClientOptions = {
|
|
@@ -11,5 +16,9 @@ export function auditClient(options?: AuditClientOptions): ClientFeatureDefiniti
|
|
|
11
16
|
return {
|
|
12
17
|
name: AUDIT_FEATURE,
|
|
13
18
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
19
|
+
columnRenderers: {
|
|
20
|
+
[AUDIT_EVENT_CELL_COMPONENT]: AuditEventCell,
|
|
21
|
+
[AUDIT_OBJECT_CELL_COMPONENT]: AuditObjectCell,
|
|
22
|
+
},
|
|
14
23
|
};
|
|
15
24
|
}
|
|
@@ -13,6 +13,17 @@ export const SYSTEM_ACTOR_IDS: ReadonlySet<string> = new Set([
|
|
|
13
13
|
"00000000-0000-0000-0000-000000000000",
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
+
/** Column-renderer names shared by screens.ts and `auditClient()`. */
|
|
17
|
+
export const AUDIT_EVENT_CELL_COMPONENT = "audit-event-cell" as const;
|
|
18
|
+
export const AUDIT_OBJECT_CELL_COMPONENT = "audit-object-cell" as const;
|
|
19
|
+
|
|
20
|
+
/** Action suffix of entity events (`<entity>.<action>`); other event types have none. */
|
|
21
|
+
export const AUDIT_EVENT_ACTIONS = ["created", "updated", "deleted", "restored"] as const;
|
|
22
|
+
export type AuditEventAction = (typeof AUDIT_EVENT_ACTIONS)[number];
|
|
23
|
+
|
|
24
|
+
/** Value of the facet that brings technical entries (escape-hatch use) back into the log. */
|
|
25
|
+
export const INCLUDE_TECHNICAL_FILTER = "include-technical" as const;
|
|
26
|
+
|
|
16
27
|
export const AuditQueries = {
|
|
17
28
|
list: "audit:query:list",
|
|
18
29
|
details: "audit:query:details",
|