@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +64 -7
- package/src/auth-mfa/handlers/verify.write.ts +8 -3
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/session-timezone-field.ts +7 -0
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.176.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -104,12 +104,10 @@
|
|
|
104
104
|
"./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
|
|
105
105
|
"./feature-toggles": "./src/feature-toggles/index.ts",
|
|
106
106
|
"./feature-toggles/web": "./src/feature-toggles/web/index.ts",
|
|
107
|
-
"./text-content": "./src/text-content/index.ts",
|
|
108
|
-
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
109
|
-
"./text-content/web": "./src/text-content/web/index.ts",
|
|
110
107
|
"./template-resolver": "./src/template-resolver/index.ts",
|
|
111
108
|
"./template-resolver/seeding": "./src/template-resolver/seeding.ts",
|
|
112
109
|
"./template-resolver/testing": "./src/template-resolver/testing.ts",
|
|
110
|
+
"./template-resolver/web": "./src/template-resolver/web/index.ts",
|
|
113
111
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
114
112
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
115
113
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
@@ -122,12 +120,12 @@
|
|
|
122
120
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
123
121
|
},
|
|
124
122
|
"dependencies": {
|
|
125
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
123
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.176.1",
|
|
124
|
+
"@cosmicdrift/kumiko-framework": "0.176.1",
|
|
125
|
+
"@cosmicdrift/kumiko-headless": "0.176.1",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer": "0.176.1",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer-web": "0.176.1",
|
|
128
|
+
"@cosmicdrift/kumiko-types": "0.176.1",
|
|
131
129
|
"@mollie/api-client": "^4.5.0",
|
|
132
130
|
"imapflow": "^1.3.3",
|
|
133
131
|
"mailparser": "^3.9.8",
|
|
@@ -17,17 +17,21 @@ import {
|
|
|
17
17
|
tenantComplianceProfileTable,
|
|
18
18
|
} from "../compliance-profiles/schema/profile-selection";
|
|
19
19
|
import { seedComplianceProfile } from "../compliance-profiles/seeding";
|
|
20
|
-
import {
|
|
21
|
-
import { seedTextBlock } from "../
|
|
22
|
-
import {
|
|
20
|
+
import { createTemplateResolverFeature } from "../template-resolver/feature";
|
|
21
|
+
import { seedTextBlock } from "../template-resolver/seeding";
|
|
22
|
+
import {
|
|
23
|
+
type TemplateResourceRow,
|
|
24
|
+
templateResourceEntity,
|
|
25
|
+
templateResourcesTable,
|
|
26
|
+
} from "../template-resolver/table";
|
|
23
27
|
|
|
24
28
|
let stack: TestStack;
|
|
25
29
|
|
|
26
30
|
beforeAll(async () => {
|
|
27
31
|
stack = await setupTestStack({
|
|
28
|
-
features: [
|
|
32
|
+
features: [createTemplateResolverFeature(), createComplianceProfilesFeature()],
|
|
29
33
|
});
|
|
30
|
-
await unsafeCreateEntityTable(stack.db,
|
|
34
|
+
await unsafeCreateEntityTable(stack.db, templateResourceEntity);
|
|
31
35
|
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
32
36
|
await createEventsTable(stack.db);
|
|
33
37
|
});
|
|
@@ -43,32 +47,32 @@ describe("boot-seed contract", () => {
|
|
|
43
47
|
await seedTextBlock(stack.db, {
|
|
44
48
|
tenantId,
|
|
45
49
|
slug: "imprint",
|
|
46
|
-
|
|
50
|
+
locale: "de",
|
|
47
51
|
title: "Impressum",
|
|
48
|
-
|
|
52
|
+
content: "seed body",
|
|
49
53
|
});
|
|
50
54
|
await seedTextBlock(stack.db, {
|
|
51
55
|
tenantId,
|
|
52
56
|
slug: "imprint",
|
|
53
|
-
|
|
57
|
+
locale: "de",
|
|
54
58
|
title: "Impressum (edited)",
|
|
55
|
-
|
|
59
|
+
content: "admin body",
|
|
56
60
|
ifExists: "update",
|
|
57
61
|
});
|
|
58
62
|
await seedTextBlock(stack.db, {
|
|
59
63
|
tenantId,
|
|
60
64
|
slug: "imprint",
|
|
61
|
-
|
|
65
|
+
locale: "de",
|
|
62
66
|
title: "Impressum",
|
|
63
|
-
|
|
67
|
+
content: "seed body",
|
|
64
68
|
});
|
|
65
69
|
|
|
66
|
-
const row = await fetchOne<
|
|
70
|
+
const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
|
|
67
71
|
tenantId,
|
|
68
72
|
slug: "imprint",
|
|
69
|
-
|
|
73
|
+
locale: "de",
|
|
70
74
|
});
|
|
71
|
-
expect(row).toMatchObject({ title: "Impressum (edited)",
|
|
75
|
+
expect(row).toMatchObject({ title: "Impressum (edited)", content: "admin body", version: 2 });
|
|
72
76
|
|
|
73
77
|
const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
|
|
74
78
|
expect(events).toHaveLength(2);
|
|
@@ -351,6 +351,37 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
351
351
|
expect(memberships).toHaveLength(2);
|
|
352
352
|
});
|
|
353
353
|
|
|
354
|
+
test("Bob accepts with a timezone → JWT retains the timezone", async () => {
|
|
355
|
+
await asRawClient(stack.db).unsafe(
|
|
356
|
+
`UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
|
|
357
|
+
["Europe/Berlin", bobId],
|
|
358
|
+
);
|
|
359
|
+
try {
|
|
360
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
361
|
+
|
|
362
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
363
|
+
token,
|
|
364
|
+
email: BOB_EMAIL,
|
|
365
|
+
password: BOB_PASSWORD,
|
|
366
|
+
});
|
|
367
|
+
expect(res.status).toBe(200);
|
|
368
|
+
const body = (await res.json()) as { token?: string };
|
|
369
|
+
expect(body.token).toBeTypeOf("string");
|
|
370
|
+
if (!body.token) throw new Error("invite acceptance did not return a token");
|
|
371
|
+
|
|
372
|
+
const payload = await stack.jwt.verify(body.token);
|
|
373
|
+
expect(payload.timezone).toBe("Europe/Berlin");
|
|
374
|
+
} finally {
|
|
375
|
+
// Bob is shared across this describe block (other tests rely on his
|
|
376
|
+
// membership state persisting) — restore his timezone so later tests
|
|
377
|
+
// don't silently inherit it.
|
|
378
|
+
await asRawClient(stack.db).unsafe(
|
|
379
|
+
`UPDATE "${userTable.tableName}" SET timezone = NULL WHERE id = $1`,
|
|
380
|
+
[bobId],
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
354
385
|
test("Wrong password → 422 invalid_invite_token (anti-enum)", async () => {
|
|
355
386
|
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
356
387
|
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
29
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
-
import { decryptStoredPii, verifyPassword } from "../../shared";
|
|
31
|
+
import { decryptStoredPii, sessionTimezoneField, verifyPassword } from "../../shared";
|
|
32
32
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
33
33
|
import {
|
|
34
34
|
INVITATION_STATUS,
|
|
@@ -96,7 +96,11 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
96
96
|
readonly role: string;
|
|
97
97
|
readonly version: number;
|
|
98
98
|
};
|
|
99
|
-
type UserAuthRow = {
|
|
99
|
+
type UserAuthRow = {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly passwordHash: string | null;
|
|
102
|
+
readonly timezone?: string | null;
|
|
103
|
+
};
|
|
100
104
|
|
|
101
105
|
let committed = false;
|
|
102
106
|
try {
|
|
@@ -173,6 +177,7 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
173
177
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
174
178
|
// a reserved role on the invitation itself must never reach the session.
|
|
175
179
|
roles: buildSessionRoles([], [invitationRole]),
|
|
180
|
+
...sessionTimezoneField(userRow.timezone),
|
|
176
181
|
};
|
|
177
182
|
|
|
178
183
|
committed = true;
|
|
@@ -14,9 +14,12 @@ import {
|
|
|
14
14
|
expectErrorIncludes,
|
|
15
15
|
seedRow,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
+
import { AuthHandlers as AuthEmailPasswordHandlers } from "../../auth-email-password/constants";
|
|
18
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
19
|
import { createConfigFeature } from "../../config";
|
|
18
20
|
import { createConfigResolver } from "../../config/resolver";
|
|
19
21
|
import { configValuesTable } from "../../config/table";
|
|
22
|
+
import { hashPassword } from "../../shared";
|
|
20
23
|
import { createTenantFeature } from "../../tenant";
|
|
21
24
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
22
25
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
@@ -25,7 +28,7 @@ import { createUserFeature } from "../../user/feature";
|
|
|
25
28
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
26
29
|
import { base32Decode } from "../base32";
|
|
27
30
|
import { AuthMfaHandlers } from "../constants";
|
|
28
|
-
import { createAuthMfaFeature } from "../feature";
|
|
31
|
+
import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
|
|
29
32
|
import { signMfaChallengeToken } from "../mfa-challenge-token";
|
|
30
33
|
import { userMfaEntity } from "../schema/user-mfa";
|
|
31
34
|
import { currentTotpCode } from "../totp";
|
|
@@ -49,18 +52,26 @@ beforeAll(async () => {
|
|
|
49
52
|
const encryption = createTestEnvelopeCipher();
|
|
50
53
|
configureEntityFieldEncryption(encryption);
|
|
51
54
|
const resolver = createConfigResolver({ cipher: encryption });
|
|
55
|
+
const authMfaFeature = createAuthMfaFeature({
|
|
56
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
57
|
+
issuer: "Kumiko Test",
|
|
58
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
59
|
+
});
|
|
52
60
|
stack = await setupTestStack({
|
|
53
61
|
features: [
|
|
54
62
|
createConfigFeature(),
|
|
55
63
|
createUserFeature(),
|
|
56
64
|
createTenantFeature(),
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
65
|
+
authMfaFeature,
|
|
66
|
+
createAuthEmailPasswordFeature({
|
|
67
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
61
68
|
}),
|
|
62
69
|
],
|
|
63
70
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
71
|
+
authConfig: {
|
|
72
|
+
membershipQuery: "tenant:query:memberships",
|
|
73
|
+
loginHandler: AuthEmailPasswordHandlers.login,
|
|
74
|
+
},
|
|
64
75
|
});
|
|
65
76
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
66
77
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
@@ -76,8 +87,10 @@ async function enableMfaFor(idSeed: number): Promise<{
|
|
|
76
87
|
user: ReturnType<typeof createTestUser>;
|
|
77
88
|
secret: Buffer;
|
|
78
89
|
recoveryCodes: string[];
|
|
90
|
+
password: string;
|
|
79
91
|
}> {
|
|
80
92
|
const user = createTestUser({ id: idSeed, roles: ["User"] });
|
|
93
|
+
const password = `password-${idSeed}-long-enough`;
|
|
81
94
|
const start = await stack.http.writeOk<{
|
|
82
95
|
setupToken: string;
|
|
83
96
|
otpauthUri: string;
|
|
@@ -103,14 +116,14 @@ async function enableMfaFor(idSeed: number): Promise<{
|
|
|
103
116
|
id: user.id,
|
|
104
117
|
tenantId: user.tenantId,
|
|
105
118
|
email: `user-${user.id}@example.com`,
|
|
106
|
-
passwordHash:
|
|
119
|
+
passwordHash: await hashPassword(password),
|
|
107
120
|
displayName: `Test User ${idSeed}`,
|
|
108
121
|
locale: "de",
|
|
109
122
|
emailVerified: true,
|
|
110
123
|
roles: "[]",
|
|
111
124
|
status: USER_STATUS.Active,
|
|
112
125
|
});
|
|
113
|
-
return { user, secret, recoveryCodes: start.recoveryCodes };
|
|
126
|
+
return { user, secret, recoveryCodes: start.recoveryCodes, password };
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
function challengeFor(userId: string, tenantId: TenantId): string {
|
|
@@ -131,6 +144,50 @@ describe("mfa verify — completes a two-step login", () => {
|
|
|
131
144
|
expect(res.session.tenantId).toBe(user.tenantId);
|
|
132
145
|
});
|
|
133
146
|
|
|
147
|
+
test("a password login followed by MFA verify retains the user's timezone", async () => {
|
|
148
|
+
const { user, password, secret } = await enableMfaFor(9);
|
|
149
|
+
await asRawClient(stack.db).unsafe(
|
|
150
|
+
`UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
|
|
151
|
+
["Europe/Berlin", user.id],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
155
|
+
email: `user-${user.id}@example.com`,
|
|
156
|
+
password,
|
|
157
|
+
});
|
|
158
|
+
expect(loginRes.status).toBe(200);
|
|
159
|
+
const login = (await loginRes.json()) as { challengeToken?: string };
|
|
160
|
+
expect(login.challengeToken).toBeTypeOf("string");
|
|
161
|
+
if (!login.challengeToken) throw new Error("MFA login did not return a challenge token");
|
|
162
|
+
|
|
163
|
+
const verified = await stack.http.writeOk<{ session: SessionUser }>(
|
|
164
|
+
AuthMfaHandlers.verify,
|
|
165
|
+
{ challengeToken: login.challengeToken, code: currentTotpCode(secret) },
|
|
166
|
+
GUEST,
|
|
167
|
+
);
|
|
168
|
+
expect(verified.session.timezone).toBe("Europe/Berlin");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a password login followed by MFA verify omits an unset timezone", async () => {
|
|
172
|
+
const { user, password, secret } = await enableMfaFor(10);
|
|
173
|
+
|
|
174
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
175
|
+
email: `user-${user.id}@example.com`,
|
|
176
|
+
password,
|
|
177
|
+
});
|
|
178
|
+
expect(loginRes.status).toBe(200);
|
|
179
|
+
const login = (await loginRes.json()) as { challengeToken?: string };
|
|
180
|
+
expect(login.challengeToken).toBeTypeOf("string");
|
|
181
|
+
if (!login.challengeToken) throw new Error("MFA login did not return a challenge token");
|
|
182
|
+
|
|
183
|
+
const verified = await stack.http.writeOk<{ session: SessionUser }>(
|
|
184
|
+
AuthMfaHandlers.verify,
|
|
185
|
+
{ challengeToken: login.challengeToken, code: currentTotpCode(secret) },
|
|
186
|
+
GUEST,
|
|
187
|
+
);
|
|
188
|
+
expect(verified.session.timezone).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
134
191
|
test("a wrong TOTP code is rejected", async () => {
|
|
135
192
|
const { user } = await enableMfaFor(2);
|
|
136
193
|
const challengeToken = challengeFor(user.id, user.tenantId);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { burnToken } from "../../shared";
|
|
11
|
+
import { burnToken, sessionTimezoneField } from "../../shared";
|
|
12
12
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
13
13
|
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
14
14
|
import { findUserMfaRow } from "../db/queries";
|
|
@@ -135,7 +135,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
135
135
|
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
136
136
|
const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
137
137
|
id: userId,
|
|
138
|
-
})) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
|
|
138
|
+
})) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
|
|
139
139
|
|
|
140
140
|
// Re-check status + membership the way login.write.ts does after its
|
|
141
141
|
// password check — the challenge token only proves "password was
|
|
@@ -168,7 +168,12 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
168
168
|
// read-time backstop against a rebuild-resurrected role.
|
|
169
169
|
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
170
170
|
|
|
171
|
-
const baseSession: SessionUser = {
|
|
171
|
+
const baseSession: SessionUser = {
|
|
172
|
+
id: userId,
|
|
173
|
+
tenantId,
|
|
174
|
+
roles: mergedRoles,
|
|
175
|
+
...sessionTimezoneField(userRow?.timezone),
|
|
176
|
+
};
|
|
172
177
|
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
173
178
|
const session: SessionUser =
|
|
174
179
|
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# legal-pages
|
|
2
2
|
|
|
3
|
-
Opt-in wrapper around [`
|
|
3
|
+
Opt-in wrapper around [`template-resolver`](../template-resolver/) text-blocks for
|
|
4
4
|
DACH compliance. Ships four fixed public HTML routes
|
|
5
5
|
(`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
|
|
6
6
|
`/legal/privacy`) with Markdown→HTML rendering and a boot check that
|
|
@@ -17,25 +17,25 @@ feature.
|
|
|
17
17
|
```typescript
|
|
18
18
|
import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
|
|
19
19
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "@cosmicdrift/kumiko-bundled-features/
|
|
20
|
+
createTemplateResolverApi,
|
|
21
|
+
createTemplateResolverFeature,
|
|
22
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
23
23
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
24
|
|
|
25
25
|
runProdApp({
|
|
26
26
|
features: [
|
|
27
|
-
|
|
27
|
+
createTemplateResolverFeature(), // legal-pages requires template-resolver
|
|
28
28
|
createLegalPagesFeature(),
|
|
29
29
|
/* ... */
|
|
30
30
|
],
|
|
31
31
|
// Two wirings are required:
|
|
32
32
|
// 1. anonymousAccess for /legal/* routes (run without a JWT)
|
|
33
|
-
// 2. extraContext.
|
|
34
|
-
// decoupling — legal-pages imports no code from
|
|
33
|
+
// 2. extraContext.templateResolver for the boot check (cross-feature
|
|
34
|
+
// decoupling — legal-pages imports no code from template-resolver,
|
|
35
35
|
// only uses the API via ctx)
|
|
36
36
|
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
37
37
|
extraContext: ({ db }) => ({
|
|
38
|
-
|
|
38
|
+
templateResolver: createTemplateResolverApi(db),
|
|
39
39
|
}),
|
|
40
40
|
});
|
|
41
41
|
```
|
|
@@ -44,19 +44,20 @@ runProdApp({
|
|
|
44
44
|
|
|
45
45
|
### Production table setup
|
|
46
46
|
|
|
47
|
-
legal-pages doesn't have its own table — it
|
|
48
|
-
`
|
|
47
|
+
legal-pages doesn't have its own table — it stores its blocks as
|
|
48
|
+
`kind: "text-block"` rows in template-resolver's `read_template_resources`.
|
|
49
|
+
Table setup therefore goes through template-resolver:
|
|
49
50
|
|
|
50
51
|
```bash
|
|
51
|
-
bun kumiko
|
|
52
|
-
bun kumiko
|
|
52
|
+
bun kumiko schema generate <name> # template-resource entity is detected
|
|
53
|
+
bun kumiko schema apply
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
See [
|
|
56
|
+
See [template-resolver/README.md](../template-resolver/README.md).
|
|
56
57
|
|
|
57
58
|
## Routes
|
|
58
59
|
|
|
59
|
-
| Path | Slug +
|
|
60
|
+
| Path | Slug + locale | Title fallback (when block empty) |
|
|
60
61
|
|---|---|---|
|
|
61
62
|
| `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
|
|
62
63
|
| `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
|
|
@@ -64,12 +65,12 @@ See [text-content/README.md](../text-content/README.md#production-table-setup).
|
|
|
64
65
|
| `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
|
|
65
66
|
|
|
66
67
|
Response:
|
|
67
|
-
- `200 text/html` — block exists + has
|
|
68
|
+
- `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
|
|
68
69
|
- `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
|
|
69
70
|
- `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
|
|
70
71
|
|
|
71
72
|
Layout: a minimal HTML5 skeleton with inline CSS — apps that want to
|
|
72
|
-
integrate into their own layout use `
|
|
73
|
+
integrate into their own layout use `template-resolver:query:by-slug`
|
|
73
74
|
directly and render themselves.
|
|
74
75
|
|
|
75
76
|
---
|
|
@@ -79,7 +80,7 @@ directly and render themselves.
|
|
|
79
80
|
`r.job` with `runOnBoot: true` checks at app start whether the DE
|
|
80
81
|
required blocks exist in SYSTEM_TENANT:
|
|
81
82
|
|
|
82
|
-
| Slug +
|
|
83
|
+
| Slug + locale | What happens when missing |
|
|
83
84
|
|---|---|
|
|
84
85
|
| `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.log.warn(...)` |
|
|
85
86
|
| `privacy` / `de` | as above |
|
|
@@ -104,18 +105,18 @@ await fetch("/api/write", {
|
|
|
104
105
|
method: "POST",
|
|
105
106
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
|
|
106
107
|
body: JSON.stringify({
|
|
107
|
-
type: "
|
|
108
|
+
type: "template-resolver:write:set",
|
|
108
109
|
payload: {
|
|
109
110
|
slug: "imprint",
|
|
110
|
-
|
|
111
|
+
locale: "de",
|
|
111
112
|
title: "Impressum",
|
|
112
|
-
|
|
113
|
+
content: "## Angaben gemäß § 5 TMG\n\n...",
|
|
113
114
|
},
|
|
114
115
|
}),
|
|
115
116
|
});
|
|
116
117
|
```
|
|
117
118
|
|
|
118
|
-
→ Idempotent: a second call with the same `(slug,
|
|
119
|
+
→ Idempotent: a second call with the same `(slug, locale)` updates the block.
|
|
119
120
|
ACL: `roles: ["TenantAdmin", "SystemAdmin"]` — SystemAdmin (a global
|
|
120
121
|
role) may set SYSTEM_TENANT texts, TenantAdmin only tenant-owned ones.
|
|
121
122
|
|
|
@@ -128,15 +129,15 @@ instant visibility, you can help things along with a CDN purge.
|
|
|
128
129
|
On first app boot or via migration:
|
|
129
130
|
|
|
130
131
|
```typescript
|
|
131
|
-
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/
|
|
132
|
+
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
|
|
132
133
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
133
134
|
|
|
134
135
|
await seedTextBlock(db, {
|
|
135
136
|
tenantId: SYSTEM_TENANT_ID,
|
|
136
137
|
slug: "imprint",
|
|
137
|
-
|
|
138
|
+
locale: "de",
|
|
138
139
|
title: "Impressum",
|
|
139
|
-
|
|
140
|
+
content: `## Angaben gemäß § 5 TMG
|
|
140
141
|
|
|
141
142
|
**Marc Frost**
|
|
142
143
|
|
|
@@ -181,7 +182,7 @@ no DOMPurify dependency needed:
|
|
|
181
182
|
Kumiko app share the SYSTEM_TENANT version of the legal pages. If you
|
|
182
183
|
need per-tenant imprints (rare — typical case: the platform operator
|
|
183
184
|
is the responsible party, not the tenant customer), call
|
|
184
|
-
|
|
185
|
+
the by-slug query directly with a tenant-specific TenantId
|
|
185
186
|
and put your own routes in front.
|
|
186
187
|
|
|
187
188
|
---
|