@cosmicdrift/kumiko-bundled-features 0.391.1 → 0.392.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 (100) hide show
  1. package/dist/admin-shell/feature.d.ts +3 -0
  2. package/dist/admin-shell/feature.js +47 -30
  3. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -2
  5. package/dist/cap-overview/handlers/caps-usage.query.js +1 -0
  6. package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -2
  7. package/dist/cap-overview/i18n.js +4 -0
  8. package/dist/cap-overview/types.d.ts +3 -1
  9. package/dist/cap-overview/web/cap-amount-format.d.ts +3 -0
  10. package/dist/cap-overview/web/cap-amount-format.js +30 -0
  11. package/dist/cap-overview/web/cap-cards-panel.js +63 -23
  12. package/dist/cap-overview/web/cap-usage-bar.js +9 -4
  13. package/dist/cap-overview/web/cap-usage-cell.js +4 -2
  14. package/dist/cap-overview/web/usage-math.d.ts +1 -0
  15. package/dist/cap-overview/web/usage-math.js +4 -1
  16. package/dist/channel-email/app-transport.d.ts +14 -0
  17. package/dist/channel-email/app-transport.js +20 -0
  18. package/dist/channel-email/feature.js +6 -0
  19. package/dist/channel-email/index.d.ts +1 -0
  20. package/dist/channel-email/index.js +1 -0
  21. package/dist/channel-email/smtp-transport.js +2 -19
  22. package/dist/compliance-profiles/schema/profile-selection.d.ts +8 -8
  23. package/dist/foundation-shared/index.d.ts +1 -0
  24. package/dist/foundation-shared/index.js +1 -0
  25. package/dist/foundation-shared/mail-display-name.d.ts +1 -0
  26. package/dist/foundation-shared/mail-display-name.js +18 -0
  27. package/dist/inbound-mail-foundation/account-record.d.ts +13 -0
  28. package/dist/inbound-mail-foundation/account-record.js +27 -0
  29. package/dist/inbound-mail-foundation/feature.js +1 -1
  30. package/dist/inbound-mail-foundation/i18n.js +17 -0
  31. package/dist/inbound-mail-foundation/index.d.ts +3 -1
  32. package/dist/inbound-mail-foundation/index.js +3 -1
  33. package/dist/inbound-mail-foundation/outbound-mail.d.ts +28 -0
  34. package/dist/inbound-mail-foundation/outbound-mail.js +52 -0
  35. package/dist/inbound-mail-foundation/probe.js +5 -28
  36. package/dist/inbound-mail-foundation/screens.d.ts +2 -0
  37. package/dist/inbound-mail-foundation/screens.js +14 -0
  38. package/dist/inbound-mail-foundation/send-from-account.d.ts +31 -0
  39. package/dist/inbound-mail-foundation/send-from-account.js +87 -0
  40. package/dist/inbound-mail-foundation/types.d.ts +4 -0
  41. package/dist/inbound-mail-foundation/watch-supervisor.js +10 -8
  42. package/dist/inbound-provider-imap/connect-mailbox.d.ts +13 -0
  43. package/dist/inbound-provider-imap/connect-mailbox.js +124 -0
  44. package/dist/inbound-provider-imap/constants.d.ts +41 -0
  45. package/dist/inbound-provider-imap/constants.js +39 -0
  46. package/dist/inbound-provider-imap/credential-document.d.ts +6 -0
  47. package/dist/inbound-provider-imap/credential-document.js +31 -1
  48. package/dist/inbound-provider-imap/feature.d.ts +1 -1
  49. package/dist/inbound-provider-imap/feature.js +13 -22
  50. package/dist/inbound-provider-imap/i18n.d.ts +3 -0
  51. package/dist/inbound-provider-imap/i18n.js +45 -0
  52. package/dist/inbound-provider-imap/imap-client.d.ts +1 -0
  53. package/dist/inbound-provider-imap/imap-client.js +1 -1
  54. package/dist/inbound-provider-imap/screens.d.ts +2 -0
  55. package/dist/inbound-provider-imap/screens.js +39 -0
  56. package/dist/inbound-provider-imap/send-mail.d.ts +2 -0
  57. package/dist/inbound-provider-imap/send-mail.js +48 -0
  58. package/dist/inbound-provider-imap/sent-folder.d.ts +8 -0
  59. package/dist/inbound-provider-imap/sent-folder.js +40 -0
  60. package/dist/inbound-provider-imap/smtp-client.d.ts +35 -0
  61. package/dist/inbound-provider-imap/smtp-client.js +106 -0
  62. package/dist/ledger/entity.d.ts +8 -8
  63. package/dist/mail-foundation/i18n.d.ts +9 -1
  64. package/dist/mail-foundation/i18n.js +9 -1
  65. package/dist/mail-foundation/probe.js +32 -16
  66. package/dist/readiness/built-in-probes.d.ts +2 -1
  67. package/dist/readiness/built-in-probes.js +12 -1
  68. package/dist/readiness/constants.d.ts +5 -0
  69. package/dist/readiness/constants.js +1 -0
  70. package/dist/readiness/i18n.js +7 -1
  71. package/dist/readiness/index.d.ts +1 -1
  72. package/dist/readiness/index.js +1 -1
  73. package/dist/readiness/schema/probe-run.d.ts +8 -8
  74. package/dist/readiness/screens.d.ts +3 -0
  75. package/dist/readiness/screens.js +25 -2
  76. package/dist/readiness/visible-results.d.ts +2 -1
  77. package/dist/template-resolver/table.d.ts +36 -36
  78. package/dist/template-resolver/user-content-table.d.ts +26 -26
  79. package/dist/tenant/invitation-table.d.ts +8 -8
  80. package/dist/tenant/schema/tenant.d.ts +10 -10
  81. package/dist/tier-engine/entity.d.ts +3 -3
  82. package/dist/tier-engine/feature.js +8 -4
  83. package/dist/tier-engine/tier-resolver.d.ts +3 -3
  84. package/dist/user/schema/user.d.ts +8 -8
  85. package/dist/user-data-rights/handlers/download-by-token.query.js +3 -3
  86. package/dist/user-data-rights/schema/download-token.js +5 -3
  87. package/dist/user-data-rights/schema/export-job.d.ts +8 -8
  88. package/dist/user-data-rights/token-helpers.d.ts +1 -2
  89. package/dist/user-data-rights/token-helpers.js +5 -4
  90. package/dist/waitlist/entity.d.ts +8 -8
  91. package/package.json +11 -11
  92. package/src/admin-shell/changes.json +6 -0
  93. package/src/cap-overview/changes.json +12 -0
  94. package/src/channel-email/changes.json +6 -0
  95. package/src/foundation-shared/changes.json +6 -0
  96. package/src/inbound-mail-foundation/changes.json +18 -0
  97. package/src/inbound-provider-imap/changes.json +12 -0
  98. package/src/mail-foundation/changes.json +6 -0
  99. package/src/readiness/changes.json +30 -0
  100. package/src/tier-engine/changes.json +6 -0
@@ -0,0 +1,106 @@
1
+ // nodemailer wiring for sending from a connected mailbox: one short-lived, non-pooled connection
2
+ // per call, always implicit TLS, pinned to the address the mail host guard approved.
3
+ import { BlockedHostError, resolveMailConnectTarget, } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
4
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
5
+ import { errorCodeOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
6
+ import { createTransport } from "nodemailer";
7
+ import { imapMailHostGuard } from "./imap-client.js";
8
+ const log = createFallbackLogger("inbound-provider-imap");
9
+ // nodemailer's defaults wait minutes; the connect form holds a request open meanwhile.
10
+ const CONNECTION_TIMEOUT_MS = 10_000;
11
+ const GREETING_TIMEOUT_MS = 10_000;
12
+ const SOCKET_TIMEOUT_MS = 30_000;
13
+ // Carries only the category and the library code: the raw error holds the host and the server
14
+ // response, which stay in the server log.
15
+ export class SmtpFailureError extends Error {
16
+ failure;
17
+ constructor(failure, detail) {
18
+ super(`SMTP ${failure}: ${detail}`);
19
+ this.name = "SmtpFailureError";
20
+ this.failure = failure;
21
+ }
22
+ }
23
+ const LOGIN_CODES = ["EAUTH", "ENOAUTH"];
24
+ const CONNECTION_CODES = ["ECONNECTION", "ETIMEDOUT", "ESOCKET", "EDNS", "EPROXY"];
25
+ const TLS_CODE = /^(ETLS|ERR_SSL|ERR_TLS)|CERT|SELF_SIGNED|UNABLE_TO_VERIFY/;
26
+ // nodemailer wraps a TLS handshake error as ESOCKET and keeps the TLS code as originalCode.
27
+ function isTlsFailure(code, originalCode) {
28
+ return TLS_CODE.test(code) || (typeof originalCode === "string" && TLS_CODE.test(originalCode));
29
+ }
30
+ export function classifySmtpError(err) {
31
+ const code = errorCodeOf(err) ?? "";
32
+ if (LOGIN_CODES.includes(code))
33
+ return "loginRejected";
34
+ if (isTlsFailure(code, propertyOf(err, "originalCode")))
35
+ return "tlsFailed";
36
+ if (CONNECTION_CODES.includes(code))
37
+ return "connectFailed";
38
+ if (typeof propertyOf(err, "responseCode") === "number")
39
+ return "rejected";
40
+ return "connectFailed";
41
+ }
42
+ export function smtpTransportOptions(login, target) {
43
+ return {
44
+ host: target.host,
45
+ port: login.port,
46
+ secure: true,
47
+ ...(target.servername && { servername: target.servername }),
48
+ auth: { user: login.user, pass: login.password },
49
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
50
+ greetingTimeout: GREETING_TIMEOUT_MS,
51
+ socketTimeout: SOCKET_TIMEOUT_MS,
52
+ // The message is composed by us; nothing in it may make nodemailer read files or URLs.
53
+ disableFileAccess: true,
54
+ disableUrlAccess: true,
55
+ logger: false,
56
+ debug: false,
57
+ };
58
+ }
59
+ async function openSmtpTransport(login) {
60
+ let target;
61
+ try {
62
+ target = await resolveMailConnectTarget(login.host, imapMailHostGuard());
63
+ }
64
+ catch (err) {
65
+ const reason = err instanceof Error ? err.message : String(err);
66
+ const blocked = err instanceof BlockedHostError;
67
+ log.warn(blocked ? "rejected blocked smtp host" : "smtp host resolution failed", {
68
+ host: login.host,
69
+ reason,
70
+ });
71
+ throw new SmtpFailureError("hostUnreachable", blocked ? "blocked" : "unresolved");
72
+ }
73
+ return createTransport(smtpTransportOptions(login, target));
74
+ }
75
+ // No error message in the log: a server's reply to a failed AUTH can echo the user name.
76
+ function smtpFailureOf(err, host) {
77
+ const failure = classifySmtpError(err);
78
+ const code = errorCodeOf(err) ?? "unknown";
79
+ const responseCode = propertyOf(err, "responseCode");
80
+ log.warn("smtp call failed", { host, failure, code, responseCode });
81
+ return new SmtpFailureError(failure, code);
82
+ }
83
+ export async function verifySmtpLogin(login) {
84
+ const transport = await openSmtpTransport(login);
85
+ try {
86
+ await transport.verify();
87
+ }
88
+ catch (err) {
89
+ throw smtpFailureOf(err, login.host);
90
+ }
91
+ finally {
92
+ transport.close();
93
+ }
94
+ }
95
+ export async function sendRawViaSmtp(login, envelope, raw) {
96
+ const transport = await openSmtpTransport(login);
97
+ try {
98
+ await transport.sendMail({ envelope: { from: envelope.from, to: [...envelope.to] }, raw });
99
+ }
100
+ catch (err) {
101
+ throw smtpFailureOf(err, login.host);
102
+ }
103
+ finally {
104
+ transport.close();
105
+ }
106
+ }
@@ -14,11 +14,11 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
14
14
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
15
15
  readonly display?: "radio" | "dropdown";
16
16
  readonly optionTones?: {
17
- readonly asset?: "bad" | "neutral" | "ok" | "warn" | undefined;
18
- readonly equity?: "bad" | "neutral" | "ok" | "warn" | undefined;
19
- readonly expense?: "bad" | "neutral" | "ok" | "warn" | undefined;
20
- readonly income?: "bad" | "neutral" | "ok" | "warn" | undefined;
21
- readonly liability?: "bad" | "neutral" | "ok" | "warn" | undefined;
17
+ readonly asset?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
18
+ readonly equity?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
19
+ readonly expense?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
20
+ readonly income?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
21
+ readonly liability?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
22
22
  } | undefined;
23
23
  readonly optionsQuery?: string;
24
24
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -67,8 +67,8 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
67
67
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
68
68
  readonly display?: "radio" | "dropdown";
69
69
  readonly optionTones?: {
70
- readonly draft?: "bad" | "neutral" | "ok" | "warn" | undefined;
71
- readonly posted?: "bad" | "neutral" | "ok" | "warn" | undefined;
70
+ readonly draft?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
71
+ readonly posted?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
72
72
  } | undefined;
73
73
  readonly optionsQuery?: string;
74
74
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -144,7 +144,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
144
144
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
145
145
  readonly display?: "radio" | "dropdown";
146
146
  readonly optionTones?: {
147
- readonly monthly?: "bad" | "neutral" | "ok" | "warn" | undefined;
147
+ readonly monthly?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
148
148
  } | undefined;
149
149
  readonly optionsQuery?: string;
150
150
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -2,12 +2,14 @@ export declare const MAIL_PROBE_KEYS: {
2
2
  readonly area: "mail-foundation.probe.area";
3
3
  readonly label: "mail-foundation.probe.send-test-mail.label";
4
4
  readonly accepted: "mail-foundation.probe.send-test-mail.accepted";
5
+ readonly platformAccepted: "mail-foundation.probe.send-test-mail.platform-accepted";
5
6
  readonly notConfigured: "mail-foundation.probe.not-configured";
6
7
  readonly noRecipient: "mail-foundation.probe.no-recipient";
7
8
  readonly hostNotFound: "mail-foundation.probe.host-not-found";
8
9
  readonly setupIncomplete: "mail-foundation.probe.setup-incomplete";
9
10
  readonly setupFailed: "mail-foundation.probe.setup-failed";
10
11
  readonly sendRejected: "mail-foundation.probe.send-rejected";
12
+ readonly platformSendRejected: "mail-foundation.probe.platform-send-rejected";
11
13
  };
12
14
  export declare const MAIL_FOUNDATION_I18N: {
13
15
  readonly "screen:mail-foundation-tenant.title": {
@@ -34,8 +36,11 @@ export declare const MAIL_FOUNDATION_I18N: {
34
36
  readonly "mail-foundation.probe.send-test-mail.accepted": {
35
37
  readonly en: "Accepted by the mail server";
36
38
  };
39
+ readonly "mail-foundation.probe.send-test-mail.platform-accepted": {
40
+ readonly en: "Sent via the platform sender, accepted by its mail server";
41
+ };
37
42
  readonly "mail-foundation.probe.not-configured": {
38
- readonly en: "No own email sending set up";
43
+ readonly en: "No email sending set up";
39
44
  };
40
45
  readonly "mail-foundation.probe.no-recipient": {
41
46
  readonly en: "No email address known for the person who started the check";
@@ -52,4 +57,7 @@ export declare const MAIL_FOUNDATION_I18N: {
52
57
  readonly "mail-foundation.probe.send-rejected": {
53
58
  readonly en: "The mail server did not accept the test email, check address, port and login";
54
59
  };
60
+ readonly "mail-foundation.probe.platform-send-rejected": {
61
+ readonly en: "The platform mail server did not accept the test email";
62
+ };
55
63
  };
@@ -2,12 +2,14 @@ export const MAIL_PROBE_KEYS = {
2
2
  area: "mail-foundation.probe.area",
3
3
  label: "mail-foundation.probe.send-test-mail.label",
4
4
  accepted: "mail-foundation.probe.send-test-mail.accepted",
5
+ platformAccepted: "mail-foundation.probe.send-test-mail.platform-accepted",
5
6
  notConfigured: "mail-foundation.probe.not-configured",
6
7
  noRecipient: "mail-foundation.probe.no-recipient",
7
8
  hostNotFound: "mail-foundation.probe.host-not-found",
8
9
  setupIncomplete: "mail-foundation.probe.setup-incomplete",
9
10
  setupFailed: "mail-foundation.probe.setup-failed",
10
11
  sendRejected: "mail-foundation.probe.send-rejected",
12
+ platformSendRejected: "mail-foundation.probe.platform-send-rejected",
11
13
  };
12
14
  export const MAIL_FOUNDATION_I18N = {
13
15
  "screen:mail-foundation-tenant.title": { en: "Email sending" },
@@ -20,7 +22,10 @@ export const MAIL_FOUNDATION_I18N = {
20
22
  [MAIL_PROBE_KEYS.area]: { en: "Email" },
21
23
  [MAIL_PROBE_KEYS.label]: { en: "Test email to the person who started the check" },
22
24
  [MAIL_PROBE_KEYS.accepted]: { en: "Accepted by the mail server" },
23
- [MAIL_PROBE_KEYS.notConfigured]: { en: "No own email sending set up" },
25
+ [MAIL_PROBE_KEYS.platformAccepted]: {
26
+ en: "Sent via the platform sender, accepted by its mail server",
27
+ },
28
+ [MAIL_PROBE_KEYS.notConfigured]: { en: "No email sending set up" },
24
29
  [MAIL_PROBE_KEYS.noRecipient]: {
25
30
  en: "No email address known for the person who started the check",
26
31
  },
@@ -32,4 +37,7 @@ export const MAIL_FOUNDATION_I18N = {
32
37
  [MAIL_PROBE_KEYS.sendRejected]: {
33
38
  en: "The mail server did not accept the test email, check address, port and login",
34
39
  },
40
+ [MAIL_PROBE_KEYS.platformSendRejected]: {
41
+ en: "The platform mail server did not accept the test email",
42
+ },
35
43
  };
@@ -1,3 +1,4 @@
1
+ import { findEmailAppTransport, } from "@cosmicdrift/kumiko-bundled-features/channel-email";
1
2
  import { createSystemUser, ProbeFailure, SETTINGS_HUB_FEATURE, } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { HostResolutionError } from "@cosmicdrift/kumiko-framework/http";
@@ -21,6 +22,28 @@ function transportFailureKey(error) {
21
22
  ? MAIL_PROBE_KEYS.setupIncomplete
22
23
  : MAIL_PROBE_KEYS.setupFailed;
23
24
  }
25
+ async function ownTenantTransport(ctx) {
26
+ const { configResolver, secrets } = ctx.services;
27
+ if (!configResolver)
28
+ return null;
29
+ const config = createConfigAccessor(ctx.registry, configResolver, ctx.tenantId, createSystemUser(ctx.tenantId).id, ctx.db);
30
+ return tenantMailTransport({
31
+ tenantId: ctx.tenantId,
32
+ config,
33
+ registry: ctx.registry,
34
+ secrets,
35
+ }).catch((error) => {
36
+ throw probeFailure(ctx, error, transportFailureKey(error));
37
+ });
38
+ }
39
+ // Same order as channel-email for tenant mails: the tenant's own provider, else the app sender.
40
+ async function probeTransport(ctx) {
41
+ const own = await ownTenantTransport(ctx);
42
+ if (own !== null)
43
+ return { transport: own, viaPlatform: false };
44
+ const platform = findEmailAppTransport(ctx.registry);
45
+ return platform === null ? null : { transport: platform, viaPlatform: true };
46
+ }
24
47
  // The mail goes to whoever started the check; the address never appears in the stored result.
25
48
  export function registerMailProbe(r) {
26
49
  r.probe({
@@ -31,25 +54,14 @@ export function registerMailProbe(r) {
31
54
  label: MAIL_PROBE_KEYS.label,
32
55
  fixScreen: MAIL_SETTINGS_SCREEN_QN,
33
56
  run: async (ctx) => {
34
- const { configResolver, secrets } = ctx.services;
35
- if (!configResolver) {
36
- return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
37
- }
38
- const config = createConfigAccessor(ctx.registry, configResolver, ctx.tenantId, createSystemUser(ctx.tenantId).id, ctx.db);
39
- const transport = await tenantMailTransport({
40
- tenantId: ctx.tenantId,
41
- config,
42
- registry: ctx.registry,
43
- secrets,
44
- }).catch((error) => {
45
- throw probeFailure(ctx, error, transportFailureKey(error));
46
- });
47
- if (transport === null) {
57
+ const resolved = await probeTransport(ctx);
58
+ if (resolved === null) {
48
59
  return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
49
60
  }
50
61
  if (ctx.triggeredByEmail === undefined) {
51
62
  return { status: "skipped", message: { key: MAIL_PROBE_KEYS.noRecipient } };
52
63
  }
64
+ const { transport, viaPlatform } = resolved;
53
65
  await transport
54
66
  .send({
55
67
  to: ctx.triggeredByEmail,
@@ -58,9 +70,13 @@ export function registerMailProbe(r) {
58
70
  html: `<p>${PROBE_TEXT}</p>`,
59
71
  })
60
72
  .catch((error) => {
61
- throw probeFailure(ctx, error, MAIL_PROBE_KEYS.sendRejected);
73
+ const key = viaPlatform
74
+ ? MAIL_PROBE_KEYS.platformSendRejected
75
+ : MAIL_PROBE_KEYS.sendRejected;
76
+ throw probeFailure(ctx, error, key);
62
77
  });
63
- return { status: "ok", message: { key: MAIL_PROBE_KEYS.accepted } };
78
+ const accepted = viaPlatform ? MAIL_PROBE_KEYS.platformAccepted : MAIL_PROBE_KEYS.accepted;
79
+ return { status: "ok", message: { key: accepted } };
64
80
  },
65
81
  });
66
82
  }
@@ -1,4 +1,4 @@
1
- import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const BuiltInProbeKeys: {
3
3
  readonly areaInfrastructure: "readiness.area.infrastructure";
4
4
  readonly databaseLabel: "readiness.probe.database.label";
@@ -7,5 +7,6 @@ export declare const BuiltInProbeKeys: {
7
7
  readonly cacheOk: "readiness.probe.cache.ok";
8
8
  readonly searchLabel: "readiness.probe.search.label";
9
9
  readonly searchOk: "readiness.probe.search.ok";
10
+ readonly searchUnavailable: "readiness.probe.search.unavailable";
10
11
  };
11
12
  export declare function registerBuiltInProbes(r: FeatureRegistrar<"readiness">): void;
@@ -1,3 +1,4 @@
1
+ import { ProbeFailure, } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import { ProbeTextKeys } from "./constants.js";
2
3
  import { probeRunsTable } from "./schema/probe-run.js";
3
4
  export const BuiltInProbeKeys = {
@@ -8,6 +9,7 @@ export const BuiltInProbeKeys = {
8
9
  cacheOk: "readiness.probe.cache.ok",
9
10
  searchLabel: "readiness.probe.search.label",
10
11
  searchOk: "readiness.probe.search.ok",
12
+ searchUnavailable: "readiness.probe.search.unavailable",
11
13
  };
12
14
  export function registerBuiltInProbes(r) {
13
15
  r.probe({
@@ -48,7 +50,16 @@ export function registerBuiltInProbes(r) {
48
50
  if (!searchAdapter) {
49
51
  return { status: "skipped", message: { key: ProbeTextKeys.notConfigured } };
50
52
  }
51
- await searchAdapter.search(ctx.tenantId, "readiness-probe", { limit: 1 });
53
+ try {
54
+ await searchAdapter.search(ctx.tenantId, "readiness-probe", { limit: 1 });
55
+ }
56
+ catch (error) {
57
+ // The raw error can name the search host, so it stays in the server log.
58
+ ctx.obs.log.warn("readiness search probe failed", {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ });
61
+ throw new ProbeFailure({ key: BuiltInProbeKeys.searchUnavailable });
62
+ }
52
63
  return { status: "ok", message: { key: BuiltInProbeKeys.searchOk } };
53
64
  },
54
65
  });
@@ -10,6 +10,11 @@ export declare const ReadinessQueries: {
10
10
  export declare const ReadinessWrites: {
11
11
  readonly runProbes: "readiness:write:run-probes";
12
12
  };
13
+ export declare const RUN_OUTCOME: {
14
+ readonly ok: "ok";
15
+ readonly failed: "failed";
16
+ readonly running: "running";
17
+ };
13
18
  export declare const DEFAULT_PROBE_TIMEOUT_MS = 10000;
14
19
  export declare const MIN_STALE_RUN_AFTER_MS: number;
15
20
  export declare const STALE_RUN_TIMEOUT_FACTOR = 3;
@@ -12,6 +12,7 @@ export const ReadinessQueries = {
12
12
  export const ReadinessWrites = {
13
13
  runProbes: "readiness:write:run-probes",
14
14
  };
15
+ export const RUN_OUTCOME = { ok: "ok", failed: "failed", running: "running" };
15
16
  export const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
16
17
  // An active run older than this is treated as dead (worker crash) and may be replaced.
17
18
  export const MIN_STALE_RUN_AFTER_MS = 10 * 60_000;
@@ -8,7 +8,8 @@ export const READINESS_I18N = {
8
8
  "readiness.probe.cache.ok": { en: "Cache reachable" },
9
9
  "readiness.probe.search.label": { en: "Search" },
10
10
  "readiness.probe.search.ok": { en: "Search reachable" },
11
- "readiness.probe.paid-not-requested": { en: "Not run, paid checks were not requested" },
11
+ "readiness.probe.search.unavailable": { en: "The search service does not answer" },
12
+ "readiness.probe.paid-not-requested": { en: "Only via “Test AI extraction”" },
12
13
  "readiness.probe.no-targets": { en: "Nothing to check" },
13
14
  "readiness.probe.timeout": { en: "No answer after {seconds} seconds" },
14
15
  "readiness.probe.failed": { en: "The check failed" },
@@ -27,6 +28,11 @@ export const READINESS_I18N = {
27
28
  en: "Real calls against mail, storage, search and the other connected services. A failed row links to the screen that fixes it.",
28
29
  },
29
30
  "readiness.screen.latest.empty": { en: "No results yet. Start a check." },
31
+ "readiness.screen.latest.running": {
32
+ en: "The check is running. Results appear when it is done.",
33
+ },
34
+ "readiness.screen.latest.nothing-to-check": { en: "There is nothing to check for this tenant." },
35
+ "readiness.screen.latest.abandoned": { en: "The run was stopped before it returned results." },
30
36
  "readiness.screen.history.title": { en: "History" },
31
37
  "readiness.screen.history.description": {
32
38
  en: "Earlier runs, newest first. Open one to see its results.",
@@ -1,3 +1,3 @@
1
- export { READINESS_FEATURE, ReadinessQueries, ReadinessWrites } from "./constants.ts";
1
+ export { ProbeTextKeys, READINESS_FEATURE, ReadinessQueries, ReadinessWrites, } from "./constants.ts";
2
2
  export { createReadinessFeature } from "./feature.ts";
3
3
  export type { ReadinessMissingSecret } from "./handlers/status.query.ts";
@@ -1,2 +1,2 @@
1
- export { READINESS_FEATURE, ReadinessQueries, ReadinessWrites } from "./constants.js";
1
+ export { ProbeTextKeys, READINESS_FEATURE, ReadinessQueries, ReadinessWrites, } from "./constants.js";
2
2
  export { createReadinessFeature } from "./feature.js";
@@ -36,10 +36,10 @@ export declare const probeRunEntity: import("@cosmicdrift/kumiko-types/fields").
36
36
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
37
37
  readonly display?: "radio" | "dropdown";
38
38
  readonly optionTones?: {
39
- readonly abandoned?: "bad" | "neutral" | "ok" | "warn" | undefined;
40
- readonly done?: "bad" | "neutral" | "ok" | "warn" | undefined;
41
- readonly pending?: "bad" | "neutral" | "ok" | "warn" | undefined;
42
- readonly running?: "bad" | "neutral" | "ok" | "warn" | undefined;
39
+ readonly abandoned?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
40
+ readonly done?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
41
+ readonly pending?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
42
+ readonly running?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
43
43
  } | undefined;
44
44
  readonly optionsQuery?: string;
45
45
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -195,10 +195,10 @@ export declare const probeRunsTable: import("@cosmicdrift/kumiko-framework/db").
195
195
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
196
196
  readonly display?: "radio" | "dropdown";
197
197
  readonly optionTones?: {
198
- readonly abandoned?: "bad" | "neutral" | "ok" | "warn" | undefined;
199
- readonly done?: "bad" | "neutral" | "ok" | "warn" | undefined;
200
- readonly pending?: "bad" | "neutral" | "ok" | "warn" | undefined;
201
- readonly running?: "bad" | "neutral" | "ok" | "warn" | undefined;
198
+ readonly abandoned?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
199
+ readonly done?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
200
+ readonly pending?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
201
+ readonly running?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
202
202
  } | undefined;
203
203
  readonly optionsQuery?: string;
204
204
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -9,6 +9,9 @@ export declare const ScreenTextKeys: {
9
9
  readonly latestTitle: "readiness.screen.latest.title";
10
10
  readonly latestDescription: "readiness.screen.latest.description";
11
11
  readonly latestEmpty: "readiness.screen.latest.empty";
12
+ readonly latestRunning: "readiness.screen.latest.running";
13
+ readonly latestNothingToCheck: "readiness.screen.latest.nothing-to-check";
14
+ readonly latestAbandoned: "readiness.screen.latest.abandoned";
12
15
  readonly historyTitle: "readiness.screen.history.title";
13
16
  readonly historyDescription: "readiness.screen.history.description";
14
17
  readonly historyEmpty: "readiness.screen.history.empty";
@@ -1,4 +1,5 @@
1
- import { ReadinessQueries, ReadinessWrites } from "./constants.js";
1
+ import { ReadinessQueries, ReadinessWrites, RUN_OUTCOME } from "./constants.js";
2
+ import { PROBE_RUN_STATUS } from "./schema/probe-run.js";
2
3
  export const SYSTEM_CHECK_SCREEN_ID = "system-check";
3
4
  export const SYSTEM_CHECK_RUN_SCREEN_ID = "system-check-run";
4
5
  export const SYSTEM_CHECK_SCREEN_QN = `readiness:screen:${SYSTEM_CHECK_SCREEN_ID}`;
@@ -9,6 +10,9 @@ export const ScreenTextKeys = {
9
10
  latestTitle: "readiness.screen.latest.title",
10
11
  latestDescription: "readiness.screen.latest.description",
11
12
  latestEmpty: "readiness.screen.latest.empty",
13
+ latestRunning: "readiness.screen.latest.running",
14
+ latestNothingToCheck: "readiness.screen.latest.nothing-to-check",
15
+ latestAbandoned: "readiness.screen.latest.abandoned",
12
16
  historyTitle: "readiness.screen.history.title",
13
17
  historyDescription: "readiness.screen.history.description",
14
18
  historyEmpty: "readiness.screen.history.empty",
@@ -60,7 +64,24 @@ function resultsSection(query, title, extra) {
60
64
  title,
61
65
  query,
62
66
  columns: RESULT_COLUMNS,
63
- emptyState: { title: ScreenTextKeys.latestEmpty },
67
+ emptyState: {
68
+ title: ScreenTextKeys.latestEmpty,
69
+ // A finished run without rows had nothing the caller may see, not "no run yet".
70
+ variants: [
71
+ {
72
+ when: { field: "outcome", eq: RUN_OUTCOME.running },
73
+ title: ScreenTextKeys.latestRunning,
74
+ },
75
+ {
76
+ when: { field: "outcome", eq: RUN_OUTCOME.ok },
77
+ title: ScreenTextKeys.latestNothingToCheck,
78
+ },
79
+ {
80
+ when: { field: "status", eq: PROBE_RUN_STATUS.Abandoned },
81
+ title: ScreenTextKeys.latestAbandoned,
82
+ },
83
+ ],
84
+ },
64
85
  ...extra,
65
86
  };
66
87
  }
@@ -102,6 +123,8 @@ export function systemCheckScreen(dormant) {
102
123
  access: ACCESS,
103
124
  actions: RUN_ACTIONS,
104
125
  primaryAction: "run-check",
126
+ // The paid run is the only way to test AI extraction, so it must not hide in the menu.
127
+ inlineActions: ["run-paid-check"],
105
128
  metrics: RESULT_METRICS,
106
129
  metricsStyle: "tiles",
107
130
  layout: {
@@ -1,5 +1,6 @@
1
1
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import type { RUN_OUTCOME } from "./constants.ts";
3
4
  declare const probeResultRowSchema: z.ZodObject<{
4
5
  probe: z.ZodString;
5
6
  scope: z.ZodEnum<{
@@ -36,7 +37,7 @@ export type VisibleProbeResult = Omit<StoredProbeResult, "label" | "message" | "
36
37
  readonly message: ProbeText;
37
38
  readonly fixScreen?: string;
38
39
  };
39
- export type RunOutcome = "ok" | "failed" | "running";
40
+ export type RunOutcome = (typeof RUN_OUTCOME)[keyof typeof RUN_OUTCOME];
40
41
  export type ResultCounts = {
41
42
  readonly okCount: number;
42
43
  readonly failedCount: number;