@cosmicdrift/kumiko-samples 0.339.0 → 0.341.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 +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
|
@@ -1 +1,13 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Uploads resolve a generic content type from the file content",
|
|
6
|
+
"detail": "POST /api/files now resolves application/octet-stream or a missing type from the bytes (PDF, XML, PNG, JPEG, GIF, WebP, ZIP) and falls back to the file extension. A concrete declared type is never overridden. The shared helper resolveContentType is exported from the files module."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.341.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Uploads without entityId no longer produce an empty storage key segment"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Inbound mail projections use typed bun-db helpers",
|
|
6
|
+
"detail": "Mail-account and mail-thread events upsert through `upsertOnConflict` (`connectedAt` is only written on insert), inbound messages insert through `insertOnConflictDoNothing`. The raw `db/queries/inbound-projections.ts` module is gone.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.339.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
// write-handler. Die applies kopieren nur durch; decrypted wird erst
|
|
18
18
|
// in den list-queries.
|
|
19
19
|
|
|
20
|
-
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
-
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
20
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
insertOnConflictDoNothing,
|
|
22
|
+
requireEntityTableMeta,
|
|
23
|
+
upsertOnConflict,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
25
|
+
import { buildEntityTable, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
27
|
import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.js";
|
|
27
28
|
import type {
|
|
28
29
|
InboundMessageEventPayload,
|
|
@@ -39,9 +40,20 @@ export const inboundMessagesProjectionTable = buildEntityTable(
|
|
|
39
40
|
);
|
|
40
41
|
export const mailThreadsProjectionTable = buildEntityTable("mail-thread", mailThreadEntity);
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Unbranded metas: the apply functions below ARE the executor of these
|
|
44
|
+
// projections, so they may write through the typed helpers.
|
|
45
|
+
const mailAccountsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
46
|
+
mailAccountsProjectionTable,
|
|
47
|
+
"mail-account",
|
|
48
|
+
);
|
|
49
|
+
const inboundMessagesProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
50
|
+
inboundMessagesProjectionTable,
|
|
51
|
+
"inbound-message",
|
|
52
|
+
);
|
|
53
|
+
const mailThreadsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
54
|
+
mailThreadsProjectionTable,
|
|
55
|
+
"mail-thread",
|
|
56
|
+
);
|
|
45
57
|
|
|
46
58
|
// =============================================================================
|
|
47
59
|
// mail-account — connected/updated/disconnected teilen den payload-shape,
|
|
@@ -49,40 +61,30 @@ function tableNameOf(table: unknown): string {
|
|
|
49
61
|
// defensive consistency (rebuild-aus-dem-Nichts, out-of-order).
|
|
50
62
|
// =============================================================================
|
|
51
63
|
|
|
52
|
-
const ACCOUNT_SET_CLAUSES = [
|
|
53
|
-
`"provider" = EXCLUDED."provider"`,
|
|
54
|
-
`"auth_method" = EXCLUDED."auth_method"`,
|
|
55
|
-
`"owner_user_id" = EXCLUDED."owner_user_id"`,
|
|
56
|
-
`"display_name" = EXCLUDED."display_name"`,
|
|
57
|
-
`"address" = EXCLUDED."address"`,
|
|
58
|
-
`"status" = EXCLUDED."status"`,
|
|
59
|
-
`"watch_state" = EXCLUDED."watch_state"`,
|
|
60
|
-
// connected_at bewusst NICHT im SET: der Erst-Connect-Zeitpunkt
|
|
61
|
-
// bleibt stehen, updated/disconnected überschreiben ihn nicht.
|
|
62
|
-
];
|
|
63
|
-
|
|
64
64
|
const applyMailAccountUpsert = defineApply<MailAccountEventPayload>(async (event, tx) => {
|
|
65
65
|
const p = event.payload;
|
|
66
|
-
const
|
|
67
|
-
id: event.aggregateId,
|
|
68
|
-
tenant_id: event.tenantId,
|
|
66
|
+
const mutable = {
|
|
69
67
|
provider: p.provider,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
authMethod: p.authMethod,
|
|
69
|
+
ownerUserId: p.ownerUserId,
|
|
70
|
+
displayName: p.displayName,
|
|
73
71
|
address: p.address,
|
|
74
72
|
status: p.status,
|
|
75
|
-
|
|
76
|
-
// Insert-Pfad = Erst-Connect (bzw. Rebuild: createdAt des ersten
|
|
77
|
-
// events des Streams — fachlich derselbe Zeitpunkt).
|
|
78
|
-
connected_at: event.createdAt.toString(),
|
|
73
|
+
watchState: p.watchState,
|
|
79
74
|
};
|
|
80
|
-
await
|
|
75
|
+
await upsertOnConflict(
|
|
81
76
|
tx,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
mailAccountsProjectionMeta,
|
|
78
|
+
{
|
|
79
|
+
id: event.aggregateId,
|
|
80
|
+
tenantId: event.tenantId,
|
|
81
|
+
...mutable,
|
|
82
|
+
// Only the insert path is the first connect (on rebuild: createdAt of the
|
|
83
|
+
// stream's first event, the same moment). Left out of `update` so the
|
|
84
|
+
// first-connect time survives updated/disconnected events.
|
|
85
|
+
connectedAt: event.createdAt,
|
|
86
|
+
},
|
|
87
|
+
{ conflictKeys: ["id"], update: mutable },
|
|
86
88
|
);
|
|
87
89
|
});
|
|
88
90
|
|
|
@@ -104,20 +106,20 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
|
|
|
104
106
|
export const applyInboundMessageReceived = defineApply<InboundMessageEventPayload>(
|
|
105
107
|
async (event, tx) => {
|
|
106
108
|
const p = event.payload;
|
|
107
|
-
await
|
|
109
|
+
await insertOnConflictDoNothing(tx, inboundMessagesProjectionMeta, {
|
|
108
110
|
id: event.aggregateId,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
tenantId: event.tenantId,
|
|
112
|
+
accountId: p.accountId,
|
|
113
|
+
ownerUserId: p.ownerUserId,
|
|
114
|
+
messageIdHeader: p.messageIdHeader,
|
|
115
|
+
threadKey: p.threadKey,
|
|
114
116
|
from: p.from,
|
|
115
117
|
to: p.to,
|
|
116
118
|
cc: p.cc,
|
|
117
119
|
subject: p.subject,
|
|
118
120
|
snippet: p.snippet,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
receivedAt: p.receivedAtIso,
|
|
122
|
+
bodyRef: p.bodyRef,
|
|
121
123
|
scope: p.scope,
|
|
122
124
|
});
|
|
123
125
|
},
|
|
@@ -133,23 +135,20 @@ export const applyInboundMessageReceived = defineApply<InboundMessageEventPayloa
|
|
|
133
135
|
/** mail-thread-updated → UPSERT full mit dem Payload-Snapshot. */
|
|
134
136
|
export const applyMailThreadUpdated = defineApply<MailThreadEventPayload>(async (event, tx) => {
|
|
135
137
|
const p = event.payload;
|
|
136
|
-
const
|
|
137
|
-
id: event.aggregateId,
|
|
138
|
-
tenant_id: event.tenantId,
|
|
139
|
-
thread_key: p.threadKey,
|
|
138
|
+
const mutable = {
|
|
140
139
|
subject: p.subject,
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
lastMessageAt: p.lastMessageAtIso,
|
|
141
|
+
messageCount: p.messageCount,
|
|
143
142
|
};
|
|
144
|
-
await
|
|
143
|
+
await upsertOnConflict(
|
|
145
144
|
tx,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
mailThreadsProjectionMeta,
|
|
146
|
+
{
|
|
147
|
+
id: event.aggregateId,
|
|
148
|
+
tenantId: event.tenantId,
|
|
149
|
+
threadKey: p.threadKey,
|
|
150
|
+
...mutable,
|
|
151
|
+
},
|
|
152
|
+
{ conflictKeys: ["id"], update: mutable },
|
|
154
153
|
);
|
|
155
154
|
});
|
|
@@ -71,9 +71,32 @@ Response:
|
|
|
71
71
|
- `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
|
|
72
72
|
- `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
|
|
73
73
|
|
|
74
|
-
Layout: a minimal HTML5 skeleton with inline CSS
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
Layout: a minimal HTML5 skeleton with inline CSS. Apps that want their own
|
|
75
|
+
header, footer and theme pass `createLegalPagesFeature({ wrapLayout })`, a
|
|
76
|
+
`PublicPageWrapLayout` (`@cosmicdrift/kumiko-bundled-features/page-render`):
|
|
77
|
+
`({ title, bodyHtml, lang, slug?, alternates? }) => string`. The framework
|
|
78
|
+
keeps setting the security headers; the layout only returns the HTML.
|
|
79
|
+
|
|
80
|
+
The page CSP is `script-src 'none'`, so the layout must not need JavaScript
|
|
81
|
+
(a language switch has to be plain links).
|
|
82
|
+
|
|
83
|
+
Trailing slash: every route also answers its other slash form
|
|
84
|
+
(`/legal/impressum/`) with a `301` to the configured path, for `GET` and `HEAD`,
|
|
85
|
+
query string kept. A path configured with a trailing slash redirects the form
|
|
86
|
+
without it.
|
|
87
|
+
|
|
88
|
+
### Contract termination pages
|
|
89
|
+
|
|
90
|
+
`createContractTerminationRoutes({ paths?, wrapLayout? })` (billing-foundation,
|
|
91
|
+
consumer protection) serves `/legal/kuendigen` and `/legal/cancel`. It takes the
|
|
92
|
+
same `wrapLayout` and wraps every page with it: form, review, result, 429 and
|
|
93
|
+
error. `alternates` maps `de`/`en` to the configured paths (custom `paths`
|
|
94
|
+
included), so the layout can link to the other language. The body sits in
|
|
95
|
+
`<div data-kumiko-page="contract-termination">` with `data-kumiko-*` hooks on
|
|
96
|
+
the forms, field groups, buttons and review table for app CSS. The headers (CSP
|
|
97
|
+
with `script-src 'none'`, `frame-ancestors 'none'`, `X-Frame-Options: DENY`,
|
|
98
|
+
`no-store`) are the same with and without `wrapLayout`, so a layout that loads
|
|
99
|
+
scripts fails. The slash redirect applies here too; `POST` has no alias.
|
|
77
100
|
|
|
78
101
|
---
|
|
79
102
|
|
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
requireTemplateResolver,
|
|
3
|
-
TEXT_BLOCK_KIND,
|
|
4
|
-
type TemplateResolverApi,
|
|
5
|
-
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
6
1
|
import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
|
|
7
2
|
import {
|
|
8
3
|
defineFeature,
|
|
9
4
|
type FeatureDefinition,
|
|
10
5
|
SYSTEM_TENANT_ID,
|
|
11
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
cachedSecurePageResponse,
|
|
9
|
+
type PublicPageWrapLayout,
|
|
10
|
+
redirectToCanonicalPath,
|
|
11
|
+
slashVariantOf,
|
|
12
|
+
} from "../page-render/index.js";
|
|
13
13
|
import {
|
|
14
14
|
LEGAL_REQUIRED_BLOCKS,
|
|
15
15
|
LEGAL_ROUTES,
|
|
16
16
|
type LegalPageRoute,
|
|
17
17
|
type LegalRequiredBlock,
|
|
18
18
|
} from "./constants.js";
|
|
19
|
+
import { runLegalPagesBootCheck } from "./lib/boot-check.js";
|
|
20
|
+
import { validateRoutes } from "./lib/validate-routes.js";
|
|
19
21
|
import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
|
|
20
22
|
|
|
23
|
+
export { type LegalPagesBootCheckCtx, runLegalPagesBootCheck } from "./lib/boot-check.js";
|
|
24
|
+
|
|
21
25
|
// QN-Konstante als dokumentierter Public-Contract des template-resolver-
|
|
22
26
|
// Features. Ein magic-string statt eines Code-Imports ist hier explizit
|
|
23
27
|
// gewollt: Cross-Feature-Calls gehen nur über stable Public-API
|
|
@@ -59,12 +63,7 @@ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
|
59
63
|
// setzen — sonst wirft Boot-Check beim Start
|
|
60
64
|
// • anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID } — sonst
|
|
61
65
|
// antworten die Routes mit 503
|
|
62
|
-
export type LegalPagesWrapLayout =
|
|
63
|
-
readonly title: string;
|
|
64
|
-
readonly bodyHtml: string;
|
|
65
|
-
readonly lang: string;
|
|
66
|
-
readonly slug?: string;
|
|
67
|
-
}) => string;
|
|
66
|
+
export type LegalPagesWrapLayout = PublicPageWrapLayout;
|
|
68
67
|
|
|
69
68
|
export type LegalPagesOptions = {
|
|
70
69
|
/** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
|
|
@@ -86,26 +85,6 @@ export type LegalPagesOptions = {
|
|
|
86
85
|
readonly requiredBlocks?: readonly LegalRequiredBlock[];
|
|
87
86
|
};
|
|
88
87
|
|
|
89
|
-
// Validated once at feature-build time (not per-request) so a config typo
|
|
90
|
-
// fails app startup instead of surfacing as a confusing 404 later.
|
|
91
|
-
function validateRoutes(routes: readonly LegalPageRoute[]): void {
|
|
92
|
-
const seenPaths = new Set<string>();
|
|
93
|
-
for (const route of routes) {
|
|
94
|
-
if (!route.path || !route.slug || !route.lang) {
|
|
95
|
-
throw new Error(
|
|
96
|
-
`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
if (!route.path.startsWith("/")) {
|
|
100
|
-
throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
|
|
101
|
-
}
|
|
102
|
-
if (seenPaths.has(route.path)) {
|
|
103
|
-
throw new Error(`legal-pages: duplicate route path "${route.path}"`);
|
|
104
|
-
}
|
|
105
|
-
seenPaths.add(route.path);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
88
|
export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
|
|
110
89
|
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
111
90
|
const routes = opts.routes ?? LEGAL_ROUTES;
|
|
@@ -138,6 +117,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
138
117
|
method: "GET",
|
|
139
118
|
path: route.path,
|
|
140
119
|
anonymous: true,
|
|
120
|
+
// Public pages backed by a DB query: bound the per-IP read rate.
|
|
121
|
+
rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
|
|
141
122
|
handler: async (c, { systemQuery }) => {
|
|
142
123
|
// Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
|
|
143
124
|
// Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
|
|
@@ -208,6 +189,20 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
208
189
|
});
|
|
209
190
|
}
|
|
210
191
|
|
|
192
|
+
// Hono matches strictly, so "/legal/x/" would 404 for sites that link with
|
|
193
|
+
// a trailing slash. GET also serves HEAD.
|
|
194
|
+
const configuredPaths = new Set(routes.map((route) => route.path));
|
|
195
|
+
for (const route of routes) {
|
|
196
|
+
const variant = slashVariantOf(route.path);
|
|
197
|
+
if (variant === null || configuredPaths.has(variant)) continue;
|
|
198
|
+
r.httpRoute({
|
|
199
|
+
method: "GET",
|
|
200
|
+
path: variant,
|
|
201
|
+
anonymous: true,
|
|
202
|
+
handler: (c) => redirectToCanonicalPath(c.req.url, route.path),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
211
206
|
// Boot-Check via ctx.templateResolver (extraContext-Pattern, symmetrisch
|
|
212
207
|
// zu requireConfigResolver in config). App-Bootstrap muss templateResolver
|
|
213
208
|
// wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
|
|
@@ -232,56 +227,3 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
232
227
|
return {};
|
|
233
228
|
});
|
|
234
229
|
}
|
|
235
|
-
|
|
236
|
-
// Minimal-shape für die Boot-Check-Logik — nur die Felder die der Check
|
|
237
|
-
// braucht. Akzeptiert HandlerContext + AppContext + jeden anderen
|
|
238
|
-
// Container der templateResolver + log mitbringt. Macht den Check direkt
|
|
239
|
-
// unit-testbar mit constructed ctx-Objects.
|
|
240
|
-
export type LegalPagesBootCheckCtx = {
|
|
241
|
-
readonly templateResolver?: TemplateResolverApi;
|
|
242
|
-
readonly log?: {
|
|
243
|
-
readonly info?: (msg: string) => void;
|
|
244
|
-
readonly warn?: (msg: string) => void;
|
|
245
|
-
};
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
// Exportiert für direkte Tests. Wirft InternalError wenn ctx.templateResolver
|
|
249
|
-
// nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
|
|
250
|
-
// in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
|
|
251
|
-
// Logged log.info wenn alles vorhanden ist (kein silent-skip).
|
|
252
|
-
export async function runLegalPagesBootCheck(
|
|
253
|
-
ctx: LegalPagesBootCheckCtx,
|
|
254
|
-
requiredBlocks: readonly LegalRequiredBlock[] = LEGAL_REQUIRED_BLOCKS,
|
|
255
|
-
): Promise<void> {
|
|
256
|
-
const templateResolver: TemplateResolverApi = requireTemplateResolver(
|
|
257
|
-
ctx,
|
|
258
|
-
"legal-pages-boot-check",
|
|
259
|
-
);
|
|
260
|
-
const missing: { slug: string; lang: string }[] = [];
|
|
261
|
-
|
|
262
|
-
for (const required of requiredBlocks) {
|
|
263
|
-
const block = await templateResolver.findExact({
|
|
264
|
-
tenantId: SYSTEM_TENANT_ID,
|
|
265
|
-
slug: required.slug,
|
|
266
|
-
kind: TEXT_BLOCK_KIND,
|
|
267
|
-
locale: required.lang,
|
|
268
|
-
});
|
|
269
|
-
if (!block?.content) {
|
|
270
|
-
missing.push({ slug: required.slug, lang: required.lang });
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (missing.length === 0) {
|
|
275
|
-
ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
|
|
276
|
-
} else {
|
|
277
|
-
const message =
|
|
278
|
-
`legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
|
|
279
|
-
missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
|
|
280
|
-
". Seed via template-resolver:write:set or the seedTextBlock helper.";
|
|
281
|
-
|
|
282
|
-
if (process.env["NODE_ENV"] === "production") {
|
|
283
|
-
throw new Error(`Boot-Validation failed: ${message}`);
|
|
284
|
-
}
|
|
285
|
-
ctx.log?.warn?.(message);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireTemplateResolver,
|
|
3
|
+
TEXT_BLOCK_KIND,
|
|
4
|
+
type TemplateResolverApi,
|
|
5
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
6
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { LEGAL_REQUIRED_BLOCKS, type LegalRequiredBlock } from "../constants.js";
|
|
8
|
+
|
|
9
|
+
// Only the fields the check reads, so HandlerContext, AppContext or a hand-built
|
|
10
|
+
// object in a unit test all fit.
|
|
11
|
+
export type LegalPagesBootCheckCtx = {
|
|
12
|
+
readonly templateResolver?: TemplateResolverApi;
|
|
13
|
+
readonly log?: {
|
|
14
|
+
readonly info?: (msg: string) => void;
|
|
15
|
+
readonly warn?: (msg: string) => void;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Missing required blocks fail the boot only in production; elsewhere they warn,
|
|
20
|
+
// so a dev setup without seeded legal texts still starts. A complete set logs
|
|
21
|
+
// info on purpose, so a skipped check is visible.
|
|
22
|
+
export async function runLegalPagesBootCheck(
|
|
23
|
+
ctx: LegalPagesBootCheckCtx,
|
|
24
|
+
requiredBlocks: readonly LegalRequiredBlock[] = LEGAL_REQUIRED_BLOCKS,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const templateResolver: TemplateResolverApi = requireTemplateResolver(
|
|
27
|
+
ctx,
|
|
28
|
+
"legal-pages-boot-check",
|
|
29
|
+
);
|
|
30
|
+
const missing: { slug: string; lang: string }[] = [];
|
|
31
|
+
|
|
32
|
+
for (const required of requiredBlocks) {
|
|
33
|
+
const block = await templateResolver.findExact({
|
|
34
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
35
|
+
slug: required.slug,
|
|
36
|
+
kind: TEXT_BLOCK_KIND,
|
|
37
|
+
locale: required.lang,
|
|
38
|
+
});
|
|
39
|
+
if (!block?.content) {
|
|
40
|
+
missing.push({ slug: required.slug, lang: required.lang });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (missing.length === 0) {
|
|
45
|
+
ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
|
|
46
|
+
} else {
|
|
47
|
+
const message =
|
|
48
|
+
`legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
|
|
49
|
+
missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
|
|
50
|
+
". Seed via template-resolver:write:set or the seedTextBlock helper.";
|
|
51
|
+
|
|
52
|
+
if (process.env["NODE_ENV"] === "production") {
|
|
53
|
+
throw new Error(`Boot-Validation failed: ${message}`);
|
|
54
|
+
}
|
|
55
|
+
ctx.log?.warn?.(message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LegalPageRoute } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
// Validated once at feature-build time (not per-request) so a config typo
|
|
4
|
+
// fails app startup instead of surfacing as a confusing 404 later.
|
|
5
|
+
export function validateRoutes(routes: readonly LegalPageRoute[]): void {
|
|
6
|
+
const seenPaths = new Set<string>();
|
|
7
|
+
for (const route of routes) {
|
|
8
|
+
if (!route.path || !route.slug || !route.lang) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
if (!route.path.startsWith("/")) {
|
|
14
|
+
throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
|
|
15
|
+
}
|
|
16
|
+
if (seenPaths.has(route.path)) {
|
|
17
|
+
throw new Error(`legal-pages: duplicate route path "${route.path}"`);
|
|
18
|
+
}
|
|
19
|
+
seenPaths.add(route.path);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -13,6 +13,7 @@ export {
|
|
|
13
13
|
} from "./cached-page-response.js";
|
|
14
14
|
export { sanitizeTenantCss } from "./css-sanitize.js";
|
|
15
15
|
export {
|
|
16
|
+
type PublicPageWrapLayout,
|
|
16
17
|
type SeoHeadInput,
|
|
17
18
|
TENANT_CONTENT_ATTR,
|
|
18
19
|
tenantStyleBlock,
|
|
@@ -20,3 +21,4 @@ export {
|
|
|
20
21
|
} from "./layout.js";
|
|
21
22
|
export { renderSafeMarkdown } from "./markdown.js";
|
|
22
23
|
export { securePageHeaders } from "./security-headers.js";
|
|
24
|
+
export { redirectToCanonicalPath, slashVariantOf } from "./trailing-slash.js";
|
|
@@ -8,6 +8,18 @@ import { sanitizeTenantCss } from "./css-sanitize.js";
|
|
|
8
8
|
// wrapInLayout's own opts, so callers don't repeat them).
|
|
9
9
|
export type SeoHeadInput = Omit<ApexHead, "title" | "description" | "lang">;
|
|
10
10
|
|
|
11
|
+
// Signature a public FW page (legal-pages, contract termination) accepts for
|
|
12
|
+
// an app-supplied layout. `alternates` maps a locale to the path of the same
|
|
13
|
+
// page in that locale, so the layout can render a language switch from plain
|
|
14
|
+
// links (the page CSP has script-src 'none').
|
|
15
|
+
export type PublicPageWrapLayout = (opts: {
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly bodyHtml: string;
|
|
18
|
+
readonly lang: string;
|
|
19
|
+
readonly slug?: string;
|
|
20
|
+
readonly alternates?: Readonly<Record<string, string>>;
|
|
21
|
+
}) => string;
|
|
22
|
+
|
|
11
23
|
// Attribute marking the content container. The page body lives in
|
|
12
24
|
// `<main data-tenant-content>`; tenant custom CSS is scoped to its descendants
|
|
13
25
|
// and host containment clips its paint to this box. A custom wrapLayout that
|
|
@@ -98,6 +110,11 @@ ${headHtml}
|
|
|
98
110
|
a { color: var(--accent); }
|
|
99
111
|
code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
100
112
|
hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
|
|
113
|
+
input, select, textarea, button { font: inherit; color: inherit; padding: 0.4rem 0.6rem;
|
|
114
|
+
border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; }
|
|
115
|
+
input:not([type="radio"]):not([type="checkbox"]):not([type="hidden"]), select, textarea {
|
|
116
|
+
width: 100%; max-width: 100%; }
|
|
117
|
+
button { cursor: pointer; background: #f4f4f4; }
|
|
101
118
|
.brand-header { position: relative; z-index: 1; display: flex; align-items: center;
|
|
102
119
|
gap: 0.6rem; margin-bottom: 1.5rem; }
|
|
103
120
|
.brand-header a { display: flex; align-items: center; gap: 0.6rem; color: inherit; text-decoration: none; }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The other trailing-slash form of a path, or null for the root path (it has
|
|
2
|
+
// no distinct variant).
|
|
3
|
+
export function slashVariantOf(path: string): string | null {
|
|
4
|
+
if (path === "/") return null;
|
|
5
|
+
return path.endsWith("/") ? path.slice(0, -1) : `${path}/`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Hono matches strictly, so the slash variant of a page would 404. A 301 keeps
|
|
9
|
+
// one canonical URL; the query string is carried over.
|
|
10
|
+
export function redirectToCanonicalPath(requestUrl: string, canonicalPath: string): Response {
|
|
11
|
+
return new Response(null, {
|
|
12
|
+
status: 301,
|
|
13
|
+
headers: { location: `${canonicalPath}${new URL(requestUrl).search}` },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Localized mail branding, locale through delivery, logoPath",
|
|
6
|
+
"detail": "`ctx.notify` accepts `locale`, and the locale now travels through the delivery jobs (`delivery.render`, `delivery.send`) to `NotificationRenderer.render`, where `RendererInput.locale` is available. Jobs queued before this release have no locale and keep working. The email channel and `renderer-simple` pass it on; the auth mails and the consumer-protection mails set it to the language they already translate their content into. This lands in the same release as the `NotifyOptions` change from #3508.\n`MailBranding` for `renderer-simple` can now be localized: `footerText`, `footerLinks[].label` and `footerLinks[].url` take a plain string or a map of locale to string. The renderer picks the exact locale, then the language part (`de-AT` falls back to `de`), then the new optional `MailBranding.defaultLocale`, then the first entry. Every URL in a map must be an absolute http(s) URL, otherwise `createSimpleRenderer` throws at boot. Without a locale and with plain strings the HTML is unchanged.\n`MailBranding` also gets `logoPath` and `baseUrl`, so an app can point at a raster logo on its own origin (`logoPath: \"/logo.png\"`, `baseUrl` the same base as `auth.mail.baseUrl`). `logoPath` and `logoUrl` exclude each other, `logoPath` needs a valid `baseUrl`, must start with a single `/` and must stay on the base origin; violations throw at boot. Use PNG or JPEG, because mail clients block SVG."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.339.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
createSimpleRenderer,
|
|
4
|
+
type LocalizedText,
|
|
5
|
+
type MailBranding,
|
|
6
|
+
simpleRenderer,
|
|
7
|
+
} from "./simple-renderer.js";
|