@cosmicdrift/kumiko-bundled-features 0.146.4 → 0.147.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 +6 -6
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +1 -0
- package/src/admin-shell/overview-allowlist.ts +1 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +11 -13
- package/src/audit/__tests__/audit-screens.boot.test.ts +17 -8
- package/src/audit/handlers/details.query.ts +1 -1
- package/src/audit/web/audit-log-detail-screen.tsx +7 -9
- package/src/audit/web/audit-log-screen.tsx +16 -29
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +4 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -2
- package/src/auth-email-password/password-hashing.ts +4 -55
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/auth-mfa/__tests__/totp.test.ts +122 -0
- package/src/auth-mfa/base32.ts +45 -0
- package/src/auth-mfa/otpauth-uri.ts +21 -0
- package/src/auth-mfa/totp.ts +47 -0
- package/src/billing-foundation/events.ts +4 -4
- package/src/billing-foundation/tenant-destroy-hook.ts +5 -6
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
- package/src/cap-counter/enforce-cap.ts +15 -18
- package/src/cap-counter/with-cap-enforcement.ts +2 -2
- package/src/config/__tests__/deserialize-value.test.ts +2 -2
- package/src/config/resolver.ts +7 -2
- package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
- package/src/custom-fields/lib/field-access.ts +8 -1
- package/src/custom-fields/run-retention.ts +9 -1
- package/src/data-retention/__tests__/parse-override.test.ts +1 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +81 -2
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +39 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +29 -8
- package/src/inbound-mail-foundation/retention-sweep.ts +25 -12
- package/src/inbound-provider-imap/feature.ts +3 -7
- package/src/jobs/web/job-run-detail-screen.tsx +7 -9
- package/src/jobs/web/job-runs-screen.tsx +3 -21
- package/src/page-render/__tests__/layout.test.ts +10 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
- package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
- package/src/renderer-simple/resolve-variables.ts +3 -1
- package/src/seo/__tests__/seo.integration.test.ts +12 -0
- package/src/seo/feature.ts +16 -4
- package/src/seo/sitemap.ts +3 -0
- package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared/password-hashing.ts +55 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
- package/src/tags/web/tag-section.tsx +6 -3
- package/src/tenant-lifecycle/lifecycle-gate.ts +17 -10
- package/src/text-content/web/client-plugin.tsx +1 -1
- package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
- package/src/tier-engine/feature.ts +5 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
- package/src/user-data-rights/email-templates.ts +6 -6
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -0
- /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
- /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={
|
|
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
|
|
package/src/seo/feature.ts
CHANGED
|
@@ -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({
|
|
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 =
|
|
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
|
-
? [
|
|
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,
|
package/src/seo/sitemap.ts
CHANGED
|
@@ -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 "
|
|
19
|
-
import { verifyPassword } from "
|
|
18
|
+
import { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
19
|
+
import { verifyPassword } from "./password-hashing";
|
|
20
20
|
|
|
21
21
|
// --- Test helpers ---
|
|
22
22
|
|
package/src/shared/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
122
|
+
break;
|
|
123
123
|
}
|
|
124
124
|
for (const tagId of removed) {
|
|
125
125
|
if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
|
|
126
|
-
|
|
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.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
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
|
-
|
|
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 !== "" ?
|
|
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="
|
|
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
|
|
File without changes
|
|
File without changes
|