@cosmicdrift/kumiko-samples 0.365.1 → 0.367.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 +1 -1
- package/packages/bundled-features/src/admin-shell/feature.ts +9 -9
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +87 -5
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +0 -2
- package/packages/bundled-features/src/auth-email-password/feature.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +4 -4
- package/packages/bundled-features/src/auth-mfa-user-data/index.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +89 -28
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +3 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
- package/packages/bundled-features/src/cap-counter/tables.ts +5 -2
- package/packages/bundled-features/src/cap-overview/feature.ts +3 -3
- package/packages/bundled-features/src/channel-discord/feature.ts +2 -2
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
- package/packages/bundled-features/src/channel-email/feature.ts +2 -2
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-push/feature.ts +1 -1
- package/packages/bundled-features/src/channel-slack/feature.ts +2 -2
- package/packages/bundled-features/src/channel-teams/feature.ts +2 -2
- package/packages/bundled-features/src/channel-telegram/feature.ts +2 -2
- package/packages/bundled-features/src/compliance-profiles/feature.ts +1 -1
- package/packages/bundled-features/src/compliance-profiles-ops/index.ts +3 -3
- package/packages/bundled-features/src/config/feature.ts +1 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/feature.ts +2 -2
- package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +17 -13
- package/packages/bundled-features/src/data-retention/feature.ts +1 -1
- package/packages/bundled-features/src/data-retention/presets.ts +4 -4
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +6 -2
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +4 -4
- package/packages/bundled-features/src/delivery/delivery-service.ts +71 -4
- package/packages/bundled-features/src/delivery/feature.ts +10 -6
- package/packages/bundled-features/src/delivery/handlers/resubscribe-address.write.ts +12 -8
- package/packages/bundled-features/src/delivery/handlers/resubscribe-user.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/set-preference.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +13 -6
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-user.write.ts +3 -1
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/notification-type-aliases.ts +13 -0
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +4 -0
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
- package/packages/bundled-features/src/derivatives-sharp/feature.ts +1 -1
- package/packages/bundled-features/src/document-ingest-foundation/feature.ts +2 -2
- package/packages/bundled-features/src/file-derivatives/feature.ts +3 -3
- package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/file-provider-inmemory/feature.ts +2 -2
- package/packages/bundled-features/src/file-provider-s3/feature.ts +3 -3
- package/packages/bundled-features/src/file-provider-s3-env/feature.ts +2 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/folders/feature.ts +2 -2
- package/packages/bundled-features/src/folders-user-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +2 -2
- package/packages/bundled-features/src/form-draft-user-data/index.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +9 -1
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +19 -7
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +21 -2
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -2
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +1 -1
- package/packages/bundled-features/src/ledger/feature.ts +5 -5
- package/packages/bundled-features/src/legal-pages/feature.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/feature.ts +51 -10
- package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
- package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +2 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +3 -3
- package/packages/bundled-features/src/managed-pages/css-gate.ts +1 -1
- package/packages/bundled-features/src/managed-pages/feature.ts +2 -2
- package/packages/bundled-features/src/metrics/feature.ts +2 -2
- package/packages/bundled-features/src/notes-history/feature.ts +2 -2
- package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +15 -11
- package/packages/bundled-features/src/readiness/feature.ts +2 -2
- package/packages/bundled-features/src/renderer-foundation/README.md +1 -1
- package/packages/bundled-features/src/renderer-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/renderer-pdf/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/seo/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +17 -13
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -1
- package/packages/bundled-features/src/subscription-mollie/feature.ts +1 -1
- package/packages/bundled-features/src/subscription-stripe/feature.ts +3 -3
- package/packages/bundled-features/src/tags/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +25 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
- package/packages/bundled-features/src/template-resolver/index.ts +4 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
- package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
- package/packages/bundled-features/src/template-resolver-user-data/index.ts +1 -1
- package/packages/bundled-features/src/tenant/feature.ts +4 -4
- package/packages/bundled-features/src/tenant/seeding.ts +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +2 -3
- package/packages/bundled-features/src/tenant-settings/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +36 -27
- package/packages/bundled-features/src/user/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/README.md +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -4
- package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +2 -2
- package/packages/bundled-features/src/user-profile/feature.ts +4 -4
- package/packages/bundled-features/src/waitlist/feature.ts +2 -2
- package/samples/apps/cap-billing-demo/README.md +1 -1
- package/samples/apps/cap-billing-demo/src/feature.ts +4 -4
- package/samples/apps/cap-billing-demo/src/run-config.ts +1 -1
- package/samples/apps/config-demo/src/features/demo/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/feature.ts +43 -28
- package/samples/apps/styleguide/src/features/demo/feature.ts +18 -18
- package/samples/apps/styleguide/src/features/widgets/ai-text-demo.ts +10 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +90 -108
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +36 -36
- package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +6 -6
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +7 -7
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +3 -3
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +1 -1
- package/samples/apps/user-data-rights-demo/src/feature.ts +5 -5
- package/samples/recipes/access-control/src/feature.ts +21 -25
- package/samples/recipes/anonymous-access/src/feature.ts +36 -43
- package/samples/recipes/anonymous-access-multitenant/src/feature.ts +14 -16
- package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
- package/samples/recipes/basic-entity/README.md +1 -1
- package/samples/recipes/boot-check/README.md +1 -1
- package/samples/recipes/boot-check/src/feature.ts +2 -2
- package/samples/recipes/chat-channels/src/feature.ts +1 -1
- package/samples/recipes/cross-feature-events/src/feature.ts +7 -7
- package/samples/recipes/currencies-global/src/feature.ts +3 -3
- package/samples/recipes/currencies-per-tenant/src/feature.ts +2 -2
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/custom-fields-basic/src/feature.ts +3 -2
- package/samples/recipes/custom-handlers/README.md +3 -3
- package/samples/recipes/custom-handlers/src/feature.ts +37 -41
- package/samples/recipes/dashboard-stats/src/feature.ts +6 -6
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/delivery-notifications/src/feature.ts +8 -8
- package/samples/recipes/designer-demo/src/feature.ts +2 -1
- package/samples/recipes/embedded/src/feature.ts +1 -1
- package/samples/recipes/embedded-entity-form/src/feature.ts +13 -13
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +1 -1
- package/samples/recipes/error-contract/src/feature.ts +25 -25
- package/samples/recipes/event-sourcing/src/feature.ts +70 -76
- package/samples/recipes/feature-toggles/src/feature.ts +28 -20
- package/samples/recipes/files-post-processing/src/feature.ts +22 -26
- package/samples/recipes/idempotency/src/feature.ts +16 -18
- package/samples/recipes/inbound-mail-triage/src/feature.ts +1 -1
- package/samples/recipes/lane-routing/src/feature.ts +10 -12
- package/samples/recipes/legal-pages/src/feature.ts +1 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -4
- package/samples/recipes/lifecycle-hooks/src/feature.ts +55 -35
- package/samples/recipes/managed-config/src/feature.ts +8 -8
- package/samples/recipes/managed-pages/src/feature.ts +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/notes-history-basic/src/feature.ts +2 -2
- package/samples/recipes/pdf-audit-export/src/feature.ts +7 -7
- package/samples/recipes/pipeline-basics/README.md +1 -1
- package/samples/recipes/pipeline-basics/src/feature.ts +7 -7
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/rate-limiting/src/feature.ts +7 -9
- package/samples/recipes/record-detail-layout/src/feature.ts +18 -18
- package/samples/recipes/reference-data/src/feature.ts +1 -1
- package/samples/recipes/relations/src/feature.ts +2 -2
- package/samples/recipes/screens-nav/src/feature.ts +1 -1
- package/samples/recipes/search/src/feature.ts +17 -19
- package/samples/recipes/security-baseline/README.md +1 -1
- package/samples/recipes/state-machine/src/feature.ts +1 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/tags-basic/src/feature.ts +2 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/samples/recipes/tenant-settings/src/feature.ts +1 -1
- package/samples/recipes/timezones/src/feature.ts +12 -12
- package/samples/recipes/user-data-rights/README.md +2 -2
- package/samples/recipes/user-data-rights/src/feature.ts +1 -1
- package/samples/recipes/user-profile/src/feature.ts +1 -1
- package/samples/recipes/webhook-step/src/feature.ts +1 -1
- package/samples/recipes/writeform-section/src/feature.ts +12 -12
|
@@ -2,14 +2,20 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { upsertAddressOptOut } from "../address-opt-out.js";
|
|
5
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
6
|
+
|
|
7
|
+
// Also the payload an app job receives with `trigger: { on: DeliveryHandlers.unsubscribeAddress }`
|
|
8
|
+
// after a committed address opt-out (the tenant is the job's `ctx.triggeredBy.tenantId`).
|
|
9
|
+
export const unsubscribeAddressPayloadSchema = z.object({
|
|
10
|
+
addressHash: z.string().min(1),
|
|
11
|
+
notificationType: z.string().min(1),
|
|
12
|
+
channel: z.string().min(1),
|
|
13
|
+
});
|
|
14
|
+
export type UnsubscribeAddressPayload = z.infer<typeof unsubscribeAddressPayloadSchema>;
|
|
5
15
|
|
|
6
16
|
export const unsubscribeAddressWrite = defineWriteHandler({
|
|
7
17
|
name: "unsubscribeAddress",
|
|
8
|
-
schema:
|
|
9
|
-
addressHash: z.string().min(1),
|
|
10
|
-
notificationType: z.string().min(1),
|
|
11
|
-
channel: z.string().min(1),
|
|
12
|
-
}),
|
|
18
|
+
schema: unsubscribeAddressPayloadSchema,
|
|
13
19
|
access: { roles: access.systemAdmin },
|
|
14
20
|
// Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
|
|
15
21
|
// verify() has already proven the address-token signature — not a surface
|
|
@@ -18,7 +24,8 @@ export const unsubscribeAddressWrite = defineWriteHandler({
|
|
|
18
24
|
description:
|
|
19
25
|
"Records a no-account address opt-out for one notificationType/channel combination; dispatched by the signed unsubscribe route, not meant for UI callers.",
|
|
20
26
|
handler: async (event, ctx) => {
|
|
21
|
-
const { addressHash,
|
|
27
|
+
const { addressHash, channel } = event.payload;
|
|
28
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
22
29
|
const { tenantId } = event.user;
|
|
23
30
|
|
|
24
31
|
if (!ctx.systemDb) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
4
5
|
import { upsertPreference } from "../upsert-preference.js";
|
|
5
6
|
|
|
6
7
|
export const unsubscribeUserWrite = defineWriteHandler({
|
|
@@ -20,7 +21,8 @@ export const unsubscribeUserWrite = defineWriteHandler({
|
|
|
20
21
|
handler: async (event, ctx) => {
|
|
21
22
|
// userId comes from the payload, not event.user.id — the caller here is
|
|
22
23
|
// the system user dispatchSystemWrite runs as, not the user unsubscribing.
|
|
23
|
-
const { userId,
|
|
24
|
+
const { userId, channel } = event.payload;
|
|
25
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
24
26
|
const { tenantId } = event.user;
|
|
25
27
|
|
|
26
28
|
if (!ctx.systemDb) {
|
|
@@ -63,6 +63,10 @@ export {
|
|
|
63
63
|
type RateLimitConfig,
|
|
64
64
|
} from "./delivery-service.js";
|
|
65
65
|
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
|
|
66
|
+
export {
|
|
67
|
+
type UnsubscribeAddressPayload,
|
|
68
|
+
unsubscribeAddressPayloadSchema,
|
|
69
|
+
} from "./handlers/unsubscribe-address.write.js";
|
|
66
70
|
export { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
67
71
|
export {
|
|
68
72
|
deliveryAttemptsTable,
|
|
@@ -94,3 +98,5 @@ export {
|
|
|
94
98
|
type UnsubscribeRouteOptions,
|
|
95
99
|
type UnsubscribeTokenPayload,
|
|
96
100
|
} from "./unsubscribe.js";
|
|
101
|
+
export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
102
|
+
export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export function notificationTypeNames(registry: Registry, type: string): readonly string[] {
|
|
4
|
+
const renamedFrom = registry.getAllNotifications().get(type)?.renamedFrom ?? [];
|
|
5
|
+
return [type, ...renamedFrom];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function currentNotificationType(registry: Registry, type: string): string {
|
|
9
|
+
for (const [name, definition] of registry.getAllNotifications()) {
|
|
10
|
+
if (definition.renamedFrom?.includes(type)) return name;
|
|
11
|
+
}
|
|
12
|
+
return type;
|
|
13
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js"
|
|
|
15
15
|
import { collectChannels, createDeliveryService } from "./delivery-service.js";
|
|
16
16
|
import { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
17
17
|
import type { DeliveryService } from "./types.js";
|
|
18
|
+
import type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
18
19
|
|
|
19
20
|
export type CreateDeliveryTestContextOptions = {
|
|
20
21
|
readonly tenantUserIdsQuery?: string;
|
|
@@ -27,6 +28,7 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
27
28
|
readonly idempotencyRedis?: Redis;
|
|
28
29
|
readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
|
|
29
30
|
readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
|
|
31
|
+
readonly unsubscribeLinks?: UnsubscribeLinkConfig;
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type {
|
|
4
|
+
ConfigAccessor,
|
|
4
5
|
DeliveryErrorCode,
|
|
5
6
|
NotifyDeliveryStatus,
|
|
6
7
|
NotifyDispatched,
|
|
@@ -39,6 +40,9 @@ export type ChannelContext = {
|
|
|
39
40
|
// Chat channels read their per-tenant webhook URL / bot token from it.
|
|
40
41
|
// Absent when no secrets feature/KEK is wired.
|
|
41
42
|
readonly secrets?: SecretsContext | undefined;
|
|
43
|
+
// The tenant's own config accessor, built server-side for this tenantId. Absent when the
|
|
44
|
+
// delivery service has no tenantConfigFor and the job context carries no config.
|
|
45
|
+
readonly config?: ConfigAccessor | undefined;
|
|
42
46
|
// Renders a stored mail template, the tenant's own or the system fallback.
|
|
43
47
|
readonly renderMail?: (payload: MailPayload) => Promise<RenderedMail>;
|
|
44
48
|
// Reads the bytes behind an attachment ref at send time. Absent when no file storage is wired.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { publicOriginOf } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
4
|
+
import { DELIVERY_UNSUBSCRIBE_PATH } from "./constants.js";
|
|
5
|
+
import type { ChannelContext } from "./types.js";
|
|
6
|
+
import { signAddressUnsubscribeToken, signUnsubscribeToken } from "./unsubscribe.js";
|
|
7
|
+
|
|
8
|
+
/** Secret shared with `createUnsubscribeRoutes` plus the public origin the links point at. */
|
|
9
|
+
export type UnsubscribeLinkConfig = {
|
|
10
|
+
readonly secret: string;
|
|
11
|
+
readonly baseUrl: string;
|
|
12
|
+
/** The sending tenant's own public origin (subdomain or custom domain), null when it has
|
|
13
|
+
* none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
|
|
14
|
+
* fall back to `baseUrl`, so a missing tenant origin never fails a send. */
|
|
15
|
+
readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type UnsubscribeLinkRecipient =
|
|
19
|
+
| { readonly kind: "user"; readonly userId: string }
|
|
20
|
+
| { readonly kind: "address"; readonly address: string };
|
|
21
|
+
|
|
22
|
+
// https only, unless the app itself runs on plain http (local dev): a tenant origin must not
|
|
23
|
+
// downgrade the link that carries the token.
|
|
24
|
+
function isUsableTenantOrigin(origin: string, baseUrl: string): boolean {
|
|
25
|
+
return (
|
|
26
|
+
origin.startsWith("https://") || (baseUrl.startsWith("http://") && origin.startsWith("http://"))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function unsubscribeLinkOrigin(
|
|
31
|
+
config: UnsubscribeLinkConfig,
|
|
32
|
+
ctx: ChannelContext,
|
|
33
|
+
onFallback: (reason: string) => void,
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
if (config.tenantOrigin === undefined) return config.baseUrl;
|
|
36
|
+
let resolved: string | null | undefined;
|
|
37
|
+
try {
|
|
38
|
+
resolved = await config.tenantOrigin(ctx);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
onFallback(`tenantOrigin threw (${err instanceof Error ? err.name : typeof err})`);
|
|
41
|
+
return config.baseUrl;
|
|
42
|
+
}
|
|
43
|
+
if (resolved === null || resolved === undefined) return config.baseUrl;
|
|
44
|
+
const origin = publicOriginOf(resolved);
|
|
45
|
+
if (origin === undefined || !isUsableTenantOrigin(origin, config.baseUrl)) {
|
|
46
|
+
onFallback("tenantOrigin returned no usable https origin");
|
|
47
|
+
return config.baseUrl;
|
|
48
|
+
}
|
|
49
|
+
return origin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Undefined means "no link": without a blind-index key an address opt-out could never be
|
|
53
|
+
// matched again, and a mail without a link is better than a failed send.
|
|
54
|
+
export async function buildUnsubscribeUrl(
|
|
55
|
+
config: UnsubscribeLinkConfig,
|
|
56
|
+
origin: string,
|
|
57
|
+
recipient: UnsubscribeLinkRecipient,
|
|
58
|
+
scope: {
|
|
59
|
+
tenantId: TenantId;
|
|
60
|
+
notificationType: string;
|
|
61
|
+
channel: string;
|
|
62
|
+
locale: string | undefined;
|
|
63
|
+
},
|
|
64
|
+
): Promise<string | undefined> {
|
|
65
|
+
if (recipient.kind === "address" && hashUnsubscribeAddress(recipient.address) === undefined) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const token =
|
|
69
|
+
recipient.kind === "user"
|
|
70
|
+
? await signUnsubscribeToken({ ...scope, userId: recipient.userId }, config.secret)
|
|
71
|
+
: await signAddressUnsubscribeToken({ ...scope, address: recipient.address }, config.secret);
|
|
72
|
+
return `${origin}${DELIVERY_UNSUBSCRIBE_PATH}?token=${encodeURIComponent(token)}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
|
+
|
|
3
|
+
// The pages behind a mailed unsubscribe link speak the mail's language, so their texts live in
|
|
4
|
+
// the mail translations: English here, other languages in the locale packages.
|
|
5
|
+
export const UNSUBSCRIBE_PAGE_EN = {
|
|
6
|
+
"mail.unsubscribe.confirmTitle": "Unsubscribe",
|
|
7
|
+
"mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?",
|
|
8
|
+
"mail.unsubscribe.confirmButton": "Unsubscribe",
|
|
9
|
+
"mail.unsubscribe.doneTitle": "Unsubscribed",
|
|
10
|
+
"mail.unsubscribe.doneMessage": "You have been unsubscribed.",
|
|
11
|
+
"mail.unsubscribe.undoButton": "Undo",
|
|
12
|
+
"mail.unsubscribe.resubscribedTitle": "Subscribed again",
|
|
13
|
+
"mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
|
|
14
|
+
"mail.unsubscribe.failed": "That did not work. Please try again later.",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
registerMailTranslations("en", UNSUBSCRIBE_PAGE_EN);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
signatureRoute,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
8
8
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
|
|
9
10
|
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
10
11
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
11
12
|
import * as jose from "jose";
|
|
@@ -17,6 +18,14 @@ import {
|
|
|
17
18
|
DeliveryErrors,
|
|
18
19
|
DeliveryHandlers,
|
|
19
20
|
} from "./constants.js";
|
|
21
|
+
import "./unsubscribe-page-strings.js";
|
|
22
|
+
|
|
23
|
+
// The mail's locale, so the pages behind the link speak the mail's language. Optional: links
|
|
24
|
+
// signed before the claim existed keep verifying and fall back to English.
|
|
25
|
+
const localeClaimSchema = z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
|
|
28
|
+
.optional();
|
|
20
29
|
|
|
21
30
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
22
31
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
@@ -32,6 +41,7 @@ const unsubscribeJwtPayloadSchema = z.object({
|
|
|
32
41
|
tenantId: z.string().min(1),
|
|
33
42
|
notificationType: z.string().min(1),
|
|
34
43
|
channel: z.string().min(1),
|
|
44
|
+
locale: localeClaimSchema,
|
|
35
45
|
});
|
|
36
46
|
|
|
37
47
|
// Address-token payload. `sub` and `addressHash` are the same value (the
|
|
@@ -45,6 +55,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
|
45
55
|
notificationType: z.string().min(1),
|
|
46
56
|
channel: z.string().min(1),
|
|
47
57
|
addressHash: z.string().min(1),
|
|
58
|
+
locale: localeClaimSchema,
|
|
48
59
|
});
|
|
49
60
|
|
|
50
61
|
export type UnsubscribeTokenPayload = {
|
|
@@ -52,6 +63,7 @@ export type UnsubscribeTokenPayload = {
|
|
|
52
63
|
readonly tenantId: TenantId;
|
|
53
64
|
readonly notificationType: string;
|
|
54
65
|
readonly channel: string;
|
|
66
|
+
readonly locale?: string | undefined;
|
|
55
67
|
};
|
|
56
68
|
|
|
57
69
|
export type AddressUnsubscribeTokenPayload = {
|
|
@@ -59,6 +71,7 @@ export type AddressUnsubscribeTokenPayload = {
|
|
|
59
71
|
readonly address: string;
|
|
60
72
|
readonly notificationType: string;
|
|
61
73
|
readonly channel: string;
|
|
74
|
+
readonly locale?: string | undefined;
|
|
62
75
|
};
|
|
63
76
|
|
|
64
77
|
/**
|
|
@@ -87,6 +100,11 @@ function assertUnsubscribeSecret(secret: string, caller: string): void {
|
|
|
87
100
|
}
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
// An unusable locale is left out instead of failing the send; the pages then fall back to English.
|
|
104
|
+
function localeClaim(locale: string | undefined): { locale?: string } {
|
|
105
|
+
return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
|
|
91
109
|
error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
|
|
92
110
|
} as const;
|
|
@@ -101,6 +119,7 @@ export async function signUnsubscribeToken(
|
|
|
101
119
|
tenantId: payload.tenantId,
|
|
102
120
|
notificationType: payload.notificationType,
|
|
103
121
|
channel: payload.channel,
|
|
122
|
+
...localeClaim(payload.locale),
|
|
104
123
|
})
|
|
105
124
|
.setProtectedHeader({ alg: "HS256" })
|
|
106
125
|
.setSubject(String(payload.userId))
|
|
@@ -136,6 +155,7 @@ export async function signAddressUnsubscribeToken(
|
|
|
136
155
|
channel: payload.channel,
|
|
137
156
|
addressHash,
|
|
138
157
|
kind: "address",
|
|
158
|
+
...localeClaim(payload.locale),
|
|
139
159
|
})
|
|
140
160
|
.setProtectedHeader({ alg: "HS256" })
|
|
141
161
|
.setSubject(addressHash)
|
|
@@ -154,6 +174,7 @@ type VerifiedUnsubscribe =
|
|
|
154
174
|
readonly addressHash: string;
|
|
155
175
|
readonly notificationType: string;
|
|
156
176
|
readonly channel: string;
|
|
177
|
+
readonly locale: string | undefined;
|
|
157
178
|
}
|
|
158
179
|
| {
|
|
159
180
|
readonly kind: "user";
|
|
@@ -161,6 +182,7 @@ type VerifiedUnsubscribe =
|
|
|
161
182
|
readonly userId: string;
|
|
162
183
|
readonly notificationType: string;
|
|
163
184
|
readonly channel: string;
|
|
185
|
+
readonly locale: string | undefined;
|
|
164
186
|
};
|
|
165
187
|
|
|
166
188
|
// RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
|
|
@@ -183,6 +205,7 @@ async function signResubscribeToken(
|
|
|
183
205
|
tenantId: verified.tenantId,
|
|
184
206
|
notificationType: verified.notificationType,
|
|
185
207
|
channel: verified.channel,
|
|
208
|
+
...localeClaim(verified.locale),
|
|
186
209
|
};
|
|
187
210
|
const jwt =
|
|
188
211
|
verified.kind === "address"
|
|
@@ -254,6 +277,7 @@ async function verifyUnsubscribeToken(
|
|
|
254
277
|
addressHash: parsed.addressHash,
|
|
255
278
|
notificationType: parsed.notificationType,
|
|
256
279
|
channel: parsed.channel,
|
|
280
|
+
locale: parsed.locale,
|
|
257
281
|
};
|
|
258
282
|
}
|
|
259
283
|
|
|
@@ -274,6 +298,7 @@ async function verifyUnsubscribeToken(
|
|
|
274
298
|
userId: parsed.sub,
|
|
275
299
|
notificationType: parsed.notificationType,
|
|
276
300
|
channel: parsed.channel,
|
|
301
|
+
locale: parsed.locale,
|
|
277
302
|
};
|
|
278
303
|
} catch (err) {
|
|
279
304
|
if (err instanceof ExtraRouteRejection) throw err;
|
|
@@ -358,40 +383,68 @@ async function dispatchResubscribeWrite(
|
|
|
358
383
|
return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
|
|
359
384
|
}
|
|
360
385
|
|
|
361
|
-
function
|
|
386
|
+
function pageLocale(locale: string | undefined): string {
|
|
387
|
+
return resolveMailLocale(locale ?? "en");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function htmlPage(locale: string, title: string, bodyHtml: string): string {
|
|
362
391
|
return `<!doctype html>
|
|
363
|
-
<html>
|
|
392
|
+
<html lang="${escapeHtmlAttr(locale)}">
|
|
364
393
|
<head>
|
|
365
394
|
<meta charset="utf-8">
|
|
366
395
|
<meta name="robots" content="noindex">
|
|
367
|
-
<
|
|
396
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
397
|
+
<title>${escapeHtmlAttr(title)}</title>
|
|
368
398
|
</head>
|
|
369
399
|
<body>
|
|
370
|
-
|
|
371
|
-
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
372
|
-
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
373
|
-
<button type="submit">Unsubscribe</button>
|
|
374
|
-
</form>
|
|
400
|
+
${bodyHtml}
|
|
375
401
|
</body>
|
|
376
402
|
</html>`;
|
|
377
403
|
}
|
|
378
404
|
|
|
379
|
-
function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
<
|
|
386
|
-
|
|
387
|
-
<
|
|
388
|
-
|
|
405
|
+
function confirmationPage(token: string, locale: string | undefined): string {
|
|
406
|
+
const lang = pageLocale(locale);
|
|
407
|
+
return htmlPage(
|
|
408
|
+
lang,
|
|
409
|
+
mailT(lang, "mail.unsubscribe.confirmTitle"),
|
|
410
|
+
`<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
|
|
411
|
+
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
412
|
+
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
413
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
|
|
414
|
+
</form>`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function unsubscribedPage(resubscribeToken: string, locale: string | undefined): string {
|
|
419
|
+
const lang = pageLocale(locale);
|
|
420
|
+
return htmlPage(
|
|
421
|
+
lang,
|
|
422
|
+
mailT(lang, "mail.unsubscribe.doneTitle"),
|
|
423
|
+
`<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
|
|
389
424
|
<form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
|
|
390
425
|
<input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
|
|
391
|
-
<button type="submit"
|
|
392
|
-
</form
|
|
393
|
-
|
|
394
|
-
|
|
426
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
|
|
427
|
+
</form>`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resubscribedPage(locale: string | undefined): string {
|
|
432
|
+
const lang = pageLocale(locale);
|
|
433
|
+
return htmlPage(
|
|
434
|
+
lang,
|
|
435
|
+
mailT(lang, "mail.unsubscribe.resubscribedTitle"),
|
|
436
|
+
`<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function failedPage(locale: string | undefined): string {
|
|
441
|
+
const lang = pageLocale(locale);
|
|
442
|
+
const message = mailT(lang, "mail.unsubscribe.failed");
|
|
443
|
+
return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isFormRequest(contentType: string | undefined): boolean {
|
|
447
|
+
return contentType?.includes("application/x-www-form-urlencoded") === true;
|
|
395
448
|
}
|
|
396
449
|
|
|
397
450
|
// One-click clients put the token in the query string; only the
|
|
@@ -431,7 +484,7 @@ export function createUnsubscribeRoutes(
|
|
|
431
484
|
assertUnsubscribeSecret(options.secret, "createUnsubscribeRoutes");
|
|
432
485
|
const encodedSecret = new TextEncoder().encode(options.secret);
|
|
433
486
|
|
|
434
|
-
const confirmRoute = signatureRoute<{ token: string }>({
|
|
487
|
+
const confirmRoute = signatureRoute<{ token: string; locale: string | undefined }>({
|
|
435
488
|
method: "GET",
|
|
436
489
|
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
437
490
|
entry: "signature",
|
|
@@ -444,11 +497,11 @@ export function createUnsubscribeRoutes(
|
|
|
444
497
|
"missing unsubscribe token",
|
|
445
498
|
);
|
|
446
499
|
}
|
|
447
|
-
await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
448
|
-
return { token };
|
|
500
|
+
const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
501
|
+
return { token, locale: verified.locale };
|
|
449
502
|
},
|
|
450
|
-
handler: async (c, { token }) =>
|
|
451
|
-
c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
|
|
503
|
+
handler: async (c, { token, locale }) =>
|
|
504
|
+
c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
|
|
452
505
|
});
|
|
453
506
|
|
|
454
507
|
const writeRoute = signatureRoute<VerifiedUnsubscribe>({
|
|
@@ -463,11 +516,13 @@ export function createUnsubscribeRoutes(
|
|
|
463
516
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
464
517
|
const succeeded = await dispatchUnsubscribeWrite(verified, deps);
|
|
465
518
|
if (!succeeded) {
|
|
466
|
-
return c.html(
|
|
519
|
+
return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
|
|
467
520
|
}
|
|
468
|
-
return c.html(
|
|
469
|
-
|
|
470
|
-
|
|
521
|
+
return c.html(
|
|
522
|
+
unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale),
|
|
523
|
+
200,
|
|
524
|
+
{ "Cache-Control": "no-store" },
|
|
525
|
+
);
|
|
471
526
|
},
|
|
472
527
|
});
|
|
473
528
|
|
|
@@ -482,6 +537,14 @@ export function createUnsubscribeRoutes(
|
|
|
482
537
|
verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
|
|
483
538
|
handler: async (c, verified, deps) => {
|
|
484
539
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
540
|
+
// The undo button on the unsubscribed page posts a form; a browser should land on a page.
|
|
541
|
+
if (isFormRequest(c.req.header("content-type"))) {
|
|
542
|
+
return outcome === "success"
|
|
543
|
+
? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
|
|
544
|
+
: c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
|
|
545
|
+
"Cache-Control": "no-store",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
485
548
|
if (outcome === "limit_reached") {
|
|
486
549
|
return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
|
|
487
550
|
"Cache-Control": "no-store",
|
|
@@ -15,7 +15,7 @@ export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
15
15
|
category: "storage",
|
|
16
16
|
recommended: false,
|
|
17
17
|
});
|
|
18
|
-
r.requires("file-derivatives");
|
|
18
|
+
r.requires({ features: ["file-derivatives"] });
|
|
19
19
|
|
|
20
20
|
const plugin: DerivativeRendererPlugin = { render: renderImage };
|
|
21
21
|
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
|
|
@@ -132,9 +132,9 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
132
132
|
});
|
|
133
133
|
// tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
|
|
134
134
|
// tenant-subject ciphertext and needs the destroy hook below.
|
|
135
|
-
r.requires("config", "tenant-lifecycle");
|
|
135
|
+
r.requires({ features: ["config", "tenant-lifecycle"] });
|
|
136
136
|
|
|
137
|
-
r.entity("documentExtract", documentExtractEntity);
|
|
137
|
+
r.entity({ name: "documentExtract", ...documentExtractEntity });
|
|
138
138
|
r.useExtension(EXT_TENANT_DATA, "documentExtract", {
|
|
139
139
|
destroy: documentExtractTenantDestroyHook,
|
|
140
140
|
});
|
|
@@ -111,12 +111,12 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
111
111
|
});
|
|
112
112
|
// Needs a storage provider to read the original + write the variant —
|
|
113
113
|
// without file-foundation there's nothing to derive from or write to.
|
|
114
|
-
r.requires("file-foundation");
|
|
114
|
+
r.requires({ features: ["file-foundation"] });
|
|
115
115
|
// variant() reads fileRefsTable, which "files" (not file-foundation)
|
|
116
116
|
// owns — without this, mounting file-derivatives + file-foundation
|
|
117
117
|
// alone boots fine but fails at request-time with a SQL error the first
|
|
118
118
|
// time variant() runs.
|
|
119
|
-
r.requires("files");
|
|
119
|
+
r.requires({ features: ["files"] });
|
|
120
120
|
|
|
121
121
|
r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
|
|
122
122
|
onRegister: () => {
|
|
@@ -164,7 +164,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
164
164
|
// tenant's public variants from staying reachable — require the
|
|
165
165
|
// dependency so a missing mount fails boot, not the tenant check
|
|
166
166
|
// silently no-op-ing at request time.
|
|
167
|
-
r.requires("tenant");
|
|
167
|
+
r.requires({ features: ["tenant"] });
|
|
168
168
|
r.queryHandler(publicVariantByFileRefQuery);
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -46,7 +46,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
46
46
|
category: "storage",
|
|
47
47
|
recommended: false,
|
|
48
48
|
});
|
|
49
|
-
r.requires("config");
|
|
49
|
+
r.requires({ features: ["config"] });
|
|
50
50
|
|
|
51
51
|
r.extendsRegistrar(EXT_FILE_PROVIDER, {
|
|
52
52
|
onRegister: () => {
|
|
@@ -68,10 +68,10 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
68
68
|
category: "storage",
|
|
69
69
|
recommended: false,
|
|
70
70
|
});
|
|
71
|
-
// Kein
|
|
71
|
+
// Kein requires auf "config" oder "secrets" — der
|
|
72
72
|
// In-Memory-Provider hat keine Config + kein Secret. Nur die
|
|
73
73
|
// file-foundation muss da sein (Plugin-extension-point).
|
|
74
|
-
r.requires("file-foundation");
|
|
74
|
+
r.requires({ features: ["file-foundation"] });
|
|
75
75
|
|
|
76
76
|
const plugin: FileProviderPlugin = {
|
|
77
77
|
build: async (_ctx: FileProviderContext, tenantId: string): Promise<FileStorageProvider> => {
|
|
@@ -47,9 +47,9 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
47
47
|
category: "storage",
|
|
48
48
|
recommended: false,
|
|
49
49
|
});
|
|
50
|
-
r.requires("config");
|
|
51
|
-
r.requires("secrets");
|
|
52
|
-
r.requires("file-foundation");
|
|
50
|
+
r.requires({ features: ["config"] });
|
|
51
|
+
r.requires({ features: ["secrets"] });
|
|
52
|
+
r.requires({ features: ["file-foundation"] });
|
|
53
53
|
|
|
54
54
|
const secretAccessKey = r.secret("s3.secretAccessKey", {
|
|
55
55
|
label: { en: "S3 secret access key" },
|
|
@@ -42,10 +42,10 @@ export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
42
42
|
category: "storage",
|
|
43
43
|
recommended: false,
|
|
44
44
|
});
|
|
45
|
-
// No
|
|
45
|
+
// No requires on "config" or "secrets" — credentials come from
|
|
46
46
|
// env, not the per-tenant config + secrets store. Only the file-foundation
|
|
47
47
|
// plugin-extension-point must be mounted.
|
|
48
|
-
r.requires("file-foundation");
|
|
48
|
+
r.requires({ features: ["file-foundation"] });
|
|
49
49
|
|
|
50
50
|
const plugin: FileProviderPlugin = {
|
|
51
51
|
// tenantId ignored: one shared bucket serves all tenants (isolation via
|
|
@@ -5,7 +5,7 @@ Sprint-1.5 Refactor (Pre-Sprint-2).
|
|
|
5
5
|
|
|
6
6
|
## Was es macht
|
|
7
7
|
|
|
8
|
-
Deklariert `r.entity("fileRef", ...)` für die DB-Tabelle die das
|
|
8
|
+
Deklariert `r.entity({ name: "fileRef", ... })` für die DB-Tabelle die das
|
|
9
9
|
Framework via `createFileRoutes` (multipart-Upload + binary-Download)
|
|
10
10
|
bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
|
|
11
11
|
|
|
@@ -39,7 +39,7 @@ export function createFilesTenantDataFeature(): FeatureDefinition {
|
|
|
39
39
|
category: "compliance",
|
|
40
40
|
recommended: false,
|
|
41
41
|
});
|
|
42
|
-
r.requires("tenant-lifecycle");
|
|
42
|
+
r.requires({ features: ["tenant-lifecycle"] });
|
|
43
43
|
r.optionalRequires("files");
|
|
44
44
|
|
|
45
45
|
r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy: fileRefTenantDestroyHook });
|
|
@@ -58,8 +58,8 @@ function registerFolders(
|
|
|
58
58
|
if (toggleable !== undefined) r.toggleable(toggleable);
|
|
59
59
|
|
|
60
60
|
const folderAssignmentEntity = createFolderAssignmentEntity(ownership, parents);
|
|
61
|
-
r.entity("folder", folderEntity);
|
|
62
|
-
r.entity("folder-assignment", folderAssignmentEntity);
|
|
61
|
+
r.entity({ name: "folder", ...folderEntity });
|
|
62
|
+
r.entity({ name: "folder-assignment", ...folderAssignmentEntity });
|
|
63
63
|
|
|
64
64
|
// Folder catalog — plain CRUD, no custom logic. update is rename (and, in a
|
|
65
65
|
// later stage, reparent: it accepts changes.parentId, optimistic-locked).
|
|
@@ -20,7 +20,7 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
|
|
|
20
20
|
// OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
|
|
21
21
|
// tier), a hard r.requires would throw an "effectively disabled" boot
|
|
22
22
|
// warning even though the folder entities exist and the hooks work fine.
|
|
23
|
-
r.requires("user-data-rights");
|
|
23
|
+
r.requires({ features: ["user-data-rights"] });
|
|
24
24
|
r.optionalRequires("folders");
|
|
25
25
|
r.useExtension(EXT_USER_DATA, "folder", {
|
|
26
26
|
export: folderExportHook,
|
|
@@ -30,11 +30,11 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
|
|
|
30
30
|
// config is optional: the retention-days key only resolves when the
|
|
31
31
|
// config feature is mounted (ctx.configResolver present) — the cleanup
|
|
32
32
|
// job already falls back to FORM_DRAFT_DEFAULT_RETENTION_DAYS otherwise
|
|
33
|
-
// (see handlers/cleanup.job.ts). A hard r.requires("config") would break
|
|
33
|
+
// (see handlers/cleanup.job.ts). A hard r.requires({ features: ["config"] }) would break
|
|
34
34
|
// every app that mounts form-draft without config, which is a breaking
|
|
35
35
|
// change no minor/patch release should introduce silently.
|
|
36
36
|
r.optionalRequires("config");
|
|
37
|
-
r.entity("form-draft", formDraftEntity);
|
|
37
|
+
r.entity({ name: "form-draft", ...formDraftEntity });
|
|
38
38
|
|
|
39
39
|
r.writeHandler(saveDraftWrite);
|
|
40
40
|
r.writeHandler(discardDraftWrite);
|
|
@@ -12,7 +12,7 @@ export const formDraftUserDataFeature = defineFeature("form-draft-user-data", (r
|
|
|
12
12
|
r.describe(
|
|
13
13
|
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `form-draft` feature's `form-draft` entity. Mounts the EXT_USER_DATA export hook so a user's saved drafts are included in the user-data export bundle, and a delete hook that physically removes the user's own draft rows on forget. Kept separate from `form-draft` so form-draft consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `form-draft`.",
|
|
14
14
|
);
|
|
15
|
-
r.requires("user-data-rights");
|
|
15
|
+
r.requires({ features: ["user-data-rights"] });
|
|
16
16
|
r.optionalRequires("form-draft");
|
|
17
17
|
r.useExtension(EXT_USER_DATA, "form-draft", {
|
|
18
18
|
export: formDraftExportHook,
|