@cosmicdrift/kumiko-bundled-features 0.208.3 → 0.209.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) 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/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
  26. package/src/sessions/changes.json +6 -0
  27. package/src/sessions/constants.ts +4 -0
  28. package/src/sessions/feature.ts +17 -1
  29. package/src/sessions/schema/user-session.ts +5 -0
  30. package/src/sessions/session-callbacks.ts +20 -1
  31. package/src/sessions/session-checker-fail-open.test.ts +38 -0
  32. package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
  33. package/src/delivery/web/delivery-log-screen.tsx +0 -103
  34. package/src/delivery/web/i18n.ts +0 -15
@@ -1,14 +1,77 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ definePagedQueryHandler,
6
+ MAX_LIST_LIMIT,
7
+ type NotifyPriority,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
3
9
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { Temporal } from "temporal-polyfill";
4
11
  import { z } from "zod";
5
12
  import { decryptStoredPii } from "../../shared";
13
+ import type { DeliveryStatusValue } from "../constants";
6
14
  import { deliveryAttemptsTable } from "../tables";
7
15
 
8
- export const logQuery = defineQueryHandler({
16
+ type DeliveryLogRow = {
17
+ id: string;
18
+ tenantId: string;
19
+ notificationType: string;
20
+ channel: string;
21
+ recipientId: string | null;
22
+ recipientAddress: string | null;
23
+ status: DeliveryStatusValue;
24
+ error: string | null;
25
+ priority: NotifyPriority;
26
+ createdAt: Temporal.Instant;
27
+ };
28
+
29
+ // Sort whitelist keyed by the DISPLAY field the client actually sends: the
30
+ // declarative projectionList renderer marks every listed column uniformly
31
+ // sortable and a header click sends `sort=<screen.columns[].field>`, not the
32
+ // DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
33
+ // "recipient" is deliberately absent — it's PII decrypted per-row in
34
+ // application code, so there is no column to ORDER BY in SQL for it.
35
+ const DELIVERY_LOG_SORT_COLUMNS = {
36
+ createdAt: "createdAt",
37
+ type: "notificationType",
38
+ channel: "channel",
39
+ status: "status",
40
+ } as const;
41
+ type DeliveryLogSortField = keyof typeof DELIVERY_LOG_SORT_COLUMNS;
42
+
43
+ function isDeliveryLogSortField(value: string): value is DeliveryLogSortField {
44
+ return value in DELIVERY_LOG_SORT_COLUMNS;
45
+ }
46
+
47
+ type DeliveryLogSortColumn = (typeof DELIVERY_LOG_SORT_COLUMNS)[DeliveryLogSortField];
48
+
49
+ // createdAt round-trips through Temporal.Instant (matches how the WHERE
50
+ // builder coerces timestamptz values — see bun-db/query.ts prepareValue);
51
+ // every other whitelisted column is a plain text column. Keyed by the
52
+ // PHYSICAL column, not the display alias — row only has the DB field names
53
+ // (e.g. notificationType), never the alias (e.g. "type").
54
+ function encodeSortCursor(column: DeliveryLogSortColumn, row: DeliveryLogRow): string {
55
+ if (column === "createdAt") return encodeCursor(row.createdAt.toString());
56
+ return encodeCursor(String(row[column]));
57
+ }
58
+
59
+ function decodeSortCursor(
60
+ column: DeliveryLogSortColumn,
61
+ cursor: string,
62
+ ): Temporal.Instant | string {
63
+ const decoded = decodeCursor(cursor);
64
+ if (column === "createdAt") return Temporal.Instant.from(decoded);
65
+ return decoded;
66
+ }
67
+
68
+ export const logQuery = definePagedQueryHandler({
9
69
  name: "log",
10
70
  schema: z.object({
11
- limit: z.number().min(1).max(100).default(50),
71
+ cursor: z.string().optional(),
72
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
73
+ sort: z.string().optional(),
74
+ sortDirection: z.enum(["asc", "desc"]).optional(),
12
75
  }),
13
76
  access: { roles: access.admin },
14
77
  handler: async (query, ctx) => {
@@ -20,27 +83,61 @@ export const logQuery = defineQueryHandler({
20
83
  // tenantId, not a query filter — it returns that same unfiltered db, so
21
84
  // the explicit `where` below still does the actual tenant scoping.
22
85
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
23
- const rows = await selectMany(
24
- db,
25
- deliveryAttemptsTable,
26
- { tenantId: query.user.tenantId },
27
- {
28
- orderBy: { col: "createdAt", direction: "desc" },
29
- limit: query.payload.limit,
30
- },
31
- );
86
+
87
+ const requestedSort = query.payload.sort;
88
+ const sortField: DeliveryLogSortField =
89
+ requestedSort !== undefined && isDeliveryLogSortField(requestedSort)
90
+ ? requestedSort
91
+ : "createdAt";
92
+ const sortDirection = query.payload.sortDirection ?? "desc";
93
+ const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
94
+
95
+ const where: WhereObject = { tenantId: query.user.tenantId };
96
+ if (query.payload.cursor) {
97
+ where[sortColumn] = {
98
+ [sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
99
+ };
100
+ }
101
+
102
+ const rows = await selectMany<DeliveryLogRow>(db, deliveryAttemptsTable, where, {
103
+ // Tie-breaker on id keeps ordering stable across pages when the sort
104
+ // column has duplicate values (e.g. many rows with the same status).
105
+ orderBy: [
106
+ { col: sortColumn, direction: sortDirection },
107
+ { col: "id", direction: "asc" },
108
+ ],
109
+ limit: query.payload.limit,
110
+ });
111
+
112
+ const lastRow = rows[rows.length - 1];
113
+ const nextCursor =
114
+ rows.length === query.payload.limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
115
+
32
116
  // recipientAddress is stored encrypted under the recipient's DEK (#799)
33
117
  // — decrypt for the admin log view; forgotten subjects show [[erased]].
118
+ // The notificationType/recipientAddress → type/recipient rename happens
119
+ // here, not in the client: a projectionList screen has no entity to
120
+ // derive a field-mapping from, so the row shape the query returns is the
121
+ // shape the declarative columns read directly.
34
122
  return {
35
123
  rows: await Promise.all(
36
124
  rows.map(async (row) => ({
37
- ...row,
38
- recipientAddress:
39
- typeof row["recipientAddress"] === "string"
40
- ? await decryptStoredPii(row["recipientAddress"], "recipientAddress", "delivery-log")
41
- : row["recipientAddress"],
125
+ id: row.id,
126
+ tenantId: row.tenantId,
127
+ recipientId: row.recipientId,
128
+ type: row.notificationType,
129
+ channel: row.channel,
130
+ recipient:
131
+ row.recipientAddress !== null
132
+ ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
133
+ : row.recipientAddress,
134
+ status: row.status,
135
+ error: row.error,
136
+ priority: row.priority,
137
+ createdAt: row.createdAt,
42
138
  })),
43
139
  ),
140
+ nextCursor,
44
141
  };
45
142
  },
46
143
  });
@@ -3,4 +3,8 @@ type LocalizedString = { readonly en: string };
3
3
  export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:delivery-log.title": { en: "Delivery log" },
5
5
  "delivery:nav.deliveryLog": { en: "Delivery" },
6
+ "delivery.log.col.type": { en: "Type" },
7
+ "delivery.log.col.channel": { en: "Channel" },
8
+ "delivery.log.col.recipient": { en: "Recipient" },
9
+ "delivery.log.col.status": { en: "Status" },
6
10
  };
@@ -14,6 +14,11 @@ export const DeliveryQueries = {
14
14
 
15
15
  export const DELIVERY_LOG_SCREEN_ID = "delivery-log" as const;
16
16
 
17
+ // Client column-renderer registered for the delivery-log screen's status
18
+ // column — see `screen.columns[].renderer.react.__component` in feature.ts
19
+ // and `deliveryClient()`'s `columnRenderers` map.
20
+ export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
21
+
17
22
  export const DeliveryErrors = {
18
23
  noRecipient: "delivery_no_recipient",
19
24
  channelFailed: "delivery_channel_failed",
@@ -1,9 +1,8 @@
1
1
  // @runtime client
2
- import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID } from "../public-names";
5
- import { DeliveryLogScreen } from "./delivery-log-screen";
6
- import { defaultTranslations } from "./i18n";
4
+ import { DELIVERY_FEATURE, DELIVERY_STATUS_CELL_COMPONENT } from "../public-names";
5
+ import { DeliveryStatusCell } from "./delivery-status-cell";
7
6
 
8
7
  export type DeliveryClientOptions = {
9
8
  readonly translations?: TranslationsByLocale;
@@ -12,9 +11,9 @@ export type DeliveryClientOptions = {
12
11
  export function deliveryClient(options?: DeliveryClientOptions): ClientFeatureDefinition {
13
12
  return {
14
13
  name: DELIVERY_FEATURE,
15
- translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
16
- components: {
17
- [DELIVERY_LOG_SCREEN_ID]: DeliveryLogScreen,
14
+ columnRenderers: {
15
+ [DELIVERY_STATUS_CELL_COMPONENT]: DeliveryStatusCell,
18
16
  },
17
+ ...(options?.translations !== undefined && { translations: options.translations }),
19
18
  };
20
19
  }
@@ -0,0 +1,26 @@
1
+ // @runtime client
2
+ // Column-renderer for the delivery-log screen's status column, registered
3
+ // via `deliveryClient()`'s `columnRenderers` map (screen.columns[].renderer
4
+ // in feature.ts). The list frame itself is the generic declarative
5
+ // projectionList renderer — only this one cell stays TSX.
6
+
7
+ import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
8
+ import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
9
+ import type { ReactNode } from "react";
10
+ import { DeliveryStatus } from "../public-names";
11
+
12
+ const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
13
+ [DeliveryStatus.sent]: "ok",
14
+ [DeliveryStatus.failed]: "bad",
15
+ [DeliveryStatus.queued]: "muted",
16
+ [DeliveryStatus.skipped]: "muted",
17
+ };
18
+
19
+ export function DeliveryStatusCell({ row }: ColumnRendererProps): ReactNode {
20
+ const status = typeof row["status"] === "string" ? row["status"] : "";
21
+ return (
22
+ <StatusBadge tone={STATUS_TONE[status] ?? "muted"} testId="delivery-status-cell">
23
+ {status}
24
+ </StatusBadge>
25
+ );
26
+ }
@@ -1,3 +1,3 @@
1
1
  // @runtime client
2
2
  export { type DeliveryClientOptions, deliveryClient } from "./client-plugin";
3
- export { DeliveryLogScreen } from "./delivery-log-screen";
3
+ export { DeliveryStatusCell } from "./delivery-status-cell";
@@ -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;