@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 +9 -9
- package/src/auth-email-password/changes.json +5 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-mfa/__tests__/session-scoped-sse-revoke.integration.test.ts +270 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -0
- package/src/cap-counter/changes.json +5 -0
- package/src/cap-counter/enforce-cap.ts +8 -1
- package/src/sessions/handlers/revoke-all-others.write.ts +1 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/sessions/session-revoked-event.ts +3 -0
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "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 {
|
|
@@ -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
|
|
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) {
|
|
@@ -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",
|