@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.
- package/package.json +8 -8
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +10 -2
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +50 -0
- package/src/compliance-profiles/changes.json +8 -1
- package/src/compliance-profiles/constants.ts +5 -0
- package/src/compliance-profiles/feature.ts +28 -3
- package/src/compliance-profiles/i18n.ts +25 -0
- package/src/compliance-profiles/web/client-plugin.tsx +7 -4
- package/src/compliance-profiles/web/compliance-profile-catalog.tsx +55 -0
- package/src/compliance-profiles/web/i18n.ts +1 -7
- package/src/compliance-profiles/web/index.ts +1 -1
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +132 -0
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -5
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
- package/src/delivery/changes.json +6 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/feature.ts +28 -3
- package/src/delivery/handlers/log.query.ts +115 -18
- package/src/delivery/i18n.ts +4 -0
- package/src/delivery/public-names.ts +5 -0
- package/src/delivery/web/client-plugin.tsx +6 -7
- package/src/delivery/web/delivery-status-cell.tsx +26 -0
- package/src/delivery/web/index.ts +1 -1
- package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
- package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
- package/src/jobs/db/queries/retention.ts +44 -0
- package/src/jobs/events.ts +5 -8
- package/src/jobs/feature.ts +26 -146
- package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-run-logger.ts +112 -59
- package/src/jobs/job-run-table.ts +36 -28
- package/src/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
- package/src/sessions/changes.json +6 -0
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +17 -1
- package/src/sessions/schema/user-session.ts +5 -0
- package/src/sessions/session-callbacks.ts +20 -1
- package/src/sessions/session-checker-fail-open.test.ts +38 -0
- package/src/user-data-rights/feature.ts +4 -4
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
- package/src/delivery/web/delivery-log-screen.tsx +0 -103
- 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
|
|
21
|
-
//
|
|
22
|
-
// -Complete / -Failed
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
29
|
-
// design discussion: one
|
|
30
|
-
// no log duplication across status
|
|
31
|
-
// expands the batch into N rows in
|
|
32
|
-
//
|
|
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
|
|
35
|
-
//
|
|
36
|
-
// the
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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: "
|
|
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
|
-
|
|
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** —
|
|
72
|
-
// - serial PK (
|
|
73
|
-
// -
|
|
74
|
-
// -
|
|
75
|
-
//
|
|
76
|
-
// pgTable
|
|
77
|
-
//
|
|
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;
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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: [
|
|
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
|
|
138
|
-
//
|
|
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
|
|
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
|
|
491
|
+
{ trigger: { cron: "0 3 * * *" }, concurrency: "skip" },
|
|
492
492
|
async (_payload, ctx) => {
|
|
493
493
|
if (!ctx.db || !ctx.registry) {
|
|
494
494
|
throw new Error(
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
}
|