@agent-native/core 0.84.21 → 0.84.23
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/model-config.ts +1 -1
- package/corpus/core/src/cli/skills.ts +17 -3
- package/corpus/core/src/client/AssistantChat.tsx +27 -4
- package/corpus/core/src/client/guided-questions.tsx +11 -6
- package/corpus/core/src/client/tool-display.ts +8 -0
- package/corpus/core/src/file-upload/builder.ts +7 -2
- package/corpus/core/src/notifications/channels.ts +235 -38
- package/corpus/core/src/notifications/index.ts +1 -0
- package/corpus/core/src/notifications/registry.ts +13 -1
- package/corpus/core/src/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/AGENTS.md +21 -0
- package/corpus/templates/analytics/README.md +35 -0
- package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
- package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
- package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
- package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
- package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
- package/corpus/templates/analytics/server/db/schema.ts +55 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +74 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +803 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
- package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
- package/corpus/templates/analytics/server/plugins/db.ts +100 -0
- package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
- package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/assets/README.md +30 -8
- package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/calendar/README.md +22 -28
- package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/chat/README.md +32 -0
- package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
- package/corpus/templates/clips/README.md +37 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
- package/corpus/templates/clips/actions/reprocess-recording.ts +160 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
- package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +41 -1
- package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
- package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/content/AGENTS.md +7 -3
- package/corpus/templates/content/README.md +22 -34
- package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
- package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
- package/corpus/templates/content/app/i18n-data.ts +278 -40
- package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +75 -19
- package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/design/AGENTS.md +7 -6
- package/corpus/templates/design/README.md +33 -0
- package/corpus/templates/design/actions/delete-file.ts +86 -10
- package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
- package/corpus/templates/design/actions/present-design-variants.ts +11 -13
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/pages/DesignEditor.tsx +13 -3
- package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
- package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/videos/README.md +22 -201
- package/dist/agent/model-config.js +1 -1
- package/dist/agent/model-config.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +17 -3
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +28 -4
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +10 -6
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/client/tool-display.d.ts.map +1 -1
- package/dist/client/tool-display.js +8 -0
- package/dist/client/tool-display.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +6 -2
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/notifications/channels.d.ts +10 -0
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +172 -20
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +1 -1
- package/dist/notifications/index.js.map +1 -1
- package/dist/notifications/registry.d.ts +5 -0
- package/dist/notifications/registry.d.ts.map +1 -1
- package/dist/notifications/registry.js +4 -1
- package/dist/notifications/registry.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/analytics.d.ts +16 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +30 -7
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +28 -12
- package/dist/server/ssr-handler.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.84.23
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fde28eb: Expose notification delivery channel results so callers can distinguish custom-channel delivery from inbox persistence.
|
|
8
|
+
- fde28eb: Re-enable Claude Sonnet 5 as the visible and default Sonnet model now that the Builder gateway supports it.
|
|
9
|
+
- fde28eb: Sharpen the exported Design skill's quality bar to defeat "AI slop": frame the variant flow as separating taste/exploration/spec/code, pair every banned default with a concrete alternative, warn about second-order convergence, and add explicit guidance for building on an existing design system or codebase.
|
|
10
|
+
|
|
11
|
+
## 0.84.22
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 72d6a3e: Add built-in Slack webhook and email notification channels, keep chat status visible through auto-continuation handoffs, and make guided-question option values authoritative in follow-up context.
|
|
16
|
+
- 72d6a3e: Send both Builder upload compression-skip query params for compatibility with the production upload API rollout.
|
|
17
|
+
- 72d6a3e: Make the document Report-Only script-src CSP account for the framework-injected Google Analytics / Tag Manager loader and inline gtag config script, so GA no longer triggers a CSP violation on every page load (it was Report-Only, so GA was never actually blocked) and the policy stays safe to graduate to enforcement.
|
|
18
|
+
|
|
3
19
|
## 0.84.21
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.84.
|
|
3
|
+
"version": "0.84.23",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -120,7 +120,7 @@ export function getContextWindowForModel(modelId: string): number {
|
|
|
120
120
|
return DEFAULT_CONTEXT_WINDOW;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const ENABLE_CLAUDE_SONNET_5 =
|
|
123
|
+
const ENABLE_CLAUDE_SONNET_5 = true;
|
|
124
124
|
|
|
125
125
|
export const CLAUDE_SONNET_MODEL_ID = ENABLE_CLAUDE_SONNET_5
|
|
126
126
|
? "claude-sonnet-5"
|
|
@@ -363,14 +363,28 @@ iteration, or a human-in-the-loop choice among design directions.
|
|
|
363
363
|
|
|
364
364
|
## Design Quality Bar
|
|
365
365
|
|
|
366
|
+
Generic "AI slop" comes from letting one prompt set taste, explore, and emit code
|
|
367
|
+
at once — so the model returns the training-average (Inter, an indigo/violet
|
|
368
|
+
gradient, a centered hero, three rounded cards). The variant flow above exists to
|
|
369
|
+
separate those jobs; use it, and hold this bar:
|
|
370
|
+
|
|
366
371
|
- Before generating, name the concrete audience, the screen's primary job, and
|
|
367
372
|
the visual thesis. If the brief is vague, make a reasonable choice and state
|
|
368
373
|
it instead of producing a generic dashboard/landing-page default.
|
|
369
|
-
-
|
|
370
|
-
|
|
374
|
+
- Refuse the defaults, and pair every "don't" with a "do" (banning Inter alone
|
|
375
|
+
just makes you reach for Roboto). Avoid Inter/Roboto/system fonts, the
|
|
376
|
+
indigo/violet slop palette (\`#6366F1\`/\`#8B5CF6\`/\`#A855F7\`) and purple-on-white
|
|
377
|
+
gradients, and centered-hero + three-icon-card layouts; instead pick a
|
|
378
|
+
distinctive font pairing, one non-default palette family with a single decisive
|
|
379
|
+
accent, and an asymmetric layout with a clear focal point.
|
|
371
380
|
- Make each direction distinct in structure and behavior, not just palette.
|
|
372
381
|
Give every variant one memorable signature choice, then keep the surrounding
|
|
373
|
-
chrome disciplined.
|
|
382
|
+
chrome disciplined. Even your creative picks converge (Space Grotesk
|
|
383
|
+
everywhere) — vary deliberately so two directions never share a fingerprint.
|
|
384
|
+
- For existing products, inspect the current screen, design system, tokens, and
|
|
385
|
+
component language before inventing a new direction. Treat any drift back to a
|
|
386
|
+
default as a missing token to pin, and vary layout per screen so on-brand does
|
|
387
|
+
not become same-in-your-colors.
|
|
374
388
|
- Treat copy, data, and imagery as design material. Use realistic domain
|
|
375
389
|
content and first-party/generated assets when images matter; avoid lorem
|
|
376
390
|
ipsum, vague SaaS filler, and decorative placeholder boxes.
|
|
@@ -233,6 +233,7 @@ const PENDING_SELECTION_KEY = "pending-selection-context";
|
|
|
233
233
|
const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
|
|
234
234
|
const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
|
|
235
235
|
const ACTIVE_RUN_POLL_INTERVAL_MS = 150;
|
|
236
|
+
const AUTO_RESUME_STATUS_TIMEOUT_MS = 30_000;
|
|
236
237
|
// How long a single activity (model call, tool prep, long tool) must stay
|
|
237
238
|
// in-flight before its label is surfaced in the running indicator. Below this
|
|
238
239
|
// the indicator stays a steady "Thinking" so normal fast turns don't flicker
|
|
@@ -1410,6 +1411,7 @@ const AssistantChatInner = forwardRef<
|
|
|
1410
1411
|
// True during the 250ms continuation window and startup of the next chunk
|
|
1411
1412
|
// (adapter's auto-continue delay before POSTing the next chunk).
|
|
1412
1413
|
const [isAutoResuming, setIsAutoResuming] = useState(false);
|
|
1414
|
+
const autoResumeTimerRef = useRef<number | null>(null);
|
|
1413
1415
|
const [optimisticRunning, setOptimisticRunning] = useState(false);
|
|
1414
1416
|
const [runningActivityLabel, setRunningActivityLabel] = useState<
|
|
1415
1417
|
string | null
|
|
@@ -2491,20 +2493,41 @@ const AssistantChatInner = forwardRef<
|
|
|
2491
2493
|
const handler = (e: Event) => {
|
|
2492
2494
|
const detail = (e as CustomEvent).detail as { tabId?: string };
|
|
2493
2495
|
if (tabId && detail?.tabId && detail.tabId !== tabId) return;
|
|
2496
|
+
if (autoResumeTimerRef.current !== null) {
|
|
2497
|
+
window.clearTimeout(autoResumeTimerRef.current);
|
|
2498
|
+
}
|
|
2494
2499
|
setIsAutoResuming(true);
|
|
2500
|
+
autoResumeTimerRef.current = window.setTimeout(() => {
|
|
2501
|
+
autoResumeTimerRef.current = null;
|
|
2502
|
+
setIsAutoResuming(false);
|
|
2503
|
+
}, AUTO_RESUME_STATUS_TIMEOUT_MS);
|
|
2495
2504
|
};
|
|
2496
2505
|
window.addEventListener("agent-chat:auto-continue", handler);
|
|
2497
|
-
return () =>
|
|
2506
|
+
return () => {
|
|
2507
|
+
if (autoResumeTimerRef.current !== null) {
|
|
2508
|
+
window.clearTimeout(autoResumeTimerRef.current);
|
|
2509
|
+
autoResumeTimerRef.current = null;
|
|
2510
|
+
}
|
|
2498
2511
|
window.removeEventListener("agent-chat:auto-continue", handler);
|
|
2512
|
+
};
|
|
2499
2513
|
}, [tabId]);
|
|
2500
2514
|
|
|
2501
|
-
// Clear auto-resume
|
|
2515
|
+
// Clear auto-resume once the next chunk has visibly started or the user stops.
|
|
2516
|
+
// Do not clear solely because `isRunning` is false: auto-resume intentionally
|
|
2517
|
+
// covers that between-chunk gap so the chat never looks idle while work is
|
|
2518
|
+
// still scheduled.
|
|
2502
2519
|
useEffect(() => {
|
|
2503
|
-
if (
|
|
2520
|
+
if (isRunning || forceStopped) {
|
|
2521
|
+
if (autoResumeTimerRef.current !== null) {
|
|
2522
|
+
window.clearTimeout(autoResumeTimerRef.current);
|
|
2523
|
+
autoResumeTimerRef.current = null;
|
|
2524
|
+
}
|
|
2504
2525
|
setIsAutoResuming(false);
|
|
2526
|
+
}
|
|
2527
|
+
if (!isRunning && !isAutoResuming) {
|
|
2505
2528
|
resetRunningActivity();
|
|
2506
2529
|
}
|
|
2507
|
-
}, [isRunning, resetRunningActivity]);
|
|
2530
|
+
}, [forceStopped, isAutoResuming, isRunning, resetRunningActivity]);
|
|
2508
2531
|
|
|
2509
2532
|
// Auto-dequeue: when the agent is idle, send the next queued message. This
|
|
2510
2533
|
// intentionally does not depend on observing the running -> idle transition:
|
|
@@ -150,6 +150,16 @@ export function formatGuidedAnswersForAgent(
|
|
|
150
150
|
.join("\n");
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
function defaultGuidedSubmitContext(formattedAnswers: string): string {
|
|
154
|
+
return [
|
|
155
|
+
"The user answered the guided questions.",
|
|
156
|
+
"Use the selected option values below as authoritative. If an answer includes exact ids, file names, or action instructions, follow those exact details instead of inferring them.",
|
|
157
|
+
"",
|
|
158
|
+
"Answers:",
|
|
159
|
+
formattedAnswers,
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
/** A single option for {@link askUserQuestion}. Mirrors the agent `ask-question`
|
|
154
164
|
* tool and Claude Code's AskUserQuestion option shape. */
|
|
155
165
|
export interface AskUserQuestionOption {
|
|
@@ -1080,12 +1090,7 @@ export function useGuidedQuestionFlow({
|
|
|
1080
1090
|
const resolvedSubmitMessage = payload?.submitMessage ?? submitMessage;
|
|
1081
1091
|
const context =
|
|
1082
1092
|
buildSubmitContext?.({ answers, formattedAnswers }) ??
|
|
1083
|
-
|
|
1084
|
-
"The user answered the pre-generation questions.",
|
|
1085
|
-
"",
|
|
1086
|
-
"Answers:",
|
|
1087
|
-
formattedAnswers,
|
|
1088
|
-
].join("\n");
|
|
1093
|
+
defaultGuidedSubmitContext(formattedAnswers);
|
|
1089
1094
|
sendToAgentChat({
|
|
1090
1095
|
message: resolvedSubmitMessage,
|
|
1091
1096
|
context,
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
|
2
|
+
"delete-file": "delete screen",
|
|
3
|
+
"get-design-snapshot": "get screen snapshot",
|
|
4
|
+
"edit-design": "edit screen",
|
|
5
|
+
};
|
|
6
|
+
|
|
1
7
|
export function humanizeToolName(toolName: string | undefined): string {
|
|
2
8
|
const raw = (toolName ?? "").trim();
|
|
3
9
|
if (!raw) return "tool";
|
|
10
|
+
const displayName = TOOL_DISPLAY_NAMES[raw];
|
|
11
|
+
if (displayName) return displayName;
|
|
4
12
|
|
|
5
13
|
let name = raw;
|
|
6
14
|
if (name.startsWith("mcp__")) {
|
|
@@ -44,6 +44,11 @@ function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function setSkipCompressionQueryParams(url: URL): void {
|
|
48
|
+
url.searchParams.set("skipCompressionWait", "true");
|
|
49
|
+
url.searchParams.set("skipCompression", "true");
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
async function assertOk(res: Response, label: string): Promise<void> {
|
|
48
53
|
if (!res.ok) {
|
|
49
54
|
const body = await res.text().catch(() => "");
|
|
@@ -154,7 +159,7 @@ async function completeBuilderUpload(
|
|
|
154
159
|
const host = builderUploadHost();
|
|
155
160
|
const url = new URL("/api/v1/upload/complete", host);
|
|
156
161
|
if (options?.skipCompressionWait) {
|
|
157
|
-
url
|
|
162
|
+
setSkipCompressionQueryParams(url);
|
|
158
163
|
}
|
|
159
164
|
const res = await fetchWithTimeout(url.toString(), {
|
|
160
165
|
method: "POST",
|
|
@@ -255,7 +260,7 @@ export const builderFileUploadProvider: FileUploadProvider = {
|
|
|
255
260
|
const url = new URL("/api/v1/upload", builderUploadHost());
|
|
256
261
|
if (filename) url.searchParams.set("name", filename);
|
|
257
262
|
if (input.skipCompressionWait) {
|
|
258
|
-
url
|
|
263
|
+
setSkipCompressionQueryParams(url);
|
|
259
264
|
}
|
|
260
265
|
|
|
261
266
|
const response = await uploadSmallFile(url, {
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* value never enters the agent context.
|
|
11
11
|
* NOTIFICATIONS_WEBHOOK_AUTH → optional `Authorization` header value (also
|
|
12
12
|
* supports `${keys.NAME}`).
|
|
13
|
+
* NOTIFICATIONS_SLACK_WEBHOOK_URL
|
|
14
|
+
* → POST notifications to a Slack incoming webhook.
|
|
15
|
+
* Supports `${keys.NAME}` substitution.
|
|
16
|
+
* NOTIFICATIONS_EMAIL_CHANNEL=1
|
|
17
|
+
* → enable the built-in email channel for
|
|
18
|
+
* per-notification recipients.
|
|
19
|
+
* NOTIFICATIONS_EMAIL_RECIPIENTS
|
|
20
|
+
* → comma-separated fallback recipients for email
|
|
21
|
+
* notifications that do not pass
|
|
22
|
+
* `metadata.emailRecipients`.
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
25
|
import { ssrfSafeFetch } from "../extensions/url-safety.js";
|
|
@@ -18,6 +28,7 @@ import {
|
|
|
18
28
|
validateUrlAllowlist,
|
|
19
29
|
getKeyAllowlist,
|
|
20
30
|
} from "../secrets/substitution.js";
|
|
31
|
+
import { sendEmail } from "../server/email.js";
|
|
21
32
|
import { registerNotificationChannel } from "./registry.js";
|
|
22
33
|
import type { NotificationChannel } from "./types.js";
|
|
23
34
|
|
|
@@ -31,6 +42,16 @@ export function registerBuiltinNotificationChannels(): void {
|
|
|
31
42
|
if (url) {
|
|
32
43
|
registerNotificationChannel(createWebhookChannel(url));
|
|
33
44
|
}
|
|
45
|
+
|
|
46
|
+
const slackUrl = process.env.NOTIFICATIONS_SLACK_WEBHOOK_URL;
|
|
47
|
+
if (slackUrl) {
|
|
48
|
+
registerNotificationChannel(createSlackWebhookChannel(slackUrl));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const emailRecipients = process.env.NOTIFICATIONS_EMAIL_RECIPIENTS?.trim();
|
|
52
|
+
if (process.env.NOTIFICATIONS_EMAIL_CHANNEL === "1" || emailRecipients) {
|
|
53
|
+
registerNotificationChannel(createEmailChannel());
|
|
54
|
+
}
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
function createWebhookChannel(urlTemplate: string): NotificationChannel {
|
|
@@ -38,47 +59,12 @@ function createWebhookChannel(urlTemplate: string): NotificationChannel {
|
|
|
38
59
|
return {
|
|
39
60
|
name: "webhook",
|
|
40
61
|
async deliver(input, meta) {
|
|
41
|
-
|
|
42
|
-
// secrets. Missing keys throw — the error surfaces in logs and the
|
|
43
|
-
// channel is marked un-delivered, but other channels still run.
|
|
44
|
-
const { resolved: url } = await resolveKeyReferences(
|
|
62
|
+
const { url, headers } = await resolveWebhookRequest(
|
|
45
63
|
urlTemplate,
|
|
46
|
-
|
|
64
|
+
authTemplate,
|
|
47
65
|
meta.owner,
|
|
66
|
+
"webhook",
|
|
48
67
|
);
|
|
49
|
-
const headers: Record<string, string> = {
|
|
50
|
-
"Content-Type": "application/json",
|
|
51
|
-
};
|
|
52
|
-
if (authTemplate) {
|
|
53
|
-
const { resolved: auth } = await resolveKeyReferences(
|
|
54
|
-
authTemplate,
|
|
55
|
-
"user",
|
|
56
|
-
meta.owner,
|
|
57
|
-
);
|
|
58
|
-
headers.Authorization = auth;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// If the user set an allowlist on a referenced key, enforce it here —
|
|
62
|
-
// origin-level check, same rule the automations fetch-tool applies.
|
|
63
|
-
const keyNames = Array.from(
|
|
64
|
-
new Set(
|
|
65
|
-
Array.from(
|
|
66
|
-
urlTemplate.matchAll(/\$\{keys\.([A-Za-z0-9_-]+)\}/g),
|
|
67
|
-
(m) => m[1],
|
|
68
|
-
),
|
|
69
|
-
),
|
|
70
|
-
);
|
|
71
|
-
const allowlists = await Promise.all(
|
|
72
|
-
keyNames.map((name) => getKeyAllowlist(name, "user", meta.owner)),
|
|
73
|
-
);
|
|
74
|
-
keyNames.forEach((name, i) => {
|
|
75
|
-
if (!validateUrlAllowlist(url, allowlists[i])) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
`[notifications] webhook URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
68
|
const res = await ssrfSafeFetch(
|
|
83
69
|
url,
|
|
84
70
|
{
|
|
@@ -106,6 +92,217 @@ function createWebhookChannel(urlTemplate: string): NotificationChannel {
|
|
|
106
92
|
};
|
|
107
93
|
}
|
|
108
94
|
|
|
95
|
+
function createSlackWebhookChannel(urlTemplate: string): NotificationChannel {
|
|
96
|
+
const authTemplate = process.env.NOTIFICATIONS_SLACK_WEBHOOK_AUTH;
|
|
97
|
+
return {
|
|
98
|
+
name: "slack",
|
|
99
|
+
async deliver(input, meta) {
|
|
100
|
+
const { url, headers } = await resolveWebhookRequest(
|
|
101
|
+
urlTemplate,
|
|
102
|
+
authTemplate,
|
|
103
|
+
meta.owner,
|
|
104
|
+
"Slack webhook",
|
|
105
|
+
);
|
|
106
|
+
const res = await ssrfSafeFetch(
|
|
107
|
+
url,
|
|
108
|
+
{
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers,
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
text: slackText(input.severity, input.title, input.body),
|
|
113
|
+
blocks: [
|
|
114
|
+
{
|
|
115
|
+
type: "section",
|
|
116
|
+
text: {
|
|
117
|
+
type: "mrkdwn",
|
|
118
|
+
text: `*${escapeSlack(input.title)}*`,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
...(input.body
|
|
122
|
+
? [
|
|
123
|
+
{
|
|
124
|
+
type: "section",
|
|
125
|
+
text: {
|
|
126
|
+
type: "mrkdwn",
|
|
127
|
+
text: escapeSlack(input.body),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
]
|
|
131
|
+
: []),
|
|
132
|
+
{
|
|
133
|
+
type: "context",
|
|
134
|
+
elements: [
|
|
135
|
+
{
|
|
136
|
+
type: "mrkdwn",
|
|
137
|
+
text: `Severity: \`${input.severity}\` Owner: ${escapeSlack(meta.owner)}`,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
{ maxRedirects: 3 },
|
|
145
|
+
);
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`[notifications] Slack webhook ${new URL(url).origin} returned ${res.status}${
|
|
149
|
+
(await readErrorSnippet(res)) || ""
|
|
150
|
+
}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createEmailChannel(): NotificationChannel {
|
|
158
|
+
return {
|
|
159
|
+
name: "email",
|
|
160
|
+
async deliver(input) {
|
|
161
|
+
const recipients = notificationEmailRecipients(input.metadata);
|
|
162
|
+
if (recipients.length === 0) return;
|
|
163
|
+
const subject =
|
|
164
|
+
typeof input.metadata?.emailSubject === "string" &&
|
|
165
|
+
input.metadata.emailSubject.trim()
|
|
166
|
+
? input.metadata.emailSubject.trim()
|
|
167
|
+
: `[${input.severity}] ${input.title}`;
|
|
168
|
+
const metadata = scrubEmailMetadata(input.metadata);
|
|
169
|
+
const metadataText = metadata
|
|
170
|
+
? `\n\nMetadata:\n${JSON.stringify(metadata, null, 2)}`
|
|
171
|
+
: "";
|
|
172
|
+
const text = `${input.title}\n\n${input.body ?? ""}${metadataText}`;
|
|
173
|
+
const html = [
|
|
174
|
+
`<p><strong>${escapeHtml(input.title)}</strong></p>`,
|
|
175
|
+
input.body ? `<p>${escapeHtml(input.body)}</p>` : "",
|
|
176
|
+
metadata
|
|
177
|
+
? `<pre>${escapeHtml(JSON.stringify(metadata, null, 2))}</pre>`
|
|
178
|
+
: "",
|
|
179
|
+
].join("");
|
|
180
|
+
|
|
181
|
+
await Promise.all(
|
|
182
|
+
recipients.map((to) =>
|
|
183
|
+
sendEmail({
|
|
184
|
+
to,
|
|
185
|
+
subject,
|
|
186
|
+
text,
|
|
187
|
+
html,
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function resolveWebhookRequest(
|
|
196
|
+
urlTemplate: string,
|
|
197
|
+
authTemplate: string | undefined,
|
|
198
|
+
owner: string,
|
|
199
|
+
label: string,
|
|
200
|
+
): Promise<{ url: string; headers: Record<string, string> }> {
|
|
201
|
+
// Resolve `${keys.NAME}` references against the owner's user-scope secrets.
|
|
202
|
+
// Missing keys throw — the error surfaces in logs and the channel is marked
|
|
203
|
+
// un-delivered, but other channels still run.
|
|
204
|
+
const { resolved: url } = await resolveKeyReferences(
|
|
205
|
+
urlTemplate,
|
|
206
|
+
"user",
|
|
207
|
+
owner,
|
|
208
|
+
);
|
|
209
|
+
const headers: Record<string, string> = {
|
|
210
|
+
"Content-Type": "application/json",
|
|
211
|
+
};
|
|
212
|
+
if (authTemplate) {
|
|
213
|
+
const { resolved: auth } = await resolveKeyReferences(
|
|
214
|
+
authTemplate,
|
|
215
|
+
"user",
|
|
216
|
+
owner,
|
|
217
|
+
);
|
|
218
|
+
headers.Authorization = auth;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// If the user set an allowlist on a referenced key, enforce it here —
|
|
222
|
+
// origin-level check, same rule the automations fetch-tool applies.
|
|
223
|
+
const keyNames = Array.from(
|
|
224
|
+
new Set(
|
|
225
|
+
Array.from(urlTemplate.matchAll(/\$\{keys\.([A-Za-z0-9_-]+)\}/g), (m) =>
|
|
226
|
+
String(m[1]),
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
const allowlists = await Promise.all(
|
|
231
|
+
keyNames.map((name) => getKeyAllowlist(name, "user", owner)),
|
|
232
|
+
);
|
|
233
|
+
keyNames.forEach((name, i) => {
|
|
234
|
+
if (!validateUrlAllowlist(url, allowlists[i])) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`[notifications] ${label} URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return { url, headers };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function notificationEmailRecipients(
|
|
244
|
+
metadata: Record<string, unknown> | undefined,
|
|
245
|
+
): string[] {
|
|
246
|
+
const fromMetadata = stringArray(metadata?.emailRecipients);
|
|
247
|
+
const fromEnv = commaList(process.env.NOTIFICATIONS_EMAIL_RECIPIENTS);
|
|
248
|
+
const seen = new Set<string>();
|
|
249
|
+
const recipients: string[] = [];
|
|
250
|
+
for (const raw of [...fromMetadata, ...fromEnv]) {
|
|
251
|
+
const email = raw.trim().toLowerCase();
|
|
252
|
+
if (!email || seen.has(email)) continue;
|
|
253
|
+
seen.add(email);
|
|
254
|
+
recipients.push(email);
|
|
255
|
+
}
|
|
256
|
+
return recipients;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function stringArray(value: unknown): string[] {
|
|
260
|
+
if (!Array.isArray(value)) return [];
|
|
261
|
+
return value.filter((item): item is string => typeof item === "string");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function commaList(value: string | undefined): string[] {
|
|
265
|
+
return value
|
|
266
|
+
? value
|
|
267
|
+
.split(",")
|
|
268
|
+
.map((item) => item.trim())
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
: [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function scrubEmailMetadata(
|
|
274
|
+
metadata: Record<string, unknown> | undefined,
|
|
275
|
+
): Record<string, unknown> | null {
|
|
276
|
+
if (!metadata) return null;
|
|
277
|
+
const entries = Object.entries(metadata).filter(
|
|
278
|
+
([key]) => key !== "emailRecipients" && key !== "emailSubject",
|
|
279
|
+
);
|
|
280
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function slackText(
|
|
284
|
+
severity: string,
|
|
285
|
+
title: string,
|
|
286
|
+
body: string | undefined,
|
|
287
|
+
): string {
|
|
288
|
+
return `[${severity}] ${title}${body ? `\n${body}` : ""}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function escapeSlack(value: string): string {
|
|
292
|
+
return value
|
|
293
|
+
.replace(/&/g, "&")
|
|
294
|
+
.replace(/</g, "<")
|
|
295
|
+
.replace(/>/g, ">");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function escapeHtml(value: string): string {
|
|
299
|
+
return value
|
|
300
|
+
.replace(/&/g, "&")
|
|
301
|
+
.replace(/</g, "<")
|
|
302
|
+
.replace(/>/g, ">")
|
|
303
|
+
.replace(/"/g, """);
|
|
304
|
+
}
|
|
305
|
+
|
|
109
306
|
/**
|
|
110
307
|
* Read up to ~1 KB from the body for error context. Streams chunks so a
|
|
111
308
|
* misbehaving endpoint returning a large error page doesn't pin that whole
|
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
type Notification,
|
|
14
14
|
} from "./types.js";
|
|
15
15
|
|
|
16
|
+
export interface NotificationDeliveryResult {
|
|
17
|
+
notification?: Notification;
|
|
18
|
+
deliveredChannels: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
registerEvent({
|
|
17
22
|
name: "notification.sent",
|
|
18
23
|
description:
|
|
@@ -84,6 +89,13 @@ export async function notify(
|
|
|
84
89
|
input: NotificationInput,
|
|
85
90
|
meta: NotificationMeta,
|
|
86
91
|
): Promise<Notification | undefined> {
|
|
92
|
+
return (await notifyWithDelivery(input, meta)).notification;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function notifyWithDelivery(
|
|
96
|
+
input: NotificationInput,
|
|
97
|
+
meta: NotificationMeta,
|
|
98
|
+
): Promise<NotificationDeliveryResult> {
|
|
87
99
|
if (!meta?.owner) {
|
|
88
100
|
throw new Error("notify: meta.owner is required");
|
|
89
101
|
}
|
|
@@ -166,7 +178,7 @@ export async function notify(
|
|
|
166
178
|
}
|
|
167
179
|
}
|
|
168
180
|
|
|
169
|
-
return stored;
|
|
181
|
+
return { notification: stored, deliveredChannels: delivered };
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
function selectChannels(allowlist?: string[]): NotificationChannel[] {
|
|
@@ -39,14 +39,30 @@ function getGaMeasurementId(): string | null {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Script hosts the injected GA loader pulls executable code from. Google Tag
|
|
44
|
+
* Manager serves `gtag/js`, and GA4 can lazy-load additional collectors from
|
|
45
|
+
* `www.google-analytics.com`. These must be listed in the document `script-src`
|
|
46
|
+
* so the CSP reflects the code the framework itself injects (see
|
|
47
|
+
* `applyDocumentCsp` in `ssr-handler.ts`).
|
|
48
|
+
*/
|
|
49
|
+
export const GA_CSP_SCRIPT_HOSTS = [
|
|
50
|
+
"https://www.googletagmanager.com",
|
|
51
|
+
"https://www.google-analytics.com",
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The exact JS body (no surrounding `<script>` tags) of the inline gtag config
|
|
56
|
+
* block injected next to the gtag.js loader. Returned so the SSR handler can
|
|
57
|
+
* hash it for the `script-src` CSP directive — the hash must be computed from
|
|
58
|
+
* the identical string that `getGaScript()` embeds, so both call this helper.
|
|
59
|
+
* Returns `null` when GA is not configured.
|
|
60
|
+
*/
|
|
61
|
+
export function getGaInlineConfigScriptBody(): string | null {
|
|
43
62
|
const id = getGaMeasurementId();
|
|
44
63
|
if (!id) return null;
|
|
45
|
-
const srcId = encodeURIComponent(id);
|
|
46
64
|
const jsId = JSON.stringify(id);
|
|
47
65
|
return (
|
|
48
|
-
`<script async src="https://www.googletagmanager.com/gtag/js?id=${srcId}"></script>` +
|
|
49
|
-
`<script>` +
|
|
50
66
|
`window.dataLayer=window.dataLayer||[];` +
|
|
51
67
|
`function gtag(){dataLayer.push(arguments);}` +
|
|
52
68
|
`gtag('js',new Date());` +
|
|
@@ -54,8 +70,18 @@ function getGaScript(): string | null {
|
|
|
54
70
|
`if(typeof sessionStorage!=='undefined'&&sessionStorage.getItem('__an_signin')){` +
|
|
55
71
|
`sessionStorage.removeItem('__an_signin');` +
|
|
56
72
|
`gtag('event','sign_in');` +
|
|
57
|
-
`}`
|
|
58
|
-
|
|
73
|
+
`}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getGaScript(): string | null {
|
|
78
|
+
const id = getGaMeasurementId();
|
|
79
|
+
if (!id) return null;
|
|
80
|
+
const srcId = encodeURIComponent(id);
|
|
81
|
+
const inlineBody = getGaInlineConfigScriptBody();
|
|
82
|
+
return (
|
|
83
|
+
`<script async src="https://www.googletagmanager.com/gtag/js?id=${srcId}"></script>` +
|
|
84
|
+
`<script>${inlineBody}</script>`
|
|
59
85
|
);
|
|
60
86
|
}
|
|
61
87
|
|