@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.
- 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/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/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,14 +1,77 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
});
|
package/src/delivery/i18n.ts
CHANGED
|
@@ -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 {
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { DELIVERY_FEATURE,
|
|
5
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
+
}
|
|
@@ -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;
|