@cosmicdrift/kumiko-samples 0.391.1 → 0.392.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
- package/packages/bundled-features/src/cap-overview/changes.json +18 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
- package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
- package/packages/bundled-features/src/cap-overview/types.ts +4 -1
- package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +137 -54
- package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
- package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
- package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +6 -0
- package/packages/bundled-features/src/channel-email/index.ts +5 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
- package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +18 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +158 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +44 -0
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +47 -1
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +52 -0
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
- package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +135 -0
- package/packages/bundled-features/src/legal-pages/README.md +5 -10
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
- package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
- package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
- package/packages/bundled-features/src/readiness/changes.json +30 -0
- package/packages/bundled-features/src/readiness/constants.ts +2 -0
- package/packages/bundled-features/src/readiness/i18n.ts +7 -1
- package/packages/bundled-features/src/readiness/index.ts +6 -1
- package/packages/bundled-features/src/readiness/screens.ts +25 -2
- package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
- package/packages/bundled-features/src/template-resolver/README.md +144 -165
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
- package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
- package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
- package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
- package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
- package/samples/apps/config-demo/src/app/server.ts +4 -4
- package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
- package/samples/apps/marketing-demo/playwright.config.ts +3 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
- package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
- package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
- package/samples/apps/marketing-demo/src/app/server.ts +7 -7
- package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
- package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
- package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
- package/samples/apps/showcase/src/app/client.tsx +3 -3
- package/samples/apps/showcase/src/app/seed-items.ts +14 -14
- package/samples/apps/showcase/src/app/server.ts +7 -7
- package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
- package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
- package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
- package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
- package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
- package/samples/apps/showcase/src/features/items/feature.ts +8 -8
- package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
- package/samples/apps/showcase/src/features/items/index.ts +3 -3
- package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
- package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
- package/samples/apps/styleguide/src/app/seed.ts +3 -3
- package/samples/apps/styleguide/src/app/server.ts +2 -2
- package/samples/apps/styleguide/src/app/shell.tsx +4 -4
- package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
- package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
- package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
- package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
- package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
- package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
- package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
- package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
- package/samples/apps/use-all-bundled/bin/main.ts +5 -5
- package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
- package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
- package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
- package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
- package/samples/apps/workspaces/playwright.config.ts +5 -5
- package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
- package/samples/apps/workspaces/src/app/server.ts +16 -16
- package/samples/apps/workspaces/src/app/shell.tsx +2 -2
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
- package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
- package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
- package/samples/recipes/designer-demo/src/feature.ts +6 -6
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
- package/samples/recipes/lane-routing/src/feature.ts +5 -5
- package/samples/recipes/legal-pages/src/feature.ts +29 -29
- package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
- package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
- package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
- package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
- package/samples/recipes/user-data-rights/src/feature.ts +13 -13
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Stores the copy of a sent mail in the mailbox's sent folder, so the customer finds the reply in
|
|
2
|
+
// their own mail client. Best effort: the mail is already out, a failed copy is only logged.
|
|
3
|
+
|
|
4
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
5
|
+
import type { ImapFlow } from "imapflow";
|
|
6
|
+
import type { ImapCredentialDocument } from "./credential-document.ts";
|
|
7
|
+
import { createImapClient, imapMailHostGuard } from "./imap-client.ts";
|
|
8
|
+
|
|
9
|
+
const log = createFallbackLogger("inbound-provider-imap");
|
|
10
|
+
|
|
11
|
+
const SENT_SPECIAL_USE = "\\Sent";
|
|
12
|
+
const SENT_FALLBACK_PATHS = ["Sent", "Gesendet", "INBOX.Sent"];
|
|
13
|
+
|
|
14
|
+
type ListedMailbox = { readonly path: string; readonly specialUse?: string };
|
|
15
|
+
|
|
16
|
+
export function pickSentMailboxPath(mailboxes: readonly ListedMailbox[]): string | undefined {
|
|
17
|
+
const flagged = mailboxes.find((mailbox) => mailbox.specialUse === SENT_SPECIAL_USE);
|
|
18
|
+
if (flagged) return flagged.path;
|
|
19
|
+
for (const name of SENT_FALLBACK_PATHS) {
|
|
20
|
+
const match = mailboxes.find((mailbox) => mailbox.path.toLowerCase() === name.toLowerCase());
|
|
21
|
+
if (match) return match.path;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function appendToSentFolder(
|
|
27
|
+
doc: ImapCredentialDocument,
|
|
28
|
+
raw: Buffer,
|
|
29
|
+
accountId: string,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
let client: ImapFlow | undefined;
|
|
32
|
+
try {
|
|
33
|
+
client = await createImapClient(doc, imapMailHostGuard());
|
|
34
|
+
await client.connect();
|
|
35
|
+
const path = pickSentMailboxPath(await client.list());
|
|
36
|
+
if (!path) {
|
|
37
|
+
log.warn("mailbox has no sent folder, sent copy not stored", { accountId });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await client.append(path, raw, ["\\Seen"]);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log.warn("storing the sent copy failed", {
|
|
43
|
+
accountId,
|
|
44
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
await client?.logout().catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// nodemailer wiring for sending from a connected mailbox: one short-lived, non-pooled connection
|
|
2
|
+
// per call, TLS enforced (implicit on 465, STARTTLS otherwise), pinned to the address the mail host guard approved.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
BlockedHostError,
|
|
6
|
+
type MailConnectTarget,
|
|
7
|
+
resolveMailConnectTarget,
|
|
8
|
+
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
9
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
10
|
+
import { errorCodeOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
11
|
+
import { createTransport, type Transporter } from "nodemailer";
|
|
12
|
+
import { imapMailHostGuard } from "./imap-client.ts";
|
|
13
|
+
|
|
14
|
+
const log = createFallbackLogger("inbound-provider-imap");
|
|
15
|
+
|
|
16
|
+
export const SMTP_IMPLICIT_TLS_PORT = 465;
|
|
17
|
+
|
|
18
|
+
// nodemailer's defaults wait minutes; the connect form holds a request open meanwhile.
|
|
19
|
+
const CONNECTION_TIMEOUT_MS = 10_000;
|
|
20
|
+
const GREETING_TIMEOUT_MS = 10_000;
|
|
21
|
+
const SOCKET_TIMEOUT_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
export type SmtpLogin = {
|
|
24
|
+
readonly host: string;
|
|
25
|
+
readonly port: number;
|
|
26
|
+
readonly user: string;
|
|
27
|
+
readonly password: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SmtpFailure =
|
|
31
|
+
| "hostUnreachable"
|
|
32
|
+
| "loginRejected"
|
|
33
|
+
| "tlsFailed"
|
|
34
|
+
| "connectFailed"
|
|
35
|
+
| "rejected";
|
|
36
|
+
|
|
37
|
+
// Carries only the category and the library code: the raw error holds the host and the server
|
|
38
|
+
// response, which stay in the server log.
|
|
39
|
+
export class SmtpFailureError extends Error {
|
|
40
|
+
readonly failure: SmtpFailure;
|
|
41
|
+
constructor(failure: SmtpFailure, detail: string) {
|
|
42
|
+
super(`SMTP ${failure}: ${detail}`);
|
|
43
|
+
this.name = "SmtpFailureError";
|
|
44
|
+
this.failure = failure;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const LOGIN_CODES = ["EAUTH", "ENOAUTH"];
|
|
49
|
+
const CONNECTION_CODES = ["ECONNECTION", "ETIMEDOUT", "ESOCKET", "EDNS", "EPROXY"];
|
|
50
|
+
const TLS_CODE = /^(ETLS|ERR_SSL|ERR_TLS)|CERT|SELF_SIGNED|UNABLE_TO_VERIFY/;
|
|
51
|
+
|
|
52
|
+
// nodemailer wraps a TLS handshake error as ESOCKET and keeps the TLS code as originalCode.
|
|
53
|
+
function isTlsFailure(code: string, originalCode: unknown): boolean {
|
|
54
|
+
return TLS_CODE.test(code) || (typeof originalCode === "string" && TLS_CODE.test(originalCode));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function classifySmtpError(err: unknown): SmtpFailure {
|
|
58
|
+
const code = errorCodeOf(err) ?? "";
|
|
59
|
+
if (LOGIN_CODES.includes(code)) return "loginRejected";
|
|
60
|
+
if (isTlsFailure(code, propertyOf(err, "originalCode"))) return "tlsFailed";
|
|
61
|
+
if (CONNECTION_CODES.includes(code)) return "connectFailed";
|
|
62
|
+
if (typeof propertyOf(err, "responseCode") === "number") return "rejected";
|
|
63
|
+
return "connectFailed";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function smtpTransportOptions(login: SmtpLogin, target: MailConnectTarget) {
|
|
67
|
+
return {
|
|
68
|
+
host: target.host,
|
|
69
|
+
port: login.port,
|
|
70
|
+
secure: login.port === SMTP_IMPLICIT_TLS_PORT,
|
|
71
|
+
// Without it nodemailer sends AUTH in clear text when the server offers no STARTTLS.
|
|
72
|
+
...(login.port !== SMTP_IMPLICIT_TLS_PORT && { requireTLS: true }),
|
|
73
|
+
...(target.servername && { servername: target.servername }),
|
|
74
|
+
auth: { user: login.user, pass: login.password },
|
|
75
|
+
connectionTimeout: CONNECTION_TIMEOUT_MS,
|
|
76
|
+
greetingTimeout: GREETING_TIMEOUT_MS,
|
|
77
|
+
socketTimeout: SOCKET_TIMEOUT_MS,
|
|
78
|
+
// The message is composed by us; nothing in it may make nodemailer read files or URLs.
|
|
79
|
+
disableFileAccess: true,
|
|
80
|
+
disableUrlAccess: true,
|
|
81
|
+
logger: false,
|
|
82
|
+
debug: false,
|
|
83
|
+
} as const;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function openSmtpTransport(login: SmtpLogin): Promise<Transporter> {
|
|
87
|
+
let target: MailConnectTarget;
|
|
88
|
+
try {
|
|
89
|
+
target = await resolveMailConnectTarget(login.host, imapMailHostGuard());
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
92
|
+
const blocked = err instanceof BlockedHostError;
|
|
93
|
+
log.warn(blocked ? "rejected blocked smtp host" : "smtp host resolution failed", {
|
|
94
|
+
host: login.host,
|
|
95
|
+
reason,
|
|
96
|
+
});
|
|
97
|
+
throw new SmtpFailureError("hostUnreachable", blocked ? "blocked" : "unresolved");
|
|
98
|
+
}
|
|
99
|
+
return createTransport(smtpTransportOptions(login, target));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// No error message in the log: a server's reply to a failed AUTH can echo the user name.
|
|
103
|
+
function smtpFailureOf(err: unknown, host: string): SmtpFailureError {
|
|
104
|
+
const failure = classifySmtpError(err);
|
|
105
|
+
const code = errorCodeOf(err) ?? "unknown";
|
|
106
|
+
const responseCode = propertyOf(err, "responseCode");
|
|
107
|
+
log.warn("smtp call failed", { host, failure, code, responseCode });
|
|
108
|
+
return new SmtpFailureError(failure, code);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function verifySmtpLogin(login: SmtpLogin): Promise<void> {
|
|
112
|
+
const transport = await openSmtpTransport(login);
|
|
113
|
+
try {
|
|
114
|
+
await transport.verify();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw smtpFailureOf(err, login.host);
|
|
117
|
+
} finally {
|
|
118
|
+
transport.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function sendRawViaSmtp(
|
|
123
|
+
login: SmtpLogin,
|
|
124
|
+
envelope: { readonly from: string; readonly to: readonly string[] },
|
|
125
|
+
raw: Buffer,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const transport = await openSmtpTransport(login);
|
|
128
|
+
try {
|
|
129
|
+
await transport.sendMail({ envelope: { from: envelope.from, to: [...envelope.to] }, raw });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
throw smtpFailureOf(err, login.host);
|
|
132
|
+
} finally {
|
|
133
|
+
transport.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -251,10 +251,8 @@ import { legalContentSeed } from "@cosmicdrift/kumiko-bundled-features/legal-pag
|
|
|
251
251
|
runProdApp({ /* ... */ seeds: [legalContentSeed({ blocks: legalContent })] });
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
-
Templates for imprint + privacy policy:
|
|
255
|
-
|
|
256
|
-
and vetted external generators (e-recht24.de,
|
|
257
|
-
datenschutz-generator.de).
|
|
254
|
+
Templates for imprint + privacy policy: use vetted external generators
|
|
255
|
+
(e-recht24.de, datenschutz-generator.de).
|
|
258
256
|
|
|
259
257
|
---
|
|
260
258
|
|
|
@@ -290,11 +288,8 @@ and put your own routes in front.
|
|
|
290
288
|
|
|
291
289
|
## Architecture cross-refs
|
|
292
290
|
|
|
293
|
-
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
— templates + where-is-what for imprint/AVV/TOMs/RoPA
|
|
297
|
-
- [docs/plans/datenschutz/compliance-as-product.md](../../../../docs/plans/datenschutz/compliance-as-product.md)
|
|
298
|
-
— roadmap for auto-generation (sub-processor list, TOMs, data-breach workflow)
|
|
291
|
+
- Privacy planning (templates and where-is-what for imprint/AVV/TOMs/RoPA, plus the
|
|
292
|
+
roadmap for auto-generation of the sub-processor list, TOMs and data-breach
|
|
293
|
+
workflow) lives in the private platform repo.
|
|
299
294
|
- [samples/recipes/legal-pages/](../../../../samples/recipes/legal-pages/)
|
|
300
295
|
— live sample with both features wired up
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The mail probe tests the platform sender",
|
|
6
|
+
"detail": "A tenant without its own mail provider now gets the test mail through the channel-email app transport, the sender its tenant-transport notifications fall back to. The probe reports mail-foundation.probe.send-test-mail.platform-accepted on success and mail-foundation.probe.platform-send-rejected when the platform server rejects it; it is skipped only when neither an own provider nor an app transport exists, and mail-foundation.probe.not-configured now reads \"No email sending set up\". New keys in all locale packages."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.391.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -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
|
} as const;
|
|
12
14
|
|
|
13
15
|
export const MAIL_FOUNDATION_I18N = {
|
|
@@ -21,7 +23,10 @@ export const MAIL_FOUNDATION_I18N = {
|
|
|
21
23
|
[MAIL_PROBE_KEYS.area]: { en: "Email" },
|
|
22
24
|
[MAIL_PROBE_KEYS.label]: { en: "Test email to the person who started the check" },
|
|
23
25
|
[MAIL_PROBE_KEYS.accepted]: { en: "Accepted by the mail server" },
|
|
24
|
-
[MAIL_PROBE_KEYS.
|
|
26
|
+
[MAIL_PROBE_KEYS.platformAccepted]: {
|
|
27
|
+
en: "Sent via the platform sender, accepted by its mail server",
|
|
28
|
+
},
|
|
29
|
+
[MAIL_PROBE_KEYS.notConfigured]: { en: "No email sending set up" },
|
|
25
30
|
[MAIL_PROBE_KEYS.noRecipient]: {
|
|
26
31
|
en: "No email address known for the person who started the check",
|
|
27
32
|
},
|
|
@@ -33,4 +38,7 @@ export const MAIL_FOUNDATION_I18N = {
|
|
|
33
38
|
[MAIL_PROBE_KEYS.sendRejected]: {
|
|
34
39
|
en: "The mail server did not accept the test email, check address, port and login",
|
|
35
40
|
},
|
|
41
|
+
[MAIL_PROBE_KEYS.platformSendRejected]: {
|
|
42
|
+
en: "The platform mail server did not accept the test email",
|
|
43
|
+
},
|
|
36
44
|
} as const;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EmailTransport,
|
|
3
|
+
findEmailAppTransport,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
1
5
|
import {
|
|
2
6
|
createSystemUser,
|
|
3
7
|
type FeatureRegistrar,
|
|
@@ -30,6 +34,36 @@ function transportFailureKey(error: unknown): string {
|
|
|
30
34
|
: MAIL_PROBE_KEYS.setupFailed;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
async function ownTenantTransport(ctx: ProbeContext): Promise<EmailTransport | null> {
|
|
38
|
+
const { configResolver, secrets } = ctx.services;
|
|
39
|
+
if (!configResolver) return null;
|
|
40
|
+
const config = createConfigAccessor(
|
|
41
|
+
ctx.registry,
|
|
42
|
+
configResolver,
|
|
43
|
+
ctx.tenantId,
|
|
44
|
+
createSystemUser(ctx.tenantId).id,
|
|
45
|
+
ctx.db,
|
|
46
|
+
);
|
|
47
|
+
return tenantMailTransport({
|
|
48
|
+
tenantId: ctx.tenantId,
|
|
49
|
+
config,
|
|
50
|
+
registry: ctx.registry,
|
|
51
|
+
secrets,
|
|
52
|
+
}).catch((error: unknown) => {
|
|
53
|
+
throw probeFailure(ctx, error, transportFailureKey(error));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Same order as channel-email for tenant mails: the tenant's own provider, else the app sender.
|
|
58
|
+
async function probeTransport(
|
|
59
|
+
ctx: ProbeContext,
|
|
60
|
+
): Promise<{ readonly transport: EmailTransport; readonly viaPlatform: boolean } | null> {
|
|
61
|
+
const own = await ownTenantTransport(ctx);
|
|
62
|
+
if (own !== null) return { transport: own, viaPlatform: false };
|
|
63
|
+
const platform = findEmailAppTransport(ctx.registry);
|
|
64
|
+
return platform === null ? null : { transport: platform, viaPlatform: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
// The mail goes to whoever started the check; the address never appears in the stored result.
|
|
34
68
|
export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void {
|
|
35
69
|
r.probe({
|
|
@@ -40,31 +74,14 @@ export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void
|
|
|
40
74
|
label: MAIL_PROBE_KEYS.label,
|
|
41
75
|
fixScreen: MAIL_SETTINGS_SCREEN_QN,
|
|
42
76
|
run: async (ctx) => {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
|
|
46
|
-
}
|
|
47
|
-
const config = createConfigAccessor(
|
|
48
|
-
ctx.registry,
|
|
49
|
-
configResolver,
|
|
50
|
-
ctx.tenantId,
|
|
51
|
-
createSystemUser(ctx.tenantId).id,
|
|
52
|
-
ctx.db,
|
|
53
|
-
);
|
|
54
|
-
const transport = await tenantMailTransport({
|
|
55
|
-
tenantId: ctx.tenantId,
|
|
56
|
-
config,
|
|
57
|
-
registry: ctx.registry,
|
|
58
|
-
secrets,
|
|
59
|
-
}).catch((error: unknown) => {
|
|
60
|
-
throw probeFailure(ctx, error, transportFailureKey(error));
|
|
61
|
-
});
|
|
62
|
-
if (transport === null) {
|
|
77
|
+
const resolved = await probeTransport(ctx);
|
|
78
|
+
if (resolved === null) {
|
|
63
79
|
return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
|
|
64
80
|
}
|
|
65
81
|
if (ctx.triggeredByEmail === undefined) {
|
|
66
82
|
return { status: "skipped", message: { key: MAIL_PROBE_KEYS.noRecipient } };
|
|
67
83
|
}
|
|
84
|
+
const { transport, viaPlatform } = resolved;
|
|
68
85
|
await transport
|
|
69
86
|
.send({
|
|
70
87
|
to: ctx.triggeredByEmail,
|
|
@@ -73,9 +90,13 @@ export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void
|
|
|
73
90
|
html: `<p>${PROBE_TEXT}</p>`,
|
|
74
91
|
})
|
|
75
92
|
.catch((error: unknown) => {
|
|
76
|
-
|
|
93
|
+
const key = viaPlatform
|
|
94
|
+
? MAIL_PROBE_KEYS.platformSendRejected
|
|
95
|
+
: MAIL_PROBE_KEYS.sendRejected;
|
|
96
|
+
throw probeFailure(ctx, error, key);
|
|
77
97
|
});
|
|
78
|
-
|
|
98
|
+
const accepted = viaPlatform ? MAIL_PROBE_KEYS.platformAccepted : MAIL_PROBE_KEYS.accepted;
|
|
99
|
+
return { status: "ok", message: { key: accepted } };
|
|
79
100
|
},
|
|
80
101
|
});
|
|
81
102
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type FeatureRegistrar,
|
|
3
|
+
type ProbeContext,
|
|
4
|
+
ProbeFailure,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
6
|
import { ProbeTextKeys } from "./constants.ts";
|
|
3
7
|
import { probeRunsTable } from "./schema/probe-run.ts";
|
|
4
8
|
|
|
@@ -10,6 +14,7 @@ export const BuiltInProbeKeys = {
|
|
|
10
14
|
cacheOk: "readiness.probe.cache.ok",
|
|
11
15
|
searchLabel: "readiness.probe.search.label",
|
|
12
16
|
searchOk: "readiness.probe.search.ok",
|
|
17
|
+
searchUnavailable: "readiness.probe.search.unavailable",
|
|
13
18
|
} as const;
|
|
14
19
|
|
|
15
20
|
export function registerBuiltInProbes(r: FeatureRegistrar<"readiness">): void {
|
|
@@ -51,7 +56,15 @@ export function registerBuiltInProbes(r: FeatureRegistrar<"readiness">): void {
|
|
|
51
56
|
if (!searchAdapter) {
|
|
52
57
|
return { status: "skipped", message: { key: ProbeTextKeys.notConfigured } };
|
|
53
58
|
}
|
|
54
|
-
|
|
59
|
+
try {
|
|
60
|
+
await searchAdapter.search(ctx.tenantId, "readiness-probe", { limit: 1 });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// The raw error can name the search host, so it stays in the server log.
|
|
63
|
+
ctx.obs.log.warn("readiness search probe failed", {
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
});
|
|
66
|
+
throw new ProbeFailure({ key: BuiltInProbeKeys.searchUnavailable });
|
|
67
|
+
}
|
|
55
68
|
return { status: "ok", message: { key: BuiltInProbeKeys.searchOk } };
|
|
56
69
|
},
|
|
57
70
|
});
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The paid run is a button next to Start check",
|
|
6
|
+
"detail": "The system check screen declares run-paid-check as an inline action, so \"Test AI extraction\" shows as a button beside \"Start check\" instead of in the \"More actions\" menu. It stays hidden while the app has no paid probes."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.392.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "The readiness entry exports ProbeTextKeys",
|
|
12
|
+
"detail": "ProbeTextKeys comes from @cosmicdrift/kumiko-bundled-features/readiness next to ReadinessQueries and ReadinessWrites, so apps can name probe texts without a deep import."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.392.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "The paid probe hint names the button",
|
|
18
|
+
"detail": "readiness.probe.paid-not-requested now reads \"Only via “Test AI extraction”\" (de „Nur über „KI-Auslesen testen““), the label of the button that starts the paid run."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.392.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "The search probe fails with its own text",
|
|
24
|
+
"detail": "When the search service throws, the search probe fails with readiness.probe.search.unavailable (\"The search service does not answer\"). The raw error can name the search host, so it goes only to the server log. New key in all locale packages."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.392.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "An empty latest run says why it is empty",
|
|
30
|
+
"detail": "The latest results and the run detail show \"The check is running. Results appear when it is done.\" while a run is running and \"There is nothing to check for this tenant.\" when a finished run has no rows the caller may see, and \"The run was stopped before it returned results.\" for an abandoned run. Without a run the old text stays. New keys readiness.screen.latest.running, readiness.screen.latest.nothing-to-check and readiness.screen.latest.abandoned in all locale packages."
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.391.0",
|
|
4
34
|
"type": "improvement",
|
|
@@ -15,6 +15,8 @@ export const ReadinessWrites = {
|
|
|
15
15
|
runProbes: "readiness:write:run-probes",
|
|
16
16
|
} as const;
|
|
17
17
|
|
|
18
|
+
export const RUN_OUTCOME = { ok: "ok", failed: "failed", running: "running" } as const;
|
|
19
|
+
|
|
18
20
|
export const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
|
|
19
21
|
|
|
20
22
|
// An active run older than this is treated as dead (worker crash) and may be replaced.
|
|
@@ -10,7 +10,8 @@ export const READINESS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
10
10
|
"readiness.probe.cache.ok": { en: "Cache reachable" },
|
|
11
11
|
"readiness.probe.search.label": { en: "Search" },
|
|
12
12
|
"readiness.probe.search.ok": { en: "Search reachable" },
|
|
13
|
-
"readiness.probe.
|
|
13
|
+
"readiness.probe.search.unavailable": { en: "The search service does not answer" },
|
|
14
|
+
"readiness.probe.paid-not-requested": { en: "Only via “Test AI extraction”" },
|
|
14
15
|
"readiness.probe.no-targets": { en: "Nothing to check" },
|
|
15
16
|
"readiness.probe.timeout": { en: "No answer after {seconds} seconds" },
|
|
16
17
|
"readiness.probe.failed": { en: "The check failed" },
|
|
@@ -29,6 +30,11 @@ export const READINESS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
29
30
|
en: "Real calls against mail, storage, search and the other connected services. A failed row links to the screen that fixes it.",
|
|
30
31
|
},
|
|
31
32
|
"readiness.screen.latest.empty": { en: "No results yet. Start a check." },
|
|
33
|
+
"readiness.screen.latest.running": {
|
|
34
|
+
en: "The check is running. Results appear when it is done.",
|
|
35
|
+
},
|
|
36
|
+
"readiness.screen.latest.nothing-to-check": { en: "There is nothing to check for this tenant." },
|
|
37
|
+
"readiness.screen.latest.abandoned": { en: "The run was stopped before it returned results." },
|
|
32
38
|
"readiness.screen.history.title": { en: "History" },
|
|
33
39
|
"readiness.screen.history.description": {
|
|
34
40
|
en: "Earlier runs, newest first. Open one to see its results.",
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
ProbeTextKeys,
|
|
3
|
+
READINESS_FEATURE,
|
|
4
|
+
ReadinessQueries,
|
|
5
|
+
ReadinessWrites,
|
|
6
|
+
} from "./constants.ts";
|
|
2
7
|
export { createReadinessFeature } from "./feature.ts";
|
|
3
8
|
export type { ReadinessMissingSecret } from "./handlers/status.query.ts";
|
|
@@ -4,7 +4,8 @@ import type {
|
|
|
4
4
|
ProjectionDetailScreenDefinition,
|
|
5
5
|
RowAction,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
-
import { ReadinessQueries, ReadinessWrites } from "./constants.ts";
|
|
7
|
+
import { ReadinessQueries, ReadinessWrites, RUN_OUTCOME } from "./constants.ts";
|
|
8
|
+
import { PROBE_RUN_STATUS } from "./schema/probe-run.ts";
|
|
8
9
|
|
|
9
10
|
export const SYSTEM_CHECK_SCREEN_ID = "system-check";
|
|
10
11
|
export const SYSTEM_CHECK_RUN_SCREEN_ID = "system-check-run";
|
|
@@ -17,6 +18,9 @@ export const ScreenTextKeys = {
|
|
|
17
18
|
latestTitle: "readiness.screen.latest.title",
|
|
18
19
|
latestDescription: "readiness.screen.latest.description",
|
|
19
20
|
latestEmpty: "readiness.screen.latest.empty",
|
|
21
|
+
latestRunning: "readiness.screen.latest.running",
|
|
22
|
+
latestNothingToCheck: "readiness.screen.latest.nothing-to-check",
|
|
23
|
+
latestAbandoned: "readiness.screen.latest.abandoned",
|
|
20
24
|
historyTitle: "readiness.screen.history.title",
|
|
21
25
|
historyDescription: "readiness.screen.history.description",
|
|
22
26
|
historyEmpty: "readiness.screen.history.empty",
|
|
@@ -76,7 +80,24 @@ function resultsSection(
|
|
|
76
80
|
title,
|
|
77
81
|
query,
|
|
78
82
|
columns: RESULT_COLUMNS,
|
|
79
|
-
emptyState: {
|
|
83
|
+
emptyState: {
|
|
84
|
+
title: ScreenTextKeys.latestEmpty,
|
|
85
|
+
// A finished run without rows had nothing the caller may see, not "no run yet".
|
|
86
|
+
variants: [
|
|
87
|
+
{
|
|
88
|
+
when: { field: "outcome", eq: RUN_OUTCOME.running },
|
|
89
|
+
title: ScreenTextKeys.latestRunning,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
when: { field: "outcome", eq: RUN_OUTCOME.ok },
|
|
93
|
+
title: ScreenTextKeys.latestNothingToCheck,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
when: { field: "status", eq: PROBE_RUN_STATUS.Abandoned },
|
|
97
|
+
title: ScreenTextKeys.latestAbandoned,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
80
101
|
...extra,
|
|
81
102
|
};
|
|
82
103
|
}
|
|
@@ -121,6 +142,8 @@ export function systemCheckScreen(dormant: boolean): ProjectionDetailScreenDefin
|
|
|
121
142
|
access: ACCESS,
|
|
122
143
|
actions: RUN_ACTIONS,
|
|
123
144
|
primaryAction: "run-check",
|
|
145
|
+
// The paid run is the only way to test AI extraction, so it must not hide in the menu.
|
|
146
|
+
inlineActions: ["run-paid-check"],
|
|
124
147
|
metrics: RESULT_METRICS,
|
|
125
148
|
metricsStyle: "tiles",
|
|
126
149
|
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
|
import { PROBE_RUN_STATUS } from "./schema/probe-run.ts";
|
|
4
5
|
|
|
5
6
|
const probeTextSchema = z.object({
|
|
@@ -38,7 +39,7 @@ export type VisibleProbeResult = Omit<StoredProbeResult, "label" | "message" | "
|
|
|
38
39
|
readonly fixScreen?: string;
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
export type RunOutcome =
|
|
42
|
+
export type RunOutcome = (typeof RUN_OUTCOME)[keyof typeof RUN_OUTCOME];
|
|
42
43
|
|
|
43
44
|
export type ResultCounts = {
|
|
44
45
|
readonly okCount: number;
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# renderer-foundation
|
|
2
2
|
|
|
3
|
-
Plugin
|
|
4
|
-
|
|
5
|
-
**Plan-Doc:** [`kumiko-platform/docs/plans/features/renderer-foundation.md`](../../../../../../kumiko-platform/docs/plans/features/renderer-foundation.md)
|
|
3
|
+
Plugin foundation for renderers (HTML email, PDF, image). Plugins (`renderer-mail-html`, `renderer-puppeteer-client`) register via `r.useExtension("renderer", "<name>", { kinds, render })`.
|
|
6
4
|
|
|
7
5
|
## Mount
|
|
8
6
|
|
|
@@ -17,7 +15,7 @@ const features = [
|
|
|
17
15
|
createTemplateResolverFeature(),
|
|
18
16
|
createRendererFoundationFeature(),
|
|
19
17
|
createRendererMailHtmlFeature(), // Plugin: kind "mail"
|
|
20
|
-
//
|
|
18
|
+
// more plugins...
|
|
21
19
|
];
|
|
22
20
|
|
|
23
21
|
const app = createKumikoApp({
|
|
@@ -30,7 +28,7 @@ const app = createKumikoApp({
|
|
|
30
28
|
});
|
|
31
29
|
```
|
|
32
30
|
|
|
33
|
-
##
|
|
31
|
+
## Consumption
|
|
34
32
|
|
|
35
33
|
```typescript
|
|
36
34
|
import { requireRendererFoundation } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
@@ -49,18 +47,18 @@ async function sendMail(ctx, tenantId) {
|
|
|
49
47
|
}
|
|
50
48
|
```
|
|
51
49
|
|
|
52
|
-
Plugins
|
|
50
|
+
Plugins without service dependencies ignore the second `ctx` parameter.
|
|
53
51
|
|
|
54
|
-
|
|
52
|
+
Emails from an `r.notification` are rendered by the delivery itself: `template` and `meta` on the notification are seeded at boot (`seedNotificationTemplates`) and rendered with kind `"mail"`. Without `template` and `meta`, sending through the email channel fails.
|
|
55
53
|
|
|
56
|
-
## Plugin
|
|
54
|
+
## Plugin Selection Order
|
|
57
55
|
|
|
58
|
-
1. Tenant
|
|
59
|
-
2. `DEFAULT_PLUGIN_BY_KIND`
|
|
60
|
-
3.
|
|
61
|
-
4. `RendererError("no_plugin_for_kind")`
|
|
56
|
+
1. Tenant override (config key `rendererPluginByKind`, for example `{ "mail": "mail-html" }`)
|
|
57
|
+
2. `DEFAULT_PLUGIN_BY_KIND` from constants
|
|
58
|
+
3. First plugin in the pool that handles the kind
|
|
59
|
+
4. `RendererError("no_plugin_for_kind")` if nothing matches
|
|
62
60
|
|
|
63
|
-
##
|
|
61
|
+
## Writing a Custom Plugin
|
|
64
62
|
|
|
65
63
|
```typescript
|
|
66
64
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -69,19 +67,19 @@ export const myRendererFeature = defineFeature("renderer-myown", (r) => {
|
|
|
69
67
|
r.requires({ features: ["renderer-foundation"] });
|
|
70
68
|
r.useExtension("renderer", "myown", {
|
|
71
69
|
kinds: ["document-pdf"],
|
|
72
|
-
// ctx
|
|
70
|
+
// ctx is optional, only use it when service access is required
|
|
73
71
|
render: async (req, ctx) => {
|
|
74
|
-
// ctx.db, ctx.registry, ctx.tenantId
|
|
75
|
-
//
|
|
72
|
+
// ctx.db, ctx.registry, ctx.tenantId available
|
|
73
|
+
// custom PDF logic
|
|
76
74
|
return { kind: "document-pdf", pdfBytes: ..., pageCount: 1, sizeBytes: ... };
|
|
77
75
|
},
|
|
78
76
|
});
|
|
79
77
|
});
|
|
80
78
|
```
|
|
81
79
|
|
|
82
|
-
## Out
|
|
80
|
+
## Out of Scope
|
|
83
81
|
|
|
84
|
-
- Template
|
|
85
|
-
- Resource
|
|
86
|
-
- Template
|
|
87
|
-
-
|
|
82
|
+
- Template storage (provided by `template-resolver`)
|
|
83
|
+
- Resource URL substitution (caller's responsibility: signed URL versus data URI depending on the kind)
|
|
84
|
+
- Template authoring UI, `designer` bundle (planned)
|
|
85
|
+
- Email delivery, `delivery` + `mail-transport-smtp`
|