@cosmicdrift/kumiko-bundled-features 0.306.0 → 0.307.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.306.0",
3
+ "version": "0.307.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>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.306.0",
137
- "@cosmicdrift/kumiko-framework": "0.306.0",
138
- "@cosmicdrift/kumiko-headless": "0.306.0",
139
- "@cosmicdrift/kumiko-renderer": "0.306.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.306.0",
141
- "@cosmicdrift/kumiko-types": "0.306.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.307.0",
137
+ "@cosmicdrift/kumiko-framework": "0.307.0",
138
+ "@cosmicdrift/kumiko-headless": "0.307.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.307.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.307.0",
141
+ "@cosmicdrift/kumiko-types": "0.307.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.306.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.306.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.307.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.307.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.307.0",
4
+ "type": "improvement",
5
+ "title": "Anonymous signup-confirm and invite-signup-complete (auth-email-password) and verify / enable-confirm-preauth (auth-mfa) declare personalData: public-intake, since they write personal data through a TenantDb derived from ctx.db.unsafeRaw (fw#3185)"
6
+ },
2
7
  {
3
8
  "version": "0.305.0",
4
9
  "type": "breaking",
@@ -92,7 +92,7 @@ export function createInviteSignupCompleteHandler() {
92
92
  >({
93
93
  name: "invite-signup-complete",
94
94
  schema: InviteSignupCompleteSchema,
95
- access: { roles: ["anonymous"] },
95
+ access: { roles: ["anonymous"], personalData: "public-intake" },
96
96
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
97
97
  agent: { expose: false },
98
98
  escapeHatch: {
@@ -67,7 +67,7 @@ export function createSignupConfirmHandler() {
67
67
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
68
68
  name: "signup-confirm",
69
69
  schema: SignupConfirmSchema,
70
- access: { roles: ["anonymous"] },
70
+ access: { roles: ["anonymous"], personalData: "public-intake" },
71
71
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
72
72
  agent: { expose: false },
73
73
  escapeHatch: {
@@ -0,0 +1,270 @@
1
+ // Session-scoped SSE revocation (kumiko-framework "session-scoped-revoke").
2
+ //
3
+ // enable-confirm (and disable/regenerate-recovery) call
4
+ // sessionRevokeAllOthers, which appends sessions:event:session-revoked with
5
+ // an optional `keptSessionId` set to the caller's own sid — the ONE sid this
6
+ // write deliberately does NOT revoke. Before this fix,
7
+ // createAccessInvalidationEventConsumer ignored that and called
8
+ // sseBroker.publishAccessInvalidation USERWIDE, which closed the caller's
9
+ // own just-opened SSE stream along with every other session's — the exact
10
+ // bug this test proves fixed: studio's live MFA-status panel never saw the
11
+ // state flip because its own stream died with everyone else's.
12
+ //
13
+ // This is a KEEP-list (spare exactly keptSessionId), not a kill-list
14
+ // (scope to the revoked sessionIds) — the first test below proves why: a
15
+ // third session (C) that was already logged out through the real
16
+ // /api/auth/logout endpoint BEFORE enable-confirm runs never appears in any
17
+ // session-revoked event's sessionIds (logout revokes via the raw
18
+ // sessionRevoker callback with no event at all). A kill-list keyed on
19
+ // sessionIds would silently leave C's still-open stream running; the
20
+ // keep-list closes it like every other non-kept stream, exactly as the
21
+ // pre-scoping userwide invalidation always did.
22
+ //
23
+ // Real stack throughout: setupTestStack + real HTTP (createLiveDispatcher for
24
+ // the SSE streams, stack.http for the writes), never createTestDispatcher.
25
+
26
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
27
+ import { randomBytes } from "node:crypto";
28
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
29
+ import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
30
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
31
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
32
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
33
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
34
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
35
+ import {
36
+ setupTestStack,
37
+ type TestStack,
38
+ TestUsers,
39
+ testTenantId,
40
+ unsafeCreateEntityTable,
41
+ unsafePushTables,
42
+ } from "@cosmicdrift/kumiko-framework/stack";
43
+ import {
44
+ createLateBoundHolder,
45
+ createTestEnvelopeCipher,
46
+ resetTestTables,
47
+ } from "@cosmicdrift/kumiko-framework/testing";
48
+ import { z } from "zod";
49
+ import { AuthHandlers } from "../../auth-email-password/constants";
50
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
51
+ import { createConfigFeature } from "../../config";
52
+ import { createConfigResolver } from "../../config/resolver";
53
+ import { configValuesTable } from "../../config/table";
54
+ import { makeSessionHelpers } from "../../sessions/__tests__/test-helpers";
55
+ import { createSessionsFeature } from "../../sessions/feature";
56
+ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
57
+ import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
58
+ import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
59
+ import { createTenantFeature } from "../../tenant";
60
+ import { TenantHandlers } from "../../tenant/constants";
61
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
62
+ import { tenantEntity } from "../../tenant/schema/tenant";
63
+ import { createUserFeature } from "../../user/feature";
64
+ import { userEntity, userTable } from "../../user/schema/user";
65
+ import { base32Decode } from "../base32";
66
+ import { AuthMfaHandlers } from "../constants";
67
+ import { bindMfaRevokeAllOtherSessionsFromFeature, createAuthMfaFeature } from "../feature";
68
+ import { userMfaEntity } from "../schema/user-mfa";
69
+ import { currentTotpCode } from "../totp";
70
+
71
+ let stack: TestStack;
72
+ let h: ReturnType<typeof makeSessionHelpers>;
73
+ let sessionCreator: SessionCreator;
74
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
75
+
76
+ const encryptionKey = randomBytes(32).toString("base64");
77
+ const SETUP_TOKEN_SECRET = "test-mfa-setup-token-secret-at-least-32-bytes!!";
78
+ const TENANT = testTenantId(1);
79
+
80
+ const chunkGates = new Map<string, { promise: Promise<void>; release: () => void }>();
81
+
82
+ function createGate(gateId: string): () => void {
83
+ let release!: () => void;
84
+ const promise = new Promise<void>((resolve) => {
85
+ release = resolve;
86
+ });
87
+ chunkGates.set(gateId, { promise, release });
88
+ return release;
89
+ }
90
+
91
+ const streamProbeFeature = defineFeature("stream-probe", (r) => {
92
+ r.streamHandler(
93
+ "probe:tail",
94
+ z.object({ gateId: z.string().min(1) }),
95
+ async function* (event) {
96
+ yield { phase: "open" as const };
97
+ const gate = chunkGates.get(event.payload.gateId);
98
+ if (!gate) throw new Error(`missing gate ${event.payload.gateId}`);
99
+ await gate.promise;
100
+ yield { phase: "survived" as const };
101
+ },
102
+ { access: { roles: ["User"] } },
103
+ );
104
+ });
105
+
106
+ function buildLiveFetch(authJwt: string): {
107
+ readonly fetch: typeof fetch;
108
+ readonly csrfToken: string;
109
+ } {
110
+ const csrfToken = generateToken();
111
+ const cookieHeader = `kumiko_auth=${authJwt}; kumiko_csrf=${csrfToken}`;
112
+ const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
113
+ const reqInit: RequestInit = {
114
+ ...(init ?? {}),
115
+ headers: {
116
+ ...(init?.headers ?? {}),
117
+ Cookie: cookieHeader,
118
+ },
119
+ };
120
+ return stack.app.request(String(url), reqInit);
121
+ }) as unknown as typeof fetch;
122
+ return { fetch: fetchImpl, csrfToken };
123
+ }
124
+
125
+ async function openProbeStream(token: string): Promise<{
126
+ iter: AsyncIterator<{ phase: string }>;
127
+ release: () => void;
128
+ }> {
129
+ const gateId = generateToken();
130
+ const release = createGate(gateId);
131
+ const { fetch, csrfToken } = buildLiveFetch(token);
132
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
133
+ const iter = dispatcher
134
+ .stream<{ phase: string }>("stream-probe:stream:probe:tail", { gateId })
135
+ [Symbol.asyncIterator]();
136
+ const first = await iter.next();
137
+ expect(first.done).toBe(false);
138
+ expect(first.value).toEqual({ phase: "open" });
139
+ return { iter, release };
140
+ }
141
+
142
+ beforeAll(async () => {
143
+ const encryption = createTestEnvelopeCipher(encryptionKey);
144
+ configureEntityFieldEncryption(encryption);
145
+ const resolver = createConfigResolver({ cipher: encryption });
146
+ const bound = sessionCallbacksFromLateBound(callbacks);
147
+ const mfaFeature = createAuthMfaFeature({
148
+ setupTokenSecret: SETUP_TOKEN_SECRET,
149
+ issuer: "Kumiko Test",
150
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
151
+ });
152
+
153
+ stack = await setupTestStack({
154
+ features: [
155
+ createConfigFeature(),
156
+ createUserFeature(),
157
+ createTenantFeature(),
158
+ createAuthEmailPasswordFeature(),
159
+ authFoundationFeature,
160
+ createSessionsFeature({ autoRevokeOnPasswordChange: bound.asMassRevoker() }),
161
+ mfaFeature,
162
+ streamProbeFeature,
163
+ ],
164
+ extraContext: { configResolver: resolver, configEncryption: encryption },
165
+ authConfig: {
166
+ ...bound.asAuthConfig(),
167
+ membershipQuery: "tenant:query:memberships",
168
+ loginHandler: AuthHandlers.login,
169
+ },
170
+ });
171
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
172
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(callbacks.get().sessionRevokeAllOthers);
173
+
174
+ h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
175
+ const boundSessionCreator = bound.asAuthConfig().sessionCreator;
176
+ if (!boundSessionCreator) throw new Error("sessionCreator not bound — check setup order");
177
+ sessionCreator = boundSessionCreator;
178
+
179
+ await unsafeCreateEntityTable(stack.db, userEntity);
180
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
181
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
182
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
183
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
184
+ });
185
+
186
+ afterAll(async () => {
187
+ await stack.cleanup();
188
+ });
189
+
190
+ beforeEach(async () => {
191
+ await resetTestTables(stack.db, [
192
+ userTable,
193
+ tenantMembershipsTable,
194
+ userSessionTable,
195
+ eventsTable,
196
+ ]);
197
+ chunkGates.clear();
198
+ });
199
+
200
+ describe("session-scoped SSE revoke (enable-confirm keeps the caller's own stream open)", () => {
201
+ test("enable-confirm from session A keeps A's stream alive, closes B's, and also closes C's even though C was already logged out through the eventless /api/auth/logout path", async () => {
202
+ await h.seedUser("mfa-scope-a@example.com", "pw-long-enough");
203
+ const sessionA = await h.login("mfa-scope-a@example.com", "pw-long-enough");
204
+ const sessionB = await h.login("mfa-scope-a@example.com", "pw-long-enough");
205
+ const sessionC = await h.login("mfa-scope-a@example.com", "pw-long-enough");
206
+
207
+ const streamA = await openProbeStream(sessionA.token);
208
+ const streamB = await openProbeStream(sessionB.token);
209
+ const streamC = await openProbeStream(sessionC.token);
210
+
211
+ // C logs out through the REAL endpoint — sessionRevoker (session-
212
+ // callbacks.ts) revokes the row directly, no session-revoked event at
213
+ // all. C's sid therefore never appears in any later event's sessionIds.
214
+ const logoutRes = await h.authedPost("/api/auth/logout", sessionC.token);
215
+ expect(logoutRes.status).toBe(200);
216
+
217
+ const start = await h.authedPost("/api/write", sessionA.token, {
218
+ type: AuthMfaHandlers.enableStart,
219
+ payload: { accountLabel: "mfa-scope-a@example.com" },
220
+ });
221
+ expect(start.status).toBe(200);
222
+ const startBody = (await start.json()) as {
223
+ data: { setupToken: string; otpauthUri: string };
224
+ };
225
+ const secretParam =
226
+ new URLSearchParams(startBody.data.otpauthUri.split("?")[1]).get("secret") ?? "";
227
+ const secret = base32Decode(secretParam);
228
+
229
+ const confirm = await h.authedPost("/api/write", sessionA.token, {
230
+ type: AuthMfaHandlers.enableConfirm,
231
+ payload: { setupToken: startBody.data.setupToken, code: currentTotpCode(secret) },
232
+ });
233
+ expect(confirm.status).toBe(200);
234
+
235
+ await stack.eventDispatcher?.runOnce();
236
+
237
+ // Assert B and C first, without releasing A's gate: their rejections are
238
+ // proof the publish already reached every listener (they fire from the
239
+ // same publish loop), so a still-pending A cannot pass "by being slow".
240
+ await expect(streamB.iter.next()).rejects.toMatchObject({ code: "access_denied" });
241
+ await expect(streamC.iter.next()).rejects.toMatchObject({ code: "access_denied" });
242
+
243
+ streamA.release();
244
+ const survivedChunk = await streamA.iter.next();
245
+ expect(survivedChunk.done).toBe(false);
246
+ expect(survivedChunk.value).toEqual({ phase: "survived" });
247
+ });
248
+
249
+ test("an invalidation reason with no keptSessionId (role change) still closes every session's stream, unscoped", async () => {
250
+ const { userId } = await h.seedUser("mfa-scope-b@example.com", "pw-long-enough");
251
+ const sessionA = await h.login("mfa-scope-b@example.com", "pw-long-enough");
252
+ const sessionB = await h.login("mfa-scope-b@example.com", "pw-long-enough");
253
+
254
+ const streamA = await openProbeStream(sessionA.token);
255
+ const streamB = await openProbeStream(sessionB.token);
256
+
257
+ const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
258
+ const result = await stack.http.writeOk(
259
+ TenantHandlers.updateMemberRoles,
260
+ { userId, tenantId: TENANT, roles: ["Admin"] },
261
+ admin,
262
+ );
263
+ expect(result).toBeTruthy();
264
+
265
+ await stack.eventDispatcher?.runOnce();
266
+
267
+ await expect(streamA.iter.next()).rejects.toMatchObject({ code: "access_denied" });
268
+ await expect(streamB.iter.next()).rejects.toMatchObject({ code: "access_denied" });
269
+ });
270
+ });
@@ -63,7 +63,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
63
63
  setupToken: z.string().min(1),
64
64
  code: z.string().length(6),
65
65
  }),
66
- access: { roles: ["anonymous"] },
66
+ access: { roles: ["anonymous"], personalData: "public-intake" },
67
67
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
68
68
  escapeHatch: {
69
69
  reason:
@@ -45,7 +45,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
45
45
  return defineWriteHandler({
46
46
  name: "verify",
47
47
  schema: z.object({ challengeToken: z.string().min(1), code: z.string().min(6).max(9) }),
48
- access: { roles: ["anonymous"] },
48
+ access: { roles: ["anonymous"], personalData: "public-intake" },
49
49
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
50
50
  escapeHatch: {
51
51
  reason:
@@ -114,6 +114,13 @@ describe("enforceCap — burstable profile (mails / tokens)", () => {
114
114
  await expect(enforceCap(ctx, opts)).rejects.toThrow(CapExceededError);
115
115
  });
116
116
 
117
+ test("amount checks the last of the booked units: 1190 + 10 units passes, + 11 throws", async () => {
118
+ const ctx = stubCalendarCtx([{ value: 1190, lastSoftWarnedAt: null }]);
119
+ const passing = await enforceCap(ctx, { ...opts, amount: 10 });
120
+ expect(passing).toEqual({ state: "soft-hit", value: 1199, crossed: true });
121
+ await expect(enforceCap(ctx, { ...opts, amount: 11 })).rejects.toThrow(CapExceededError);
122
+ });
123
+
117
124
  test("CapExceededError carries cap-name + limit + currentValue", async () => {
118
125
  const ctx = stubCalendarCtx([{ value: 1500, lastSoftWarnedAt: null }]);
119
126
  try {
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.307.0",
4
+ "type": "improvement",
5
+ "title": "enforceCap accepts an amount for multi-unit bookings"
6
+ },
2
7
  {
3
8
  "version": "0.278.0",
4
9
  "type": "improvement",
@@ -93,6 +93,9 @@ export async function enforceCap(
93
93
  readonly periodStartIso: string;
94
94
  readonly limit: number;
95
95
  readonly profile: CapToleranceProfileName;
96
+ // Units the caller is about to book in one go (default 1), checked with
97
+ // the same outcome as booking them one by one.
98
+ readonly amount?: number;
96
99
  },
97
100
  ): Promise<EnforceCapResult> {
98
101
  if (!ctx.db) {
@@ -110,7 +113,9 @@ export async function enforceCap(
110
113
  );
111
114
 
112
115
  const row = rows[0];
113
- const value = row ? (row["value"] as number) : 0; // @cast-boundary db-row
116
+ const storedValue = row ? (row["value"] as number) : 0; // @cast-boundary db-row
117
+ // The last of `amount` units sees this value before its own increment.
118
+ const value = storedValue + (options.amount ?? 1) - 1;
114
119
 
115
120
  if (value >= hardThreshold) {
116
121
  throw new CapExceededError(options.capName, options.limit, value, tolerance);
@@ -285,6 +290,7 @@ export async function enforceCapAndMaybeNotify(
285
290
  readonly limit: number;
286
291
  readonly profile: CapToleranceProfileName;
287
292
  readonly notify: SoftHitNotifier;
293
+ readonly amount?: number;
288
294
  },
289
295
  ): Promise<EnforceCapResult> {
290
296
  const result = await enforceCap(ctx, {
@@ -292,6 +298,7 @@ export async function enforceCapAndMaybeNotify(
292
298
  periodStartIso: options.periodStartIso,
293
299
  limit: options.limit,
294
300
  profile: options.profile,
301
+ ...(options.amount !== undefined && { amount: options.amount }),
295
302
  });
296
303
 
297
304
  if (result.state === "soft-hit" && result.crossed) {
@@ -55,6 +55,7 @@ export const revokeAllOthersWrite = defineWriteHandler({
55
55
  payload: {
56
56
  userId: event.user.id,
57
57
  sessionIds: updated.map((row) => row.id),
58
+ keptSessionId: keepSid,
58
59
  },
59
60
  });
60
61
  }
@@ -244,6 +244,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
244
244
  const payload = sessionRevokedSchema.parse({
245
245
  userId,
246
246
  sessionIds: result.map((row: { id: string }) => row.id),
247
+ keptSessionId: currentSid,
247
248
  });
248
249
  await append(db, {
249
250
  aggregateId: generateId(),
@@ -24,6 +24,9 @@ export const SESSION_REVOKED_AGGREGATE_TYPE = "user-session-revocation" as const
24
24
  export const sessionRevokedSchema = z.object({
25
25
  userId: z.string().min(1),
26
26
  sessionIds: z.array(z.uuid()).min(1),
27
+ // The caller's own session a "revoke all others" write spared. Optional so
28
+ // events appended before it existed still replay.
29
+ keptSessionId: z.uuid().optional(),
27
30
  });
28
31
 
29
32
  export type SessionRevokedPayload = z.infer<typeof sessionRevokedSchema>;
@@ -187,6 +187,11 @@ describe("workflow-runner event-trigger", () => {
187
187
  // The registrar namespaces every MSP as `<feature>:projection:<name>`.
188
188
  const consumerName = `workflow-runner-integration-test-triggers:projection:workflow-${failingWorkflow.name}`;
189
189
 
190
+ // Consumers run their turns concurrently, so events an earlier test's
191
+ // workflow emitted may still be ahead of this consumer's cursor; drain
192
+ // them first so `processed` below counts only this test's trigger.
193
+ while (((await stack.eventDispatcher?.runOnce())?.processed ?? 0) > 0) {}
194
+
190
195
  await insertOne(stack.db, eventsTable, {
191
196
  aggregateId: crypto.randomUUID(),
192
197
  aggregateType: "wr-test-source",