@cosmicdrift/kumiko-bundled-features 0.70.0 → 0.71.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
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>",
@@ -84,11 +84,11 @@
84
84
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.70.0",
88
- "@cosmicdrift/kumiko-framework": "0.70.0",
89
- "@cosmicdrift/kumiko-headless": "0.70.0",
90
- "@cosmicdrift/kumiko-renderer": "0.70.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.70.0",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.71.0",
88
+ "@cosmicdrift/kumiko-framework": "0.71.0",
89
+ "@cosmicdrift/kumiko-headless": "0.71.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.71.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.71.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -15,6 +15,8 @@ import {
15
15
  unsafeCreateEntityTable,
16
16
  } from "@cosmicdrift/kumiko-framework/stack";
17
17
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { createUserFeature } from "../../user/feature";
19
+ import { userEntity } from "../../user/schema/user";
18
20
  import { createSessionsFeature } from "../feature";
19
21
  import { cleanupJob } from "../handlers/cleanup.job";
20
22
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
@@ -38,9 +40,10 @@ let stack: TestStack;
38
40
 
39
41
  beforeAll(async () => {
40
42
  stack = await setupTestStack({
41
- features: [createSessionsFeature()],
43
+ features: [createSessionsFeature(), createUserFeature()],
42
44
  });
43
45
  await unsafeCreateEntityTable(stack.db, userSessionEntity);
46
+ await unsafeCreateEntityTable(stack.db, userEntity);
44
47
  });
45
48
 
46
49
  afterAll(async () => {
@@ -19,6 +19,7 @@ import {
19
19
  unsafeCreateEntityTable,
20
20
  } from "@cosmicdrift/kumiko-framework/stack";
21
21
  import { Temporal } from "temporal-polyfill";
22
+ import { createUserFeature } from "../../user/feature";
22
23
  import { createSessionsFeature } from "../feature";
23
24
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
24
25
 
@@ -75,14 +76,14 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
75
76
 
76
77
  describe("sessions / read_user_sessions survives projection rebuild", () => {
77
78
  test("is NOT registered as a rebuildable implicit projection", () => {
78
- const registry = createRegistry([createSessionsFeature()]);
79
+ const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
79
80
  expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
80
81
  });
81
82
 
82
83
  test("direct-written rows (incl. revoked state) survive a rebuild", async () => {
83
84
  await insertRevokedSession(createTenantDb(testDb.db, TENANT));
84
85
 
85
- const registry = createRegistry([createSessionsFeature()]);
86
+ const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
86
87
  // Pre-fix: the implicit projection exists → rebuild swaps an empty shadow
87
88
  // → rows wiped. Post-fix: absent → no rebuild → rows untouched. Either way
88
89
  // a regression (re-adding r.entity) makes this fail.
@@ -21,7 +21,7 @@ import { createTenantFeature } from "../../tenant";
21
21
  import { tenantMembershipsTable } from "../../tenant/membership-table";
22
22
  import { tenantEntity } from "../../tenant/schema/tenant";
23
23
  import { createUserFeature } from "../../user/feature";
24
- import { userEntity, userTable } from "../../user/schema/user";
24
+ import { USER_STATUS, userEntity, userTable } from "../../user/schema/user";
25
25
  import { SessionHandlers, SessionQueries } from "../constants";
26
26
  import { createSessionsFeature } from "../feature";
27
27
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
@@ -455,3 +455,65 @@ describe("sessions feature — login → check → revoke → rejected", () => {
455
455
  expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
456
456
  });
457
457
  });
458
+
459
+ // Defense-in-depth: the sessionChecker refuses a live sid once the user it
460
+ // belongs to is locked, independent of whether session-revoke ran. Each case
461
+ // logs in WHILE active (login itself blocks locked users) and then flips the
462
+ // status, mirroring "user got restricted while a session was open".
463
+ describe("sessions feature — locked accounts blocked on a live session", () => {
464
+ test("active user passes — the gate leaves the happy path untouched", async () => {
465
+ await h.seedUser("active@example.com", "pw-long-enough");
466
+ const { token } = await h.login("active@example.com", "pw-long-enough");
467
+
468
+ const res = await h.authedPost("/api/query", token, {
469
+ type: "user:query:user:me",
470
+ payload: {},
471
+ });
472
+ expect(res.status).toBe(200);
473
+ });
474
+
475
+ test("restricted after login → 401 reason=blocked", async () => {
476
+ const { userId } = await h.seedUser("restrict@example.com", "pw-long-enough");
477
+ const { token } = await h.login("restrict@example.com", "pw-long-enough");
478
+ await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
479
+
480
+ const res = await h.authedPost("/api/query", token, {
481
+ type: "user:query:user:me",
482
+ payload: {},
483
+ });
484
+ expect(res.status).toBe(401);
485
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
486
+ expect(body.error?.details?.reason).toBe("blocked");
487
+ });
488
+
489
+ test("deleted after login → 401 reason=blocked", async () => {
490
+ const { userId } = await h.seedUser("gone@example.com", "pw-long-enough");
491
+ const { token } = await h.login("gone@example.com", "pw-long-enough");
492
+ await updateMany(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: userId });
493
+
494
+ const res = await h.authedPost("/api/query", token, {
495
+ type: "user:query:user:me",
496
+ payload: {},
497
+ });
498
+ expect(res.status).toBe(401);
499
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
500
+ expect(body.error?.details?.reason).toBe("blocked");
501
+ });
502
+
503
+ test("deletionRequested keeps its session live — reversible grace period", async () => {
504
+ const { userId } = await h.seedUser("leaving@example.com", "pw-long-enough");
505
+ const { token } = await h.login("leaving@example.com", "pw-long-enough");
506
+ await updateMany(
507
+ stack.db,
508
+ userTable,
509
+ { status: USER_STATUS.DeletionRequested },
510
+ { id: userId },
511
+ );
512
+
513
+ const res = await h.authedPost("/api/query", token, {
514
+ type: "user:query:user:me",
515
+ payload: {},
516
+ });
517
+ expect(res.status).toBe(200);
518
+ });
519
+ });
@@ -43,6 +43,10 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
43
43
  r.describe(
44
44
  "Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
45
45
  );
46
+ // sessionChecker reads read_users on every authenticated request (status
47
+ // gate for locked accounts) — make that a boot-time dependency so a
48
+ // sessions-without-user wiring fails validateBoot instead of 500ing live.
49
+ r.requires("user");
46
50
  // read_user_sessions is a hot-path direct-write store: sessionCreator
47
51
  // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
48
52
  // events (the row columns ARE the audit trail). Registering it as
@@ -10,9 +10,18 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
10
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
12
12
  import { Temporal } from "temporal-polyfill";
13
+ import { USER_STATUS, userTable } from "../user";
13
14
  import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
14
15
  import { userSessionTable } from "./schema/user-session";
15
16
 
17
+ // Locked accounts whose live sessions must be refused. deletionRequested is
18
+ // intentionally absent — it's a reversible grace period and the user needs
19
+ // their session to reach cancel-deletion.
20
+ const BLOCKED_STATUSES: ReadonlySet<string> = new Set([
21
+ USER_STATUS.Restricted,
22
+ USER_STATUS.Deleted,
23
+ ]);
24
+
16
25
  // Why the callbacks live at the raw-DB level rather than going through the
17
26
  // dispatcher: session-create/revoke/check run on the hot path of every
18
27
  // login and every request. The (createdAt/revokedAt/ip/userAgent) columns
@@ -90,6 +99,13 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
90
99
  if (row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds) {
91
100
  return "expired";
92
101
  }
102
+ // Defense-in-depth: status flips (Art. 18 restrict, forget) revoke
103
+ // sessions, but a missed revoke must not keep a locked account alive on
104
+ // a stale sid. Fail-OPEN on a lookup miss — this is the second layer,
105
+ // revocation is primary; never turn a user-row miss into a global
106
+ // lockout. (+1 PK read on read_users per authenticated request.)
107
+ const user = await fetchOne<{ status: string }>(db, userTable, { id: expectedUserId });
108
+ if (user && BLOCKED_STATUSES.has(user.status)) return "blocked";
93
109
  return "live";
94
110
  },
95
111
 
@@ -40,7 +40,7 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
40
40
  }
41
41
 
42
42
  const restored = await tagAssignmentExecutor.restore({ id }, event.user, ctx.db);
43
- if (restored.isSuccess) return restored;
43
+ if (restored.isSuccess) return { isSuccess: true as const, data: { id } };
44
44
  if (restored.error.code !== "not_found") return restored;
45
45
 
46
46
  const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
@@ -1,4 +1,4 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import {
3
3
  createStaticLocaleResolver,
4
4
  LocaleProvider,
@@ -17,6 +17,13 @@ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
17
17
  let catalogRows: readonly TagRow[] = [];
18
18
  let assignmentRows: readonly AssignmentRow[] = [];
19
19
 
20
+ // Each test sets its own rows; reset so a forgotten setup can't inherit the
21
+ // previous test's data (order-dependent shared state).
22
+ beforeEach(() => {
23
+ catalogRows = [];
24
+ assignmentRows = [];
25
+ });
26
+
20
27
  const dispatchSpy = mock(async (type: string) =>
21
28
  type === TagsHandlers.createTag
22
29
  ? { isSuccess: true, data: { id: "tag-new" } }
@@ -45,6 +52,46 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
45
52
  );
46
53
  }
47
54
 
55
+ // The real combobox is cmdk + Radix — its popover is e2e/primitive-test
56
+ // territory (see note above). To pin the onSelectionChange → assign/remove
57
+ // wiring we swap in a headless stub that renders one toggle button per option
58
+ // and fires onChange with the toggled selection — same contract, no popover.
59
+ const StubInput: typeof defaultPrimitives.Input = (props) => {
60
+ if (props.kind === "combobox" && props.multiple === true) {
61
+ const value = props.value;
62
+ return (
63
+ <div data-testid="stub-combobox">
64
+ {props.options.map((o) => {
65
+ const selected = value.includes(o.value);
66
+ return (
67
+ <button
68
+ key={o.value}
69
+ type="button"
70
+ data-testid={`tag-opt-${o.value}`}
71
+ onClick={() =>
72
+ props.onChange(selected ? value.filter((v) => v !== o.value) : [...value, o.value])
73
+ }
74
+ >
75
+ {o.label}
76
+ </button>
77
+ );
78
+ })}
79
+ </div>
80
+ );
81
+ }
82
+ return <input data-testid={`stub-${props.id}`} />;
83
+ };
84
+
85
+ function StubComboboxWrapper({ children }: { readonly children: ReactNode }): ReactNode {
86
+ return (
87
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
88
+ <PrimitivesProvider value={{ ...defaultPrimitives, Input: StubInput }}>
89
+ {children}
90
+ </PrimitivesProvider>
91
+ </LocaleProvider>
92
+ );
93
+ }
94
+
48
95
  // The combobox's assign/remove toggle drives onChange with the full new
49
96
  // selection; the component diffs it against the current tags via this helper.
50
97
  // Popover interaction itself (cmdk + Radix in jsdom) is covered by the
@@ -113,6 +160,41 @@ describe("TagSection", () => {
113
160
  );
114
161
  });
115
162
 
163
+ test("#524/3: selection change dispatches assign for additions, remove for removals", async () => {
164
+ catalogRows = [
165
+ { id: "t1", name: "important" },
166
+ { id: "t2", name: "project-x" },
167
+ ];
168
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
169
+ dispatchSpy.mockClear();
170
+
171
+ render(
172
+ <StubComboboxWrapper>
173
+ <TagSection entityName="note" entityId="note-1" />
174
+ </StubComboboxWrapper>,
175
+ );
176
+
177
+ // t2 is unselected → toggling it on adds it → assign-tag with t2
178
+ fireEvent.click(screen.getByTestId("tag-opt-t2"));
179
+ await waitFor(() =>
180
+ expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
181
+ tagId: "t2",
182
+ entityType: "note",
183
+ entityId: "note-1",
184
+ }),
185
+ );
186
+
187
+ // t1 is selected → toggling it off removes it → remove-tag with t1
188
+ fireEvent.click(screen.getByTestId("tag-opt-t1"));
189
+ await waitFor(() =>
190
+ expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
191
+ tagId: "t1",
192
+ entityType: "note",
193
+ entityId: "note-1",
194
+ }),
195
+ );
196
+ });
197
+
116
198
  test("create-mode (no entityId yet) shows the save-first hint instead of the manager", () => {
117
199
  render(
118
200
  <Wrapper>
@@ -113,15 +113,16 @@ export const userEntity = createEntity({
113
113
 
114
114
  // S2.U1: User-Lifecycle-Status für user-data-rights (Sprint 2).
115
115
  // - "active": Normaler State, alle Operationen erlaubt
116
- // - "restricted": Art. 18 Restriction — Auth-Middleware blockiert
117
- // Schreib-Endpoints, Read bleibt erlaubt damit
118
- // User das Banner sieht + lift-restriction klicken kann
119
- // - "deletionRequested": delete-account aufgerufen, gracePeriodEnd
120
- // gesetzt, User kann via cancel-deletion zurueck
121
- // auf "active". Auth-Middleware blockiert wie
122
- // "restricted".
116
+ // - "restricted": Art. 18 Restriction — Login blockiert + jede
117
+ // Live-Session wird vom sessionChecker abgewiesen
118
+ // ("blocked"). Recovery via lift-restriction
119
+ // (openToAll, session-unabhängig).
120
+ // - "deletionRequested": delete-account aufgerufen, gracePeriodEnd gesetzt,
121
+ // Login blockiert. Bestehende Session bleibt LIVE
122
+ // (reversibel) — User kann via cancel-deletion
123
+ // zurück auf "active".
123
124
  // - "deleted": Forget executed nach Grace, Row anonymisiert via
124
- // softDelete. Auth-Middleware blockt Login.
125
+ // softDelete. Login blockiert + Session "blocked".
125
126
  //
126
127
  // Schreibrecht privileged: nur die request-deletion / restrict / lift /
127
128
  // execute-forget-Handler (alle SYSTEM-context) duerfen status flippen.
@@ -188,12 +188,13 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
188
188
 
189
189
  const after = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
190
190
  status: string;
191
- gracePeriodEnd: unknown;
191
+ gracePeriodEnd: typeof gracePeriodEnd | null;
192
192
  }>;
193
193
  expect(after[0]?.status).toBe(USER_STATUS.DeletionRequested);
194
- // gracePeriodEnd ueberlebt nicht null nach Replay.
195
- expect(after[0]?.gracePeriodEnd).not.toBeNull();
196
- expect(after[0]?.gracePeriodEnd).toBeDefined();
194
+ // gracePeriodEnd ueberlebt den Replay WERT-genau, nicht nur non-null: ein
195
+ // Timezone-/Roundtrip-Fehler liefert non-null aber den falschen Instant.
196
+ // epoch-ms toleriert die DB-Präzision (µs) ohne sub-ms-Drift zu prüfen.
197
+ expect(after[0]?.gracePeriodEnd?.epochMilliseconds).toBe(gracePeriodEnd.epochMilliseconds);
197
198
  });
198
199
 
199
200
  // Ehrlicher Spiegel zum Forward-Test: Bestandsdaten, deren Status der ALTE
@@ -146,6 +146,44 @@ describe("ProfileScreen", () => {
146
146
  fetchSpy.mockRestore();
147
147
  }
148
148
  });
149
+
150
+ // #472/1: der Server antwortet auf den Verification-Versand mit ok:false
151
+ // (z.B. 4xx) OHNE zu werfen. Das ist ein anderer Zweig als das catch oben —
152
+ // er muss eigenständig geloggt werden, der Wechsel bleibt erfolgreich.
153
+ test("email change: verification-send rejected by server (ok:false) is surfaced", async () => {
154
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
155
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
156
+ new Response("{}", { status: 400 }),
157
+ );
158
+ try {
159
+ const view = renderProfile(activeMe);
160
+ await waitFor(() => {
161
+ if (view.queryByTestId("profile-email") === null) throw new Error("not mounted yet");
162
+ });
163
+
164
+ const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
165
+ const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
166
+ if (!emailInput || !pwInput) throw new Error("email form inputs not found");
167
+ fireEvent.change(emailInput, { target: { value: "new@example.com" } });
168
+ fireEvent.change(pwInput, { target: { value: "current-pw" } });
169
+ fireEvent.click(view.getByTestId("profile-email-submit"));
170
+
171
+ // Der ok:false-Zweig loggt SEINE Message ("could not be sent"),
172
+ // nicht die des catch-Zweigs ("send threw").
173
+ await waitFor(() => {
174
+ const hit = warnSpy.mock.calls.some((c) => String(c[0]).includes("could not be sent"));
175
+ if (!hit) throw new Error("ok:false verification failure not surfaced");
176
+ });
177
+ expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("send threw"))).toBe(false);
178
+ // Wechsel bleibt erfolgreich: das Eingabefeld wird zurückgesetzt.
179
+ await waitFor(() => {
180
+ if (emailInput.value !== "") throw new Error("email input not cleared after success");
181
+ });
182
+ } finally {
183
+ warnSpy.mockRestore();
184
+ fetchSpy.mockRestore();
185
+ }
186
+ });
149
187
  });
150
188
 
151
189
  describe("formatDeletionDate", () => {