@cosmicdrift/kumiko-bundled-features 0.290.0 → 0.291.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 +6 -0
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +31 -2
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +120 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +72 -0
- package/src/auth-email-password/web/auth-form-logic.ts +5 -5
- package/src/auth-email-password/web/invite-accept-screen.tsx +46 -21
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/src/delivery/feature.ts +6 -1
- package/src/jobs/__tests__/tenant-job-failures.integration.test.ts +275 -0
- package/src/jobs/changes.json +7 -0
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/db/queries/retention.ts +17 -1
- package/src/jobs/feature.ts +7 -1
- package/src/jobs/handlers/tenant-failures.query.ts +82 -0
- package/src/jobs/index.ts +1 -0
- package/src/jobs/job-run-logger.ts +73 -4
- package/src/jobs/tenant-job-failure-table.ts +39 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +69 -1
- package/src/rate-limiting/changes.json +9 -1
- package/src/rate-limiting/constants.ts +1 -0
- package/src/rate-limiting/handlers/bucket-access.ts +39 -0
- package/src/rate-limiting/handlers/status.query.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.291.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.291.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.291.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.291.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.291.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.291.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.291.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.291.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.291.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "loggedInHref callbacks on SignupCompleteScreen and InviteAcceptScreen now receive the roles granted by the flow",
|
|
6
|
+
"detail": "Apps can route a freshly activated user to a screen their role can actually open, instead of a fixed path that may render Access denied. The string form of loggedInHref is unchanged; the function form gains a roles field next to the existing tenantKey/tenantId."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.286.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -34,11 +34,40 @@ describe("retryAfterMinutes", () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
describe("resolveLoggedInHref", () => {
|
|
37
|
+
type SignupArgs = { tenantKey: string; roles: readonly string[] };
|
|
38
|
+
|
|
37
39
|
test("string href returned as-is", () => {
|
|
38
|
-
expect(resolveLoggedInHref("/", "acme")).toBe("/");
|
|
40
|
+
expect(resolveLoggedInHref<SignupArgs>("/", { tenantKey: "acme", roles: ["User"] })).toBe("/");
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
test("function href receives tenantKey", () => {
|
|
42
|
-
expect(
|
|
44
|
+
expect(
|
|
45
|
+
resolveLoggedInHref<SignupArgs>(({ tenantKey }) => `/${tenantKey}/`, {
|
|
46
|
+
tenantKey: "acme",
|
|
47
|
+
roles: [],
|
|
48
|
+
}),
|
|
49
|
+
).toBe("/acme/");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("function href can route on the roles the server returned", () => {
|
|
53
|
+
const byRole = ({ tenantKey, roles }: SignupArgs): string =>
|
|
54
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`;
|
|
55
|
+
|
|
56
|
+
expect(resolveLoggedInHref(byRole, { tenantKey: "acme", roles: ["Dealer"] })).toBe(
|
|
57
|
+
"/acme/vehicle-start",
|
|
58
|
+
);
|
|
59
|
+
expect(resolveLoggedInHref(byRole, { tenantKey: "acme", roles: ["SystemAdmin"] })).toBe(
|
|
60
|
+
"/a/waitlist-list",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("args object shape is per-screen — invite passes tenantId", () => {
|
|
65
|
+
expect(
|
|
66
|
+
resolveLoggedInHref(
|
|
67
|
+
({ tenantId, roles }: { tenantId: string; roles: readonly string[] }) =>
|
|
68
|
+
`/${tenantId}/${roles[0] ?? "none"}`,
|
|
69
|
+
{ tenantId: "t1", roles: ["Editor"] },
|
|
70
|
+
),
|
|
71
|
+
).toBe("/t1/Editor");
|
|
43
72
|
});
|
|
44
73
|
});
|
|
@@ -91,6 +91,53 @@ describe("InviteAcceptScreen — logged-in branch", () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test("branch 1: loggedInHref-Function bekommt die Rolle aus der invite-accept-Response", async () => {
|
|
95
|
+
globalThis.fetch = mock(
|
|
96
|
+
async () =>
|
|
97
|
+
new Response(
|
|
98
|
+
JSON.stringify({ tenantId: "tenant-new", role: "Dealer", alreadyMember: false }),
|
|
99
|
+
{ status: 200 },
|
|
100
|
+
),
|
|
101
|
+
) as unknown as typeof fetch;
|
|
102
|
+
const assign = mock<(url: string) => void>();
|
|
103
|
+
Object.defineProperty(window, "location", {
|
|
104
|
+
configurable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
renderWithProviders(
|
|
110
|
+
<InviteAcceptScreen
|
|
111
|
+
token="tok-123"
|
|
112
|
+
loggedInHref={({ tenantId, roles }) =>
|
|
113
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantId}/vehicle-start`
|
|
114
|
+
}
|
|
115
|
+
/>,
|
|
116
|
+
{ session: makeSessionApi({ status: "authenticated" }) },
|
|
117
|
+
);
|
|
118
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(assign).toHaveBeenCalledWith("/tenant-new/vehicle-start");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("String-Form bleibt unverändert gültig", async () => {
|
|
125
|
+
const assign = mock<(url: string) => void>();
|
|
126
|
+
Object.defineProperty(window, "location", {
|
|
127
|
+
configurable: true,
|
|
128
|
+
writable: true,
|
|
129
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
renderWithProviders(<InviteAcceptScreen token="tok-123" loggedInHref="/fixed-landing" />, {
|
|
133
|
+
session: makeSessionApi({ status: "authenticated" }),
|
|
134
|
+
});
|
|
135
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(assign).toHaveBeenCalledWith("/fixed-landing");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
94
141
|
test("accept logged-in failure shows invalidInviteToken banner", async () => {
|
|
95
142
|
globalThis.fetch = mock(
|
|
96
143
|
async () => new Response(null, { status: 422 }),
|
|
@@ -154,6 +201,79 @@ describe("InviteAcceptScreen — anonymous branches", () => {
|
|
|
154
201
|
});
|
|
155
202
|
});
|
|
156
203
|
|
|
204
|
+
test("branch 2/3: die Session-Rollen aus user.roles schlagen die Invitation-Rolle", async () => {
|
|
205
|
+
globalThis.fetch = mock(
|
|
206
|
+
async () =>
|
|
207
|
+
new Response(
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
token: "jwt",
|
|
210
|
+
user: { id: "u1", tenantId: "t1", roles: ["Dealer"] },
|
|
211
|
+
tenantId: "t1",
|
|
212
|
+
role: "SystemAdmin",
|
|
213
|
+
}),
|
|
214
|
+
{ status: 200 },
|
|
215
|
+
),
|
|
216
|
+
) as unknown as typeof fetch;
|
|
217
|
+
const assign = mock<(url: string) => void>();
|
|
218
|
+
Object.defineProperty(window, "location", {
|
|
219
|
+
configurable: true,
|
|
220
|
+
writable: true,
|
|
221
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
render(
|
|
225
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
226
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
227
|
+
<InviteAcceptScreen
|
|
228
|
+
token="tok-123"
|
|
229
|
+
loggedInHref={({ tenantId, roles }) => `/${tenantId}/${roles.join("+")}`}
|
|
230
|
+
/>
|
|
231
|
+
</LocaleProvider>
|
|
232
|
+
</PrimitivesProvider>,
|
|
233
|
+
);
|
|
234
|
+
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: "a@example.com" } });
|
|
235
|
+
fireEvent.change(screen.getByLabelText(/^Password/), { target: { value: "secret123" } });
|
|
236
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept + sign in" }));
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(assign).toHaveBeenCalledWith("/t1/Dealer");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("MFA-Challenge (200 ohne Rollen) liefert eine leere Rollenliste statt undefined", async () => {
|
|
243
|
+
globalThis.fetch = mock(
|
|
244
|
+
async () =>
|
|
245
|
+
new Response(JSON.stringify({ mfaRequired: true, challengeToken: "ch" }), { status: 200 }),
|
|
246
|
+
) as unknown as typeof fetch;
|
|
247
|
+
const assign = mock<(url: string) => void>();
|
|
248
|
+
Object.defineProperty(window, "location", {
|
|
249
|
+
configurable: true,
|
|
250
|
+
writable: true,
|
|
251
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
252
|
+
});
|
|
253
|
+
const seen: (readonly string[])[] = [];
|
|
254
|
+
|
|
255
|
+
render(
|
|
256
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
257
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
258
|
+
<InviteAcceptScreen
|
|
259
|
+
token="tok-123"
|
|
260
|
+
loggedInHref={({ roles }) => {
|
|
261
|
+
seen.push(roles);
|
|
262
|
+
return "/";
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</LocaleProvider>
|
|
266
|
+
</PrimitivesProvider>,
|
|
267
|
+
);
|
|
268
|
+
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: "a@example.com" } });
|
|
269
|
+
fireEvent.change(screen.getByLabelText(/^Password/), { target: { value: "secret123" } });
|
|
270
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept + sign in" }));
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
expect(assign).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
expect(seen).toEqual([[]]);
|
|
275
|
+
});
|
|
276
|
+
|
|
157
277
|
test("anon failure shows invalidInviteToken banner", async () => {
|
|
158
278
|
globalThis.fetch = mock(
|
|
159
279
|
async () => new Response(null, { status: 422 }),
|
|
@@ -107,6 +107,78 @@ describe("SignupCompleteScreen", () => {
|
|
|
107
107
|
}
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("loggedInHref-Function bekommt die Rollen aus der signup-confirm-Response", async () => {
|
|
111
|
+
globalThis.fetch = mock(
|
|
112
|
+
async () =>
|
|
113
|
+
new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
user: { id: "u1", tenantId: "t1", roles: ["Dealer"] },
|
|
116
|
+
tenantKey: "acme",
|
|
117
|
+
}),
|
|
118
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
119
|
+
),
|
|
120
|
+
) as unknown as typeof fetch;
|
|
121
|
+
|
|
122
|
+
renderWithProviders(
|
|
123
|
+
<SignupCompleteScreen
|
|
124
|
+
token="abc-token"
|
|
125
|
+
loggedInHref={({ tenantKey, roles }) =>
|
|
126
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`
|
|
127
|
+
}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
fillPasswords("validpass1", "validpass1");
|
|
131
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
135
|
+
"/acme/vehicle-start",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("gleiche Function, SystemAdmin-Rolle → anderes Ziel", async () => {
|
|
141
|
+
globalThis.fetch = mock(
|
|
142
|
+
async () =>
|
|
143
|
+
new Response(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
user: { id: "u1", tenantId: "t1", roles: ["SystemAdmin"] },
|
|
146
|
+
tenantKey: "acme",
|
|
147
|
+
}),
|
|
148
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
149
|
+
),
|
|
150
|
+
) as unknown as typeof fetch;
|
|
151
|
+
|
|
152
|
+
renderWithProviders(
|
|
153
|
+
<SignupCompleteScreen
|
|
154
|
+
token="abc-token"
|
|
155
|
+
loggedInHref={({ tenantKey, roles }) =>
|
|
156
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`
|
|
157
|
+
}
|
|
158
|
+
/>,
|
|
159
|
+
);
|
|
160
|
+
fillPasswords("validpass1", "validpass1");
|
|
161
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
165
|
+
"/a/waitlist-list",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("String-Form bleibt unverändert gültig und ignoriert die Rollen", async () => {
|
|
171
|
+
renderWithProviders(<SignupCompleteScreen token="abc-token" loggedInHref="/fixed-landing" />);
|
|
172
|
+
fillPasswords("validpass1", "validpass1");
|
|
173
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
177
|
+
"/fixed-landing",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
110
182
|
test("mismatch → client-side error, kein fetch-Call", async () => {
|
|
111
183
|
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
112
184
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
@@ -21,10 +21,10 @@ export function retryAfterMinutes(retryAfterSeconds?: number): number | undefine
|
|
|
21
21
|
return Math.ceil(retryAfterSeconds / 60);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Resolves post-login redirect: string template or function of
|
|
25
|
-
export function resolveLoggedInHref(
|
|
26
|
-
href: string | ((args:
|
|
27
|
-
|
|
24
|
+
/** Resolves post-login redirect: string template or function of the screen's args. */
|
|
25
|
+
export function resolveLoggedInHref<Args>(
|
|
26
|
+
href: string | ((args: Args) => string),
|
|
27
|
+
args: Args,
|
|
28
28
|
): string {
|
|
29
|
-
return typeof href === "function" ? href(
|
|
29
|
+
return typeof href === "function" ? href(args) : href;
|
|
30
30
|
}
|
|
@@ -19,21 +19,40 @@
|
|
|
19
19
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import { type FormEvent, type ReactNode, useContext, useState } from "react";
|
|
21
21
|
import { csrfHeader } from "./auth-client";
|
|
22
|
+
import { resolveLoggedInHref } from "./auth-form-logic";
|
|
22
23
|
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
23
24
|
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
24
25
|
|
|
25
26
|
export type InviteAcceptScreenProps = {
|
|
26
27
|
readonly title?: string;
|
|
27
28
|
readonly token?: string;
|
|
28
|
-
/** Where to redirect on success. Default "/" —
|
|
29
|
-
*
|
|
30
|
-
|
|
29
|
+
/** Where to redirect on success. Default "/" — multi-tenant apps can pass
|
|
30
|
+
* `(data) => "/${data.tenantId}/"`. Function-form receives the roles this
|
|
31
|
+
* flow grants in the target tenant; branch 1 reports the invitation's
|
|
32
|
+
* role, which for an existing member is not their full role set there. */
|
|
33
|
+
readonly loggedInHref?:
|
|
34
|
+
| string
|
|
35
|
+
| ((args: { tenantId: string; roles: readonly string[] }) => string);
|
|
31
36
|
/** Login-Href für "Mit anderem Account anmelden". Default "/login". */
|
|
32
37
|
readonly loginHref?: string;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
type Mode = "loggedin" | "anon-existing" | "anon-new";
|
|
36
41
|
|
|
42
|
+
// All three accept routes answer 200 with tenantId + the invitation role;
|
|
43
|
+
// the two session-minting branches additionally carry the stripped session
|
|
44
|
+
// roles. An MFA challenge is also a 200 and carries none of them.
|
|
45
|
+
type InviteAcceptResponse = {
|
|
46
|
+
readonly tenantId: string;
|
|
47
|
+
readonly role?: string;
|
|
48
|
+
readonly user?: { readonly roles?: readonly string[] };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function grantedRoles(data: InviteAcceptResponse): readonly string[] {
|
|
52
|
+
if (data.user?.roles !== undefined) return data.user.roles;
|
|
53
|
+
return data.role === undefined ? [] : [data.role];
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
export function InviteAcceptScreen({
|
|
38
57
|
title,
|
|
39
58
|
token: tokenProp,
|
|
@@ -70,12 +89,14 @@ export function InviteAcceptScreen({
|
|
|
70
89
|
});
|
|
71
90
|
setSubmitting(false);
|
|
72
91
|
if (res.ok) {
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
:
|
|
78
|
-
|
|
92
|
+
// @cast-boundary engine-payload — auth route JSON
|
|
93
|
+
const data = (await res.json()) as InviteAcceptResponse;
|
|
94
|
+
window.location.assign(
|
|
95
|
+
resolveLoggedInHref(loggedInHref, {
|
|
96
|
+
tenantId: data.tenantId,
|
|
97
|
+
roles: grantedRoles(data),
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
79
100
|
return;
|
|
80
101
|
}
|
|
81
102
|
setError(t("auth.errors.invalidInviteToken"));
|
|
@@ -93,12 +114,14 @@ export function InviteAcceptScreen({
|
|
|
93
114
|
});
|
|
94
115
|
setSubmitting(false);
|
|
95
116
|
if (res.ok) {
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
:
|
|
101
|
-
|
|
117
|
+
// @cast-boundary engine-payload — auth route JSON
|
|
118
|
+
const data = (await res.json()) as InviteAcceptResponse;
|
|
119
|
+
window.location.assign(
|
|
120
|
+
resolveLoggedInHref(loggedInHref, {
|
|
121
|
+
tenantId: data.tenantId,
|
|
122
|
+
roles: grantedRoles(data),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
102
125
|
return;
|
|
103
126
|
}
|
|
104
127
|
setError(t("auth.errors.invalidInviteToken"));
|
|
@@ -116,12 +139,14 @@ export function InviteAcceptScreen({
|
|
|
116
139
|
});
|
|
117
140
|
setSubmitting(false);
|
|
118
141
|
if (res.ok) {
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
:
|
|
124
|
-
|
|
142
|
+
// @cast-boundary engine-payload — auth route JSON
|
|
143
|
+
const data = (await res.json()) as InviteAcceptResponse;
|
|
144
|
+
window.location.assign(
|
|
145
|
+
resolveLoggedInHref(loggedInHref, {
|
|
146
|
+
tenantId: data.tenantId,
|
|
147
|
+
roles: grantedRoles(data),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
125
150
|
return;
|
|
126
151
|
}
|
|
127
152
|
setError(t("auth.errors.invalidInviteToken"));
|
|
@@ -25,8 +25,11 @@ export type SignupCompleteScreenProps = {
|
|
|
25
25
|
readonly title?: string;
|
|
26
26
|
/** Override for the URL token — server-rendered apps can pass it. Default: `?token=...`. */
|
|
27
27
|
readonly token?: string;
|
|
28
|
-
/** Where to send the user after activation. Function-form receives the tenantKey
|
|
29
|
-
|
|
28
|
+
/** Where to send the user after activation. Function-form receives the tenantKey and the
|
|
29
|
+
* roles granted by this flow in that tenant — for role-dependent landing targets. Default "/". */
|
|
30
|
+
readonly loggedInHref?:
|
|
31
|
+
| string
|
|
32
|
+
| ((args: { tenantKey: string; roles: readonly string[] }) => string);
|
|
30
33
|
/** Href for the "already have an account?" link when the token is missing. Default "/login". */
|
|
31
34
|
readonly loginHref?: string;
|
|
32
35
|
};
|
|
@@ -64,7 +67,12 @@ export function SignupCompleteScreen({
|
|
|
64
67
|
// Cookies are already set (auto-login). Show a confirmation with an
|
|
65
68
|
// explicit continue button instead of navigating away silently —
|
|
66
69
|
// the user otherwise gets no signal that activation worked.
|
|
67
|
-
setContinueHref(
|
|
70
|
+
setContinueHref(
|
|
71
|
+
resolveLoggedInHref(loggedInHref, {
|
|
72
|
+
tenantKey: res.data.tenantKey,
|
|
73
|
+
roles: res.data.user.roles,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
68
76
|
return;
|
|
69
77
|
}
|
|
70
78
|
if (res.error.reason === "invalid_signup_token") {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -64,8 +64,13 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
64
64
|
// apply-key is a registered domain-event (DELIVERY_ATTEMPT_EVENT).
|
|
65
65
|
// recipientAddress is the real PII (email address); recipientId stays
|
|
66
66
|
// plaintext — pseudonymous fk, same line as config.userId (#821).
|
|
67
|
+
// recipientId is nullable (system-triggered sends to non-users), so the
|
|
68
|
+
// address falls back to the tenant key instead of the plaintext it used
|
|
69
|
+
// to be — still shreddable, one tenant wide (fw#2776).
|
|
67
70
|
r.defineEvent("attempt", deliveryAttemptSchema, {
|
|
68
|
-
piiFields: {
|
|
71
|
+
piiFields: {
|
|
72
|
+
recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } },
|
|
73
|
+
},
|
|
69
74
|
});
|
|
70
75
|
|
|
71
76
|
// Inline projection that materialises every delivery attempt into
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Tenant-visible job failures (fw#3079) end to end: a write triggers a job,
|
|
2
|
+
// the job fails, and the triggering tenant reads the failure back through
|
|
3
|
+
// `jobs:query:failures` — over real HTTP, a real BullMQ worker and a real
|
|
4
|
+
// Postgres, with the real createJobRunLogger callbacks wired in.
|
|
5
|
+
//
|
|
6
|
+
// Not setupTestStack: that helper builds a JobRunner but wires none of the
|
|
7
|
+
// bundled run-logger callbacks (test-stack.ts), which are the write path
|
|
8
|
+
// under test here. Same buildServer + createJobRunner + createJobRunLogger
|
|
9
|
+
// harness the neighbouring jobs integration tests use.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
13
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import {
|
|
16
|
+
createRegistry,
|
|
17
|
+
defineFeature,
|
|
18
|
+
defineWriteHandler,
|
|
19
|
+
type SessionUser,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
22
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
23
|
+
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
24
|
+
import {
|
|
25
|
+
createTestDb,
|
|
26
|
+
createTestRedis,
|
|
27
|
+
createTestUser,
|
|
28
|
+
type TestDb,
|
|
29
|
+
type TestRedis,
|
|
30
|
+
testTenantId,
|
|
31
|
+
unsafePushTables,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
33
|
+
import { sleep } from "@cosmicdrift/kumiko-framework/testing";
|
|
34
|
+
import type { Hono } from "hono";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { JobQueries } from "../constants";
|
|
37
|
+
import { createJobsFeature } from "../feature";
|
|
38
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
39
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
40
|
+
import { tenantJobFailuresTable } from "../tenant-job-failure-table";
|
|
41
|
+
|
|
42
|
+
const JWT_SECRET = "tenant-job-failures-integration-secret-key-0123456789";
|
|
43
|
+
const DECLARED_KEY = "app:errors.generationFailed";
|
|
44
|
+
const BUDGET_KEY = "app:errors.budgetExceeded";
|
|
45
|
+
// A provider message that must never reach the tenant.
|
|
46
|
+
const PROVIDER_MESSAGE = "openai 429: prompt 'Herr Schmidt, Kennzeichen B-XY-123' rejected";
|
|
47
|
+
|
|
48
|
+
const tenantA = testTenantId(1);
|
|
49
|
+
const tenantB = testTenantId(2);
|
|
50
|
+
const userA = createTestUser({ id: 1, tenantId: tenantA, roles: ["Admin"] });
|
|
51
|
+
const userB = createTestUser({ id: 2, tenantId: tenantB, roles: ["Admin"] });
|
|
52
|
+
const systemAdmin = createTestUser({ id: 3, tenantId: tenantA, roles: ["SystemAdmin"] });
|
|
53
|
+
|
|
54
|
+
const generateWrite = defineWriteHandler({
|
|
55
|
+
name: "generate",
|
|
56
|
+
description: "Test-only: starts the text generation for one campaign.",
|
|
57
|
+
schema: z.object({ campaignId: z.string(), mode: z.enum(["fail", "budget", "succeed"]) }),
|
|
58
|
+
access: { roles: ["Admin"] },
|
|
59
|
+
handler: async (event) => ({ isSuccess: true as const, data: { ...event.payload } }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const retryWrite = defineWriteHandler({
|
|
63
|
+
name: "startFlaky",
|
|
64
|
+
description: "Test-only: starts a job that fails on every attempt.",
|
|
65
|
+
schema: z.object({}),
|
|
66
|
+
access: { roles: ["Admin"] },
|
|
67
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const appFeature = defineFeature("app", (r) => {
|
|
71
|
+
r.writeHandler(generateWrite);
|
|
72
|
+
r.writeHandler(retryWrite);
|
|
73
|
+
|
|
74
|
+
r.job(
|
|
75
|
+
"generateTexts",
|
|
76
|
+
{
|
|
77
|
+
trigger: { on: "app:write:generate" },
|
|
78
|
+
tenantVisibleFailure: { messageKey: DECLARED_KEY, subjectFields: ["campaignId"] },
|
|
79
|
+
},
|
|
80
|
+
async (payload) => {
|
|
81
|
+
if (payload["mode"] === "budget") {
|
|
82
|
+
throw new UnprocessableError("budget_exceeded", { i18nKey: BUDGET_KEY });
|
|
83
|
+
}
|
|
84
|
+
if (payload["mode"] === "fail") throw new Error(PROVIDER_MESSAGE);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// retries: 1 — two attempts, both failing. Only the last one may record.
|
|
89
|
+
r.job(
|
|
90
|
+
"flaky",
|
|
91
|
+
{
|
|
92
|
+
trigger: { on: "app:write:start-flaky" },
|
|
93
|
+
retries: 1,
|
|
94
|
+
tenantVisibleFailure: { messageKey: DECLARED_KEY },
|
|
95
|
+
},
|
|
96
|
+
async () => {
|
|
97
|
+
throw new Error(PROVIDER_MESSAGE);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let testDb: TestDb;
|
|
103
|
+
let testRedis: TestRedis;
|
|
104
|
+
let db: DbConnection;
|
|
105
|
+
let app: Hono;
|
|
106
|
+
let jwt: JwtHelper;
|
|
107
|
+
let jobRunner: JobRunner;
|
|
108
|
+
|
|
109
|
+
beforeAll(async () => {
|
|
110
|
+
testDb = await createTestDb();
|
|
111
|
+
testRedis = await createTestRedis();
|
|
112
|
+
db = testDb.db;
|
|
113
|
+
|
|
114
|
+
const registry = createRegistry([appFeature, createJobsFeature()]);
|
|
115
|
+
await unsafePushTables(db, { jobRunsTable, jobRunLogsTable, tenantJobFailuresTable });
|
|
116
|
+
await createEventsTable(db);
|
|
117
|
+
|
|
118
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
119
|
+
jobRunner = createJobRunner({
|
|
120
|
+
registry,
|
|
121
|
+
context: { db },
|
|
122
|
+
redisUrl,
|
|
123
|
+
consumerLane: "worker",
|
|
124
|
+
queueNamePrefix: `kumiko-tenant-job-failures-${Date.now()}`,
|
|
125
|
+
...createJobRunLogger({ db, registry }),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const server = buildServer({
|
|
129
|
+
registry,
|
|
130
|
+
context: { db, registry, jobRunner },
|
|
131
|
+
jwtSecret: JWT_SECRET,
|
|
132
|
+
// Event-triggered jobs enqueue from dispatch-write's afterCommit hooks,
|
|
133
|
+
// which read the runner off the dispatcher — not off the AppContext.
|
|
134
|
+
dispatcherOptions: { jobRunner },
|
|
135
|
+
});
|
|
136
|
+
app = server.app;
|
|
137
|
+
jwt = server.jwt;
|
|
138
|
+
|
|
139
|
+
await jobRunner.start();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterAll(async () => {
|
|
143
|
+
await jobRunner.stop();
|
|
144
|
+
await testDb.cleanup();
|
|
145
|
+
await testRedis.cleanup();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
async function post(path: string, user: SessionUser, body: unknown): Promise<Response> {
|
|
149
|
+
const token = await jwt.sign(user);
|
|
150
|
+
return app.request(path, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
153
|
+
body: JSON.stringify(body),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type FailureRow = {
|
|
158
|
+
readonly jobName: string;
|
|
159
|
+
readonly subject: Record<string, unknown> | null;
|
|
160
|
+
readonly messageKey: string;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
async function generate(
|
|
164
|
+
user: SessionUser,
|
|
165
|
+
campaignId: string,
|
|
166
|
+
mode: "fail" | "budget" | "succeed",
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const res = await post("/api/write", user, {
|
|
169
|
+
type: "app:write:generate",
|
|
170
|
+
payload: { campaignId, mode },
|
|
171
|
+
});
|
|
172
|
+
expect((await res.json()).isSuccess).toBe(true);
|
|
173
|
+
await sleep(1500);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function failures(user: SessionUser, payload: unknown = {}): Promise<FailureRow[]> {
|
|
177
|
+
const res = await post("/api/query", user, { type: JobQueries.failures, payload });
|
|
178
|
+
const body = await res.json();
|
|
179
|
+
expect(res.status, JSON.stringify(body)).toBe(200);
|
|
180
|
+
return body.data.rows;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe("jobs:query:failures (fw#3079)", () => {
|
|
184
|
+
test("the triggering tenant reads its own failed job, scoped by subject", async () => {
|
|
185
|
+
await generate(userA, "campaign-1", "fail");
|
|
186
|
+
await generate(userA, "campaign-2", "budget");
|
|
187
|
+
|
|
188
|
+
const rows = await failures(userA);
|
|
189
|
+
expect(rows).toHaveLength(2);
|
|
190
|
+
const byCampaign = new Map(rows.map((row) => [row.subject?.["campaignId"], row]));
|
|
191
|
+
expect(byCampaign.get("campaign-1")?.jobName).toBe("app:job:generate-texts");
|
|
192
|
+
// Plain Error → the key declared at the job.
|
|
193
|
+
expect(byCampaign.get("campaign-1")?.messageKey).toBe(DECLARED_KEY);
|
|
194
|
+
// KumikoError → its own i18nKey wins over the declared fallback.
|
|
195
|
+
expect(byCampaign.get("campaign-2")?.messageKey).toBe(BUDGET_KEY);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("another tenant sees none of them, and only its own", async () => {
|
|
199
|
+
expect(await failures(userB)).toHaveLength(0);
|
|
200
|
+
|
|
201
|
+
await generate(userB, "campaign-1", "fail");
|
|
202
|
+
|
|
203
|
+
const rowsB = await failures(userB);
|
|
204
|
+
expect(rowsB).toHaveLength(1);
|
|
205
|
+
expect(rowsB[0]?.subject?.["campaignId"]).toBe("campaign-1");
|
|
206
|
+
// Tenant A's two records are untouched by B's own run of the same job
|
|
207
|
+
// and the same campaign id.
|
|
208
|
+
expect(await failures(userA)).toHaveLength(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("no provider message reaches the tenant", async () => {
|
|
212
|
+
const res = await post("/api/query", userA, { type: JobQueries.failures, payload: {} });
|
|
213
|
+
const raw = await res.text();
|
|
214
|
+
expect(raw).toContain(DECLARED_KEY);
|
|
215
|
+
expect(raw).not.toContain(PROVIDER_MESSAGE);
|
|
216
|
+
expect(raw).not.toContain("openai");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a later successful run of the same subject clears the record", async () => {
|
|
220
|
+
await generate(userA, "campaign-1", "succeed");
|
|
221
|
+
|
|
222
|
+
const rows = await failures(userA);
|
|
223
|
+
expect(rows.map((row) => row.subject?.["campaignId"])).toEqual(["campaign-2"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("only the final attempt records — a retried job leaves one record", async () => {
|
|
227
|
+
const res = await post("/api/write", userB, { type: "app:write:start-flaky", payload: {} });
|
|
228
|
+
expect((await res.json()).isSuccess).toBe(true);
|
|
229
|
+
await sleep(2500);
|
|
230
|
+
|
|
231
|
+
const rows = await failures(userB, { jobName: "app:job:flaky" });
|
|
232
|
+
expect(rows).toHaveLength(1);
|
|
233
|
+
expect(rows[0]?.subject).toBeNull();
|
|
234
|
+
|
|
235
|
+
// Both attempts did land as their own failed run — the single record
|
|
236
|
+
// above is the final-attempt gate, not a missing second attempt.
|
|
237
|
+
const runs = await selectMany(db, jobRunsTable, { jobName: "app:job:flaky" });
|
|
238
|
+
expect(runs).toHaveLength(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("the subject filter selects one record", async () => {
|
|
242
|
+
const rows = await failures(userA, { subject: { campaignId: "campaign-2" } });
|
|
243
|
+
expect(rows).toHaveLength(1);
|
|
244
|
+
expect(rows[0]?.messageKey).toBe(BUDGET_KEY);
|
|
245
|
+
|
|
246
|
+
expect(await failures(userA, { subject: { campaignId: "campaign-unknown" } })).toHaveLength(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("SystemAdmin still sees every tenant's run with its provider message", async () => {
|
|
250
|
+
const res = await post("/api/query", systemAdmin, {
|
|
251
|
+
type: JobQueries.list,
|
|
252
|
+
payload: { jobName: "app:job:generate-texts", status: "failed" },
|
|
253
|
+
});
|
|
254
|
+
const raw = await res.text();
|
|
255
|
+
expect(res.status, raw).toBe(200);
|
|
256
|
+
|
|
257
|
+
// Both of tenant A's failures plus tenant B's — the tenant-visible record
|
|
258
|
+
// is an addition, it takes nothing away from the SystemAdmin view.
|
|
259
|
+
expect(JSON.parse(raw).data.rows).toHaveLength(3);
|
|
260
|
+
expect(raw).toContain(PROVIDER_MESSAGE);
|
|
261
|
+
});
|
|
262
|
+
test("a corrupt stored subject degrades to null instead of failing the list", async () => {
|
|
263
|
+
await insertOne(db, tenantJobFailuresTable, {
|
|
264
|
+
tenantId: tenantB,
|
|
265
|
+
jobName: "app:job:corrupt",
|
|
266
|
+
subject: '{"campaignId":"not-an-entry-array"}',
|
|
267
|
+
messageKey: DECLARED_KEY,
|
|
268
|
+
failedAt: Temporal.Now.instant(),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const rows = await failures(userB, { jobName: "app:job:corrupt" });
|
|
272
|
+
expect(rows).toHaveLength(1);
|
|
273
|
+
expect(rows[0]?.subject).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
});
|
package/src/jobs/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Tenant-visible job failures: `r.job({ tenantVisibleFailure })` plus `jobs:query:failures` (fw#3079)",
|
|
6
|
+
"detail": "A fire-and-forget job that fails left the tenant's screen on a spinner that never ends — `jobs:query:list` is SystemAdmin and reads cross-tenant over `systemDb.unsafeRaw`, so a tenant could not see its own job failing. Apps worked around it with their own failure entity written in the job's catch.\nA job now opts in declaratively: `r.job(\"generateTexts\", { trigger: …, tenantVisibleFailure: { messageKey: \"app:errors.generationFailed\", subjectFields: [\"campaignId\"] } }, handler)`. When its last attempt fails, the run-logger records one row per tenant, job and subject in the new `store_tenant_job_failures` table, and the tenant reads it back through `jobs:query:failures` (every membership rank, own tenant only).\nOnly a translation key travels to the tenant: the thrown error's own `i18nKey` when it carries one, otherwise the declared `messageKey`. The provider's message stays on `store_job_runs.error` and in the run log, both SystemAdmin-only. Records are scoped to the run's tenant — a tenant-less run (cron, `SYSTEM_TENANT_ID`) records nothing.\nLifetime and retries: a record lives until the next successful run of the same job and subject deletes it; there is no acknowledgement step (tenant job administration stays out of scope). Only the final attempt records, so a job with `retries` that succeeds on a later attempt never shows the tenant a failure. The daily `retention-cleanup` job purges leftovers past `retentionDays`.\n`jobs:query:list`, `jobs:query:details` and `jobs:query:retry` are unchanged. `JobRunnerOptions.onJobComplete`/`onJobFailed` gained an optional fifth `outcome` argument — existing four-argument callbacks keep working.",
|
|
7
|
+
"migration": "New store table. Run `kumiko migrate generate` and apply the migration — `store_tenant_job_failures` is created empty and stays empty until a job declares `tenantVisibleFailure`. No change needed for apps that do not opt in."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.241.0",
|
|
4
11
|
"type": "breaking",
|
package/src/jobs/constants.ts
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
14
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
15
|
import { jobRunLogsTable, jobRunsTable } from "../../job-run-table";
|
|
16
|
+
import { tenantJobFailuresTable } from "../../tenant-job-failure-table";
|
|
16
17
|
|
|
17
18
|
const RETENTION_DELETE_BATCH_SIZE = 500;
|
|
18
19
|
|
|
19
20
|
export type JobRunRetentionResult = {
|
|
20
21
|
readonly runsDeleted: number;
|
|
21
22
|
readonly logsDeleted: number;
|
|
23
|
+
readonly tenantFailuresDeleted: number;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
export async function deleteStaleJobRuns(
|
|
@@ -40,5 +42,19 @@ export async function deleteStaleJobRuns(
|
|
|
40
42
|
{ limit: RETENTION_DELETE_BATCH_SIZE },
|
|
41
43
|
);
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
// Same window for the tenant-visible failure records (fw#3079): the run
|
|
46
|
+
// they point at is gone by now, and a job whose subject never ran again
|
|
47
|
+
// would otherwise keep its record forever.
|
|
48
|
+
const tenantFailuresResult = await deleteManyBatched(
|
|
49
|
+
db,
|
|
50
|
+
tenantJobFailuresTable,
|
|
51
|
+
{ failedAt: { lt: cutoff } },
|
|
52
|
+
{ limit: RETENTION_DELETE_BATCH_SIZE },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
runsDeleted: runsResult.deleted,
|
|
57
|
+
logsDeleted: logsResult.deleted,
|
|
58
|
+
tenantFailuresDeleted: tenantFailuresResult.deleted,
|
|
59
|
+
};
|
|
44
60
|
}
|
package/src/jobs/feature.ts
CHANGED
|
@@ -21,9 +21,11 @@ import {
|
|
|
21
21
|
createStaleRunSweepJob,
|
|
22
22
|
DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
|
|
23
23
|
} from "./handlers/stale-run-sweep.job";
|
|
24
|
+
import { tenantFailuresQuery } from "./handlers/tenant-failures.query";
|
|
24
25
|
import { triggerWrite } from "./handlers/trigger.write";
|
|
25
26
|
import { JOBS_I18N } from "./i18n";
|
|
26
27
|
import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table";
|
|
28
|
+
import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table";
|
|
27
29
|
|
|
28
30
|
export type JobsFeatureOptions = {
|
|
29
31
|
// How long a job run (and its logs) stays in store_job_runs/
|
|
@@ -42,7 +44,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
42
44
|
const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
|
|
43
45
|
return defineFeature("jobs", (r) => {
|
|
44
46
|
r.describe(
|
|
45
|
-
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI.",
|
|
47
|
+
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079).",
|
|
46
48
|
);
|
|
47
49
|
r.uiHints({
|
|
48
50
|
displayLabel: "Jobs · Audit & Operator UI",
|
|
@@ -61,6 +63,9 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
61
63
|
r.storeTable(jobRunLogsTableMeta, {
|
|
62
64
|
reason: "read_side.job_run_logs",
|
|
63
65
|
});
|
|
66
|
+
r.storeTable(tenantJobFailuresTableMeta, {
|
|
67
|
+
reason: "direct_write.tenant_job_failures",
|
|
68
|
+
});
|
|
64
69
|
|
|
65
70
|
// Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
|
|
66
71
|
r.job(
|
|
@@ -112,6 +117,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
112
117
|
list: r.queryHandler(listQuery),
|
|
113
118
|
detail: r.queryHandler(detailQuery),
|
|
114
119
|
catalog: r.queryHandler(catalogQuery),
|
|
120
|
+
failures: r.queryHandler(tenantFailuresQuery),
|
|
115
121
|
};
|
|
116
122
|
|
|
117
123
|
const systemAdminAccess = { roles: ["SystemAdmin"] as const };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { tenantJobFailuresTable } from "../tenant-job-failure-table";
|
|
7
|
+
|
|
8
|
+
type TenantJobFailureRow = {
|
|
9
|
+
readonly tenantId: string;
|
|
10
|
+
readonly jobName: string;
|
|
11
|
+
readonly subject: string | null;
|
|
12
|
+
readonly messageKey: string;
|
|
13
|
+
readonly failedAt: Temporal.Instant;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LIMIT = 50;
|
|
17
|
+
|
|
18
|
+
// Mirrors job-runner.ts's jobSubjectKey: sorted field names, so a caller that
|
|
19
|
+
// passes the same subject values gets the same string the writer stored.
|
|
20
|
+
function subjectKey(subject: Record<string, string | number | boolean | null>): string {
|
|
21
|
+
return JSON.stringify(
|
|
22
|
+
Object.keys(subject)
|
|
23
|
+
.sort()
|
|
24
|
+
.map((field) => [field, subject[field] ?? null]),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isSubjectEntry(value: unknown): value is [string, unknown] {
|
|
29
|
+
return Array.isArray(value) && typeof value[0] === "string";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
33
|
+
if (stored === null) return null;
|
|
34
|
+
// A corrupt subject must not fail the whole list — the record still tells
|
|
35
|
+
// the tenant which job failed and why. parseJsonSafe only survives a
|
|
36
|
+
// SyntaxError, so the shape needs its own check.
|
|
37
|
+
const parsed = parseJsonSafe<unknown>(stored, null);
|
|
38
|
+
if (!Array.isArray(parsed)) return null;
|
|
39
|
+
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const tenantFailuresQuery = defineQueryHandler({
|
|
43
|
+
name: "failures",
|
|
44
|
+
description:
|
|
45
|
+
"Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
|
|
46
|
+
schema: z.object({
|
|
47
|
+
jobName: z.string().optional(),
|
|
48
|
+
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
49
|
+
limit: z.number().min(1).max(200).optional(),
|
|
50
|
+
}),
|
|
51
|
+
// Every membership rank: a failure record carries a job name and a
|
|
52
|
+
// translation key, nothing a team member of the tenant may not see.
|
|
53
|
+
access: { roles: access.roles("User", "Editor", ...access.admin) },
|
|
54
|
+
handler: async (query, ctx) => {
|
|
55
|
+
if (!ctx.systemDb) {
|
|
56
|
+
throw new InternalError({
|
|
57
|
+
message: "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// assertTenantMatch is a self-check on the caller and returns the same
|
|
61
|
+
// unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
|
|
62
|
+
// is the filter, assertRowsTenant the second net. There is no
|
|
63
|
+
// cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
|
|
64
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
65
|
+
const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
|
|
66
|
+
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
67
|
+
if (query.payload.subject) where["subject"] = subjectKey(query.payload.subject);
|
|
68
|
+
const rows = await selectMany<TenantJobFailureRow>(db, tenantJobFailuresTable, where, {
|
|
69
|
+
orderBy: { col: "failedAt", direction: "desc" },
|
|
70
|
+
limit: query.payload.limit ?? DEFAULT_LIMIT,
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
|
|
74
|
+
jobName: row.jobName,
|
|
75
|
+
subject: parseSubject(row.subject),
|
|
76
|
+
messageKey: row.messageKey,
|
|
77
|
+
failedAt: row.failedAt,
|
|
78
|
+
})),
|
|
79
|
+
nextCursor: null,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
});
|
package/src/jobs/index.ts
CHANGED
|
@@ -4,3 +4,4 @@ export type { JobRunLoggerCallbacks } from "./job-run-logger";
|
|
|
4
4
|
export { createJobRunLogger } from "./job-run-logger";
|
|
5
5
|
export type { JobLogLevel, JobRunStatus } from "./job-run-table";
|
|
6
6
|
export { jobRunLogsTable, jobRunsTable } from "./job-run-table";
|
|
7
|
+
export { tenantJobFailuresTable } from "./tenant-job-failure-table";
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
deleteMany,
|
|
3
|
+
fetchOne,
|
|
4
|
+
insertMany,
|
|
5
|
+
insertOne,
|
|
6
|
+
updateMany,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
8
|
import {
|
|
3
9
|
configuredPiiSubjectKms,
|
|
4
10
|
encryptPiiValueForSubject,
|
|
@@ -8,12 +14,18 @@ import {
|
|
|
8
14
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
15
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
16
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
JobLogEntry,
|
|
19
|
+
JobMeta,
|
|
20
|
+
JobOutcomeMeta,
|
|
21
|
+
JobRunnerOptions,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/jobs";
|
|
12
23
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
13
24
|
import { mapWithConcurrency } from "../shared";
|
|
14
25
|
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
|
|
15
26
|
import { parseJobInstant } from "./job-instant";
|
|
16
27
|
import { jobRunLogsTable, jobRunsTable } from "./job-run-table";
|
|
28
|
+
import { tenantJobFailuresTable } from "./tenant-job-failure-table";
|
|
17
29
|
|
|
18
30
|
// Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
|
|
19
31
|
// bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
|
|
@@ -138,6 +150,54 @@ async function encryptStartedPayload(
|
|
|
138
150
|
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
154
|
+
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
155
|
+
// clears it. `subject` is null for a job that declares no subjectFields.
|
|
156
|
+
// Null target = nothing to write or clear: the job did not opt in, the run
|
|
157
|
+
// was tenant-less (cron resolves to SYSTEM_TENANT_ID, where no tenant-scoped
|
|
158
|
+
// query could ever read the row), or the caller predates fw#3079 and passes
|
|
159
|
+
// no outcome at all.
|
|
160
|
+
function tenantJobFailureTarget(
|
|
161
|
+
jobName: string,
|
|
162
|
+
outcome: JobOutcomeMeta | undefined,
|
|
163
|
+
): Record<string, unknown> | null {
|
|
164
|
+
if (!outcome?.tenantVisible || outcome.tenantId === SYSTEM_TENANT_ID) return null;
|
|
165
|
+
return { tenantId: outcome.tenantId, jobName, subject: outcome.tenantVisible.subject };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function recordTenantJobFailure(
|
|
169
|
+
db: DbConnection,
|
|
170
|
+
jobName: string,
|
|
171
|
+
outcome: JobOutcomeMeta | undefined,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const where = tenantJobFailureTarget(jobName, outcome);
|
|
174
|
+
const messageKey = outcome?.tenantVisible?.messageKey;
|
|
175
|
+
// skip: no tenant-visible target, or an attempt BullMQ may still retry — a
|
|
176
|
+
// non-final failure must not show the tenant a failure the next attempt
|
|
177
|
+
// may still resolve.
|
|
178
|
+
if (!where || !messageKey || outcome?.finalAttempt !== true) return;
|
|
179
|
+
// ponytail: delete-then-insert instead of an upsert — two runs of the same
|
|
180
|
+
// key finishing at once can leave two rows, and the query returns the
|
|
181
|
+
// newest. Add a unique index + ON CONFLICT if that ever matters.
|
|
182
|
+
await deleteMany(db, tenantJobFailuresTable, where);
|
|
183
|
+
await insertOne(db, tenantJobFailuresTable, {
|
|
184
|
+
...where,
|
|
185
|
+
messageKey,
|
|
186
|
+
failedAt: Temporal.Now.instant(),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function clearTenantJobFailure(
|
|
191
|
+
db: DbConnection,
|
|
192
|
+
jobName: string,
|
|
193
|
+
outcome: JobOutcomeMeta | undefined,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const where = tenantJobFailureTarget(jobName, outcome);
|
|
196
|
+
// skip: no tenant-visible target — nothing was ever recorded
|
|
197
|
+
if (!where) return;
|
|
198
|
+
await deleteMany(db, tenantJobFailuresTable, where);
|
|
199
|
+
}
|
|
200
|
+
|
|
141
201
|
export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks {
|
|
142
202
|
const { db } = opts;
|
|
143
203
|
|
|
@@ -241,11 +301,16 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
241
301
|
},
|
|
242
302
|
|
|
243
303
|
onJobComplete: async (
|
|
244
|
-
|
|
304
|
+
jobName: string,
|
|
245
305
|
bullJobId: string,
|
|
246
306
|
duration: number,
|
|
247
307
|
logs: JobLogEntry[],
|
|
308
|
+
outcome?: JobOutcomeMeta,
|
|
248
309
|
) => {
|
|
310
|
+
// Before the run-row write and independent of it: a successful run
|
|
311
|
+
// clears the tenant's failure record even when the run row itself is
|
|
312
|
+
// unreachable (the state-loss return below).
|
|
313
|
+
await clearTenantJobFailure(db, jobName, outcome);
|
|
249
314
|
const resolved = await resolveRun(bullJobId);
|
|
250
315
|
// skip: state loss between start + complete (worker restart, cache
|
|
251
316
|
// evicted AND DB has no matching bull_job_id). Rare edge case; we
|
|
@@ -297,11 +362,15 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
297
362
|
},
|
|
298
363
|
|
|
299
364
|
onJobFailed: async (
|
|
300
|
-
|
|
365
|
+
jobName: string,
|
|
301
366
|
bullJobId: string,
|
|
302
367
|
error: string,
|
|
303
368
|
logs: JobLogEntry[],
|
|
369
|
+
outcome?: JobOutcomeMeta,
|
|
304
370
|
) => {
|
|
371
|
+
// Mirror of onJobComplete: recorded independently of the run row, so a
|
|
372
|
+
// tenant still learns their job failed if the row is unreachable.
|
|
373
|
+
await recordTenantJobFailure(db, jobName, outcome);
|
|
305
374
|
const resolved = await resolveRun(bullJobId);
|
|
306
375
|
// skip: same rare state-loss case as in onJobComplete — drop the
|
|
307
376
|
// failure write rather than forge a run row from scratch.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
type EntityTableMeta,
|
|
4
|
+
instant,
|
|
5
|
+
table as pgTable,
|
|
6
|
+
serial,
|
|
7
|
+
sql,
|
|
8
|
+
text,
|
|
9
|
+
uuid,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
|
|
12
|
+
// The tenant's own view of a failed job (fw#3079). Direct-write store like
|
|
13
|
+
// store_job_runs: job-run-logger.ts writes it from the BullMQ callbacks,
|
|
14
|
+
// outside any dispatcher transaction.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately holds no error text. `message_key` is a translation key
|
|
17
|
+
// (the thrown KumikoError's i18nKey or the one declared at the job), so a
|
|
18
|
+
// provider message can never reach the tenant through this table — it stays
|
|
19
|
+
// on store_job_runs.error and in store_job_run_logs, both SystemAdmin-only.
|
|
20
|
+
//
|
|
21
|
+
// One row per (tenant, job, subject): a later final-attempt failure replaces
|
|
22
|
+
// it, a later successful run of the same key deletes it. `subject` is the
|
|
23
|
+
// canonical JSON of the job's declared subjectFields, or NULL for a job that
|
|
24
|
+
// declares none — stored in clear, so a job must not declare a PII field
|
|
25
|
+
// as its subject.
|
|
26
|
+
export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
|
|
27
|
+
id: serial("id").primaryKey(),
|
|
28
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
29
|
+
jobName: text("job_name").notNull(),
|
|
30
|
+
subject: text("subject"),
|
|
31
|
+
messageKey: text("message_key").notNull(),
|
|
32
|
+
failedAt: instant("failed_at").default(sql`now()`).notNull(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const derivedTenantJobFailuresTableMeta = asEntityTableMeta(tenantJobFailuresTable);
|
|
36
|
+
if (!derivedTenantJobFailuresTableMeta) {
|
|
37
|
+
throw new Error("tenantJobFailuresTable: table carries no EntityTableMeta — built via table()?");
|
|
38
|
+
}
|
|
39
|
+
export const tenantJobFailuresTableMeta: EntityTableMeta = derivedTenantJobFailuresTableMeta;
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
TestUsers,
|
|
14
|
+
testTenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
16
|
import { z } from "zod";
|
|
12
17
|
import { createRateLimitingFeature } from "../feature";
|
|
13
18
|
|
|
@@ -93,3 +98,66 @@ describe("rate-limiting feature — status query", () => {
|
|
|
93
98
|
expect(res.status).toBe(403);
|
|
94
99
|
});
|
|
95
100
|
});
|
|
101
|
+
|
|
102
|
+
// The bucket key is caller-supplied, so the tenant boundary has to be drawn
|
|
103
|
+
// on it server-side. i18nKey is asserted alongside the code because a plain
|
|
104
|
+
// 403 could also come from tenant resolution and would pass either way.
|
|
105
|
+
const OUTSIDE_TENANT_KEY = "rateLimiting.errors.bucketOutsideTenant";
|
|
106
|
+
|
|
107
|
+
describe("rate-limiting feature — bucket tenant scope", () => {
|
|
108
|
+
test("denies an admin the bucket of another tenant", async () => {
|
|
109
|
+
const err = await stack.http.queryErr(
|
|
110
|
+
"rate-limiting:query:status",
|
|
111
|
+
{ bucket: `tenant:${testTenantId(2)}`, limit: 5, windowSeconds: 60 },
|
|
112
|
+
admin,
|
|
113
|
+
);
|
|
114
|
+
expect(err.code).toBe("access_denied");
|
|
115
|
+
expect(err.i18nKey).toBe(OUTSIDE_TENANT_KEY);
|
|
116
|
+
expect(err.httpStatus).toBe(403);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("denies a key that only starts with the caller's tenant id", async () => {
|
|
120
|
+
const err = await stack.http.queryErr(
|
|
121
|
+
"rate-limiting:query:status",
|
|
122
|
+
{ bucket: `tenant:${admin.tenantId}-other`, limit: 5, windowSeconds: 60 },
|
|
123
|
+
admin,
|
|
124
|
+
);
|
|
125
|
+
expect(err.i18nKey).toBe(OUTSIDE_TENANT_KEY);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("denies another user's bucket and the global IP buckets", async () => {
|
|
129
|
+
for (const bucket of [`user:${TestUsers.user.id}`, "l1:203.0.113.5", "ip:203.0.113.5"]) {
|
|
130
|
+
const err = await stack.http.queryErr(
|
|
131
|
+
"rate-limiting:query:status",
|
|
132
|
+
{ bucket, limit: 5, windowSeconds: 60 },
|
|
133
|
+
admin,
|
|
134
|
+
);
|
|
135
|
+
expect(err.i18nKey).toBe(OUTSIDE_TENANT_KEY);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("allows the caller's own tenant and handler-scoped buckets", async () => {
|
|
140
|
+
for (const bucket of [
|
|
141
|
+
`tenant:${admin.tenantId}`,
|
|
142
|
+
`tenant+handler:${admin.tenantId}:rl-probe:query:ping`,
|
|
143
|
+
`user+handler:${admin.id}:rl-probe:query:ping`,
|
|
144
|
+
]) {
|
|
145
|
+
const status = await stack.http.queryOk<{ bucket: string; remaining: number }>(
|
|
146
|
+
"rate-limiting:query:status",
|
|
147
|
+
{ bucket, limit: 5, windowSeconds: 60 },
|
|
148
|
+
admin,
|
|
149
|
+
);
|
|
150
|
+
expect(status.bucket).toBe(bucket);
|
|
151
|
+
expect(status.remaining).toBe(5);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("leaves SystemAdmin access to foreign buckets untouched", async () => {
|
|
156
|
+
const status = await stack.http.queryOk<{ bucket: string }>(
|
|
157
|
+
"rate-limiting:query:status",
|
|
158
|
+
{ bucket: `tenant:${testTenantId(2)}`, limit: 5, windowSeconds: 60 },
|
|
159
|
+
TestUsers.systemAdmin,
|
|
160
|
+
);
|
|
161
|
+
expect(status.bucket).toBe(`tenant:${testTenantId(2)}`);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "`rate-limiting:query:status` only peeks buckets of the calling tenant (fw#3076)",
|
|
6
|
+
"detail": "The handler took an arbitrary bucket key and peeked it, so a tenant Admin who knew or guessed another tenant's key could read that bucket's counter. The key is now matched segment-exact against the caller: `tenant:`/`tenant+handler:` must carry the caller's own tenant id, `user:`/`user+handler:` the caller's own user id. Everything else — other tenants, other users, the global `ip:`, `l1:` and `l2:` middleware buckets, malformed keys — is `access_denied` (`bucket_outside_tenant`) unless the caller is SystemAdmin, whose access is unchanged. The check runs before the resolver-wiring check, so a denied caller learns nothing about the mount either.",
|
|
7
|
+
"migration": "Only affects non-SystemAdmin callers of `rate-limiting:query:status`. A tenant\nAdmin keeps `tenant:<own>`, `tenant+handler:<own>:<handler>`, `user:<self>` and\n`user+handler:<self>:<handler>`. Two reads it had before now need SystemAdmin:\nanother user's bucket inside the same tenant (the tenant is not part of a\n`user:` key, so it cannot be verified without a membership lookup) and the\nglobal `ip:`/`l1:`/`l2:` buckets, which are not tenant-owned. Ops tooling that\npeeks those from a tenant Admin session has to run as SystemAdmin."
|
|
8
|
+
}
|
|
9
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
import { RateLimitErrors } from "../constants";
|
|
3
|
+
|
|
4
|
+
// Bucket keys are `<dimension>:<subject>[:<handler>]` (framework
|
|
5
|
+
// rate-limit/bucket.ts) — only `tenant*` and `user*` carry a subject the
|
|
6
|
+
// caller can own. `l1:`/`l2:` (middleware.ts) and every `ip*` bucket are
|
|
7
|
+
// global, so they stay SystemAdmin-only.
|
|
8
|
+
type BucketCaller = {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly tenantId: string;
|
|
11
|
+
readonly roles: readonly string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function ownsBucket(bucket: string, caller: BucketCaller): boolean {
|
|
15
|
+
const [dimension, subject, ...handler] = bucket.split(":");
|
|
16
|
+
if (dimension === undefined) return false;
|
|
17
|
+
const owner =
|
|
18
|
+
dimension === "tenant" || dimension === "tenant+handler"
|
|
19
|
+
? caller.tenantId
|
|
20
|
+
: dimension === "user" || dimension === "user+handler"
|
|
21
|
+
? caller.id
|
|
22
|
+
: undefined;
|
|
23
|
+
if (owner === undefined) return false;
|
|
24
|
+
// Segment-exact: a startsWith check would pass `tenant:<own-id>-other`.
|
|
25
|
+
if (subject !== owner) return false;
|
|
26
|
+
return dimension.endsWith("+handler") ? handler.length > 0 : handler.length === 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function bucketAccessDenied(
|
|
30
|
+
bucket: string,
|
|
31
|
+
caller: BucketCaller,
|
|
32
|
+
): AccessDeniedError | undefined {
|
|
33
|
+
if (caller.roles.includes("SystemAdmin")) return undefined;
|
|
34
|
+
if (ownsBucket(bucket, caller)) return undefined;
|
|
35
|
+
return new AccessDeniedError({
|
|
36
|
+
i18nKey: "rateLimiting.errors.bucketOutsideTenant",
|
|
37
|
+
details: { reason: RateLimitErrors.bucketOutsideTenant },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -2,6 +2,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { RateLimitErrors } from "../constants";
|
|
5
|
+
import { bucketAccessDenied } from "./bucket-access";
|
|
5
6
|
|
|
6
7
|
// Ops-side bucket inspection. Pass the bucket key (e.g. "user:42",
|
|
7
8
|
// "user+handler:42:orders:write:order:create") plus the limit/window the bucket
|
|
@@ -16,14 +17,15 @@ import { RateLimitErrors } from "../constants";
|
|
|
16
17
|
// Bucket key format is owned by the framework (see rate-limit/bucket.ts);
|
|
17
18
|
// callers pass the constructed key directly. We don't synthesize from
|
|
18
19
|
// (per, user, handler) here — peeking is a low-level op, the lookup
|
|
19
|
-
// surface stays small.
|
|
20
|
+
// surface stays small. Because the key is caller-supplied, bucket-access.ts
|
|
21
|
+
// restricts it to the caller's own tenant/user unless they are SystemAdmin.
|
|
20
22
|
export const rateLimitStatus = defineQueryHandler({
|
|
21
23
|
// Short name — the registry qualifies this to `rate-limiting:query:status`
|
|
22
24
|
// when the feature is registered. Passing the qualified form here would
|
|
23
25
|
// double-prefix it and the handler wouldn't be reachable.
|
|
24
26
|
name: "status",
|
|
25
27
|
description:
|
|
26
|
-
"Peeks at one rate-limit bucket and returns its remaining tokens, window and next reset without consuming a token; use it to explain why a caller is being throttled.",
|
|
28
|
+
"Peeks at one rate-limit bucket and returns its remaining tokens, window and next reset without consuming a token; use it to explain why a caller is being throttled. Non-SystemAdmin callers may only peek their own tenant's buckets (`tenant:`/`tenant+handler:`) and their own user buckets (`user:`/`user+handler:`); every other key, including the global `ip:`/`l1:`/`l2:` buckets, requires SystemAdmin.",
|
|
27
29
|
schema: z.object({
|
|
28
30
|
bucket: z.string().min(1),
|
|
29
31
|
limit: z.number().int().positive(),
|
|
@@ -31,6 +33,10 @@ export const rateLimitStatus = defineQueryHandler({
|
|
|
31
33
|
}),
|
|
32
34
|
access: { roles: ["Admin", "SystemAdmin"] },
|
|
33
35
|
handler: async (query, ctx) => {
|
|
36
|
+
// Before the resolver check, so a foreign-tenant caller learns nothing
|
|
37
|
+
// about the wiring either.
|
|
38
|
+
const denied = bucketAccessDenied(query.payload.bucket, ctx.user);
|
|
39
|
+
if (denied) throw denied;
|
|
34
40
|
if (!ctx.rateLimit) {
|
|
35
41
|
throw new UnprocessableError(RateLimitErrors.resolverUnavailable, {
|
|
36
42
|
i18nKey: "rateLimiting.errors.resolverUnavailable",
|