@cosmicdrift/kumiko-bundled-features 0.290.0 → 0.292.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/audit/__tests__/audit-screens.boot.test.ts +40 -1
- package/src/audit/__tests__/audit.integration.test.ts +96 -2
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +8 -1
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +18 -4
- 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/custom-fields/__tests__/audit-integration.integration.test.ts +2 -0
- package/src/delivery/feature.ts +6 -1
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +132 -9
- package/src/file-foundation/changes.json +9 -1
- package/src/file-foundation/feature.ts +2 -0
- 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
|
@@ -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") {
|
|
@@ -33,6 +33,7 @@ import { AuditQueries } from "../../audit/constants";
|
|
|
33
33
|
import { createAuditFeature } from "../../audit/feature";
|
|
34
34
|
import { createConfigFeature } from "../../config";
|
|
35
35
|
import { createTenantFeature } from "../../tenant";
|
|
36
|
+
import { createUserFeature } from "../../user";
|
|
36
37
|
import { fieldDefinitionEntity } from "../entity";
|
|
37
38
|
import { createCustomFieldsFeature } from "../feature";
|
|
38
39
|
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
@@ -86,6 +87,7 @@ beforeAll(async () => {
|
|
|
86
87
|
propertyFeature,
|
|
87
88
|
createConfigFeature(),
|
|
88
89
|
createTenantFeature(),
|
|
90
|
+
createUserFeature(),
|
|
89
91
|
createAuditFeature(),
|
|
90
92
|
],
|
|
91
93
|
});
|
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
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
7
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
access,
|
|
10
|
+
createTenantConfig,
|
|
11
|
+
defineFeature,
|
|
12
|
+
defineWriteHandler,
|
|
13
|
+
FILE_PROVIDER_CONFIG_KEY,
|
|
14
|
+
FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
15
|
+
FILE_STORAGE_PROVIDER_ENV,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
17
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
18
|
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
11
19
|
import {
|
|
@@ -25,7 +33,11 @@ import { z } from "zod";
|
|
|
25
33
|
import { createConfigFeature } from "../../config";
|
|
26
34
|
import { ConfigHandlers } from "../../config/constants";
|
|
27
35
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
28
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
buildEnvConfigOverrides,
|
|
38
|
+
type ConfigResolver,
|
|
39
|
+
createConfigResolver,
|
|
40
|
+
} from "../../config/resolver";
|
|
29
41
|
import { configValuesTable } from "../../config/table";
|
|
30
42
|
import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../../file-provider-s3";
|
|
31
43
|
import { fileProviderS3EnvFeature } from "../../file-provider-s3-env";
|
|
@@ -37,15 +49,30 @@ import { createFileProviderForTenant, fileFoundationFeature } from "../feature";
|
|
|
37
49
|
// --- Test-Handler that exercises the factory end-to-end ---
|
|
38
50
|
|
|
39
51
|
const TEST_HANDLER_QN = "file-test:write:build-provider";
|
|
52
|
+
const PROBE_SECRET_KEY = "file-test:config:probe-secret";
|
|
40
53
|
const testProbeFeature = defineFeature("file-test", (r) => {
|
|
41
54
|
r.requires("config");
|
|
42
55
|
r.requires("secrets");
|
|
56
|
+
// Envelope-cipher round-trip partner: proves the resolver still decrypts
|
|
57
|
+
// once it also carries ENV-bridged app-overrides.
|
|
58
|
+
r.config({
|
|
59
|
+
keys: {
|
|
60
|
+
probeSecret: createTenantConfig("text", {
|
|
61
|
+
encrypted: true,
|
|
62
|
+
default: "",
|
|
63
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
64
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
43
68
|
r.writeHandler(
|
|
44
69
|
defineWriteHandler({
|
|
45
70
|
name: "build-provider",
|
|
46
71
|
schema: z.object({}),
|
|
47
72
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
48
73
|
handler: async (event, ctx) => {
|
|
74
|
+
const readConfig = ctx.config;
|
|
75
|
+
if (!readConfig) throw new Error(`${TEST_HANDLER_QN}: ctx.config is missing`);
|
|
49
76
|
const provider = await createFileProviderForTenant(
|
|
50
77
|
ctx,
|
|
51
78
|
event.user.tenantId,
|
|
@@ -54,6 +81,8 @@ const testProbeFeature = defineFeature("file-test", (r) => {
|
|
|
54
81
|
return {
|
|
55
82
|
isSuccess: true,
|
|
56
83
|
data: {
|
|
84
|
+
selectedProvider: await readConfig(FILE_PROVIDER_CONFIG_KEY),
|
|
85
|
+
probeSecret: await readConfig(PROBE_SECRET_KEY),
|
|
57
86
|
hasWrite: typeof provider.write === "function",
|
|
58
87
|
hasRead: typeof provider.read === "function",
|
|
59
88
|
hasDelete: typeof provider.delete === "function",
|
|
@@ -72,10 +101,10 @@ let resolver: ConfigResolver;
|
|
|
72
101
|
let providerRef: MutableMasterKeyProvider;
|
|
73
102
|
|
|
74
103
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
104
|
+
const ENV_SELECTED_PROVIDER = "s3-env";
|
|
75
105
|
|
|
76
106
|
beforeAll(async () => {
|
|
77
107
|
const encryption = createTestEnvelopeCipher(testEncryptionKey);
|
|
78
|
-
resolver = createConfigResolver({ cipher: encryption });
|
|
79
108
|
|
|
80
109
|
const initialKp = createEnvMasterKeyProvider({
|
|
81
110
|
env: {
|
|
@@ -96,12 +125,22 @@ beforeAll(async () => {
|
|
|
96
125
|
testProbeFeature,
|
|
97
126
|
],
|
|
98
127
|
masterKeyProvider: providerRef,
|
|
99
|
-
extraContext: ({ db, registry }) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
extraContext: ({ db, registry }) => {
|
|
129
|
+
// No app-side resolver rebuild: the provider key declares
|
|
130
|
+
// env: FILE_STORAGE_PROVIDER, so the generic ENV bridge selects it.
|
|
131
|
+
resolver = createConfigResolver({
|
|
132
|
+
cipher: encryption,
|
|
133
|
+
appOverrides: buildEnvConfigOverrides(registry, {
|
|
134
|
+
[FILE_STORAGE_PROVIDER_ENV]: ENV_SELECTED_PROVIDER,
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
configResolver: resolver,
|
|
139
|
+
configEncryption: encryption,
|
|
140
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
141
|
+
secrets: createSecretsContext({ db, masterKeyProvider: providerRef }),
|
|
142
|
+
};
|
|
143
|
+
},
|
|
105
144
|
});
|
|
106
145
|
db = stack.db;
|
|
107
146
|
|
|
@@ -283,3 +322,87 @@ describe("scenario 4: s3-env provider (app-wide env, no secrets)", () => {
|
|
|
283
322
|
}
|
|
284
323
|
});
|
|
285
324
|
});
|
|
325
|
+
|
|
326
|
+
// --- Scenario 5: FILE_STORAGE_PROVIDER selects the provider, no app resolver ---
|
|
327
|
+
//
|
|
328
|
+
// The stack's resolver is the plain framework one — its only app-override
|
|
329
|
+
// comes from buildEnvConfigOverrides reading the key's `env` declaration.
|
|
330
|
+
// Before #3112 every app rebuilt createConfigResolver just to pin this key.
|
|
331
|
+
|
|
332
|
+
describe("scenario 5: ENV-bridged provider selection", () => {
|
|
333
|
+
function withS3Env<T>(run: () => Promise<T>): Promise<T> {
|
|
334
|
+
const saved = S3_ENV_KEYS.map((k) => [k, process.env[k]] as const);
|
|
335
|
+
Object.assign(process.env, {
|
|
336
|
+
S3_BUCKET: "env-bridge-bucket",
|
|
337
|
+
S3_REGION: "fsn1",
|
|
338
|
+
S3_ACCESS_KEY: "AKIABRIDGE",
|
|
339
|
+
S3_SECRET_KEY: "env-secret-not-real",
|
|
340
|
+
});
|
|
341
|
+
return run().finally(() => {
|
|
342
|
+
for (const [k, v] of saved) {
|
|
343
|
+
if (v === undefined) delete process.env[k];
|
|
344
|
+
else process.env[k] = v;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
test("no tenant row → FILE_STORAGE_PROVIDER wins and the factory builds that provider", async () => {
|
|
350
|
+
const admin = adminFor(506);
|
|
351
|
+
|
|
352
|
+
const result = await withS3Env(
|
|
353
|
+
async () => (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<string, unknown>,
|
|
354
|
+
);
|
|
355
|
+
expect(result["selectedProvider"]).toBe(ENV_SELECTED_PROVIDER);
|
|
356
|
+
expect(result["hasWrite"]).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("tenant row still overrides the ENV value (cascade unchanged)", async () => {
|
|
360
|
+
const admin = adminFor(507);
|
|
361
|
+
|
|
362
|
+
await selectS3Provider(admin);
|
|
363
|
+
await setConfig(admin, "file-provider-s3:config:bucket", "row-bucket");
|
|
364
|
+
await setConfig(admin, "file-provider-s3:config:region", "fsn1");
|
|
365
|
+
await setConfig(admin, "file-provider-s3:config:access-key-id", "AKIAROW");
|
|
366
|
+
await stack.http.writeOk(
|
|
367
|
+
"secrets:write:set",
|
|
368
|
+
{ key: S3_SECRET_ACCESS_KEY.name, value: "row-secret" },
|
|
369
|
+
admin,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const result = await withS3Env(
|
|
373
|
+
async () => (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<string, unknown>,
|
|
374
|
+
);
|
|
375
|
+
expect(result["selectedProvider"]).toBe("s3");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("boot-gate sentinel selects no provider", async () => {
|
|
379
|
+
// What runDevApp writes into FILE_STORAGE_PROVIDER when options.files
|
|
380
|
+
// already wires a provider — it names no plugin, so the bridge must not
|
|
381
|
+
// turn it into a selection.
|
|
382
|
+
expect(
|
|
383
|
+
buildEnvConfigOverrides(stack.registry, {
|
|
384
|
+
[FILE_STORAGE_PROVIDER_ENV]: FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
385
|
+
}).get(FILE_PROVIDER_CONFIG_KEY),
|
|
386
|
+
).toBe(FILE_STORAGE_PROVIDER_BOOT_SENTINEL);
|
|
387
|
+
|
|
388
|
+
const admin = adminFor(508);
|
|
389
|
+
await setConfig(admin, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL);
|
|
390
|
+
|
|
391
|
+
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
392
|
+
// The factory throw surfaces as internal_error; the original message sits
|
|
393
|
+
// in details.causeMessage.
|
|
394
|
+
expect(JSON.stringify(error)).toMatch(
|
|
395
|
+
/no provider selected — set the 'file-foundation:config:provider' config-key/,
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("encrypted config keys still round-trip through the same resolver", async () => {
|
|
400
|
+
const admin = adminFor(509);
|
|
401
|
+
await setConfig(admin, PROBE_SECRET_KEY, "cipher-round-trip");
|
|
402
|
+
|
|
403
|
+
const result = await withS3Env(
|
|
404
|
+
async () => (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<string, unknown>,
|
|
405
|
+
);
|
|
406
|
+
expect(result["probeSecret"]).toBe("cipher-round-trip");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.292.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "FILE_STORAGE_PROVIDER selects the file provider, not just the boot gate",
|
|
6
|
+
"detail": "The `provider` config key declares `env: \"FILE_STORAGE_PROVIDER\"`, so the ENV→app-override bridge selects the provider at boot without an app-side `createConfigResolver` rebuild. Tenant rows still override the ENV value (cascade unchanged). The boot-gate placeholder `\"configured\"` that `runDevApp` writes names no plugin, so `createFileProviderForTenant` treats it as unset and raises \"no provider selected\"; both strings are exported as `FILE_STORAGE_PROVIDER_ENV` / `FILE_STORAGE_PROVIDER_BOOT_SENTINEL`.",
|
|
7
|
+
"migration": "Apps on the framework's default config resolver: make sure `FILE_STORAGE_PROVIDER` holds a registered provider name (`inmemory`, `s3`, `s3-env`) — it is now the provider selection, not only a presence check. Apps that pass their own `configResolver` are unaffected and can drop their hand-rolled `file-foundation:config:provider` app-override."
|
|
8
|
+
}
|
|
9
|
+
]
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
createTenantConfig,
|
|
22
22
|
defineFeature,
|
|
23
23
|
EXT_FILE_PROVIDER,
|
|
24
|
+
FILE_STORAGE_PROVIDER_ENV,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
26
|
|
|
26
27
|
export type {
|
|
@@ -58,6 +59,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
58
59
|
"provider",
|
|
59
60
|
createTenantConfig("text", {
|
|
60
61
|
default: "",
|
|
62
|
+
env: FILE_STORAGE_PROVIDER_ENV,
|
|
61
63
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
62
64
|
read: access.roles("TenantAdmin", "SystemAdmin", "User"),
|
|
63
65
|
}),
|
|
@@ -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
|
+
});
|