@cosmicdrift/kumiko-bundled-features 0.208.0 → 0.208.2
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.208.
|
|
3
|
+
"version": "0.208.2",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.208.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.208.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.208.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.208.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.208.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.208.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.208.2",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.208.2",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.208.2",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.208.2",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.208.2",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.208.2",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,6 +160,6 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.208.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.208.2"
|
|
164
164
|
}
|
|
165
165
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
resetBlindIndexKeyForTests,
|
|
26
26
|
resetPiiSubjectKmsForTests,
|
|
27
27
|
resetTestTables,
|
|
28
|
+
seedRows,
|
|
28
29
|
updateRows,
|
|
29
30
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
30
31
|
import { Temporal } from "temporal-polyfill";
|
|
@@ -579,18 +580,25 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
579
580
|
});
|
|
580
581
|
expect(asAdmin.status).toBe(200);
|
|
581
582
|
const body = (await asAdmin.json()) as {
|
|
582
|
-
data:
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
583
|
+
data: {
|
|
584
|
+
rows: Array<{
|
|
585
|
+
id: string;
|
|
586
|
+
userId: string;
|
|
587
|
+
createdAt: string;
|
|
588
|
+
revokedAt: string | null;
|
|
589
|
+
}>;
|
|
590
|
+
nextCursor: string | null;
|
|
591
|
+
};
|
|
588
592
|
};
|
|
593
|
+
// PagedRows envelope (fw#2216) — the renderer reads data.rows, not a
|
|
594
|
+
// bare array; asserting the shape here is the proof session-list
|
|
595
|
+
// actually renders rows instead of silently falling back to [].
|
|
596
|
+
expect(body.data.nextCursor).toBeNull();
|
|
589
597
|
// Three rows total for the two test users: Alice's pre-promotion session,
|
|
590
598
|
// Alice's post-promotion session, Bob's session. seedUser's bootstrap
|
|
591
599
|
// actor (systemAdmin) also holds live sessions in this tenant — exclude
|
|
592
600
|
// them, they aren't part of what this test pins.
|
|
593
|
-
const nonSystemRows = body.data.filter((r) => r.userId !== TestUsers.systemAdmin.id);
|
|
601
|
+
const nonSystemRows = body.data.rows.filter((r) => r.userId !== TestUsers.systemAdmin.id);
|
|
594
602
|
expect(nonSystemRows).toHaveLength(3);
|
|
595
603
|
const userIds = new Set(nonSystemRows.map((r) => r.userId));
|
|
596
604
|
expect(userIds.size).toBe(2);
|
|
@@ -598,7 +606,161 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
598
606
|
// Order: most-recently-created first. aliceAsAdmin's session was the
|
|
599
607
|
// last login; aliceAsAdmin.sid leads the list. Pinning guards against
|
|
600
608
|
// silent orderBy removal.
|
|
601
|
-
expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
|
|
609
|
+
expect(body.data.rows[0]?.id).toBe(aliceAsAdmin.sid);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// fw#2230 — sort/sortDirection let the admin UI re-order the table client-
|
|
613
|
+
// side without a full refetch-and-sort round trip.
|
|
614
|
+
test("session:list sorts by an allowlisted column and direction", async () => {
|
|
615
|
+
const { userId: aliceId } = await h.seedUser("alice-sort@example.com", "pw-long-enough");
|
|
616
|
+
const { userId: bobId } = await h.seedUser("bob-sort@example.com", "pw-long-enough");
|
|
617
|
+
const { userId: carolId } = await h.seedUser("carol-sort@example.com", "pw-long-enough");
|
|
618
|
+
await updateRows(
|
|
619
|
+
stack.db,
|
|
620
|
+
tenantMembershipsTable,
|
|
621
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
622
|
+
{ userId: aliceId, tenantId: TENANT },
|
|
623
|
+
);
|
|
624
|
+
const aliceAsAdmin = await h.login("alice-sort@example.com", "pw-long-enough");
|
|
625
|
+
await h.login("bob-sort@example.com", "pw-long-enough");
|
|
626
|
+
await h.login("carol-sort@example.com", "pw-long-enough");
|
|
627
|
+
|
|
628
|
+
const fetchUserIds = async (sortDirection: "asc" | "desc") => {
|
|
629
|
+
const res = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
630
|
+
type: SessionQueries.list,
|
|
631
|
+
payload: { sort: "userId", sortDirection },
|
|
632
|
+
});
|
|
633
|
+
expect(res.status).toBe(200);
|
|
634
|
+
const body = (await res.json()) as { data: { rows: Array<{ id: string; userId: string }> } };
|
|
635
|
+
return body.data.rows
|
|
636
|
+
.filter((r) => r.userId !== TestUsers.systemAdmin.id)
|
|
637
|
+
.map((r) => r.userId);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Comparing asc against desc on the same query (rather than asserting
|
|
641
|
+
// against a re-sort of the response itself) is what actually fails if
|
|
642
|
+
// `sort`/`sortDirection` were silently ignored and rows just stayed in
|
|
643
|
+
// createdAt-desc order.
|
|
644
|
+
const ascending = await fetchUserIds("asc");
|
|
645
|
+
const descending = await fetchUserIds("desc");
|
|
646
|
+
expect(ascending).toEqual([...descending].reverse());
|
|
647
|
+
expect(new Set(ascending)).toEqual(new Set([aliceId, bobId, carolId]));
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// A `sort` value outside the column allowlist (or an outright injection
|
|
651
|
+
// attempt) must not throw or 500 — it silently falls back to the default
|
|
652
|
+
// createdAt/desc ordering instead of letting a client sort by a
|
|
653
|
+
// non-exposed column like ip/userAgent.
|
|
654
|
+
test.each(["ip", "createdAt; DROP TABLE"])(
|
|
655
|
+
"session:list falls back to createdAt desc for a disallowed sort=%s",
|
|
656
|
+
async (badSort) => {
|
|
657
|
+
const { userId: aliceId } = await h.seedUser(
|
|
658
|
+
`alice-badsort-${badSort.length}@example.com`,
|
|
659
|
+
"pw-long-enough",
|
|
660
|
+
);
|
|
661
|
+
await updateRows(
|
|
662
|
+
stack.db,
|
|
663
|
+
tenantMembershipsTable,
|
|
664
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
665
|
+
{ userId: aliceId, tenantId: TENANT },
|
|
666
|
+
);
|
|
667
|
+
const aliceAsAdmin = await h.login(
|
|
668
|
+
`alice-badsort-${badSort.length}@example.com`,
|
|
669
|
+
"pw-long-enough",
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
const res = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
673
|
+
type: SessionQueries.list,
|
|
674
|
+
payload: { sort: badSort },
|
|
675
|
+
});
|
|
676
|
+
expect(res.status).toBe(200);
|
|
677
|
+
const body = (await res.json()) as {
|
|
678
|
+
data: { rows: Array<{ id: string; createdAt: string }> };
|
|
679
|
+
};
|
|
680
|
+
expect(body.data.rows.length).toBeGreaterThan(0);
|
|
681
|
+
const createdAtValues = body.data.rows.map((r) => Date.parse(r.createdAt));
|
|
682
|
+
const sortedDescending = [...createdAtValues].sort((a, b) => b - a);
|
|
683
|
+
expect(createdAtValues).toEqual(sortedDescending);
|
|
684
|
+
},
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
test("session:list defaults to createdAt desc without a sort param", async () => {
|
|
688
|
+
await h.seedUser("alice-nosort@example.com", "pw-long-enough");
|
|
689
|
+
await h.login("alice-nosort@example.com", "pw-long-enough");
|
|
690
|
+
const { userId: bobId } = await h.seedUser("bob-nosort@example.com", "pw-long-enough");
|
|
691
|
+
await updateRows(
|
|
692
|
+
stack.db,
|
|
693
|
+
tenantMembershipsTable,
|
|
694
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
695
|
+
{ userId: bobId, tenantId: TENANT },
|
|
696
|
+
);
|
|
697
|
+
const bobAsAdmin = await h.login("bob-nosort@example.com", "pw-long-enough");
|
|
698
|
+
|
|
699
|
+
const res = await h.authedPost("/api/query", bobAsAdmin.token, {
|
|
700
|
+
type: SessionQueries.list,
|
|
701
|
+
payload: {},
|
|
702
|
+
});
|
|
703
|
+
expect(res.status).toBe(200);
|
|
704
|
+
const body = (await res.json()) as { data: { rows: Array<{ id: string; createdAt: string }> } };
|
|
705
|
+
const createdAtValues = body.data.rows.map((r) => Date.parse(r.createdAt));
|
|
706
|
+
const sortedDescending = [...createdAtValues].sort((a, b) => b - a);
|
|
707
|
+
expect(createdAtValues).toEqual(sortedDescending);
|
|
708
|
+
// bobAsAdmin's own (re-)login is the most recent row, must lead.
|
|
709
|
+
expect(body.data.rows[0]?.id).toBe(bobAsAdmin.sid);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// fw#2198/PR#2208 (the sibling stable-ORDER-BY fix) found the repro shape
|
|
713
|
+
// for Postgres's Top-N-heapsort tie instability empirically: few rows on
|
|
714
|
+
// large pages stay green even with no tie-breaker at all, by luck — it
|
|
715
|
+
// took 25 rows sharing one sort value at page size 3 to make it flip
|
|
716
|
+
// reliably. Same recipe here: seed 25 sessions with an identical
|
|
717
|
+
// createdAt, then compare a small LIMIT (a Top-N heap) against a LIMIT
|
|
718
|
+
// covering everything (a full sort) — without an `id` tie-breaker the two
|
|
719
|
+
// heap sizes can disagree on tie order even though the underlying rows
|
|
720
|
+
// never change between the two calls.
|
|
721
|
+
test("session:list keeps a stable order for tied createdAt values across page sizes", async () => {
|
|
722
|
+
const { userId: adminId } = await h.seedUser("tiebreak-admin@example.com", "pw-long-enough");
|
|
723
|
+
await updateRows(
|
|
724
|
+
stack.db,
|
|
725
|
+
tenantMembershipsTable,
|
|
726
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
727
|
+
{ userId: adminId, tenantId: TENANT },
|
|
728
|
+
);
|
|
729
|
+
const admin = await h.login("tiebreak-admin@example.com", "pw-long-enough");
|
|
730
|
+
|
|
731
|
+
const tiedCreatedAt = Temporal.Instant.from("2026-01-01T00:00:00Z");
|
|
732
|
+
const futureExpiry = Temporal.Instant.from("2099-01-01T00:00:00Z");
|
|
733
|
+
await seedRows(
|
|
734
|
+
stack.db,
|
|
735
|
+
userSessionTable,
|
|
736
|
+
Array.from({ length: 25 }, (_, i) => ({
|
|
737
|
+
id: `22222222-2222-2222-2222-${String(i).padStart(12, "0")}`,
|
|
738
|
+
tenantId: TENANT,
|
|
739
|
+
userId: adminId,
|
|
740
|
+
createdAt: tiedCreatedAt,
|
|
741
|
+
expiresAt: futureExpiry,
|
|
742
|
+
revokedAt: null,
|
|
743
|
+
ip: null,
|
|
744
|
+
userAgent: null,
|
|
745
|
+
})),
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const fetchIds = async (limit: number) => {
|
|
749
|
+
const res = await h.authedPost("/api/query", admin.token, {
|
|
750
|
+
type: SessionQueries.list,
|
|
751
|
+
payload: { limit },
|
|
752
|
+
});
|
|
753
|
+
expect(res.status).toBe(200);
|
|
754
|
+
const body = (await res.json()) as { data: { rows: Array<{ id: string }> } };
|
|
755
|
+
return body.data.rows.map((r) => r.id);
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// admin's own login row is strictly newer than the 25 tied rows, so it
|
|
759
|
+
// always leads both lists — the tie-breaking under test is among the 25.
|
|
760
|
+
const top3 = await fetchIds(3);
|
|
761
|
+
const full = await fetchIds(26);
|
|
762
|
+
expect(full).toHaveLength(26);
|
|
763
|
+
expect(top3).toEqual(full.slice(0, 3));
|
|
602
764
|
});
|
|
603
765
|
|
|
604
766
|
// Single-row inspector backing the session-detail screen (kumiko-framework#255).
|
|
@@ -685,8 +847,8 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
685
847
|
payload: {},
|
|
686
848
|
});
|
|
687
849
|
expect(listRes.status).toBe(200);
|
|
688
|
-
const listBody = (await listRes.json()) as { data: Array<{ id: string }> };
|
|
689
|
-
expect(listBody.data.map((r) => r.id)).not.toContain(carolAsAdmin.sid);
|
|
850
|
+
const listBody = (await listRes.json()) as { data: { rows: Array<{ id: string }> } };
|
|
851
|
+
expect(listBody.data.rows.map((r) => r.id)).not.toContain(carolAsAdmin.sid);
|
|
690
852
|
});
|
|
691
853
|
});
|
|
692
854
|
|
package/src/sessions/feature.ts
CHANGED
|
@@ -217,6 +217,10 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
217
217
|
id: SESSION_LIST_SCREEN_ID,
|
|
218
218
|
type: "projectionList",
|
|
219
219
|
query: SessionQueries.list,
|
|
220
|
+
// Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
|
|
221
|
+
// desc) — kept in sync by hand, boot-validator only requires the field
|
|
222
|
+
// be present once the query accepts `sort` (fw#2230).
|
|
223
|
+
defaultSort: { field: "createdAt", dir: "desc" },
|
|
220
224
|
columns: [
|
|
221
225
|
{ field: "id", label: "sessions.list.col.id" },
|
|
222
226
|
{ field: "userId", label: "sessions.list.col.userId" },
|
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
definePagedQueryHandler,
|
|
5
|
+
MAX_LIST_LIMIT,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
7
|
import { z } from "zod";
|
|
4
8
|
import { decryptStoredPii } from "../../shared";
|
|
5
9
|
import { userSessionTable } from "../schema/user-session";
|
|
6
10
|
|
|
11
|
+
// `sort` arrives raw from the client's query string. selectMany's orderBy
|
|
12
|
+
// has no column-existence check — an unrecognised field just gets
|
|
13
|
+
// snake_cased and quoted as-is (bun-db/query.ts columnOf) — so this
|
|
14
|
+
// allowlist is what stops a client sorting by ip/userAgent or any other
|
|
15
|
+
// non-exposed column.
|
|
16
|
+
const SORTABLE_COLUMNS = ["id", "userId", "createdAt", "expiresAt", "revokedAt"] as const;
|
|
17
|
+
type SortableColumn = (typeof SORTABLE_COLUMNS)[number];
|
|
18
|
+
|
|
19
|
+
function isSortableColumn(value: string): value is SortableColumn {
|
|
20
|
+
return (SORTABLE_COLUMNS as readonly string[]).includes(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
// Admin view of every session in the active tenant. ctx.db (TenantDb)
|
|
8
24
|
// applies tenant-scoping automatically on selects from tables with a
|
|
9
25
|
// tenantId column. Includes revoked rows; UI shows revokedAt distinct.
|
|
10
|
-
|
|
26
|
+
// No cursor — nextCursor is always null; `limit` only caps the page size,
|
|
27
|
+
// it doesn't offset into a further one.
|
|
28
|
+
export const listQuery = definePagedQueryHandler({
|
|
11
29
|
name: "user-session:list",
|
|
12
|
-
schema: z.object({
|
|
30
|
+
schema: z.object({
|
|
31
|
+
limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
|
|
32
|
+
sort: z.string().optional(),
|
|
33
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
34
|
+
}),
|
|
13
35
|
access: { roles: access.admin },
|
|
14
|
-
handler: async (
|
|
36
|
+
handler: async (query, ctx) => {
|
|
37
|
+
const requestedSort = query.payload.sort;
|
|
38
|
+
const sortColumn: SortableColumn =
|
|
39
|
+
requestedSort !== undefined && isSortableColumn(requestedSort) ? requestedSort : "createdAt";
|
|
15
40
|
const rows = await selectMany<{
|
|
16
41
|
id: string;
|
|
17
42
|
userId: string;
|
|
@@ -21,9 +46,19 @@ export const listQuery = defineQueryHandler({
|
|
|
21
46
|
ip: string | null;
|
|
22
47
|
userAgent: string | null;
|
|
23
48
|
}>(ctx.db, userSessionTable, undefined, {
|
|
24
|
-
|
|
49
|
+
// `id` as a tie-breaker keeps row order (and, with `limit` set, row
|
|
50
|
+
// selection) deterministic across identical requests — sortColumn
|
|
51
|
+
// alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
|
|
52
|
+
orderBy:
|
|
53
|
+
sortColumn === "id"
|
|
54
|
+
? { col: "id", direction: query.payload.sortDirection ?? "desc" }
|
|
55
|
+
: [
|
|
56
|
+
{ col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
|
|
57
|
+
{ col: "id", direction: "asc" },
|
|
58
|
+
],
|
|
59
|
+
...(query.payload.limit !== undefined && { limit: query.payload.limit }),
|
|
25
60
|
});
|
|
26
|
-
|
|
61
|
+
const decryptedRows = await Promise.all(
|
|
27
62
|
rows.map(async (r) => ({
|
|
28
63
|
id: r.id,
|
|
29
64
|
userId: r.userId,
|
|
@@ -36,5 +71,6 @@ export const listQuery = defineQueryHandler({
|
|
|
36
71
|
: r.userAgent,
|
|
37
72
|
})),
|
|
38
73
|
);
|
|
74
|
+
return { rows: decryptedRows, nextCursor: null };
|
|
39
75
|
},
|
|
40
76
|
});
|