@cosmicdrift/kumiko-bundled-features 0.340.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.
Files changed (151) hide show
  1. package/dist/auth-email-password/bootstrap.d.ts +74 -0
  2. package/dist/auth-email-password/bootstrap.js +165 -0
  3. package/dist/auth-email-password/constants.d.ts +2 -0
  4. package/dist/auth-email-password/constants.js +5 -0
  5. package/dist/auth-email-password/errors.d.ts +1 -0
  6. package/dist/auth-email-password/errors.js +6 -0
  7. package/dist/auth-email-password/feature.js +2 -1
  8. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  9. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  10. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  11. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  12. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  13. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  15. package/dist/auth-email-password/index.d.ts +2 -0
  16. package/dist/auth-email-password/index.js +1 -0
  17. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  18. package/dist/auth-email-password/invite-token-store.js +5 -0
  19. package/dist/auth-email-password/magic-link-mail.js +1 -0
  20. package/dist/auth-mfa/config.d.ts +1 -1
  21. package/dist/auth-mfa/config.js +8 -3
  22. package/dist/auth-mfa/feature.d.ts +2 -0
  23. package/dist/auth-mfa/feature.js +2 -1
  24. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  25. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  26. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  27. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  28. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  29. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  30. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  31. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  32. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  33. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  34. package/dist/channel-discord/discord-channel.d.ts +4 -0
  35. package/dist/channel-discord/discord-channel.js +4 -0
  36. package/dist/channel-discord/feature.js +7 -2
  37. package/dist/channel-discord/index.d.ts +1 -1
  38. package/dist/channel-discord/index.js +1 -1
  39. package/dist/channel-email/email-channel.js +1 -0
  40. package/dist/channel-slack/feature.js +4 -2
  41. package/dist/channel-slack/index.d.ts +1 -1
  42. package/dist/channel-slack/index.js +1 -1
  43. package/dist/channel-slack/slack-channel.d.ts +4 -0
  44. package/dist/channel-slack/slack-channel.js +4 -0
  45. package/dist/channel-teams/feature.js +4 -2
  46. package/dist/channel-teams/index.d.ts +1 -1
  47. package/dist/channel-teams/index.js +1 -1
  48. package/dist/channel-teams/teams-channel.d.ts +4 -0
  49. package/dist/channel-teams/teams-channel.js +4 -0
  50. package/dist/channel-telegram/feature.js +4 -2
  51. package/dist/channel-telegram/index.d.ts +1 -1
  52. package/dist/channel-telegram/index.js +1 -1
  53. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  54. package/dist/channel-telegram/telegram-channel.js +28 -12
  55. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  56. package/dist/delivery/chat-webhook-channel.js +10 -5
  57. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  58. package/dist/delivery/chat-webhook-sender.js +13 -8
  59. package/dist/delivery/delivery-service.d.ts +4 -3
  60. package/dist/delivery/delivery-service.js +53 -33
  61. package/dist/delivery/index.d.ts +2 -2
  62. package/dist/delivery/index.js +2 -2
  63. package/dist/delivery/jobs.js +3 -1
  64. package/dist/delivery/types.d.ts +5 -3
  65. package/dist/legal-pages/feature.d.ts +3 -15
  66. package/dist/legal-pages/feature.js +20 -50
  67. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  68. package/dist/legal-pages/lib/boot-check.js +33 -0
  69. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  70. package/dist/legal-pages/lib/validate-routes.js +17 -0
  71. package/dist/page-render/index.d.ts +2 -1
  72. package/dist/page-render/index.js +1 -0
  73. package/dist/page-render/layout.d.ts +7 -0
  74. package/dist/page-render/layout.js +5 -0
  75. package/dist/page-render/trailing-slash.d.ts +2 -0
  76. package/dist/page-render/trailing-slash.js +15 -0
  77. package/dist/renderer-simple/feature.js +1 -0
  78. package/dist/renderer-simple/index.d.ts +1 -1
  79. package/dist/renderer-simple/index.js +1 -1
  80. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  81. package/dist/renderer-simple/simple-renderer.js +66 -12
  82. package/dist/secrets/announce-secret-change.d.ts +8 -0
  83. package/dist/secrets/announce-secret-change.js +18 -0
  84. package/dist/secrets/feature.js +1 -1
  85. package/dist/secrets/handlers/delete.write.js +2 -0
  86. package/dist/secrets/handlers/set.write.js +6 -1
  87. package/dist/secrets/i18n.d.ts +4 -0
  88. package/dist/secrets/i18n.js +4 -0
  89. package/dist/secrets/secrets-context.d.ts +2 -0
  90. package/dist/secrets/secrets-context.js +3 -0
  91. package/dist/secrets/write-gate.d.ts +5 -1
  92. package/dist/secrets/write-gate.js +24 -1
  93. package/dist/tenant/constants.d.ts +1 -0
  94. package/dist/tenant/constants.js +1 -0
  95. package/dist/tenant/handlers/add-member.write.js +14 -3
  96. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  97. package/dist/tenant/handlers/invitations.query.js +15 -1
  98. package/dist/tenant/index.d.ts +1 -0
  99. package/dist/tenant/index.js +1 -0
  100. package/dist/tenant/invitation-table.d.ts +2 -0
  101. package/dist/tenant/invitation-table.js +10 -1
  102. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  103. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  104. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  105. package/dist/tier-engine/feature.js +83 -14
  106. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  107. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  108. package/dist/user/invited-global-roles.d.ts +14 -0
  109. package/dist/user/invited-global-roles.js +35 -0
  110. package/dist/user-data-rights/feature.js +3 -0
  111. package/dist/waitlist/constants.d.ts +33 -0
  112. package/dist/waitlist/constants.js +35 -0
  113. package/dist/waitlist/entity.d.ts +433 -0
  114. package/dist/waitlist/entity.js +64 -0
  115. package/dist/waitlist/feature.d.ts +3 -0
  116. package/dist/waitlist/feature.js +38 -0
  117. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  118. package/dist/waitlist/handlers/invite.write.js +119 -0
  119. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  120. package/dist/waitlist/handlers/list.query.js +10 -0
  121. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  122. package/dist/waitlist/handlers/reject.write.js +80 -0
  123. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  124. package/dist/waitlist/handlers/submit.write.js +112 -0
  125. package/dist/waitlist/i18n.d.ts +6 -0
  126. package/dist/waitlist/i18n.js +31 -0
  127. package/dist/waitlist/index.d.ts +5 -0
  128. package/dist/waitlist/index.js +4 -0
  129. package/dist/waitlist/lib.d.ts +10 -0
  130. package/dist/waitlist/lib.js +24 -0
  131. package/dist/waitlist/mail.d.ts +15 -0
  132. package/dist/waitlist/mail.js +73 -0
  133. package/dist/waitlist/options.d.ts +29 -0
  134. package/dist/waitlist/options.js +8 -0
  135. package/dist/waitlist/payloads.d.ts +14 -0
  136. package/dist/waitlist/payloads.js +47 -0
  137. package/dist/waitlist/screens.d.ts +2 -0
  138. package/dist/waitlist/screens.js +42 -0
  139. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  140. package/dist/waitlist/user-data-hooks.js +71 -0
  141. package/package.json +17 -9
  142. package/src/auth-email-password/changes.json +18 -0
  143. package/src/auth-mfa/changes.json +6 -0
  144. package/src/billing-foundation/changes.json +6 -0
  145. package/src/channel-telegram/changes.json +9 -0
  146. package/src/delivery/changes.json +7 -0
  147. package/src/files/changes.json +13 -1
  148. package/src/renderer-simple/changes.json +6 -0
  149. package/src/secrets/changes.json +12 -0
  150. package/src/tenant/changes.json +6 -0
  151. package/src/waitlist/changes.json +17 -0
@@ -28,6 +28,8 @@ import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } fr
28
28
  import { findForbiddenMembershipRole, reservedMembershipRoleError, } from "../../tenant/membership-roles.js";
29
29
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
30
30
  import { seedTenantMembership } from "../../tenant/seeding.js";
31
+ // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
32
+ import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.js";
31
33
  // kumiko-lint-ignore cross-feature-import existence-check
32
34
  import { userTable } from "../../user/schema/user.js";
33
35
  import { invalidInviteToken } from "../errors.js";
@@ -70,6 +72,7 @@ export function createInviteSignupCompleteHandler() {
70
72
  const invitationTenantId = invitation.tenantId;
71
73
  const invitationEmail = await decryptStoredPii(invitation.email, "email", "auth:invite-signup-complete");
72
74
  const invitationRole = invitation.role;
75
+ const invitationGlobalRoles = parseInvitedGlobalRoles(invitation.globalRoles);
73
76
  const invitationVersion = invitation.version;
74
77
  // User-Not-Exists-Check: wenn die Email schon registriert ist,
75
78
  // muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
@@ -93,6 +96,7 @@ export function createInviteSignupCompleteHandler() {
93
96
  password: event.payload.password,
94
97
  displayName: invitationEmail.split("@")[0] ?? invitationEmail,
95
98
  emailVerified: true,
99
+ ...(invitationGlobalRoles.length > 0 && { roles: invitationGlobalRoles }),
96
100
  });
97
101
  // Membership-Add via seed-helper (gleiches Pattern wie
98
102
  // provisionSignupAccount — bypassed addMember access-check
@@ -121,7 +125,7 @@ export function createInviteSignupCompleteHandler() {
121
125
  tenantId: invitationTenantId,
122
126
  // buildSessionRoles calls stripForbiddenMembershipRoles internally —
123
127
  // a reserved role on the invitation itself must never reach the session.
124
- roles: buildSessionRoles([], [invitationRole]),
128
+ roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
125
129
  };
126
130
  committed = true;
127
131
  return {
@@ -54,6 +54,7 @@ export function createSignupRequestHandler(opts) {
54
54
  schema: SignupRequestSchema,
55
55
  access: { roles: ["anonymous"] },
56
56
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
57
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
57
58
  description: "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
58
59
  escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
59
60
  handler: async (event, ctx) => {
@@ -27,6 +27,7 @@ export function createTokenRequestHandler(spec, opts) {
27
27
  schema: RequestTokenSchema,
28
28
  access: { roles: ["anonymous"] },
29
29
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
30
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
30
31
  escapeHatch: {
31
32
  reason: "Anonymous requester has no session — looks up the target user by email via " +
32
33
  "ctx.queryAs(SYSTEM, user:findForAuth) to mint the out-of-band token.",
@@ -1,5 +1,7 @@
1
1
  export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
2
2
  export { type AuthPath, type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
3
+ export type { BootstrapConfigValue, BootstrapDeps, BootstrapInvite, BootstrapInviteOutcome, BootstrapPlan, BootstrapReport, BootstrapSeedDeps, BootstrapSystemAdmin, BootstrapTenant, BootstrapTenantOutcome, } from "./bootstrap.js";
4
+ export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
3
5
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
4
6
  export type { AuthMailContent, AuthMailLocale, AuthMailSection, RenderInviteEmailArgs, RenderTokenContentArgs, } from "./email-templates.js";
5
7
  export { renderActivationEmail, renderInviteEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail, } from "./email-templates.js";
@@ -6,6 +6,7 @@
6
6
  // its own changeset + deliberate deprecation window, not a silent drop.
7
7
  export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
8
8
  export { DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
9
+ export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
9
10
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
10
11
  export { renderActivationEmail, renderInviteEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail, } from "./email-templates.js";
11
12
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature.js";
@@ -28,3 +28,6 @@ export declare function deleteInviteToken(redis: Redis, args: {
28
28
  /** Burn release for failed-accept paths (DB error etc.) so a legitimate
29
29
  * retry isn't blocked by a stale burn marker. */
30
30
  export declare const unburnInviteToken: (redis: Redis, token: string) => Promise<void>;
31
+ /** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
32
+ * is the single truth for invite expiry (see the module comment). */
33
+ export declare function hasLiveInviteToken(redis: Redis, invitationId: string): Promise<boolean>;
@@ -58,3 +58,8 @@ export async function deleteInviteToken(redis, args) {
58
58
  /** Burn release for failed-accept paths (DB error etc.) so a legitimate
59
59
  * retry isn't blocked by a stale burn marker. */
60
60
  export const unburnInviteToken = store.unburn;
61
+ /** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
62
+ * is the single truth for invite expiry (see the module comment). */
63
+ export async function hasLiveInviteToken(redis, invitationId) {
64
+ return (await store.getTokenHashForSubject(redis, invitationId)) !== null;
65
+ }
@@ -44,6 +44,7 @@ export async function dispatchMagicLinkMail(notify, spec, params) {
44
44
  await notify(spec.notificationType, {
45
45
  route: { email: params.email },
46
46
  data: content,
47
+ locale,
47
48
  priority: "critical",
48
49
  });
49
50
  }
@@ -2,4 +2,4 @@ import { type ConfigKeyHandle } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const MFA_REQUIRED_POLICIES: readonly ["optional", "admins", "all"];
3
3
  export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
4
4
  export declare const mfaRequiredConfigHandle: ConfigKeyHandle<"select">;
5
- export declare function mfaRequiredConfigKey(): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
5
+ export declare function mfaRequiredConfigKey(defaultPolicy?: MfaRequiredPolicy): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
@@ -8,15 +8,20 @@
8
8
  // UI). Flipping this away from "optional" before PR3 locks out every
9
9
  // unenrolled matching user with no recovery path. Fine for a tenant that
10
10
  // enrolls its admins out-of-band first; a footgun otherwise.
11
- import { createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { access, createTenantConfig, } from "@cosmicdrift/kumiko-framework/engine";
12
12
  export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"];
13
13
  export const mfaRequiredConfigHandle = {
14
14
  name: "auth-mfa:config:required",
15
15
  type: "select",
16
16
  };
17
- export function mfaRequiredConfigKey() {
17
+ // Write access is machine-only: a TenantAdmin must not be able to relax the
18
+ // policy back to "optional". The app-wide default comes from
19
+ // `createAuthMfaFeature({ requiredPolicy })`, per-tenant overrides are set by
20
+ // system writes (runBootstrap `tenants[].config`, jobs, SystemAdmin tooling).
21
+ export function mfaRequiredConfigKey(defaultPolicy = "optional") {
18
22
  return createTenantConfig("select", {
19
- default: "optional",
23
+ default: defaultPolicy,
20
24
  options: [...MFA_REQUIRED_POLICIES],
25
+ write: access.system,
21
26
  });
22
27
  }
@@ -1,10 +1,12 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type MfaRequiredPolicy } from "./config.js";
2
3
  import { type MfaCodeVerifier } from "./mfa-code-verifier.js";
3
4
  import { type MfaStatusChecker } from "./mfa-status-checker.js";
4
5
  export type AuthMfaFeatureOptions = {
5
6
  readonly setupTokenSecret: string;
6
7
  readonly issuer: string;
7
8
  readonly challengeTokenSecret: string;
9
+ readonly requiredPolicy?: MfaRequiredPolicy;
8
10
  };
9
11
  export type BindMfaRevokeAllOtherSessions = (revoker: (userId: string, currentSid: string | undefined) => Promise<number>) => void;
10
12
  export declare function bindMfaRevokeAllOtherSessionsFromFeature(feature: FeatureDefinition): BindMfaRevokeAllOtherSessions | undefined;
@@ -87,7 +87,7 @@ export function createAuthMfaFeature(opts) {
87
87
  // the app boots clean and the first MFA login dies at runtime on an
88
88
  // unknown handler instead of failing at mount time.
89
89
  r.requires("tenant");
90
- r.config("required", mfaRequiredConfigKey());
90
+ r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
91
91
  // Declarative secretMint screen — mint (no input) -> reveal QR/secret/
92
92
  // recovery-codes -> confirm with a code. App places it via r.nav in its
93
93
  // logged-in settings area (see personal-access-tokens/feature.ts for the
@@ -154,6 +154,7 @@ export function createAuthMfaFeature(opts) {
154
154
  // at app-boot time.
155
155
  const checkMfaStatus = createMfaStatusChecker({
156
156
  challengeTokenSecret: opts.challengeTokenSecret,
157
+ ...(opts.requiredPolicy && { requiredPolicy: opts.requiredPolicy }),
157
158
  });
158
159
  // No late-bind needed — same reasoning as checkMfaStatus: only needs the
159
160
  // HandlerContext the caller already has.
@@ -1,4 +1,5 @@
1
1
  import { type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type MfaRequiredPolicy } from "./config.js";
2
3
  export type MfaStatusCheckResult = {
3
4
  readonly required: false;
4
5
  } | {
@@ -11,4 +12,5 @@ export type MfaStatusCheckResult = {
11
12
  export type MfaStatusChecker = (ctx: HandlerContext, userId: string, tenantId: TenantId, roles: readonly string[]) => Promise<MfaStatusCheckResult>;
12
13
  export declare function createMfaStatusChecker(opts: {
13
14
  readonly challengeTokenSecret: string;
15
+ readonly requiredPolicy?: MfaRequiredPolicy;
14
16
  }): MfaStatusChecker;
@@ -26,7 +26,7 @@ export function createMfaStatusChecker(opts) {
26
26
  // user's tenant needs.
27
27
  // @cast-boundary engine-payload — "select" config values are validated
28
28
  // against the declared `options` at write-time by the config feature.
29
- const policy = ((await ctx.configResolver?.get(mfaRequiredConfigHandle.name, mfaRequiredConfigKey(), tenantId, userId, scopedDb)) ?? "optional");
29
+ const policy = ((await ctx.configResolver?.get(mfaRequiredConfigHandle.name, mfaRequiredConfigKey(opts.requiredPolicy), tenantId, userId, scopedDb)) ?? "optional");
30
30
  if (policy === "optional")
31
31
  return { required: false };
32
32
  const buildSetupToken = () => signMfaPreauthSetupToken({ userId, tenantId }, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES, opts.challengeTokenSecret).token;
@@ -138,6 +138,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
138
138
  await ctx.notify(CONTRACT_CONFIRMATION_NOTIFICATION_TYPE, {
139
139
  route: { email: recipient.data.email },
140
140
  data: content,
141
+ locale,
141
142
  priority: "critical",
142
143
  });
143
144
  return {
@@ -41,6 +41,7 @@ export function renderTerminationReceipt(args) {
41
41
  subject,
42
42
  header: subject,
43
43
  sections: [
44
+ { text: texts.receiptGreeting },
44
45
  { text: texts.receiptIntro },
45
46
  { text: `${texts.receivedAt}: ${formatReceivedAt(args.receivedAtIso, args.locale)}` },
46
47
  { text: `${texts.requestId}: ${args.requestId}` },
@@ -16,6 +16,7 @@ export async function notifyOperator(ctx, consumerProtection, plan) {
16
16
  ...plan,
17
17
  operatorEmail: consumerProtection.operatorEmail,
18
18
  }),
19
+ locale: plan.locale,
19
20
  priority: "critical",
20
21
  });
21
22
  }
@@ -1,8 +1,15 @@
1
1
  import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
2
+ import { type PublicPageWrapLayout } from "../../page-render/index.js";
2
3
  import type { ConsentLocale } from "./consent-text.js";
3
4
  export type ContractTerminationRoutesOptions = {
4
5
  readonly paths?: Readonly<Partial<Record<ConsentLocale, string>>>;
6
+ /** Layout around every page (form, review, result, 429, error). Default:
7
+ * the minimal `wrapInLayout` skeleton. The page headers (CSP with
8
+ * `script-src 'none'`, framing, no-store) stay with the framework, so the
9
+ * layout must work without JavaScript and only returns the HTML string. */
10
+ readonly wrapLayout?: PublicPageWrapLayout;
5
11
  };
6
12
  /** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
7
- * mount via the app's `extraRoutes`. */
13
+ * mount via the app's `extraRoutes`. The other trailing-slash form of each
14
+ * path 301-redirects (GET/HEAD) to the configured path. */
8
15
  export declare function createContractTerminationRoutes(options?: ContractTerminationRoutesOptions): AnonymousExtraRoute[];
@@ -2,12 +2,13 @@
2
2
  // no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
3
3
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
4
4
  import { getCookie } from "hono/cookie";
5
- import { securePageHeaders, wrapInLayout } from "../../page-render/index.js";
5
+ import { redirectToCanonicalPath, securePageHeaders, slashVariantOf, wrapInLayout, } from "../../page-render/index.js";
6
6
  import { SubscriptionFoundationHandlers } from "../constants.js";
7
7
  import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS } from "../events.js";
8
8
  import { formatReceivedAt } from "./termination-mail.js";
9
9
  import { requestContractTerminationSchema } from "./termination-request.js";
10
10
  import { TERMINATION_TEXTS } from "./termination-texts.js";
11
+ const PAGE_SLUG = "contract-termination";
11
12
  const DEFAULT_PATHS = {
12
13
  de: "/legal/kuendigen",
13
14
  en: "/legal/cancel",
@@ -42,11 +43,15 @@ function pageHeaders() {
42
43
  "x-frame-options": "DENY",
43
44
  };
44
45
  }
45
- function htmlResponse(locale, title, bodyHtml, status) {
46
- return new Response(wrapInLayout({ title, bodyHtml, lang: locale }), {
47
- status,
48
- headers: pageHeaders(),
49
- });
46
+ function htmlResponse(ctx, title, bodyHtml, status) {
47
+ const root = `<div data-kumiko-page="${PAGE_SLUG}">\n${bodyHtml}\n</div>`;
48
+ return new Response(ctx.wrapLayout({
49
+ title,
50
+ bodyHtml: root,
51
+ lang: ctx.locale,
52
+ slug: PAGE_SLUG,
53
+ alternates: ctx.alternates,
54
+ }), { status, headers: pageHeaders() });
50
55
  }
51
56
  function isOneOf(options, value) {
52
57
  return options.includes(value);
@@ -109,30 +114,32 @@ function radioGroup(name, options, labels, selected) {
109
114
  .join("");
110
115
  }
111
116
  function textField(name, label, value, attributes) {
112
- return `<p><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
117
+ return `<p data-kumiko-field-group="${name}"><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
113
118
  }
114
- function formPage(locale, path, values, messages, status) {
119
+ function formPage(ctx, values, messages, status) {
120
+ const { locale, path } = ctx;
115
121
  const texts = TERMINATION_TEXTS[locale];
116
122
  const { page } = texts;
117
123
  const body = `<h1>${escapeHtml(page.title)}</h1>
118
124
  <p>${escapeHtml(page.lead)}</p>
119
125
  ${errorsHtml(messages, page)}
120
- <form method="post" action="${escapeHtmlAttr(path)}">
126
+ <form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination">
121
127
  <input type="hidden" name="step" value="review">
122
- <fieldset><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
123
- <fieldset><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
128
+ <fieldset data-kumiko-field-group="declarationType"><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
129
+ <fieldset data-kumiko-field-group="terminationKind"><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
124
130
  ${textField("name", page.nameLabel, values.name, { required: true, maxlength: 200 })}
125
131
  ${textField("email", page.emailLabel, values.email, { type: "email", required: true, maxlength: 254 })}
126
132
  ${textField("customerReference", page.customerReferenceLabel, values.customerReference, { maxlength: 200 })}
127
- <p><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
128
- <p><button type="submit">${escapeHtml(page.reviewButton)}</button></p>
133
+ <p data-kumiko-field-group="reason"><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
134
+ <p><button type="submit" data-kumiko-button="review">${escapeHtml(page.reviewButton)}</button></p>
129
135
  </form>`;
130
- return htmlResponse(locale, page.title, body, status);
136
+ return htmlResponse(ctx, page.title, body, status);
131
137
  }
132
138
  function hiddenInputs(values) {
133
139
  return FORM_FIELDS.map((field) => `<input type="hidden" name="${field}" value="${escapeHtmlAttr(values[field])}">`).join("\n");
134
140
  }
135
- function reviewPage(locale, path, values) {
141
+ function reviewPage(ctx, values) {
142
+ const { locale, path } = ctx;
136
143
  const texts = TERMINATION_TEXTS[locale];
137
144
  const { page } = texts;
138
145
  // validationMessages already proved the enum values.
@@ -147,7 +154,7 @@ function reviewPage(locale, path, values) {
147
154
  : `<tr><th scope="row">${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
148
155
  const body = `<h1>${escapeHtml(page.reviewTitle)}</h1>
149
156
  <p>${escapeHtml(page.reviewLead)}</p>
150
- <table>
157
+ <table data-kumiko-review-table>
151
158
  ${row(texts.declarationType, texts.declarationTypeLabel[declarationType])}
152
159
  ${row(texts.terminationKind, texts.terminationKindLabel[terminationKind])}
153
160
  ${row(texts.name, values.name)}
@@ -155,21 +162,22 @@ ${row(texts.email, values.email)}
155
162
  ${row(texts.customerReference, values.customerReference)}
156
163
  ${row(texts.reasonFieldLabel, values.reason)}
157
164
  </table>
158
- <form method="post" action="${escapeHtmlAttr(path)}">
165
+ <form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination-review">
159
166
  ${hiddenInputs(values)}
160
- <button type="submit" name="step" value="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
161
- <button type="submit" name="step" value="edit">${escapeHtml(page.backButton)}</button>
167
+ <button type="submit" name="step" value="confirm" data-kumiko-button="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
168
+ <button type="submit" name="step" value="edit" data-kumiko-button="back">${escapeHtml(page.backButton)}</button>
162
169
  </form>`;
163
- return htmlResponse(locale, page.reviewTitle, body, 200);
170
+ return htmlResponse(ctx, page.reviewTitle, body, 200);
164
171
  }
165
- function resultPage(locale, receivedAtIso, requestId) {
172
+ function resultPage(ctx, receivedAtIso, requestId) {
173
+ const { locale } = ctx;
166
174
  const { page } = TERMINATION_TEXTS[locale];
167
175
  const body = `<h1>${escapeHtml(page.resultTitle)}</h1>
168
176
  <p>${escapeHtml(page.resultLead)}</p>
169
177
  <p>${escapeHtml(page.resultReceivedAt)}: <strong>${escapeHtml(formatReceivedAt(receivedAtIso, locale))}</strong></p>
170
178
  <p>${escapeHtml(page.resultRequestId)}: <code>${escapeHtml(requestId)}</code></p>
171
179
  <p>${escapeHtml(page.resultMailNote)}</p>`;
172
- return htmlResponse(locale, page.resultTitle, body, 200);
180
+ return htmlResponse(ctx, page.resultTitle, body, 200);
173
181
  }
174
182
  const RATE_LIMITED_CODE = "rate_limited";
175
183
  const TENANT_HEADER = "x-tenant";
@@ -227,14 +235,27 @@ async function submitDeclaration(c, app, clientIp, values, locale) {
227
235
  }
228
236
  return { kind: "failed" };
229
237
  }
230
- function routesFor(locale, path) {
238
+ function routesFor(ctx) {
239
+ const { locale, path } = ctx;
240
+ const slashVariant = slashVariantOf(path);
231
241
  return [
232
242
  {
233
243
  method: "GET",
234
244
  path,
235
245
  entry: "anonymous",
236
- handler: () => formPage(locale, path, EMPTY_FORM, [], 200),
246
+ handler: () => formPage(ctx, EMPTY_FORM, [], 200),
237
247
  },
248
+ // GET also serves HEAD; POST gets no alias (a redirect would drop the body).
249
+ ...(slashVariant === null
250
+ ? []
251
+ : [
252
+ {
253
+ method: "GET",
254
+ path: slashVariant,
255
+ entry: "anonymous",
256
+ handler: (c) => redirectToCanonicalPath(c.req.url, path),
257
+ },
258
+ ]),
238
259
  {
239
260
  method: "POST",
240
261
  path,
@@ -242,27 +263,33 @@ function routesFor(locale, path) {
242
263
  handler: async (c, deps) => {
243
264
  const { step, values } = await readForm(c);
244
265
  if (step === "edit")
245
- return formPage(locale, path, values, [], 200);
266
+ return formPage(ctx, values, [], 200);
246
267
  const messages = validationMessages(values, locale);
247
268
  if (messages.length > 0)
248
- return formPage(locale, path, values, messages, 400);
269
+ return formPage(ctx, values, messages, 400);
249
270
  if (step !== "confirm")
250
- return reviewPage(locale, path, values);
271
+ return reviewPage(ctx, values);
251
272
  const outcome = await submitDeclaration(c, deps.app, deps.clientIp, values, locale);
252
273
  const { page } = TERMINATION_TEXTS[locale];
253
274
  if (outcome.kind === "received") {
254
- return resultPage(locale, outcome.receivedAtIso, outcome.requestId);
275
+ return resultPage(ctx, outcome.receivedAtIso, outcome.requestId);
255
276
  }
256
277
  if (outcome.kind === "rate-limited") {
257
- return htmlResponse(locale, page.rateLimitedTitle, `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`, 429);
278
+ return htmlResponse(ctx, page.rateLimitedTitle, `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`, 429);
258
279
  }
259
- return formPage(locale, path, values, [page.errorGeneric], 400);
280
+ return formPage(ctx, values, [page.errorGeneric], 400);
260
281
  },
261
282
  },
262
283
  ];
263
284
  }
264
285
  /** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
265
- * mount via the app's `extraRoutes`. */
286
+ * mount via the app's `extraRoutes`. The other trailing-slash form of each
287
+ * path 301-redirects (GET/HEAD) to the configured path. */
266
288
  export function createContractTerminationRoutes(options = {}) {
267
- return ["de", "en"].flatMap((locale) => routesFor(locale, options.paths?.[locale] ?? DEFAULT_PATHS[locale]));
289
+ const alternates = {
290
+ de: options.paths?.de ?? DEFAULT_PATHS.de,
291
+ en: options.paths?.en ?? DEFAULT_PATHS.en,
292
+ };
293
+ const wrapLayout = options.wrapLayout ?? wrapInLayout;
294
+ return ["de", "en"].flatMap((locale) => routesFor({ locale, path: alternates[locale], wrapLayout, alternates }));
268
295
  }
@@ -62,6 +62,7 @@ async function sendTerminationMails(ctx, consumerProtection, plan) {
62
62
  declaration: plan.declaration,
63
63
  operatorEmail: consumerProtection.operatorEmail,
64
64
  }),
65
+ locale: plan.locale,
65
66
  priority: "critical",
66
67
  });
67
68
  // skip: no operator notice needed for this declaration
@@ -104,6 +105,7 @@ export function createRequestContractTerminationHandler(options, consumerProtect
104
105
  schema: requestContractTerminationSchema,
105
106
  access: { roles: ["anonymous"] },
106
107
  rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
108
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
107
109
  ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
108
110
  escapeHatch: {
109
111
  reason: "Anonymous declarant has no session — finds the contract by email via ctx.queryAs(system, user:find-for-auth) and tenant:memberships; the tenant written to comes only from those lookups.",
@@ -12,6 +12,7 @@ export type TerminationTexts = {
12
12
  readonly customerReference: string;
13
13
  readonly reasonFieldLabel: string;
14
14
  readonly receiptSubject: Readonly<Record<ContractTerminationDeclarationType, string>>;
15
+ readonly receiptGreeting: string;
15
16
  readonly receiptIntro: string;
16
17
  readonly receiptNextSteps: string;
17
18
  readonly receiptRevoke: (operatorEmail: string) => string;
@@ -27,6 +27,7 @@ export const TERMINATION_TEXTS = {
27
27
  termination: "Eingangsbestätigung deiner Kündigung",
28
28
  withdrawal: "Eingangsbestätigung deines Widerrufs",
29
29
  },
30
+ receiptGreeting: "Hallo,",
30
31
  receiptIntro: "wir bestätigen den Eingang deiner Erklärung mit folgendem Inhalt. Diese E-Mail ist eine Eingangsbestätigung, keine Bestätigung der Wirksamkeit.",
31
32
  receiptNextSteps: "Wir prüfen deine Erklärung und melden uns, sobald sie bearbeitet ist oder wir Rückfragen haben.",
32
33
  receiptRevoke: (operatorEmail) => `Hast du diese Erklärung nicht abgegeben oder möchtest du sie zurücknehmen? Schreib uns bitte umgehend an ${operatorEmail}.`,
@@ -105,7 +106,8 @@ export const TERMINATION_TEXTS = {
105
106
  termination: "Receipt for your termination",
106
107
  withdrawal: "Receipt for your withdrawal",
107
108
  },
108
- receiptIntro: "we confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
109
+ receiptGreeting: "Hello,",
110
+ receiptIntro: "We confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
109
111
  receiptNextSteps: "We will review your declaration and get back to you once it is processed or if we have questions.",
110
112
  receiptRevoke: (operatorEmail) => `If you did not make this declaration or want to take it back, please write to ${operatorEmail} right away.`,
111
113
  contact: "Questions go to",
@@ -1,5 +1,9 @@
1
1
  import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
2
  export type DiscordChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const DISCORD_SECRET_KEYS: {
4
+ readonly webhookPrefix: "channel-discord:webhooks.";
5
+ readonly webhookKeyFor: (connection: string) => string;
6
+ };
3
7
  export declare const DISCORD_DEFAULT_ALLOWED_HOSTS: readonly ["discord.com", "discordapp.com"];
4
8
  export declare const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
5
9
  export declare const DISCORD_CONTENT_MAX_CHARS = 2000;
@@ -1,4 +1,8 @@
1
1
  import { chatMessageText, createChatWebhookChannel, truncateChars, } from "../delivery/index.js";
2
+ export const DISCORD_SECRET_KEYS = {
3
+ webhookPrefix: "channel-discord:webhooks.",
4
+ webhookKeyFor: (connection) => `channel-discord:webhooks.${connection}`,
5
+ };
2
6
  export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"];
3
7
  export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
4
8
  export const DISCORD_CONTENT_MAX_CHARS = 2000;
@@ -1,6 +1,6 @@
1
- import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
+ import { chatConnectionNameSchema, chatWebhookUrlSchema, DELIVERY_CHANNEL_EXTENSION, resolveChatWebhookTarget, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
- import { createDiscordChannel } from "./discord-channel.js";
3
+ import { createDiscordChannel, DISCORD_DEFAULT_ALLOWED_HOSTS, DISCORD_WEBHOOK_PATH_PREFIX, } from "./discord-channel.js";
4
4
  export function createChannelDiscordFeature(options = {}) {
5
5
  return defineFeature("channel-discord", (r) => {
6
6
  r.describe('Posts delivery notifications to a Discord webhook, registered as the `discord` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { discord: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-discord:webhooks.<connection name>`; only the connection name is logged. Mentions are disabled and content is cut to 2000 characters. Requires `delivery` and `secrets`. Host allowlist (discord.com, discordapp.com, path /api/webhooks/), https-only and the request timeout are app options of `createChannelDiscordFeature(opts)`, never tenant config.');
@@ -11,6 +11,10 @@ export function createChannelDiscordFeature(options = {}) {
11
11
  });
12
12
  r.requires("delivery");
13
13
  r.requires("secrets");
14
+ const webhookTarget = resolveChatWebhookTarget({
15
+ defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
16
+ requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
17
+ }, options);
14
18
  const webhooks = r.secretNamespace("webhooks", {
15
19
  label: { en: "Discord webhooks", de: "Discord-Webhooks", es: "Webhooks de Discord" },
16
20
  hint: {
@@ -20,6 +24,7 @@ export function createChannelDiscordFeature(options = {}) {
20
24
  },
21
25
  scope: "tenant",
22
26
  nameSchema: chatConnectionNameSchema,
27
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
23
28
  });
24
29
  const channel = createDiscordChannel(options, webhooks.keyFor);
25
30
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
@@ -1,2 +1,2 @@
1
- export { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
1
+ export { createDiscordChannel, DISCORD_DEFAULT_ALLOWED_HOSTS, DISCORD_SECRET_KEYS, DISCORD_WEBHOOK_PATH_PREFIX, type DiscordChannelOptions, } from "./discord-channel.js";
2
2
  export { createChannelDiscordFeature } from "./feature.js";
@@ -1,2 +1,2 @@
1
- export { createDiscordChannel } from "./discord-channel.js";
1
+ export { createDiscordChannel, DISCORD_DEFAULT_ALLOWED_HOSTS, DISCORD_SECRET_KEYS, DISCORD_WEBHOOK_PATH_PREFIX, } from "./discord-channel.js";
2
2
  export { createChannelDiscordFeature } from "./feature.js";
@@ -73,6 +73,7 @@ export function createEmailChannel(options) {
73
73
  const html = await renderer.render({
74
74
  template: message.notificationType,
75
75
  variables,
76
+ locale: message.locale,
76
77
  });
77
78
  const subject = variables["subject"] ?? message.title; // @cast-boundary dynamic-key
78
79
  return { html, subject };
@@ -1,6 +1,6 @@
1
- import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
+ import { chatConnectionNameSchema, chatWebhookUrlSchema, DELIVERY_CHANNEL_EXTENSION, resolveChatWebhookTarget, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
- import { createSlackChannel } from "./slack-channel.js";
3
+ import { createSlackChannel, SLACK_DEFAULT_ALLOWED_HOSTS, } from "./slack-channel.js";
4
4
  export function createChannelSlackFeature(options = {}) {
5
5
  return defineFeature("channel-slack", (r) => {
6
6
  r.describe('Posts delivery notifications to a Slack incoming webhook, registered as the `slack` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { slack: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-slack:webhooks.<connection name>`; only the connection name is logged. Requires `delivery` and `secrets`. Host allowlist (hooks.slack.com), https-only and the request timeout are app options of `createChannelSlackFeature(opts)`, never tenant config.');
@@ -11,6 +11,7 @@ export function createChannelSlackFeature(options = {}) {
11
11
  });
12
12
  r.requires("delivery");
13
13
  r.requires("secrets");
14
+ const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS }, options);
14
15
  const webhooks = r.secretNamespace("webhooks", {
15
16
  label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
16
17
  hint: {
@@ -20,6 +21,7 @@ export function createChannelSlackFeature(options = {}) {
20
21
  },
21
22
  scope: "tenant",
22
23
  nameSchema: chatConnectionNameSchema,
24
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
23
25
  });
24
26
  const channel = createSlackChannel(options, webhooks.keyFor);
25
27
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
@@ -1,2 +1,2 @@
1
1
  export { createChannelSlackFeature } from "./feature.js";
2
- export { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
2
+ export { createSlackChannel, SLACK_DEFAULT_ALLOWED_HOSTS, SLACK_SECRET_KEYS, type SlackChannelOptions, } from "./slack-channel.js";
@@ -1,2 +1,2 @@
1
1
  export { createChannelSlackFeature } from "./feature.js";
2
- export { createSlackChannel } from "./slack-channel.js";
2
+ export { createSlackChannel, SLACK_DEFAULT_ALLOWED_HOSTS, SLACK_SECRET_KEYS, } from "./slack-channel.js";
@@ -1,5 +1,9 @@
1
1
  import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
2
  export type SlackChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const SLACK_SECRET_KEYS: {
4
+ readonly webhookPrefix: "channel-slack:webhooks.";
5
+ readonly webhookKeyFor: (connection: string) => string;
6
+ };
3
7
  export declare const SLACK_DEFAULT_ALLOWED_HOSTS: readonly ["hooks.slack.com"];
4
8
  export declare function escapeSlackText(text: string): string;
5
9
  export declare function createSlackChannel(options: SlackChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -1,4 +1,8 @@
1
1
  import { chatMessageText, createChatWebhookChannel, } from "../delivery/index.js";
2
+ export const SLACK_SECRET_KEYS = {
3
+ webhookPrefix: "channel-slack:webhooks.",
4
+ webhookKeyFor: (connection) => `channel-slack:webhooks.${connection}`,
5
+ };
2
6
  export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"];
3
7
  // Slack mrkdwn treats & < > as control characters (links, @-mentions via <!channel>).
4
8
  export function escapeSlackText(text) {