@cosmicdrift/kumiko-samples 0.404.0 → 0.405.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 +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
- package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
- package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
- package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
- package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
- package/packages/bundled-features/src/documents/changes.json +8 -0
- package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
- package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
- package/packages/bundled-features/src/folders/feature.ts +2 -8
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
- package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
- package/packages/bundled-features/src/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
- package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
- package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
- package/packages/bundled-features/src/ledger/feature.ts +2 -7
- package/packages/bundled-features/src/legal-pages/README.md +21 -13
- package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
- package/packages/bundled-features/src/notes-history/feature.ts +7 -8
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
- package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
- package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
- package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
- package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
- package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
- package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
- package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
- package/packages/bundled-features/src/shared/signed-token.ts +4 -14
- package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
- package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
- package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
- package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
- package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
- package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
- package/packages/bundled-features/src/tags/feature.ts +2 -8
- package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
- package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
- package/packages/bundled-features/src/template-resolver/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
- package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
- package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
- package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type EntityDefinition,
|
|
26
26
|
type FeatureRegistrar,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.ts";
|
|
28
|
+
import { assertNoWhereRuleInOwnershipWrite, resolveFeatureAccess } from "../shared/index.ts";
|
|
29
29
|
import { createFolderAssignmentEntity, folderEntity } from "./entity.ts";
|
|
30
30
|
import { createClearFolderHandler } from "./handlers/clear-folder.write.ts";
|
|
31
31
|
import { createDeleteFolderHandler } from "./handlers/delete-folder.write.ts";
|
|
@@ -148,12 +148,6 @@ export type FoldersFeatureOptions = {
|
|
|
148
148
|
readonly parents?: readonly string[];
|
|
149
149
|
};
|
|
150
150
|
|
|
151
|
-
function resolveAccess(opts: FoldersFeatureOptions): AccessRule {
|
|
152
|
-
if (opts.access !== undefined) return opts.access;
|
|
153
|
-
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
154
|
-
return DEFAULT_FOLDER_ACCESS;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
151
|
// Options wrapper. Without options returns the module-level singleton (no
|
|
158
152
|
// rebuild). access/roles/toggleable build a fresh feature-definition.
|
|
159
153
|
export function createFoldersFeature(
|
|
@@ -177,7 +171,7 @@ export function createFoldersFeature(
|
|
|
177
171
|
"admissible as a host instead.",
|
|
178
172
|
);
|
|
179
173
|
}
|
|
180
|
-
const access =
|
|
174
|
+
const access = resolveFeatureAccess(opts, DEFAULT_FOLDER_ACCESS);
|
|
181
175
|
return defineFeature(FOLDERS_FEATURE_NAME, (r) =>
|
|
182
176
|
registerFolders(r, access, opts.toggleable, opts.ownership, opts.parents),
|
|
183
177
|
);
|
|
@@ -26,14 +26,10 @@
|
|
|
26
26
|
// src (node_modules is ignored by default) or these classes never compile. money-
|
|
27
27
|
// horse already does; that scan is a real shipping requirement for any host.
|
|
28
28
|
|
|
29
|
-
import {
|
|
30
|
-
useDispatcher,
|
|
31
|
-
usePrimitives,
|
|
32
|
-
useQuery,
|
|
33
|
-
useTranslation,
|
|
34
|
-
} from "@cosmicdrift/kumiko-renderer";
|
|
29
|
+
import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
35
30
|
import { ChevronRight, File, Folder, type LucideIcon, Pencil, Plus, Trash2 } from "lucide-react";
|
|
36
31
|
import { type DragEvent, type ReactNode, useEffect, useState } from "react";
|
|
32
|
+
import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
|
|
37
33
|
import { FoldersHandlers, FoldersQueries } from "./constants.ts";
|
|
38
34
|
import { buildFolderTree, type FolderNode, type FolderRow } from "./tree.ts";
|
|
39
35
|
|
|
@@ -99,13 +95,15 @@ export function FolderManager({
|
|
|
99
95
|
}): ReactNode {
|
|
100
96
|
const { Banner, Button, Dialog, Input, Text } = usePrimitives();
|
|
101
97
|
const t = useTranslation();
|
|
102
|
-
const dispatcher = useDispatcher();
|
|
103
98
|
const catalog = useQuery<FolderListResponse>(FoldersQueries.folderList, {});
|
|
104
99
|
const [pending, setPending] = useState<Pending>(null);
|
|
105
100
|
const [draftName, setDraftName] = useState("");
|
|
106
101
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(loadCollapsed);
|
|
107
|
-
const
|
|
108
|
-
|
|
102
|
+
const { busy, setBusy, errorKey, setErrorKey, writeOk, apply } = useDispatchedWrites(async () => {
|
|
103
|
+
setPending(null);
|
|
104
|
+
await catalog.refetch();
|
|
105
|
+
await filing?.onReassigned();
|
|
106
|
+
});
|
|
109
107
|
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
|
|
110
108
|
const [pendingDelete, setPendingDelete] = useState<FolderNode | null>(null);
|
|
111
109
|
|
|
@@ -177,29 +175,6 @@ export function FolderManager({
|
|
|
177
175
|
setPending({ mode: "rename", id: folder.id, version: folder.version });
|
|
178
176
|
};
|
|
179
177
|
|
|
180
|
-
const apply = async (write: () => Promise<boolean>): Promise<void> => {
|
|
181
|
-
setBusy(true);
|
|
182
|
-
setErrorKey(null);
|
|
183
|
-
try {
|
|
184
|
-
if (await write()) {
|
|
185
|
-
setPending(null);
|
|
186
|
-
await catalog.refetch();
|
|
187
|
-
await filing?.onReassigned();
|
|
188
|
-
}
|
|
189
|
-
} finally {
|
|
190
|
-
setBusy(false);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
195
|
-
const result = await dispatcher.write(type, payload);
|
|
196
|
-
if (!result.isSuccess) {
|
|
197
|
-
setErrorKey(result.error.i18nKey);
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
return true;
|
|
201
|
-
};
|
|
202
|
-
|
|
203
178
|
const saveDraft = (): void => {
|
|
204
179
|
const name = draftName.trim();
|
|
205
180
|
if (name === "" || pending === null) return;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
useTranslation,
|
|
19
19
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import { type ReactNode, useState } from "react";
|
|
21
|
+
import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
|
|
21
22
|
import { FoldersHandlers, FoldersQueries } from "./constants.ts";
|
|
22
23
|
import { type FolderRow, folderPath } from "./tree.ts";
|
|
23
24
|
|
|
@@ -58,8 +59,10 @@ export function FolderSection({
|
|
|
58
59
|
{ enabled },
|
|
59
60
|
);
|
|
60
61
|
const [newName, setNewName] = useState("");
|
|
61
|
-
const
|
|
62
|
-
|
|
62
|
+
const refetch = async (): Promise<void> => {
|
|
63
|
+
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
64
|
+
};
|
|
65
|
+
const { busy, errorKey, setErrorKey, writeOk, apply } = useDispatchedWrites(refetch);
|
|
63
66
|
|
|
64
67
|
if (entityId === null) {
|
|
65
68
|
return (
|
|
@@ -95,29 +98,6 @@ export function FolderSection({
|
|
|
95
98
|
...folders.map((f) => ({ value: f.id, label: folderPath(folders, f.id) })),
|
|
96
99
|
];
|
|
97
100
|
|
|
98
|
-
const refetch = async (): Promise<void> => {
|
|
99
|
-
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const apply = async (write: () => Promise<boolean>): Promise<void> => {
|
|
103
|
-
setBusy(true);
|
|
104
|
-
setErrorKey(null);
|
|
105
|
-
try {
|
|
106
|
-
if (await write()) await refetch();
|
|
107
|
-
} finally {
|
|
108
|
-
setBusy(false);
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
113
|
-
const result = await dispatcher.write(type, payload);
|
|
114
|
-
if (!result.isSuccess) {
|
|
115
|
-
setErrorKey(result.error.i18nKey);
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
return true;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
101
|
const onSelect = (next: string): void => {
|
|
122
102
|
if (next === currentFolderId) return;
|
|
123
103
|
void apply(() =>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { failUnprocessable
|
|
2
|
+
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
4
|
+
import { isUniqueConstraintFailure } from "../../shared/index.ts";
|
|
4
5
|
import { countDraftsByOwner } from "../db/queries/draft-count.ts";
|
|
5
6
|
import { formDraftExecutor } from "../executor.ts";
|
|
6
7
|
import { lookupDraft } from "../lookup.ts";
|
|
@@ -11,6 +12,9 @@ import {
|
|
|
11
12
|
FORM_DRAFT_UNIQUE_KEY_CONSTRAINT,
|
|
12
13
|
} from "../web/constants.ts";
|
|
13
14
|
|
|
15
|
+
const SAVE_DRAFT_COUNT_REASON =
|
|
16
|
+
"COUNT(*) of the caller's own drafts without loading 64KB value blobs";
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* save is an upsert keyed on (tenantId, ownerId, draftKey) — same
|
|
16
20
|
* lookup-then-create|update-with-race-retry idiom as
|
|
@@ -20,20 +24,6 @@ import {
|
|
|
20
24
|
* winner's row, and updates it (with the winner's version) instead of
|
|
21
25
|
* erroring.
|
|
22
26
|
*/
|
|
23
|
-
function isDraftKeyConflict(failure: WriteFailure): boolean {
|
|
24
|
-
const error = failure.error as {
|
|
25
|
-
code?: string;
|
|
26
|
-
details?: { constraintName?: string };
|
|
27
|
-
}; // @cast-boundary error-details
|
|
28
|
-
return (
|
|
29
|
-
error.code === "unique_violation" &&
|
|
30
|
-
error.details?.constraintName === FORM_DRAFT_UNIQUE_KEY_CONSTRAINT
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const SAVE_DRAFT_COUNT_REASON =
|
|
35
|
-
"COUNT(*) of the caller's own drafts without loading 64KB value blobs";
|
|
36
|
-
|
|
37
27
|
export const saveDraftWrite = defineWriteHandler({
|
|
38
28
|
name: "save",
|
|
39
29
|
schema: saveDraftPayloadSchema,
|
|
@@ -69,7 +59,7 @@ export const saveDraftWrite = defineWriteHandler({
|
|
|
69
59
|
|
|
70
60
|
const result = await formDraftExecutor.create({ ownerId, draftKey, draft }, event.user, ctx.db);
|
|
71
61
|
if (result.isSuccess) return result;
|
|
72
|
-
if (!
|
|
62
|
+
if (!isUniqueConstraintFailure(result, FORM_DRAFT_UNIQUE_KEY_CONSTRAINT)) return result;
|
|
73
63
|
|
|
74
64
|
const winner = await lookupDraft(ctx.db, event.user.tenantId, ownerId, draftKey);
|
|
75
65
|
if (!winner) return result;
|
|
@@ -55,16 +55,16 @@ the app sets it.
|
|
|
55
55
|
|
|
56
56
|
## Options
|
|
57
57
|
|
|
58
|
-
| Option
|
|
59
|
-
|
|
60
|
-
| `tenantBy`
|
|
61
|
-
| `baseDomain`
|
|
62
|
-
| `apex`
|
|
63
|
-
| `tenant`
|
|
64
|
-
| `unknownTenant`
|
|
65
|
-
| `hosts`
|
|
66
|
-
| `apexAnonymousTenantId` | Tenant for anonymous API calls on the apex (e.g. `SYSTEM_TENANT_ID` for public signup); omit and the apex has none
|
|
67
|
-
| `locales`
|
|
58
|
+
| Option | Meaning |
|
|
59
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
60
|
+
| `tenantBy` | `"host"` (`<key>.<baseDomain>`) or `"path"` with `tenantPathPrefix` (`/t/<key>/...` on the apex) |
|
|
61
|
+
| `baseDomain` | Apex hostname, lowercase, no port. `www.<baseDomain>` counts as apex |
|
|
62
|
+
| `apex` | `{ file, csp?, localeFiles? }` for the apex |
|
|
63
|
+
| `tenant` | `{ file, csp? }` for a known tenant and for the bare path prefix |
|
|
64
|
+
| `unknownTenant` | `{ file, csp? }` served with 404; omit for a bare 404 |
|
|
65
|
+
| `hosts` | Further fixed hostnames with their own surface, checked first |
|
|
66
|
+
| `apexAnonymousTenantId` | Tenant for anonymous API calls on the apex (e.g. `SYSTEM_TENANT_ID` for public signup); omit and the apex has none |
|
|
67
|
+
| `locales` | `{ default, prefixed }`: `/<default>/...` answers 301 to the unprefixed path, `/<prefixed>/...` serves `apex.localeFiles[locale]` |
|
|
68
68
|
|
|
69
69
|
A `csp` replaces runProdApp's default CSP for that surface; without it the
|
|
70
70
|
default applies. Options are validated when the feature or dispatch is built.
|
|
@@ -228,12 +228,8 @@ export function createInboundMailFoundationFeature(options: InboundMailRoleOptio
|
|
|
228
228
|
|
|
229
229
|
// Plugin extension point — providers register via
|
|
230
230
|
// r.useExtension("inboundMailProvider", "<key>", plugin).
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// No side-effects at register-time — lookup happens at runtime
|
|
234
|
-
// via resolveInboundProviderForKey/-Account.
|
|
235
|
-
},
|
|
236
|
-
});
|
|
231
|
+
// Lookup happens at runtime via resolveInboundProviderForKey/-Account.
|
|
232
|
+
r.extendsRegistrar(INBOUND_MAIL_PROVIDER_EXTENSION, {});
|
|
237
233
|
|
|
238
234
|
// Sync machinery: high-frequency tick state, deliberately NOT
|
|
239
235
|
// event-sourced — r.storeTable keeps the migration DDL but excludes the
|
|
@@ -107,6 +107,7 @@ export async function appendThreadRollup(
|
|
|
107
107
|
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
108
108
|
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
109
109
|
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
110
|
+
// biome-ignore lint/complexity/useMathMinMax: ISO strings compare lexicographically; Math.max would coerce them to NaN
|
|
110
111
|
const newLastAt = receivedAtIso > previousLastAt ? receivedAtIso : previousLastAt;
|
|
111
112
|
const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
|
|
112
113
|
tenantId,
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// in the HMAC input, timing-safe comparison, expiry in the clear-text
|
|
12
12
|
// segment).
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
16
16
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
17
|
+
import { hmacSignatureMatches, signHmacSha256Base64Url } from "../shared/index.ts";
|
|
17
18
|
|
|
18
19
|
const STATE_PURPOSE = "inbound-mail-oauth-connect";
|
|
19
20
|
|
|
@@ -34,10 +35,6 @@ export type VerifyOAuthStateResult =
|
|
|
34
35
|
| { readonly ok: true; readonly payload: OAuthStatePayload }
|
|
35
36
|
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
36
37
|
|
|
37
|
-
function sign(input: string, secret: string): string {
|
|
38
|
-
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
38
|
function hmacInput(payloadB64: string, expiresAtMs: number): string {
|
|
42
39
|
return `${STATE_PURPOSE}:${payloadB64}.${expiresAtMs}`;
|
|
43
40
|
}
|
|
@@ -51,7 +48,7 @@ export function signOAuthState(
|
|
|
51
48
|
const full: OAuthStatePayload = { ...payload, nonce: randomUUID() };
|
|
52
49
|
const payloadB64 = Buffer.from(JSON.stringify(full), "utf8").toString("base64url");
|
|
53
50
|
const expiresAtMs = now.add({ minutes: ttlMinutes }).epochMilliseconds;
|
|
54
|
-
const signature =
|
|
51
|
+
const signature = signHmacSha256Base64Url(hmacInput(payloadB64, expiresAtMs), secret);
|
|
55
52
|
return `${payloadB64}.${expiresAtMs}.${signature}`;
|
|
56
53
|
}
|
|
57
54
|
|
|
@@ -70,13 +67,8 @@ export function verifyOAuthState(
|
|
|
70
67
|
return { ok: false, reason: "malformed" };
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
const expected =
|
|
74
|
-
|
|
75
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
76
|
-
// Check the length before timingSafeEqual: it throws on a length mismatch,
|
|
77
|
-
// and the throw itself leaks information through timing.
|
|
78
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
79
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
70
|
+
const expected = signHmacSha256Base64Url(hmacInput(payloadB64, expiresAtMs), secret);
|
|
71
|
+
if (!hmacSignatureMatches(expected, providedSig)) return { ok: false, reason: "bad_signature" };
|
|
80
72
|
|
|
81
73
|
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
|
|
82
74
|
return { ok: false, reason: "expired" };
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// unreadable. These hooks dispose of the rows.
|
|
17
17
|
|
|
18
18
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import type { TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { archiveAndDeleteTenantRows } from "../tenant-lifecycle/index.ts";
|
|
21
21
|
import { seenMessageTable, syncCursorTable } from "./entities.ts";
|
|
22
22
|
import {
|
|
23
23
|
INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
@@ -32,31 +32,21 @@ import {
|
|
|
32
32
|
} from "./projection.ts";
|
|
33
33
|
import { quarantineTable } from "./quarantine-entities.ts";
|
|
34
34
|
|
|
35
|
-
const ARCHIVED_BY = "tenant-lifecycle:destroy";
|
|
36
|
-
const REASON = "tenant_destroy";
|
|
37
|
-
|
|
38
35
|
// archiveStream sits outside TenantDb's own methods — needs the escape hatch.
|
|
39
36
|
export const INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON =
|
|
40
37
|
"tenant-destroy archives each inbound-mail entity's event-streams before the projection rows are deleted";
|
|
41
38
|
|
|
42
|
-
|
|
39
|
+
function archiveAndDeleteRows(
|
|
43
40
|
ctx: TenantDataHookCtx,
|
|
44
41
|
table: EntityTableMeta,
|
|
45
42
|
aggregateType: string,
|
|
46
43
|
): Promise<readonly string[]> {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
aggregateType,
|
|
54
|
-
archivedBy: ARCHIVED_BY,
|
|
55
|
-
reason: REASON,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
await ctx.db.deleteMany(table, { tenantId: ctx.tenantId });
|
|
59
|
-
return rows.map((row) => row.id);
|
|
44
|
+
return archiveAndDeleteTenantRows(
|
|
45
|
+
ctx,
|
|
46
|
+
table,
|
|
47
|
+
aggregateType,
|
|
48
|
+
INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
|
|
49
|
+
);
|
|
60
50
|
}
|
|
61
51
|
|
|
62
52
|
export async function mailAccountTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
@@ -2,8 +2,8 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
|
|
5
|
-
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
5
|
import * as z from "zod";
|
|
6
|
+
import { parseSubject } from "../parse-subject.ts";
|
|
7
7
|
import { tenantJobFailuresTable } from "../tenant-job-failure-table.ts";
|
|
8
8
|
|
|
9
9
|
type TenantJobFailureRow = {
|
|
@@ -16,20 +16,6 @@ type TenantJobFailureRow = {
|
|
|
16
16
|
|
|
17
17
|
const DEFAULT_LIMIT = 50;
|
|
18
18
|
|
|
19
|
-
function isSubjectEntry(value: unknown): value is [string, unknown] {
|
|
20
|
-
return Array.isArray(value) && typeof value[0] === "string";
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
24
|
-
if (stored === null) return null;
|
|
25
|
-
// A corrupt subject must not fail the whole list — the record still tells
|
|
26
|
-
// the tenant which job failed and why. parseJsonSafe only survives a
|
|
27
|
-
// SyntaxError, so the shape needs its own check.
|
|
28
|
-
const parsed = parseJsonSafe<unknown>(stored, null);
|
|
29
|
-
if (!Array.isArray(parsed)) return null;
|
|
30
|
-
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
19
|
export function createTenantFailuresQuery(readRoles: readonly string[]) {
|
|
34
20
|
return defineQueryHandler({
|
|
35
21
|
name: "failures",
|
|
@@ -2,8 +2,8 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
|
|
5
|
-
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
5
|
import * as z from "zod";
|
|
6
|
+
import { parseSubject } from "../parse-subject.ts";
|
|
7
7
|
import { type TenantJobRunStatus, tenantJobRunsTable } from "../tenant-job-run-table.ts";
|
|
8
8
|
|
|
9
9
|
type TenantJobRunRow = {
|
|
@@ -19,19 +19,6 @@ type TenantJobRunRow = {
|
|
|
19
19
|
|
|
20
20
|
const DEFAULT_LIMIT = 50;
|
|
21
21
|
|
|
22
|
-
function isSubjectEntry(value: unknown): value is [string, unknown] {
|
|
23
|
-
return Array.isArray(value) && typeof value[0] === "string";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
27
|
-
if (stored === null) return null;
|
|
28
|
-
// A corrupt subject must not fail the whole list; parseJsonSafe only
|
|
29
|
-
// survives a SyntaxError, so the shape needs its own check.
|
|
30
|
-
const parsed = parseJsonSafe<unknown>(stored, null);
|
|
31
|
-
if (!Array.isArray(parsed)) return null;
|
|
32
|
-
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
22
|
export function createTenantRunsQuery(readRoles: readonly string[]) {
|
|
36
23
|
return defineQueryHandler({
|
|
37
24
|
name: "tenant-runs",
|
|
@@ -153,6 +153,27 @@ async function encryptLogMessages<T extends { readonly message: string }>(
|
|
|
153
153
|
}));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
async function insertEncryptedRunLogs(
|
|
157
|
+
db: DbConnection,
|
|
158
|
+
runId: string,
|
|
159
|
+
triggeredById: string | null,
|
|
160
|
+
logs: readonly { readonly level: string; readonly message: string; readonly timestamp: string }[],
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
// skip: empty log batch — the worker ran silent, no child rows to insert
|
|
163
|
+
if (logs.length === 0) return;
|
|
164
|
+
const encryptedLogs = await encryptLogMessages(logs, triggeredById);
|
|
165
|
+
await insertMany(
|
|
166
|
+
db,
|
|
167
|
+
jobRunLogsTable,
|
|
168
|
+
encryptedLogs.map((log) => ({
|
|
169
|
+
runId,
|
|
170
|
+
level: log.level,
|
|
171
|
+
message: log.message,
|
|
172
|
+
timestamp: parseJobInstant(log.timestamp),
|
|
173
|
+
})),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
// Mirrors encryptFailureError for the failure-path `error` column:
|
|
157
178
|
// stored alongside logs[].message under the same triggering user's DEK, so
|
|
158
179
|
// a log/error pair from the same failed run decrypt together or erase
|
|
@@ -349,14 +370,7 @@ async function recordTenantRunOutcome(
|
|
|
349
370
|
// A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
|
|
350
371
|
// seeing pending work, not a failure the next attempt may still resolve.
|
|
351
372
|
if (result === "failed" && !outcome.finalAttempt) {
|
|
352
|
-
await
|
|
353
|
-
updateMany(
|
|
354
|
-
db,
|
|
355
|
-
tenantJobRunsTable,
|
|
356
|
-
{ status: "queued", startedAt: null, finishedAt: null, updatedAt: now },
|
|
357
|
-
{ tenantId: key.tenantId, jobName, bullJobId },
|
|
358
|
-
),
|
|
359
|
-
);
|
|
373
|
+
await markTenantRunQueued(db, key.tenantId, jobName, bullJobId, now);
|
|
360
374
|
// skip: retry pending, the row stays active until the final attempt
|
|
361
375
|
return;
|
|
362
376
|
}
|
|
@@ -403,12 +417,22 @@ async function requeueTenantRun(
|
|
|
403
417
|
const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
|
|
404
418
|
// skip: system-tenant run, no tenant-scoped query reads it
|
|
405
419
|
if (!key) return;
|
|
406
|
-
await
|
|
420
|
+
await markTenantRunQueued(db, key.tenantId, jobName, bullJobId, Temporal.Now.instant());
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function markTenantRunQueued(
|
|
424
|
+
db: DbConnection,
|
|
425
|
+
tenantId: string,
|
|
426
|
+
jobName: string,
|
|
427
|
+
bullJobId: string,
|
|
428
|
+
updatedAt: Temporal.Instant,
|
|
429
|
+
): Promise<void> {
|
|
430
|
+
await inTenantScope(tenantId, () =>
|
|
407
431
|
updateMany(
|
|
408
432
|
db,
|
|
409
433
|
tenantJobRunsTable,
|
|
410
|
-
{ status: "queued", startedAt: null, finishedAt: null, updatedAt
|
|
411
|
-
{ tenantId
|
|
434
|
+
{ status: "queued", startedAt: null, finishedAt: null, updatedAt },
|
|
435
|
+
{ tenantId, jobName, bullJobId },
|
|
412
436
|
),
|
|
413
437
|
);
|
|
414
438
|
}
|
|
@@ -672,21 +696,7 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
672
696
|
},
|
|
673
697
|
{ id: runId },
|
|
674
698
|
);
|
|
675
|
-
|
|
676
|
-
// insert; the status update above already recorded completion.
|
|
677
|
-
if (payload.logs.length > 0) {
|
|
678
|
-
const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
|
|
679
|
-
await insertMany(
|
|
680
|
-
db,
|
|
681
|
-
jobRunLogsTable,
|
|
682
|
-
encryptedLogs.map((log) => ({
|
|
683
|
-
runId,
|
|
684
|
-
level: log.level,
|
|
685
|
-
message: log.message,
|
|
686
|
-
timestamp: parseJobInstant(log.timestamp),
|
|
687
|
-
})),
|
|
688
|
-
);
|
|
689
|
-
}
|
|
699
|
+
await insertEncryptedRunLogs(db, runId, triggeredById, payload.logs);
|
|
690
700
|
runIdByBullJobId.delete(bullJobId); // immediate cleanup on terminal callback
|
|
691
701
|
},
|
|
692
702
|
|
|
@@ -746,20 +756,7 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
746
756
|
},
|
|
747
757
|
{ id: runId },
|
|
748
758
|
);
|
|
749
|
-
|
|
750
|
-
if (payload.logs.length > 0) {
|
|
751
|
-
const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
|
|
752
|
-
await insertMany(
|
|
753
|
-
db,
|
|
754
|
-
jobRunLogsTable,
|
|
755
|
-
encryptedLogs.map((log) => ({
|
|
756
|
-
runId,
|
|
757
|
-
level: log.level,
|
|
758
|
-
message: log.message,
|
|
759
|
-
timestamp: parseJobInstant(log.timestamp),
|
|
760
|
-
})),
|
|
761
|
-
);
|
|
762
|
-
}
|
|
759
|
+
await insertEncryptedRunLogs(db, runId, triggeredById, payload.logs);
|
|
763
760
|
runIdByBullJobId.delete(bullJobId); // immediate cleanup on terminal callback
|
|
764
761
|
},
|
|
765
762
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
2
|
+
|
|
3
|
+
function isSubjectEntry(value: unknown): value is [string, unknown] {
|
|
4
|
+
return Array.isArray(value) && typeof value[0] === "string";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// A corrupt subject must not fail the whole list: the record still tells the
|
|
8
|
+
// tenant which job failed. parseJsonSafe only survives a SyntaxError, so the
|
|
9
|
+
// shape needs its own check.
|
|
10
|
+
export function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
11
|
+
if (stored === null) return null;
|
|
12
|
+
const parsed = parseJsonSafe<unknown>(stored, null);
|
|
13
|
+
if (!Array.isArray(parsed)) return null;
|
|
14
|
+
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
15
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
defineFeature,
|
|
22
22
|
type FeatureRegistrar,
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import { resolveFeatureAccess } from "../shared/index.ts";
|
|
24
25
|
import { accountEntity, scheduleEntity, transactionEntity } from "./entity.ts";
|
|
25
26
|
import { createConfirmSchedulePeriodHandler } from "./handlers/confirm-schedule-period.write.ts";
|
|
26
27
|
import { createCreateTransactionHandler } from "./handlers/create-transaction.write.ts";
|
|
@@ -166,18 +167,12 @@ export type LedgerFeatureOptions = {
|
|
|
166
167
|
readonly toggleable?: LedgerToggleable;
|
|
167
168
|
};
|
|
168
169
|
|
|
169
|
-
function resolveAccess(opts: LedgerFeatureOptions): AccessRule {
|
|
170
|
-
if (opts.access !== undefined) return opts.access;
|
|
171
|
-
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
172
|
-
return DEFAULT_LEDGER_ACCESS;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
170
|
// Options wrapper. Without options returns the module-level singleton (no
|
|
176
171
|
// rebuild). access/roles/toggleable build a fresh feature-definition.
|
|
177
172
|
export function createLedgerFeature(opts: LedgerFeatureOptions = {}): typeof defaultLedgerFeature {
|
|
178
173
|
if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
|
|
179
174
|
return defaultLedgerFeature;
|
|
180
175
|
}
|
|
181
|
-
const access =
|
|
176
|
+
const access = resolveFeatureAccess(opts, DEFAULT_LEDGER_ACCESS);
|
|
182
177
|
return defineFeature(LEDGER_FEATURE_NAME, (r) => registerLedger(r, access, opts.toggleable));
|
|
183
178
|
}
|
|
@@ -25,7 +25,7 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
25
25
|
runProdApp({
|
|
26
26
|
envSchema, // composeEnvSchema({ core: frameworkCoreEnvSchema, features })
|
|
27
27
|
features: [
|
|
28
|
-
createTemplateResolverFeature(),
|
|
28
|
+
createTemplateResolverFeature(), // legal-pages requires template-resolver
|
|
29
29
|
createLegalPagesFeature(),
|
|
30
30
|
/* ... */
|
|
31
31
|
],
|
|
@@ -60,12 +60,12 @@ See [template-resolver/README.md](../template-resolver/README.md).
|
|
|
60
60
|
|
|
61
61
|
**Default** (DACH: DE required, EN optional):
|
|
62
62
|
|
|
63
|
-
| Path
|
|
64
|
-
|
|
65
|
-
| `GET /legal/impressum`
|
|
66
|
-
| `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung"
|
|
67
|
-
| `GET /legal/imprint`
|
|
68
|
-
| `GET /legal/privacy`
|
|
63
|
+
| Path | Slug + locale | Title fallback (when block empty) |
|
|
64
|
+
| ------------------------ | ---------------- | --------------------------------- |
|
|
65
|
+
| `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
|
|
66
|
+
| `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
|
|
67
|
+
| `GET /legal/imprint` | `imprint` / `en` | "Imprint" |
|
|
68
|
+
| `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
|
|
69
69
|
|
|
70
70
|
Response:
|
|
71
71
|
- `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
|
|
@@ -112,11 +112,19 @@ that needs an additional page (terms/AGB), passes its own lists:
|
|
|
112
112
|
createLegalPagesFeature({
|
|
113
113
|
routes: [
|
|
114
114
|
{ path: "/legal/aviso-legal", slug: "imprint", lang: "es", titleFallback: "Aviso legal" },
|
|
115
|
-
{
|
|
115
|
+
{
|
|
116
|
+
path: "/legal/privacidad",
|
|
117
|
+
slug: "privacy",
|
|
118
|
+
lang: "es",
|
|
119
|
+
titleFallback: "Política de privacidad",
|
|
120
|
+
},
|
|
116
121
|
{ path: "/legal/terminos", slug: "terms", lang: "es", titleFallback: "Términos y condiciones" },
|
|
117
122
|
],
|
|
118
|
-
requiredBlocks: [
|
|
119
|
-
}
|
|
123
|
+
requiredBlocks: [
|
|
124
|
+
{ slug: "imprint", lang: "es" },
|
|
125
|
+
{ slug: "privacy", lang: "es" },
|
|
126
|
+
],
|
|
127
|
+
});
|
|
120
128
|
```
|
|
121
129
|
|
|
122
130
|
`slug` is free-form — it only has to match the `slug` you seed the
|
|
@@ -162,10 +170,10 @@ starts and checks whether the required blocks exist in SYSTEM_TENANT.
|
|
|
162
170
|
A throw rejects the boot, so the process never reports ready.
|
|
163
171
|
**Default** (DACH):
|
|
164
172
|
|
|
165
|
-
| Slug + locale
|
|
166
|
-
|
|
173
|
+
| Slug + locale | What happens when missing |
|
|
174
|
+
| ---------------- | ----------------------------------------------------------------------------------------- |
|
|
167
175
|
| `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.obs.log.warn(...)` |
|
|
168
|
-
| `privacy` / `de` | as above
|
|
176
|
+
| `privacy` / `de` | as above |
|
|
169
177
|
|
|
170
178
|
EN versions are **not** boot-fail-relevant by default (`LEGAL_OPTIONAL_BLOCKS`).
|
|
171
179
|
Routes return `404` if an EN block is missing.
|
|
@@ -141,13 +141,9 @@ export function createMailFoundationFeature() {
|
|
|
141
141
|
// Plugin extension-point. Provider-features register here. The
|
|
142
142
|
// entityName at registration time becomes the value tenants pick in
|
|
143
143
|
// `provider` config-key (e.g. "smtp", "brevo-api").
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// usage, factory looks it up at request-time. Same shape as
|
|
148
|
-
// delivery's extendsRegistrar.
|
|
149
|
-
},
|
|
150
|
-
});
|
|
144
|
+
// The registry stores the usage, factory looks it up at request-time. Same shape as
|
|
145
|
+
// delivery's extendsRegistrar.
|
|
146
|
+
r.extendsRegistrar(MAIL_TRANSPORT_EXTENSION, {});
|
|
151
147
|
|
|
152
148
|
// Empty = this tenant has no own provider: tenantMailTransport yields null and
|
|
153
149
|
// channel-email uses the app transport, so empty is a valid choice a tenant can
|