@k2b/cloud 0.25.0 → 0.27.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 (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. package/src/styles/effects.css +69 -0
@@ -0,0 +1,286 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { listApps } from "../../_internal/registry";
3
+ import {
4
+ type AdminMailApp,
5
+ type AdminMailProfile,
6
+ type MailAppAccess,
7
+ MailAppAccessSchema,
8
+ type MailErrorCode,
9
+ type MailProfile,
10
+ type MailProfileInput,
11
+ MailProfileInputSchema,
12
+ MailProfileKeySchema,
13
+ } from "../../contracts/outgoing-mail";
14
+ import type { AppRegistryEntry } from "../../contracts/registry";
15
+ import { type AuditActor, audit } from "../audit";
16
+ import { toPgTextArray } from "../postgres";
17
+ import { decryptValue, encryptValue } from "../settings/crypto";
18
+
19
+ export class OutgoingMailError extends Error {
20
+ constructor(
21
+ public readonly code: MailErrorCode,
22
+ message: string,
23
+ public readonly status: 400 | 404 | 409 | 502 = 400,
24
+ ) {
25
+ super(message);
26
+ }
27
+ }
28
+ export type MailAuditContext = { actor: AuditActor; requestId?: string };
29
+ type ProfileRow = {
30
+ id: string;
31
+ key: string;
32
+ name: string;
33
+ from_address: string;
34
+ from_name: string | null;
35
+ smtp_host: string;
36
+ smtp_port: number;
37
+ smtp_secure: boolean;
38
+ smtp_user: string | null;
39
+ has_password: boolean;
40
+ pace_per_minute: number;
41
+ daily_recipient_limit: number | null;
42
+ max_attachment_bytes: number;
43
+ is_default: boolean;
44
+ revision: number;
45
+ created_at: Date | string;
46
+ updated_at: Date | string;
47
+ updated_by: string | null;
48
+ };
49
+ const columns = (db: SQL) => db`id, key, name, from_address, from_name, smtp_host, smtp_port, smtp_secure, smtp_user,
50
+ (smtp_password_encrypted IS NOT NULL) AS has_password, pace_per_minute, daily_recipient_limit, max_attachment_bytes,
51
+ is_default, revision, created_at, updated_at, updated_by`;
52
+ const timestamp = (value: Date | string) => new Date(value).toISOString();
53
+ const keyValue = (key: string) => {
54
+ const result = MailProfileKeySchema.safeParse(key);
55
+ if (!result.success) throw new OutgoingMailError("invalid_profile", "Invalid profile key.");
56
+ return result.data;
57
+ };
58
+ const unknown = (): never => {
59
+ throw new OutgoingMailError("profile_unknown", "Outgoing mail profile does not exist.", 404);
60
+ };
61
+ const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
62
+ sql.begin(async (tx) => {
63
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
64
+ return run(tx);
65
+ });
66
+ const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
67
+ audit.record(
68
+ {
69
+ ...context,
70
+ action: `outgoing_mail.${action}`,
71
+ outcome: "allowed",
72
+ target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
73
+ },
74
+ db,
75
+ );
76
+ const accessRows = async (db: SQL) => {
77
+ const access = await db<{ app_id: string; mode: "default" | "selected" }[]>`SELECT app_id, mode FROM outgoing_mail.app_access`;
78
+ const grants = await db<
79
+ { app_id: string; key: string }[]
80
+ >`SELECT ap.app_id, p.key FROM outgoing_mail.app_profiles ap JOIN outgoing_mail.profiles p ON p.id = ap.profile_id ORDER BY p.key`;
81
+ return { access, grants };
82
+ };
83
+ const mapProfile = (row: ProfileRow, appCount: number): AdminMailProfile => ({
84
+ key: row.key,
85
+ name: row.name,
86
+ fromAddress: row.from_address,
87
+ fromName: row.from_name,
88
+ smtpHost: row.smtp_host,
89
+ smtpPort: row.smtp_port,
90
+ smtpSecure: row.smtp_secure,
91
+ smtpUser: row.smtp_user,
92
+ hasPassword: row.has_password,
93
+ pacePerMinute: row.pace_per_minute,
94
+ dailyRecipientLimit: row.daily_recipient_limit,
95
+ maxAttachmentBytes: row.max_attachment_bytes,
96
+ isDefault: row.is_default,
97
+ revision: row.revision,
98
+ createdAt: timestamp(row.created_at),
99
+ updatedAt: timestamp(row.updated_at),
100
+ updatedBy: row.updated_by,
101
+ appCount,
102
+ });
103
+ const list = async (db: SQL = sql, registeredApps?: readonly AppRegistryEntry[]): Promise<AdminMailProfile[]> => {
104
+ const apps = registeredApps ?? (await listApps());
105
+ const rows = await db<ProfileRow[]>`SELECT ${columns(db)} FROM outgoing_mail.profiles ORDER BY is_default DESC, name, key`;
106
+ const { access, grants } = await accessRows(db);
107
+ const selected = new Set(access.filter((a) => a.mode === "selected").map((a) => a.app_id));
108
+ const defaults = apps.filter((app) => app.platformPermissions?.includes("mail:send") && !selected.has(app.id));
109
+ return rows.map((row) =>
110
+ mapProfile(row, grants.filter((g) => g.key === row.key && selected.has(g.app_id)).length + (row.is_default ? defaults.length : 0)),
111
+ );
112
+ };
113
+ const get = async (key: string): Promise<AdminMailProfile> => {
114
+ keyValue(key);
115
+ return (await list()).find((profile) => profile.key === key) ?? unknown();
116
+ };
117
+ const put = async (
118
+ key: string,
119
+ value: MailProfileInput,
120
+ context: MailAuditContext,
121
+ ): Promise<{ profile: AdminMailProfile; created: boolean }> => {
122
+ keyValue(key);
123
+ const parsed = MailProfileInputSchema.safeParse(value);
124
+ if (!parsed.success) throw new OutgoingMailError("invalid_profile", "Invalid outgoing mail profile configuration.");
125
+ const input = parsed.data;
126
+ const apps = await listApps();
127
+ return locked(async (tx) => {
128
+ const [before] = await tx<ProfileRow[]>`SELECT ${columns(tx)} FROM outgoing_mail.profiles WHERE key = ${key}`;
129
+ if (before && input.revision === undefined)
130
+ throw new OutgoingMailError("profile_exists", "Profile exists; provide its current revision.", 409);
131
+ if (input.revision !== undefined && (!before || input.revision !== before.revision))
132
+ throw new OutgoingMailError("revision_conflict", "Profile revision changed; reload before replacing it.", 409);
133
+ if (
134
+ before?.has_password &&
135
+ input.smtpPassword === undefined &&
136
+ before.smtp_host.trim().toLowerCase() !== input.smtpHost.trim().toLowerCase()
137
+ )
138
+ throw new OutgoingMailError("invalid_profile", "Enter the SMTP password again, or remove it, when you change the SMTP host.");
139
+ const password =
140
+ input.smtpPassword === undefined ? undefined : input.smtpPassword === null ? null : await encryptValue(input.smtpPassword);
141
+ if (before) {
142
+ await tx`UPDATE outgoing_mail.profiles SET name = ${input.name}, from_address = ${input.fromAddress}, from_name = ${input.fromName},
143
+ smtp_host = ${input.smtpHost}, smtp_port = ${input.smtpPort}, smtp_secure = ${input.smtpSecure}, smtp_user = ${input.smtpUser},
144
+ smtp_password_encrypted = CASE WHEN ${password !== undefined} THEN ${password ?? null} ELSE smtp_password_encrypted END,
145
+ pace_per_minute = ${input.pacePerMinute}, daily_recipient_limit = ${input.dailyRecipientLimit}, max_attachment_bytes = ${input.maxAttachmentBytes},
146
+ revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE key = ${key}`;
147
+ } else {
148
+ await tx`INSERT INTO outgoing_mail.profiles(id, key, name, from_address, from_name, smtp_host, smtp_port, smtp_secure, smtp_user,
149
+ smtp_password_encrypted, pace_per_minute, daily_recipient_limit, max_attachment_bytes, is_default, updated_by)
150
+ VALUES (${crypto.randomUUID()}::uuid, ${key}, ${input.name}, ${input.fromAddress}, ${input.fromName}, ${input.smtpHost}, ${input.smtpPort},
151
+ ${input.smtpSecure}, ${input.smtpUser}, ${password ?? null}, ${input.pacePerMinute}, ${input.dailyRecipientLimit}, ${input.maxAttachmentBytes},
152
+ NOT EXISTS(SELECT 1 FROM outgoing_mail.profiles), ${context.actor.userId ?? null})`;
153
+ }
154
+ await record(tx, context, before ? "profile.update" : "profile.create", key);
155
+ return { profile: (await list(tx, apps)).find((p) => p.key === key)!, created: !before };
156
+ });
157
+ };
158
+ const setDefault = async (key: string, context: MailAuditContext): Promise<AdminMailProfile> => {
159
+ keyValue(key);
160
+ const apps = await listApps();
161
+ return locked(async (tx) => {
162
+ const [target] = await tx<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE key = ${key}`;
163
+ if (!target) unknown();
164
+ await tx`UPDATE outgoing_mail.profiles SET is_default = false, revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE is_default AND key <> ${key}`;
165
+ await tx`UPDATE outgoing_mail.profiles SET is_default = true, revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE key = ${key} AND NOT is_default`;
166
+ await record(tx, context, "profile.set_default", key);
167
+ return (await list(tx, apps)).find((p) => p.key === key)!;
168
+ });
169
+ };
170
+ const remove = async (key: string, context: MailAuditContext): Promise<void> => {
171
+ keyValue(key);
172
+ await locked(async (tx) => {
173
+ const [before] = await tx<{ is_default: boolean }[]>`SELECT is_default FROM outgoing_mail.profiles WHERE key = ${key}`;
174
+ if (!before) return unknown();
175
+ if (before.is_default)
176
+ throw new OutgoingMailError("profile_is_default", "Choose another default profile before deleting this one.", 409);
177
+ await tx`DELETE FROM outgoing_mail.profiles WHERE key = ${key}`;
178
+ await record(tx, context, "profile.delete", key);
179
+ });
180
+ };
181
+ const appsState = async (
182
+ db: SQL = sql,
183
+ registeredApps?: readonly AppRegistryEntry[],
184
+ ): Promise<{ defaultProfile: string | null; items: AdminMailApp[] }> => {
185
+ const registered = registeredApps ?? (await listApps());
186
+ const { access, grants } = await accessRows(db);
187
+ const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
188
+ const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id)]);
189
+ const items = [...ids]
190
+ .map((appId): AdminMailApp => {
191
+ const app = registered.find((a) => a.id === appId);
192
+ const mode = access.find((a) => a.app_id === appId)?.mode ?? "default";
193
+ return {
194
+ appId,
195
+ name: app?.name ?? appId,
196
+ registered: !!app,
197
+ declared: !!app?.platformPermissions?.includes("mail:send"),
198
+ mode,
199
+ profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
200
+ };
201
+ })
202
+ .sort((a, b) => a.name.localeCompare(b.name) || a.appId.localeCompare(b.appId));
203
+ return { defaultProfile: profile?.key ?? null, items };
204
+ };
205
+ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAuditContext): Promise<AdminMailApp> => {
206
+ if (appId === "core") throw new OutgoingMailError("invalid_profile", "Core's system email always uses the default profile.");
207
+ if (!appId.trim()) throw new OutgoingMailError("invalid_profile", "Application id is required.");
208
+ const parsed = MailAppAccessSchema.safeParse(value);
209
+ if (!parsed.success) throw new OutgoingMailError("invalid_profile", "Invalid application access policy.");
210
+ const input = parsed.data;
211
+ const registered = await listApps();
212
+ return locked(async (tx) => {
213
+ const keys = input.mode === "selected" ? [...new Set(input.profiles)] : [];
214
+ const rows = await tx<
215
+ { id: string; key: string }[]
216
+ >`SELECT id, key FROM outgoing_mail.profiles WHERE key = ANY(${toPgTextArray(keys)}::text[])`;
217
+ if (rows.length !== keys.length) throw new OutgoingMailError("profile_unknown", "An outgoing mail profile does not exist.");
218
+ await tx`DELETE FROM outgoing_mail.app_profiles WHERE app_id = ${appId}`;
219
+ if (input.mode === "default") await tx`DELETE FROM outgoing_mail.app_access WHERE app_id = ${appId}`;
220
+ else {
221
+ await tx`INSERT INTO outgoing_mail.app_access(app_id, mode, updated_by) VALUES (${appId}, 'selected', ${context.actor.userId ?? null})
222
+ ON CONFLICT(app_id) DO UPDATE SET mode = 'selected', updated_at = now(), updated_by = EXCLUDED.updated_by`;
223
+ for (const row of rows) await tx`INSERT INTO outgoing_mail.app_profiles(app_id, profile_id) VALUES (${appId}, ${row.id}::uuid)`;
224
+ }
225
+ await record(tx, context, "app_access.update", appId);
226
+ return (
227
+ (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
228
+ appId,
229
+ name: appId,
230
+ registered: false,
231
+ declared: false,
232
+ mode: "default",
233
+ profiles: [],
234
+ }
235
+ );
236
+ });
237
+ };
238
+ const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
239
+ const rows = await sql<
240
+ {
241
+ key: string;
242
+ name: string;
243
+ from_address: string;
244
+ is_default: boolean;
245
+ max_attachment_bytes: number;
246
+ daily_recipient_limit: number | null;
247
+ }[]
248
+ >`
249
+ SELECT p.key, p.name, p.from_address, p.is_default, p.max_attachment_bytes, p.daily_recipient_limit
250
+ FROM outgoing_mail.profiles p WHERE
251
+ CASE WHEN EXISTS(SELECT 1 FROM outgoing_mail.app_access WHERE app_id = ${appId} AND mode = 'selected')
252
+ THEN EXISTS(SELECT 1 FROM outgoing_mail.app_profiles WHERE app_id = ${appId} AND profile_id = p.id)
253
+ ELSE p.is_default END ORDER BY p.is_default DESC, p.name, p.key`;
254
+ return rows.map((row) => ({
255
+ key: row.key,
256
+ name: row.name,
257
+ from: row.from_address,
258
+ default: row.is_default,
259
+ maxAttachmentBytes: row.max_attachment_bytes,
260
+ quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: 0 },
261
+ }));
262
+ };
263
+ export const outgoingMailStore = { list, get, put, setDefault, delete: remove, apps: appsState, setAppAccess, profilesForApp };
264
+
265
+ /** Platform email send path only. Never use this helper to shape API or CLI responses. */
266
+ export const resolveMailCredentials = async (key?: string) => {
267
+ const [row] = await sql<(ProfileRow & { smtp_password_encrypted: string | null })[]>`
268
+ SELECT ${columns(sql)}, smtp_password_encrypted FROM outgoing_mail.profiles WHERE ${key === undefined ? sql`is_default` : sql`key = ${keyValue(key)}`}`;
269
+ if (!row)
270
+ throw new OutgoingMailError(
271
+ "profile_unknown",
272
+ key ? "Outgoing mail profile does not exist." : "Configure a default outgoing mail profile before sending email.",
273
+ 404,
274
+ );
275
+ const password = row.smtp_password_encrypted === null ? null : await decryptValue(row.smtp_password_encrypted);
276
+ if (password !== null && typeof password !== "string") throw new Error("Outgoing mail SMTP password is invalid.");
277
+ return {
278
+ fromAddress: row.from_address,
279
+ fromName: row.from_name,
280
+ smtpHost: row.smtp_host,
281
+ smtpPort: row.smtp_port,
282
+ smtpSecure: row.smtp_secure,
283
+ smtpUser: row.smtp_user,
284
+ smtpPassword: password,
285
+ };
286
+ };
@@ -0,0 +1,40 @@
1
+ import { audit } from "../audit";
2
+ import { get } from "../settings";
3
+ import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
4
+ import { buildMailTransport } from "./transport";
5
+
6
+ export const smtpFailureMessage = (error: unknown, secrets: readonly (string | null)[]): string => {
7
+ let message = error instanceof Error ? error.message : "SMTP delivery failed.";
8
+ for (const secret of secrets.filter((s): s is string => !!s)) {
9
+ for (const representation of [secret, Buffer.from(secret).toString("base64")])
10
+ message = message.split(representation).join("[REDACTED]");
11
+ }
12
+ return message;
13
+ };
14
+ export const outgoingMailTest = {
15
+ async send(key: string, recipient: string, context: MailAuditContext): Promise<void> {
16
+ const profile = await resolveMailCredentials(key);
17
+ const event = { ...context, action: "outgoing_mail.profile.test", target: { type: "outgoing_mail_profile", id: key } };
18
+ // Fail before the external effect if audit storage is unavailable.
19
+ await audit.record({ ...event, outcome: "allowed" });
20
+ const transport = buildMailTransport(profile);
21
+ try {
22
+ await transport.sendMail({
23
+ from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
24
+ to: recipient,
25
+ subject: "Cloud test email",
26
+ text: "This is a test email from Cloud. SMTP delivery is configured correctly if you received it.",
27
+ });
28
+ } catch (error) {
29
+ const message = smtpFailureMessage(error, [
30
+ profile.smtpPassword,
31
+ profile.smtpUser,
32
+ profile.smtpUser ? `\0${profile.smtpUser}\0${profile.smtpPassword ?? ""}` : null,
33
+ ]);
34
+ await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
35
+ throw new OutgoingMailError("smtp_failed", message, 502);
36
+ } finally {
37
+ transport.close();
38
+ }
39
+ },
40
+ };
@@ -0,0 +1,13 @@
1
+ import { createTransport } from "nodemailer";
2
+ import type { resolveMailCredentials } from "./store";
3
+
4
+ export const buildMailTransport = (profile: Awaited<ReturnType<typeof resolveMailCredentials>>) =>
5
+ createTransport({
6
+ host: profile.smtpHost,
7
+ port: profile.smtpPort,
8
+ secure: profile.smtpSecure,
9
+ ...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
10
+ connectionTimeout: 10000,
11
+ greetingTimeout: 10000,
12
+ socketTimeout: 60000,
13
+ });
@@ -292,7 +292,7 @@ export const CORE_SETTINGS = {
292
292
  default: 30,
293
293
  min: 0,
294
294
  description:
295
- "Maximum running time per AI turn. Zero or an empty field disables this limit. Existing turns keep their original budget; request and tool timeouts still apply.",
295
+ "Maximum running time per AI turn. In the last tenth, the Assistant stops using tools and answers. Zero or an empty field disables this limit. Existing turns keep their original budget; request and tool timeouts still apply.",
296
296
  },
297
297
  "ai.max_tool_result_chars": {
298
298
  kind: "number",
@@ -672,43 +672,6 @@ export const CORE_SETTINGS = {
672
672
  description: "Account request denial email template (HTML). Subject: Account Request Update",
673
673
  templateVars: ["FIRST_NAME", "REASON", "CONTACT_EMAIL", "APP_NAME"] as readonly string[],
674
674
  },
675
- "mail.noreply.smtp_host": {
676
- kind: "string",
677
- label: "SMTP Host",
678
- default: "",
679
- description: "SMTP server hostname",
680
- placeholder: "e.g. smtp.example.org",
681
- },
682
- "mail.noreply.smtp_port": {
683
- kind: "number",
684
- label: "SMTP Port",
685
- default: 587,
686
- min: 1,
687
- max: 65535,
688
- description: "SMTP server port (587 for STARTTLS, 465 for SSL)",
689
- },
690
- "mail.noreply.from": {
691
- kind: "email",
692
- label: "From Address",
693
- default: "",
694
- description: "From email address",
695
- placeholder: "e.g. noreply@example.org",
696
- },
697
- "mail.noreply.user": {
698
- kind: "string",
699
- label: "SMTP User",
700
- default: "",
701
- description: "SMTP username",
702
- placeholder: "e.g. noreply@example.org",
703
- },
704
- "mail.noreply.password": {
705
- kind: "secret",
706
- label: "SMTP Password",
707
- default: "",
708
- description: "SMTP password",
709
- placeholder: "SMTP password",
710
- },
711
-
712
675
  // ── Security ────────────────────────────────────────────────────────────
713
676
  "security.rate_limit_per_second": {
714
677
  kind: "number",
@@ -136,7 +136,11 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
136
136
  */
137
137
  export const allKnownKeys = (): string[] => SETTINGS.map((d) => d.key);
138
138
 
139
- const knownKeysWith = (extraKnownKeys: readonly string[]) => Array.from(new Set([...allKnownKeys(), ...extraKnownKeys]));
139
+ // Retain the prior SMTP configuration for one release so operators can roll back.
140
+ const retainedLegacyKeys = ["smtp_host", "smtp_port", "from", "user", "password"].map((key) => `mail.noreply.${key}`);
141
+
142
+ const knownKeysWith = (extraKnownKeys: readonly string[]) =>
143
+ Array.from(new Set([...allKnownKeys(), ...retainedLegacyKeys, ...extraKnownKeys]));
140
144
 
141
145
  export const listLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<LegacySettingRow[]> => {
142
146
  const knownKeys = knownKeysWith(extraKnownKeys);
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { AiExtraBodySchema, AiReasoningEffortSchema, AiRequestHeadersSchema } from "./ai-request-options";
3
+
4
+ export const AiModelRequestSettingsUpdateSchema = z
5
+ .object({
6
+ expected: z.string().min(1),
7
+ reasoningEffort: AiReasoningEffortSchema.nullable(),
8
+ extraBody: AiExtraBodySchema.nullable().optional(),
9
+ requestHeaders: AiRequestHeadersSchema.optional(),
10
+ clearRequestHeaders: z.boolean().optional(),
11
+ })
12
+ .strict();
13
+ export type AiModelRequestSettingsUpdate = z.infer<typeof AiModelRequestSettingsUpdateSchema>;
14
+ export const AiModelRequestSettingsSchema = z.object({
15
+ id: z.string(),
16
+ reasoningEffort: z.string().nullable(),
17
+ extraBody: z.record(z.string(), z.unknown()).nullable(),
18
+ requestHeaderNames: z.array(z.string()),
19
+ revision: z.string(),
20
+ });
21
+ export type AiModelRequestSettings = z.infer<typeof AiModelRequestSettingsSchema>;
@@ -58,7 +58,7 @@ Use search_tools only when the needed operation is unknown. If a loaded Skill or
58
58
  {%- if appToolsEnabled %}
59
59
 
60
60
  # Cloud app tools
61
- Installed apps publish live Queries and Actions through tool discovery. Calls run with the current user's permissions and the owning app authorizes every call; catalog visibility is not access. Search with a known appId when possible, load only needed names, and treat Query or Action as read/write metadata rather than a search filter. Reuse returned typed resource refs unchanged. Missing tools may be temporary. Claim success only after the call succeeds.
61
+ Installed apps publish live Queries and Actions through tool discovery. Calls run with the current user's permissions and the owning app authorizes every call; catalog visibility is not access. Search with a known appId when possible, load only needed names, and treat Query or Action as read/write metadata rather than a search filter. Reuse returned typed resource refs unchanged. If load_tools reports a tool as unavailable, follow its reason: look up an unknown name once with search_tools; otherwise do not search or retry for it again in this turn, and continue with what is available or tell the user what is missing. Claim success only after the call succeeds.
62
62
  {%- endif %}
63
63
  {%- if hasFiles %}
64
64
 
@@ -0,0 +1,185 @@
1
+ import { z } from "zod";
2
+
3
+ // nessi 0.15 deep-merges extraBody last. Reserve identity/content, tool/loop
4
+ // control, structured output, and profile-owned generation settings across all
5
+ // adapters so an escape hatch cannot replace Cloud's request or quota budget.
6
+ export const AI_RESERVED_EXTRA_BODY_KEYS = [
7
+ "model",
8
+ "models",
9
+ "route",
10
+ "messages",
11
+ "system",
12
+ "contents",
13
+ "systemInstruction",
14
+ "tools",
15
+ "tool_choice",
16
+ "toolConfig",
17
+ "parallel_tool_calls",
18
+ "stream",
19
+ "stream_options",
20
+ "response_format",
21
+ "structured_outputs",
22
+ "format",
23
+ "temperature",
24
+ "n",
25
+ "max_tokens",
26
+ "max_completion_tokens",
27
+ "reasoning_effort",
28
+ "reasoning",
29
+ ] as const;
30
+ const keyName = (key: string) => key.replace(/_/g, "").toLowerCase();
31
+ const reserved = new Set(AI_RESERVED_EXTRA_BODY_KEYS.map(keyName));
32
+ const unsafeKeys = new Set(["__proto__", "constructor", "prototype"]);
33
+ const nestedReserved = new Map<string, ReadonlySet<string>>([
34
+ [
35
+ keyName("generationConfig"),
36
+ new Set(["responseSchema", "responseJsonSchema", "responseMimeType", "temperature", "maxOutputTokens", "candidateCount"].map(keyName)),
37
+ ],
38
+ [keyName("output_config"), new Set(["format"].map(keyName))],
39
+ [keyName("options"), new Set(["temperature", "num_predict"].map(keyName))],
40
+ ]);
41
+ const plainObject = (value: unknown): value is Record<string, unknown> =>
42
+ value !== null &&
43
+ typeof value === "object" &&
44
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
45
+
46
+ export const AiReasoningEffortSchema = z.preprocess(
47
+ (value) => (typeof value === "string" && !value.trim() ? undefined : value),
48
+ z
49
+ .string()
50
+ .trim()
51
+ .min(1)
52
+ .max(32)
53
+ .regex(/^[a-z0-9_-]+$/)
54
+ .optional(),
55
+ );
56
+
57
+ export const AiExtraBodySchema = z
58
+ .custom<Record<string, unknown>>(plainObject, "Extra parameters must be a plain JSON object.")
59
+ .superRefine((body, ctx) => {
60
+ if (!plainObject(body)) return;
61
+ try {
62
+ if (new TextEncoder().encode(JSON.stringify(body)).byteLength > 8192) {
63
+ ctx.addIssue({ code: "custom", message: "Extra parameters exceed 8 KiB of UTF-8 JSON." });
64
+ return;
65
+ }
66
+ } catch {
67
+ ctx.addIssue({ code: "custom", message: "Extra parameters must contain serializable JSON values." });
68
+ return;
69
+ }
70
+ let invalidJson = false;
71
+ const seen = new Set<object>();
72
+ const pending: { value: unknown; path: (string | number)[] }[] = [{ value: body, path: [] }];
73
+ while (pending.length) {
74
+ const entry = pending.pop()!;
75
+ const { value, path } = entry;
76
+ if (
77
+ value === null ||
78
+ typeof value === "string" ||
79
+ typeof value === "boolean" ||
80
+ (typeof value === "number" && Number.isFinite(value))
81
+ )
82
+ continue;
83
+ if (!Array.isArray(value) && !plainObject(value)) {
84
+ invalidJson = true;
85
+ continue;
86
+ }
87
+ if (seen.has(value)) continue;
88
+ seen.add(value);
89
+ for (const [key, child] of Object.entries(value)) {
90
+ if (unsafeKeys.has(key))
91
+ ctx.addIssue({ code: "custom", path: [...path, key], message: "Prototype keys are not allowed in extra parameters." });
92
+ pending.push({ value: child, path: [...path, key] });
93
+ }
94
+ }
95
+ if (invalidJson) {
96
+ ctx.addIssue({ code: "custom", message: "Extra parameters must contain only JSON values." });
97
+ return;
98
+ }
99
+ for (const key of Object.keys(body)) {
100
+ const normalizedKey = keyName(key);
101
+ if (reserved.has(normalizedKey))
102
+ ctx.addIssue({
103
+ code: "custom",
104
+ path: [key],
105
+ message:
106
+ normalizedKey === "reasoning" || normalizedKey === "reasoningeffort"
107
+ ? "Use the thinking-level field (reasoningEffort) instead."
108
+ : "This parameter is owned by Cloud; use the model profile fields instead.",
109
+ });
110
+ const nested = body[key];
111
+ const reservedChildren = nestedReserved.get(normalizedKey);
112
+ if (reservedChildren && !plainObject(nested))
113
+ ctx.addIssue({
114
+ code: "custom",
115
+ path: [key],
116
+ message: "This parameter must be a plain JSON object so Cloud can preserve its owned settings.",
117
+ });
118
+ if (plainObject(nested))
119
+ for (const child of Object.keys(nested)) {
120
+ if (reservedChildren?.has(keyName(child)))
121
+ ctx.addIssue({
122
+ code: "custom",
123
+ path: [key, child],
124
+ message: "This parameter is owned by Cloud's generation or structured-output settings.",
125
+ });
126
+ }
127
+ }
128
+ })
129
+ .meta({
130
+ type: "object",
131
+ additionalProperties: true,
132
+ description: "Extra provider parameters (at most 8192 bytes of UTF-8 JSON); Cloud-owned request keys are reserved.",
133
+ });
134
+
135
+ const reservedHeaders = new Set(["content-type", "content-length", "host", "connection", "transfer-encoding"]);
136
+ // Validate with metadata-only messages: never include a submitted header value.
137
+ export const AiRequestHeadersSchema = z
138
+ .custom<Record<string, string | null>>(plainObject, "Extra headers must be a JSON object of names to strings or null.")
139
+ .superRefine((headers, ctx) => {
140
+ if (!plainObject(headers)) return;
141
+ if (Object.keys(headers).length > 32) ctx.addIssue({ code: "custom", message: "At most 32 extra headers are allowed." });
142
+ const names = new Set<string>();
143
+ for (const [name, value] of Object.entries(headers)) {
144
+ const lower = name.toLowerCase();
145
+ if (name.length > 128 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
146
+ ctx.addIssue({ code: "custom", path: [name], message: "Header names must be HTTP tokens of at most 128 characters." });
147
+ if (names.has(lower)) ctx.addIssue({ code: "custom", path: [name], message: "Duplicate header names are case-insensitive." });
148
+ names.add(lower);
149
+ if (reservedHeaders.has(lower)) ctx.addIssue({ code: "custom", path: [name], message: "This HTTP header is reserved." });
150
+ if (value !== null && (typeof value !== "string" || value.length > 4096 || /[^\t\x20-\x7E]/.test(value)))
151
+ ctx.addIssue({
152
+ code: "custom",
153
+ path: [name],
154
+ message: "Header values must be strings of at most 4096 printable ASCII characters, spaces or tabs, or null to remove.",
155
+ });
156
+ }
157
+ })
158
+ .meta({
159
+ type: "object",
160
+ maxProperties: 32,
161
+ propertyNames: { type: "string", maxLength: 128, pattern: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" },
162
+ additionalProperties: { anyOf: [{ type: "string", maxLength: 4096, pattern: "^[\\t\\x20-\\x7E]*$" }, { type: "null" }] },
163
+ writeOnly: true,
164
+ });
165
+
166
+ export const AI_REQUEST_HEADERS_PROVIDER_ERROR = "Extra headers are only supported for vLLM and OpenAI-compatible endpoints.";
167
+ export const providerSupportsRequestHeaders = (provider: string): boolean => provider === "vllm" || provider === "openai-compatible";
168
+
169
+ /** Case-insensitive PATCH. Canonical Authorization avoids duplicate casing in nessi's Bearer overwrite. */
170
+ export const patchAiRequestHeaders = (current: Record<string, string>, patch: Record<string, string | null>): Record<string, string> => {
171
+ AiRequestHeadersSchema.parse(patch);
172
+ const result: Record<string, string> = Object.fromEntries(Object.entries(current));
173
+ for (const [name, value] of Object.entries(patch)) {
174
+ for (const stored of Object.keys(result)) if (stored.toLowerCase() === name.toLowerCase()) delete result[stored];
175
+ if (value !== null)
176
+ Object.defineProperty(result, name.toLowerCase() === "authorization" ? "Authorization" : name, {
177
+ value,
178
+ enumerable: true,
179
+ configurable: true,
180
+ writable: true,
181
+ });
182
+ }
183
+ AiRequestHeadersSchema.parse(result);
184
+ return result;
185
+ };
@@ -27,6 +27,15 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
27
27
  }, {});
28
28
  };
29
29
 
30
+ /** Resolve only an app's display name and description, for consumers that carry no other app metadata. */
31
+ export const resolveAppIdentityPresentation = (
32
+ app: { name: string; description: string; presentation?: AppPresentationCatalog },
33
+ requestedLocale: string,
34
+ ): { name: string; description: string } => {
35
+ const translation = app.presentation ? mergedTranslation(app.presentation, requestedLocale) : {};
36
+ return { name: translation.name ?? app.name, description: translation.description ?? app.description };
37
+ };
38
+
30
39
  /** Resolve only human presentation; stable app identity, routes, icons, and authorization stay untouched. */
31
40
  export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocale: string): T => {
32
41
  if (!app.presentation) return app;
@@ -34,8 +43,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
34
43
  if (Object.keys(translation).length === 0) return app;
35
44
  return {
36
45
  ...app,
37
- name: translation.name ?? app.name,
38
- description: translation.description ?? app.description,
46
+ ...resolveAppIdentityPresentation(app, requestedLocale),
39
47
  adminNav: app.adminNav?.map((group) => ({
40
48
  ...group,
41
49
  label: (group.id && translation.adminGroups?.[group.id]) || group.label,
@@ -14,7 +14,7 @@ export const buildAdminGroups = (apps: readonly RuntimeContext["apps"][number][]
14
14
  const t = platformMessages.resolve([locale]).t;
15
15
  const settingsLinks: AdminLink[] = [
16
16
  { href: "/admin/settings?tab=general", icon: "ti-app-window", label: t.general },
17
- { href: "/admin/settings?tab=mail", icon: "ti-mail", label: "Mail" },
17
+ { href: "/admin/outgoing-mail", icon: "ti-mail", label: t.outgoingMail },
18
18
  { href: "/admin/settings?tab=pdf-rendering", icon: "ti-file-type-pdf", label: t.pdfRendering },
19
19
  { href: "/admin/settings?tab=email-templates", icon: "ti-template", label: t.emailTemplates },
20
20
  { href: "/admin/settings?tab=security", icon: "ti-shield-lock", label: t.security },