@cosmicdrift/kumiko-bundled-features 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/dist/audit/constants.d.ts +5 -1
- package/dist/audit/constants.js +5 -1
- package/dist/audit/escape-hatch-audit-sink.d.ts +1 -0
- package/dist/audit/escape-hatch-audit-sink.js +1 -0
- package/dist/audit/run-escape-hatch-retention.d.ts +3 -2
- package/dist/audit/run-escape-hatch-retention.js +71 -7
- package/dist/auth-email-password/email-templates.d.ts +4 -0
- package/dist/auth-email-password/email-templates.js +69 -16
- package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +4 -1
- package/dist/auth-email-password/handlers/token-request-handler.js +3 -0
- package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
- package/dist/auth-email-password/magic-link-mail.js +2 -0
- package/dist/channel-discord/feature.js +1 -0
- package/dist/channel-email/feature.js +1 -0
- package/dist/channel-in-app/feature.js +1 -0
- package/dist/channel-push/feature.js +1 -0
- package/dist/channel-slack/feature.js +1 -0
- package/dist/channel-teams/feature.js +1 -0
- package/dist/channel-teams/teams-channel.d.ts +2 -1
- package/dist/channel-teams/teams-channel.js +12 -0
- package/dist/channel-telegram/feature.js +1 -0
- package/dist/delivery/chat-webhook-channel.d.ts +2 -1
- package/dist/delivery/chat-webhook-channel.js +5 -2
- package/dist/delivery/chat-webhook-sender.d.ts +8 -1
- package/dist/delivery/chat-webhook-sender.js +45 -4
- package/dist/delivery/constants.d.ts +1 -1
- package/dist/delivery/constants.js +1 -1
- package/dist/delivery/delivery-service.d.ts +2 -1
- package/dist/delivery/delivery-service.js +55 -4
- package/dist/delivery/events.d.ts +19 -1
- package/dist/delivery/events.js +11 -1
- package/dist/delivery/feature.js +29 -5
- package/dist/delivery/handlers/log.query.d.ts +4 -2
- package/dist/delivery/handlers/log.query.js +46 -18
- package/dist/delivery/i18n.js +36 -0
- package/dist/delivery/index.d.ts +2 -1
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/jobs.js +24 -10
- package/dist/delivery/notify-factory.d.ts +5 -0
- package/dist/delivery/notify-factory.js +6 -0
- package/dist/delivery/public-names.d.ts +4 -0
- package/dist/delivery/public-names.js +4 -0
- package/dist/delivery/tables.js +3 -0
- package/dist/delivery/testing.d.ts +2 -0
- package/dist/delivery/testing.js +2 -1
- package/dist/delivery/types.d.ts +5 -3
- package/dist/delivery/web/client-plugin.js +9 -1
- package/dist/delivery/web/delivery-channel-cell.d.ts +3 -0
- package/dist/delivery/web/delivery-channel-cell.js +10 -0
- package/dist/delivery/web/delivery-error-cell.d.ts +3 -0
- package/dist/delivery/web/delivery-error-cell.js +16 -0
- package/dist/delivery/web/delivery-status-cell.d.ts +1 -1
- package/dist/delivery/web/delivery-status-cell.js +11 -1
- package/dist/delivery/web/delivery-time-cell.d.ts +3 -0
- package/dist/delivery/web/delivery-time-cell.js +6 -0
- package/dist/delivery/web/delivery-type-cell.d.ts +3 -0
- package/dist/delivery/web/delivery-type-cell.js +17 -0
- package/dist/secrets/constants.d.ts +6 -0
- package/dist/secrets/constants.js +8 -0
- package/dist/secrets/feature.d.ts +3 -0
- package/dist/secrets/feature.js +3 -0
- package/dist/secrets/i18n.js +4 -3
- package/dist/secrets/index.d.ts +3 -2
- package/dist/secrets/index.js +3 -2
- package/dist/secrets/write-gate.d.ts +1 -0
- package/dist/secrets/write-gate.js +14 -6
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/redact.d.ts +4 -0
- package/dist/shared/redact.js +21 -0
- package/dist/step-dispatcher/feature.js +2 -1
- package/dist/step-dispatcher/mail-runner.d.ts +0 -1
- package/dist/step-dispatcher/mail-runner.js +0 -5
- package/dist/tenant/web/member-roles-cell.js +1 -1
- package/dist/tenant/web/member-status-cell.js +1 -1
- package/dist/tenant-lifecycle/feature.js +8 -2
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +2 -1
- package/dist/tenant-lifecycle/run-tenant-destroy.js +16 -7
- package/dist/user/index.d.ts +1 -0
- package/dist/user/index.js +1 -0
- package/dist/user/resolve-display-names.d.ts +2 -0
- package/dist/user/resolve-display-names.js +24 -0
- package/package.json +9 -9
- package/src/audit/changes.json +6 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/channel-teams/changes.json +8 -0
- package/src/delivery/changes.json +32 -0
- package/src/jobs/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- /package/dist/{tenant → shared}/web/translate-or-raw.d.ts +0 -0
- /package/dist/{tenant → shared}/web/translate-or-raw.js +0 -0
|
@@ -13,6 +13,10 @@ export declare const AuditQueries: {
|
|
|
13
13
|
export declare const AUDIT_LOG_SCREEN_ID: "audit-log";
|
|
14
14
|
/** Single-event detail screen, breadcrumb-linked to the audit-log list. */
|
|
15
15
|
export declare const AUDIT_LOG_DETAIL_SCREEN_ID: "audit-log-detail";
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Config key (system scope) for how long `escapeHatchUse` audit events are kept. Only a fallback
|
|
18
|
+
* for apps without compliance-profiles; with it mounted, the tenant profile's `auditLog.retention`
|
|
19
|
+
* applies (a tenant override can only lengthen it).
|
|
20
|
+
*/
|
|
17
21
|
export declare const ESCAPE_HATCH_RETENTION_DAYS_KEY: "audit:config:escape-hatch-retention-days";
|
|
18
22
|
export declare const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
|
package/dist/audit/constants.js
CHANGED
|
@@ -18,6 +18,10 @@ export const AuditQueries = {
|
|
|
18
18
|
export const AUDIT_LOG_SCREEN_ID = "audit-log";
|
|
19
19
|
/** Single-event detail screen, breadcrumb-linked to the audit-log list. */
|
|
20
20
|
export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail";
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Config key (system scope) for how long `escapeHatchUse` audit events are kept. Only a fallback
|
|
23
|
+
* for apps without compliance-profiles; with it mounted, the tenant profile's `auditLog.retention`
|
|
24
|
+
* applies (a tenant override can only lengthen it).
|
|
25
|
+
*/
|
|
22
26
|
export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days";
|
|
23
27
|
export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
|
|
@@ -7,6 +7,7 @@ export declare const escapeHatchUsedSchema: z.ZodObject<{
|
|
|
7
7
|
handler: z.ZodString;
|
|
8
8
|
kind: z.ZodEnum<{
|
|
9
9
|
"acknowledge-cross-tenant": "acknowledge-cross-tenant";
|
|
10
|
+
"cross-tenant-read": "cross-tenant-read";
|
|
10
11
|
"global-write": "global-write";
|
|
11
12
|
"identity-switch": "identity-switch";
|
|
12
13
|
"unsafe-all-tenants": "unsafe-all-tenants";
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { type ConfigResolver, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { type PruneEventsResult } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
4
3
|
export declare function isDbConnection(runner: DbRunner): runner is DbConnection;
|
|
5
4
|
export declare function runEscapeHatchRetention(args: {
|
|
6
5
|
readonly db: DbRunner;
|
|
7
6
|
readonly registry: Registry;
|
|
8
7
|
readonly configResolver: ConfigResolver | undefined;
|
|
9
8
|
readonly userId: string;
|
|
10
|
-
}): Promise<
|
|
9
|
+
}): Promise<{
|
|
10
|
+
readonly deletedCount: number;
|
|
11
|
+
}>;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { COMPLIANCE_PROFILES, subtractRetentionSpec, } from "@cosmicdrift/kumiko-framework/compliance";
|
|
3
|
+
import { parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
2
5
|
import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
6
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
7
|
+
import { resolveProfileForTenant } from "../compliance-profiles/index.js";
|
|
3
8
|
import { createConfigAccessor } from "../config/index.js";
|
|
4
9
|
import { DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, ESCAPE_HATCH_RETENTION_DAYS_KEY, } from "./constants.js";
|
|
5
|
-
import { ESCAPE_HATCH_USE_AGGREGATE_TYPE } from "./escape-hatch-audit-sink.js";
|
|
10
|
+
import { ESCAPE_HATCH_USE_AGGREGATE_TYPE, escapeHatchUsedSchema, } from "./escape-hatch-audit-sink.js";
|
|
6
11
|
// pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
|
|
7
12
|
export function isDbConnection(runner) {
|
|
8
13
|
return "begin" in runner && typeof runner.begin === "function";
|
|
@@ -14,14 +19,73 @@ async function resolveRetentionDays(args) {
|
|
|
14
19
|
const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
|
|
15
20
|
return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
|
|
16
21
|
}
|
|
22
|
+
// Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
|
|
23
|
+
const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
|
|
24
|
+
const PRUNE_CHUNK_SIZE = 1000;
|
|
25
|
+
// Overrides may only lengthen retention: the earlier cutoff (same `now`) is the longer period.
|
|
26
|
+
function earlierInstant(a, b) {
|
|
27
|
+
return Temporal.Instant.compare(a, b) <= 0 ? a : b;
|
|
28
|
+
}
|
|
29
|
+
async function resolveTenantCutoff(db, tenantId, now) {
|
|
30
|
+
const { profile } = await resolveProfileForTenant({ db, tenantId });
|
|
31
|
+
const effective = subtractRetentionSpec(now, profile.auditLog.retention);
|
|
32
|
+
const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
|
|
33
|
+
return earlierInstant(effective, base);
|
|
34
|
+
}
|
|
35
|
+
// A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
|
|
36
|
+
// satisfy the target tenant's retention. A malformed payload falls back to the storing tenant.
|
|
37
|
+
function targetTenantOf(payload) {
|
|
38
|
+
const parsed = escapeHatchUsedSchema.safeParse(payload);
|
|
39
|
+
return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
|
|
40
|
+
}
|
|
41
|
+
async function pruneByTenantProfiles(db) {
|
|
42
|
+
const rows = await selectMany(db, eventsTable, {
|
|
43
|
+
aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
|
|
44
|
+
});
|
|
45
|
+
const now = getTemporal().Now.instant();
|
|
46
|
+
const cutoffByTenant = new Map();
|
|
47
|
+
const cutoffOf = async (tenantId) => {
|
|
48
|
+
const cached = cutoffByTenant.get(tenantId);
|
|
49
|
+
if (cached)
|
|
50
|
+
return cached;
|
|
51
|
+
const cutoff = await resolveTenantCutoff(db, tenantId, now);
|
|
52
|
+
cutoffByTenant.set(tenantId, cutoff);
|
|
53
|
+
return cutoff;
|
|
54
|
+
};
|
|
55
|
+
const expiredAggregateIds = [];
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
const targetTenantId = targetTenantOf(row.payload);
|
|
58
|
+
const storeCutoff = await cutoffOf(row.tenantId);
|
|
59
|
+
const cutoff = targetTenantId
|
|
60
|
+
? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
|
|
61
|
+
: storeCutoff;
|
|
62
|
+
if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
|
|
63
|
+
expiredAggregateIds.push(row.aggregateId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let deletedCount = 0;
|
|
67
|
+
for (let i = 0; i < expiredAggregateIds.length; i += PRUNE_CHUNK_SIZE) {
|
|
68
|
+
const result = await pruneEvents(db, {
|
|
69
|
+
aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
|
|
70
|
+
aggregateIds: expiredAggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
|
|
71
|
+
olderThan: now,
|
|
72
|
+
});
|
|
73
|
+
deletedCount += result.deletedCount;
|
|
74
|
+
}
|
|
75
|
+
return deletedCount;
|
|
76
|
+
}
|
|
17
77
|
export async function runEscapeHatchRetention(args) {
|
|
18
78
|
const { db } = args;
|
|
19
79
|
if (!isDbConnection(db)) {
|
|
20
80
|
throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
|
|
21
81
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
82
|
+
if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
|
|
83
|
+
const olderThanDays = await resolveRetentionDays({ ...args, db });
|
|
84
|
+
const { deletedCount } = await pruneEvents(db, {
|
|
85
|
+
aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
|
|
86
|
+
olderThanDays,
|
|
87
|
+
});
|
|
88
|
+
return { deletedCount };
|
|
89
|
+
}
|
|
90
|
+
return { deletedCount: await pruneByTenantProfiles(db) };
|
|
27
91
|
}
|
|
@@ -6,6 +6,10 @@ export type RenderTokenContentArgs = {
|
|
|
6
6
|
readonly locale?: AuthMailLocale;
|
|
7
7
|
/** Optional app name for subject + intro. Default from auth.mail.appNameDefault. */
|
|
8
8
|
readonly appName?: string;
|
|
9
|
+
/** ISO instant the token was issued; the validity duration is measured from it. Default: now. */
|
|
10
|
+
readonly issuedAt?: string;
|
|
11
|
+
/** IANA zone the expiry time is shown in. Default "UTC"; an invalid zone falls back to "UTC". */
|
|
12
|
+
readonly timeZone?: string;
|
|
9
13
|
};
|
|
10
14
|
export type RenderInviteEmailArgs = RenderTokenContentArgs & {
|
|
11
15
|
readonly role: string;
|
|
@@ -15,31 +15,31 @@ export const AUTH_MAIL_EN = {
|
|
|
15
15
|
"auth.mail.reset.greeting": "Hi,",
|
|
16
16
|
"auth.mail.reset.intro": "you requested a password reset for {app}. Click the link below to set a new password:",
|
|
17
17
|
"auth.mail.reset.button": "Reset password",
|
|
18
|
-
"auth.mail.reset.expiry": "The link
|
|
18
|
+
"auth.mail.reset.expiry": "The link is valid for {duration} (until {when}).",
|
|
19
19
|
"auth.mail.reset.ignore": "If you didn't request a reset, you can safely ignore this email — your password won't change.",
|
|
20
20
|
"auth.mail.verify.subject": "{app} — Verify your email",
|
|
21
21
|
"auth.mail.verify.greeting": "Welcome,",
|
|
22
22
|
"auth.mail.verify.intro": "please verify your email address for {app} to activate your account:",
|
|
23
23
|
"auth.mail.verify.button": "Verify email",
|
|
24
|
-
"auth.mail.verify.expiry": "The link
|
|
24
|
+
"auth.mail.verify.expiry": "The link is valid for {duration} (until {when}).",
|
|
25
25
|
"auth.mail.verify.ignore": "If you didn't create this account, you can ignore this email.",
|
|
26
26
|
"auth.mail.activation.subject": "{app} — Activate your account",
|
|
27
27
|
"auth.mail.activation.greeting": "Welcome,",
|
|
28
28
|
"auth.mail.activation.intro": "click the link below to activate your {app} account. The next step is choosing your password:",
|
|
29
29
|
"auth.mail.activation.button": "Activate account",
|
|
30
|
-
"auth.mail.activation.expiry": "The link
|
|
30
|
+
"auth.mail.activation.expiry": "The link is valid for {duration} (until {when}).",
|
|
31
31
|
"auth.mail.activation.ignore": "If you didn't sign up, you can ignore this email — no account is created until you open the link.",
|
|
32
32
|
"auth.mail.invite.subject": "{app} — Workspace invitation",
|
|
33
33
|
"auth.mail.invite.greeting": "Hi,",
|
|
34
34
|
"auth.mail.invite.intro": "you've been invited to a {app} workspace as {role}. Click the link below to accept:",
|
|
35
35
|
"auth.mail.invite.button": "Accept invitation",
|
|
36
|
-
"auth.mail.invite.expiry": "The link
|
|
36
|
+
"auth.mail.invite.expiry": "The link is valid for {duration} (until {when}).",
|
|
37
37
|
"auth.mail.invite.ignore": "If you weren't expecting this invitation, you can ignore this email.",
|
|
38
38
|
"auth.mail.unlock.subject": "{app} — Unlock your account",
|
|
39
39
|
"auth.mail.unlock.greeting": "Hi,",
|
|
40
40
|
"auth.mail.unlock.intro": "your {app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:",
|
|
41
41
|
"auth.mail.unlock.button": "Unlock account",
|
|
42
|
-
"auth.mail.unlock.expiry": "The link
|
|
42
|
+
"auth.mail.unlock.expiry": "The link is valid for {duration} (until {when}).",
|
|
43
43
|
"auth.mail.unlock.ignore": "If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
|
|
44
44
|
};
|
|
45
45
|
registerMailTranslations("en", AUTH_MAIL_EN);
|
|
@@ -73,7 +73,7 @@ export function renderResetPasswordEmail(args) {
|
|
|
73
73
|
intro: t(locale, "auth.mail.reset.intro", { app }),
|
|
74
74
|
buttonLabel: t(locale, "auth.mail.reset.button"),
|
|
75
75
|
buttonUrl: args.url,
|
|
76
|
-
expiry: t(locale, "auth.mail.reset.expiry",
|
|
76
|
+
expiry: t(locale, "auth.mail.reset.expiry", expiryParams(args, locale)),
|
|
77
77
|
ignore: t(locale, "auth.mail.reset.ignore"),
|
|
78
78
|
});
|
|
79
79
|
}
|
|
@@ -87,7 +87,7 @@ export function renderUnlockAccountEmail(args) {
|
|
|
87
87
|
intro: t(locale, "auth.mail.unlock.intro", { app }),
|
|
88
88
|
buttonLabel: t(locale, "auth.mail.unlock.button"),
|
|
89
89
|
buttonUrl: args.url,
|
|
90
|
-
expiry: t(locale, "auth.mail.unlock.expiry",
|
|
90
|
+
expiry: t(locale, "auth.mail.unlock.expiry", expiryParams(args, locale)),
|
|
91
91
|
ignore: t(locale, "auth.mail.unlock.ignore"),
|
|
92
92
|
});
|
|
93
93
|
}
|
|
@@ -101,7 +101,7 @@ export function renderVerifyEmail(args) {
|
|
|
101
101
|
intro: t(locale, "auth.mail.verify.intro", { app }),
|
|
102
102
|
buttonLabel: t(locale, "auth.mail.verify.button"),
|
|
103
103
|
buttonUrl: args.url,
|
|
104
|
-
expiry: t(locale, "auth.mail.verify.expiry",
|
|
104
|
+
expiry: t(locale, "auth.mail.verify.expiry", expiryParams(args, locale)),
|
|
105
105
|
ignore: t(locale, "auth.mail.verify.ignore"),
|
|
106
106
|
});
|
|
107
107
|
}
|
|
@@ -115,7 +115,7 @@ export function renderActivationEmail(args) {
|
|
|
115
115
|
intro: t(locale, "auth.mail.activation.intro", { app }),
|
|
116
116
|
buttonLabel: t(locale, "auth.mail.activation.button"),
|
|
117
117
|
buttonUrl: args.url,
|
|
118
|
-
expiry: t(locale, "auth.mail.activation.expiry",
|
|
118
|
+
expiry: t(locale, "auth.mail.activation.expiry", expiryParams(args, locale)),
|
|
119
119
|
ignore: t(locale, "auth.mail.activation.ignore"),
|
|
120
120
|
});
|
|
121
121
|
}
|
|
@@ -129,19 +129,72 @@ export function renderInviteEmail(args) {
|
|
|
129
129
|
intro: t(locale, "auth.mail.invite.intro", { app, role: args.role }),
|
|
130
130
|
buttonLabel: t(locale, "auth.mail.invite.button"),
|
|
131
131
|
buttonUrl: args.url,
|
|
132
|
-
expiry: t(locale, "auth.mail.invite.expiry",
|
|
132
|
+
expiry: t(locale, "auth.mail.invite.expiry", expiryParams(args, locale)),
|
|
133
133
|
ignore: t(locale, "auth.mail.invite.ignore"),
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
|
-
|
|
136
|
+
const MS_PER_MINUTE = 60_000;
|
|
137
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
138
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
139
|
+
// Rounds to whole minutes first so a few ms between issuing and signing still
|
|
140
|
+
// read as "1 day"; a larger unit is only used when it divides the span exactly.
|
|
141
|
+
function durationParts(ms) {
|
|
142
|
+
const minutes = Math.max(1, Math.round(ms / MS_PER_MINUTE));
|
|
143
|
+
const minutesPerHour = MS_PER_HOUR / MS_PER_MINUTE;
|
|
144
|
+
const minutesPerDay = MS_PER_DAY / MS_PER_MINUTE;
|
|
145
|
+
if (minutes % minutesPerDay === 0)
|
|
146
|
+
return { amount: minutes / minutesPerDay, unit: "day" };
|
|
147
|
+
if (minutes % minutesPerHour === 0)
|
|
148
|
+
return { amount: minutes / minutesPerHour, unit: "hour" };
|
|
149
|
+
return { amount: minutes, unit: "minute" };
|
|
150
|
+
}
|
|
151
|
+
function formatDuration(ms, locale) {
|
|
152
|
+
const { amount, unit } = durationParts(ms);
|
|
153
|
+
const options = { style: "unit", unit, unitDisplay: "long" };
|
|
137
154
|
try {
|
|
138
|
-
|
|
139
|
-
return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
|
|
155
|
+
return new Intl.NumberFormat(locale, options).format(amount);
|
|
140
156
|
}
|
|
141
157
|
catch {
|
|
142
|
-
return
|
|
158
|
+
return new Intl.NumberFormat("en", options).format(amount);
|
|
143
159
|
}
|
|
144
160
|
}
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
// Intl rejects timeZoneName together with dateStyle/timeStyle, so the zone
|
|
162
|
+
// abbreviation is read from a second formatter and appended.
|
|
163
|
+
function formatWhen(expiresAt, locale, timeZone) {
|
|
164
|
+
const build = (tag, zone) => {
|
|
165
|
+
const at = expiresAt.epochMilliseconds;
|
|
166
|
+
const dateTime = new Intl.DateTimeFormat(tag, {
|
|
167
|
+
dateStyle: "medium",
|
|
168
|
+
timeStyle: "short",
|
|
169
|
+
timeZone: zone,
|
|
170
|
+
}).format(at);
|
|
171
|
+
const zoneName = new Intl.DateTimeFormat(tag, { timeZone: zone, timeZoneName: "short" })
|
|
172
|
+
.formatToParts(at)
|
|
173
|
+
.find((part) => part.type === "timeZoneName")?.value;
|
|
174
|
+
return zoneName ? `${dateTime} ${zoneName}` : dateTime;
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
return build(locale, timeZone);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
try {
|
|
181
|
+
return build(locale, "UTC");
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return build("en", "UTC");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function expiryParams(args, locale) {
|
|
189
|
+
try {
|
|
190
|
+
const expiresAt = Temporal.Instant.from(args.expiresAt);
|
|
191
|
+
const issuedAt = args.issuedAt ? Temporal.Instant.from(args.issuedAt) : Temporal.Now.instant();
|
|
192
|
+
return {
|
|
193
|
+
duration: formatDuration(expiresAt.epochMilliseconds - issuedAt.epochMilliseconds, locale),
|
|
194
|
+
when: formatWhen(expiresAt, locale, args.timeZone ?? "UTC"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { duration: args.expiresAt, when: args.expiresAt };
|
|
199
|
+
}
|
|
147
200
|
}
|
|
@@ -67,7 +67,8 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
|
|
|
67
67
|
}
|
|
68
68
|
const email = request.email.toLowerCase();
|
|
69
69
|
const tenantId = inviter.tenantId;
|
|
70
|
-
const
|
|
70
|
+
const issuedAt = Temporal.Now.instant();
|
|
71
|
+
const expiresAt = issuedAt.add({ seconds: ttlSeconds });
|
|
71
72
|
// The unique index allows one row per (tenantId, email). Whatever its
|
|
72
73
|
// status, a re-invite resets it to pending with a fresh token.
|
|
73
74
|
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
@@ -126,6 +127,8 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
|
|
|
126
127
|
appUrl: opts.appUrl,
|
|
127
128
|
token,
|
|
128
129
|
expiresAt: expiresAt.toString(),
|
|
130
|
+
issuedAt: issuedAt.toString(),
|
|
131
|
+
timeZone: ctx.tz.user,
|
|
129
132
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
130
133
|
locale,
|
|
131
134
|
});
|
|
@@ -104,7 +104,8 @@ export function createSignupRequestHandler(opts) {
|
|
|
104
104
|
// uses randomBytes from node:crypto, the same source as CSRF/
|
|
105
105
|
// session tokens.
|
|
106
106
|
const token = generateToken();
|
|
107
|
-
const
|
|
107
|
+
const issuedAt = Temporal.Now.instant();
|
|
108
|
+
const expiresAt = issuedAt.add({ seconds: ttlSeconds });
|
|
108
109
|
const expiresAtIso = expiresAt.toString();
|
|
109
110
|
await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
|
|
110
111
|
// A handover grant in this request re-verifies and replaces the prior
|
|
@@ -158,6 +159,8 @@ export function createSignupRequestHandler(opts) {
|
|
|
158
159
|
appUrl: opts.appUrl,
|
|
159
160
|
token,
|
|
160
161
|
expiresAt: expiresAtIso,
|
|
162
|
+
issuedAt: issuedAt.toString(),
|
|
163
|
+
timeZone: ctx.tz.user,
|
|
161
164
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
162
165
|
locale,
|
|
163
166
|
});
|
|
@@ -55,6 +55,7 @@ export function createTokenRequestHandler(spec, opts) {
|
|
|
55
55
|
return { isSuccess: true, data };
|
|
56
56
|
}
|
|
57
57
|
const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
|
|
58
|
+
const issuedAt = expiresAt.subtract({ minutes: ttl }).toString();
|
|
58
59
|
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
59
60
|
await dispatchMagicLinkMail(ctx.notify, {
|
|
60
61
|
handlerName: spec.handlerName,
|
|
@@ -67,6 +68,8 @@ export function createTokenRequestHandler(spec, opts) {
|
|
|
67
68
|
appUrl: opts.appUrl,
|
|
68
69
|
token,
|
|
69
70
|
expiresAt: expiresAt.toString(),
|
|
71
|
+
issuedAt,
|
|
72
|
+
timeZone: ctx.tz.user,
|
|
70
73
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
71
74
|
locale,
|
|
72
75
|
});
|
|
@@ -12,6 +12,8 @@ export type MagicLinkMailParams = {
|
|
|
12
12
|
readonly expiresAt: string;
|
|
13
13
|
readonly appName?: string;
|
|
14
14
|
readonly locale?: AuthMailLocale;
|
|
15
|
+
readonly issuedAt?: string;
|
|
16
|
+
readonly timeZone?: string;
|
|
15
17
|
};
|
|
16
18
|
export declare function resolveHandlerMailLocale(ctx: {
|
|
17
19
|
readonly locale?: string;
|
|
@@ -40,6 +40,8 @@ export async function dispatchMagicLinkMail(notify, spec, params) {
|
|
|
40
40
|
expiresAt: params.expiresAt,
|
|
41
41
|
...(params.locale !== undefined && { locale: params.locale }),
|
|
42
42
|
...(params.appName !== undefined && { appName: params.appName }),
|
|
43
|
+
...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
|
|
44
|
+
...(params.timeZone !== undefined && { timeZone: params.timeZone }),
|
|
43
45
|
});
|
|
44
46
|
await notify(spec.notificationType, {
|
|
45
47
|
route: { email: params.email },
|
|
@@ -27,6 +27,7 @@ export function createChannelDiscordFeature(options = {}) {
|
|
|
27
27
|
valueSchema: chatWebhookUrlSchema(webhookTarget),
|
|
28
28
|
});
|
|
29
29
|
const channel = createDiscordChannel(options, webhooks.keyFor);
|
|
30
|
+
r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
|
|
30
31
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
|
|
31
32
|
mode: channel.mode,
|
|
32
33
|
send: channel.send,
|
|
@@ -11,6 +11,7 @@ export function createChannelEmailFeature(options) {
|
|
|
11
11
|
recommended: false,
|
|
12
12
|
});
|
|
13
13
|
r.requires("delivery");
|
|
14
|
+
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
14
15
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
|
|
15
16
|
mode: channel.mode,
|
|
16
17
|
resolve: channel.resolve,
|
|
@@ -19,6 +19,7 @@ export function createChannelInAppFeature() {
|
|
|
19
19
|
reason: "read_side.in_app_messages",
|
|
20
20
|
});
|
|
21
21
|
// Register as delivery channel via extension system
|
|
22
|
+
r.translations({ keys: { "delivery.channel.inApp": { en: "In-app" } } });
|
|
22
23
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
|
|
23
24
|
mode: inAppChannel.mode,
|
|
24
25
|
resolve: inAppChannel.resolve,
|
|
@@ -11,6 +11,7 @@ export function createChannelPushFeature(options) {
|
|
|
11
11
|
recommended: false,
|
|
12
12
|
});
|
|
13
13
|
r.requires("delivery");
|
|
14
|
+
r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
|
|
14
15
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
15
16
|
mode: channel.mode,
|
|
16
17
|
resolve: channel.resolve,
|
|
@@ -24,6 +24,7 @@ export function createChannelSlackFeature(options = {}) {
|
|
|
24
24
|
valueSchema: chatWebhookUrlSchema(webhookTarget),
|
|
25
25
|
});
|
|
26
26
|
const channel = createSlackChannel(options, webhooks.keyFor);
|
|
27
|
+
r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
|
|
27
28
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
|
|
28
29
|
mode: channel.mode,
|
|
29
30
|
send: channel.send,
|
|
@@ -28,6 +28,7 @@ export function createChannelTeamsFeature(options = {}) {
|
|
|
28
28
|
valueSchema: chatWebhookUrlSchema(webhookTarget),
|
|
29
29
|
});
|
|
30
30
|
const channel = createTeamsChannel(options, webhooks.keyFor);
|
|
31
|
+
r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
|
|
31
32
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
|
|
32
33
|
mode: channel.mode,
|
|
33
34
|
send: channel.send,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
|
|
1
|
+
import { type ChatSendResult, type ChatWebhookChannelOptions, type ChatWebhookResponse, type DeliveryChannel } from "../delivery/index.js";
|
|
2
2
|
export type TeamsChannelOptions = ChatWebhookChannelOptions;
|
|
3
3
|
export declare const TEAMS_SECRET_KEYS: {
|
|
4
4
|
readonly webhookPrefix: "channel-teams:webhooks.";
|
|
@@ -6,4 +6,5 @@ export declare const TEAMS_SECRET_KEYS: {
|
|
|
6
6
|
};
|
|
7
7
|
export declare const TEAMS_DEFAULT_ALLOWED_HOSTS: readonly [".webhook.office.com", ".logic.azure.com", ".powerplatform.com"];
|
|
8
8
|
export declare function buildTeamsAdaptiveCard(title: string, body: string | undefined): unknown;
|
|
9
|
+
export declare function classifyTeamsResponse(response: ChatWebhookResponse): Promise<ChatSendResult>;
|
|
9
10
|
export declare function createTeamsChannel(options: TeamsChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
|
|
@@ -30,12 +30,24 @@ export function buildTeamsAdaptiveCard(title, body) {
|
|
|
30
30
|
],
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
// Classic Office connectors answer 200 with the body "1". Workflow endpoints answer
|
|
34
|
+
// 202 and never say whether the card reached the channel. Any other 2xx (an empty
|
|
35
|
+
// 200 is what a made-up URL returns) is not a Teams success.
|
|
36
|
+
export async function classifyTeamsResponse(response) {
|
|
37
|
+
if (response.status === 202)
|
|
38
|
+
return { ok: true, confirmed: false };
|
|
39
|
+
if (response.status === 200 && (await response.readBodyPrefix()).trim() === "1") {
|
|
40
|
+
return { ok: true, confirmed: true };
|
|
41
|
+
}
|
|
42
|
+
return { ok: false, code: "unexpected_response" };
|
|
43
|
+
}
|
|
33
44
|
export function createTeamsChannel(options, keyFor) {
|
|
34
45
|
return createChatWebhookChannel({
|
|
35
46
|
name: "teams",
|
|
36
47
|
featureName: "channel-teams",
|
|
37
48
|
defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
|
|
38
49
|
keyFor,
|
|
50
|
+
classifyResponse: classifyTeamsResponse,
|
|
39
51
|
buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
|
|
40
52
|
options,
|
|
41
53
|
});
|
|
@@ -22,6 +22,7 @@ export function createChannelTelegramFeature(options = {}) {
|
|
|
22
22
|
valueSchema: z.string().refine(isTelegramBotToken, "invalid bot token"),
|
|
23
23
|
});
|
|
24
24
|
const channel = createTelegramChannel(options, botToken.name);
|
|
25
|
+
r.translations({ keys: { "delivery.channel.telegram": { en: "Telegram" } } });
|
|
25
26
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
|
|
26
27
|
mode: channel.mode,
|
|
27
28
|
send: channel.send,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ChatSendResult, type ChatWebhookTarget } from "./chat-webhook-sender.js";
|
|
1
|
+
import { type ChatSendResult, type ChatWebhookRequest, type ChatWebhookTarget } from "./chat-webhook-sender.js";
|
|
2
2
|
import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
|
|
3
3
|
export type ChatWebhookChannelOptions = {
|
|
4
4
|
readonly allowedHosts?: readonly string[];
|
|
@@ -18,6 +18,7 @@ export type ChatWebhookChannelSpec = {
|
|
|
18
18
|
readonly requiredPathPrefix?: string;
|
|
19
19
|
readonly keyFor: (connection: string) => string;
|
|
20
20
|
readonly buildBody: (message: ChannelMessage) => unknown;
|
|
21
|
+
readonly classifyResponse?: ChatWebhookRequest["classifyResponse"];
|
|
21
22
|
readonly options: ChatWebhookChannelOptions;
|
|
22
23
|
};
|
|
23
24
|
export declare function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
2
|
import { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, } from "./chat-webhook-sender.js";
|
|
3
3
|
export function toChannelResult(address, result) {
|
|
4
|
-
|
|
4
|
+
if (!result.ok)
|
|
5
|
+
return { status: "failed", error: result.code, address };
|
|
6
|
+
return result.confirmed
|
|
5
7
|
? { status: "sent", address }
|
|
6
|
-
: { status: "
|
|
8
|
+
: { status: "sent", confirmed: false, address };
|
|
7
9
|
}
|
|
8
10
|
export function chatMessageText(message) {
|
|
9
11
|
return message.body ? `${message.title}\n${message.body}` : message.title;
|
|
@@ -46,6 +48,7 @@ export function createChatWebhookChannel(spec) {
|
|
|
46
48
|
...resolveChatWebhookTarget(spec, options),
|
|
47
49
|
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
48
50
|
body: spec.buildBody(message),
|
|
51
|
+
...(spec.classifyResponse && { classifyResponse: spec.classifyResponse }),
|
|
49
52
|
});
|
|
50
53
|
return toChannelResult(address, result);
|
|
51
54
|
},
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import type { ChatSendFailureCode } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
2
|
import * as z from "zod";
|
|
2
|
-
export type ChatSendFailureCode
|
|
3
|
+
export type { ChatSendFailureCode };
|
|
3
4
|
export type ChatSendResult = {
|
|
4
5
|
readonly ok: true;
|
|
6
|
+
readonly confirmed: boolean;
|
|
5
7
|
} | {
|
|
6
8
|
readonly ok: false;
|
|
7
9
|
readonly code: ChatSendFailureCode;
|
|
8
10
|
};
|
|
11
|
+
export type ChatWebhookResponse = {
|
|
12
|
+
readonly status: number;
|
|
13
|
+
readBodyPrefix(): Promise<string>;
|
|
14
|
+
};
|
|
9
15
|
export declare const DEFAULT_CHAT_TIMEOUT_MS = 10000;
|
|
10
16
|
export declare const chatConnectionNameSchema: z.ZodString;
|
|
11
17
|
export type ChatWebhookTarget = {
|
|
@@ -18,6 +24,7 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
|
|
|
18
24
|
readonly timeoutMs: number;
|
|
19
25
|
readonly body: unknown;
|
|
20
26
|
readonly send?: (url: string, init: RequestInit) => Promise<Response>;
|
|
27
|
+
readonly classifyResponse?: (response: ChatWebhookResponse) => Promise<ChatSendResult>;
|
|
21
28
|
};
|
|
22
29
|
export declare function checkChatWebhookTarget(target: ChatWebhookTarget, url: string): URL | undefined;
|
|
23
30
|
export declare function chatWebhookUrlSchema(target: ChatWebhookTarget): z.ZodType<string>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BlockedHostError, egress } from "@cosmicdrift/kumiko-framework/http";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
const MAX_BODY_PREFIX_BYTES = 64;
|
|
3
4
|
export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
|
|
4
5
|
// Tenant-chosen connection name (the route address for webhook channels). Also
|
|
5
6
|
// the suffix the secrets write gate accepts under the channel's namespace, so
|
|
@@ -92,15 +93,55 @@ export async function postChatWebhook(request) {
|
|
|
92
93
|
catch (err) {
|
|
93
94
|
return { ok: false, code: classifyFailure(err) };
|
|
94
95
|
}
|
|
95
|
-
// Free the connection; the body is never read or stored.
|
|
96
|
-
await response.body?.cancel().catch(() => undefined);
|
|
97
96
|
const { status } = response;
|
|
98
|
-
if (status >= 300 && status < 400)
|
|
97
|
+
if (status >= 300 && status < 400) {
|
|
98
|
+
await response.body?.cancel().catch(() => undefined);
|
|
99
99
|
return { ok: false, code: "redirect_blocked" };
|
|
100
|
+
}
|
|
100
101
|
if (status >= 200 && status < 300)
|
|
101
|
-
return
|
|
102
|
+
return classifySuccess(request, response);
|
|
103
|
+
await response.body?.cancel().catch(() => undefined);
|
|
102
104
|
return { ok: false, code: `http_${status}` };
|
|
103
105
|
}
|
|
106
|
+
// The body is cancelled afterwards in every case, even when the classifier throws,
|
|
107
|
+
// so the connection is freed and a large body is never read to the end.
|
|
108
|
+
async function classifySuccess(request, response) {
|
|
109
|
+
let reader;
|
|
110
|
+
try {
|
|
111
|
+
if (!request.classifyResponse)
|
|
112
|
+
return { ok: true, confirmed: true };
|
|
113
|
+
return await request.classifyResponse({
|
|
114
|
+
status: response.status,
|
|
115
|
+
async readBodyPrefix() {
|
|
116
|
+
reader ??= response.body?.getReader();
|
|
117
|
+
if (!reader)
|
|
118
|
+
return "";
|
|
119
|
+
const chunks = [];
|
|
120
|
+
let total = 0;
|
|
121
|
+
while (total < MAX_BODY_PREFIX_BYTES) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done)
|
|
124
|
+
break;
|
|
125
|
+
chunks.push(value);
|
|
126
|
+
total += value.length;
|
|
127
|
+
}
|
|
128
|
+
const joined = new Uint8Array(total);
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const chunk of chunks) {
|
|
131
|
+
joined.set(chunk, offset);
|
|
132
|
+
offset += chunk.length;
|
|
133
|
+
}
|
|
134
|
+
return new TextDecoder().decode(joined.subarray(0, MAX_BODY_PREFIX_BYTES));
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return { ok: false, code: classifyFailure(err) };
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await (reader ? reader.cancel() : response.body?.cancel())?.catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
104
145
|
export function truncateChars(text, maxChars) {
|
|
105
146
|
const chars = Array.from(text);
|
|
106
147
|
return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
export declare const DELIVERY_CHANNEL_EXTENSION: "deliveryChannel";
|
|
3
|
-
export { DELIVERY_ATTEMPT_EVENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, type DeliveryStatusValue, } from "./public-names.js";
|
|
3
|
+
export { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, type DeliveryStatusValue, } from "./public-names.js";
|
|
4
4
|
export declare const deliveryPriorityRank: Record<NotifyPriority, number>;
|
|
5
5
|
export declare const DeliveryJobs: {
|
|
6
6
|
readonly render: string;
|