@cosmicdrift/kumiko-bundled-features 0.208.3 → 0.209.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +8 -8
  2. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +10 -2
  3. package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +50 -0
  4. package/src/compliance-profiles/changes.json +8 -1
  5. package/src/compliance-profiles/constants.ts +5 -0
  6. package/src/compliance-profiles/feature.ts +28 -3
  7. package/src/compliance-profiles/i18n.ts +25 -0
  8. package/src/compliance-profiles/web/client-plugin.tsx +7 -4
  9. package/src/compliance-profiles/web/compliance-profile-catalog.tsx +55 -0
  10. package/src/compliance-profiles/web/i18n.ts +1 -7
  11. package/src/compliance-profiles/web/index.ts +1 -1
  12. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +132 -0
  13. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -5
  14. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -2
  15. package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
  16. package/src/delivery/changes.json +6 -0
  17. package/src/delivery/constants.ts +1 -0
  18. package/src/delivery/feature.ts +28 -3
  19. package/src/delivery/handlers/log.query.ts +115 -18
  20. package/src/delivery/i18n.ts +4 -0
  21. package/src/delivery/public-names.ts +5 -0
  22. package/src/delivery/web/client-plugin.tsx +6 -7
  23. package/src/delivery/web/delivery-status-cell.tsx +26 -0
  24. package/src/delivery/web/index.ts +1 -1
  25. package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
  26. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
  27. package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
  28. package/src/jobs/db/queries/retention.ts +44 -0
  29. package/src/jobs/events.ts +5 -8
  30. package/src/jobs/feature.ts +26 -146
  31. package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
  32. package/src/jobs/index.ts +1 -1
  33. package/src/jobs/job-run-logger.ts +112 -59
  34. package/src/jobs/job-run-table.ts +36 -28
  35. package/src/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
  36. package/src/sessions/changes.json +6 -0
  37. package/src/sessions/constants.ts +4 -0
  38. package/src/sessions/feature.ts +17 -1
  39. package/src/sessions/schema/user-session.ts +5 -0
  40. package/src/sessions/session-callbacks.ts +20 -1
  41. package/src/sessions/session-checker-fail-open.test.ts +38 -0
  42. package/src/user-data-rights/feature.ts +4 -4
  43. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
  44. package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
  45. package/src/delivery/web/delivery-log-screen.tsx +0 -103
  46. package/src/delivery/web/i18n.ts +0 -15
@@ -1,6 +1,6 @@
1
1
  import {
2
- buildEntityTable,
3
2
  defineUnmanagedTable,
3
+ deriveEntityTableMeta,
4
4
  type EntityTableMeta,
5
5
  instant,
6
6
  table as pgTable,
@@ -17,28 +17,29 @@ import {
17
17
  export type JobRunStatus = "queued" | "running" | "completed" | "failed";
18
18
  export type JobLogLevel = "info" | "warn" | "error";
19
19
 
20
- // jobRun is a system-scoped events-only aggregate: every job execution is
21
- // its own stream, driven entirely by BullMQ-callbacks (onJobStart /
22
- // -Complete / -Failed) via the low-level append() path. Three domain-
23
- // events cover the lifecycle:
24
- // - `jobs:event:run-started` (when BullMQ picks a job off its queue)
25
- // - `jobs:event:run-completed` (duration + batched log entries)
26
- // - `jobs:event:run-failed` (error + duration + batched log entries)
20
+ // jobRun is a system-scoped direct-write store (#2243): every job execution
21
+ // writes straight into jobRunsTable / jobRunLogsTable from the BullMQ
22
+ // callbacks (onJobStart / -Complete / -Failed, see job-run-logger.ts) —
23
+ // no event-store detour. Pre-#2243 this was an events-only aggregate
24
+ // replayed through two inline projections; that generated two permanent
25
+ // `kumiko_events` rows per run for data that is itself already the
26
+ // system of record (no other consumer replays or MSP-subscribes to it).
27
27
  //
28
- // Logs ride the completed/failed event as an array — "Option B" from the
29
- // design discussion: one event per run instead of N events per log line,
30
- // no log duplication across status transitions. The inline projection
31
- // expands the batch into N rows in jobRunLogsTable, keeping the pre-ES
32
- // detail-query-shape intact.
28
+ // Logs are batched onto the completed/failed callback as an array —
29
+ // "Option B" from the original design discussion: one write per run
30
+ // instead of one write per log line, no log duplication across status
31
+ // transitions. job-run-logger.ts expands the batch into N rows in
32
+ // jobRunLogsTable.
33
33
  //
34
- // Entity-derived table — Phase 3b of drizzle-replacement. Earlier this was
35
- // a hand-written pgTable; the entity-form is the single source for both
36
- // the drizzle-table (query API) and the future EntityTableMeta-based
37
- // migration generator. status/$type<JobRunStatus> ist nicht im entity-
38
- // schema modelliert — Drizzle's column-type ist text mit CHECK-Constraint
39
- // als App-Boundary (gleicher Pattern wie template-resolver kind/scope).
34
+ // Entity-derived table (query API + migration meta share one field
35
+ // definition). status/$type<JobRunStatus> is not modeled in the entity
36
+ // schema — the column type is text, with the status union enforced at the
37
+ // app boundary (same pattern as template-resolver kind/scope).
38
+ // `table: "store_job_runs"` (not `read_*`) because this is no longer a
39
+ // rebuildable projection — `defineUnmanagedTable`/`deriveEntityTableMeta`
40
+ // reject the `read_` prefix for `source: "unmanaged"` (#1208/#1220).
40
41
  export const jobRunEntity = createEntity({
41
- table: "read_job_runs",
42
+ table: "store_job_runs",
42
43
  fields: {
43
44
  jobName: createTextField({ required: true }),
44
45
  bullJobId: createTextField({ required: true }),
@@ -53,7 +54,14 @@ export const jobRunEntity = createEntity({
53
54
  },
54
55
  });
55
56
 
56
- export const jobRunsTable = buildEntityTable("job-run", jobRunEntity);
57
+ // Plain EntityTableMeta, NOT a branded EntityTable (buildEntityTable would
58
+ // mark it executor-only): job-run is an unmanaged direct-write store, so
59
+ // onJobStart/-Complete/-Failed need to write via ctx.db/insertOne directly
60
+ // (same pattern as sessions/schema/user-session.ts).
61
+ export const jobRunsTable: EntityTableMeta = deriveEntityTableMeta("job-run", jobRunEntity, {
62
+ source: "unmanaged",
63
+ });
64
+ export const jobRunsTableMeta = jobRunsTable;
57
65
 
58
66
  // Child projection keyed by the jobRun aggregate id. Pre-ES used a serial
59
67
  // PK + integer runId; post-ES runId is still exposed but now holds the
@@ -68,13 +76,13 @@ export const jobRunLogsTable = pgTable("store_job_run_logs", {
68
76
  timestamp: instant("timestamp").notNull(),
69
77
  });
70
78
 
71
- // **Unmanaged table** — bewusst KEIN createEntity. Begründung:
72
- // - serial PK (kein uuid) — pre-ES legacy, kompatibilität mit existing rows
73
- // - KEIN tenant_id — child-Tabelle von jobRun, tenant-context lebt am parent
74
- // - keine base-columns (kein version/inserted_at/inserted_by_id) — append-
75
- // only log, kein in-place-update, keine Audit-Spalten gewünscht
76
- // pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
77
- // aus dieser Meta ab.
79
+ // **Unmanaged table** — deliberately no createEntity. Reasoning:
80
+ // - serial PK (not uuid) — pre-ES legacy, compatible with existing rows
81
+ // - no tenant_id — child table of jobRun, tenant context lives on the parent
82
+ // - no base columns (no version/inserted_at/inserted_by_id) — append-only
83
+ // log, no in-place update, no audit columns needed
84
+ // pgTable stays the source of truth for the query API; this meta mirrors it
85
+ // for migration generation.
78
86
  export const jobRunLogsTableMeta: EntityTableMeta = defineUnmanagedTable({
79
87
  tableName: "store_job_run_logs",
80
88
  columns: [
@@ -0,0 +1,165 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
5
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ setupTestStack,
9
+ type TestStack,
10
+ testTenantId,
11
+ unsafeCreateEntityTable,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import {
15
+ createLateBoundHolder,
16
+ createTestEnvelopeCipher,
17
+ updateRows,
18
+ } from "@cosmicdrift/kumiko-framework/testing";
19
+ import { Temporal } from "temporal-polyfill";
20
+ import { AuthHandlers } from "../../auth-email-password/constants";
21
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
22
+ import { createConfigFeature } from "../../config";
23
+ import { createConfigResolver } from "../../config/resolver";
24
+ import { configValuesTable } from "../../config/table";
25
+ import { createTenantFeature } from "../../tenant";
26
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
27
+ import { tenantEntity } from "../../tenant/schema/tenant";
28
+ import { createUserFeature } from "../../user/feature";
29
+ import { userEntity, userTable } from "../../user/schema/user";
30
+ import { LAST_SEEN_REFRESH_MS } from "../constants";
31
+ import { createSessionsFeature } from "../feature";
32
+ import { userSessionEntity, userSessionTable } from "../schema/user-session";
33
+ import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
34
+ import { sessionCallbacksFromLateBound } from "../testing";
35
+ import { makeSessionHelpers } from "./test-helpers";
36
+
37
+ // Proves the #2220 lastSeenAt refresh: sessionChecker stamps a coarse
38
+ // activity marker on the session row, but only once per LAST_SEEN_REFRESH_MS
39
+ // rather than on every authenticated request.
40
+
41
+ let stack: TestStack;
42
+ let h: ReturnType<typeof makeSessionHelpers>;
43
+ let sessionCreator: SessionCreator;
44
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
45
+
46
+ const encryptionKey = randomBytes(32).toString("base64");
47
+
48
+ const TENANT: TenantId = testTenantId(1);
49
+
50
+ async function readLastSeenAt(sid: string): Promise<Temporal.Instant | null> {
51
+ const rows = await selectMany<{ lastSeenAt: Temporal.Instant | null }>(
52
+ stack.db,
53
+ userSessionTable,
54
+ { id: sid },
55
+ );
56
+ return rows[0]?.lastSeenAt ?? null;
57
+ }
58
+
59
+ beforeAll(async () => {
60
+ const encryption = createTestEnvelopeCipher(encryptionKey);
61
+ const resolver = createConfigResolver({ cipher: encryption });
62
+ const bound = sessionCallbacksFromLateBound(callbacks);
63
+
64
+ stack = await setupTestStack({
65
+ features: [
66
+ createConfigFeature(),
67
+ createUserFeature(),
68
+ createTenantFeature(),
69
+ createAuthEmailPasswordFeature(),
70
+ authFoundationFeature,
71
+ createSessionsFeature(),
72
+ ],
73
+ extraContext: { configResolver: resolver, configEncryption: encryption },
74
+ authConfig: {
75
+ ...bound.asAuthConfig(),
76
+ membershipQuery: "tenant:query:memberships",
77
+ loginHandler: AuthHandlers.login,
78
+ },
79
+ });
80
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
81
+ const creator = bound.asAuthConfig().sessionCreator;
82
+ if (!creator) throw new Error("sessionCreator missing from bound auth config");
83
+ sessionCreator = creator;
84
+ h = makeSessionHelpers(stack, TENANT, sessionCreator);
85
+
86
+ await unsafeCreateEntityTable(stack.db, userEntity);
87
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
88
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
89
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
90
+ });
91
+
92
+ afterAll(async () => {
93
+ await stack.cleanup();
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
98
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
99
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
100
+ });
101
+
102
+ describe("sessionChecker — lastSeenAt refresh", () => {
103
+ test("freshly created session has lastSeenAt set, not null", async () => {
104
+ await h.seedUser("fresh@example.com", "pw-long-enough");
105
+ const { sid } = await h.login("fresh@example.com", "pw-long-enough");
106
+
107
+ const lastSeenAt = await readLastSeenAt(sid);
108
+ expect(lastSeenAt).not.toBeNull();
109
+ // Sanity: stamped at creation time, not far in the past/future.
110
+ const driftMs = Math.abs(
111
+ Temporal.Now.instant().epochMilliseconds - (lastSeenAt?.epochMilliseconds ?? 0),
112
+ );
113
+ expect(driftMs).toBeLessThan(10_000);
114
+ });
115
+
116
+ test("null lastSeenAt is refreshed to a fresh value on check", async () => {
117
+ const { userId } = await h.seedUser("null-refresh@example.com", "pw-long-enough");
118
+ const { sid } = await h.login("null-refresh@example.com", "pw-long-enough");
119
+
120
+ await updateRows(stack.db, userSessionTable, { lastSeenAt: null }, { id: sid });
121
+ expect(await readLastSeenAt(sid)).toBeNull();
122
+
123
+ await callbacks.get().sessionChecker(sid, userId);
124
+
125
+ const refreshed = await readLastSeenAt(sid);
126
+ expect(refreshed).not.toBeNull();
127
+ const driftMs = Math.abs(
128
+ Temporal.Now.instant().epochMilliseconds - (refreshed?.epochMilliseconds ?? 0),
129
+ );
130
+ expect(driftMs).toBeLessThan(10_000);
131
+ });
132
+
133
+ test("lastSeenAt older than LAST_SEEN_REFRESH_MS is refreshed to a fresh value on check", async () => {
134
+ const { userId } = await h.seedUser("stale-refresh@example.com", "pw-long-enough");
135
+ const { sid } = await h.login("stale-refresh@example.com", "pw-long-enough");
136
+
137
+ const stale = Temporal.Now.instant().subtract({ milliseconds: LAST_SEEN_REFRESH_MS + 1_000 });
138
+ await updateRows(stack.db, userSessionTable, { lastSeenAt: stale }, { id: sid });
139
+ expect((await readLastSeenAt(sid))?.epochMilliseconds).toBe(stale.epochMilliseconds);
140
+
141
+ await callbacks.get().sessionChecker(sid, userId);
142
+
143
+ const refreshed = await readLastSeenAt(sid);
144
+ expect(refreshed?.epochMilliseconds).toBeGreaterThan(stale.epochMilliseconds);
145
+ const driftMs = Math.abs(
146
+ Temporal.Now.instant().epochMilliseconds - (refreshed?.epochMilliseconds ?? 0),
147
+ );
148
+ expect(driftMs).toBeLessThan(10_000);
149
+ });
150
+
151
+ test("lastSeenAt younger than LAST_SEEN_REFRESH_MS is left unchanged on check", async () => {
152
+ const { userId } = await h.seedUser("recent-noop@example.com", "pw-long-enough");
153
+ const { sid } = await h.login("recent-noop@example.com", "pw-long-enough");
154
+
155
+ const recent = Temporal.Now.instant().subtract({ milliseconds: 1_000 });
156
+ await updateRows(stack.db, userSessionTable, { lastSeenAt: recent }, { id: sid });
157
+ const before = await readLastSeenAt(sid);
158
+ expect(before?.epochMilliseconds).toBe(recent.epochMilliseconds);
159
+
160
+ await callbacks.get().sessionChecker(sid, userId);
161
+
162
+ const after = await readLastSeenAt(sid);
163
+ expect(after?.epochMilliseconds).toBe(before?.epochMilliseconds);
164
+ });
165
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.208.2",
4
+ "type": "improvement",
5
+ "title": "Session rows track lastSeenAt, refreshed hourly",
6
+ "detail": "userSession now carries a lastSeenAt timestamp, set on session creation and refreshed by sessionChecker at most once per hour (LAST_SEEN_REFRESH_MS). A refresh failure is swallowed and the check still returns \"live\" — this is a coarse activity marker, not a per-request write."
7
+ },
2
8
  {
3
9
  "version": "0.165.1",
4
10
  "type": "fix",
@@ -48,5 +48,9 @@ export const DEFAULT_SESSION_CACHE_TTL_MS = 60_000;
48
48
  // MVP ships a single window; per-app overrides can come later.
49
49
  export const DEFAULT_SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
50
50
 
51
+ // Minimum gap between lastSeenAt writes — coarse activity tracking without a
52
+ // write on every authenticated request.
53
+ export const LAST_SEEN_REFRESH_MS = 3_600_000;
54
+
51
55
  export const SESSION_LIST_SCREEN_ID = "session-list" as const;
52
56
  export const SESSION_DETAIL_SCREEN_ID = "session-detail" as const;
@@ -245,10 +245,26 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
245
245
  type: "projectionDetail",
246
246
  query: SessionQueries.detail,
247
247
  listScreenId: SESSION_LIST_SCREEN_ID,
248
+ // No write path on this screen — a "Cancel" button here would only
249
+ // ever discard nothing. Back-navigation still works via listScreenId
250
+ // above (resolveDetailBreadcrumb, renderer-web) — fw#2245.
251
+ hideActions: true,
248
252
  layout: {
249
253
  sections: [
250
254
  {
251
- fields: ["id", "userId", "createdAt", "expiresAt", "revokedAt", "ip", "userAgent"],
255
+ fields: [
256
+ "id",
257
+ "userId",
258
+ // The shim (projection-detail-shim.ts) stamps every field as
259
+ // type:"text" — field.renderer is the only way this screen
260
+ // type reaches real per-type formatting instead of a raw ISO
261
+ // string (fw#2245).
262
+ { field: "createdAt", renderer: { format: "timestamp" } },
263
+ { field: "expiresAt", renderer: { format: "timestamp" } },
264
+ { field: "revokedAt", renderer: { format: "timestamp" } },
265
+ "ip",
266
+ "userAgent",
267
+ ],
252
268
  },
253
269
  ],
254
270
  },
@@ -53,6 +53,11 @@ export const userSessionEntity = createEntity({
53
53
  revokedAt: createTimestampField({
54
54
  access: { write: access.privileged },
55
55
  }),
56
+ // Coarse last-activity marker, refreshed at most hourly (see
57
+ // LAST_SEEN_REFRESH_MS) — not a per-request write.
58
+ lastSeenAt: createTimestampField({
59
+ access: { write: access.privileged },
60
+ }),
56
61
  // Subject is the session's user, not the session row — a user-forget
57
62
  // must shred these, so the key hangs off userId (sid-self would orphan).
58
63
  ip: createTextField({
@@ -19,7 +19,7 @@ import { Temporal } from "temporal-polyfill";
19
19
  import { encryptForDirectWrite } from "../shared";
20
20
  import { tenantMembershipsTable } from "../tenant";
21
21
  import { USER_STATUS, type UserStatus, userTable } from "../user";
22
- import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
22
+ import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS } from "./constants";
23
23
  import { userSessionEntity, userSessionTable } from "./schema/user-session";
24
24
  import {
25
25
  SESSION_REVOKED_AGGREGATE_TYPE,
@@ -91,6 +91,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
91
91
  userId: user.id,
92
92
  createdAt: now,
93
93
  expiresAt,
94
+ lastSeenAt: now,
94
95
  ip: meta.ip,
95
96
  userAgent: meta.userAgent,
96
97
  },
@@ -120,6 +121,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
120
121
  tenantId: TenantId;
121
122
  revokedAt: unknown;
122
123
  expiresAt: { epochMilliseconds: number };
124
+ lastSeenAt: { epochMilliseconds: number } | null;
123
125
  }>(db, userSessionTable, { id: sid });
124
126
  if (!row) return "missing";
125
127
  // Cross-user check: if the sid belongs to someone else, treat it
@@ -133,6 +135,23 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
133
135
  if (row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds) {
134
136
  return "expired";
135
137
  }
138
+
139
+ // Hourly, not per-request: this check runs on every authenticated
140
+ // request, so a write here has to stay off the hot path.
141
+ const nowMs = Temporal.Now.instant().epochMilliseconds;
142
+ if (!row.lastSeenAt || row.lastSeenAt.epochMilliseconds + LAST_SEEN_REFRESH_MS <= nowMs) {
143
+ try {
144
+ await updateMany(
145
+ db,
146
+ userSessionTable,
147
+ { lastSeenAt: Temporal.Now.instant() },
148
+ { id: sid },
149
+ );
150
+ } catch {
151
+ // fail-open: a refresh failure must not block the session check
152
+ }
153
+ }
154
+
136
155
  // Defense-in-depth: status flips (Art. 18 restrict, forget) revoke
137
156
  // sessions, but a missed revoke must not keep a locked account alive on
138
157
  // a stale sid. Fail-OPEN on a lookup miss — this is the second layer,
@@ -110,6 +110,44 @@ describe("sessionChecker fail-open on user-lookup throw", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("sessionChecker fail-open on lastSeenAt refresh throw", () => {
114
+ test("updateMany THROW on refresh → live (not 500)", async () => {
115
+ const db = {} as DbConnection;
116
+ const cbs = createSessionCallbacks({ db });
117
+ const sid = "00000000-0000-4000-8000-00000000sid5";
118
+ const userId = "00000000-0000-4000-8000-00000000usr5";
119
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
120
+
121
+ const originalUpdateMany = bunDb.updateMany;
122
+ const fetchSpy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
123
+ if (table === userSessionTable) {
124
+ return {
125
+ userId,
126
+ revokedAt: null,
127
+ expiresAt: { epochMilliseconds: farFutureMs },
128
+ lastSeenAt: null,
129
+ };
130
+ }
131
+ if (table === userTable) {
132
+ throw new Error("simulated pool exhaustion");
133
+ }
134
+ throw new Error("unexpected table in sessionChecker spy");
135
+ }) as FetchOne);
136
+ const updateSpy = spyOn(bunDb, "updateMany").mockImplementation(async () => {
137
+ throw new Error("simulated write failure");
138
+ });
139
+
140
+ try {
141
+ expect(await cbs.sessionChecker(sid, userId)).toBe("live");
142
+ expect(updateSpy).toHaveBeenCalledTimes(1);
143
+ } finally {
144
+ fetchSpy.mockRestore();
145
+ updateSpy.mockRestore();
146
+ expect(bunDb.updateMany).toBe(originalUpdateMany);
147
+ }
148
+ });
149
+ });
150
+
113
151
  describe("sessionChecker role re-derivation", () => {
114
152
  test("live session composes global + tenant-membership roles fresh from the DB", async () => {
115
153
  const db = {} as DbConnection;
@@ -134,8 +134,8 @@ export type UserDataRightsOptions = {
134
134
  };
135
135
 
136
136
  export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
137
- // One-shot operator warning (the export cron fires every minute — warn once
138
- // per process, not every run). Lives in the factory scope so the cron closure
137
+ // One-shot operator warning (the export cron fires daily — warn once per
138
+ // process, not every run). Lives in the factory scope so the cron closure
139
139
  // shares it across runs.
140
140
  let warnedMissingExportUrl = false;
141
141
  return defineFeature("user-data-rights", (r) => {
@@ -401,7 +401,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
401
401
  // S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
402
402
  r.job(
403
403
  "run-export-jobs",
404
- { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
404
+ { trigger: { cron: "0 3 * * *" }, concurrency: "skip" },
405
405
  async (_payload, ctx) => {
406
406
  if (!ctx.db || !ctx.registry) {
407
407
  throw new Error(
@@ -488,7 +488,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
488
488
  // — Art.17 wuerde nie ausgefuehrt.
489
489
  r.job(
490
490
  "run-forget-cleanup",
491
- { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
491
+ { trigger: { cron: "0 3 * * *" }, concurrency: "skip" },
492
492
  async (_payload, ctx) => {
493
493
  if (!ctx.db || !ctx.registry) {
494
494
  throw new Error(
@@ -401,7 +401,7 @@ describe("delivery-attempt userData-hooks (#799)", () => {
401
401
  describe("job-run userData-hooks (#799)", () => {
402
402
  async function seedRun(id: string, triggeredById: string | null): Promise<void> {
403
403
  await asRawClient(full.db).unsafe(
404
- `INSERT INTO read_job_runs
404
+ `INSERT INTO store_job_runs
405
405
  (id, tenant_id, job_name, bull_job_id, status, payload, attempt, started_at, triggered_by_id)
406
406
  VALUES ($1::uuid, $2, 'app:job:export', $3, 'completed', '{"scope":"mine"}', 1, now(), $4)`,
407
407
  [id, TENANT_A, `bull-${id}`, triggeredById],
@@ -1,152 +0,0 @@
1
- // @runtime client
2
- import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
4
- import { type ReactNode, useCallback, useEffect, useState } from "react";
5
- import { ComplianceProfileHandlers, ComplianceProfileQueries } from "../constants";
6
-
7
- type ProfileSummary = {
8
- readonly key: string;
9
- readonly region: string;
10
- readonly label: string;
11
- readonly authorityContact: string;
12
- };
13
-
14
- type ListProfilesResponse = {
15
- readonly profiles: readonly ProfileSummary[];
16
- };
17
-
18
- type CurrentProfileResponse = {
19
- readonly key: string;
20
- readonly label: string;
21
- readonly region: string;
22
- readonly warning?: string;
23
- };
24
-
25
- type State =
26
- | { readonly kind: "loading" }
27
- | { readonly kind: "error"; readonly message: string }
28
- | {
29
- readonly kind: "ready";
30
- readonly profiles: readonly ProfileSummary[];
31
- readonly current: CurrentProfileResponse | null;
32
- };
33
-
34
- export function ComplianceProfileScreen(): ReactNode {
35
- const t = useTranslation();
36
- const { Banner, Button, Card, Field, Form, Heading, Input, Text } = usePrimitives();
37
- const dispatcher = useDispatcher();
38
- const [state, setState] = useState<State>({ kind: "loading" });
39
- const [selected, setSelected] = useState("");
40
- const [saving, setSaving] = useState(false);
41
- const [actionError, setActionError] = useState<string | null>(null);
42
-
43
- const load = useCallback(async (): Promise<void> => {
44
- setState({ kind: "loading" });
45
- const [profilesRes, currentRes] = await Promise.all([
46
- dispatcher.query<ListProfilesResponse>(ComplianceProfileQueries.listProfiles, {}),
47
- dispatcher.query<CurrentProfileResponse>(ComplianceProfileQueries.forTenant, {}),
48
- ]);
49
- if (!profilesRes.isSuccess) {
50
- setState({ kind: "error", message: profilesRes.error.message });
51
- return;
52
- }
53
- if (!currentRes.isSuccess) {
54
- setState({ kind: "error", message: currentRes.error.message });
55
- return;
56
- }
57
- setSelected(currentRes.data.key);
58
- setState({ kind: "ready", profiles: profilesRes.data.profiles, current: currentRes.data });
59
- }, [dispatcher]);
60
-
61
- useEffect(() => {
62
- void load();
63
- }, [load]);
64
-
65
- const onSave = (): void => {
66
- void (async (): Promise<void> => {
67
- if (selected === "") return;
68
- setActionError(null);
69
- setSaving(true);
70
- const res = await dispatcher.write(ComplianceProfileHandlers.setProfile, {
71
- profileKey: selected,
72
- });
73
- setSaving(false);
74
- if (!res.isSuccess) {
75
- setActionError(res.error.message);
76
- return;
77
- }
78
- await load();
79
- })();
80
- };
81
-
82
- if (state.kind === "loading") {
83
- return (
84
- <FormScreenShell testId="compliance-profile-screen">
85
- <Text variant="small">{t("compliance.profile.loading")}</Text>
86
- </FormScreenShell>
87
- );
88
- }
89
-
90
- if (state.kind === "error") {
91
- return (
92
- <FormScreenShell testId="compliance-profile-screen">
93
- <Banner variant="error">{state.message}</Banner>
94
- </FormScreenShell>
95
- );
96
- }
97
-
98
- const profileOptions = state.profiles.map((profile) => ({
99
- value: profile.key,
100
- label: `${profile.label} (${profile.key})`,
101
- }));
102
-
103
- return (
104
- <FormScreenShell testId="compliance-profile-screen" className="flex flex-col gap-6">
105
- <Heading variant="page">{t("compliance.profile.title")}</Heading>
106
-
107
- {state.current !== null && (
108
- <Banner variant="info" testId="compliance-current-profile">
109
- {t("compliance.profile.current")}: {state.current.label} ({state.current.key})
110
- </Banner>
111
- )}
112
-
113
- <Form
114
- onSubmit={onSave}
115
- actions={
116
- <Button
117
- type="submit"
118
- variant="primary"
119
- disabled={saving || selected === ""}
120
- loading={saving}
121
- testId="compliance-profile-save"
122
- >
123
- {saving ? t("compliance.profile.saving") : t("compliance.profile.save")}
124
- </Button>
125
- }
126
- >
127
- <Field id="compliance-profile-select" label={t("compliance.profile.select")} required>
128
- <Input
129
- kind="select"
130
- id="compliance-profile-select"
131
- name="compliance-profile-select"
132
- value={selected}
133
- onChange={setSelected}
134
- options={profileOptions}
135
- required
136
- />
137
- </Field>
138
- {actionError !== null && <Banner variant="error">{actionError}</Banner>}
139
- </Form>
140
-
141
- <Card slots={{ title: t("compliance.profile.catalog") }}>
142
- <ul className="flex flex-col gap-2 text-sm">
143
- {state.profiles.map((profile) => (
144
- <li key={profile.key} data-profile-key={profile.key}>
145
- <strong>{profile.label}</strong> — {profile.region} — {profile.authorityContact}
146
- </li>
147
- ))}
148
- </ul>
149
- </Card>
150
- </FormScreenShell>
151
- );
152
- }