@cosmicdrift/kumiko-samples 0.342.0 → 0.343.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/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -1
- package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -0
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
- package/packages/bundled-features/src/channel-push/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/changes.json +8 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/changes.json +32 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
- package/packages/bundled-features/src/delivery/constants.ts +4 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
- package/packages/bundled-features/src/delivery/events.ts +15 -1
- package/packages/bundled-features/src/delivery/feature.ts +32 -4
- package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
- package/packages/bundled-features/src/delivery/i18n.ts +55 -0
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +43 -20
- package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
- package/packages/bundled-features/src/delivery/public-names.ts +4 -0
- package/packages/bundled-features/src/delivery/tables.ts +3 -0
- package/packages/bundled-features/src/delivery/testing.ts +5 -9
- package/packages/bundled-features/src/delivery/types.ts +6 -2
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
- package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
- package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
- package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
- package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/constants.ts +10 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -0
- package/packages/bundled-features/src/secrets/i18n.ts +5 -3
- package/packages/bundled-features/src/secrets/index.ts +8 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/redact.ts +27 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
- package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
- package/packages/bundled-features/src/user/index.ts +1 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
- /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.343.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.343.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "escape-hatch retention follows the tenant compliance profile",
|
|
6
|
+
"detail": "The audit job `escape-hatch-retention` now deletes `escapeHatchUse` events after the `auditLog.retention` of the tenant's compliance profile. A tenant override can only lengthen that period, never go below the base retention of the selected profile. Cross-tenant audits (with `targetTenantId`, e.g. `identity-switch`) additionally follow the profile of the target tenant. The config key `audit:config:escape-hatch-retention-days` is only the fallback when compliance-profiles is not mounted.\n`pruneEvents` gains the optional `aggregateIds` option to restrict the prune to specific aggregates. `@cosmicdrift/kumiko-framework/compliance` exports `subtractRetentionSpec` and the `RetentionSpec` type for calendar-aware cutoffs (months and years)."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.313.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -25,7 +25,11 @@ export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
|
|
|
25
25
|
/** Single-event detail screen, breadcrumb-linked to the audit-log list. */
|
|
26
26
|
export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail" as const;
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Config key (system scope) for how long `escapeHatchUse` audit events are kept. Only a fallback
|
|
30
|
+
* for apps without compliance-profiles; with it mounted, the tenant profile's `auditLog.retention`
|
|
31
|
+
* applies (a tenant override can only lengthen it).
|
|
32
|
+
*/
|
|
29
33
|
export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days" as const;
|
|
30
34
|
|
|
31
35
|
export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
COMPLIANCE_PROFILES,
|
|
4
|
+
subtractRetentionSpec,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
1
6
|
import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
7
|
import {
|
|
3
8
|
type ConfigResolver,
|
|
9
|
+
parseTenantId,
|
|
4
10
|
type Registry,
|
|
5
11
|
SYSTEM_TENANT_ID,
|
|
12
|
+
type TenantId,
|
|
6
13
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import {
|
|
14
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
16
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
17
|
+
import { resolveProfileForTenant } from "../compliance-profiles/index.js";
|
|
8
18
|
import { createConfigAccessor } from "../config/index.js";
|
|
9
19
|
import {
|
|
10
20
|
DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
|
|
11
21
|
ESCAPE_HATCH_RETENTION_DAYS_KEY,
|
|
12
22
|
} from "./constants.js";
|
|
13
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
ESCAPE_HATCH_USE_AGGREGATE_TYPE,
|
|
25
|
+
escapeHatchUsedSchema,
|
|
26
|
+
} from "./escape-hatch-audit-sink.js";
|
|
14
27
|
|
|
15
28
|
// pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
|
|
16
29
|
export function isDbConnection(runner: DbRunner): runner is DbConnection {
|
|
@@ -35,19 +48,97 @@ async function resolveRetentionDays(args: {
|
|
|
35
48
|
return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
// Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
|
|
52
|
+
const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
|
|
53
|
+
const PRUNE_CHUNK_SIZE = 1000;
|
|
54
|
+
|
|
55
|
+
type AuditEventRow = {
|
|
56
|
+
readonly aggregateId: string;
|
|
57
|
+
readonly tenantId: TenantId;
|
|
58
|
+
readonly createdAt: Temporal.Instant;
|
|
59
|
+
readonly payload: unknown;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Overrides may only lengthen retention: the earlier cutoff (same `now`) is the longer period.
|
|
63
|
+
function earlierInstant(a: Temporal.Instant, b: Temporal.Instant): Temporal.Instant {
|
|
64
|
+
return Temporal.Instant.compare(a, b) <= 0 ? a : b;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveTenantCutoff(
|
|
68
|
+
db: DbConnection,
|
|
69
|
+
tenantId: TenantId,
|
|
70
|
+
now: Temporal.Instant,
|
|
71
|
+
): Promise<Temporal.Instant> {
|
|
72
|
+
const { profile } = await resolveProfileForTenant({ db, tenantId });
|
|
73
|
+
const effective = subtractRetentionSpec(now, profile.auditLog.retention);
|
|
74
|
+
const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
|
|
75
|
+
return earlierInstant(effective, base);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
|
|
79
|
+
// satisfy the target tenant's retention. A malformed payload falls back to the storing tenant.
|
|
80
|
+
function targetTenantOf(payload: unknown): TenantId | null {
|
|
81
|
+
const parsed = escapeHatchUsedSchema.safeParse(payload);
|
|
82
|
+
return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
|
|
86
|
+
const rows = await selectMany<AuditEventRow>(db, eventsTable, {
|
|
87
|
+
aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
|
|
88
|
+
});
|
|
89
|
+
const now = getTemporal().Now.instant();
|
|
90
|
+
const cutoffByTenant = new Map<TenantId, Temporal.Instant>();
|
|
91
|
+
const cutoffOf = async (tenantId: TenantId): Promise<Temporal.Instant> => {
|
|
92
|
+
const cached = cutoffByTenant.get(tenantId);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
const cutoff = await resolveTenantCutoff(db, tenantId, now);
|
|
95
|
+
cutoffByTenant.set(tenantId, cutoff);
|
|
96
|
+
return cutoff;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const expiredAggregateIds: string[] = [];
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const targetTenantId = targetTenantOf(row.payload);
|
|
102
|
+
const storeCutoff = await cutoffOf(row.tenantId);
|
|
103
|
+
const cutoff = targetTenantId
|
|
104
|
+
? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
|
|
105
|
+
: storeCutoff;
|
|
106
|
+
if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
|
|
107
|
+
expiredAggregateIds.push(row.aggregateId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let deletedCount = 0;
|
|
112
|
+
for (let i = 0; i < expiredAggregateIds.length; i += PRUNE_CHUNK_SIZE) {
|
|
113
|
+
const result = await pruneEvents(db, {
|
|
114
|
+
aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
|
|
115
|
+
aggregateIds: expiredAggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
|
|
116
|
+
olderThan: now,
|
|
117
|
+
});
|
|
118
|
+
deletedCount += result.deletedCount;
|
|
119
|
+
}
|
|
120
|
+
return deletedCount;
|
|
121
|
+
}
|
|
122
|
+
|
|
38
123
|
export async function runEscapeHatchRetention(args: {
|
|
39
124
|
readonly db: DbRunner;
|
|
40
125
|
readonly registry: Registry;
|
|
41
126
|
readonly configResolver: ConfigResolver | undefined;
|
|
42
127
|
readonly userId: string;
|
|
43
|
-
}): Promise<
|
|
128
|
+
}): Promise<{ readonly deletedCount: number }> {
|
|
44
129
|
const { db } = args;
|
|
45
130
|
if (!isDbConnection(db)) {
|
|
46
131
|
throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
|
|
47
132
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
133
|
+
|
|
134
|
+
if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
|
|
135
|
+
const olderThanDays = await resolveRetentionDays({ ...args, db });
|
|
136
|
+
const { deletedCount } = await pruneEvents(db, {
|
|
137
|
+
aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
|
|
138
|
+
olderThanDays,
|
|
139
|
+
});
|
|
140
|
+
return { deletedCount };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { deletedCount: await pruneByTenantProfiles(db) };
|
|
53
144
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Mail expiry shows duration and local time with time zone",
|
|
6
|
+
"detail": "Auth mails state the link validity as a duration and in the user's time zone\nThe five token mail renderers replace the UTC timestamp with `{duration}` (for example \"1 day\") and `{when}` (medium date and short time with zone abbreviation, in the recipient's locale). `RenderTokenContentArgs` gains `issuedAt` (default now) and `timeZone` (default UTC; an invalid zone falls back to UTC); the handlers pass the token issue time and `ctx.tz.user`. German strings use \"Konto\" instead of \"Account\".",
|
|
7
|
+
"migration": "Custom `auth.mail.*.expiry` translations should use the new `{duration}` and `{when}` placeholders; `{when}` now includes the time and zone abbreviation instead of a UTC timestamp."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.342.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -20,21 +20,21 @@ export const AUTH_MAIL_EN: Readonly<Record<string, string>> = {
|
|
|
20
20
|
"auth.mail.reset.intro":
|
|
21
21
|
"you requested a password reset for {app}. Click the link below to set a new password:",
|
|
22
22
|
"auth.mail.reset.button": "Reset password",
|
|
23
|
-
"auth.mail.reset.expiry": "The link
|
|
23
|
+
"auth.mail.reset.expiry": "The link is valid for {duration} (until {when}).",
|
|
24
24
|
"auth.mail.reset.ignore":
|
|
25
25
|
"If you didn't request a reset, you can safely ignore this email — your password won't change.",
|
|
26
26
|
"auth.mail.verify.subject": "{app} — Verify your email",
|
|
27
27
|
"auth.mail.verify.greeting": "Welcome,",
|
|
28
28
|
"auth.mail.verify.intro": "please verify your email address for {app} to activate your account:",
|
|
29
29
|
"auth.mail.verify.button": "Verify email",
|
|
30
|
-
"auth.mail.verify.expiry": "The link
|
|
30
|
+
"auth.mail.verify.expiry": "The link is valid for {duration} (until {when}).",
|
|
31
31
|
"auth.mail.verify.ignore": "If you didn't create this account, you can ignore this email.",
|
|
32
32
|
"auth.mail.activation.subject": "{app} — Activate your account",
|
|
33
33
|
"auth.mail.activation.greeting": "Welcome,",
|
|
34
34
|
"auth.mail.activation.intro":
|
|
35
35
|
"click the link below to activate your {app} account. The next step is choosing your password:",
|
|
36
36
|
"auth.mail.activation.button": "Activate account",
|
|
37
|
-
"auth.mail.activation.expiry": "The link
|
|
37
|
+
"auth.mail.activation.expiry": "The link is valid for {duration} (until {when}).",
|
|
38
38
|
"auth.mail.activation.ignore":
|
|
39
39
|
"If you didn't sign up, you can ignore this email — no account is created until you open the link.",
|
|
40
40
|
"auth.mail.invite.subject": "{app} — Workspace invitation",
|
|
@@ -42,14 +42,14 @@ export const AUTH_MAIL_EN: Readonly<Record<string, string>> = {
|
|
|
42
42
|
"auth.mail.invite.intro":
|
|
43
43
|
"you've been invited to a {app} workspace as {role}. Click the link below to accept:",
|
|
44
44
|
"auth.mail.invite.button": "Accept invitation",
|
|
45
|
-
"auth.mail.invite.expiry": "The link
|
|
45
|
+
"auth.mail.invite.expiry": "The link is valid for {duration} (until {when}).",
|
|
46
46
|
"auth.mail.invite.ignore": "If you weren't expecting this invitation, you can ignore this email.",
|
|
47
47
|
"auth.mail.unlock.subject": "{app} — Unlock your account",
|
|
48
48
|
"auth.mail.unlock.greeting": "Hi,",
|
|
49
49
|
"auth.mail.unlock.intro":
|
|
50
50
|
"your {app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:",
|
|
51
51
|
"auth.mail.unlock.button": "Unlock account",
|
|
52
|
-
"auth.mail.unlock.expiry": "The link
|
|
52
|
+
"auth.mail.unlock.expiry": "The link is valid for {duration} (until {when}).",
|
|
53
53
|
"auth.mail.unlock.ignore":
|
|
54
54
|
"If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
|
|
55
55
|
};
|
|
@@ -62,6 +62,10 @@ export type RenderTokenContentArgs = {
|
|
|
62
62
|
readonly locale?: AuthMailLocale;
|
|
63
63
|
/** Optional app name for subject + intro. Default from auth.mail.appNameDefault. */
|
|
64
64
|
readonly appName?: string;
|
|
65
|
+
/** ISO instant the token was issued; the validity duration is measured from it. Default: now. */
|
|
66
|
+
readonly issuedAt?: string;
|
|
67
|
+
/** IANA zone the expiry time is shown in. Default "UTC"; an invalid zone falls back to "UTC". */
|
|
68
|
+
readonly timeZone?: string;
|
|
65
69
|
};
|
|
66
70
|
|
|
67
71
|
export type RenderInviteEmailArgs = RenderTokenContentArgs & { readonly role: string };
|
|
@@ -119,7 +123,7 @@ export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMail
|
|
|
119
123
|
intro: t(locale, "auth.mail.reset.intro", { app }),
|
|
120
124
|
buttonLabel: t(locale, "auth.mail.reset.button"),
|
|
121
125
|
buttonUrl: args.url,
|
|
122
|
-
expiry: t(locale, "auth.mail.reset.expiry",
|
|
126
|
+
expiry: t(locale, "auth.mail.reset.expiry", expiryParams(args, locale)),
|
|
123
127
|
ignore: t(locale, "auth.mail.reset.ignore"),
|
|
124
128
|
});
|
|
125
129
|
}
|
|
@@ -134,7 +138,7 @@ export function renderUnlockAccountEmail(args: RenderTokenContentArgs): AuthMail
|
|
|
134
138
|
intro: t(locale, "auth.mail.unlock.intro", { app }),
|
|
135
139
|
buttonLabel: t(locale, "auth.mail.unlock.button"),
|
|
136
140
|
buttonUrl: args.url,
|
|
137
|
-
expiry: t(locale, "auth.mail.unlock.expiry",
|
|
141
|
+
expiry: t(locale, "auth.mail.unlock.expiry", expiryParams(args, locale)),
|
|
138
142
|
ignore: t(locale, "auth.mail.unlock.ignore"),
|
|
139
143
|
});
|
|
140
144
|
}
|
|
@@ -149,7 +153,7 @@ export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent
|
|
|
149
153
|
intro: t(locale, "auth.mail.verify.intro", { app }),
|
|
150
154
|
buttonLabel: t(locale, "auth.mail.verify.button"),
|
|
151
155
|
buttonUrl: args.url,
|
|
152
|
-
expiry: t(locale, "auth.mail.verify.expiry",
|
|
156
|
+
expiry: t(locale, "auth.mail.verify.expiry", expiryParams(args, locale)),
|
|
153
157
|
ignore: t(locale, "auth.mail.verify.ignore"),
|
|
154
158
|
});
|
|
155
159
|
}
|
|
@@ -164,7 +168,7 @@ export function renderActivationEmail(args: RenderTokenContentArgs): AuthMailCon
|
|
|
164
168
|
intro: t(locale, "auth.mail.activation.intro", { app }),
|
|
165
169
|
buttonLabel: t(locale, "auth.mail.activation.button"),
|
|
166
170
|
buttonUrl: args.url,
|
|
167
|
-
expiry: t(locale, "auth.mail.activation.expiry",
|
|
171
|
+
expiry: t(locale, "auth.mail.activation.expiry", expiryParams(args, locale)),
|
|
168
172
|
ignore: t(locale, "auth.mail.activation.ignore"),
|
|
169
173
|
});
|
|
170
174
|
}
|
|
@@ -179,20 +183,76 @@ export function renderInviteEmail(args: RenderInviteEmailArgs): AuthMailContent
|
|
|
179
183
|
intro: t(locale, "auth.mail.invite.intro", { app, role: args.role }),
|
|
180
184
|
buttonLabel: t(locale, "auth.mail.invite.button"),
|
|
181
185
|
buttonUrl: args.url,
|
|
182
|
-
expiry: t(locale, "auth.mail.invite.expiry",
|
|
186
|
+
expiry: t(locale, "auth.mail.invite.expiry", expiryParams(args, locale)),
|
|
183
187
|
ignore: t(locale, "auth.mail.invite.ignore"),
|
|
184
188
|
});
|
|
185
189
|
}
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
const MS_PER_MINUTE = 60_000;
|
|
192
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
193
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
194
|
+
|
|
195
|
+
type DurationUnit = "day" | "hour" | "minute";
|
|
196
|
+
|
|
197
|
+
// Rounds to whole minutes first so a few ms between issuing and signing still
|
|
198
|
+
// read as "1 day"; a larger unit is only used when it divides the span exactly.
|
|
199
|
+
function durationParts(ms: number): { readonly amount: number; readonly unit: DurationUnit } {
|
|
200
|
+
const minutes = Math.max(1, Math.round(ms / MS_PER_MINUTE));
|
|
201
|
+
const minutesPerHour = MS_PER_HOUR / MS_PER_MINUTE;
|
|
202
|
+
const minutesPerDay = MS_PER_DAY / MS_PER_MINUTE;
|
|
203
|
+
if (minutes % minutesPerDay === 0) return { amount: minutes / minutesPerDay, unit: "day" };
|
|
204
|
+
if (minutes % minutesPerHour === 0) return { amount: minutes / minutesPerHour, unit: "hour" };
|
|
205
|
+
return { amount: minutes, unit: "minute" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatDuration(ms: number, locale: string): string {
|
|
209
|
+
const { amount, unit } = durationParts(ms);
|
|
210
|
+
const options: Intl.NumberFormatOptions = { style: "unit", unit, unitDisplay: "long" };
|
|
188
211
|
try {
|
|
189
|
-
|
|
190
|
-
return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
|
|
212
|
+
return new Intl.NumberFormat(locale, options).format(amount);
|
|
191
213
|
} catch {
|
|
192
|
-
return
|
|
214
|
+
return new Intl.NumberFormat("en", options).format(amount);
|
|
193
215
|
}
|
|
194
216
|
}
|
|
195
217
|
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
// Intl rejects timeZoneName together with dateStyle/timeStyle, so the zone
|
|
219
|
+
// abbreviation is read from a second formatter and appended.
|
|
220
|
+
function formatWhen(expiresAt: Temporal.Instant, locale: string, timeZone: string): string {
|
|
221
|
+
const build = (tag: string, zone: string): string => {
|
|
222
|
+
const at = expiresAt.epochMilliseconds;
|
|
223
|
+
const dateTime = new Intl.DateTimeFormat(tag, {
|
|
224
|
+
dateStyle: "medium",
|
|
225
|
+
timeStyle: "short",
|
|
226
|
+
timeZone: zone,
|
|
227
|
+
}).format(at);
|
|
228
|
+
const zoneName = new Intl.DateTimeFormat(tag, { timeZone: zone, timeZoneName: "short" })
|
|
229
|
+
.formatToParts(at)
|
|
230
|
+
.find((part) => part.type === "timeZoneName")?.value;
|
|
231
|
+
return zoneName ? `${dateTime} ${zoneName}` : dateTime;
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
return build(locale, timeZone);
|
|
235
|
+
} catch {
|
|
236
|
+
try {
|
|
237
|
+
return build(locale, "UTC");
|
|
238
|
+
} catch {
|
|
239
|
+
return build("en", "UTC");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function expiryParams(
|
|
245
|
+
args: RenderTokenContentArgs,
|
|
246
|
+
locale: string,
|
|
247
|
+
): { readonly duration: string; readonly when: string } {
|
|
248
|
+
try {
|
|
249
|
+
const expiresAt = Temporal.Instant.from(args.expiresAt);
|
|
250
|
+
const issuedAt = args.issuedAt ? Temporal.Instant.from(args.issuedAt) : Temporal.Now.instant();
|
|
251
|
+
return {
|
|
252
|
+
duration: formatDuration(expiresAt.epochMilliseconds - issuedAt.epochMilliseconds, locale),
|
|
253
|
+
when: formatWhen(expiresAt, locale, args.timeZone ?? "UTC"),
|
|
254
|
+
};
|
|
255
|
+
} catch {
|
|
256
|
+
return { duration: args.expiresAt, when: args.expiresAt };
|
|
257
|
+
}
|
|
198
258
|
}
|
|
@@ -160,7 +160,8 @@ async function issueInvitation(
|
|
|
160
160
|
|
|
161
161
|
const email = request.email.toLowerCase();
|
|
162
162
|
const tenantId = inviter.tenantId;
|
|
163
|
-
const
|
|
163
|
+
const issuedAt = Temporal.Now.instant();
|
|
164
|
+
const expiresAt = issuedAt.add({ seconds: ttlSeconds });
|
|
164
165
|
|
|
165
166
|
// The unique index allows one row per (tenantId, email). Whatever its
|
|
166
167
|
// status, a re-invite resets it to pending with a fresh token.
|
|
@@ -233,6 +234,8 @@ async function issueInvitation(
|
|
|
233
234
|
appUrl: opts.appUrl,
|
|
234
235
|
token,
|
|
235
236
|
expiresAt: expiresAt.toString(),
|
|
237
|
+
issuedAt: issuedAt.toString(),
|
|
238
|
+
timeZone: ctx.tz.user,
|
|
236
239
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
237
240
|
locale,
|
|
238
241
|
},
|
|
@@ -150,7 +150,8 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
150
150
|
// session tokens.
|
|
151
151
|
const token = generateToken();
|
|
152
152
|
|
|
153
|
-
const
|
|
153
|
+
const issuedAt = Temporal.Now.instant();
|
|
154
|
+
const expiresAt = issuedAt.add({ seconds: ttlSeconds });
|
|
154
155
|
const expiresAtIso = expiresAt.toString();
|
|
155
156
|
|
|
156
157
|
await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
|
|
@@ -211,6 +212,8 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
211
212
|
appUrl: opts.appUrl,
|
|
212
213
|
token,
|
|
213
214
|
expiresAt: expiresAtIso,
|
|
215
|
+
issuedAt: issuedAt.toString(),
|
|
216
|
+
timeZone: ctx.tz.user,
|
|
214
217
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
215
218
|
locale,
|
|
216
219
|
},
|
|
@@ -137,6 +137,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
|
|
140
|
+
const issuedAt = expiresAt.subtract({ minutes: ttl }).toString();
|
|
140
141
|
|
|
141
142
|
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
142
143
|
|
|
@@ -154,6 +155,8 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
154
155
|
appUrl: opts.appUrl,
|
|
155
156
|
token,
|
|
156
157
|
expiresAt: expiresAt.toString(),
|
|
158
|
+
issuedAt,
|
|
159
|
+
timeZone: ctx.tz.user,
|
|
157
160
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
158
161
|
locale,
|
|
159
162
|
},
|
|
@@ -34,6 +34,8 @@ export type MagicLinkMailParams = {
|
|
|
34
34
|
readonly expiresAt: string;
|
|
35
35
|
readonly appName?: string;
|
|
36
36
|
readonly locale?: AuthMailLocale;
|
|
37
|
+
readonly issuedAt?: string;
|
|
38
|
+
readonly timeZone?: string;
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
function resolveAppUrl(appUrl: string | ((locale: string) => string), locale: string): string {
|
|
@@ -78,6 +80,8 @@ export async function dispatchMagicLinkMail(
|
|
|
78
80
|
expiresAt: params.expiresAt,
|
|
79
81
|
...(params.locale !== undefined && { locale: params.locale }),
|
|
80
82
|
...(params.appName !== undefined && { appName: params.appName }),
|
|
83
|
+
...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
|
|
84
|
+
...(params.timeZone !== undefined && { timeZone: params.timeZone }),
|
|
81
85
|
});
|
|
82
86
|
await notify(spec.notificationType, {
|
|
83
87
|
route: { email: params.email },
|
|
@@ -47,6 +47,7 @@ export function createChannelDiscordFeature(
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
const channel = createDiscordChannel(options, webhooks.keyFor);
|
|
50
|
+
r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
|
|
50
51
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
|
|
51
52
|
mode: channel.mode,
|
|
52
53
|
send: channel.send,
|
|
@@ -16,6 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
16
16
|
});
|
|
17
17
|
r.requires("delivery");
|
|
18
18
|
|
|
19
|
+
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
19
20
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
|
|
20
21
|
mode: channel.mode,
|
|
21
22
|
resolve: channel.resolve,
|
|
@@ -24,6 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
// Register as delivery channel via extension system
|
|
27
|
+
r.translations({ keys: { "delivery.channel.inApp": { en: "In-app" } } });
|
|
27
28
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
|
|
28
29
|
mode: inAppChannel.mode,
|
|
29
30
|
resolve: inAppChannel.resolve,
|
|
@@ -16,6 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
|
|
|
16
16
|
});
|
|
17
17
|
r.requires("delivery");
|
|
18
18
|
|
|
19
|
+
r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
|
|
19
20
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
20
21
|
mode: channel.mode,
|
|
21
22
|
resolve: channel.resolve,
|
|
@@ -41,6 +41,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const channel = createSlackChannel(options, webhooks.keyFor);
|
|
44
|
+
r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
|
|
44
45
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
|
|
45
46
|
mode: channel.mode,
|
|
46
47
|
send: channel.send,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Teams no longer reports any 2xx as delivered",
|
|
6
|
+
"detail": "`channel-teams` no longer reports every 2xx answer as sent. A 200 with the body `1` (classic Office connector) is a confirmed send. A 202 (Power Automate / Workflows endpoints) means the message was accepted but not confirmed: the attempt is `sent` and `NotifyResult.deliveries[].confirmed` is `false`. Any other 2xx, including an empty 200 as a made-up URL returns, fails with `unexpected_response`.\n`NotifyDelivery.confirmed` and `ChannelResult.confirmed` are new and only ever `false`. `createChatWebhookChannel` takes an optional `classifyResponse`, which `postChatWebhook` calls for 2xx answers with a status and a lazy `readBodyPrefix()` (at most 64 bytes; the body is always cancelled afterwards). `ChatSendResult` success now carries `confirmed`. `confirmed` is returned for inline delivery only; jobs do not carry it yet."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -45,6 +45,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
const channel = createTeamsChannel(options, webhooks.keyFor);
|
|
48
|
+
r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
|
|
48
49
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
|
|
49
50
|
mode: channel.mode,
|
|
50
51
|
send: channel.send,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ChatSendResult,
|
|
2
3
|
type ChatWebhookChannelOptions,
|
|
4
|
+
type ChatWebhookResponse,
|
|
3
5
|
createChatWebhookChannel,
|
|
4
6
|
type DeliveryChannel,
|
|
5
7
|
} from "../delivery/index.js";
|
|
@@ -40,6 +42,19 @@ export function buildTeamsAdaptiveCard(title: string, body: string | undefined):
|
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// Classic Office connectors answer 200 with the body "1". Workflow endpoints answer
|
|
46
|
+
// 202 and never say whether the card reached the channel. Any other 2xx (an empty
|
|
47
|
+
// 200 is what a made-up URL returns) is not a Teams success.
|
|
48
|
+
export async function classifyTeamsResponse(
|
|
49
|
+
response: ChatWebhookResponse,
|
|
50
|
+
): Promise<ChatSendResult> {
|
|
51
|
+
if (response.status === 202) return { ok: true, confirmed: false };
|
|
52
|
+
if (response.status === 200 && (await response.readBodyPrefix()).trim() === "1") {
|
|
53
|
+
return { ok: true, confirmed: true };
|
|
54
|
+
}
|
|
55
|
+
return { ok: false, code: "unexpected_response" };
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
export function createTeamsChannel(
|
|
44
59
|
options: TeamsChannelOptions,
|
|
45
60
|
keyFor: (connection: string) => string,
|
|
@@ -49,6 +64,7 @@ export function createTeamsChannel(
|
|
|
49
64
|
featureName: "channel-teams",
|
|
50
65
|
defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
|
|
51
66
|
keyFor,
|
|
67
|
+
classifyResponse: classifyTeamsResponse,
|
|
52
68
|
buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
|
|
53
69
|
options,
|
|
54
70
|
});
|
|
@@ -33,6 +33,7 @@ export function createChannelTelegramFeature(
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
const channel = createTelegramChannel(options, botToken.name);
|
|
36
|
+
r.translations({ keys: { "delivery.channel.telegram": { en: "Telegram" } } });
|
|
36
37
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
|
|
37
38
|
mode: channel.mode,
|
|
38
39
|
send: channel.send,
|
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Delivery log marks sends the provider did not confirm",
|
|
6
|
+
"detail": "The delivery log now records when a provider accepted a send without confirming delivery (for example a Teams webhook answering 202). `store_delivery_attempts` gets a nullable boolean column `confirmed`: `false` means accepted but unconfirmed, `null` means confirmed or not applicable. The `deliveryAttempt` event carries `confirmed: false` on both the inline and the job path, `delivery:query:log` returns `confirmed` per row, and the status cell shows \"Sent (unconfirmed)\" for such rows.",
|
|
7
|
+
"migration": "New nullable column store_delivery_attempts.confirmed. Consumers generate the migration with the generator (kumiko-schema generate) and apply it; existing rows stay null."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.343.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Delivery errors are fixed codes instead of raw messages",
|
|
13
|
+
"detail": "Delivery stores and returns only fixed error codes, never raw error messages. `delivery_attempts.error`, the `deliveryAttempt` event, `NotifyResult.deliveries[].error` and the job failure now hold one of `DELIVERY_FAILURE_CODES` (`timeout`, `network_error`, `redirect_blocked`, `host_not_allowed`, `missing_credentials`, `invalid_address`, `unexpected_response`, `render_failed`, `send_failed`, `channel_error`), one of `DELIVERY_SKIP_REASONS`, or `http_<status>`. A throwing channel ends as `send_failed` or `render_failed`; a failure around resolve or dispatch ends as `channel_error`. The full error goes to the log with URLs and email addresses redacted. `redactUrls` and `redactErrorText` are new next to `redactEmailAddresses`, which moved out of the step dispatcher.\n`@cosmicdrift/kumiko-framework/engine` exports `DELIVERY_FAILURE_CODES`, `DELIVERY_SKIP_REASONS`, `isDeliveryErrorCode` and the types `DeliveryErrorCode`, `DeliveryFailureCode`, `DeliverySkipReason` and `ChatSendFailureCode`. `NotifyDelivery.error`, `ChannelResult.error` and `DeliveryLogEntry.error` are now `DeliveryErrorCode`. `delivery:query:log` returns `channel_error` for stored rows that still hold free text.",
|
|
14
|
+
"migration": "NotifyDelivery.error is now a DeliveryErrorCode. Code that matches on error text must match the code instead (for example send_failed). Custom channels must return a DeliveryErrorCode in ChannelResult.error. Existing attempt events keep their free text; reading them through delivery:query:log masks it as channel_error."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.343.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Delivery log shows time and error and translates its values",
|
|
20
|
+
"detail": "The delivery log screen shows the time and the error of each attempt and translates type, channel, status and error. Error codes map to `delivery.error.<code>` (`http_<status>` to `delivery.error.http` with a `status` parameter), statuses to `delivery.status.<status>`, channels to `delivery.channel.<name>`. Every bundled channel feature registers its own channel label. Notification types are shown through `<scope>.notification.<name>` for a type `<scope>:notify:<name>`; apps register those labels, and the short name is shown when none exists. English, German and Spanish texts are included for the bundled keys.\n`translateOrRaw` moved from the tenant web folder to `shared/web`; it is internal."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.343.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "Delivery log shows the recipient name",
|
|
26
|
+
"detail": "`delivery:query:log` returns a `recipientLabel` per row and the delivery log screen shows it in the recipient column: the user's display name, else the decrypted address, else the recipient id. Names are resolved with one batched lookup per page, and only when the user feature is mounted. The new `resolveUserDisplayNames(db, userIds)` is exported from the user feature. The recipient column is not sortable."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.343.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "createDeliveryTestContext matches production notify",
|
|
32
|
+
"detail": "`createDeliveryTestContext` now behaves like the production `ctx.notify`. It takes a `secrets` option for inline-delivered chat channels and passes the calling context's job dispatcher to `notify()` on every call. Queued channels now go through the delivery jobs when the stack has a job consumer (`jobs: { consumerLane: \"worker\" }`); call `stack.drainJobs()` before asserting on the result. With `jobs: {}` and no `consumerLane` the attempts stay queued and `drainJobs()` never finishes. Without `jobs` they still deliver inline.\nNew export `createDeliveryNotifyFactory(deliveryService, { deliverQueuedInline? })` builds this `NotifyFactory` for custom setups."
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"version": "0.341.0",
|
|
4
36
|
"type": "breaking",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
2
|
import {
|
|
3
3
|
type ChatSendResult,
|
|
4
|
+
type ChatWebhookRequest,
|
|
4
5
|
type ChatWebhookTarget,
|
|
5
6
|
chatConnectionNameSchema,
|
|
6
7
|
DEFAULT_CHAT_TIMEOUT_MS,
|
|
@@ -17,9 +18,10 @@ export type ChatWebhookChannelOptions = {
|
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export function toChannelResult(address: string, result: ChatSendResult): ChannelResult {
|
|
20
|
-
return result.
|
|
21
|
+
if (!result.ok) return { status: "failed", error: result.code, address };
|
|
22
|
+
return result.confirmed
|
|
21
23
|
? { status: "sent", address }
|
|
22
|
-
: { status: "
|
|
24
|
+
: { status: "sent", confirmed: false, address };
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function chatMessageText(message: ChannelMessage): string {
|
|
@@ -50,6 +52,8 @@ export type ChatWebhookChannelSpec = {
|
|
|
50
52
|
// Resolves the secret key for a validated connection name.
|
|
51
53
|
readonly keyFor: (connection: string) => string;
|
|
52
54
|
readonly buildBody: (message: ChannelMessage) => unknown;
|
|
55
|
+
// Providers whose 2xx does not by itself mean "delivered" check the answer here.
|
|
56
|
+
readonly classifyResponse?: ChatWebhookRequest["classifyResponse"];
|
|
53
57
|
readonly options: ChatWebhookChannelOptions;
|
|
54
58
|
};
|
|
55
59
|
|
|
@@ -82,6 +86,7 @@ export function createChatWebhookChannel(spec: ChatWebhookChannelSpec): Delivery
|
|
|
82
86
|
...resolveChatWebhookTarget(spec, options),
|
|
83
87
|
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
84
88
|
body: spec.buildBody(message),
|
|
89
|
+
...(spec.classifyResponse && { classifyResponse: spec.classifyResponse }),
|
|
85
90
|
});
|
|
86
91
|
return toChannelResult(address, result);
|
|
87
92
|
},
|