@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.3
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 +12 -7
- package/src/auth-email-password/feature.ts +6 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/login.write.ts +66 -9
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/auth-client.ts +34 -12
- package/src/auth-email-password/web/auth-gate.tsx +32 -3
- package/src/auth-email-password/web/client-plugin.ts +11 -2
- package/src/auth-email-password/web/index.ts +4 -2
- package/src/auth-email-password/web/login-screen.tsx +31 -2
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +25 -0
- package/src/auth-mfa/db/queries.ts +34 -0
- package/src/auth-mfa/errors.ts +73 -0
- package/src/auth-mfa/feature.ts +146 -0
- package/src/auth-mfa/handlers/disable.write.ts +51 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
- package/src/auth-mfa/handlers/verify.write.ts +141 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +15 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-setup-token.ts +90 -0
- package/src/auth-mfa/mfa-status-checker.ts +75 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/recovery-codes.ts +44 -0
- package/src/auth-mfa/schema/user-mfa.ts +40 -0
- package/src/auth-mfa/totp.ts +8 -0
- package/src/auth-mfa/verify-factor.ts +30 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +69 -0
- package/src/auth-mfa/web/index.ts +15 -0
- package/src/auth-mfa/web/mfa-client.ts +60 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
- package/src/legal-pages/feature.ts +19 -28
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +111 -0
- package/src/managed-pages/feature.ts +44 -44
- package/src/seo/__tests__/seo.integration.test.ts +68 -0
- package/src/seo/feature.ts +28 -41
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/session-callbacks.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Login-Challenge-Step, gerendert wenn LoginScreen's onMfaChallenge feuert
|
|
3
|
+
// (/auth/login antwortete mfaRequired). Stilistisch wie login-screen.tsx:
|
|
4
|
+
// useState + direkter fetch-Call, kein Dispatcher — es existiert noch kein
|
|
5
|
+
// JWT. Nach Erfolg ruft es session.refresh() statt eines Hard-Reloads, weil
|
|
6
|
+
// der Aufrufer (typisch ein Wizard-State im App-Root) sofort neu rendern
|
|
7
|
+
// will, sobald status:"authenticated" ankommt.
|
|
8
|
+
|
|
9
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
11
|
+
// kumiko-lint-ignore cross-feature-import client-only barrel, the feature's server barrel has no web/ re-export
|
|
12
|
+
import { AuthCard, useSession } from "../../auth-email-password/web";
|
|
13
|
+
import { verifyMfaChallenge } from "./mfa-client";
|
|
14
|
+
|
|
15
|
+
export type MfaVerifyScreenProps = {
|
|
16
|
+
readonly challengeToken: string;
|
|
17
|
+
readonly title?: string;
|
|
18
|
+
readonly subtitle?: ReactNode;
|
|
19
|
+
readonly submitLabel?: string;
|
|
20
|
+
/** Called after the server confirms the code and the session state has
|
|
21
|
+
* refreshed. Optional — apps that swap this screen back out purely on
|
|
22
|
+
* session.status changing to "authenticated" don't need it. */
|
|
23
|
+
readonly onSuccess?: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function reasonToKey(reason: string): string {
|
|
27
|
+
switch (reason) {
|
|
28
|
+
case "invalid_totp_code":
|
|
29
|
+
case "invalid_recovery_code":
|
|
30
|
+
case "invalid_code":
|
|
31
|
+
return "auth.mfa.errors.invalidCode";
|
|
32
|
+
case "challenge_expired":
|
|
33
|
+
case "invalid_challenge_token":
|
|
34
|
+
return "auth.mfa.errors.challengeExpired";
|
|
35
|
+
case "too_many_attempts":
|
|
36
|
+
return "auth.mfa.errors.tooManyAttempts";
|
|
37
|
+
case "rate_limited":
|
|
38
|
+
return "auth.errors.rateLimited";
|
|
39
|
+
default:
|
|
40
|
+
return "auth.mfa.errors.verifyFailed";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function MfaVerifyScreen({
|
|
45
|
+
challengeToken,
|
|
46
|
+
title,
|
|
47
|
+
subtitle,
|
|
48
|
+
submitLabel,
|
|
49
|
+
onSuccess,
|
|
50
|
+
}: MfaVerifyScreenProps): ReactNode {
|
|
51
|
+
const t = useTranslation();
|
|
52
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
53
|
+
const session = useSession();
|
|
54
|
+
const [code, setCode] = useState("");
|
|
55
|
+
const [submitting, setSubmitting] = useState(false);
|
|
56
|
+
const [error, setError] = useState<string | null>(null);
|
|
57
|
+
|
|
58
|
+
const doSubmit = async (): Promise<void> => {
|
|
59
|
+
setSubmitting(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
const res = await verifyMfaChallenge(challengeToken, code);
|
|
62
|
+
if (res.kind === "success") {
|
|
63
|
+
await session.refresh();
|
|
64
|
+
setSubmitting(false);
|
|
65
|
+
onSuccess?.();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setSubmitting(false);
|
|
69
|
+
setError(res.error.reason);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
73
|
+
e?.preventDefault();
|
|
74
|
+
void doSubmit();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<AuthCard
|
|
79
|
+
title={title ?? t("auth.mfa.verify.title")}
|
|
80
|
+
subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
|
|
81
|
+
>
|
|
82
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
83
|
+
<Form onSubmit={onSubmit}>
|
|
84
|
+
<Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
|
|
85
|
+
<Input
|
|
86
|
+
kind="text"
|
|
87
|
+
id="mfa-verify-code"
|
|
88
|
+
name="mfa-verify-code"
|
|
89
|
+
value={code}
|
|
90
|
+
onChange={setCode}
|
|
91
|
+
disabled={submitting}
|
|
92
|
+
required
|
|
93
|
+
autoComplete="one-time-code"
|
|
94
|
+
/>
|
|
95
|
+
</Field>
|
|
96
|
+
{error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
|
|
97
|
+
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
98
|
+
{submitting
|
|
99
|
+
? t("auth.mfa.verify.submitting")
|
|
100
|
+
: (submitLabel ?? t("auth.mfa.verify.submit"))}
|
|
101
|
+
</Button>
|
|
102
|
+
</Form>
|
|
103
|
+
</div>
|
|
104
|
+
</AuthCard>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// userMfaExportHook/userMfaDeleteHook — GDPR export must never surface the
|
|
2
|
+
// TOTP secret or recovery codes, and forget must actually remove the row
|
|
3
|
+
// (not just leave it dangling for a future projection rebuild to resurrect).
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import {
|
|
8
|
+
createTestUser,
|
|
9
|
+
setupTestStack,
|
|
10
|
+
type TestStack,
|
|
11
|
+
unsafeCreateEntityTable,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
|
+
import { base32Decode } from "../../auth-mfa/base32";
|
|
16
|
+
import { AuthMfaHandlers } from "../../auth-mfa/constants";
|
|
17
|
+
import { createAuthMfaFeature } from "../../auth-mfa/feature";
|
|
18
|
+
import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
|
|
19
|
+
import { currentTotpCode } from "../../auth-mfa/totp";
|
|
20
|
+
import { createConfigFeature } from "../../config";
|
|
21
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
22
|
+
import { configValuesTable } from "../../config/table";
|
|
23
|
+
import { createUserFeature } from "../../user/feature";
|
|
24
|
+
import { userEntity } from "../../user/schema/user";
|
|
25
|
+
import { userMfaDeleteHook, userMfaExportHook } from "../hooks";
|
|
26
|
+
|
|
27
|
+
let stack: TestStack;
|
|
28
|
+
|
|
29
|
+
const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
const encryption = createTestEnvelopeCipher();
|
|
33
|
+
configureEntityFieldEncryption(encryption);
|
|
34
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
35
|
+
stack = await setupTestStack({
|
|
36
|
+
features: [
|
|
37
|
+
createConfigFeature(),
|
|
38
|
+
createUserFeature(),
|
|
39
|
+
createAuthMfaFeature({
|
|
40
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
41
|
+
issuer: "Kumiko Test",
|
|
42
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
46
|
+
});
|
|
47
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
48
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
49
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await stack.cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function enrollUser(userId: string) {
|
|
57
|
+
const user = createTestUser({ id: userId, roles: ["User"] });
|
|
58
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
59
|
+
AuthMfaHandlers.enableStart,
|
|
60
|
+
{ accountLabel: `${userId}@example.com` },
|
|
61
|
+
user,
|
|
62
|
+
);
|
|
63
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
64
|
+
const secret = base32Decode(secretParam);
|
|
65
|
+
await stack.http.writeOk(
|
|
66
|
+
AuthMfaHandlers.enableConfirm,
|
|
67
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
68
|
+
user,
|
|
69
|
+
);
|
|
70
|
+
return user;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
74
|
+
test("export confirms enrollment without leaking the TOTP secret or recovery codes", async () => {
|
|
75
|
+
const user = await enrollUser("export-1");
|
|
76
|
+
|
|
77
|
+
const snippet = await userMfaExportHook({
|
|
78
|
+
db: stack.db,
|
|
79
|
+
registry: stack.registry,
|
|
80
|
+
tenantId: user.tenantId,
|
|
81
|
+
userId: user.id,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(snippet).not.toBeNull();
|
|
85
|
+
expect(snippet?.rows).toHaveLength(1);
|
|
86
|
+
const row = snippet?.rows[0] as Record<string, unknown>;
|
|
87
|
+
expect(row["enrolled"]).toBe(true);
|
|
88
|
+
expect(JSON.stringify(row)).not.toContain("totpSecret");
|
|
89
|
+
expect(JSON.stringify(row)).not.toContain("recoveryCodes");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("export returns null for a user who never enrolled", async () => {
|
|
93
|
+
const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
|
|
94
|
+
const snippet = await userMfaExportHook({
|
|
95
|
+
db: stack.db,
|
|
96
|
+
registry: stack.registry,
|
|
97
|
+
tenantId: user.tenantId,
|
|
98
|
+
userId: user.id,
|
|
99
|
+
});
|
|
100
|
+
expect(snippet).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("delete hard-removes the row via forget (rebuild-safe)", async () => {
|
|
104
|
+
const user = await enrollUser("delete-1");
|
|
105
|
+
|
|
106
|
+
const before = await userMfaExportHook({
|
|
107
|
+
db: stack.db,
|
|
108
|
+
registry: stack.registry,
|
|
109
|
+
tenantId: user.tenantId,
|
|
110
|
+
userId: user.id,
|
|
111
|
+
});
|
|
112
|
+
expect(before).not.toBeNull();
|
|
113
|
+
|
|
114
|
+
await userMfaDeleteHook(
|
|
115
|
+
{
|
|
116
|
+
db: stack.db,
|
|
117
|
+
registry: stack.registry,
|
|
118
|
+
tenantId: user.tenantId,
|
|
119
|
+
userId: user.id,
|
|
120
|
+
},
|
|
121
|
+
"delete",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const after = await userMfaExportHook({
|
|
125
|
+
db: stack.db,
|
|
126
|
+
registry: stack.registry,
|
|
127
|
+
tenantId: user.tenantId,
|
|
128
|
+
userId: user.id,
|
|
129
|
+
});
|
|
130
|
+
expect(after).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// EXT_USER_DATA hooks for userMfa (GDPR Art. 15/17/20). Both totpSecret and
|
|
2
|
+
// recoveryCodes are marked userOwned in the schema for exactly this: on
|
|
3
|
+
// forget, the row must actually disappear — a leftover TOTP secret or
|
|
4
|
+
// recovery-code hash under an anonymized user is still a live credential
|
|
5
|
+
// nobody can rotate. Mirrors folders-user-data/hooks.ts's executor-based
|
|
6
|
+
// delete (rebuild-safe: forget replays via the event, a raw DELETE would be
|
|
7
|
+
// resurrected by a projection rebuild).
|
|
8
|
+
|
|
9
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
createSystemUser,
|
|
13
|
+
type UserDataDeleteHook,
|
|
14
|
+
type UserDataExportHook,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { userMfaEntity, userMfaTable } from "../auth-mfa";
|
|
17
|
+
|
|
18
|
+
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
19
|
+
entityName: "user-mfa",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Never includes totpSecret/recoveryCodes — secret-derived credential
|
|
23
|
+
// material, not portable personal data (same reasoning as api-token's
|
|
24
|
+
// tokenHash exclusion in user-data-rights-defaults). Just confirms
|
|
25
|
+
// enrollment + when, which is what a data subject actually needs to see.
|
|
26
|
+
export const userMfaExportHook: UserDataExportHook = async (ctx) => {
|
|
27
|
+
const rows = await selectMany<{ id: string; enabledAt: unknown }>(ctx.db, userMfaTable, {
|
|
28
|
+
userId: ctx.userId,
|
|
29
|
+
tenantId: ctx.tenantId,
|
|
30
|
+
});
|
|
31
|
+
if (rows.length === 0) return null;
|
|
32
|
+
return {
|
|
33
|
+
entity: "user-mfa",
|
|
34
|
+
rows: rows.map((r) => ({ enrolled: true, enabledAt: String(r.enabledAt ?? "") })),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Always a real hard-delete regardless of the incoming strategy — a 2FA
|
|
39
|
+
// secret can't be anonymized, and userOwned already means this row belongs
|
|
40
|
+
// to exactly one user (never shared tenant data).
|
|
41
|
+
export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
42
|
+
const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
|
|
43
|
+
userId: ctx.userId,
|
|
44
|
+
tenantId: ctx.tenantId,
|
|
45
|
+
});
|
|
46
|
+
// skip: nothing enrolled for this user — no row to erase
|
|
47
|
+
if (rows.length === 0) return;
|
|
48
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
49
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
await executor.forget({ id: row.id }, systemUser, tdb);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Provides the EXT_USER_DATA export/delete hooks for the `user-mfa` entity as
|
|
2
|
+
// a standalone feature — mount it alongside `auth-mfa` + `user-data-rights`
|
|
3
|
+
// when an app needs 2FA enrollment in its GDPR export/forget pipeline. Kept
|
|
4
|
+
// separate from `auth-mfa` (which only requires "user"/"config") so auth-mfa
|
|
5
|
+
// consumers without the user-data-rights stack don't pull a hard dependency.
|
|
6
|
+
// Mirrors folders-user-data.
|
|
7
|
+
|
|
8
|
+
import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { userMfaDeleteHook, userMfaExportHook } from "./hooks";
|
|
10
|
+
|
|
11
|
+
export const authMfaUserDataFeature = defineFeature("auth-mfa-user-data", (r) => {
|
|
12
|
+
r.describe(
|
|
13
|
+
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `auth-mfa` feature's `user-mfa` entity. Mounts the EXT_USER_DATA export + delete hooks so 2FA enrollment status is included in the user-data export bundle and a user's TOTP secret + recovery codes are hard-deleted (via executor.forget, rebuild-safe) on a data-subject erasure request. Kept separate from `auth-mfa` so consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `auth-mfa`.",
|
|
14
|
+
);
|
|
15
|
+
r.requires("user-data-rights");
|
|
16
|
+
r.optionalRequires("auth-mfa");
|
|
17
|
+
r.useExtension(EXT_USER_DATA, "user-mfa", {
|
|
18
|
+
export: userMfaExportHook,
|
|
19
|
+
delete: userMfaDeleteHook,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -238,6 +238,10 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
|
|
|
238
238
|
// Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
|
|
239
239
|
// pages den respektieren würde, wäre der DB-Lookup leer.
|
|
240
240
|
tenantResolver: () => otherTenantId,
|
|
241
|
+
// Mirrors publicstatus's real subdomain resolver: the tenant is
|
|
242
|
+
// derived from the host, which the client cannot forge — trusted
|
|
243
|
+
// over any client-supplied tenant.
|
|
244
|
+
resolverTrust: "authoritative",
|
|
241
245
|
tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
|
|
242
246
|
},
|
|
243
247
|
extraContext: ({ db }) => ({
|
|
@@ -21,10 +21,8 @@ import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
|
|
|
21
21
|
// wie bei jedem API-Endpunkt.
|
|
22
22
|
const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
type
|
|
26
|
-
data: { title: string; body: string | null; updatedAt: string } | null;
|
|
27
|
-
};
|
|
24
|
+
// Return-Shape von bySlugQuery (text-content/handlers/by-slug.query.ts).
|
|
25
|
+
type TextBlockQueryResult = { title: string; body: string | null; updatedAt: string } | null;
|
|
28
26
|
|
|
29
27
|
// 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
|
|
30
28
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
@@ -81,35 +79,28 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
81
79
|
method: "GET",
|
|
82
80
|
path: route.path,
|
|
83
81
|
anonymous: true,
|
|
84
|
-
handler: async (c, {
|
|
85
|
-
const url = new URL(c.req.url);
|
|
82
|
+
handler: async (c, { systemQuery }) => {
|
|
86
83
|
// Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
|
|
87
84
|
// Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
|
|
88
|
-
// legal-pages serven IMMER die SYSTEM_TENANT-Texte.
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
if (!queryRes.ok) {
|
|
85
|
+
// legal-pages serven IMMER die SYSTEM_TENANT-Texte. systemQuery
|
|
86
|
+
// erzwingt SYSTEM_TENANT_ID in-process (kein X-Tenant-Header-
|
|
87
|
+
// Trick über app.fetch — der sähe für einen host-basierten
|
|
88
|
+
// anonymousAccess-Resolver wie ein Client-Override aus und würde
|
|
89
|
+
// von resolverTrust: "authoritative" zurecht abgelehnt).
|
|
90
|
+
let data: TextBlockQueryResult;
|
|
91
|
+
try {
|
|
92
|
+
// @cast-boundary engine-payload — dispatcher.query returnt
|
|
93
|
+
// unknown; Shape ist durch bySlugQuery's Handler-Return-Type
|
|
94
|
+
// (siehe text-content/handlers/by-slug.query.ts) vertraut.
|
|
95
|
+
data = (await systemQuery(
|
|
96
|
+
TEXT_CONTENT_BY_SLUG_QN,
|
|
97
|
+
{ slug: route.slug, lang: route.lang },
|
|
98
|
+
SYSTEM_TENANT_ID,
|
|
99
|
+
)) as TextBlockQueryResult;
|
|
100
|
+
} catch {
|
|
108
101
|
return c.text("legal page unavailable", 503);
|
|
109
102
|
}
|
|
110
103
|
|
|
111
|
-
const body: ByslugQueryBody = await queryRes.json();
|
|
112
|
-
const data = body.data;
|
|
113
104
|
if (!data?.body) {
|
|
114
105
|
return c.text(
|
|
115
106
|
`${route.titleFallback} not configured. Tenant-Admin must set this text-block.`,
|
|
@@ -697,3 +697,114 @@ describe("managed-pages :: Custom CSS (gated, sanitized render)", () => {
|
|
|
697
697
|
expect(JSON.stringify(screen)).toContain("branding-custom-css");
|
|
698
698
|
});
|
|
699
699
|
});
|
|
700
|
+
|
|
701
|
+
// resolverTrust: "authoritative" (kumiko-platform#278/1, show-pony#51):
|
|
702
|
+
// diese Suite verdrahtet einen ECHTEN host-basierten anonymousAccess.
|
|
703
|
+
// tenantResolver (nicht nur tenantExists wie die Suite oben) — genau die
|
|
704
|
+
// Konfiguration, unter der publicstatus/show-pony laufen.
|
|
705
|
+
//
|
|
706
|
+
// Mechanismus: ein interner Self-Fetch (der alte app.fetch-Pfad) trägt
|
|
707
|
+
// KEINEN Host-Header — Host steckt nur implizit in der Request-URL, nicht
|
|
708
|
+
// in Headers. Ein Resolver, der (wie publicstatus' echter Resolver) nur
|
|
709
|
+
// c.req.header("host") liest, löst für den inneren Request also null auf;
|
|
710
|
+
// unter resolverTrust: "authoritative" gibt es dafür keinen Client-Tenant-
|
|
711
|
+
// Fallback → tenant_required → "page unavailable" 503, obwohl der äußere
|
|
712
|
+
// Request (Host da) den Tenant längst korrekt aufgelöst hätte. systemQuery
|
|
713
|
+
// (siehe feature.ts) entfernt den internen HTTP-Roundtrip komplett und
|
|
714
|
+
// erzwingt den bereits aufgelösten tenantId in-process — kein Header mehr,
|
|
715
|
+
// der fehlen könnte. Rot/Grün verifiziert: mit dem alten app.fetch-Pfad UND
|
|
716
|
+
// einem fallback-freien Resolver (wie hier) schlagen 4 der Tests in dieser
|
|
717
|
+
// Datei/seo.integration.test.ts fehl (503/leere sitemap-Einträge); mit
|
|
718
|
+
// systemQuery sind alle grün.
|
|
719
|
+
describe("managed-pages :: resolverTrust authoritative (host-based, wie publicstatus/show-pony)", () => {
|
|
720
|
+
let authStack: TestStack;
|
|
721
|
+
const authManaged = createManagedPagesFeature({
|
|
722
|
+
resolveApexTenant: (host) => {
|
|
723
|
+
if (host.startsWith("a.")) return TENANT_A;
|
|
724
|
+
if (host.startsWith("b.")) return TENANT_B;
|
|
725
|
+
return null;
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
const authConfigFeature = createConfigFeature();
|
|
729
|
+
const hostTenant = (host: string): string | null => {
|
|
730
|
+
if (host.startsWith("a.")) return TENANT_A;
|
|
731
|
+
if (host.startsWith("b.")) return TENANT_B;
|
|
732
|
+
return null;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
beforeAll(async () => {
|
|
736
|
+
const resolver = createConfigResolver();
|
|
737
|
+
authStack = await setupTestStack({
|
|
738
|
+
features: [authConfigFeature, authManaged],
|
|
739
|
+
anonymousAccess: {
|
|
740
|
+
// KEIN URL-Fallback — spiegelt publicstatus' echten Resolver
|
|
741
|
+
// (resolveSubdomain(c.req.header("Host") ?? "")). Ein interner
|
|
742
|
+
// Self-Fetch trägt keinen Host-Header (Host steckt nur in der URL,
|
|
743
|
+
// nicht in Headers) — ein Resolver, der nur den Header liest, löst
|
|
744
|
+
// für den inneren Request also null auf. Mit URL-Fallback würde
|
|
745
|
+
// dieser Test den Bug maskieren.
|
|
746
|
+
tenantResolver: (c) => hostTenant(c.req.header("host") ?? ""),
|
|
747
|
+
resolverTrust: "authoritative",
|
|
748
|
+
tenantExists: async (id) => id === TENANT_A || id === TENANT_B,
|
|
749
|
+
},
|
|
750
|
+
extraContext: ({ registry }) => ({
|
|
751
|
+
configResolver: resolver,
|
|
752
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
753
|
+
}),
|
|
754
|
+
});
|
|
755
|
+
await unsafeCreateEntityTable(authStack.db, pageEntity);
|
|
756
|
+
await unsafePushTables(authStack.db, { configValuesTable });
|
|
757
|
+
await createEventsTable(authStack.db);
|
|
758
|
+
await seedPage(authStack.db, {
|
|
759
|
+
tenantId: TENANT_A,
|
|
760
|
+
slug: "about",
|
|
761
|
+
lang: "en",
|
|
762
|
+
title: "About A",
|
|
763
|
+
body: "# Hello from **A**",
|
|
764
|
+
published: true,
|
|
765
|
+
});
|
|
766
|
+
await seedPage(authStack.db, {
|
|
767
|
+
tenantId: TENANT_B,
|
|
768
|
+
slug: "about",
|
|
769
|
+
lang: "en",
|
|
770
|
+
title: "About B",
|
|
771
|
+
body: "# Hello from **B**",
|
|
772
|
+
published: true,
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
afterAll(async () => {
|
|
777
|
+
await authStack.cleanup();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("published Page → 200 (systemQuery-Render-Pfad übersteht authoritative resolver)", async () => {
|
|
781
|
+
const res = await authStack.app.request("http://a.example.com/p/about");
|
|
782
|
+
expect(res.status).toBe(200);
|
|
783
|
+
const html = await res.text();
|
|
784
|
+
expect(html).toContain("About A");
|
|
785
|
+
expect(html).toContain("<strong>A</strong>");
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("Cross-Tenant-Isolation: Host löst den jeweils eigenen Tenant auf", async () => {
|
|
789
|
+
const a = await (await authStack.app.request("http://a.example.com/p/about")).text();
|
|
790
|
+
const b = await (await authStack.app.request("http://b.example.com/p/about")).text();
|
|
791
|
+
expect(a).toContain("About A");
|
|
792
|
+
expect(a).not.toContain("About B");
|
|
793
|
+
expect(b).toContain("About B");
|
|
794
|
+
expect(b).not.toContain("About A");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("X-Tenant-Header auf der Render-Route ist wirkungslos — Host bleibt allein maßgeblich", async () => {
|
|
798
|
+
// Die Render-Route liest den Tenant NUR über resolveApexTenant(host),
|
|
799
|
+
// nie über den Client-Header — anders als bei Query/Write-Dispatch gibt
|
|
800
|
+
// es hier also gar keinen X-Tenant-Override-Vektor. Ein gesetzter,
|
|
801
|
+
// abweichender Header wird schlicht ignoriert: 200, weiterhin A's Page.
|
|
802
|
+
const res = await authStack.app.request("http://a.example.com/p/about", {
|
|
803
|
+
headers: { "X-Tenant": TENANT_B },
|
|
804
|
+
});
|
|
805
|
+
expect(res.status).toBe(200);
|
|
806
|
+
const body = await res.text();
|
|
807
|
+
expect(body).toContain("About A");
|
|
808
|
+
expect(body).not.toContain("About B");
|
|
809
|
+
});
|
|
810
|
+
});
|
|
@@ -34,22 +34,27 @@ const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
|
|
|
34
34
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
35
35
|
|
|
36
36
|
// QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
|
|
37
|
-
// die by-slug-Query via
|
|
38
|
-
// symmetrisch zum legal-pages-Muster).
|
|
37
|
+
// die by-slug-Query via systemQuery in-process (kein Code-Import des
|
|
38
|
+
// Handlers, symmetrisch zum legal-pages-Muster).
|
|
39
39
|
const BY_SLUG_QN = "managed-pages:query:by-slug";
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
|
|
42
|
+
// http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
|
|
43
|
+
// isn't part of the engine's public surface (only the httpRoute-
|
|
44
|
+
// registration types are; same convention as seo/feature.ts's FetchApp).
|
|
45
|
+
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
46
|
+
|
|
47
|
+
// Raw Handler-Return von bySlugQuery (published-only) — systemQuery dispatcht
|
|
48
|
+
// direkt gegen den Handler, keine `{data}`-Envelope wie am /api/query-Wire.
|
|
49
|
+
type BySlugQueryResult = {
|
|
50
|
+
title: string;
|
|
51
|
+
body: string;
|
|
52
|
+
lang: string;
|
|
53
|
+
description: string | null;
|
|
54
|
+
ogImage: string | null;
|
|
55
|
+
version: number;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
} | null;
|
|
53
58
|
|
|
54
59
|
function brandingRevisionSeed(branding: BrandingTokens): string {
|
|
55
60
|
return JSON.stringify([
|
|
@@ -63,14 +68,15 @@ function brandingRevisionSeed(branding: BrandingTokens): string {
|
|
|
63
68
|
]);
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
async function
|
|
70
|
-
|
|
71
|
+
// Never throws: a query failure or malformed result degrades to the
|
|
72
|
+
// unbranded default (branding is decoration, not a hard dependency of the
|
|
73
|
+
// page render).
|
|
74
|
+
async function readBrandingViaSystemQuery(
|
|
75
|
+
systemQuery: SystemQueryFn,
|
|
76
|
+
tenantId: string,
|
|
77
|
+
): Promise<BrandingTokens> {
|
|
71
78
|
try {
|
|
72
|
-
|
|
73
|
-
return coerceBranding(body.data);
|
|
79
|
+
return coerceBranding(await systemQuery(BRANDING_QUERY_QN, {}, tenantId));
|
|
74
80
|
} catch {
|
|
75
81
|
return EMPTY_BRANDING;
|
|
76
82
|
}
|
|
@@ -162,7 +168,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
162
168
|
|
|
163
169
|
return defineFeature("managed-pages", (r) => {
|
|
164
170
|
r.describe(
|
|
165
|
-
"Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
|
|
171
|
+
"Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
|
|
166
172
|
);
|
|
167
173
|
r.uiHints({
|
|
168
174
|
displayLabel: "Managed Pages · Public CMS",
|
|
@@ -205,7 +211,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
205
211
|
method: "GET",
|
|
206
212
|
path: `${basePath}/:slug`,
|
|
207
213
|
anonymous: true,
|
|
208
|
-
handler: async (c, {
|
|
214
|
+
handler: async (c, { systemQuery }) => {
|
|
209
215
|
// `param("slug")` ist string|undefined, weil `path` ein computed
|
|
210
216
|
// Template ist (Hono inferiert `:slug` nur aus String-Literalen).
|
|
211
217
|
const slug = c.req.param("slug");
|
|
@@ -218,31 +224,25 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
218
224
|
const tenantId = await opts.resolveApexTenant(host);
|
|
219
225
|
if (!tenantId) return c.text("not found", 404);
|
|
220
226
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// X-Tenant). Branding is decoration → a failed/empty branding read
|
|
234
|
-
// degrades to the unbranded default, it never blocks the page.
|
|
235
|
-
const [pageRes, brandingRes] = await Promise.all([
|
|
236
|
-
queryReq(BY_SLUG_QN, { slug, lang }),
|
|
237
|
-
queryReq(BRANDING_QUERY_QN, {}),
|
|
227
|
+
// Page + branding read in parallel, beide über systemQuery in-
|
|
228
|
+
// process gegen den host-resolved tenantId erzwungen (kein X-Tenant-
|
|
229
|
+
// Header-Trick über app.fetch — der sähe für einen host-basierten
|
|
230
|
+
// anonymousAccess-Resolver wie ein Client-Override aus und würde
|
|
231
|
+
// von resolverTrust: "authoritative" zurecht abgelehnt; siehe
|
|
232
|
+
// systemQuery's Doc in http-route.ts). Branding ist Deko → ein
|
|
233
|
+
// Fehlschlag degradiert zum unbranded Default, blockt nie die Page.
|
|
234
|
+
const [pageSettled, brandingResult] = await Promise.all([
|
|
235
|
+
systemQuery(BY_SLUG_QN, { slug, lang }, tenantId)
|
|
236
|
+
.then((data) => ({ ok: true as const, data: data as BySlugQueryResult }))
|
|
237
|
+
.catch(() => ({ ok: false as const, data: null as BySlugQueryResult })),
|
|
238
|
+
readBrandingViaSystemQuery(systemQuery, tenantId),
|
|
238
239
|
]);
|
|
239
|
-
if (!
|
|
240
|
+
if (!pageSettled.ok) return c.text("page unavailable", 503);
|
|
240
241
|
|
|
241
|
-
const
|
|
242
|
-
const data = body.data;
|
|
242
|
+
const data = pageSettled.data;
|
|
243
243
|
if (!data) return c.text("not found", 404);
|
|
244
244
|
|
|
245
|
-
const branding =
|
|
245
|
+
const branding = brandingResult;
|
|
246
246
|
|
|
247
247
|
const etag = computeRevisionEtag([
|
|
248
248
|
tenantId,
|