@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.0",
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.0",
130
- "@cosmicdrift/kumiko-framework": "0.208.0",
131
- "@cosmicdrift/kumiko-headless": "0.208.0",
132
- "@cosmicdrift/kumiko-renderer": "0.208.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.208.0",
134
- "@cosmicdrift/kumiko-types": "0.208.0",
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.0"
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: Array<{
583
- id: string;
584
- userId: string;
585
- createdAt: string;
586
- revokedAt: string | null;
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
 
@@ -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 { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
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
- export const listQuery = defineQueryHandler({
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 (_query, ctx) => {
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
- orderBy: { col: "createdAt", direction: "desc" },
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
- return Promise.all(
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
  });