@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.2
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/feature.ts +1 -1
- 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.`,
|
|
@@ -162,7 +162,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
162
162
|
|
|
163
163
|
return defineFeature("managed-pages", (r) => {
|
|
164
164
|
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.",
|
|
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,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
166
|
);
|
|
167
167
|
r.uiHints({
|
|
168
168
|
displayLabel: "Managed Pages · Public CMS",
|
package/src/sessions/feature.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { revokeWrite } from "./handlers/revoke.write";
|
|
|
7
7
|
import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
8
8
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
9
9
|
import { userSessionEntity } from "./schema/user-session";
|
|
10
|
-
import type { SessionMassRevoker } from "./session-callbacks";
|
|
10
|
+
import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
|
|
11
11
|
|
|
12
12
|
export type SessionsFeatureOptions = {
|
|
13
13
|
// A successful update on the `user` entity that changes the `passwordHash`
|
|
@@ -29,6 +29,7 @@ export type SessionsFeatureOptions = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
32
|
+
export type BindRevokeAllOtherSessions = (revoker: SessionAllOthersRevoker) => void;
|
|
32
33
|
|
|
33
34
|
// Reads the late-bind setter off a mounted sessions feature's exports.
|
|
34
35
|
// run{Prod,Dev}App call it once the DB connection is concrete — the feature
|
|
@@ -33,6 +33,16 @@ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
|
|
|
33
33
|
// caller can log "revoked N other sessions".
|
|
34
34
|
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
35
35
|
|
|
36
|
+
// "Sign out everywhere else, keep this one" — used both by the user-facing
|
|
37
|
+
// revoke-all-others handler and by other features (auth-mfa) that need the
|
|
38
|
+
// same effect from a raw callback instead of a dispatcher round-trip.
|
|
39
|
+
// currentSid undefined (stateless-JWT / no sid claim) revokes everything —
|
|
40
|
+
// there is no "current" row to spare.
|
|
41
|
+
export type SessionAllOthersRevoker = (
|
|
42
|
+
userId: string,
|
|
43
|
+
currentSid: string | undefined,
|
|
44
|
+
) => Promise<number>;
|
|
45
|
+
|
|
36
46
|
export type SessionCallbacksOptions = {
|
|
37
47
|
readonly db: DbConnection;
|
|
38
48
|
// Session lifetime. MVP uses a single flat window; per-app policies can
|
|
@@ -45,6 +55,7 @@ export type SessionCallbacks = {
|
|
|
45
55
|
sessionRevoker: SessionRevoker;
|
|
46
56
|
sessionChecker: SessionChecker;
|
|
47
57
|
sessionMassRevoker: SessionMassRevoker;
|
|
58
|
+
sessionRevokeAllOthers: SessionAllOthersRevoker;
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCallbacks {
|
|
@@ -135,5 +146,17 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
135
146
|
);
|
|
136
147
|
return result.length;
|
|
137
148
|
},
|
|
149
|
+
|
|
150
|
+
async sessionRevokeAllOthers(userId: string, currentSid: string | undefined): Promise<number> {
|
|
151
|
+
const result = await updateMany(
|
|
152
|
+
db,
|
|
153
|
+
userSessionTable,
|
|
154
|
+
{ revokedAt: Temporal.Now.instant() },
|
|
155
|
+
currentSid
|
|
156
|
+
? { userId, revokedAt: null, id: { ne: currentSid } }
|
|
157
|
+
: { userId, revokedAt: null },
|
|
158
|
+
);
|
|
159
|
+
return result.length;
|
|
160
|
+
},
|
|
138
161
|
};
|
|
139
162
|
}
|
package/src/shared/index.ts
CHANGED
|
@@ -9,3 +9,4 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
|
|
|
9
9
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write";
|
|
10
10
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
11
11
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
12
|
+
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Single-use enforcement for HMAC-signed auth tokens (password-reset,
|
|
2
|
-
// email-verification).
|
|
2
|
+
// email-verification, MFA setup/challenge).
|
|
3
3
|
//
|
|
4
4
|
// Problem: the token itself carries only userId + expiry + signature.
|
|
5
5
|
// Without server-side burn, the same token can be replayed within its
|