@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.
Files changed (92) hide show
  1. package/dist/audit/constants.d.ts +5 -1
  2. package/dist/audit/constants.js +5 -1
  3. package/dist/audit/escape-hatch-audit-sink.d.ts +1 -0
  4. package/dist/audit/escape-hatch-audit-sink.js +1 -0
  5. package/dist/audit/run-escape-hatch-retention.d.ts +3 -2
  6. package/dist/audit/run-escape-hatch-retention.js +71 -7
  7. package/dist/auth-email-password/email-templates.d.ts +4 -0
  8. package/dist/auth-email-password/email-templates.js +69 -16
  9. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  10. package/dist/auth-email-password/handlers/signup-request.write.js +4 -1
  11. package/dist/auth-email-password/handlers/token-request-handler.js +3 -0
  12. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  13. package/dist/auth-email-password/magic-link-mail.js +2 -0
  14. package/dist/channel-discord/feature.js +1 -0
  15. package/dist/channel-email/feature.js +1 -0
  16. package/dist/channel-in-app/feature.js +1 -0
  17. package/dist/channel-push/feature.js +1 -0
  18. package/dist/channel-slack/feature.js +1 -0
  19. package/dist/channel-teams/feature.js +1 -0
  20. package/dist/channel-teams/teams-channel.d.ts +2 -1
  21. package/dist/channel-teams/teams-channel.js +12 -0
  22. package/dist/channel-telegram/feature.js +1 -0
  23. package/dist/delivery/chat-webhook-channel.d.ts +2 -1
  24. package/dist/delivery/chat-webhook-channel.js +5 -2
  25. package/dist/delivery/chat-webhook-sender.d.ts +8 -1
  26. package/dist/delivery/chat-webhook-sender.js +45 -4
  27. package/dist/delivery/constants.d.ts +1 -1
  28. package/dist/delivery/constants.js +1 -1
  29. package/dist/delivery/delivery-service.d.ts +2 -1
  30. package/dist/delivery/delivery-service.js +55 -4
  31. package/dist/delivery/events.d.ts +19 -1
  32. package/dist/delivery/events.js +11 -1
  33. package/dist/delivery/feature.js +29 -5
  34. package/dist/delivery/handlers/log.query.d.ts +4 -2
  35. package/dist/delivery/handlers/log.query.js +46 -18
  36. package/dist/delivery/i18n.js +36 -0
  37. package/dist/delivery/index.d.ts +2 -1
  38. package/dist/delivery/index.js +1 -0
  39. package/dist/delivery/jobs.js +24 -10
  40. package/dist/delivery/notify-factory.d.ts +5 -0
  41. package/dist/delivery/notify-factory.js +6 -0
  42. package/dist/delivery/public-names.d.ts +4 -0
  43. package/dist/delivery/public-names.js +4 -0
  44. package/dist/delivery/tables.js +3 -0
  45. package/dist/delivery/testing.d.ts +2 -0
  46. package/dist/delivery/testing.js +2 -1
  47. package/dist/delivery/types.d.ts +5 -3
  48. package/dist/delivery/web/client-plugin.js +9 -1
  49. package/dist/delivery/web/delivery-channel-cell.d.ts +3 -0
  50. package/dist/delivery/web/delivery-channel-cell.js +10 -0
  51. package/dist/delivery/web/delivery-error-cell.d.ts +3 -0
  52. package/dist/delivery/web/delivery-error-cell.js +16 -0
  53. package/dist/delivery/web/delivery-status-cell.d.ts +1 -1
  54. package/dist/delivery/web/delivery-status-cell.js +11 -1
  55. package/dist/delivery/web/delivery-time-cell.d.ts +3 -0
  56. package/dist/delivery/web/delivery-time-cell.js +6 -0
  57. package/dist/delivery/web/delivery-type-cell.d.ts +3 -0
  58. package/dist/delivery/web/delivery-type-cell.js +17 -0
  59. package/dist/secrets/constants.d.ts +6 -0
  60. package/dist/secrets/constants.js +8 -0
  61. package/dist/secrets/feature.d.ts +3 -0
  62. package/dist/secrets/feature.js +3 -0
  63. package/dist/secrets/i18n.js +4 -3
  64. package/dist/secrets/index.d.ts +3 -2
  65. package/dist/secrets/index.js +3 -2
  66. package/dist/secrets/write-gate.d.ts +1 -0
  67. package/dist/secrets/write-gate.js +14 -6
  68. package/dist/shared/index.d.ts +1 -0
  69. package/dist/shared/index.js +1 -0
  70. package/dist/shared/redact.d.ts +4 -0
  71. package/dist/shared/redact.js +21 -0
  72. package/dist/step-dispatcher/feature.js +2 -1
  73. package/dist/step-dispatcher/mail-runner.d.ts +0 -1
  74. package/dist/step-dispatcher/mail-runner.js +0 -5
  75. package/dist/tenant/web/member-roles-cell.js +1 -1
  76. package/dist/tenant/web/member-status-cell.js +1 -1
  77. package/dist/tenant-lifecycle/feature.js +8 -2
  78. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +2 -1
  79. package/dist/tenant-lifecycle/run-tenant-destroy.js +16 -7
  80. package/dist/user/index.d.ts +1 -0
  81. package/dist/user/index.js +1 -0
  82. package/dist/user/resolve-display-names.d.ts +2 -0
  83. package/dist/user/resolve-display-names.js +24 -0
  84. package/package.json +9 -9
  85. package/src/audit/changes.json +6 -0
  86. package/src/auth-email-password/changes.json +7 -0
  87. package/src/channel-teams/changes.json +8 -0
  88. package/src/delivery/changes.json +32 -0
  89. package/src/jobs/changes.json +6 -0
  90. package/src/secrets/changes.json +6 -0
  91. /package/dist/{tenant → shared}/web/translate-or-raw.d.ts +0 -0
  92. /package/dist/{tenant → shared}/web/translate-or-raw.js +0 -0
@@ -1,6 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import type { NotifyDeliveryStatus, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { DeliveryErrorCode, NotifyDeliveryStatus, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
5
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
6
6
  export type ChannelContext = {
@@ -19,7 +19,8 @@ export type ChannelMessage = {
19
19
  };
20
20
  export type ChannelResult = {
21
21
  readonly status: "sent" | "failed" | "skipped";
22
- readonly error?: string;
22
+ readonly error?: DeliveryErrorCode;
23
+ readonly confirmed?: false;
23
24
  readonly address?: string;
24
25
  };
25
26
  export type RenderedMessage = {
@@ -51,8 +52,9 @@ export type DeliveryLogEntry = {
51
52
  readonly recipientId: string | null;
52
53
  readonly recipientAddress: string | null;
53
54
  readonly status: NotifyDeliveryStatus;
54
- readonly error: string | null;
55
+ readonly error: DeliveryErrorCode | null;
55
56
  readonly priority: NotifyPriority;
57
+ readonly confirmed?: false;
56
58
  };
57
59
  export type DeliveryService = {
58
60
  notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId, jobDispatcher?: NotifyJobDispatcher): Promise<NotifyResult>;
@@ -1,10 +1,18 @@
1
- import { DELIVERY_FEATURE, DELIVERY_STATUS_CELL_COMPONENT } from "../public-names.js";
1
+ import { DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, } from "../public-names.js";
2
+ import { DeliveryChannelCell } from "./delivery-channel-cell.js";
3
+ import { DeliveryErrorCell } from "./delivery-error-cell.js";
2
4
  import { DeliveryStatusCell } from "./delivery-status-cell.js";
5
+ import { DeliveryTimeCell } from "./delivery-time-cell.js";
6
+ import { DeliveryTypeCell } from "./delivery-type-cell.js";
3
7
  export function deliveryClient(options) {
4
8
  return {
5
9
  name: DELIVERY_FEATURE,
6
10
  columnRenderers: {
7
11
  [DELIVERY_STATUS_CELL_COMPONENT]: DeliveryStatusCell,
12
+ [DELIVERY_TYPE_CELL_COMPONENT]: DeliveryTypeCell,
13
+ [DELIVERY_CHANNEL_CELL_COMPONENT]: DeliveryChannelCell,
14
+ [DELIVERY_TIME_CELL_COMPONENT]: DeliveryTimeCell,
15
+ [DELIVERY_ERROR_CELL_COMPONENT]: DeliveryErrorCell,
8
16
  },
9
17
  ...(options?.translations !== undefined && { translations: options.translations }),
10
18
  };
@@ -0,0 +1,3 @@
1
+ import { type ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ export declare function DeliveryChannelCell({ row }: ColumnRendererProps): ReactNode;
@@ -0,0 +1,10 @@
1
+ // @runtime client
2
+ // Channel column: the label comes from `delivery.channel.<name>`, registered by each
3
+ // channel feature; unknown channels show their raw name.
4
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
6
+ export function DeliveryChannelCell({ row }) {
7
+ const t = useTranslation();
8
+ const channel = typeof row["channel"] === "string" ? row["channel"] : "";
9
+ return translateOrRaw(t, `delivery.channel.${channel}`, channel);
10
+ }
@@ -0,0 +1,3 @@
1
+ import { type ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ export declare function DeliveryErrorCell({ row }: ColumnRendererProps): ReactNode;
@@ -0,0 +1,16 @@
1
+ // @runtime client
2
+ // Error column. The log only carries fixed codes (`DeliveryErrorCode`); `http_<status>`
3
+ // takes the status as a parameter, anything unknown shows as the raw value.
4
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import { HTTP_ERROR_CODE_PATTERN } from "@cosmicdrift/kumiko-types/handlers";
6
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
7
+ export function DeliveryErrorCell({ row }) {
8
+ const t = useTranslation();
9
+ const error = row["error"];
10
+ if (typeof error !== "string" || error === "")
11
+ return null;
12
+ const http = HTTP_ERROR_CODE_PATTERN.exec(error);
13
+ if (http)
14
+ return t("delivery.error.http", { status: http[1] });
15
+ return translateOrRaw(t, `delivery.error.${error}`, error);
16
+ }
@@ -1,3 +1,3 @@
1
- import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
1
+ import { type ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ReactNode } from "react";
3
3
  export declare function DeliveryStatusCell({ row }: ColumnRendererProps): ReactNode;
@@ -1,5 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @runtime client
3
+ // Column-renderer for the delivery-log screen's status column, registered
4
+ // via `deliveryClient()`'s `columnRenderers` map (screen.columns[].renderer
5
+ // in feature.ts). The list frame itself is the generic declarative
6
+ // projectionList renderer — only this one cell stays TSX.
7
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
8
  import { StatusBadge } from "@cosmicdrift/kumiko-renderer-web";
9
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
3
10
  import { DeliveryStatus } from "../public-names.js";
4
11
  const STATUS_TONE = {
5
12
  [DeliveryStatus.sent]: "ok",
@@ -8,6 +15,9 @@ const STATUS_TONE = {
8
15
  [DeliveryStatus.skipped]: "muted",
9
16
  };
10
17
  export function DeliveryStatusCell({ row }) {
18
+ const t = useTranslation();
11
19
  const status = typeof row["status"] === "string" ? row["status"] : "";
12
- return (_jsx(StatusBadge, { tone: STATUS_TONE[status] ?? "muted", testId: "delivery-status-cell", children: status }));
20
+ const unconfirmed = status === DeliveryStatus.sent && row["confirmed"] === false;
21
+ const labelKey = unconfirmed ? "delivery.status.sentUnconfirmed" : `delivery.status.${status}`;
22
+ return (_jsx(StatusBadge, { tone: unconfirmed ? "muted" : (STATUS_TONE[status] ?? "muted"), testId: "delivery-status-cell", children: translateOrRaw(t, labelKey, status) }));
13
23
  }
@@ -0,0 +1,3 @@
1
+ import { type ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ export declare function DeliveryTimeCell({ row }: ColumnRendererProps): ReactNode;
@@ -0,0 +1,6 @@
1
+ // @runtime client
2
+ import { formatWhen } from "@cosmicdrift/kumiko-renderer";
3
+ export function DeliveryTimeCell({ row }) {
4
+ const createdAt = row["createdAt"];
5
+ return typeof createdAt === "string" ? formatWhen(createdAt) : "";
6
+ }
@@ -0,0 +1,3 @@
1
+ import { type ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ export declare function DeliveryTypeCell({ row }: ColumnRendererProps): ReactNode;
@@ -0,0 +1,17 @@
1
+ // @runtime client
2
+ // Notification type column. Types are QNs like `<scope>:notify:<name>`; the
3
+ // translation key is `<scope>.notification.<name>`, registered by the app that
4
+ // owns the notification (bundled features register none). Falls back to the short
5
+ // name, or to the raw value for any other format.
6
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
7
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
8
+ const NOTIFICATION_QN_PATTERN = /^([^:]+):notify:(.+)$/;
9
+ export function DeliveryTypeCell({ row }) {
10
+ const t = useTranslation();
11
+ const type = typeof row["type"] === "string" ? row["type"] : "";
12
+ const match = NOTIFICATION_QN_PATTERN.exec(type);
13
+ if (!match)
14
+ return type;
15
+ const [, scope, name] = match;
16
+ return translateOrRaw(t, `${scope}.notification.${name}`, name ?? type);
17
+ }
@@ -1,3 +1,9 @@
1
1
  import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const DEFAULT_SECRETS_ROLES: readonly ["TenantAdmin"];
3
3
  export declare const DEFAULT_SECRETS_ACCESS: AccessRule;
4
+ export declare const SECRETS_ERROR_KEYS: {
5
+ readonly unknownKey: "secrets.errors.unknownKey";
6
+ readonly writeDenied: "secrets.errors.writeDenied";
7
+ readonly invalidValue: "secrets.errors.invalidValue";
8
+ };
9
+ export declare const INVALID_SECRET_VALUE_CODE: "invalid_secret_value";
@@ -7,3 +7,11 @@
7
7
  // design, not a per-tenant RBAC surface.
8
8
  export const DEFAULT_SECRETS_ROLES = ["TenantAdmin"];
9
9
  export const DEFAULT_SECRETS_ACCESS = { roles: DEFAULT_SECRETS_ROLES };
10
+ // Error i18n keys of the secrets write path. Shared by the write gate, the
11
+ // bundled translations and callers that need to recognise a specific failure.
12
+ export const SECRETS_ERROR_KEYS = {
13
+ unknownKey: "secrets.errors.unknownKey",
14
+ writeDenied: "secrets.errors.writeDenied",
15
+ invalidValue: "secrets.errors.invalidValue",
16
+ };
17
+ export const INVALID_SECRET_VALUE_CODE = "invalid_secret_value";
@@ -13,6 +13,9 @@ import * as z from "zod";
13
13
  * `KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION` pins one explicitly.
14
14
  */
15
15
  export declare const SECRETS_FEATURE_NAME = "secrets";
16
+ /** Env keys that carry the KEK keyring. Optional when the app supplies its own
17
+ * `masterKey` provider. */
18
+ export declare const SECRETS_MASTER_KEK_ENV_KEYS: readonly ["KUMIKO_SECRETS_MASTER_KEY_V1"];
16
19
  export declare const secretsEnvSchema: z.ZodObject<{
17
20
  KUMIKO_SECRETS_MASTER_KEY_V1: z.ZodString;
18
21
  KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: z.ZodDefault<z.ZodString>;
@@ -23,6 +23,9 @@ const ROTATE_ESCAPE_HATCH_REASON = "re-encrypts every tenant's secrets with the
23
23
  * `KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION` pins one explicitly.
24
24
  */
25
25
  export const SECRETS_FEATURE_NAME = "secrets";
26
+ /** Env keys that carry the KEK keyring. Optional when the app supplies its own
27
+ * `masterKey` provider. */
28
+ export const SECRETS_MASTER_KEK_ENV_KEYS = ["KUMIKO_SECRETS_MASTER_KEY_V1"];
26
29
  export const secretsEnvSchema = z.object({
27
30
  KUMIKO_SECRETS_MASTER_KEY_V1: z
28
31
  .string()
@@ -1,13 +1,14 @@
1
+ import { SECRETS_ERROR_KEYS } from "./constants.js";
1
2
  export const SECRETS_I18N = {
2
- "secrets.errors.unknownKey": {
3
+ [SECRETS_ERROR_KEYS.unknownKey]: {
3
4
  en: "Unknown secret key.",
4
5
  de: "Unbekannter Geheimnis-Schlüssel.",
5
6
  },
6
- "secrets.errors.writeDenied": {
7
+ [SECRETS_ERROR_KEYS.writeDenied]: {
7
8
  en: "You are not allowed to change this secret.",
8
9
  de: "Dieses Geheimnis darf nicht geändert werden.",
9
10
  },
10
- "secrets.errors.invalidValue": {
11
+ [SECRETS_ERROR_KEYS.invalidValue]: {
11
12
  en: "This value is not valid for this secret.",
12
13
  de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
13
14
  },
@@ -1,6 +1,7 @@
1
- export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES } from "./constants.js";
2
- export { createSecretsContext, createSecretsFeature, requireSecretsContext, SECRETS_FEATURE_NAME, type SecretsContext, type SecretsContextOptions, type SecretsFeatureOptions, type StoredEnvelope, type StoredMetadata, secretsEnvSchema, TENANT_SECRET_READ_EVENT, tenantSecretsTable, } from "./feature.js";
1
+ export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES, INVALID_SECRET_VALUE_CODE, SECRETS_ERROR_KEYS, } from "./constants.js";
2
+ export { createSecretsContext, createSecretsFeature, requireSecretsContext, SECRETS_FEATURE_NAME, SECRETS_MASTER_KEK_ENV_KEYS, type SecretsContext, type SecretsContextOptions, type SecretsFeatureOptions, type StoredEnvelope, type StoredMetadata, secretsEnvSchema, TENANT_SECRET_READ_EVENT, tenantSecretsTable, } from "./feature.js";
3
3
  export { createDeleteHandler } from "./handlers/delete.write.js";
4
4
  export { createListHandler } from "./handlers/list.query.js";
5
5
  export { type RotateJobPayload, type RotateJobResult, rotateJob, } from "./handlers/rotate.job.js";
6
6
  export { createSetHandler } from "./handlers/set.write.js";
7
+ export { isInvalidSecretValueError } from "./write-gate.js";
@@ -1,6 +1,7 @@
1
- export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES } from "./constants.js";
2
- export { createSecretsContext, createSecretsFeature, requireSecretsContext, SECRETS_FEATURE_NAME, secretsEnvSchema, TENANT_SECRET_READ_EVENT, tenantSecretsTable, } from "./feature.js";
1
+ export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES, INVALID_SECRET_VALUE_CODE, SECRETS_ERROR_KEYS, } from "./constants.js";
2
+ export { createSecretsContext, createSecretsFeature, requireSecretsContext, SECRETS_FEATURE_NAME, SECRETS_MASTER_KEK_ENV_KEYS, secretsEnvSchema, TENANT_SECRET_READ_EVENT, tenantSecretsTable, } from "./feature.js";
3
3
  export { createDeleteHandler } from "./handlers/delete.write.js";
4
4
  export { createListHandler } from "./handlers/list.query.js";
5
5
  export { rotateJob, } from "./handlers/rotate.job.js";
6
6
  export { createSetHandler } from "./handlers/set.write.js";
7
+ export { isInvalidSecretValueError } from "./write-gate.js";
@@ -10,5 +10,6 @@ export type SecretKeyWriteCheck = {
10
10
  export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string): SecretKeyWriteCheck;
11
11
  export type SecretValueSchemaSource = Pick<Registry, "getSecretKey" | "findSecretNamespace">;
12
12
  export declare function invalidSecretValueError(): ValidationError;
13
+ export declare function isInvalidSecretValueError(err: unknown): err is ValidationError;
13
14
  export declare function isSecretValueValid(schemas: SecretValueSchemaSource, key: string, value: string): boolean;
14
15
  export declare function checkSecretValue(registry: SecretValueSchemaSource, key: string, value: string): WriteFailure | undefined;
@@ -1,8 +1,9 @@
1
- import { AccessDeniedError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
1
+ import { AccessDeniedError, isKumikoError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { INVALID_SECRET_VALUE_CODE, SECRETS_ERROR_KEYS } from "./constants.js";
2
3
  function unknownKeyFailure(key) {
3
4
  return {
4
5
  ok: false,
5
- failure: writeFailure(new NotFoundError("secretKey", key, { i18nKey: "secrets.errors.unknownKey" })),
6
+ failure: writeFailure(new NotFoundError("secretKey", key, { i18nKey: SECRETS_ERROR_KEYS.unknownKey })),
6
7
  };
7
8
  }
8
9
  function namespaceAcceptsKey(namespace, key) {
@@ -27,7 +28,7 @@ export function checkSecretKeyWrite(registry, userRoles, key) {
27
28
  ok: false,
28
29
  failure: writeFailure(new AccessDeniedError({
29
30
  message: "secret write access denied",
30
- i18nKey: "secrets.errors.writeDenied",
31
+ i18nKey: SECRETS_ERROR_KEYS.writeDenied,
31
32
  details: { requiredRoles: writeRoles },
32
33
  })),
33
34
  };
@@ -40,11 +41,18 @@ export function invalidSecretValueError() {
40
41
  fields: [
41
42
  {
42
43
  path: "value",
43
- code: "invalid_secret_value",
44
- i18nKey: "secrets.errors.invalidValue",
44
+ code: INVALID_SECRET_VALUE_CODE,
45
+ i18nKey: SECRETS_ERROR_KEYS.invalidValue,
45
46
  },
46
47
  ],
47
- }, { i18nKey: "secrets.errors.invalidValue" });
48
+ }, { i18nKey: SECRETS_ERROR_KEYS.invalidValue });
49
+ }
50
+ // Matches on the i18n key and error code rather than instanceof, which is fragile
51
+ // when the error crosses a package boundary with a duplicated framework copy.
52
+ export function isInvalidSecretValueError(err) {
53
+ return (isKumikoError(err) &&
54
+ err.code === "validation_error" &&
55
+ err.i18nKey === SECRETS_ERROR_KEYS.invalidValue);
48
56
  }
49
57
  export function isSecretValueValid(schemas, key, value) {
50
58
  const valueSchema = schemas.getSecretKey(key)?.valueSchema ?? schemas.findSecretNamespace(key)?.valueSchema;
@@ -12,6 +12,7 @@ export { createLockoutCounter, type LockoutCounterState } from "./lockout-counte
12
12
  export { mapWithConcurrency } from "./map-with-concurrency.js";
13
13
  export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisible, } from "./parent-visibility.js";
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
+ export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
15
16
  export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
16
17
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
17
18
  export { sessionLocaleField } from "./session-locale-field.js";
@@ -12,6 +12,7 @@ export { createLockoutCounter } from "./lockout-counter.js";
12
12
  export { mapWithConcurrency } from "./map-with-concurrency.js";
13
13
  export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisible, } from "./parent-visibility.js";
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
+ export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
15
16
  export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
16
17
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
17
18
  export { sessionLocaleField } from "./session-locale-field.js";
@@ -0,0 +1,4 @@
1
+ export declare function redactEmailAddresses(text: string): string;
2
+ export declare function redactUrls(text: string): string;
3
+ export declare function redactBotTokens(text: string): string;
4
+ export declare function redactErrorText(text: string): string;
@@ -0,0 +1,21 @@
1
+ // Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
2
+ // address must not outlive the crypto-shredded dispatch payload in logs.
3
+ export function redactEmailAddresses(text) {
4
+ return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
5
+ }
6
+ // Provider URLs carry secrets in the path (Slack hook, Telegram bot token), the query or
7
+ // the userinfo. The origin stays so the log still says which host failed. Any scheme
8
+ // counts: smtp://user:pass@host carries credentials just like https.
9
+ export function redactUrls(text) {
10
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/?#<>"']*)([^\s<>"']*)/gi, (_match, scheme, authority, rest) => {
11
+ const host = authority.slice(authority.lastIndexOf("@") + 1);
12
+ return rest.length > 0 && rest !== "/" ? `${scheme}${host}/[redacted]` : `${scheme}${host}`;
13
+ });
14
+ }
15
+ // Telegram-style bot tokens outside a URL ("<digits>:<35 chars>"). Times like 12:30 are too short to match.
16
+ export function redactBotTokens(text) {
17
+ return text.replace(/\b\d{6,}:[A-Za-z0-9_-]{30,}\b/g, "[redacted-token]");
18
+ }
19
+ export function redactErrorText(text) {
20
+ return redactBotTokens(redactEmailAddresses(redactUrls(text)));
21
+ }
@@ -12,7 +12,8 @@ import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE,
12
12
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
13
13
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
14
14
  import * as z from "zod";
15
- import { mailSpecSchema, performMailDispatch, redactEmailAddresses, } from "./mail-runner.js";
15
+ import { redactEmailAddresses } from "../shared/index.js";
16
+ import { mailSpecSchema, performMailDispatch } from "./mail-runner.js";
16
17
  import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, webhookSpecSchema, } from "./webhook-runner.js";
17
18
  const log = createFallbackLogger("step-dispatcher");
18
19
  export const stepDispatcherEnvSchema = z.object({
@@ -15,4 +15,3 @@ export type MailDispatchResult = {
15
15
  };
16
16
  export declare function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void;
17
17
  export declare function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult>;
18
- export declare function redactEmailAddresses(text: string): string;
@@ -19,8 +19,3 @@ export function setMailRunner(fn) {
19
19
  export async function performMailDispatch(spec) {
20
20
  return mailRunner(spec);
21
21
  }
22
- // Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
23
- // address must not outlive the crypto-shredded dispatch payload in logs.
24
- export function redactEmailAddresses(text) {
25
- return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
26
- }
@@ -3,7 +3,7 @@
3
3
  // `readonly string[]` on TeamRow (see team-list.query.ts), and DataTable has
4
4
  // no default array-to-cell formatting, so this joins it into one string.
5
5
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
6
- import { translateOrRaw } from "./translate-or-raw.js";
6
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
7
7
  export function MemberRolesCell({ row }) {
8
8
  const t = useTranslation();
9
9
  const roles = row["roles"];
@@ -6,7 +6,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  // projectionList renderer — only this one cell stays TSX.
7
7
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
8
8
  import { StatusBadge } from "@cosmicdrift/kumiko-renderer-web";
9
- import { translateOrRaw } from "./translate-or-raw.js";
9
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
10
10
  const STATUS_TONE = {
11
11
  active: "ok",
12
12
  pending: "muted",
@@ -4,7 +4,7 @@ import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, T
4
4
  import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
5
5
  import { cancelDestructionWrite } from "./handlers/cancel-destruction.write.js";
6
6
  import { requestDestructionWrite } from "./handlers/request-destruction.write.js";
7
- import { resolveTenantLifecycleGate, runTenantDestructionSweep } from "./run-tenant-destroy.js";
7
+ import { hasTenantDestructionWork, resolveTenantLifecycleGate, runTenantDestructionSweep, } from "./run-tenant-destroy.js";
8
8
  const tenantLifecycleStatusPlugin = {
9
9
  async resolveStatus(tenantId, { db }) {
10
10
  const gate = await resolveTenantLifecycleGate(db, tenantId);
@@ -73,10 +73,16 @@ export function createTenantLifecycleFeature() {
73
73
  throw new Error("run-tenant-destruction: ctx.registry required (JobContext incomplete)");
74
74
  }
75
75
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
76
+ const now = T.Now.instant();
77
+ // Without crossTenantReads (undeclared hatch) fall through to the sweep.
78
+ if (ctx.crossTenantReads && !(await hasTenantDestructionWork(ctx.crossTenantReads, now))) {
79
+ // skip: no tenant is due, so the sweep would not touch anything
80
+ return;
81
+ }
76
82
  await runTenantDestructionSweep({
77
83
  db: ctx.db.unsafeRaw(), // @cast-boundary db-operator — jobs never run inside a DbTx
78
84
  registry: ctx.registry,
79
- now: T.Now.instant(),
85
+ now,
80
86
  log: (message) => ctx.log?.warn(message),
81
87
  fileProviderResolver: ctx._fileProviderResolver,
82
88
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
@@ -1,5 +1,5 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { type EscapeHatchAuditSink, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
5
5
  export declare function runNextDestructionStage(args: {
@@ -15,6 +15,7 @@ export declare function runNextDestructionStage(args: {
15
15
  readonly error?: string;
16
16
  readonly halted?: boolean;
17
17
  }>;
18
+ export declare function hasTenantDestructionWork(reads: Pick<TenantDb, "count">, now: Temporal.Instant): Promise<boolean>;
18
19
  /** Cron entry: start destruction for tenants past grace, then advance stages. */
19
20
  export declare function runTenantDestructionSweep(args: {
20
21
  readonly db: DbRunner;
@@ -167,14 +167,25 @@ export async function runNextDestructionStage(args) {
167
167
  return { done: false, error: message };
168
168
  }
169
169
  }
170
+ function destroyRequestedDueWhere(now) {
171
+ return {
172
+ status: "destroyRequested",
173
+ gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
174
+ };
175
+ }
176
+ const destroyingWhere = { status: "destroying" };
177
+ export async function hasTenantDestructionWork(reads, now) {
178
+ const [due, destroying] = await Promise.all([
179
+ reads.count(tenantTable, destroyRequestedDueWhere(now)),
180
+ reads.count(tenantTable, destroyingWhere),
181
+ ]);
182
+ return due > 0 || destroying > 0;
183
+ }
170
184
  /** Cron entry: start destruction for tenants past grace, then advance stages. */
171
185
  export async function runTenantDestructionSweep(args) {
172
186
  const T = getTemporal();
173
187
  const now = args.now ?? T.Now.instant();
174
- const due = await selectMany(args.db, tenantTable, {
175
- status: "destroyRequested",
176
- gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
177
- });
188
+ const due = await selectMany(args.db, tenantTable, destroyRequestedDueWhere(now));
178
189
  let triggered = 0;
179
190
  for (const row of due) {
180
191
  const tenantId = row.id;
@@ -195,9 +206,7 @@ export async function runTenantDestructionSweep(args) {
195
206
  triggered++;
196
207
  }
197
208
  let advanced = 0;
198
- const destroying = await selectMany(args.db, tenantTable, {
199
- status: "destroying",
200
- });
209
+ const destroying = await selectMany(args.db, tenantTable, destroyingWhere);
201
210
  for (const row of destroying) {
202
211
  try {
203
212
  const result = await runNextDestructionStage({
@@ -3,5 +3,6 @@ export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants
3
3
  export { backfillUserStreamTenants, type UserStreamBackfillResult, } from "./db/queries/stream-tenant-backfill.js";
4
4
  export { createUserFeature } from "./feature.js";
5
5
  export { isPrincipalBlocked, principalStatusPlugin } from "./principal-status.js";
6
+ export { resolveUserDisplayNames } from "./resolve-display-names.js";
6
7
  export type { UserStatus } from "./schema/user.js";
7
8
  export { USER_ANONYMIZED_DISPLAY_NAME, USER_ANONYMIZED_EMAIL_DOMAIN, USER_ANONYMIZED_EMAIL_PREFIX, USER_DELETED_DISPLAY_NAME, USER_DELETED_EMAIL_PREFIX, USER_STATUS, userEntity, userTable, } from "./schema/user.js";
@@ -3,4 +3,5 @@ export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants
3
3
  export { backfillUserStreamTenants, } from "./db/queries/stream-tenant-backfill.js";
4
4
  export { createUserFeature } from "./feature.js";
5
5
  export { isPrincipalBlocked, principalStatusPlugin } from "./principal-status.js";
6
+ export { resolveUserDisplayNames } from "./resolve-display-names.js";
6
7
  export { USER_ANONYMIZED_DISPLAY_NAME, USER_ANONYMIZED_EMAIL_DOMAIN, USER_ANONYMIZED_EMAIL_PREFIX, USER_DELETED_DISPLAY_NAME, USER_DELETED_EMAIL_PREFIX, USER_STATUS, userEntity, userTable, } from "./schema/user.js";
@@ -0,0 +1,2 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ export declare function resolveUserDisplayNames(db: TenantDb, userIds: readonly string[]): Promise<ReadonlyMap<string, string>>;
@@ -0,0 +1,24 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { decryptStoredPii, mapWithConcurrency } from "../shared/index.js";
3
+ import { userTable } from "./schema/user.js";
4
+ // Shares the KMS adapter's small dedicated pool, same as member-directory.
5
+ const KMS_POOL_CONCURRENCY = 4;
6
+ // One batched lookup for a page of user ids. Users that are missing, have no
7
+ // display name, or whose name cannot be decrypted (e.g. crypto-shredded) are
8
+ // left out, so callers fall back to the raw id instead of failing the page.
9
+ export async function resolveUserDisplayNames(db, userIds) {
10
+ const names = new Map();
11
+ if (userIds.length === 0)
12
+ return names;
13
+ const users = await selectMany(db, userTable, { id: [...userIds] });
14
+ const named = users.filter((user) => typeof user.displayName === "string");
15
+ await mapWithConcurrency(named, KMS_POOL_CONCURRENCY, async (user) => {
16
+ try {
17
+ names.set(String(user.id), await decryptStoredPii(String(user.displayName), "displayName", "user:resolve-display-names"));
18
+ }
19
+ catch {
20
+ // Unreadable name: the caller's fallback applies.
21
+ }
22
+ });
23
+ return names;
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.342.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>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.342.0",
507
- "@cosmicdrift/kumiko-framework": "0.342.0",
508
- "@cosmicdrift/kumiko-headless": "0.342.0",
509
- "@cosmicdrift/kumiko-renderer": "0.342.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.342.0",
511
- "@cosmicdrift/kumiko-types": "0.342.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.343.0",
507
+ "@cosmicdrift/kumiko-framework": "0.343.0",
508
+ "@cosmicdrift/kumiko-headless": "0.343.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.343.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.343.0",
511
+ "@cosmicdrift/kumiko-types": "0.343.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.342.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.342.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.343.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.343.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }
@@ -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",
@@ -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",
@@ -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
+ ]