@cosmicdrift/kumiko-bundled-features 0.146.4 → 0.147.1

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.
Files changed (56) hide show
  1. package/package.json +6 -6
  2. package/src/admin-shell/__tests__/overview-allowlist.test.ts +1 -0
  3. package/src/admin-shell/overview-allowlist.ts +1 -0
  4. package/src/admin-shell/web/platform-overview-screen.tsx +11 -13
  5. package/src/audit/__tests__/audit-screens.boot.test.ts +17 -8
  6. package/src/audit/handlers/details.query.ts +1 -1
  7. package/src/audit/web/audit-log-detail-screen.tsx +7 -9
  8. package/src/audit/web/audit-log-screen.tsx +16 -29
  9. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +4 -2
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -2
  11. package/src/auth-email-password/password-hashing.ts +4 -55
  12. package/src/auth-email-password/web/user-menu.tsx +1 -1
  13. package/src/auth-mfa/__tests__/totp.test.ts +122 -0
  14. package/src/auth-mfa/base32.ts +45 -0
  15. package/src/auth-mfa/otpauth-uri.ts +21 -0
  16. package/src/auth-mfa/totp.ts +47 -0
  17. package/src/billing-foundation/events.ts +4 -4
  18. package/src/billing-foundation/tenant-destroy-hook.ts +5 -6
  19. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
  20. package/src/cap-counter/enforce-cap.ts +15 -18
  21. package/src/cap-counter/with-cap-enforcement.ts +2 -2
  22. package/src/config/__tests__/deserialize-value.test.ts +2 -2
  23. package/src/config/resolver.ts +7 -2
  24. package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
  25. package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
  26. package/src/custom-fields/lib/field-access.ts +8 -1
  27. package/src/custom-fields/run-retention.ts +9 -1
  28. package/src/data-retention/__tests__/parse-override.test.ts +1 -0
  29. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +81 -2
  30. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +39 -1
  31. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +29 -8
  32. package/src/inbound-mail-foundation/retention-sweep.ts +25 -12
  33. package/src/inbound-provider-imap/feature.ts +3 -7
  34. package/src/jobs/web/job-run-detail-screen.tsx +7 -9
  35. package/src/jobs/web/job-runs-screen.tsx +3 -21
  36. package/src/page-render/__tests__/layout.test.ts +10 -0
  37. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
  38. package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
  39. package/src/renderer-simple/resolve-variables.ts +3 -1
  40. package/src/seo/__tests__/seo.integration.test.ts +12 -0
  41. package/src/seo/feature.ts +16 -4
  42. package/src/seo/sitemap.ts +3 -0
  43. package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
  44. package/src/shared/index.ts +2 -0
  45. package/src/shared/password-hashing.ts +55 -0
  46. package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
  47. package/src/tags/web/tag-section.tsx +6 -3
  48. package/src/tenant-lifecycle/lifecycle-gate.ts +17 -10
  49. package/src/text-content/web/client-plugin.tsx +1 -1
  50. package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
  51. package/src/tier-engine/feature.ts +5 -0
  52. package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
  53. package/src/user-data-rights/email-templates.ts +6 -6
  54. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -0
  55. /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
  56. /package/src/{auth-email-password → shared}/password-hashing.test.ts +0 -0
@@ -3,6 +3,8 @@
3
3
 
4
4
  import {
5
5
  type DataTableSort,
6
+ formatWhen,
7
+ sortByAccessor,
6
8
  useDispatcher,
7
9
  useNav,
8
10
  usePrimitives,
@@ -99,7 +101,7 @@ export function JobRunsScreen(): ReactNode {
99
101
  ]}
100
102
  sort={sort}
101
103
  onSortChange={setSort}
102
- rows={sortJobRuns(state.rows, sort).map((row) => ({
104
+ rows={sortByAccessor(state.rows, sort, SORT_ACCESSORS).map((row) => ({
103
105
  id: row.id,
104
106
  values: {
105
107
  job: row.jobName,
@@ -142,23 +144,3 @@ const SORT_ACCESSORS: Record<string, (r: JobRunRow) => string | number> = {
142
144
  status: (r) => r.status,
143
145
  started: (r) => r.startedAt,
144
146
  };
145
-
146
- function sortJobRuns(rows: readonly JobRunRow[], sort: DataTableSort | null): readonly JobRunRow[] {
147
- if (sort === null) return rows;
148
- const accessor = SORT_ACCESSORS[sort.field];
149
- if (accessor === undefined) return rows;
150
- const factor = sort.dir === "asc" ? 1 : -1;
151
- return [...rows].sort((a, b) => {
152
- const av = accessor(a);
153
- const bv = accessor(b);
154
- return av < bv ? -factor : av > bv ? factor : 0;
155
- });
156
- }
157
-
158
- function formatWhen(value: string): string {
159
- try {
160
- return new Date(value).toLocaleString();
161
- } catch {
162
- return value;
163
- }
164
- }
@@ -40,6 +40,16 @@ describe("wrapInLayout :: seo (opt-in OG/JSON-LD extension)", () => {
40
40
  expect(html).not.toContain('<meta name="description" content="About us">');
41
41
  });
42
42
 
43
+ test('with `seo` — no description means no <meta name="description"> at all (not an empty one)', () => {
44
+ const html = wrapInLayout({
45
+ title: "About",
46
+ bodyHtml: "<p>x</p>",
47
+ lang: "en",
48
+ seo: {},
49
+ });
50
+ expect(html).not.toContain('<meta name="description"');
51
+ });
52
+
43
53
  test("with `seo` — escapes title/description same as the non-seo path", () => {
44
54
  const html = wrapInLayout({
45
55
  title: "<script>alert(1)</script>",
@@ -258,7 +258,7 @@ export function PatTokensScreen({
258
258
  return <div className="flex flex-col gap-6">{content}</div>;
259
259
  }
260
260
  return (
261
- <FormScreenShell testId="pat-tokens-screen" maxWidth="3xl">
261
+ <FormScreenShell testId="pat-tokens-screen" maxWidth="3xl" className="flex flex-col gap-6">
262
262
  {content}
263
263
  </FormScreenShell>
264
264
  );
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { RendererContext } from "../../renderer-foundation";
4
+ import { resolveNotificationVariables } from "../resolve-variables";
5
+
6
+ describe("resolveNotificationVariables :: template-resolver not mounted", () => {
7
+ test("template slug set but template-resolver isn't mounted → falls back to variables, never touches ctx.db", async () => {
8
+ // Truthy-but-poisoned db: if the guard ever regresses to only checking
9
+ // `!ctx.db`, calling anything on this throws instead of silently
10
+ // succeeding — the test fails loud, not by accident.
11
+ const poisonedDb = new Proxy(
12
+ {},
13
+ {
14
+ get(): never {
15
+ throw new Error(
16
+ "resolveNotificationVariables must not touch ctx.db when template-resolver isn't mounted",
17
+ );
18
+ },
19
+ },
20
+ );
21
+ const ctx: RendererContext = {
22
+ db: poisonedDb as never,
23
+ registry: { features: new Map() } as never,
24
+ tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
25
+ };
26
+
27
+ const result = await resolveNotificationVariables(
28
+ {
29
+ kind: "notification",
30
+ payload: { template: "welcome-email", variables: { name: "Ada" } },
31
+ },
32
+ ctx,
33
+ );
34
+
35
+ expect(result).toEqual({ name: "Ada" });
36
+ });
37
+ });
@@ -14,7 +14,7 @@ export async function resolveNotificationVariables(
14
14
  ): Promise<Readonly<Record<string, unknown>>> {
15
15
  const variables = req.payload.variables ?? {};
16
16
  const slug = req.payload.template?.trim();
17
- if (!slug || req.payload.content || !ctx.db) {
17
+ if (!slug || req.payload.content || !ctx.db || !ctx.registry.features.has("template-resolver")) {
18
18
  return variables;
19
19
  }
20
20
 
@@ -31,6 +31,8 @@ export async function resolveNotificationVariables(
31
31
  const base = parsePlainTemplateContent(resolved.content);
32
32
  return { ...base, ...variables };
33
33
  }
34
+ // body always wins from the resolved template for rendered formats — a runtime
35
+ // "variables.body" override would clobber the actual rendered content.
34
36
  return { ...variables, body: resolved.content };
35
37
  } catch (err) {
36
38
  if (err instanceof TemplateNotFoundError) {
@@ -141,6 +141,16 @@ describe("seo :: GET /sitemap.xml", () => {
141
141
  expect(xml).not.toContain("http://a.example.com");
142
142
  });
143
143
 
144
+ test("x-forwarded-proto: garbage value → falls back to the raw URL scheme, not the header", async () => {
145
+ const res = await stack.app.request("http://a.example.com/sitemap.xml", {
146
+ headers: { "x-forwarded-proto": "javascript" },
147
+ });
148
+ expect(res.status).toBe(200);
149
+ const xml = await res.text();
150
+ expect(xml).toContain("<loc>http://a.example.com/legal/impressum</loc>");
151
+ expect(xml).not.toContain("javascript://a.example.com");
152
+ });
153
+
144
154
  test("host without a managed-pages tenant → callback + legal-pages entries only", async () => {
145
155
  const res = await stack.app.request("http://unknown.example.com/sitemap.xml");
146
156
  expect(res.status).toBe(200);
@@ -161,6 +171,8 @@ describe("seo :: GET /llms.txt", () => {
161
171
  expect(text).toContain("> Acme builds things.");
162
172
  expect(text).toContain("## Pages");
163
173
  expect(text).toContain("http://a.example.com/p/about");
174
+ // #979: the managed-page's real title, not its URL, is the link text.
175
+ expect(text).toContain("[About](http://a.example.com/p/about)");
164
176
  });
165
177
  });
166
178
 
@@ -131,11 +131,15 @@ async function gatherEntries(
131
131
  );
132
132
  if (res.ok) {
133
133
  const body: {
134
- data?: { pages?: readonly { slug: string; updatedAt: string }[] };
134
+ data?: { pages?: readonly { slug: string; title: string; updatedAt: string }[] };
135
135
  } = await res.json();
136
136
  const basePath = opts.managedPages.basePath ?? "/p";
137
137
  for (const page of body.data?.pages ?? []) {
138
- entries.push({ loc: `${origin}${basePath}/${page.slug}`, lastmod: page.updatedAt });
138
+ entries.push({
139
+ loc: `${origin}${basePath}/${page.slug}`,
140
+ title: page.title,
141
+ lastmod: page.updatedAt,
142
+ });
139
143
  }
140
144
  }
141
145
  } catch {
@@ -160,7 +164,10 @@ function requestHost(c: { req: { header: (name: string) => string | undefined; u
160
164
  const url = new URL(c.req.url);
161
165
  const host = c.req.header("host") ?? url.host;
162
166
  const forwardedProto = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim();
163
- const protocol = forwardedProto || url.protocol.replace(":", "");
167
+ const protocol =
168
+ forwardedProto === "https" || forwardedProto === "http"
169
+ ? forwardedProto
170
+ : url.protocol.replace(":", "");
164
171
  return { origin: `${protocol}://${host}`, host };
165
172
  }
166
173
 
@@ -225,7 +232,12 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
225
232
  ]);
226
233
  const sections =
227
234
  entries.length > 0
228
- ? [{ heading: "Pages", links: entries.map((e) => ({ title: e.loc, url: e.loc })) }]
235
+ ? [
236
+ {
237
+ heading: "Pages",
238
+ links: entries.map((e) => ({ title: e.title ?? e.loc, url: e.loc })),
239
+ },
240
+ ]
229
241
  : [];
230
242
  const text = buildLlmsTxt({
231
243
  title: seoConfig.organizationName || host,
@@ -7,6 +7,9 @@ export type SitemapEntry = {
7
7
  readonly changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
8
8
  /** hreflang alternates for the same logical page (multilingual sitemaps). */
9
9
  readonly alternates?: readonly { readonly hreflang: string; readonly href: string }[];
10
+ /** Human-/LLM-readable page title, used by llms.txt link lines. Falls back
11
+ * to `loc` when absent (callback-only entries rarely set this). */
12
+ readonly title?: string;
10
13
  };
11
14
 
12
15
  // Pure XML builder — sitemaps.org urlset + xhtml:link alternates. No
@@ -15,8 +15,8 @@
15
15
 
16
16
  import { describe, expect, test } from "bun:test";
17
17
  import { pbkdf2Sync } from "node:crypto";
18
- import { isIdentityV3Hash, verifyIdentityV3Hash } from "../identity-v3-hash";
19
- import { verifyPassword } from "../password-hashing";
18
+ import { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
19
+ import { verifyPassword } from "./password-hashing";
20
20
 
21
21
  // --- Test helpers ---
22
22
 
@@ -7,3 +7,5 @@ export {
7
7
  } from "./chunked-entity-migration";
8
8
  export { decryptStoredPii } from "./decrypt-stored-pii";
9
9
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
+ export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
11
+ export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
@@ -0,0 +1,55 @@
1
+ import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
2
+ import { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
3
+
4
+ // OWASP-recommended argon2id parameters (2024 guidance):
5
+ // memoryCost: 19 MiB, timeCost: 2, parallelism: 1
6
+ // These strike a balance between login latency (~20ms on typical hardware)
7
+ // and brute-force resistance. If hashing becomes a bottleneck, tune memoryCost
8
+ // before parallelism — memory hardness is what defeats GPU attacks.
9
+ //
10
+ // algorithm: 2 = Argon2id (best of argon2i + argon2d).
11
+ // We inline the numeric value instead of importing Algorithm because the
12
+ // @node-rs/argon2 enum is const and breaks verbatimModuleSyntax imports.
13
+ const HASH_OPTIONS = {
14
+ algorithm: 2,
15
+ memoryCost: 19456,
16
+ timeCost: 2,
17
+ parallelism: 1,
18
+ } as const;
19
+
20
+ // @wrapper-known semantic-alias
21
+ export async function hashPassword(password: string): Promise<string> {
22
+ return argonHash(password, HASH_OPTIONS);
23
+ }
24
+
25
+ // Returns true if the password matches. Never throws on wrong passwords —
26
+ // only on malformed hash strings (which would be a bug, not a login attempt).
27
+ //
28
+ // Two verifier paths:
29
+ // - argon2id (default, what `hashPassword` produces)
30
+ // - ASP.NET Core Identity V3 (verify-only, for legacy migrations from .NET
31
+ // stacks). Sniffed via the format marker; on a successful match the
32
+ // application can rehash to argon2 at the next password-change event.
33
+ export async function verifyPassword(hashString: string, password: string): Promise<boolean> {
34
+ if (isIdentityV3Hash(hashString)) {
35
+ return verifyIdentityV3Hash(password, hashString);
36
+ }
37
+ try {
38
+ return await argonVerify(hashString, password);
39
+ } catch {
40
+ // argon2 throws on unparseable hash — treat as mismatch rather than 500
41
+ // to avoid revealing which accounts have corrupted stored hashes.
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // Anti-enumeration timing equaliser (#774). The login handler runs this on
47
+ // the no-user / no-hash path so a missing account costs the same argon2
48
+ // latency as a real verify — otherwise response timing leaks whether an
49
+ // email is registered. Derived from hashPassword once and cached, so it
50
+ // always tracks HASH_OPTIONS; the password never matches, result discarded.
51
+ let dummyHash: Promise<string> | undefined;
52
+ export async function verifyDummyPassword(password: string): Promise<void> {
53
+ dummyHash ??= hashPassword("anti-enumeration-dummy");
54
+ await verifyPassword(await dummyHash, password);
55
+ }
@@ -22,6 +22,8 @@ let assignmentRows: readonly AssignmentRow[] = [];
22
22
  beforeEach(() => {
23
23
  catalogRows = [];
24
24
  assignmentRows = [];
25
+ catalogRefetch.mockClear();
26
+ assignmentsRefetch.mockClear();
25
27
  });
26
28
 
27
29
  const dispatchSpy = mock(async (type: string) =>
@@ -30,11 +32,13 @@ const dispatchSpy = mock(async (type: string) =>
30
32
  : { isSuccess: true, data: undefined },
31
33
  );
32
34
 
35
+ const catalogRefetch = mock(async () => {});
36
+ const assignmentsRefetch = mock(async () => {});
33
37
  const useQuerySpy = mock((type: string) => ({
34
38
  data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
35
39
  loading: false,
36
40
  error: null,
37
- refetch: mock(async () => {}),
41
+ refetch: type === TagsQueries.tagList ? catalogRefetch : assignmentsRefetch,
38
42
  }));
39
43
 
40
44
  const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
@@ -63,6 +67,13 @@ const StubPicker = ({
63
67
  <button type="button" data-testid="picker-add-t2" onClick={() => onChange([...value, "t2"])}>
64
68
  add t2
65
69
  </button>
70
+ <button
71
+ type="button"
72
+ data-testid="picker-add-t2-t3"
73
+ onClick={() => onChange([...value, "t2", "t3"])}
74
+ >
75
+ add t2+t3
76
+ </button>
66
77
  <button
67
78
  type="button"
68
79
  data-testid="picker-remove-t1"
@@ -169,6 +180,41 @@ describe("TagSection", () => {
169
180
  );
170
181
  });
171
182
 
183
+ test("partial write failure still refetches — no stale UI for the writes that succeeded", async () => {
184
+ catalogRows = [
185
+ { id: "t1", name: "important" },
186
+ { id: "t2", name: "project-x" },
187
+ { id: "t3", name: "urgent" },
188
+ ];
189
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
190
+ dispatchSpy.mockClear();
191
+ dispatchSpy.mockImplementation(async (_type: string, payload?: Record<string, unknown>) =>
192
+ payload?.["tagId"] === "t3"
193
+ ? { isSuccess: false, error: { i18nKey: "tags.error.assignFailed" } }
194
+ : { isSuccess: true, data: undefined },
195
+ );
196
+
197
+ render(
198
+ <Wrapper>
199
+ <TagSection entityName="note" entityId="note-1" />
200
+ </Wrapper>,
201
+ );
202
+
203
+ // t2 assigns OK, t3 fails — the loop stops, but refetch must still run so
204
+ // the UI reflects the t2 write that already succeeded server-side.
205
+ fireEvent.click(screen.getByTestId("picker-add-t2-t3"));
206
+ await waitFor(() => expect(screen.getByTestId("tags-section-action-error")).toBeTruthy());
207
+
208
+ expect(catalogRefetch).toHaveBeenCalled();
209
+ expect(assignmentsRefetch).toHaveBeenCalled();
210
+
211
+ dispatchSpy.mockImplementation(async (type: string) =>
212
+ type === TagsHandlers.createTag
213
+ ? { isSuccess: true, data: { id: "tag-new" } }
214
+ : { isSuccess: true, data: undefined },
215
+ );
216
+ });
217
+
172
218
  test("create-mode (no entityId yet) shows the save-first hint instead of the section", () => {
173
219
  render(
174
220
  <Wrapper>
@@ -119,14 +119,17 @@ export function TagSection({
119
119
  try {
120
120
  for (const tagId of added) {
121
121
  if (!(await writeOk(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId })))
122
- return;
122
+ break;
123
123
  }
124
124
  for (const tagId of removed) {
125
125
  if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
126
- return;
126
+ break;
127
127
  }
128
- await refetch();
129
128
  } finally {
129
+ // Unconditional: even a partial failure may have persisted some
130
+ // writes server-side — skipping refetch here would leave the UI
131
+ // stale relative to those, undetected until the next interaction.
132
+ await refetch();
130
133
  setBusy(false);
131
134
  }
132
135
  })();
@@ -9,17 +9,23 @@ export type TenantLifecycleGate = {
9
9
  };
10
10
 
11
11
  // Every authenticated + anonymous request consults this (via auth-middleware's
12
- // resolveTenantLifecycleStatus) — a per-request DB round-trip otherwise. No
13
- // TTL: status must be observable the instant it changes (a stale "active"
14
- // read after tombstoning would let a request slip through the 410 gate), so
15
- // this is invalidated explicitly by every status-changing write
16
- // request/cancel-destruction, the sweep, and tombstoneTenantRow all call
17
- // invalidateTenantLifecycleGate(tenantId) right after their update succeeds.
12
+ // resolveTenantLifecycleStatus) — a per-request DB round-trip otherwise.
13
+ // invalidateTenantLifecycleGate(tenantId) clears the SAME-process entry
14
+ // right after every status-changing write (request/cancel-destruction, the
15
+ // sweep, tombstoneTenantRow) the instant-visibility path for a single
16
+ // replica. In a multi-replica deployment a write on pod A never reaches pod
17
+ // B's Map, so a short TTL bounds the cross-pod staleness window instead of
18
+ // leaving it unbounded (self-heals within GATE_TTL_MS even if invalidation
19
+ // never arrives).
18
20
  // Split into its own module (not run-tenant-destroy.ts) so stages.ts can
19
21
  // invalidate too without a stages.ts <-> run-tenant-destroy.ts import cycle.
20
22
  // ponytail: unbounded Map — fine while tenant counts stay in the thousands (a
21
23
  // few bytes/entry); swap for a bounded LRU if that ever changes.
22
- const gateCache = new Map<TenantId, TenantLifecycleGate | null>();
24
+ const GATE_TTL_MS = 3000;
25
+ const gateCache = new Map<
26
+ TenantId,
27
+ { readonly value: TenantLifecycleGate | null; readonly expiresAt: number }
28
+ >();
23
29
 
24
30
  export function invalidateTenantLifecycleGate(tenantId: TenantId): void {
25
31
  gateCache.delete(tenantId);
@@ -38,7 +44,8 @@ export async function resolveTenantLifecycleGate(
38
44
  db: DbRunner,
39
45
  tenantId: TenantId,
40
46
  ): Promise<TenantLifecycleGate | null> {
41
- if (gateCache.has(tenantId)) return gateCache.get(tenantId) ?? null;
47
+ const cached = gateCache.get(tenantId);
48
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
42
49
  const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
43
50
  db,
44
51
  tenantTable,
@@ -46,13 +53,13 @@ export async function resolveTenantLifecycleGate(
46
53
  );
47
54
  const row = rows[0];
48
55
  if (!row) {
49
- gateCache.set(tenantId, null);
56
+ gateCache.set(tenantId, { value: null, expiresAt: Date.now() + GATE_TTL_MS });
50
57
  return null;
51
58
  }
52
59
  const gate: TenantLifecycleGate = {
53
60
  status: row.status,
54
61
  gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
55
62
  };
56
- gateCache.set(tenantId, gate);
63
+ gateCache.set(tenantId, { value: gate, expiresAt: Date.now() + GATE_TTL_MS });
57
64
  return gate;
58
65
  }
@@ -318,7 +318,7 @@ function TextContentEditor({
318
318
  onSubmit={onSubmit}
319
319
  testId="text-content-editor"
320
320
  title={title || slug || "—"}
321
- subtitle={lang !== "" ? `(${lang})` : undefined}
321
+ subtitle={lang !== "" ? `${slug} · (${lang})` : slug}
322
322
  actions={
323
323
  canWrite ? (
324
324
  <Button type="submit" loading={submitting} disabled={disabled}>
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TierMap } from "../compose-app";
3
+ import { createTierEngineFeature } from "../feature";
4
+
5
+ type Caps = { readonly apps: number };
6
+
7
+ const tierMap: TierMap<Caps> = {
8
+ free: { features: [], caps: { apps: 1 } },
9
+ pro: { features: ["designer"], caps: { apps: 5 } },
10
+ };
11
+
12
+ describe("createTierEngineFeature trial config validation", () => {
13
+ test("throws at construction when trial.tier is not a key in tierMap", () => {
14
+ expect(() =>
15
+ createTierEngineFeature({
16
+ tierMap,
17
+ trial: { tier: "enterprise", durationHours: 72 },
18
+ }),
19
+ ).toThrow(/trial\.tier "enterprise" is not a key in tierMap/);
20
+ });
21
+
22
+ test("does not throw when trial.tier is a valid tierMap key", () => {
23
+ expect(() =>
24
+ createTierEngineFeature({
25
+ tierMap,
26
+ trial: { tier: "pro", durationHours: 72 },
27
+ }),
28
+ ).not.toThrow();
29
+ });
30
+ });
@@ -270,6 +270,11 @@ export function createTierEngineFeature<
270
270
  // Sekunde 1 nach Signup. Darum lebt er als async trialGate (unten, am
271
271
  // build-Ende angehängt), den der dispatcher nur auf dem disabled-Pfad
272
272
  // konsultiert. trialFeatures = fixe Feature-Menge des Trial-Tiers.
273
+ if (opts.trial && !tierMap[opts.trial.tier]) {
274
+ throw new Error(
275
+ `tier-engine: trial.tier "${opts.trial.tier}" is not a key in tierMap — trial would unlock no features`,
276
+ );
277
+ }
273
278
  const trialFeatures: ReadonlySet<string> = opts.trial
274
279
  ? featuresForTier(tierMap, opts.trial.tier)
275
280
  : new Set();
@@ -31,6 +31,21 @@ describe("gdpr email-templates", () => {
31
31
  expect(de.subject).not.toBe(en.subject);
32
32
  });
33
33
 
34
+ test("export-ready: html lang attribute matches the requested locale (654/1)", () => {
35
+ const de = renderExportReadyEmail({
36
+ downloadUrl: "https://app.test/x?token=abc",
37
+ expiresAt: "2026-07-01T13:45:00Z",
38
+ locale: "de",
39
+ });
40
+ expect(de.html).toContain('<html lang="de">');
41
+ const en = renderExportReadyEmail({
42
+ downloadUrl: "https://app.test/x?token=abc",
43
+ expiresAt: "2026-07-01T13:45:00Z",
44
+ locale: "en",
45
+ });
46
+ expect(en.html).toContain('<html lang="en">');
47
+ });
48
+
34
49
  test("export-ready: ampersand in download url is escaped in the href attr", () => {
35
50
  const r = renderExportReadyEmail({
36
51
  downloadUrl: "https://app.test/x?token=a&next=b",
@@ -117,7 +117,7 @@ export function renderExportReadyEmail(args: RenderExportReadyEmailArgs): Render
117
117
  <p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t.exportReadyButton })}</p>
118
118
  <p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t.exportReadyExpiry(formatTimestamp(args.expiresAt)))}</p>
119
119
  ${renderFallbackUrl({ url: args.downloadUrl, label: t.fallbackUrl })}`;
120
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
120
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
121
121
  }
122
122
 
123
123
  export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): RenderedEmail {
@@ -128,7 +128,7 @@ export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): Rend
128
128
  const body = `
129
129
  <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
130
130
  <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportFailedIntro(app))}</p>`;
131
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
131
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
132
132
  }
133
133
 
134
134
  export function renderDeletionRequestedEmail(
@@ -142,7 +142,7 @@ export function renderDeletionRequestedEmail(
142
142
  <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
143
143
  <p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionRequestedIntro(app, formatTimestamp(args.gracePeriodEnd)))}</p>
144
144
  <p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t.deletionRequestedCancel)}</p>`;
145
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
145
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
146
146
  }
147
147
 
148
148
  export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArgs): RenderedEmail {
@@ -153,7 +153,7 @@ export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArg
153
153
  const body = `
154
154
  <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
155
155
  <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionExecutedIntro(app, formatTimestamp(args.executedAt)))}</p>`;
156
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
156
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
157
157
  }
158
158
 
159
159
  function wrapCell(bodyHtml: string): string {
@@ -162,9 +162,9 @@ function wrapCell(bodyHtml: string): string {
162
162
 
163
163
  // Plain inline-styled HTML — gespiegelt von auth-email-password.
164
164
  // guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
165
- function renderShell(args: { title: string; bodyHtml: string }): string {
165
+ function renderShell(args: { title: string; bodyHtml: string; locale: GdprMailLocale }): string {
166
166
  return `<!DOCTYPE html>
167
- <html lang="en">
167
+ <html lang="${args.locale}">
168
168
  <head>
169
169
  <meta charset="utf-8" />
170
170
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -224,6 +224,7 @@ function RestrictionSection({
224
224
  <Section
225
225
  title={t("userDataRights.privacyCenter.restriction.title")}
226
226
  testId="privacy-restriction"
227
+ variant="destructive"
227
228
  actions={
228
229
  restricted ? undefined : (
229
230
  <Button
@@ -303,6 +304,7 @@ function DeletionSection({
303
304
  <Section
304
305
  title={t("userDataRights.privacyCenter.deletion.title")}
305
306
  testId="privacy-deletion"
307
+ variant="destructive"
306
308
  actions={
307
309
  deletionRequested ? (
308
310
  <Button