@k2b/cloud 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +113 -24
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
- package/src/ai/chat/message-actions.tsx +124 -102
- package/src/ai/chat/message-utils.ts +57 -6
- package/src/ai/chat/messages.ts +214 -0
- package/src/ai/chat/presentation.tsx +67 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +69 -10
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/compaction.ts +22 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +23 -17
- package/src/ai/executor.ts +262 -99
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +3 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +26 -10
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +14 -6
- package/src/ai/store.ts +102 -34
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +9 -22
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +104 -0
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +29 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +82 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +66 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/help/store.ts +2 -1
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +12 -1
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +88 -6
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +33 -5
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/platform-messages.ts +1 -1
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +38 -17
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
- package/src/styles/utilities-navigation.css +41 -0
package/src/contracts/app.ts
CHANGED
|
@@ -119,6 +119,8 @@ export type AppMeta = {
|
|
|
119
119
|
section: "primary" | "more" | "hidden";
|
|
120
120
|
requiresAuth?: boolean;
|
|
121
121
|
requiresRoles?: Role[];
|
|
122
|
+
/** Same-origin path of a `GET` route this app serves that returns an `AppNavBadge`; see `defineApp()`. */
|
|
123
|
+
badge?: string;
|
|
122
124
|
};
|
|
123
125
|
/**
|
|
124
126
|
* Legal/info pages this app owns. Aggregated app-wide and rendered in
|
|
@@ -146,6 +148,14 @@ export type AppMeta = {
|
|
|
146
148
|
cli?: AppCliModules;
|
|
147
149
|
};
|
|
148
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Response of an app's `nav.badge` route: how many items wait for the signed-in
|
|
153
|
+
* person. A positive safe integer shows a count on the app's icon in the rail
|
|
154
|
+
* and the app grid (above 99 as "99+"); `0`, a non-200 response, or anything
|
|
155
|
+
* else shows none.
|
|
156
|
+
*/
|
|
157
|
+
export type AppNavBadge = { count: number };
|
|
158
|
+
|
|
149
159
|
export type WidgetEndpoint = {
|
|
150
160
|
/** Unique-within-the-app id, e.g. "open-requests". */
|
|
151
161
|
id: string;
|
|
@@ -8,7 +8,9 @@ export const CommandPathSchema = z
|
|
|
8
8
|
.refine((value) => {
|
|
9
9
|
if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
|
|
10
10
|
try {
|
|
11
|
-
|
|
11
|
+
const url = new URL(value, "https://cloud.invalid");
|
|
12
|
+
// Dot segments can resolve to `//host`, which a browser reads as another host.
|
|
13
|
+
return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
|
|
12
14
|
} catch {
|
|
13
15
|
return false;
|
|
14
16
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** App-chosen keys stay separate from the namespaced tags sent to browsers. */
|
|
2
|
+
export const isNotificationGroup = (value: unknown): value is string =>
|
|
3
|
+
typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[A-Za-z0-9._:-]+$/.test(value);
|
|
4
|
+
|
|
5
|
+
export const notificationGroupTag = (appId: string, group: string): string | null =>
|
|
6
|
+
typeof appId === "string" && /^[a-z][a-z0-9-]*$/.test(appId) && isNotificationGroup(group) ? `${appId}:${group}` : null;
|
|
@@ -31,6 +31,12 @@ export const validateNotificationTargetHref = (value: string): `/${string}` => {
|
|
|
31
31
|
export type NotificationPresentation = {
|
|
32
32
|
title: string;
|
|
33
33
|
body?: string;
|
|
34
|
+
/** Short text shown in the browser/OS notification, including the lock screen. Cloud passes it only to the browser channel; email, deployment channels, and notification history never receive it. */
|
|
35
|
+
preview?: string;
|
|
36
|
+
/** Stable app-chosen browser group key, namespaced by Cloud per application. */
|
|
37
|
+
group?: string;
|
|
38
|
+
/** Non-negative safe integer for the app badge; zero clears it. Browser-only. */
|
|
39
|
+
badge?: number;
|
|
34
40
|
/** Same-origin absolute path. Validated again before persistence and navigation. */
|
|
35
41
|
targetHref?: `/${string}`;
|
|
36
42
|
};
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
|
|
1
2
|
import { z } from "zod";
|
|
3
|
+
import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
|
|
2
4
|
|
|
3
5
|
export const PlatformPermissionSchema = z.enum(["mail:send"]);
|
|
4
6
|
export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
|
|
7
|
+
export const MailRetentionSchema = z.object({ contentDays: z.int().min(1).max(36500), recordDays: z.int().min(1).max(36500) }).strict();
|
|
8
|
+
export type MailRetention = z.infer<typeof MailRetentionSchema>;
|
|
9
|
+
|
|
5
10
|
export const MailProfileKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
6
11
|
const headerText = z
|
|
7
12
|
.string()
|
|
@@ -9,6 +14,26 @@ const headerText = z
|
|
|
9
14
|
.min(1)
|
|
10
15
|
.max(120)
|
|
11
16
|
.refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
|
|
17
|
+
export const MailImapInputSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
host: z
|
|
20
|
+
.string()
|
|
21
|
+
.trim()
|
|
22
|
+
.min(1)
|
|
23
|
+
.max(253)
|
|
24
|
+
.refine((value) => !/[\s/@\0]/.test(value), "Use an IMAP hostname"),
|
|
25
|
+
port: z.int().min(1).max(65535),
|
|
26
|
+
secure: z.boolean(),
|
|
27
|
+
user: z.string().trim().min(1).max(320),
|
|
28
|
+
password: z.string().max(16384).nullable().optional(),
|
|
29
|
+
folder: z
|
|
30
|
+
.string()
|
|
31
|
+
.trim()
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(200)
|
|
34
|
+
.refine((value) => !/[\x00-\x1f\x7f]/.test(value), "Folder must not contain control characters"),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
12
37
|
export const MailProfileInputSchema = z
|
|
13
38
|
.object({
|
|
14
39
|
name: headerText,
|
|
@@ -24,6 +49,7 @@ export const MailProfileInputSchema = z
|
|
|
24
49
|
smtpSecure: z.boolean(),
|
|
25
50
|
smtpUser: z.string().max(320).nullable(),
|
|
26
51
|
smtpPassword: z.string().max(16384).nullable().optional(),
|
|
52
|
+
imap: MailImapInputSchema.nullable().optional(),
|
|
27
53
|
pacePerMinute: z.int().min(1).max(6000),
|
|
28
54
|
dailyRecipientLimit: z.int().min(1).max(2147483647).nullable(),
|
|
29
55
|
maxAttachmentBytes: z.int().min(1).max(26214400),
|
|
@@ -46,6 +72,19 @@ export const MailProfileSchema = z.object({
|
|
|
46
72
|
});
|
|
47
73
|
export type MailProfile = z.infer<typeof MailProfileSchema>;
|
|
48
74
|
export const MailErrorCodeSchema = z.enum([
|
|
75
|
+
"bad_input",
|
|
76
|
+
"profile_not_allowed",
|
|
77
|
+
"profile_required",
|
|
78
|
+
"quota_exceeded",
|
|
79
|
+
"backlog_full",
|
|
80
|
+
"attachments_too_large",
|
|
81
|
+
"attachment_storage_full",
|
|
82
|
+
"profile_removed",
|
|
83
|
+
"attachment_lost",
|
|
84
|
+
"cancelled_by_admin",
|
|
85
|
+
"message_unknown",
|
|
86
|
+
"batch_unknown",
|
|
87
|
+
"message_not_queued",
|
|
49
88
|
"mail_unavailable",
|
|
50
89
|
"mail_not_declared",
|
|
51
90
|
"profile_unknown",
|
|
@@ -56,9 +95,13 @@ export const MailErrorCodeSchema = z.enum([
|
|
|
56
95
|
"smtp_failed",
|
|
57
96
|
]);
|
|
58
97
|
export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
|
|
59
|
-
export const
|
|
98
|
+
export const MailBounceErrorCodeSchema = z.enum(["open_failed", "search_failed", "apply_failed", "save_failed", "interrupted"]);
|
|
99
|
+
export type MailBounceErrorCode = z.infer<typeof MailBounceErrorCodeSchema>;
|
|
100
|
+
export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true, imap: true }).extend({
|
|
60
101
|
key: MailProfileKeySchema,
|
|
61
102
|
hasPassword: z.boolean(),
|
|
103
|
+
imap: MailImapInputSchema.omit({ password: true }).extend({ hasPassword: z.boolean() }).nullable(),
|
|
104
|
+
bounces: z.object({ checkedAt: z.string().nullable(), error: MailBounceErrorCodeSchema.nullable() }).nullable(),
|
|
62
105
|
isDefault: z.boolean(),
|
|
63
106
|
revision: z.int(),
|
|
64
107
|
createdAt: z.string(),
|
|
@@ -75,3 +118,148 @@ export type AdminMailApp = {
|
|
|
75
118
|
mode: "default" | "selected";
|
|
76
119
|
profiles: string[];
|
|
77
120
|
};
|
|
121
|
+
|
|
122
|
+
export const MailStatusSchema = z.enum(["queued", "sending", "sent", "failed", "bounced", "cancelled"]);
|
|
123
|
+
export type MailStatus = z.infer<typeof MailStatusSchema>;
|
|
124
|
+
export type MailAttachment = {
|
|
125
|
+
filename: string;
|
|
126
|
+
contentType: string;
|
|
127
|
+
content: Uint8Array | Blob | ReadableStream<Uint8Array>;
|
|
128
|
+
};
|
|
129
|
+
const body = z
|
|
130
|
+
.string()
|
|
131
|
+
.refine((value) => !value.includes("\0"), "Body must not contain NUL")
|
|
132
|
+
.max(512 * 1024)
|
|
133
|
+
.refine((value) => new TextEncoder().encode(value).byteLength <= 512 * 1024, "Body exceeds 512 KiB");
|
|
134
|
+
const safeHeader = z.string().refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
|
|
135
|
+
const allowedHeaders = new Set([
|
|
136
|
+
"list-id",
|
|
137
|
+
"list-unsubscribe",
|
|
138
|
+
"list-unsubscribe-post",
|
|
139
|
+
"in-reply-to",
|
|
140
|
+
"references",
|
|
141
|
+
"auto-submitted",
|
|
142
|
+
"precedence",
|
|
143
|
+
]);
|
|
144
|
+
export const MailHeadersSchema = z.record(z.string(), safeHeader).refine((headers) => {
|
|
145
|
+
let bytes = 0;
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
148
|
+
const lower = name.toLowerCase();
|
|
149
|
+
if (!/^[\x21-\x39\x3B-\x7E]+$/.test(name) || seen.has(lower)) return false;
|
|
150
|
+
seen.add(lower);
|
|
151
|
+
if (!(lower.startsWith("x-") && !lower.startsWith("x-cloud-")) && !allowedHeaders.has(lower)) return false;
|
|
152
|
+
bytes += new TextEncoder().encode(`${name}: ${value}\r\n`).byteLength;
|
|
153
|
+
}
|
|
154
|
+
return bytes <= 8192;
|
|
155
|
+
}, "Invalid mail headers or headers exceed 8 KiB");
|
|
156
|
+
const referenceText = z
|
|
157
|
+
.string()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(200)
|
|
160
|
+
.refine((value) => !value.includes("\0"), "Reference must not contain NUL");
|
|
161
|
+
const ref = z.object({ scope: referenceText, id: referenceText }).strict();
|
|
162
|
+
const mailAddress = z.email({ pattern: z.regexes.html5Email }).max(320);
|
|
163
|
+
export const MailMessageSchema = z
|
|
164
|
+
.object({
|
|
165
|
+
to: z.array(mailAddress).min(1).max(50),
|
|
166
|
+
subject: safeHeader.max(998),
|
|
167
|
+
text: body,
|
|
168
|
+
html: body.optional(),
|
|
169
|
+
fromName: safeHeader.max(998).optional(),
|
|
170
|
+
replyTo: mailAddress.optional(),
|
|
171
|
+
attachments: z
|
|
172
|
+
.array(
|
|
173
|
+
z
|
|
174
|
+
.object({
|
|
175
|
+
filename: safeHeader.min(1).max(998),
|
|
176
|
+
contentType: safeHeader.min(1).max(998),
|
|
177
|
+
content: z.custom<MailAttachment["content"]>(
|
|
178
|
+
(value) => value instanceof Uint8Array || value instanceof Blob || value instanceof ReadableStream,
|
|
179
|
+
),
|
|
180
|
+
})
|
|
181
|
+
.strict(),
|
|
182
|
+
)
|
|
183
|
+
.max(20)
|
|
184
|
+
.optional(),
|
|
185
|
+
headers: MailHeadersSchema.optional(),
|
|
186
|
+
profile: MailProfileKeySchema.optional(),
|
|
187
|
+
ref: ref.optional(),
|
|
188
|
+
actor: z
|
|
189
|
+
.custom<RequestActor>((value) => {
|
|
190
|
+
if (!value || typeof value !== "object" || !("kind" in value)) return false;
|
|
191
|
+
if (value.kind === "user" && "user" in value) return UserSchema.safeParse(value.user).success;
|
|
192
|
+
return (
|
|
193
|
+
value.kind === "service_account" && "serviceAccount" in value && ServiceAccountSchema.safeParse(value.serviceAccount).success
|
|
194
|
+
);
|
|
195
|
+
})
|
|
196
|
+
.optional(),
|
|
197
|
+
key: z
|
|
198
|
+
.string()
|
|
199
|
+
.min(1)
|
|
200
|
+
.max(200)
|
|
201
|
+
.refine((value) => !value.includes("\0"), "Key must not contain NUL")
|
|
202
|
+
.optional(),
|
|
203
|
+
})
|
|
204
|
+
.strict();
|
|
205
|
+
export type MailMessage = z.infer<typeof MailMessageSchema>;
|
|
206
|
+
export const MailBatchSchema = z
|
|
207
|
+
.array(MailMessageSchema)
|
|
208
|
+
.min(1)
|
|
209
|
+
.max(1000)
|
|
210
|
+
.refine((messages) => {
|
|
211
|
+
const keys = messages.flatMap((message) => (message.key === undefined ? [] : [message.key]));
|
|
212
|
+
return new Set(keys).size === keys.length;
|
|
213
|
+
}, "Duplicate outgoing mail keys in a batch");
|
|
214
|
+
export const MailFilterSchema = z
|
|
215
|
+
.object({
|
|
216
|
+
ref: ref.partial({ id: true }).optional(),
|
|
217
|
+
ids: z.array(z.uuid()).max(100).optional(),
|
|
218
|
+
batchId: z.uuid().optional(),
|
|
219
|
+
status: z.array(MailStatusSchema).max(6).optional(),
|
|
220
|
+
since: z.iso.datetime({ offset: true }).optional(),
|
|
221
|
+
})
|
|
222
|
+
.strict();
|
|
223
|
+
export type MailFilter = z.infer<typeof MailFilterSchema>;
|
|
224
|
+
export const MailRecordSchema = z.object({
|
|
225
|
+
id: z.uuid(),
|
|
226
|
+
batchId: z.uuid().optional(),
|
|
227
|
+
profile: z.string(),
|
|
228
|
+
ref: ref.optional(),
|
|
229
|
+
to: z.array(z.string()),
|
|
230
|
+
subject: z.string(),
|
|
231
|
+
text: z.string().optional(),
|
|
232
|
+
attachments: z.array(z.object({ filename: z.string(), contentType: z.string(), size: z.int().nonnegative(), sha256: z.string() })),
|
|
233
|
+
status: MailStatusSchema,
|
|
234
|
+
error: z.string().optional(),
|
|
235
|
+
failures: z.array(z.object({ recipient: z.string(), reason: z.string(), at: z.string() })),
|
|
236
|
+
attempts: z.int(),
|
|
237
|
+
actor: z.object({ id: z.string(), name: z.string() }).optional(),
|
|
238
|
+
createdAt: z.string(),
|
|
239
|
+
sentAt: z.string().optional(),
|
|
240
|
+
contentPurgedAt: z.string().optional(),
|
|
241
|
+
});
|
|
242
|
+
export type MailRecord = z.infer<typeof MailRecordSchema>;
|
|
243
|
+
export const AdminMailRecordSchema = MailRecordSchema.omit({ text: true }).extend({
|
|
244
|
+
appId: z.string(),
|
|
245
|
+
errorCode: z.string().optional(),
|
|
246
|
+
response: z.string().optional(),
|
|
247
|
+
});
|
|
248
|
+
export type AdminMailRecord = z.infer<typeof AdminMailRecordSchema>;
|
|
249
|
+
/** Extends the shared offset-page shape with stable keyset navigation. */
|
|
250
|
+
export type MailPageParams = PageParams & { cursor?: string };
|
|
251
|
+
export type MailPage<T = MailRecord> = Paginated<T> & { nextCursor?: string };
|
|
252
|
+
export const MailPageParamsSchema = z
|
|
253
|
+
.object({ page: z.int().min(1).optional(), perPage: z.int().min(1).max(100).optional(), cursor: z.string().max(1024).optional() })
|
|
254
|
+
.strict();
|
|
255
|
+
export const AdminMailFilterSchema = MailFilterSchema.extend({
|
|
256
|
+
app: z.string().min(1).optional(),
|
|
257
|
+
profile: MailProfileKeySchema.optional(),
|
|
258
|
+
recipient: z.string().max(320).optional(),
|
|
259
|
+
});
|
|
260
|
+
export type AdminMailFilter = z.infer<typeof AdminMailFilterSchema>;
|
|
261
|
+
export type MailServiceError = ServiceError<MailErrorCode> & {
|
|
262
|
+
limit?: number;
|
|
263
|
+
used?: number;
|
|
264
|
+
requested?: number;
|
|
265
|
+
};
|
|
@@ -58,6 +58,80 @@ export const UserNotificationHistoryResponseSchema = z.object({
|
|
|
58
58
|
});
|
|
59
59
|
export type UserNotificationHistoryResponse = z.infer<typeof UserNotificationHistoryResponseSchema>;
|
|
60
60
|
|
|
61
|
+
// Contracts reach the browser bundle, so this uses Intl directly instead of the stdlib date helpers.
|
|
62
|
+
const isTimeZone = (value: string): boolean => {
|
|
63
|
+
try {
|
|
64
|
+
new Intl.DateTimeFormat("en", { timeZone: value }).format(0);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Wall-clock time `HH:MM` in the quiet hours' time zone. */
|
|
72
|
+
const QuietTimeOfDaySchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use a time from 00:00 to 23:59");
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One recurring quiet period. Days are ISO weekdays (1 = Monday … 7 = Sunday). A period that ends at or before
|
|
76
|
+
* its start runs overnight into the next day; equal start and end make 24 hours quiet, so 00:00 to 00:00 is the
|
|
77
|
+
* whole day.
|
|
78
|
+
*/
|
|
79
|
+
export const NotificationQuietPeriodSchema = z.object({
|
|
80
|
+
days: z
|
|
81
|
+
.array(z.number().int().min(1).max(7))
|
|
82
|
+
.min(1)
|
|
83
|
+
.max(7)
|
|
84
|
+
.refine((days) => new Set(days).size === days.length, "Each weekday can appear only once"),
|
|
85
|
+
start: QuietTimeOfDaySchema,
|
|
86
|
+
end: QuietTimeOfDaySchema,
|
|
87
|
+
});
|
|
88
|
+
export type NotificationQuietPeriod = z.infer<typeof NotificationQuietPeriodSchema>;
|
|
89
|
+
|
|
90
|
+
/** One period per weekday is the most a weekly schedule needs, so seven periods bound the schedule. */
|
|
91
|
+
export const NOTIFICATION_QUIET_PERIOD_LIMIT = 7;
|
|
92
|
+
|
|
93
|
+
export const NotificationQuietHoursSchema = z.object({
|
|
94
|
+
timeZone: z.string().trim().min(1).max(64).refine(isTimeZone, "Unknown time zone"),
|
|
95
|
+
periods: z.array(NotificationQuietPeriodSchema).max(NOTIFICATION_QUIET_PERIOD_LIMIT),
|
|
96
|
+
});
|
|
97
|
+
export type NotificationQuietHours = z.infer<typeof NotificationQuietHoursSchema>;
|
|
98
|
+
|
|
99
|
+
export const NotificationQuietReasonSchema = z.enum(["doNotDisturb", "quietHours"]);
|
|
100
|
+
export type NotificationQuietReason = z.infer<typeof NotificationQuietReasonSchema>;
|
|
101
|
+
|
|
102
|
+
/** Whether Cloud holds back browser notifications right now and, when known, until when. */
|
|
103
|
+
export const NotificationQuietStateSchema = z.object({
|
|
104
|
+
active: z.boolean(),
|
|
105
|
+
reason: NotificationQuietReasonSchema.nullable(),
|
|
106
|
+
/** End of the current quiet time; null when inactive or when no end lies within the next week. */
|
|
107
|
+
until: z.string().datetime().nullable(),
|
|
108
|
+
/** Start of the next quiet hours while inactive; null when active or when none starts within the next week. */
|
|
109
|
+
nextStart: z.string().datetime().nullable(),
|
|
110
|
+
});
|
|
111
|
+
export type NotificationQuietState = z.infer<typeof NotificationQuietStateSchema>;
|
|
112
|
+
|
|
113
|
+
export const NotificationQuietSettingsSchema = z.object({
|
|
114
|
+
doNotDisturbUntil: z.string().datetime().nullable(),
|
|
115
|
+
quietHours: NotificationQuietHoursSchema,
|
|
116
|
+
state: NotificationQuietStateSchema,
|
|
117
|
+
});
|
|
118
|
+
export type NotificationQuietSettings = z.infer<typeof NotificationQuietSettingsSchema>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Do not disturb is a pause, so it ends within a year (366 days covers the same date next year). Holding back
|
|
122
|
+
* browser notifications for longer is turning them off, which the notification preferences and the device do.
|
|
123
|
+
*/
|
|
124
|
+
export const NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS = 366;
|
|
125
|
+
|
|
126
|
+
export const UpdateNotificationQuietSettingsSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
/** A future instant at most `NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS` ahead pauses browser notifications until then; null resumes them. */
|
|
129
|
+
doNotDisturbUntil: z.iso.datetime({ offset: true }).nullable().optional(),
|
|
130
|
+
quietHours: NotificationQuietHoursSchema.optional(),
|
|
131
|
+
})
|
|
132
|
+
.refine((value) => value.doNotDisturbUntil !== undefined || value.quietHours !== undefined, "Nothing to update");
|
|
133
|
+
export type UpdateNotificationQuietSettings = z.infer<typeof UpdateNotificationQuietSettingsSchema>;
|
|
134
|
+
|
|
61
135
|
export const isSafeBrowserPushEndpoint = (value: string): boolean => {
|
|
62
136
|
try {
|
|
63
137
|
if (/[\\\u0000-\u001f\u007f]/.test(value)) return false;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createMiddleware } from "hono/factory";
|
|
2
2
|
import { logger } from "../../services/logging";
|
|
3
|
+
import { redactSensitivePath } from "../../services/logging/redaction";
|
|
3
4
|
import type { AuthContext } from "./auth";
|
|
5
|
+
import { matchedRouteTemplate } from "./route-template";
|
|
4
6
|
|
|
5
7
|
const log = logger("http");
|
|
6
8
|
|
|
@@ -15,11 +17,18 @@ const SKIP_PREFIXES = ["/public/", "/_ssr/", "/favicon", "/branding/"];
|
|
|
15
17
|
* - Everything else (2xx, 3xx, 400, 404) → not logged (too noisy)
|
|
16
18
|
*/
|
|
17
19
|
export const requestLogger = createMiddleware<AuthContext>(async (c, next) => {
|
|
18
|
-
const
|
|
19
|
-
if (SKIP_PREFIXES.some((p) =>
|
|
20
|
+
const requestPath = c.req.path;
|
|
21
|
+
if (SKIP_PREFIXES.some((p) => requestPath.startsWith(p))) return next();
|
|
20
22
|
|
|
21
23
|
const start = Date.now();
|
|
22
24
|
await next();
|
|
25
|
+
let path: string | null = null;
|
|
26
|
+
try {
|
|
27
|
+
path = matchedRouteTemplate(c);
|
|
28
|
+
} catch {
|
|
29
|
+
// Best-effort: an unrouted context must never break request logging.
|
|
30
|
+
}
|
|
31
|
+
path ??= redactSensitivePath(requestPath);
|
|
23
32
|
const status = c.res.status;
|
|
24
33
|
const duration = Date.now() - start;
|
|
25
34
|
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { sql } from "bun";
|
|
3
3
|
import { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
|
|
4
4
|
import type { MutationResult } from "../../contracts/shared";
|
|
5
|
+
import { stripImageMetadata } from "../image-metadata";
|
|
5
6
|
|
|
6
7
|
export { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
|
|
7
8
|
|
|
@@ -95,10 +96,12 @@ export const setAvatar = async (params: { id: string; dataUrl: string }): Promis
|
|
|
95
96
|
const parsed = parseAvatarDataUrl(params.dataUrl);
|
|
96
97
|
if (!parsed.ok) return parsed;
|
|
97
98
|
|
|
99
|
+
const bytes = Buffer.from(stripImageMetadata(parsed.data.bytes));
|
|
100
|
+
const dataUrl = `data:${parsed.data.contentType};base64,${bytes.toString("base64")}`;
|
|
98
101
|
const rows = await sql<{ avatar_hash: string }[]>`
|
|
99
102
|
UPDATE auth.users
|
|
100
|
-
SET avatar_data_url = ${
|
|
101
|
-
avatar_hash = ${
|
|
103
|
+
SET avatar_data_url = ${dataUrl},
|
|
104
|
+
avatar_hash = ${hashAvatar(dataUrl)}
|
|
102
105
|
WHERE id = ${params.id}::uuid
|
|
103
106
|
RETURNING avatar_hash
|
|
104
107
|
`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
|
|
3
|
+
import { ensureSchema } from "../postgres";
|
|
3
4
|
import type { HelpCorpus, HelpMetadata } from "./types";
|
|
4
5
|
|
|
5
6
|
// One expired app lease of grace after an app can no longer be discovered.
|
|
@@ -10,7 +11,7 @@ const searchConfigs = ["english", "german", "simple"] as const;
|
|
|
10
11
|
export const migrateHelp = async (db: SQL = sql): Promise<void> => {
|
|
11
12
|
await db.begin(async (tx) => {
|
|
12
13
|
await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:help:migrate'))`;
|
|
13
|
-
await tx
|
|
14
|
+
await ensureSchema(tx, "help");
|
|
14
15
|
await tx`CREATE TABLE IF NOT EXISTS help.corpora (
|
|
15
16
|
app_id text NOT NULL, manifest_hash text NOT NULL, base_locale text NOT NULL,
|
|
16
17
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|