@cosmicdrift/kumiko-bundled-features 0.234.0 → 0.235.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/__tests__/auth.integration.test.ts +52 -0
- package/src/auth-email-password/seeding.ts +7 -5
- package/src/config/i18n.ts +2 -7
- package/src/config/web/i18n.ts +11 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +14 -4
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +38 -1
- package/src/jobs/handlers/retry.write.ts +8 -1
- package/src/legal-pages/README.md +4 -2
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +16 -5
- package/src/legal-pages/feature.ts +4 -1
- package/src/secrets/__tests__/settings-hub-i18n.test.ts +27 -0
- package/src/secrets/feature.ts +10 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +30 -2
- package/src/tenant/constants.ts +5 -3
- package/src/tenant/screens.ts +6 -1
- package/src/user/handlers/create.write.ts +2 -0
- package/src/user/handlers/find-for-auth.query.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +62 -0
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +145 -1
- package/src/workflow-runner/event-trigger.ts +9 -2
- package/src/workflow-runner/handlers/resume-run.write.ts +94 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.235.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.235.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.235.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.235.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.235.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.235.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.235.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.235.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import {
|
|
5
|
+
configureBlindIndexKey,
|
|
6
|
+
configurePiiSubjectKms,
|
|
7
|
+
InMemoryKmsAdapter,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
9
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
10
|
import {
|
|
6
11
|
createTestUser,
|
|
@@ -16,6 +21,9 @@ import {
|
|
|
16
21
|
expectErrorIncludes,
|
|
17
22
|
getSetCookieRaw,
|
|
18
23
|
getSetCookieValue,
|
|
24
|
+
resetBlindIndexKeyForTests,
|
|
25
|
+
resetPiiSubjectKmsForTests,
|
|
26
|
+
updateRows,
|
|
19
27
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
20
28
|
import { createConfigFeature } from "../../config";
|
|
21
29
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -215,6 +223,50 @@ describe("scenario 3: login without membership", () => {
|
|
|
215
223
|
});
|
|
216
224
|
});
|
|
217
225
|
|
|
226
|
+
// --- Scenario 3b: login resolves the live account when its email is shared
|
|
227
|
+
// with a soft-deleted row (fw#2464 partial bidx index only covers live rows) ---
|
|
228
|
+
|
|
229
|
+
describe("scenario 3b: login with an email shared by a soft-deleted row", () => {
|
|
230
|
+
// The DB-level dedup that lets a soft-deleted row and a live row share an
|
|
231
|
+
// email only exists on the blind-index column (see
|
|
232
|
+
// email-unique-blind-index.integration.test.ts) — without a configured
|
|
233
|
+
// key, email_bidx stays NULL and the plaintext-fallback unique index
|
|
234
|
+
// blocks the second create outright, never reaching the login path.
|
|
235
|
+
beforeAll(() => {
|
|
236
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
237
|
+
configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
afterAll(() => {
|
|
241
|
+
resetPiiSubjectKmsForTests();
|
|
242
|
+
resetBlindIndexKeyForTests();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("new account wins the login, not the soft-deleted original", async () => {
|
|
246
|
+
const original = await seedLoginUser({
|
|
247
|
+
email: "reused-login@example.com",
|
|
248
|
+
password: "original-password-123",
|
|
249
|
+
});
|
|
250
|
+
await updateRows(stack.db, userTable, { isDeleted: true }, { id: original.id });
|
|
251
|
+
|
|
252
|
+
const recreated = await seedLoginUser({
|
|
253
|
+
email: "reused-login@example.com",
|
|
254
|
+
password: "new-account-password-123",
|
|
255
|
+
});
|
|
256
|
+
expect(recreated.id).not.toBe(original.id);
|
|
257
|
+
|
|
258
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
259
|
+
email: "reused-login@example.com",
|
|
260
|
+
password: "new-account-password-123",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe(200);
|
|
264
|
+
const body = await res.json();
|
|
265
|
+
expect(body.isSuccess).toBe(true);
|
|
266
|
+
expect(body.user.id).toBe(recreated.id);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
218
270
|
// --- Scenario 4 + 5: change-password flow ---
|
|
219
271
|
|
|
220
272
|
describe("scenario 4: change-password with wrong old password", () => {
|
|
@@ -139,11 +139,13 @@ export async function provisionSignupAccount(
|
|
|
139
139
|
// create paths (#1478 — this was only wired for tenant before).
|
|
140
140
|
hooks?: SeedTenantHooks,
|
|
141
141
|
): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
|
|
142
|
-
// Create-only
|
|
143
|
-
//
|
|
144
|
-
// add-only)
|
|
145
|
-
//
|
|
146
|
-
|
|
142
|
+
// Create-only guard BEFORE seedTenant: hard-abort on an already-registered
|
|
143
|
+
// email, otherwise an orphaned tenant would be created and seedUser
|
|
144
|
+
// (idempotent add-only) would silently return the existing userId ->
|
|
145
|
+
// confirm mints a session for the wrong account (#365).
|
|
146
|
+
// isDeleted: false — the partial bidx unique index only covers live rows,
|
|
147
|
+
// so a soft-deleted row must not block re-registering its email.
|
|
148
|
+
const existingUser = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
|
|
147
149
|
if (existingUser) {
|
|
148
150
|
throw new ConflictError({ message: "signup: email already registered" });
|
|
149
151
|
}
|
package/src/config/i18n.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
2
|
|
|
3
3
|
/** Server boot keys for the auto-generated Settings-Hub (mirrors web/i18n.ts). */
|
|
4
|
-
export const CONFIG_FEATURE_I18N
|
|
5
|
-
"config.settings.title": { en: "Settings" },
|
|
6
|
-
"config.settings.system": { en: "Platform" },
|
|
7
|
-
"config.settings.tenant": { en: "Tenant" },
|
|
8
|
-
"config.settings.user": { en: "Personal" },
|
|
9
|
-
};
|
|
4
|
+
export const CONFIG_FEATURE_I18N = SETTINGS_HUB_I18N;
|
package/src/config/web/i18n.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
|
11
11
|
|
|
12
12
|
export const defaultTranslations: TranslationsByLocale = {
|
|
13
13
|
en: {
|
|
14
|
+
"config.secrets.delete": "Delete",
|
|
15
|
+
"config.secrets.notSet": "Not set",
|
|
16
|
+
"config.secrets.placeholder": "Enter a value",
|
|
17
|
+
"config.secrets.replacePlaceholder": "Enter a new value to replace",
|
|
18
|
+
"config.secrets.required": "Required",
|
|
19
|
+
"config.secrets.set": "Set",
|
|
20
|
+
"config.secrets.title": "Secrets",
|
|
14
21
|
"config.settings.title": "Settings",
|
|
15
22
|
"config.settings.system": "Platform",
|
|
16
23
|
"config.settings.tenant": "Tenant",
|
|
@@ -18,5 +25,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
18
25
|
"config.errors.systemOnly": "This value can only be set by the system.",
|
|
19
26
|
"config.errors.invalidScope": "This scope is not allowed for this key.",
|
|
20
27
|
"config.errors.unknownKey": "Unknown configuration key.",
|
|
28
|
+
// Required by every generated screen (screenTitleKey, required-surface-keys.ts) —
|
|
29
|
+
// the secrets screen has a fixed id ("secrets"), so the framework ships its
|
|
30
|
+
// title translation directly instead of asking every app to declare it.
|
|
31
|
+
"screen:secrets.title": "Secrets",
|
|
21
32
|
},
|
|
22
33
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
3
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
4
|
import {
|
|
@@ -55,7 +56,8 @@ export const subjectForgottenSchema = z.object({
|
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
export const subjectForgetDeniedSchema = z.object({
|
|
58
|
-
|
|
59
|
+
subjectKeyDigest: z.string().min(1),
|
|
60
|
+
subjectKind: z.enum(["user", "tenant"]),
|
|
59
61
|
reason: z.string().min(10),
|
|
60
62
|
forgottenBy: z.string().min(1),
|
|
61
63
|
actorTenantId: z.string().min(1),
|
|
@@ -138,13 +140,21 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
138
140
|
raw,
|
|
139
141
|
);
|
|
140
142
|
if (tenantScopeDenial) {
|
|
141
|
-
// Denied cross-tenant probes must still leave an audit trail (fw#2348)
|
|
143
|
+
// Denied cross-tenant probes must still leave an audit trail (fw#2348),
|
|
144
|
+
// but the denial lands in the REQUESTING actor's own tenant-scoped
|
|
145
|
+
// stream — it must never materialise the foreign subject's identifiers
|
|
146
|
+
// there. A plaintext subjectKey/aggregateId would survive as a
|
|
147
|
+
// permanent record for the prober and would still be present when the
|
|
148
|
+
// owning tenant later runs its own (legitimate) forget-subject for that
|
|
149
|
+
// subject. The digest still lets an operator correlate repeated probes
|
|
150
|
+
// of the same subject without exposing it (fw#2452).
|
|
142
151
|
await ctx.unsafeAppendEvent({
|
|
143
|
-
aggregateId:
|
|
152
|
+
aggregateId: event.user.id,
|
|
144
153
|
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
145
154
|
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
146
155
|
payload: {
|
|
147
|
-
subjectKey,
|
|
156
|
+
subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
|
|
157
|
+
subjectKind: raw.kind,
|
|
148
158
|
reason: event.payload.reason,
|
|
149
159
|
forgottenBy: event.user.id,
|
|
150
160
|
actorTenantId: event.user.tenantId,
|
|
@@ -451,10 +451,12 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
|
451
451
|
const RETRY_USER_ID = "u-pii-retry-1";
|
|
452
452
|
const RETRY_JOB_NAME = "retryapp:job:capture-import";
|
|
453
453
|
const capturedPayloads: Record<string, unknown>[] = [];
|
|
454
|
+
const capturedTriggeredBy: (string | null)[] = [];
|
|
454
455
|
|
|
455
456
|
const retryAppFeature = defineFeature("retryapp", (r) => {
|
|
456
|
-
r.job("captureImport", { trigger: { manual: true } }, async (payload) => {
|
|
457
|
+
r.job("captureImport", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
457
458
|
capturedPayloads.push(payload);
|
|
459
|
+
capturedTriggeredBy.push(ctx.triggeredBy?.id ?? null);
|
|
458
460
|
});
|
|
459
461
|
});
|
|
460
462
|
|
|
@@ -476,6 +478,7 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
|
476
478
|
|
|
477
479
|
beforeEach(() => {
|
|
478
480
|
capturedPayloads.length = 0;
|
|
481
|
+
capturedTriggeredBy.length = 0;
|
|
479
482
|
retryKms = new InMemoryKmsAdapter();
|
|
480
483
|
configurePiiSubjectKms(retryKms);
|
|
481
484
|
});
|
|
@@ -527,6 +530,40 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
|
527
530
|
expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
|
|
528
531
|
});
|
|
529
532
|
|
|
533
|
+
// #2469: retry dispatched without a `meta` argument, so the retried run
|
|
534
|
+
// carried no triggeredById at all — job-run-logger's onJobStart then saw
|
|
535
|
+
// triggeredById: null, skipped encryption entirely, and the retried run's
|
|
536
|
+
// payload landed in job_runs as unshreddable plaintext. The asserted
|
|
537
|
+
// invariant is the input to that chain: the retried BullMQ job must carry
|
|
538
|
+
// the ORIGINAL run's owner, not the retrying SystemAdmin and not null.
|
|
539
|
+
// (Asserting the retried run's job_runs row directly is not possible here:
|
|
540
|
+
// this stack wires no job-run-logger into the runner, so a real dispatch
|
|
541
|
+
// writes no row — every other row in this file is seeded by calling
|
|
542
|
+
// retryLogger.onJobStart by hand.)
|
|
543
|
+
test("retry re-dispatches with the original run's triggeredById so the new run's payload stays encrypted", async () => {
|
|
544
|
+
await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-5", {
|
|
545
|
+
triggeredById: RETRY_USER_ID,
|
|
546
|
+
payload: SECRET_PAYLOAD,
|
|
547
|
+
});
|
|
548
|
+
await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-5", "boom", []);
|
|
549
|
+
|
|
550
|
+
const originalRow = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-5" });
|
|
551
|
+
expect(isPiiCiphertext(originalRow?.["payload"])).toBe(true);
|
|
552
|
+
expect(originalRow?.["triggeredById"]).toBe(RETRY_USER_ID);
|
|
553
|
+
|
|
554
|
+
await retryStack.http.writeOk<{
|
|
555
|
+
jobName: string;
|
|
556
|
+
bullJobId: string;
|
|
557
|
+
retriedFromRunId: string;
|
|
558
|
+
}>(JobHandlers.retry, { runId: originalRow?.["id"] }, TestUsers.systemAdmin);
|
|
559
|
+
|
|
560
|
+
await sleep(1000);
|
|
561
|
+
|
|
562
|
+
expect(capturedTriggeredBy).toEqual([RETRY_USER_ID]);
|
|
563
|
+
expect(capturedTriggeredBy[0]).not.toBe(TestUsers.systemAdmin.id);
|
|
564
|
+
expect(capturedPayloads).toEqual([JSON.parse(SECRET_PAYLOAD)]);
|
|
565
|
+
});
|
|
566
|
+
|
|
530
567
|
// The other producer of PII_ERASED_SENTINEL: job-run-logger writes the
|
|
531
568
|
// literal "[[erased]]" string (not ciphertext) when the key is already
|
|
532
569
|
// gone at onJobStart time (see the mirrored case above this describe
|
|
@@ -18,6 +18,7 @@ type JobRunRow = {
|
|
|
18
18
|
readonly status: string;
|
|
19
19
|
readonly jobName: string;
|
|
20
20
|
readonly payload: string | null;
|
|
21
|
+
readonly triggeredById: string | null;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
export const retryWrite = defineWriteHandler({
|
|
@@ -61,6 +62,7 @@ export const retryWrite = defineWriteHandler({
|
|
|
61
62
|
// PII_ERASED_SENTINEL, which is not JSON; reject the retry rather than
|
|
62
63
|
// dispatch it with a silently emptied payload.
|
|
63
64
|
let payload: Record<string, unknown> = {};
|
|
65
|
+
let decryptedPayloadJson: string | null = null;
|
|
64
66
|
if (run.payload) {
|
|
65
67
|
const decryptedPayload = await decryptStoredPii(
|
|
66
68
|
run.payload,
|
|
@@ -78,9 +80,14 @@ export const retryWrite = defineWriteHandler({
|
|
|
78
80
|
decryptedPayload,
|
|
79
81
|
`job run ${event.payload.runId} payload`,
|
|
80
82
|
);
|
|
83
|
+
decryptedPayloadJson = decryptedPayload;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
// Retry re-uses the original run's DEK owner so the retry's logs/payload stay encrypted.
|
|
87
|
+
const bullJobId = await jobRunner.dispatch(run.jobName, payload, {
|
|
88
|
+
triggeredById: run.triggeredById ?? undefined,
|
|
89
|
+
...(decryptedPayloadJson !== null && { payload: decryptedPayloadJson }),
|
|
90
|
+
});
|
|
84
91
|
|
|
85
92
|
return {
|
|
86
93
|
isSuccess: true,
|
|
@@ -110,8 +110,10 @@ routes.
|
|
|
110
110
|
|
|
111
111
|
## Boot check
|
|
112
112
|
|
|
113
|
-
`r.job` with `
|
|
114
|
-
required blocks exist in SYSTEM_TENANT.
|
|
113
|
+
`r.job` with `bootGate: true` runs inline while the API job runner
|
|
114
|
+
starts and checks whether the required blocks exist in SYSTEM_TENANT.
|
|
115
|
+
A throw rejects the boot, so the process never reports ready.
|
|
116
|
+
**Default** (DACH):
|
|
115
117
|
|
|
116
118
|
| Slug + locale | What happens when missing |
|
|
117
119
|
|---|---|
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
13
13
|
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
|
|
14
14
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
-
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
17
|
import {
|
|
18
18
|
setupTestStack,
|
|
@@ -226,10 +226,21 @@ describe("markdown render helpers", () => {
|
|
|
226
226
|
});
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
229
|
+
describe("legal-pages :: boot gate wiring", () => {
|
|
230
|
+
// The abort behaviour itself is proven in the framework's job-runner
|
|
231
|
+
// integration tests ("boot gates"); this only pins that legal-pages is
|
|
232
|
+
// wired onto that path and not onto the fire-and-forget runOnBoot one.
|
|
233
|
+
test("boot check is declared as a bootGate, not as runOnBoot", () => {
|
|
234
|
+
const registry = createRegistry([textFeature, legalFeature]);
|
|
235
|
+
const job = registry.getJob("legal-pages:job:legal-pages-boot-check");
|
|
236
|
+
expect(job).toBeDefined();
|
|
237
|
+
expect(job?.bootGate).toBe(true);
|
|
238
|
+
expect(job?.runOnBoot).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Boot check called directly (without the dev-server job-runner path) —
|
|
243
|
+
// verifies that the logic detects blocks missing in SYSTEM_TENANT.
|
|
233
244
|
describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", () => {
|
|
234
245
|
test("legal-pages serven SYSTEM_TENANT-Texte auch wenn tenantResolver einen anderen Tenant zurückgibt", async () => {
|
|
235
246
|
// Simuliert publicstatus's Setup: host-basierter tenantResolver der
|
|
@@ -208,7 +208,10 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
208
208
|
"legal-pages-boot-check",
|
|
209
209
|
{
|
|
210
210
|
trigger: { manual: true },
|
|
211
|
-
runOnBoot:
|
|
211
|
+
// bootGate, not runOnBoot: only an inline gate can actually abort the
|
|
212
|
+
// boot. runOnBoot merely enqueues, so a missing imprint would fail a
|
|
213
|
+
// queue job while the pod goes ready anyway (fw#2590).
|
|
214
|
+
bootGate: true,
|
|
212
215
|
runIn: "api",
|
|
213
216
|
},
|
|
214
217
|
async (_payload, ctx) => runLegalPagesBootCheck(ctx, requiredBlocks),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createConfigFeature } from "../../config/feature";
|
|
4
|
+
import { createSecretsFeature } from "../feature";
|
|
5
|
+
|
|
6
|
+
// `secrets` is a foundation feature auto-mounted even when `config` is not
|
|
7
|
+
// (dev-server scaffold-app.ts FOUNDATION_FEATURES). A declared r.secret()
|
|
8
|
+
// makes buildConfigFeatureSchema emit a secretsEdit screen + Settings-Hub
|
|
9
|
+
// chrome navs, whose dot-form labels (config.settings.title, etc.) must
|
|
10
|
+
// resolve at boot without the config feature present (fw#2577).
|
|
11
|
+
function stripeSecret() {
|
|
12
|
+
return defineFeature("stripe", (r) => {
|
|
13
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("Settings-Hub chrome i18n keys — secrets without config", () => {
|
|
18
|
+
test("boots with only the secrets feature mounted (no config feature)", () => {
|
|
19
|
+
expect(() => validateBoot([createSecretsFeature(), stripeSecret()])).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("boots with both secrets and config mounted", () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
validateBoot([createSecretsFeature(), createConfigFeature(), stripeSecret()]),
|
|
25
|
+
).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/secrets/feature.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
|
|
7
8
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
import { DEFAULT_SECRETS_ACCESS } from "./constants";
|
|
@@ -129,6 +130,15 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
|
|
|
129
130
|
});
|
|
130
131
|
r.envSchema(secretsEnvSchema);
|
|
131
132
|
|
|
133
|
+
// `secrets` is a foundation feature auto-mounted even when `config` is
|
|
134
|
+
// not (see FOUNDATION_FEATURES, dev-server scaffold-app.ts), so it must
|
|
135
|
+
// ship the Settings-Hub chrome keys itself — server-authored
|
|
136
|
+
// r.translations are projected into FeatureSchema.translations and used
|
|
137
|
+
// as a client fallback bundle (create-app.tsx schemaTranslations),
|
|
138
|
+
// covering both boot-time validation and rendering without a web
|
|
139
|
+
// client-plugin for this feature.
|
|
140
|
+
r.translations({ keys: SETTINGS_HUB_I18N });
|
|
141
|
+
|
|
132
142
|
// ES entity: set/delete go through the executor, `tenantSecret.created/
|
|
133
143
|
// .updated/.deleted` events land on the aggregate stream. Reads fire a
|
|
134
144
|
// separate `tenantSecretRead` event per call (see secrets-context.get
|
|
@@ -4,10 +4,10 @@ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
|
4
4
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
5
5
|
import { createConfigFeature } from "../../config/feature";
|
|
6
6
|
import {
|
|
7
|
-
DEFAULT_INVITE_ROLE_OPTIONS,
|
|
8
7
|
INVITE_CREATE_SCREEN_ID,
|
|
9
8
|
MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
10
9
|
MEMBERS_SCREEN_ID,
|
|
10
|
+
OWNER_INVITE_ROLE_OPTIONS,
|
|
11
11
|
TenantHandlers,
|
|
12
12
|
TenantQueries,
|
|
13
13
|
} from "../constants";
|
|
@@ -106,7 +106,7 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
106
106
|
expect(section.fields).toEqual([{ field: "userId", readOnly: true }, "roles"]);
|
|
107
107
|
expect(screen.fields["roles"]).toEqual({
|
|
108
108
|
type: "multiSelect",
|
|
109
|
-
options:
|
|
109
|
+
options: OWNER_INVITE_ROLE_OPTIONS,
|
|
110
110
|
required: true,
|
|
111
111
|
});
|
|
112
112
|
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
@@ -116,6 +116,34 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
// fw-2452 regression: the roles-edit multiSelect is prefilled with a
|
|
120
|
+
// member's current roles, so it must list every rank updateMemberRoles can
|
|
121
|
+
// actually assign (incl. TenantAdmin) — while the invite picker stays a
|
|
122
|
+
// closed allowlist without TenantAdmin (fw#2414), since inviting always
|
|
123
|
+
// starts a brand-new membership rather than editing an existing one.
|
|
124
|
+
test("member-roles-edit options can represent every assignable rank; invite picker stays TenantAdmin-free", () => {
|
|
125
|
+
const tenant = createTenantFeature({ inviteScreen: true });
|
|
126
|
+
const rolesEditScreen = tenant.screens[MEMBER_ROLES_EDIT_SCREEN_ID];
|
|
127
|
+
if (rolesEditScreen?.type !== "actionForm") {
|
|
128
|
+
throw new Error("expected member-roles-edit screen to be actionForm");
|
|
129
|
+
}
|
|
130
|
+
const rolesField = rolesEditScreen.fields["roles"];
|
|
131
|
+
if (rolesField?.type !== "multiSelect") {
|
|
132
|
+
throw new Error("expected roles field to be multiSelect");
|
|
133
|
+
}
|
|
134
|
+
expect(rolesField.options).toContain("TenantAdmin");
|
|
135
|
+
|
|
136
|
+
const inviteScreen = tenant.screens[INVITE_CREATE_SCREEN_ID];
|
|
137
|
+
if (inviteScreen?.type !== "actionForm") {
|
|
138
|
+
throw new Error("expected invite-create screen to be actionForm");
|
|
139
|
+
}
|
|
140
|
+
const roleField = inviteScreen.fields["role"];
|
|
141
|
+
if (roleField?.type !== "select") {
|
|
142
|
+
throw new Error("expected role field to be select");
|
|
143
|
+
}
|
|
144
|
+
expect(roleField.options).not.toContain("TenantAdmin");
|
|
145
|
+
});
|
|
146
|
+
|
|
119
147
|
// The generic drawer-open/submit/close/refetch mechanics for kind:"drawer"
|
|
120
148
|
// toolbar actions are already covered end-to-end with a synthetic screen in
|
|
121
149
|
// renderer-web's projection-list-actions.test.tsx — this only proves OUR
|
package/src/tenant/constants.ts
CHANGED
|
@@ -23,10 +23,12 @@ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
|
|
|
23
23
|
export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
|
|
24
24
|
|
|
25
25
|
/** Closed allowlist for invite-role picker — never free text (escalation guard). */
|
|
26
|
-
// Admin (rank 2) shares
|
|
27
|
-
// appear here or Admin sees an unassignable option (fw#2414).
|
|
28
|
-
// owner invites compose OWNER_INVITE_ROLE_OPTIONS into a custom screen.
|
|
26
|
+
// Admin (rank 2) shares the invite-create screen with TenantAdmin — TenantAdmin
|
|
27
|
+
// must not appear here or Admin sees an unassignable option (fw#2414).
|
|
29
28
|
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
|
|
29
|
+
// Full assignable rank list, used by memberRolesEditScreen: that screen is
|
|
30
|
+
// prefilled with a member's *current* roles, so it must be able to represent
|
|
31
|
+
// every rank a membership can actually hold, including TenantAdmin (fw#2452).
|
|
30
32
|
export const OWNER_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
|
|
31
33
|
|
|
32
34
|
// Qualified write handler names (QN format: scope:type:name)
|
package/src/tenant/screens.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
13
13
|
MEMBER_STATUS_CELL_COMPONENT,
|
|
14
14
|
MEMBERS_SCREEN_ID,
|
|
15
|
+
OWNER_INVITE_ROLE_OPTIONS,
|
|
15
16
|
TenantHandlers,
|
|
16
17
|
TenantQueries,
|
|
17
18
|
} from "./constants";
|
|
@@ -179,7 +180,11 @@ export const memberRolesEditScreen = {
|
|
|
179
180
|
cancelTarget: MEMBERS_SCREEN_ID,
|
|
180
181
|
fields: {
|
|
181
182
|
userId: { type: "text", required: true },
|
|
182
|
-
|
|
183
|
+
// Prefilled with the member's current roles, so the option list must be
|
|
184
|
+
// able to represent every assignable rank — DEFAULT_INVITE_ROLE_OPTIONS
|
|
185
|
+
// omits TenantAdmin and would silently strip it on submit. Escalation
|
|
186
|
+
// still stays server-side via findForbiddenRoleAssignment (update-member-roles.write.ts).
|
|
187
|
+
roles: { type: "multiSelect", options: OWNER_INVITE_ROLE_OPTIONS, required: true },
|
|
183
188
|
},
|
|
184
189
|
layout: {
|
|
185
190
|
// Prefill userId from rowAction; readOnly so the operator cannot retarget.
|
|
@@ -54,8 +54,10 @@ export const createWrite = defineWriteHandler({
|
|
|
54
54
|
"user rows are tenant-agnostic identity records; uniqueness check and create need no tenant filter",
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
+
// Pre-flight must match the partial bidx unique index, which only covers live rows.
|
|
57
58
|
const existing = await fetchOne<{ id: string }>(db, userTable, {
|
|
58
59
|
email: event.payload.email,
|
|
60
|
+
isDeleted: false,
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
if (existing) {
|
|
@@ -29,9 +29,13 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
29
29
|
),
|
|
30
30
|
access: { roles: access.system },
|
|
31
31
|
handler: async (query, ctx) => {
|
|
32
|
-
|
|
32
|
+
// Soft-deleted rows can now share an email with a live row (the partial
|
|
33
|
+
// bidx unique index covers live rows only), so the email arm must resolve
|
|
34
|
+
// the live identity; every email-arm caller already rejects isDeleted
|
|
35
|
+
// rows after the fetch, so this only removes rows that would be discarded anyway.
|
|
36
|
+
const where: { email: string; isDeleted: false } | { id: string } =
|
|
33
37
|
query.payload.email !== undefined
|
|
34
|
-
? { email: query.payload.email }
|
|
38
|
+
? { email: query.payload.email, isDeleted: false }
|
|
35
39
|
: { id: query.payload.id as string }; // @cast-boundary engine-payload
|
|
36
40
|
|
|
37
41
|
if (!ctx.systemDb) {
|
|
@@ -83,9 +83,30 @@ const retryWorkflow: WorkflowDefinition = defineWorkflow({
|
|
|
83
83
|
]),
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
// Counts real side-effect executions of the step AFTER the suspension point
|
|
87
|
+
// — distinct from the recorded event log, so a double-resume that somehow
|
|
88
|
+
// wrote a deduplicated event log but still re-ran the pipeline would still
|
|
89
|
+
// be caught (framework#2552/1).
|
|
90
|
+
let sideEffectCount = 0;
|
|
91
|
+
|
|
92
|
+
const idempotentResumeWorkflow: WorkflowDefinition = defineWorkflow({
|
|
93
|
+
name: "rl-idempotent-resume",
|
|
94
|
+
trigger: { kind: "event", eventType: "rl-test.idempotent-resume" },
|
|
95
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
96
|
+
steps: stepsPipeline(({ r }) => [
|
|
97
|
+
r.step.wait({ for: (ctx) => (ctx.event.payload as { forIso: string }).forIso }),
|
|
98
|
+
r.step.compute("sideEffect", () => {
|
|
99
|
+
sideEffectCount += 1;
|
|
100
|
+
return sideEffectCount;
|
|
101
|
+
}),
|
|
102
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
103
|
+
]),
|
|
104
|
+
});
|
|
105
|
+
|
|
86
106
|
const testTriggersFeature = defineFeature("workflow-runner-resume-loop-test-triggers", (r) => {
|
|
87
107
|
registerEventTrigger(r, waitWorkflow);
|
|
88
108
|
registerEventTrigger(r, retryWorkflow);
|
|
109
|
+
registerEventTrigger(r, idempotentResumeWorkflow);
|
|
89
110
|
});
|
|
90
111
|
|
|
91
112
|
const noopLogger: JobContext["log"] = {
|
|
@@ -274,4 +295,45 @@ describe("workflow-runner resume loop", () => {
|
|
|
274
295
|
rowsAfterResume.filter((row) => row["type"] === WORKFLOW_RETRY_SCHEDULED_TYPE),
|
|
275
296
|
).toHaveLength(1);
|
|
276
297
|
});
|
|
298
|
+
|
|
299
|
+
test("a second resume-due-runs tick landing before pending-projection deletes the row resumes exactly once (fw#2552/1)", async () => {
|
|
300
|
+
const runKey = crypto.randomUUID();
|
|
301
|
+
const runId = workflowRunAggregateId(idempotentResumeWorkflow.name, runKey);
|
|
302
|
+
const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
|
|
303
|
+
sideEffectCount = 0;
|
|
304
|
+
|
|
305
|
+
await fireTrigger("rl-test.idempotent-resume", { runKey, forIso: pastIso });
|
|
306
|
+
expect(await pendingRowExists(runId, 0)).toBe(true);
|
|
307
|
+
|
|
308
|
+
await runResumeDueRunsJob();
|
|
309
|
+
|
|
310
|
+
const rowsAfterFirstResume = await loadRunEvents(runId);
|
|
311
|
+
expect(rowsAfterFirstResume.map((row) => row["type"])).toEqual([
|
|
312
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
313
|
+
WORKFLOW_WAITING_TYPE,
|
|
314
|
+
WORKFLOW_RESUMED_TYPE,
|
|
315
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
316
|
+
]);
|
|
317
|
+
expect(sideEffectCount).toBe(1);
|
|
318
|
+
|
|
319
|
+
// pending-projection.ts only deletes this row on a LATER dispatcher pass
|
|
320
|
+
// reacting to WORKFLOW_RESUMED — deliberately not run here, reproducing
|
|
321
|
+
// the framework#2552/1 race window where a second resume-due-runs tick
|
|
322
|
+
// lands inside that async-delete gap and still finds the row.
|
|
323
|
+
expect(await pendingRowExists(runId, 0)).toBe(true);
|
|
324
|
+
|
|
325
|
+
await runResumeDueRunsJob();
|
|
326
|
+
|
|
327
|
+
const rowsAfterSecondDispatch = await loadRunEvents(runId);
|
|
328
|
+
expect(rowsAfterSecondDispatch).toHaveLength(4);
|
|
329
|
+
expect(
|
|
330
|
+
rowsAfterSecondDispatch.filter((row) => row["type"] === WORKFLOW_RESUMED_TYPE),
|
|
331
|
+
).toHaveLength(1);
|
|
332
|
+
expect(
|
|
333
|
+
rowsAfterSecondDispatch.filter((row) => row["type"] === WORKFLOW_RUN_COMPLETED_TYPE),
|
|
334
|
+
).toHaveLength(1);
|
|
335
|
+
// Not just the recorded event log — the step's own side effect (which a
|
|
336
|
+
// buggy second pipeline re-entry would double-run) executed exactly once.
|
|
337
|
+
expect(sideEffectCount).toBe(1);
|
|
338
|
+
});
|
|
277
339
|
});
|
|
@@ -9,9 +9,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
9
9
|
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
10
10
|
import {
|
|
11
11
|
computeDefinitionFingerprint,
|
|
12
|
+
createSystemUser,
|
|
12
13
|
defineFeature,
|
|
13
14
|
defineWorkflow,
|
|
14
15
|
stepsPipeline,
|
|
16
|
+
WORKFLOW_RESUMED_TYPE,
|
|
17
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
15
18
|
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
16
19
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
17
20
|
WORKFLOW_RUN_STARTED_TYPE,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
type WorkflowDefinition,
|
|
20
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
24
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
25
|
+
import { getConsumerState } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
22
26
|
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
23
27
|
import { workflowRunAggregateId } from "../aggregate-id";
|
|
24
28
|
import { registerEventTrigger } from "../event-trigger";
|
|
@@ -59,10 +63,56 @@ const suspendingWorkflow: WorkflowDefinition = defineWorkflow({
|
|
|
59
63
|
]),
|
|
60
64
|
});
|
|
61
65
|
|
|
66
|
+
// fw#2552/1 regression fixture — counts how often the step AFTER the wait
|
|
67
|
+
// actually runs, keyed by runKey, so a double resume-run call can prove the
|
|
68
|
+
// step executed exactly once instead of just inferring it from event counts.
|
|
69
|
+
const doubleResumeStepRuns = new Map<string, number>();
|
|
70
|
+
|
|
71
|
+
const doubleResumeWorkflow: WorkflowDefinition = defineWorkflow({
|
|
72
|
+
name: "wr-integration-double-resume",
|
|
73
|
+
trigger: { kind: "event", eventType: "wr-test.double-resume" },
|
|
74
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
75
|
+
steps: stepsPipeline(({ r }) => [
|
|
76
|
+
r.step.wait({ for: "PT1H" }),
|
|
77
|
+
r.step.compute("afterResume", (ctx) => {
|
|
78
|
+
const runKey = (ctx.event.payload as { runKey: string }).runKey;
|
|
79
|
+
doubleResumeStepRuns.set(runKey, (doubleResumeStepRuns.get(runKey) ?? 0) + 1);
|
|
80
|
+
return doubleResumeStepRuns.get(runKey);
|
|
81
|
+
}),
|
|
82
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
83
|
+
]),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// fw#2552/1 regression fixture (Aufgabe 1 bug) — times: 3 forces TWO
|
|
87
|
+
// retry-scheduled suspensions (attempt 1 and 2 both fail, attempt 3
|
|
88
|
+
// succeeds), so resuming both must each thread a distinct retryAttempt
|
|
89
|
+
// instead of the second resume being swallowed as already-resumed.
|
|
90
|
+
const doubleRetryWorkflow: WorkflowDefinition = defineWorkflow({
|
|
91
|
+
name: "wr-integration-double-retry",
|
|
92
|
+
trigger: { kind: "event", eventType: "wr-test.double-retry" },
|
|
93
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
94
|
+
steps: stepsPipeline(({ r }) => [
|
|
95
|
+
r.step.retry({
|
|
96
|
+
times: 3,
|
|
97
|
+
backoff: "linear",
|
|
98
|
+
do: [
|
|
99
|
+
r.step.compute("gate", (ctx) => {
|
|
100
|
+
const attempt = ctx.workflow?.retryAttempt ?? 1;
|
|
101
|
+
if (attempt < 3) throw new Error(`attempt-${attempt}-fails`);
|
|
102
|
+
return attempt;
|
|
103
|
+
}),
|
|
104
|
+
],
|
|
105
|
+
}),
|
|
106
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
107
|
+
]),
|
|
108
|
+
});
|
|
109
|
+
|
|
62
110
|
const testTriggersFeature = defineFeature("workflow-runner-integration-test-triggers", (r) => {
|
|
63
111
|
registerEventTrigger(r, happyWorkflow);
|
|
64
112
|
registerEventTrigger(r, failingWorkflow);
|
|
65
113
|
registerEventTrigger(r, suspendingWorkflow);
|
|
114
|
+
registerEventTrigger(r, doubleResumeWorkflow);
|
|
115
|
+
registerEventTrigger(r, doubleRetryWorkflow);
|
|
66
116
|
});
|
|
67
117
|
|
|
68
118
|
async function fireTrigger(eventType: string, payload: Record<string, unknown>): Promise<void> {
|
|
@@ -89,6 +139,17 @@ async function loadRunEvents(runId: string) {
|
|
|
89
139
|
);
|
|
90
140
|
}
|
|
91
141
|
|
|
142
|
+
// Dispatches resume-run directly (SYSTEM_ROLE-gated) instead of going
|
|
143
|
+
// through the resume-due-runs job — resume-run never checks wakeAt, so this
|
|
144
|
+
// resumes a suspended step immediately regardless of its backoff/wait length.
|
|
145
|
+
async function resumeRun(runId: string, stepIndex: number) {
|
|
146
|
+
return stack.dispatcher.write(
|
|
147
|
+
"workflow-runner:write:resume-run",
|
|
148
|
+
{ runId, stepIndex },
|
|
149
|
+
createSystemUser(admin.tenantId),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
92
153
|
describe("workflow-runner event-trigger", () => {
|
|
93
154
|
beforeAll(async () => {
|
|
94
155
|
stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
|
|
@@ -123,8 +184,28 @@ describe("workflow-runner event-trigger", () => {
|
|
|
123
184
|
test("error path: a throwing step writes run-failed with the error text, no run-completed", async () => {
|
|
124
185
|
const runKey = crypto.randomUUID();
|
|
125
186
|
const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
|
|
187
|
+
// The registrar namespaces every MSP as `<feature>:projection:<name>`.
|
|
188
|
+
const consumerName = `workflow-runner-integration-test-triggers:projection:workflow-${failingWorkflow.name}`;
|
|
189
|
+
|
|
190
|
+
await insertOne(stack.db, eventsTable, {
|
|
191
|
+
aggregateId: crypto.randomUUID(),
|
|
192
|
+
aggregateType: "wr-test-source",
|
|
193
|
+
tenantId: admin.tenantId,
|
|
194
|
+
version: 1,
|
|
195
|
+
type: "wr-test.failure",
|
|
196
|
+
eventVersion: 1,
|
|
197
|
+
payload: { runKey },
|
|
198
|
+
metadata: { userId: admin.id },
|
|
199
|
+
createdBy: admin.id,
|
|
200
|
+
});
|
|
126
201
|
|
|
127
|
-
|
|
202
|
+
// fw#2482/2: run-failed is durably recorded, so the dispatcher must count
|
|
203
|
+
// the trigger event as delivered — not as a poison event to keep
|
|
204
|
+
// retrying (which would re-run every side-effect step until dead-letter).
|
|
205
|
+
const firstPass = await stack.eventDispatcher?.runOnce();
|
|
206
|
+
expect(firstPass?.byConsumer[consumerName]).toEqual({ processed: 1, failed: 0 });
|
|
207
|
+
const stateAfterFirstPass = await getConsumerState(stack.db, consumerName);
|
|
208
|
+
expect(stateAfterFirstPass?.status).toBe("idle");
|
|
128
209
|
|
|
129
210
|
const rows = await loadRunEvents(runId);
|
|
130
211
|
expect(rows.map((row) => row["type"])).toEqual([
|
|
@@ -133,6 +214,16 @@ describe("workflow-runner event-trigger", () => {
|
|
|
133
214
|
]);
|
|
134
215
|
expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
|
|
135
216
|
expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
|
|
217
|
+
|
|
218
|
+
// Second pass has nothing left to redeliver — the same trigger event
|
|
219
|
+
// never spawns a second run-started for this runId. `processed` counts
|
|
220
|
+
// every event the cursor walks past (the run-started/run-failed rows the
|
|
221
|
+
// first pass appended included), so the redelivery invariant is asserted
|
|
222
|
+
// on the run stream itself plus a still-clean failure count.
|
|
223
|
+
const secondPass = await stack.eventDispatcher?.runOnce();
|
|
224
|
+
expect(secondPass?.byConsumer[consumerName]?.failed).toBe(0);
|
|
225
|
+
expect(await loadRunEvents(runId)).toHaveLength(2);
|
|
226
|
+
expect(await getConsumerState(stack.db, consumerName)).toMatchObject({ status: "idle" });
|
|
136
227
|
});
|
|
137
228
|
|
|
138
229
|
test("suspension: a wait step suspends the run instead of failing it — resumable by framework#2513 Phase 2, no run-completed yet", async () => {
|
|
@@ -147,4 +238,57 @@ describe("workflow-runner event-trigger", () => {
|
|
|
147
238
|
WORKFLOW_WAITING_TYPE,
|
|
148
239
|
]);
|
|
149
240
|
});
|
|
241
|
+
|
|
242
|
+
test("fw#2552/1: a second resume-run for the same (runId, stepIndex) before pending-projection deletes the row reports already-resumed and never re-runs the step", async () => {
|
|
243
|
+
const runKey = crypto.randomUUID();
|
|
244
|
+
const runId = workflowRunAggregateId(doubleResumeWorkflow.name, runKey);
|
|
245
|
+
|
|
246
|
+
await fireTrigger("wr-test.double-resume", { runKey });
|
|
247
|
+
// pending-projection observes the suspension on a pass after it exists.
|
|
248
|
+
await stack.eventDispatcher?.runOnce();
|
|
249
|
+
|
|
250
|
+
const first = await resumeRun(runId, 0);
|
|
251
|
+
// No runOnce() in between — pending-projection has not yet deleted the
|
|
252
|
+
// pending row, so the second call still finds it (the race window).
|
|
253
|
+
const second = await resumeRun(runId, 0);
|
|
254
|
+
|
|
255
|
+
expect(first).toMatchObject({ isSuccess: true, data: { outcome: "completed" } });
|
|
256
|
+
expect(second).toMatchObject({ isSuccess: true, data: { outcome: "already-resumed" } });
|
|
257
|
+
expect(doubleResumeStepRuns.get(runKey)).toBe(1);
|
|
258
|
+
|
|
259
|
+
const rows = await loadRunEvents(runId);
|
|
260
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
261
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
262
|
+
WORKFLOW_WAITING_TYPE,
|
|
263
|
+
WORKFLOW_RESUMED_TYPE,
|
|
264
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("fw#2552/1 regression: a step retried twice gets both retry attempts, the second resume is not swallowed as already-resumed", async () => {
|
|
269
|
+
const runKey = crypto.randomUUID();
|
|
270
|
+
const runId = workflowRunAggregateId(doubleRetryWorkflow.name, runKey);
|
|
271
|
+
|
|
272
|
+
await fireTrigger("wr-test.double-retry", { runKey });
|
|
273
|
+
await stack.eventDispatcher?.runOnce(); // pending-projection materialises attempt 1's row
|
|
274
|
+
|
|
275
|
+
const firstResume = await resumeRun(runId, 0);
|
|
276
|
+
expect(firstResume).toMatchObject({ isSuccess: true, data: { outcome: "suspended" } });
|
|
277
|
+
|
|
278
|
+
await stack.eventDispatcher?.runOnce(); // pending-projection materialises attempt 2's row
|
|
279
|
+
|
|
280
|
+
const secondResume = await resumeRun(runId, 0);
|
|
281
|
+
expect(secondResume).toMatchObject({ isSuccess: true, data: { outcome: "completed" } });
|
|
282
|
+
|
|
283
|
+
const rows = await loadRunEvents(runId);
|
|
284
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
285
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
286
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
287
|
+
WORKFLOW_RESUMED_TYPE,
|
|
288
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
289
|
+
WORKFLOW_RESUMED_TYPE,
|
|
290
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
291
|
+
]);
|
|
292
|
+
expect(rows.filter((row) => row["type"] === WORKFLOW_RESUMED_TYPE)).toHaveLength(2);
|
|
293
|
+
});
|
|
150
294
|
});
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
|
|
8
8
|
// plus the synchronous portion of the pipeline land atomically. Any throw
|
|
9
9
|
// from startAndRunWorkflow is recorded as `workflow.run-failed` and
|
|
10
|
-
//
|
|
10
|
+
// swallowed — the failure is already durable, so the dispatcher advances
|
|
11
|
+
// past the trigger event instead of redelivering it.
|
|
11
12
|
|
|
12
13
|
import type {
|
|
13
14
|
FeatureRegistrar,
|
|
@@ -43,6 +44,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
43
44
|
|
|
44
45
|
r.multiStreamProjection({
|
|
45
46
|
name: `workflow-${workflow.name}`,
|
|
47
|
+
// Mounting an event-triggered workflow into an existing app must not
|
|
48
|
+
// replay the historical log and fire every side-effect step retroactively.
|
|
49
|
+
startFrom: "now",
|
|
46
50
|
apply: {
|
|
47
51
|
[eventType]: async (event, _tx, ctx) => {
|
|
48
52
|
// skip: unreachable — the guard above already established this, but the
|
|
@@ -97,7 +101,10 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
97
101
|
type: WORKFLOW_RUN_FAILED_TYPE,
|
|
98
102
|
payload: failedPayload,
|
|
99
103
|
});
|
|
100
|
-
|
|
104
|
+
// No rethrow: the failure is now durably recorded as
|
|
105
|
+
// workflow.run-failed. Rethrowing would make the dispatcher
|
|
106
|
+
// redeliver the same trigger event up to maxAttempts, re-running
|
|
107
|
+
// every side-effect step and eventually killing the consumer.
|
|
101
108
|
}
|
|
102
109
|
},
|
|
103
110
|
},
|
|
@@ -99,13 +99,91 @@ function checkQ7Fingerprint(
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
type ResolvedWorkflow =
|
|
103
|
+
| { readonly workflow: WorkflowDefinition; readonly failure: null }
|
|
104
|
+
| { readonly workflow: null; readonly failure: WorkflowRunFailedPayload };
|
|
105
|
+
|
|
106
|
+
// Both preconditions fail the run the same way, so they resolve together: the
|
|
107
|
+
// workflow must still be registered AND its definition must still match the
|
|
108
|
+
// fingerprint the run was started against.
|
|
109
|
+
function resolveRunnableWorkflow(
|
|
110
|
+
workflowName: string,
|
|
111
|
+
runId: string,
|
|
112
|
+
stepIndex: number,
|
|
113
|
+
storedFingerprint: string | null,
|
|
114
|
+
): ResolvedWorkflow {
|
|
115
|
+
const workflow = getWorkflow(workflowName);
|
|
116
|
+
if (!workflow) {
|
|
117
|
+
return {
|
|
118
|
+
workflow: null,
|
|
119
|
+
failure: {
|
|
120
|
+
workflowName,
|
|
121
|
+
stepIndex,
|
|
122
|
+
error: `Workflow "${workflowName}" is not registered — cannot resume run ${runId}.`,
|
|
123
|
+
reason: "workflow_definition_changed",
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const fingerprintFailure = checkQ7Fingerprint(
|
|
128
|
+
workflow,
|
|
129
|
+
workflowName,
|
|
130
|
+
runId,
|
|
131
|
+
stepIndex,
|
|
132
|
+
storedFingerprint,
|
|
133
|
+
);
|
|
134
|
+
return fingerprintFailure
|
|
135
|
+
? { workflow: null, failure: fingerprintFailure }
|
|
136
|
+
: { workflow, failure: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readResumedPayload(payload: unknown): {
|
|
140
|
+
readonly stepIndex: number | undefined;
|
|
141
|
+
readonly retryAttempt: number | undefined;
|
|
142
|
+
} {
|
|
143
|
+
if (typeof payload !== "object" || payload === null) {
|
|
144
|
+
return { stepIndex: undefined, retryAttempt: undefined };
|
|
145
|
+
}
|
|
146
|
+
const stepIndex =
|
|
147
|
+
"stepIndex" in payload && typeof payload.stepIndex === "number" ? payload.stepIndex : undefined;
|
|
148
|
+
const retryAttempt =
|
|
149
|
+
"retryAttempt" in payload && typeof payload.retryAttempt === "number"
|
|
150
|
+
? payload.retryAttempt
|
|
151
|
+
: undefined;
|
|
152
|
+
return { stepIndex, retryAttempt };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The workflow_run_pending row is only deleted asynchronously by
|
|
156
|
+
// pending-projection, so a resume-due-runs tick inside that window finds the
|
|
157
|
+
// row again and the append-claim no longer conflicts — the event stream is the
|
|
158
|
+
// authoritative idempotency source.
|
|
159
|
+
//
|
|
160
|
+
// retryAttempt must match too, not just stepIndex: a retry suspension resumes
|
|
161
|
+
// the SAME stepIndex on every attempt, so matching on stepIndex alone would
|
|
162
|
+
// mistake attempt 1's WORKFLOW_RESUMED for attempt 2's and skip the second
|
|
163
|
+
// retry entirely.
|
|
164
|
+
function isRunAlreadySettled(
|
|
165
|
+
runEvents: Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
|
|
166
|
+
stepIndex: number,
|
|
167
|
+
expectedRetryAttempt: number | undefined,
|
|
168
|
+
): boolean {
|
|
169
|
+
return runEvents.some((e) => {
|
|
170
|
+
if (e.type === WORKFLOW_RUN_COMPLETED_TYPE || e.type === WORKFLOW_RUN_FAILED_TYPE) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (e.type !== WORKFLOW_RESUMED_TYPE) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const resumed = readResumedPayload(e.payload);
|
|
177
|
+
return resumed.stepIndex === stepIndex && resumed.retryAttempt === expectedRetryAttempt;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function recoverTriggerEvent(
|
|
182
|
+
runEvents: Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
|
|
104
183
|
runId: string,
|
|
105
184
|
user: WriteEvent["user"],
|
|
106
|
-
):
|
|
107
|
-
const
|
|
108
|
-
const started = startedEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
|
|
185
|
+
): WriteEvent {
|
|
186
|
+
const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
|
|
109
187
|
if (!started) {
|
|
110
188
|
throw new InternalError({
|
|
111
189
|
message: `workflow-runner:write:resume-run: run ${runId} has no ${WORKFLOW_RUN_STARTED_TYPE} event — cannot recover its trigger event.`,
|
|
@@ -177,29 +255,23 @@ export const resumeRunHandler: WriteHandlerDef = {
|
|
|
177
255
|
}
|
|
178
256
|
|
|
179
257
|
const { workflowName } = pending;
|
|
180
|
-
const
|
|
181
|
-
if (!workflow) {
|
|
182
|
-
const failedPayload: WorkflowRunFailedPayload = {
|
|
183
|
-
workflowName,
|
|
184
|
-
stepIndex,
|
|
185
|
-
error: `Workflow "${workflowName}" is not registered — cannot resume run ${runId}.`,
|
|
186
|
-
reason: "workflow_definition_changed",
|
|
187
|
-
};
|
|
188
|
-
await appendRunFailed(ctx, runId, failedPayload);
|
|
189
|
-
return { isSuccess: true, data: { outcome: "failed" as const } };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const fingerprintFailure = checkQ7Fingerprint(
|
|
193
|
-
workflow,
|
|
258
|
+
const resolved = resolveRunnableWorkflow(
|
|
194
259
|
workflowName,
|
|
195
260
|
runId,
|
|
196
261
|
stepIndex,
|
|
197
262
|
pending.definitionFingerprint,
|
|
198
263
|
);
|
|
199
|
-
if (
|
|
200
|
-
await appendRunFailed(ctx, runId,
|
|
264
|
+
if (resolved.failure !== null) {
|
|
265
|
+
await appendRunFailed(ctx, runId, resolved.failure);
|
|
201
266
|
return { isSuccess: true, data: { outcome: "failed" as const } };
|
|
202
267
|
}
|
|
268
|
+
const workflow = resolved.workflow;
|
|
269
|
+
|
|
270
|
+
const runEvents = await ctx.loadAggregate(runId);
|
|
271
|
+
|
|
272
|
+
if (isRunAlreadySettled(runEvents, stepIndex, pending.retryAttempt ?? undefined)) {
|
|
273
|
+
return { isSuccess: true, data: { outcome: "already-resumed" as const } };
|
|
274
|
+
}
|
|
203
275
|
|
|
204
276
|
const claim = await ctx.tryAppendEvent({
|
|
205
277
|
aggregateId: runId,
|
|
@@ -217,7 +289,7 @@ export const resumeRunHandler: WriteHandlerDef = {
|
|
|
217
289
|
return { isSuccess: true, data: { outcome: "already-resumed" as const } };
|
|
218
290
|
}
|
|
219
291
|
|
|
220
|
-
const triggerEvent =
|
|
292
|
+
const triggerEvent = recoverTriggerEvent(runEvents, runId, event.user);
|
|
221
293
|
|
|
222
294
|
const resumeFrom =
|
|
223
295
|
pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
|