@cosmicdrift/kumiko-bundled-features 0.165.2 → 0.165.3
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 +7 -7
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +1 -1
- package/src/__tests__/access-invalidation-stream.integration.test.ts +80 -66
- package/src/admin-shell/changes.json +8 -0
- package/src/agent-tools/changes.json +1 -0
- package/src/audit/__tests__/audit.integration.test.ts +5 -2
- package/src/audit/changes.json +8 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -0
- package/src/auth-email-password/changes.json +26 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +3 -11
- package/src/auth-email-password/web/__tests__/fetch-mock.ts +14 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +3 -11
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +3 -11
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +3 -11
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +3 -11
- package/src/auth-foundation/changes.json +8 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +6 -6
- package/src/auth-mfa/changes.json +38 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +3 -11
- package/src/auth-mfa-user-data/changes.json +1 -0
- package/src/billing-foundation/changes.json +1 -0
- package/src/cap-counter/changes.json +1 -0
- package/src/channel-email/changes.json +1 -0
- package/src/channel-in-app/changes.json +1 -0
- package/src/channel-push/changes.json +1 -0
- package/src/compliance-profiles/changes.json +1 -0
- package/src/config/changes.json +9 -0
- package/src/crypto-shredding/changes.json +8 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +13 -0
- package/src/custom-fields/changes.json +8 -0
- package/src/data-retention/changes.json +8 -0
- package/src/delivery/changes.json +8 -0
- package/src/document-ingest-foundation/changes.json +1 -0
- package/src/feature-toggles/changes.json +26 -0
- package/src/file-foundation/changes.json +1 -0
- package/src/file-provider-inmemory/changes.json +1 -0
- package/src/file-provider-s3/changes.json +1 -0
- package/src/file-provider-s3-env/changes.json +1 -0
- package/src/files/changes.json +1 -0
- package/src/files-provider-s3/changes.json +1 -0
- package/src/folders/changes.json +1 -0
- package/src/folders-user-data/changes.json +1 -0
- package/src/foundation-shared/changes.json +1 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/changes.json +8 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/src/inbound-provider-imap/changes.json +1 -0
- package/src/inbound-provider-inmemory/changes.json +1 -0
- package/src/jobs/__tests__/jobs-catalog.integration.test.ts +159 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +1 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +4 -2
- package/src/jobs/changes.json +8 -0
- package/src/jobs/constants.ts +2 -0
- package/src/jobs/feature.ts +3 -1
- package/src/jobs/handlers/catalog.query.ts +43 -0
- package/src/jobs/handlers/trigger.write.ts +26 -2
- package/src/jobs/i18n.ts +33 -0
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +223 -0
- package/src/jobs/web/job-runs-screen.tsx +218 -84
- package/src/ledger/changes.json +1 -0
- package/src/legal-pages/changes.json +1 -0
- package/src/mail-foundation/changes.json +1 -0
- package/src/mail-transport-inmemory/changes.json +1 -0
- package/src/mail-transport-smtp/changes.json +1 -0
- package/src/managed-pages/changes.json +20 -0
- package/src/page-render/changes.json +14 -0
- package/src/personal-access-tokens/changes.json +8 -0
- package/src/presets/changes.json +1 -0
- package/src/presets/dsgvo-self-service.ts +1 -1
- package/src/rate-limiting/changes.json +1 -0
- package/src/readiness/changes.json +1 -0
- package/src/renderer-foundation/changes.json +1 -0
- package/src/renderer-simple/changes.json +1 -0
- package/src/secrets/changes.json +1 -0
- package/src/seo/changes.json +20 -0
- package/src/sessions/changes.json +8 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -1
- package/src/shared/changes.json +1 -0
- package/src/step-dispatcher/changes.json +1 -0
- package/src/subscription-mollie/changes.json +1 -0
- package/src/subscription-stripe/changes.json +1 -0
- package/src/tags/changes.json +1 -0
- package/src/template-resolver/changes.json +1 -0
- package/src/tenant/changes.json +27 -0
- package/src/tenant-lifecycle/changes.json +1 -0
- package/src/tenant-settings/changes.json +1 -0
- package/src/text-content/changes.json +14 -0
- package/src/tier-engine/changes.json +1 -0
- package/src/user/changes.json +1 -0
- package/src/user/schema/user.ts +8 -5
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/download.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +0 -2
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +0 -1
- package/src/user-data-rights/changes.json +21 -0
- package/src/user-data-rights/feature.ts +7 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +4 -20
- package/src/user-data-rights/handlers/restrict-account.write.ts +4 -20
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -0
- package/src/user-data-rights/lib/deny-if-target-outside-admin-tenant.ts +32 -0
- package/src/user-data-rights/run-forget-cleanup.ts +23 -0
- package/src/user-data-rights-defaults/changes.json +8 -0
- package/src/user-profile/changes.json +8 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.116.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "data-retention cleanup now implements the `anonymize` strategy (per-field anonymize funct…",
|
|
6
|
+
"detail": "data-retention cleanup now implements the `anonymize` strategy (per-field anonymize functions applied via the event-store executor, idempotent — a re-run appends zero events) and completes `blockDelete`: rows stay untouched during the keepFor legal hold, after expiry the anonymize functions run (row stays, person link goes). `RunRetentionCleanupResult.anonymizeDeferred` is replaced by `anonymized: number`; entities with an anonymize/blockDelete policy but no anonymize-annotated fields are reported in `skipped` with reason `missing_anonymize_fields`."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.120.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "PII on custom-event payloads (#799).",
|
|
6
|
+
"detail": "PII on custom-event payloads (#799). `r.defineEvent(name, schema, { piiFields: { recipientAddress: { subjectField: \"recipientId\" } } })` declares payload fields that are encrypted under the owning user's DEK (crypto-shredding). Enforcement lives in the low-level event-store `append()` — the single write funnel — so `ctx.appendEvent`, MSP-apply AND out-of-dispatcher writers (delivery attempt-log, jobs run-logger) are all covered; the stored event and the returned echo carry ciphertext, keeping inline projections and rebuilds identical. A null subject field (system cron runs, recipient-less attempts) stays plaintext — there is no user key to shred. Misconfigured `piiFields` (unknown field/subjectField) throw at feature-definition time. Bundled features annotated: `delivery:event:attempt`.`recipientAddress` (subject = recipientId) and `jobs` `run-started`.`payload` (subject = triggeredById); the pseudonymous fk ids stay plaintext. `delivery log.query` and `jobs list/details` decrypt for display — a forgotten subject shows `[[erased]]`. This makes the events-only aggregates from #797 Art.-17-capable: user-forget erases the DEK, historical delivery addresses and job payloads become unreadable without touching the append-only stream. New exports: `encryptPiiValueForSubject`, `configureEventPiiCatalog`/`configuredEventPiiCatalog`/`encryptEventPayloadPii` (framework/crypto)."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.165.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "compose-tier-resolver: fixed requires-cascade",
|
|
6
|
+
"detail": "Tier resolver now correctly handles the requires-cascade and marks non-toggleable features properly."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.163.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "feature-toggles: register `store_global_feature_state` via `r.storeTable()` (exported as …",
|
|
12
|
+
"detail": "feature-toggles: register `store_global_feature_state` via `r.storeTable()` (exported as `globalFeatureStateTableMeta`). Previously the table was only a plain Drizzle export with no store-table meta, so `collectTableMetas(FEATURES)` never saw it — `kumiko schema generate` reported no changes for any app mounting `createFeatureTogglesFeature()`, and the table was missing in any DB that wasn't manually provisioned (e.g. via `unsafePushTables` in tests). `setupTestStack` now auto-provisions the table like every other `r.storeTable()` feature."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.163.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "feature-toggles: fix `composeTierResolverWithGlobalToggles` to actually grant tier-unawar…",
|
|
18
|
+
"detail": "feature-toggles: fix `composeTierResolverWithGlobalToggles` to actually grant tier-unaware toggleable features. The previous version only ever narrowed the tier resolver's own set — a toggleable feature that no tier's `features` list mentions at all (e.g. a pure operator kill-switch like a self-registration toggle, never differentiated by plan) could never appear, regardless of its declared default or an explicit `true` override, because narrowing can only remove names, never add ones the tier never granted. Now takes a third `registry` argument and unions in `computeEffectiveFeatures`' normal cascade for every feature name absent from `tierResolver(SYSTEM_TENANT_ID)` (the tier-managed set) — tier-managed toggleables keep the narrow-only semantics from before, tier-unaware ones are governed purely by their own default/override. - @cosmicdrift/kumiko-framework@0.163.0 - @cosmicdrift/kumiko-headless@0.163.0 - @cosmicdrift/kumiko-renderer@0.163.0 - @cosmicdrift/kumiko-dispatcher-live@0.163.0 - @cosmicdrift/kumiko-renderer-web@0.163.0"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.162.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "feature-toggles: add `composeTierResolverWithGlobalToggles` to combine a tier-engine `ten…",
|
|
24
|
+
"detail": "feature-toggles: add `composeTierResolverWithGlobalToggles` to combine a tier-engine `tenantTierResolver` with a global `GlobalFeatureToggleRuntime` into a single `effectiveFeatures` resolver. Apps running both tier-engine (per-tenant feature cuts) and feature-toggles (global operator switches, e.g. a runtime kill-switch not tied to any tier) previously had no way to compose them — the framework only auto-wires one `tenantTierResolver` plugin. The global layer only narrows what the tier grants (an explicit `enabled:false` row removes a feature; no row or `true` leaves the tier's grant untouched) — it never falls back to a toggleable feature's own default, which would otherwise silently disable every tier-gated toggleable feature the moment feature-toggles gets composed in. Also adds `GlobalFeatureToggleRuntime.readOverride(name)` for the raw per-feature override, bypassing the requires() cascade."
|
|
25
|
+
}
|
|
26
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -228,8 +228,10 @@ describe("watch-supervisor — error semantics", () => {
|
|
|
228
228
|
const supervisor = createSupervisor();
|
|
229
229
|
try {
|
|
230
230
|
await supervisor.start();
|
|
231
|
-
await waitFor(() => {
|
|
231
|
+
await waitFor(async () => {
|
|
232
232
|
expect(isWatching(accountId)).toBe(true);
|
|
233
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
234
|
+
expect(accounts[0]?.["watchState"]).toBe("watching");
|
|
233
235
|
});
|
|
234
236
|
|
|
235
237
|
emitWatchError(accountId, new InboundAuthError("token revoked"));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.150.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "`@cosmicdrift/kumiko-framework/engine` bekommt `ctx.tryAppendEvent` — ein savepoint-scope…",
|
|
6
|
+
"detail": "`@cosmicdrift/kumiko-framework/engine` bekommt `ctx.tryAppendEvent` — ein savepoint-scoped Gegenstück zu `ctx.unsafeAppendEvent`, das `VersionConflictError` als `{ ok: false, conflict }` zurückgibt statt zu werfen, ohne die restliche Handler-Transaktion zu poisonen (Bun.SQL/ postgres.js brechen den gesamten `begin()` bei einem ungefangenen Statement- Fehler ab, SQLSTATE 25P02, selbst wenn der JS-Error gefangen wird — `tryAppendEvent` läuft dafür in einem echten `SAVEPOINT`). `@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation`: der `ingest-message`-Handler nutzt `ctx.tryAppendEvent` für den Message-Append — zwei parallele Ingest-Aufrufe für dieselbe `providerMessageId` (Watch-Push vs. Poll-Reconciliation-Überschneidung) liefern jetzt beide `{ duplicate: scheitert (#1038, Finding aus PR-Review #952). - @cosmicdrift/kumiko-framework@0.150.0 - @cosmicdrift/kumiko-headless@0.150.0 - @cosmicdrift/kumiko-renderer@0.150.0 - @cosmicdrift/kumiko-dispatcher-live@0.150.0 - @cosmicdrift/kumiko-renderer-web@0.150.0"
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -407,7 +407,9 @@ export function createInboundMailSupervisor(
|
|
|
407
407
|
}
|
|
408
408
|
state.stop = stop;
|
|
409
409
|
state.backoffMs = backoffInitialMs;
|
|
410
|
-
|
|
410
|
+
// Await — fire-and-forget raced with a later auth_error mark under
|
|
411
|
+
// try-first waitFor (isWatching true before projection settled).
|
|
412
|
+
await markAccount(account, { watchState: "watching" }, "watch_supervisor");
|
|
411
413
|
} catch (err) {
|
|
412
414
|
const keepRunning = await handleSyncError(account, err);
|
|
413
415
|
if (keepRunning) scheduleRestart(err);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Catalog + trigger hardening for #1602 — manual-only surface.
|
|
2
|
+
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
4
|
+
import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import {
|
|
7
|
+
createRegistry,
|
|
8
|
+
defineFeature,
|
|
9
|
+
type SessionUser,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
+
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
13
|
+
import {
|
|
14
|
+
createTestDb,
|
|
15
|
+
createTestRedis,
|
|
16
|
+
type TestDb,
|
|
17
|
+
type TestRedis,
|
|
18
|
+
TestUsers,
|
|
19
|
+
unsafePushTables,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import type { Hono } from "hono";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { JobErrors, JobHandlers, JobQueries } from "../constants";
|
|
24
|
+
import { createJobsFeature } from "../feature";
|
|
25
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
26
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
27
|
+
|
|
28
|
+
let testDb: TestDb;
|
|
29
|
+
let testRedis: TestRedis;
|
|
30
|
+
let db: DbConnection;
|
|
31
|
+
let app: Hono;
|
|
32
|
+
let jwt: JwtHelper;
|
|
33
|
+
let jobRunner: JobRunner;
|
|
34
|
+
|
|
35
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
36
|
+
const JWT_SECRET = "test-jwt-secret-for-jobs-catalog-32chars!!";
|
|
37
|
+
|
|
38
|
+
const appFeature = defineFeature("catalog-app", (r) => {
|
|
39
|
+
r.job(
|
|
40
|
+
"manualEcho",
|
|
41
|
+
{ trigger: { manual: true }, schema: z.object({ entity: z.string().min(1) }) },
|
|
42
|
+
async () => {},
|
|
43
|
+
);
|
|
44
|
+
r.job("cronOnly", { trigger: { cron: "0 * * * *" } }, async () => {});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
testDb = await createTestDb();
|
|
49
|
+
testRedis = await createTestRedis();
|
|
50
|
+
db = testDb.db;
|
|
51
|
+
|
|
52
|
+
const registry = createRegistry([appFeature, createJobsFeature()]);
|
|
53
|
+
await unsafePushTables(db, { jobRunsTable, jobRunLogsTable });
|
|
54
|
+
await createEventsTable(db);
|
|
55
|
+
|
|
56
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
57
|
+
const logger = createJobRunLogger({ db, registry });
|
|
58
|
+
jobRunner = createJobRunner({
|
|
59
|
+
registry,
|
|
60
|
+
context: { db },
|
|
61
|
+
redisUrl,
|
|
62
|
+
consumerLane: "worker",
|
|
63
|
+
queueNamePrefix: `kumiko-jobs-catalog-test-${Date.now()}`,
|
|
64
|
+
...logger,
|
|
65
|
+
});
|
|
66
|
+
const context = { db, registry, jobRunner };
|
|
67
|
+
const server = buildServer({ registry, context, jwtSecret: JWT_SECRET });
|
|
68
|
+
app = server.app;
|
|
69
|
+
jwt = server.jwt;
|
|
70
|
+
|
|
71
|
+
await jobRunner.start();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await jobRunner.stop();
|
|
76
|
+
await testDb.cleanup();
|
|
77
|
+
await testRedis.cleanup();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
async function req(
|
|
81
|
+
method: string,
|
|
82
|
+
path: string,
|
|
83
|
+
user: SessionUser,
|
|
84
|
+
body?: unknown,
|
|
85
|
+
): Promise<Response> {
|
|
86
|
+
const token = await jwt.sign(user);
|
|
87
|
+
const init: RequestInit = {
|
|
88
|
+
method,
|
|
89
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
90
|
+
};
|
|
91
|
+
if (body) init.body = JSON.stringify(body);
|
|
92
|
+
return app.request(path, init);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function write(user: SessionUser, type: string, payload: unknown) {
|
|
96
|
+
const res = await req("POST", "/api/write", user, { type, payload });
|
|
97
|
+
return res.json();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function query(user: SessionUser, type: string, payload: unknown) {
|
|
101
|
+
const res = await req("POST", "/api/query", user, { type, payload });
|
|
102
|
+
const body = await res.json();
|
|
103
|
+
if (res.status !== 200) {
|
|
104
|
+
throw new Error(`query ${type} → ${res.status}: ${JSON.stringify(body)}`);
|
|
105
|
+
}
|
|
106
|
+
return body;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("jobs:query:catalog", () => {
|
|
110
|
+
test("lists only manual jobs including framework builtins", async () => {
|
|
111
|
+
const result = await query(systemAdmin, JobQueries.catalog, {});
|
|
112
|
+
type CatalogRow = {
|
|
113
|
+
readonly jobName: string;
|
|
114
|
+
readonly perTenant: boolean;
|
|
115
|
+
readonly payloadSchema: Record<string, unknown> | null;
|
|
116
|
+
};
|
|
117
|
+
const rows = (result.data as { rows: readonly CatalogRow[] }).rows;
|
|
118
|
+
const names = rows.map((r) => r.jobName);
|
|
119
|
+
expect(names).toContain("catalog-app:job:manual-echo");
|
|
120
|
+
expect(names).toContain("jobs:job:reindex-entity");
|
|
121
|
+
expect(names).toContain("jobs:job:projection-rebuild");
|
|
122
|
+
expect(names).not.toContain("catalog-app:job:cron-only");
|
|
123
|
+
|
|
124
|
+
const echo = rows.find((r) => r.jobName === "catalog-app:job:manual-echo");
|
|
125
|
+
expect(echo).toBeDefined();
|
|
126
|
+
expect(echo?.payloadSchema).not.toBeNull();
|
|
127
|
+
|
|
128
|
+
const reindex = rows.find((r) => r.jobName === "jobs:job:reindex-entity");
|
|
129
|
+
expect(reindex?.perTenant).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("jobs:write:trigger hardening", () => {
|
|
134
|
+
test("rejects cron-only jobs", async () => {
|
|
135
|
+
const result = await write(systemAdmin, JobHandlers.trigger, {
|
|
136
|
+
jobName: "catalog-app:job:cron-only",
|
|
137
|
+
});
|
|
138
|
+
expect(result.isSuccess).toBe(false);
|
|
139
|
+
expect(result.error?.code).toBe("unprocessable");
|
|
140
|
+
expect(result.error?.details).toMatchObject({ reason: JobErrors.notManual });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("rejects invalid payload against job schema", async () => {
|
|
144
|
+
const result = await write(systemAdmin, JobHandlers.trigger, {
|
|
145
|
+
jobName: "catalog-app:job:manual-echo",
|
|
146
|
+
payload: {},
|
|
147
|
+
});
|
|
148
|
+
expect(result.isSuccess).toBe(false);
|
|
149
|
+
expect(result.error?.code).toBe("validation_error");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("accepts valid schema payload", async () => {
|
|
153
|
+
const result = await write(systemAdmin, JobHandlers.trigger, {
|
|
154
|
+
jobName: "catalog-app:job:manual-echo",
|
|
155
|
+
payload: { entity: "credit" },
|
|
156
|
+
});
|
|
157
|
+
expect(result.isSuccess).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -30,6 +30,7 @@ describe("jobs screens + handler access alignment", () => {
|
|
|
30
30
|
const roles = [...SYSTEM_ADMIN_ROLES];
|
|
31
31
|
expect(rolesOf(jobs.queryHandlers["list"]?.access)).toEqual(roles);
|
|
32
32
|
expect(rolesOf(jobs.queryHandlers["details"]?.access)).toEqual(roles);
|
|
33
|
+
expect(rolesOf(jobs.queryHandlers["catalog"]?.access)).toEqual(roles);
|
|
33
34
|
expect(rolesOf(jobs.writeHandlers["trigger"]?.access)).toEqual(roles);
|
|
34
35
|
expect(rolesOf(jobs.writeHandlers["retry"]?.access)).toEqual(roles);
|
|
35
36
|
});
|
|
@@ -45,10 +45,11 @@ afterAll(async () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
describe("access matrix: jobs operator surface is SystemAdmin-only", () => {
|
|
48
|
-
test("list, detail, trigger, retry handlers share SystemAdmin", () => {
|
|
48
|
+
test("list, detail, catalog, trigger, retry handlers share SystemAdmin", () => {
|
|
49
49
|
const roles = [...SYSTEM_ADMIN_ROLES];
|
|
50
50
|
expect(rolesOf(stack.registry.getQueryHandler(JobQueries.list)?.access)).toEqual(roles);
|
|
51
51
|
expect(rolesOf(stack.registry.getQueryHandler(JobQueries.details)?.access)).toEqual(roles);
|
|
52
|
+
expect(rolesOf(stack.registry.getQueryHandler(JobQueries.catalog)?.access)).toEqual(roles);
|
|
52
53
|
expect(rolesOf(stack.registry.getWriteHandler(JobHandlers.trigger)?.access)).toEqual(roles);
|
|
53
54
|
expect(rolesOf(stack.registry.getWriteHandler(JobHandlers.retry)?.access)).toEqual(roles);
|
|
54
55
|
});
|
|
@@ -65,7 +66,7 @@ describe("access matrix: jobs operator surface is SystemAdmin-only", () => {
|
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
describe("non-SystemAdmin denied jobs HTTP surface", () => {
|
|
68
|
-
test("TenantAdmin 403 on list, detail, trigger", async () => {
|
|
69
|
+
test("TenantAdmin 403 on list, detail, catalog, trigger", async () => {
|
|
69
70
|
for (const fn of [
|
|
70
71
|
() => stack.http.query(JobQueries.list, {}, tenantAdmin),
|
|
71
72
|
() =>
|
|
@@ -74,6 +75,7 @@ describe("non-SystemAdmin denied jobs HTTP surface", () => {
|
|
|
74
75
|
{ runId: "00000000-0000-4000-8000-000000000099" },
|
|
75
76
|
tenantAdmin,
|
|
76
77
|
),
|
|
78
|
+
() => stack.http.query(JobQueries.catalog, {}, tenantAdmin),
|
|
77
79
|
() =>
|
|
78
80
|
stack.http.write(JobHandlers.trigger, { jobName: "jobs-sec-app:job:ping" }, tenantAdmin),
|
|
79
81
|
] as const) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.120.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "PII on custom-event payloads (#799).",
|
|
6
|
+
"detail": "PII on custom-event payloads (#799). `r.defineEvent(name, schema, { piiFields: { recipientAddress: { subjectField: \"recipientId\" } } })` declares payload fields that are encrypted under the owning user's DEK (crypto-shredding). Enforcement lives in the low-level event-store `append()` — the single write funnel — so `ctx.appendEvent`, MSP-apply AND out-of-dispatcher writers (delivery attempt-log, jobs run-logger) are all covered; the stored event and the returned echo carry ciphertext, keeping inline projections and rebuilds identical. A null subject field (system cron runs, recipient-less attempts) stays plaintext — there is no user key to shred. Misconfigured `piiFields` (unknown field/subjectField) throw at feature-definition time. Bundled features annotated: `delivery:event:attempt`.`recipientAddress` (subject = recipientId) and `jobs` `run-started`.`payload` (subject = triggeredById); the pseudonymous fk ids stay plaintext. `delivery log.query` and `jobs list/details` decrypt for display — a forgotten subject shows `[[erased]]`. This makes the events-only aggregates from #797 Art.-17-capable: user-forget erases the DEK, historical delivery addresses and job payloads become unreadable without touching the append-only stream. New exports: `encryptPiiValueForSubject`, `configureEventPiiCatalog`/`configuredEventPiiCatalog`/`encryptEventPayloadPii` (framework/crypto)."
|
|
7
|
+
}
|
|
8
|
+
]
|
package/src/jobs/constants.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const JobHandlers = {
|
|
|
12
12
|
export const JobQueries = {
|
|
13
13
|
list: "jobs:query:list",
|
|
14
14
|
details: "jobs:query:details",
|
|
15
|
+
catalog: "jobs:query:catalog",
|
|
15
16
|
} as const;
|
|
16
17
|
|
|
17
18
|
// Error codes
|
|
@@ -19,6 +20,7 @@ export const JobErrors = {
|
|
|
19
20
|
unknownJob: "unknown_job",
|
|
20
21
|
notFound: "not_found",
|
|
21
22
|
onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
|
|
23
|
+
notManual: "job_not_manual",
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
24
26
|
/** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
|
package/src/jobs/feature.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID } from "./constants";
|
|
|
11
11
|
// payloads against these schemas before low-level append() — that's what
|
|
12
12
|
// keeps out-of-dispatcher writes as type-safe as ctx.appendEvent.
|
|
13
13
|
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
|
|
14
|
+
import { catalogQuery } from "./handlers/catalog.query";
|
|
14
15
|
import { detailQuery } from "./handlers/detail.query";
|
|
15
16
|
import { listQuery } from "./handlers/list.query";
|
|
16
17
|
import {
|
|
@@ -32,7 +33,7 @@ import { jobRunLogsTable, jobRunLogsTableMeta, jobRunsTable } from "./job-run-ta
|
|
|
32
33
|
export function createJobsFeature(): FeatureDefinition {
|
|
33
34
|
return defineFeature("jobs", (r) => {
|
|
34
35
|
r.describe(
|
|
35
|
-
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:
|
|
36
|
+
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI.",
|
|
36
37
|
);
|
|
37
38
|
r.uiHints({
|
|
38
39
|
displayLabel: "Jobs · Audit & Operator UI",
|
|
@@ -194,6 +195,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
194
195
|
const queries = {
|
|
195
196
|
list: r.queryHandler(listQuery),
|
|
196
197
|
detail: r.queryHandler(detailQuery),
|
|
198
|
+
catalog: r.queryHandler(catalogQuery),
|
|
197
199
|
};
|
|
198
200
|
|
|
199
201
|
const systemAdminAccess = { roles: ["SystemAdmin"] as const };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineQueryHandler, type JobDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export type ManualJobCatalogEntry = {
|
|
5
|
+
readonly jobName: string;
|
|
6
|
+
readonly perTenant: boolean;
|
|
7
|
+
/** Compact JSON Schema (or null when the job accepts any/empty payload). */
|
|
8
|
+
readonly payloadSchema: Record<string, unknown> | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function isManualTrigger(trigger: JobDefinition["trigger"]): boolean {
|
|
12
|
+
return "manual" in trigger && trigger.manual === true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function payloadSchemaJson(job: JobDefinition): Record<string, unknown> | null {
|
|
16
|
+
if (job.schema === undefined) return null;
|
|
17
|
+
try {
|
|
18
|
+
return z.toJSONSchema(job.schema) as Record<string, unknown>;
|
|
19
|
+
} catch {
|
|
20
|
+
// Non-JSON-Schema-able Zod types (transforms/effects) — UI still lists the job.
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** SystemAdmin catalog of jobs that may be started via `jobs:write:trigger`. */
|
|
26
|
+
export const catalogQuery = defineQueryHandler({
|
|
27
|
+
name: "catalog",
|
|
28
|
+
schema: z.object({}),
|
|
29
|
+
access: { roles: ["SystemAdmin"] },
|
|
30
|
+
handler: async (_query, ctx): Promise<{ rows: readonly ManualJobCatalogEntry[] }> => {
|
|
31
|
+
const rows: ManualJobCatalogEntry[] = [];
|
|
32
|
+
for (const [jobName, job] of ctx.registry.getAllJobs()) {
|
|
33
|
+
if (!isManualTrigger(job.trigger)) continue;
|
|
34
|
+
rows.push({
|
|
35
|
+
jobName,
|
|
36
|
+
perTenant: job.perTenant === true,
|
|
37
|
+
payloadSchema: payloadSchemaJson(job),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
rows.sort((a, b) => a.jobName.localeCompare(b.jobName));
|
|
41
|
+
return { rows };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
NotFoundError,
|
|
5
|
+
UnprocessableError,
|
|
6
|
+
validationErrorFromZod,
|
|
7
|
+
writeFailure,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
4
9
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
5
10
|
import { z } from "zod";
|
|
11
|
+
import { JobErrors } from "../constants";
|
|
6
12
|
|
|
7
13
|
export const triggerWrite = defineWriteHandler({
|
|
8
14
|
name: "trigger",
|
|
@@ -25,7 +31,25 @@ export const triggerWrite = defineWriteHandler({
|
|
|
25
31
|
);
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
if (!("manual" in jobDef.trigger) || jobDef.trigger.manual !== true) {
|
|
35
|
+
return writeFailure(
|
|
36
|
+
new UnprocessableError(JobErrors.notManual, {
|
|
37
|
+
i18nKey: "jobs.errors.notManual",
|
|
38
|
+
details: { jobName: event.payload.jobName },
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rawPayload = event.payload.payload ?? {};
|
|
44
|
+
let payload: DbRow = rawPayload as DbRow;
|
|
45
|
+
if (jobDef.schema !== undefined) {
|
|
46
|
+
const parsed = jobDef.schema.safeParse(rawPayload);
|
|
47
|
+
if (!parsed.success) {
|
|
48
|
+
return writeFailure(validationErrorFromZod(parsed.error));
|
|
49
|
+
}
|
|
50
|
+
payload = parsed.data as DbRow;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
const bullJobId = await jobRunner.dispatch(event.payload.jobName, payload, {
|
|
30
54
|
triggeredById: event.user.id,
|
|
31
55
|
payload: JSON.stringify(payload),
|
package/src/jobs/i18n.ts
CHANGED
|
@@ -34,4 +34,37 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
34
34
|
"jobs.detail.logs.empty": { de: "Keine Log-Zeilen.", en: "No log lines." },
|
|
35
35
|
"jobs.detail.retry": { de: "Erneut ausführen", en: "Retry" },
|
|
36
36
|
"jobs.detail.retrying": { de: "Wird erneut gestartet…", en: "Retrying…" },
|
|
37
|
+
"jobs.trigger.title": { de: "Job starten", en: "Run a job" },
|
|
38
|
+
"jobs.trigger.explainer": {
|
|
39
|
+
de: "Nur manuell startbare Jobs. Nach dem Start erscheint der Lauf in der Historie unten.",
|
|
40
|
+
en: "Only manually triggerable jobs. After starting, the run appears in the history below.",
|
|
41
|
+
},
|
|
42
|
+
"jobs.trigger.job": { de: "Job", en: "Job" },
|
|
43
|
+
"jobs.trigger.payload": { de: "Payload (JSON)", en: "Payload (JSON)" },
|
|
44
|
+
"jobs.trigger.payload.hint": { de: "Schema-Hinweis", en: "Schema hint" },
|
|
45
|
+
"jobs.trigger.payload.invalidJson": {
|
|
46
|
+
de: "Payload ist kein gültiges JSON-Objekt.",
|
|
47
|
+
en: "Payload is not a valid JSON object.",
|
|
48
|
+
},
|
|
49
|
+
"jobs.trigger.submit": { de: "Starten", en: "Run" },
|
|
50
|
+
"jobs.trigger.submitting": { de: "Wird gestartet…", en: "Starting…" },
|
|
51
|
+
"jobs.trigger.success": { de: "Job gestartet.", en: "Job started." },
|
|
52
|
+
"jobs.trigger.empty": {
|
|
53
|
+
de: "Keine manuell startbaren Jobs registriert.",
|
|
54
|
+
en: "No manually triggerable jobs are registered.",
|
|
55
|
+
},
|
|
56
|
+
"jobs.trigger.perTenant": {
|
|
57
|
+
de: "Läuft einmal pro aktivem Tenant.",
|
|
58
|
+
en: "Runs once per active tenant.",
|
|
59
|
+
},
|
|
60
|
+
"jobs.errors.unknownJob": { de: "Unbekannter Job.", en: "Unknown job." },
|
|
61
|
+
"jobs.errors.notManual": {
|
|
62
|
+
de: "Dieser Job ist nicht manuell startbar.",
|
|
63
|
+
en: "This job cannot be triggered manually.",
|
|
64
|
+
},
|
|
65
|
+
"jobs.errors.notFound": { de: "Nicht gefunden.", en: "Not found." },
|
|
66
|
+
"jobs.errors.onlyFailedCanRetry": {
|
|
67
|
+
de: "Nur fehlgeschlagene Läufe können erneut gestartet werden.",
|
|
68
|
+
en: "Only failed runs can be retried.",
|
|
69
|
+
},
|
|
37
70
|
};
|