@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.221.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/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +6 -1
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/feature.ts +7 -4
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +2 -2
- package/src/tenant/handlers/update-member-roles.write.ts +1 -1
- package/src/tenant/i18n.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +18 -5
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchOne,
|
|
1
|
+
import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { decryptStoredPii } from "../../shared";
|
|
4
4
|
import { userTable } from "../../user";
|
|
@@ -26,14 +26,18 @@ export function createAddNoteHandler(
|
|
|
26
26
|
// read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
|
|
27
27
|
// Bun.SQL poisons the whole tx after any error inside it, even one that's
|
|
28
28
|
// caught — a bare try/catch here would take the note write down with it.
|
|
29
|
-
authorName = await
|
|
29
|
+
authorName = await runInSavepointIfSupported(ctx.db.raw, async (sp) => {
|
|
30
30
|
const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
|
|
31
31
|
id: event.user.id,
|
|
32
32
|
});
|
|
33
33
|
if (!userRow?.displayName) return null;
|
|
34
34
|
return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
|
|
35
35
|
});
|
|
36
|
-
} catch {
|
|
36
|
+
} catch (e) {
|
|
37
|
+
ctx.log?.warn("notes-history: authorName lookup failed", {
|
|
38
|
+
error: e,
|
|
39
|
+
userId: event.user.id,
|
|
40
|
+
});
|
|
37
41
|
authorName = null;
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -26,11 +26,19 @@ type NoteRow = {
|
|
|
26
26
|
readonly entityType: string;
|
|
27
27
|
readonly entityId: string;
|
|
28
28
|
readonly authorId: string;
|
|
29
|
-
// Null for pre-authorName history
|
|
29
|
+
// Null for pre-authorName history; shredded authors arrive as PII_ERASED_SENTINEL.
|
|
30
30
|
readonly authorName: string | null;
|
|
31
31
|
readonly body: string;
|
|
32
32
|
readonly insertedAt: string;
|
|
33
33
|
};
|
|
34
|
+
|
|
35
|
+
// Matches @cosmicdrift/kumiko-framework/crypto PII_ERASED_SENTINEL — cannot
|
|
36
|
+
// import that module from @runtime client (runtime-isolation).
|
|
37
|
+
const PII_ERASED_SENTINEL = "[[erased]]";
|
|
38
|
+
|
|
39
|
+
function displayAuthorName(authorName: string | null, unknownLabel: string): string {
|
|
40
|
+
return authorName && authorName !== PII_ERASED_SENTINEL ? authorName : unknownLabel;
|
|
41
|
+
}
|
|
34
42
|
type NoteListResponse = { readonly rows: readonly NoteRow[] };
|
|
35
43
|
|
|
36
44
|
export function NotesSection({
|
|
@@ -144,7 +152,7 @@ export function NotesSection({
|
|
|
144
152
|
<Text testId={`notes-section-row-${n.id}-body`}>{n.body}</Text>
|
|
145
153
|
<Text variant="small" testId={`notes-section-row-${n.id}-meta`}>
|
|
146
154
|
{t("notesHistory.section.meta", {
|
|
147
|
-
author: n.authorName
|
|
155
|
+
author: displayAuthorName(n.authorName, t("notesHistory.section.authorUnknown")),
|
|
148
156
|
date: formatWhen(n.insertedAt),
|
|
149
157
|
})}
|
|
150
158
|
</Text>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSecretsFeature } from "../feature";
|
|
3
|
+
|
|
4
|
+
describe("createSecretsFeature access/roles precedence", () => {
|
|
5
|
+
test("roles-only adopts the host role vocabulary", () => {
|
|
6
|
+
expect(() => createSecretsFeature({ roles: ["Admin"] })).not.toThrow();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("access-only is accepted", () => {
|
|
10
|
+
expect(() => createSecretsFeature({ access: { openToAll: true } })).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("access + roles together fail fast", () => {
|
|
14
|
+
expect(() => createSecretsFeature({ access: { openToAll: true }, roles: ["Admin"] })).toThrow(
|
|
15
|
+
/either `access` or `roles`/,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -77,12 +77,22 @@ describe("secrets — roles option (#2296)", () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
test("the framework-default TenantAdmin role is denied once `roles` overrides it", async () => {
|
|
80
|
-
const
|
|
80
|
+
const deniedSet = await stack.http.writeErr(
|
|
81
81
|
"secrets:write:set",
|
|
82
82
|
{ key: "api.key.denied", value: "x" },
|
|
83
83
|
admin,
|
|
84
84
|
);
|
|
85
|
-
expect(
|
|
85
|
+
expect(deniedSet.code).toBe("access_denied");
|
|
86
|
+
|
|
87
|
+
const deniedList = await stack.http.queryErr("secrets:query:list", {}, admin);
|
|
88
|
+
expect(deniedList.code).toBe("access_denied");
|
|
89
|
+
|
|
90
|
+
const deniedDelete = await stack.http.writeErr(
|
|
91
|
+
"secrets:write:delete",
|
|
92
|
+
{ key: "api.key.denied" },
|
|
93
|
+
admin,
|
|
94
|
+
);
|
|
95
|
+
expect(deniedDelete.code).toBe("access_denied");
|
|
86
96
|
});
|
|
87
97
|
});
|
|
88
98
|
|
package/src/secrets/feature.ts
CHANGED
|
@@ -96,14 +96,21 @@ export type SecretsFeatureOptions = {
|
|
|
96
96
|
* user manage secrets — note this is a much larger blast radius than the
|
|
97
97
|
* default: tenant credentials (API keys, tokens) become writable/deletable,
|
|
98
98
|
* and their previews/hints listable, by every tenant member, not just
|
|
99
|
-
* admins.
|
|
99
|
+
* admins. Mutually exclusive with `roles`. The `rotate` job is unaffected —
|
|
100
100
|
* it's a manual, cross-tenant ops path, not per-tenant RBAC. */
|
|
101
101
|
readonly access?: AccessRule;
|
|
102
|
-
/** Shorthand for { access: { roles } }.
|
|
102
|
+
/** Shorthand for { access: { roles } }. Mutually exclusive with `access`. */
|
|
103
103
|
readonly roles?: readonly string[];
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
function resolveSecretsAccess(opts: SecretsFeatureOptions): AccessRule {
|
|
107
|
+
if (opts.access !== undefined && opts.roles !== undefined) {
|
|
108
|
+
throw new InternalError({
|
|
109
|
+
message:
|
|
110
|
+
"createSecretsFeature: pass either `access` or `roles`, not both — " +
|
|
111
|
+
"`access` previously silently discarded `roles`, which looks like a refinement.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
107
114
|
if (opts.access !== undefined) return opts.access;
|
|
108
115
|
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
109
116
|
return DEFAULT_SECRETS_ACCESS;
|
package/src/shared/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { isWithinGracePeriod } from "./grace-period";
|
|
|
16
16
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
17
17
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
18
18
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
19
|
+
export { sessionField } from "./session-field";
|
|
19
20
|
export { sessionLocaleField } from "./session-locale-field";
|
|
20
21
|
export { sessionTimezoneField } from "./session-timezone-field";
|
|
21
22
|
export type { SystemQueryFn } from "./system-query";
|
|
@@ -44,7 +44,7 @@ describe("mapWithConcurrency", () => {
|
|
|
44
44
|
// `mapWithConcurrency` call already settled on the first one.
|
|
45
45
|
test("a second rejection after the first doesn't surface as an unhandled rejection", async () => {
|
|
46
46
|
const unhandled: unknown[] = [];
|
|
47
|
-
const onUnhandledRejection = (reason: unknown): void => {
|
|
47
|
+
const onUnhandledRejection = (reason: unknown, _promise: Promise<unknown>): void => {
|
|
48
48
|
unhandled.push(reason);
|
|
49
49
|
};
|
|
50
50
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
@@ -68,7 +68,7 @@ describe("mapWithConcurrency", () => {
|
|
|
68
68
|
// for a real unhandled rejection (if any) to fire.
|
|
69
69
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
70
70
|
} finally {
|
|
71
|
-
process.off("unhandledRejection"
|
|
71
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
72
72
|
}
|
|
73
73
|
expect(unhandled).toEqual([]);
|
|
74
74
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
/** Omit null/undefined session keys so callers can spread without wiping defaults. */
|
|
4
|
+
export function sessionField<K extends "locale" | "timezone">(
|
|
5
|
+
key: K,
|
|
6
|
+
value: string | null | undefined,
|
|
7
|
+
): Partial<Pick<SessionUser, K>> {
|
|
8
|
+
if (value === null || value === undefined) return {};
|
|
9
|
+
return { [key]: value } as Pick<SessionUser, K>;
|
|
10
|
+
}
|
|
@@ -6,6 +6,15 @@ describe("sessionLocaleField", () => {
|
|
|
6
6
|
expect(sessionLocaleField("de-DE")).toEqual({ locale: "de-DE" });
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
test("canonicalizes primary subtag", () => {
|
|
10
|
+
expect(sessionLocaleField("DE")).toEqual({ locale: "de" });
|
|
11
|
+
expect(sessionLocaleField("DE-at")).toEqual({ locale: "de-at" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("rejects malformed locale tags", () => {
|
|
15
|
+
expect(sessionLocaleField('"><img sr')).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
|
|
9
18
|
test("null locale → empty object", () => {
|
|
10
19
|
expect(sessionLocaleField(null)).toEqual({});
|
|
11
20
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
|
+
import { sessionField } from "./session-field";
|
|
2
4
|
|
|
3
5
|
export function sessionLocaleField(
|
|
4
6
|
locale: string | null | undefined,
|
|
5
7
|
): Pick<SessionUser, "locale"> | Record<string, never> {
|
|
6
|
-
|
|
8
|
+
if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
|
|
9
|
+
return sessionField("locale", canonicalizeLocaleTag(locale));
|
|
7
10
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { sessionField } from "./session-field";
|
|
2
3
|
|
|
3
4
|
export function sessionTimezoneField(
|
|
4
5
|
timezone: string | null | undefined,
|
|
5
6
|
): Pick<SessionUser, "timezone"> | Record<string, never> {
|
|
6
|
-
return timezone
|
|
7
|
+
return sessionField("timezone", timezone);
|
|
7
8
|
}
|
|
@@ -163,17 +163,11 @@ export function createSubscriptionStripeFeature(
|
|
|
163
163
|
|
|
164
164
|
r.translations({
|
|
165
165
|
keys: {
|
|
166
|
-
"subscription-stripe.api-key": {
|
|
167
|
-
"subscription-stripe.webhook-secret": {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
},
|
|
171
|
-
"subscription-stripe.billing-live": {
|
|
172
|
-
de: "Stripe Billing live",
|
|
173
|
-
en: "Stripe Billing Live",
|
|
174
|
-
},
|
|
175
|
-
"subscription-stripe.settings": { de: "Stripe", en: "Stripe" },
|
|
176
|
-
"screen:subscription-stripe-system.title": { de: "Stripe", en: "Stripe" },
|
|
166
|
+
"subscription-stripe.api-key": { en: "Stripe API Key" },
|
|
167
|
+
"subscription-stripe.webhook-secret": { en: "Stripe Webhook Secret" },
|
|
168
|
+
"subscription-stripe.billing-live": { en: "Stripe Billing Live" },
|
|
169
|
+
"subscription-stripe.settings": { en: "Stripe" },
|
|
170
|
+
"screen:subscription-stripe-system.title": { en: "Stripe" },
|
|
177
171
|
},
|
|
178
172
|
});
|
|
179
173
|
|
package/src/tags/feature.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type AccessRule,
|
|
23
23
|
defineEntityDetailHandler,
|
|
24
24
|
defineEntityListHandler,
|
|
25
|
+
defineEntityUpdateHandler,
|
|
25
26
|
defineFeature,
|
|
26
27
|
type FeatureRegistrar,
|
|
27
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -49,7 +50,7 @@ function registerTags(
|
|
|
49
50
|
toggleable: TagsToggleable | undefined,
|
|
50
51
|
): void {
|
|
51
52
|
r.describe(
|
|
52
|
-
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN.
|
|
53
|
+
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
53
54
|
);
|
|
54
55
|
r.uiHints({
|
|
55
56
|
displayLabel: "Tags",
|
|
@@ -70,10 +71,12 @@ function registerTags(
|
|
|
70
71
|
r.writeHandler(createAssignTagHandler(access));
|
|
71
72
|
r.writeHandler(createRemoveTagHandler(access));
|
|
72
73
|
|
|
73
|
-
// Convention aliases for entityList/entityEdit —
|
|
74
|
-
//
|
|
74
|
+
// Convention aliases for entityList/entityEdit — create stays flat-payload
|
|
75
|
+
// (payloadMode=values); update must accept the {id,version,changes} envelope
|
|
76
|
+
// entityEdit sends (legacy update-tag stays for TagManager). delete stays
|
|
77
|
+
// legacy so assignment cascade is preserved (convention delete would orphan).
|
|
75
78
|
r.writeHandler(createCreateTagHandler(access, "tag:create"));
|
|
76
|
-
r.writeHandler(
|
|
79
|
+
r.writeHandler(defineEntityUpdateHandler("tag", tagEntity, { access }));
|
|
77
80
|
r.writeHandler(createDeleteTagHandler(access, "tag:delete"));
|
|
78
81
|
|
|
79
82
|
r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
|
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
15
|
import { TEXT_BLOCK_KIND } from "../constants";
|
|
16
16
|
import { createTemplateResolverFeature } from "../feature";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type LegalContentBlock,
|
|
19
|
+
type SeedSystemTemplateOptions,
|
|
20
|
+
seedLegalContentFromJson,
|
|
21
|
+
seedSystemTemplate,
|
|
22
|
+
} from "../seeding";
|
|
18
23
|
import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
|
|
19
24
|
|
|
20
25
|
// Pins seedLegalContentFromJson: seeds into SYSTEM_TENANT_ID by default and
|
|
@@ -44,6 +49,15 @@ function read(slug: string, locale: string) {
|
|
|
44
49
|
});
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
function readSystemTemplate(slug: string, locale: string, kind: SeedSystemTemplateOptions["kind"]) {
|
|
53
|
+
return fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
54
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
55
|
+
slug,
|
|
56
|
+
kind,
|
|
57
|
+
locale,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
describe("seedLegalContentFromJson", () => {
|
|
48
62
|
test("seeds all blocks into SYSTEM_TENANT_ID as SYSTEM_USER", async () => {
|
|
49
63
|
const blocks: LegalContentBlock[] = [
|
|
@@ -82,6 +96,30 @@ describe("seedLegalContentFromJson", () => {
|
|
|
82
96
|
expect(row.modifiedById).toBe(SYSTEM_USER_ID);
|
|
83
97
|
});
|
|
84
98
|
|
|
99
|
+
test("re-seed of a healthy row (existing events) keeps id and appends, does not delete+recreate", async () => {
|
|
100
|
+
await seedLegalContentFromJson(db, [
|
|
101
|
+
{ slug: "healthy-block", locale: "de", title: "Titel", content: "v1" },
|
|
102
|
+
]);
|
|
103
|
+
const before = await read("healthy-block", "de");
|
|
104
|
+
expect(before).not.toBeNull();
|
|
105
|
+
const eventsBefore = await selectMany(db, eventsTable, { aggregateId: String(before!.id) });
|
|
106
|
+
expect(eventsBefore.map((e) => e.type)).toEqual(["template-resource.created"]);
|
|
107
|
+
|
|
108
|
+
await seedLegalContentFromJson(db, [
|
|
109
|
+
{ slug: "healthy-block", locale: "de", title: "Titel", content: "v2" },
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const after = await read("healthy-block", "de");
|
|
113
|
+
expect(after).not.toBeNull();
|
|
114
|
+
expect(after!.id).toBe(before!.id);
|
|
115
|
+
expect(after).toMatchObject({ content: "v2" });
|
|
116
|
+
|
|
117
|
+
const eventsAfter = await selectMany(db, eventsTable, { aggregateId: String(before!.id) });
|
|
118
|
+
expect(eventsAfter.filter((e) => e.type === "template-resource.created")).toHaveLength(1);
|
|
119
|
+
expect(eventsAfter.filter((e) => e.type === "template-resource.updated")).toHaveLength(1);
|
|
120
|
+
expect(eventsAfter).toHaveLength(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
85
123
|
test("orphan projection (row without events) is recreated on ifExists:update", async () => {
|
|
86
124
|
await seedLegalContentFromJson(db, [
|
|
87
125
|
{ slug: "orphan-privacy", locale: "de", title: "DS", content: "old" },
|
|
@@ -108,6 +146,31 @@ describe("seedLegalContentFromJson", () => {
|
|
|
108
146
|
expect(events.some((e) => e.type === "template-resource.created")).toBe(true);
|
|
109
147
|
});
|
|
110
148
|
|
|
149
|
+
test("orphan projection (row without events) is recreated on ifExists:update — seedSystemTemplate path", async () => {
|
|
150
|
+
const opts: SeedSystemTemplateOptions = {
|
|
151
|
+
slug: "orphan-notification",
|
|
152
|
+
kind: "notification",
|
|
153
|
+
locale: "de",
|
|
154
|
+
content: "old",
|
|
155
|
+
contentFormat: "plain",
|
|
156
|
+
ifExists: "update",
|
|
157
|
+
};
|
|
158
|
+
await seedSystemTemplate(db, opts);
|
|
159
|
+
const before = await readSystemTemplate(opts.slug, opts.locale, opts.kind);
|
|
160
|
+
expect(before).not.toBeNull();
|
|
161
|
+
|
|
162
|
+
// Same desync as prod: projection left behind, event stream empty.
|
|
163
|
+
await deleteMany(db, eventsTable, { aggregateId: String(before!.id) });
|
|
164
|
+
expect(await readSystemTemplate(opts.slug, opts.locale, opts.kind)).not.toBeNull();
|
|
165
|
+
|
|
166
|
+
await seedSystemTemplate(db, { ...opts, content: "new from template" });
|
|
167
|
+
const after = await readSystemTemplate(opts.slug, opts.locale, opts.kind);
|
|
168
|
+
expect(after).toMatchObject({ content: "new from template" });
|
|
169
|
+
const events2 = await selectMany(db, eventsTable, { aggregateId: String(after!.id) });
|
|
170
|
+
expect(events2.length).toBeGreaterThan(0);
|
|
171
|
+
expect(events2.some((e) => e.type === "template-resource.created")).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
111
174
|
test("identical re-seed is a no-op (no update event, version/modifiedAt stable)", async () => {
|
|
112
175
|
const blocks: LegalContentBlock[] = [
|
|
113
176
|
{ slug: "terms", locale: "de", title: "AGB", content: "stable" },
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type DbConnection,
|
|
16
16
|
deleteMany,
|
|
17
17
|
type EntityTableMeta,
|
|
18
|
+
type TenantDb,
|
|
18
19
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
20
|
import {
|
|
20
21
|
createSystemUser,
|
|
@@ -22,18 +23,19 @@ import {
|
|
|
22
23
|
SYSTEM_TENANT_ID,
|
|
23
24
|
type TenantId,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
getStreamVersion,
|
|
28
|
+
getUnscopedStreamMaxVersionForSeed,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
30
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
27
31
|
import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants";
|
|
28
32
|
import { executor } from "./handlers/shared";
|
|
29
33
|
import { type TemplateResourceRow, templateResourcesTable } from "./table";
|
|
30
34
|
|
|
31
|
-
/**
|
|
32
|
-
* projection, stream currentVersion=0). Drop the orphan and treat as create
|
|
33
|
-
* so boot seeds (legal ifExists:update) survive desync. Stream version is the
|
|
34
|
-
* optimistic-lock source of truth when events exist. */
|
|
35
|
+
/** Drop projection orphans (no events anywhere) so boot seeds can recreate. */
|
|
35
36
|
async function resolveExistingForEventStoreSeed(
|
|
36
37
|
db: DbConnection,
|
|
38
|
+
tdb: TenantDb,
|
|
37
39
|
existingRow: { readonly id: string | number; readonly version: number } | null,
|
|
38
40
|
tenantId: TenantId,
|
|
39
41
|
): Promise<{ id: string; version: number } | null> {
|
|
@@ -41,7 +43,17 @@ async function resolveExistingForEventStoreSeed(
|
|
|
41
43
|
const id = String(existingRow.id);
|
|
42
44
|
const streamVersion = await getStreamVersion(db, id, tenantId);
|
|
43
45
|
if (streamVersion === 0) {
|
|
44
|
-
|
|
46
|
+
// Tenant-scoped 0 can still hide a live stream under another tenant —
|
|
47
|
+
// only delete when unscoped is also empty (true orphan).
|
|
48
|
+
const unscoped = await getUnscopedStreamMaxVersionForSeed(db, id);
|
|
49
|
+
if (unscoped !== 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`seed orphan check: projection ${id} has no events for tenant ${tenantId} but unscoped stream version is ${unscoped} — refusing delete/recreate to avoid wiping a live stream`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
// @cast-boundary orphan projection cleanup: empty stream, no executor verb;
|
|
55
|
+
// plain table ident trips guard-direct-entity-writes (infra EXCLUDE follow-up).
|
|
56
|
+
await deleteMany(tdb, templateResourcesTable as EntityTableMeta, { id, tenantId });
|
|
45
57
|
return null;
|
|
46
58
|
}
|
|
47
59
|
return { id, version: streamVersion };
|
|
@@ -76,6 +88,7 @@ export async function seedSystemTemplate(
|
|
|
76
88
|
});
|
|
77
89
|
const existing = await resolveExistingForEventStoreSeed(
|
|
78
90
|
db,
|
|
91
|
+
tdb,
|
|
79
92
|
existingRow ? { id: existingRow.id, version: existingRow.version } : null,
|
|
80
93
|
tenantId,
|
|
81
94
|
);
|
|
@@ -105,6 +118,34 @@ export async function seedSystemTemplate(
|
|
|
105
118
|
create: async () => {
|
|
106
119
|
const result = await executor.create({ ...rowFields, tenantId }, by, tdb);
|
|
107
120
|
if (!result.isSuccess) {
|
|
121
|
+
// Parallel boot pods can both delete the orphan and race on create —
|
|
122
|
+
// unique on (tenantId, slug, kind, locale) → resolve winner and update.
|
|
123
|
+
if (result.error?.code === "unique_violation") {
|
|
124
|
+
const again = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
125
|
+
tenantId,
|
|
126
|
+
slug: opts.slug,
|
|
127
|
+
kind: opts.kind,
|
|
128
|
+
locale: opts.locale,
|
|
129
|
+
});
|
|
130
|
+
const resolved = await resolveExistingForEventStoreSeed(
|
|
131
|
+
db,
|
|
132
|
+
tdb,
|
|
133
|
+
again ? { id: again.id, version: again.version } : null,
|
|
134
|
+
tenantId,
|
|
135
|
+
);
|
|
136
|
+
if (resolved == null) {
|
|
137
|
+
throw new Error(`seedSystemTemplate create unique_violation but row missing`);
|
|
138
|
+
}
|
|
139
|
+
const upd = await executor.update(
|
|
140
|
+
{ id: resolved.id, version: resolved.version, changes: rowFields },
|
|
141
|
+
by,
|
|
142
|
+
tdb,
|
|
143
|
+
);
|
|
144
|
+
if (!upd.isSuccess) {
|
|
145
|
+
throw new Error(`seedSystemTemplate race-update failed: ${JSON.stringify(upd)}`);
|
|
146
|
+
}
|
|
147
|
+
return { id: resolved.id };
|
|
148
|
+
}
|
|
108
149
|
throw new Error(`seedSystemTemplate create failed: ${JSON.stringify(result)}`);
|
|
109
150
|
}
|
|
110
151
|
const data = result.data as Partial<TemplateResourceRow>;
|
|
@@ -159,6 +200,7 @@ export async function seedTextBlock(
|
|
|
159
200
|
});
|
|
160
201
|
const existing = await resolveExistingForEventStoreSeed(
|
|
161
202
|
db,
|
|
203
|
+
tdb,
|
|
162
204
|
existingRow ? { id: existingRow.id, version: existingRow.version } : null,
|
|
163
205
|
opts.tenantId,
|
|
164
206
|
);
|
|
@@ -184,6 +226,36 @@ export async function seedTextBlock(
|
|
|
184
226
|
create: async () => {
|
|
185
227
|
const result = await executor.create({ ...fields, tenantId: opts.tenantId }, by, tdb);
|
|
186
228
|
if (!result.isSuccess) {
|
|
229
|
+
if (result.error?.code === "unique_violation") {
|
|
230
|
+
const again = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
231
|
+
tenantId: opts.tenantId,
|
|
232
|
+
slug: opts.slug,
|
|
233
|
+
kind: TEXT_BLOCK_KIND,
|
|
234
|
+
locale: opts.locale,
|
|
235
|
+
});
|
|
236
|
+
const resolved = await resolveExistingForEventStoreSeed(
|
|
237
|
+
db,
|
|
238
|
+
tdb,
|
|
239
|
+
again ? { id: again.id, version: again.version } : null,
|
|
240
|
+
opts.tenantId,
|
|
241
|
+
);
|
|
242
|
+
if (resolved == null) {
|
|
243
|
+
throw new Error("seedTextBlock create unique_violation but row missing");
|
|
244
|
+
}
|
|
245
|
+
const upd = await executor.update(
|
|
246
|
+
{
|
|
247
|
+
id: resolved.id,
|
|
248
|
+
version: resolved.version,
|
|
249
|
+
changes: { title: fields.title, content: fields.content, folder: fields.folder },
|
|
250
|
+
},
|
|
251
|
+
by,
|
|
252
|
+
tdb,
|
|
253
|
+
);
|
|
254
|
+
if (!upd.isSuccess) {
|
|
255
|
+
throw new Error(`seedTextBlock race-update failed: ${JSON.stringify(upd)}`);
|
|
256
|
+
}
|
|
257
|
+
return { id: resolved.id };
|
|
258
|
+
}
|
|
187
259
|
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
188
260
|
}
|
|
189
261
|
// @cast-boundary db-row — executor.create returns the inserted row from
|
|
@@ -22,9 +22,9 @@ mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () =
|
|
|
22
22
|
useShellUser: mock(),
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const actualRenderer = await import("@cosmicdrift/kumiko-renderer");
|
|
26
26
|
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
27
|
-
...
|
|
27
|
+
...actualRenderer,
|
|
28
28
|
useDispatcher: mock(() => ({ write: mock(), query: mock() })),
|
|
29
29
|
useQuery: mock(() => ({
|
|
30
30
|
data: {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
PlainContentEditor,
|
|
33
33
|
RichContentEditor,
|
|
34
34
|
} from "@cosmicdrift/kumiko-renderer-web";
|
|
35
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
35
36
|
import { type FormEvent, type ReactNode, useEffect, useId, useState } from "react";
|
|
36
37
|
import {
|
|
37
38
|
collectionHandlerName,
|
|
@@ -226,7 +227,7 @@ function findCollection(
|
|
|
226
227
|
function findContentFormat(
|
|
227
228
|
features: readonly FeatureSchema[],
|
|
228
229
|
collectionId?: string,
|
|
229
|
-
):
|
|
230
|
+
): ContentEditorFormat | undefined {
|
|
230
231
|
return findCollection(features, collectionId)?.contentFormat;
|
|
231
232
|
}
|
|
232
233
|
|
|
@@ -23,7 +23,30 @@ function membersFacetLabelKeys(): readonly string[] {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Column labels of the members screen itself — deliberately scoped to this
|
|
27
|
+
// one screen, not a generic "walk every column of every screen" helper.
|
|
28
|
+
function membersColumnLabelKeys(): readonly string[] {
|
|
29
|
+
const screen = createTenantFeature().screens[MEMBERS_SCREEN_ID];
|
|
30
|
+
if (screen?.type !== "projectionList") {
|
|
31
|
+
throw new Error("expected members screen to be projectionList");
|
|
32
|
+
}
|
|
33
|
+
return screen.columns.flatMap((col) =>
|
|
34
|
+
typeof col === "string" || col.label === undefined ? [] : [col.label],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Nav-menu-entry label(s) that point at the members screen — same
|
|
39
|
+
// single-screen scope as the facet/column helpers above.
|
|
40
|
+
function membersNavLabelKeys(): readonly string[] {
|
|
41
|
+
const feature = createTenantFeature();
|
|
42
|
+
const membersScreenTarget = `${feature.name}:screen:${MEMBERS_SCREEN_ID}`;
|
|
43
|
+
return Object.values(feature.navs)
|
|
44
|
+
.filter((nav) => nav.screen === membersScreenTarget)
|
|
45
|
+
.map((nav) => nav.label);
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
const facetKeys = membersFacetLabelKeys();
|
|
49
|
+
const membersScreenI18nKeys = [...facetKeys, ...membersColumnLabelKeys(), ...membersNavLabelKeys()];
|
|
27
50
|
|
|
28
51
|
const localePacks: ReadonlyArray<readonly [string, Readonly<Record<string, string>>]> = [
|
|
29
52
|
["es", localeEsBundle],
|
|
@@ -31,12 +54,21 @@ const localePacks: ReadonlyArray<readonly [string, Readonly<Record<string, strin
|
|
|
31
54
|
];
|
|
32
55
|
|
|
33
56
|
describe("members status filter labels are localized in every shipped locale (fw#2376)", () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
// A hardcoded toEqual() array here would fail with a confusing "array
|
|
58
|
+
// mismatch" the moment a facet/column/nav-label key is added to the
|
|
59
|
+
// members screen — instead of the clear "key not translated" failure a
|
|
60
|
+
// missing en copy should produce. Reading the keys off the live screen
|
|
61
|
+
// definition and checking each one resolves catches the real failure
|
|
62
|
+
// mode (fw#2376) without hardcoding an ever-growing key list.
|
|
63
|
+
test("every facet/column/nav-label key of the members screen has English copy", () => {
|
|
64
|
+
expect(membersScreenI18nKeys.length).toBeGreaterThan(0);
|
|
65
|
+
const translations = createTenantFeature().translations ?? {};
|
|
66
|
+
for (const key of membersScreenI18nKeys) {
|
|
67
|
+
const label = translations[key]?.["en"];
|
|
68
|
+
expect(label).toBeString();
|
|
69
|
+
expect(label).not.toBe(key);
|
|
70
|
+
expect((label ?? "").trim()).not.toBe("");
|
|
71
|
+
}
|
|
40
72
|
});
|
|
41
73
|
|
|
42
74
|
test("every facet label has English copy in the tenant feature itself", () => {
|