@agent-native/core 0.84.20 → 0.84.22
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/a2a/caller-auth.ts +37 -8
- package/corpus/core/src/a2a/client.ts +178 -67
- package/corpus/core/src/agent/production-agent.ts +19 -3
- package/corpus/core/src/agent/types.ts +1 -1
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
- package/corpus/core/src/cli/recap.ts +43 -34
- package/corpus/core/src/client/AssistantChat.tsx +27 -4
- package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
- package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
- package/corpus/core/src/client/guided-questions.tsx +11 -6
- package/corpus/core/src/client/i18n.tsx +10 -5
- package/corpus/core/src/client/sse-event-processor.ts +147 -5
- package/corpus/core/src/client/tool-display.ts +8 -0
- package/corpus/core/src/file-upload/builder.ts +40 -14
- package/corpus/core/src/file-upload/types.ts +3 -0
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
- package/corpus/core/src/mcp/builtin-tools.ts +3 -0
- package/corpus/core/src/mcp/org-directory.ts +66 -20
- package/corpus/core/src/notifications/channels.ts +235 -38
- package/corpus/core/src/scripts/call-agent.ts +7 -2
- package/corpus/core/src/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- 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/app/pages/sessions/SessionDetailPage.tsx +161 -12
- 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-load-large-production-recordings-from-scoped.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 +109 -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 +71 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -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/README.md +30 -8
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/README.md +22 -28
- package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
- package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
- package/corpus/templates/chat/README.md +32 -0
- package/corpus/templates/clips/README.md +37 -0
- package/corpus/templates/clips/actions/create-recording.ts +7 -2
- package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
- package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
- package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
- package/corpus/templates/clips/actions/lib/loom-video.ts +16 -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/player/scrubber.tsx +2 -2
- package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +106 -2
- package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
- package/corpus/templates/clips/app/routes/record.tsx +48 -8
- package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
- 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-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +64 -6
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
- package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +51 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
- package/corpus/templates/clips/shared/finalize-recovery.ts +242 -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.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 +2 -2
- package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/plan/app/global.css +1 -1
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/README.md +22 -201
- package/dist/a2a/caller-auth.d.ts +1 -0
- package/dist/a2a/caller-auth.d.ts.map +1 -1
- package/dist/a2a/caller-auth.js +34 -8
- package/dist/a2a/caller-auth.js.map +1 -1
- package/dist/a2a/client.d.ts +4 -0
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +148 -58
- package/dist/a2a/client.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +19 -4
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/types.d.ts +1 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts +3 -3
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +41 -34
- package/dist/cli/recap.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/PoweredByBadge.d.ts +9 -3
- package/dist/client/PoweredByBadge.d.ts.map +1 -1
- package/dist/client/PoweredByBadge.js +47 -15
- package/dist/client/PoweredByBadge.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +5 -1
- package/dist/client/agent-chat-adapter.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/i18n.d.ts +1 -1
- package/dist/client/i18n.d.ts.map +1 -1
- package/dist/client/i18n.js +6 -2
- package/dist/client/i18n.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +2 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +121 -5
- package/dist/client/sse-event-processor.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/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.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 +26 -8
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/types.d.ts +5 -1
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/integrations/a2a-continuation-processor.js +43 -18
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +3 -0
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/mcp/org-directory.d.ts.map +1 -1
- package/dist/mcp/org-directory.js +58 -20
- package/dist/mcp/org-directory.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/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +2 -2
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.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/package.json +1 -1
|
@@ -146,6 +146,15 @@ function scopedCacheKey(
|
|
|
146
146
|
].join("|");
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function authTokenAttempts(auth: {
|
|
150
|
+
apiKey?: string;
|
|
151
|
+
apiKeyFallbacks?: string[];
|
|
152
|
+
}): string[] {
|
|
153
|
+
return [auth.apiKey, ...(auth.apiKeyFallbacks ?? [])].filter(
|
|
154
|
+
(token): token is string => typeof token === "string" && token.length > 0,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
149
158
|
function serviceScopedCacheKey(origin: string, orgId: string): string {
|
|
150
159
|
return [origin, `service-org:${orgId}`].join("|");
|
|
151
160
|
}
|
|
@@ -200,7 +209,8 @@ export async function fetchOrgApps(opts?: {
|
|
|
200
209
|
const auth = serviceOrgId
|
|
201
210
|
? await resolveOrgDirectoryServiceAuth(serviceOrgId)
|
|
202
211
|
: await resolveOrgDirectoryCallerAuth();
|
|
203
|
-
|
|
212
|
+
const attempts = authTokenAttempts(auth);
|
|
213
|
+
if (attempts.length === 0) {
|
|
204
214
|
// No signed token available (no A2A secret / no caller identity) — the
|
|
205
215
|
// directory requires the org bearer, so degrade silently to local-only.
|
|
206
216
|
return [];
|
|
@@ -215,19 +225,23 @@ export async function fetchOrgApps(opts?: {
|
|
|
215
225
|
}
|
|
216
226
|
}
|
|
217
227
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
for (let i = 0; i < attempts.length; i++) {
|
|
229
|
+
const res = await fetch(`${origin}/_agent-native/org/apps`, {
|
|
230
|
+
method: "GET",
|
|
231
|
+
headers: {
|
|
232
|
+
Authorization: `Bearer ${attempts[i]}`,
|
|
233
|
+
Accept: "application/json",
|
|
234
|
+
},
|
|
235
|
+
signal: AbortSignal.timeout(4000),
|
|
236
|
+
});
|
|
237
|
+
if (res.ok) {
|
|
238
|
+
const json = (await res.json()) as { apps?: unknown };
|
|
239
|
+
const list = Array.isArray(json?.apps) ? json.apps : [];
|
|
240
|
+
apps = list.map(normalizeApp).filter((a): a is OrgApp => a !== null);
|
|
241
|
+
ttl = SUCCESS_TTL_MS;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
if (res.status !== 401 || i >= attempts.length - 1) break;
|
|
231
245
|
}
|
|
232
246
|
// Non-2xx ⇒ leave apps=[] with the short EMPTY_TTL (silent degrade).
|
|
233
247
|
} catch {
|
|
@@ -252,6 +266,7 @@ export function _resetOrgDirectoryCache(): void {
|
|
|
252
266
|
|
|
253
267
|
async function resolveOrgDirectoryCallerAuth(): Promise<{
|
|
254
268
|
apiKey?: string;
|
|
269
|
+
apiKeyFallbacks?: string[];
|
|
255
270
|
userEmail?: string;
|
|
256
271
|
orgId?: string;
|
|
257
272
|
orgDomain?: string;
|
|
@@ -266,6 +281,7 @@ async function resolveOrgDirectoryCallerAuth(): Promise<{
|
|
|
266
281
|
|
|
267
282
|
async function resolveOrgDirectoryServiceAuth(orgId: string): Promise<{
|
|
268
283
|
apiKey?: string;
|
|
284
|
+
apiKeyFallbacks?: string[];
|
|
269
285
|
userEmail?: string;
|
|
270
286
|
orgId?: string;
|
|
271
287
|
orgDomain?: string;
|
|
@@ -285,12 +301,42 @@ async function resolveOrgDirectoryServiceAuth(orgId: string): Promise<{
|
|
|
285
301
|
import("./connect-store.js"),
|
|
286
302
|
]);
|
|
287
303
|
const userEmail = serviceIdentityEmail("mcp-client", trimmedOrgId);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
304
|
+
const apiKeyAttempts: string[] = [];
|
|
305
|
+
const addApiKeyAttempt = (token: string | undefined) => {
|
|
306
|
+
if (!token || apiKeyAttempts.includes(token)) return;
|
|
307
|
+
apiKeyAttempts.push(token);
|
|
308
|
+
};
|
|
309
|
+
if (process.env.A2A_SECRET?.trim()) {
|
|
310
|
+
try {
|
|
311
|
+
addApiKeyAttempt(
|
|
312
|
+
await signA2AToken(userEmail, orgDomain, orgSecret, {
|
|
313
|
+
expiresIn: "5m",
|
|
314
|
+
preferGlobalSecret: true,
|
|
315
|
+
extraClaims: { org_id: trimmedOrgId },
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
if (orgSecret) {
|
|
321
|
+
try {
|
|
322
|
+
addApiKeyAttempt(
|
|
323
|
+
await signA2AToken(userEmail, orgDomain, orgSecret, {
|
|
324
|
+
expiresIn: "5m",
|
|
325
|
+
preferGlobalSecret: false,
|
|
326
|
+
extraClaims: { org_id: trimmedOrgId },
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
} catch {}
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
apiKey: apiKeyAttempts[0],
|
|
333
|
+
...(apiKeyAttempts.length > 1
|
|
334
|
+
? { apiKeyFallbacks: apiKeyAttempts.slice(1) }
|
|
335
|
+
: {}),
|
|
336
|
+
userEmail,
|
|
337
|
+
orgId: trimmedOrgId,
|
|
338
|
+
orgDomain,
|
|
339
|
+
};
|
|
294
340
|
} catch {
|
|
295
341
|
return { orgId: trimmedOrgId, orgDomain };
|
|
296
342
|
}
|
|
@@ -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
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
A2ATaskTimeoutError,
|
|
5
|
+
callAgent,
|
|
6
|
+
shouldPreferGlobalA2ASecret,
|
|
7
|
+
signA2AToken,
|
|
8
|
+
} from "../a2a/client.js";
|
|
4
9
|
import {
|
|
5
10
|
formatLlmCredentialErrorMessage,
|
|
6
11
|
isLlmCredentialError,
|
|
@@ -162,7 +167,7 @@ export async function run(
|
|
|
162
167
|
callerOrgSecret,
|
|
163
168
|
{
|
|
164
169
|
expiresIn: INTEGRATION_A2A_TOKEN_TTL,
|
|
165
|
-
preferGlobalSecret:
|
|
170
|
+
preferGlobalSecret: shouldPreferGlobalA2ASecret(callerOrgSecret),
|
|
166
171
|
},
|
|
167
172
|
);
|
|
168
173
|
} catch {}
|
|
@@ -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
|
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
AGENT_NATIVE_SOCIAL_IMAGE_WIDTH,
|
|
31
31
|
withAgentNativeSocialImageCacheBuster,
|
|
32
32
|
} from "../shared/social-meta.js";
|
|
33
|
+
import {
|
|
34
|
+
GA_CSP_SCRIPT_HOSTS,
|
|
35
|
+
getGaInlineConfigScriptBody,
|
|
36
|
+
} from "./analytics.js";
|
|
33
37
|
import {
|
|
34
38
|
getAppBasePathFromViteEnv,
|
|
35
39
|
stripAppBasePath as canonicalStripAppBasePath,
|
|
@@ -278,13 +282,18 @@ function extractScriptBody(scriptTag: string | null): string | null {
|
|
|
278
282
|
* user-controlled content reaches the HTML).
|
|
279
283
|
*
|
|
280
284
|
* A third directive, `script-src`, is emitted via `Content-Security-Policy-
|
|
281
|
-
* Report-Only` rather than enforced. The framework injects
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
285
|
+
* Report-Only` rather than enforced. The framework injects deterministic inline
|
|
286
|
+
* scripts (the Sentry config block, whose hash is computed once at process
|
|
287
|
+
* startup from the resolved env vars, and — when `GA_MEASUREMENT_ID` is set —
|
|
288
|
+
* the gtag config block, whose hash is derived from the same string
|
|
289
|
+
* `wrapWithAnalytics` embeds). It also loads Google Tag Manager / GA4 from
|
|
290
|
+
* `GA_CSP_SCRIPT_HOSTS`. All of those are listed here so the report-only policy
|
|
291
|
+
* reflects the code the framework itself injects instead of reporting a
|
|
292
|
+
* violation on every page load. Templates additionally render a theme-init
|
|
293
|
+
* inline script whose exact content varies by template (default theme param,
|
|
294
|
+
* custom docs variant, etc.) and which is rendered by React Router, not this
|
|
295
|
+
* handler, so its hash is not available here. Shipping script-src as
|
|
296
|
+
* Report-Only surfaces the remaining violations without breaking template
|
|
288
297
|
* customisations; teams can graduate to enforcement once their hashes are
|
|
289
298
|
* enumerated.
|
|
290
299
|
*
|
|
@@ -306,13 +315,23 @@ function applyDocumentCsp(headers: Headers, sentryScript: string | null): void {
|
|
|
306
315
|
);
|
|
307
316
|
}
|
|
308
317
|
|
|
309
|
-
// script-src as Report-Only: list 'self'
|
|
310
|
-
// script
|
|
311
|
-
//
|
|
312
|
-
//
|
|
318
|
+
// script-src as Report-Only: list 'self', the framework-injected inline
|
|
319
|
+
// script hashes (Sentry config + gtag config), and the Google Analytics /
|
|
320
|
+
// Tag Manager loader hosts. These are exactly the scripts the framework
|
|
321
|
+
// itself injects, so listing them keeps the report-only policy from flagging
|
|
322
|
+
// GA on every page load (and keeps it safe to graduate to enforcement).
|
|
323
|
+
// Template theme-init hashes are NOT included here — see function comment.
|
|
313
324
|
const sentryBody = extractScriptBody(sentryScript);
|
|
314
325
|
const sentryHash = sentryBody ? computeInlineScriptHash(sentryBody) : null;
|
|
315
|
-
const
|
|
326
|
+
const gaInlineBody = getGaInlineConfigScriptBody();
|
|
327
|
+
const gaHash = gaInlineBody ? computeInlineScriptHash(gaInlineBody) : null;
|
|
328
|
+
const gaHosts = gaInlineBody ? [...GA_CSP_SCRIPT_HOSTS] : [];
|
|
329
|
+
const scriptSrcTokens = [
|
|
330
|
+
"'self'",
|
|
331
|
+
...(sentryHash ? [sentryHash] : []),
|
|
332
|
+
...(gaHash ? [gaHash] : []),
|
|
333
|
+
...gaHosts,
|
|
334
|
+
];
|
|
316
335
|
const scriptSrc = `script-src ${scriptSrcTokens.join(" ")}`;
|
|
317
336
|
|
|
318
337
|
const existingRo = headers.get("content-security-policy-report-only") ?? "";
|
|
@@ -107,6 +107,27 @@ details live in `.agents/skills/`.
|
|
|
107
107
|
`@sparticuz/chromium-min` in serverless runtimes; set
|
|
108
108
|
`DASHBOARD_REPORT_CHROMIUM_PACK_URL` only when overriding the default
|
|
109
109
|
Chromium pack location.
|
|
110
|
+
- Analytics alert rules live in SQL via the `analytics-alert-rules` actions.
|
|
111
|
+
Use `save-analytics-alert-rule`, `list-analytics-alert-rules`,
|
|
112
|
+
`delete-analytics-alert-rule`, and `run-analytics-alerts`; do not hardcode
|
|
113
|
+
one-off alert checks in product code. Rules are generic over first-party
|
|
114
|
+
analytics events: set `eventName` for an exact event name, then filter on
|
|
115
|
+
columns like `event_name`, `app`, `template`, `user_key`, `session_id`,
|
|
116
|
+
`path`, or nested JSON fields like `properties.stage` and
|
|
117
|
+
`context.referrer`. `thresholdMode: "event_count"` counts matching events;
|
|
118
|
+
`thresholdMode: "distinct_count"` counts unique values from `distinctBy`.
|
|
119
|
+
Alert notifications use the shared notification channel registry, so
|
|
120
|
+
`channels` can include `inbox`, `email`, `slack`, `webhook`, or any custom
|
|
121
|
+
registered channel. Configure Slack with `NOTIFICATIONS_SLACK_WEBHOOK_URL`
|
|
122
|
+
and optional `NOTIFICATIONS_SLACK_WEBHOOK_AUTH`; configure email with
|
|
123
|
+
`NOTIFICATIONS_EMAIL_CHANNEL=1`, existing `RESEND_API_KEY` or
|
|
124
|
+
`SENDGRID_API_KEY` plus `EMAIL_FROM`, and pass per-rule `emailRecipients` or
|
|
125
|
+
the fallback `NOTIFICATIONS_EMAIL_RECIPIENTS`.
|
|
126
|
+
Netlify builds emit an alert cron trigger plus background worker from
|
|
127
|
+
`scripts/emit-netlify-dashboard-report-cron.ts` every five minutes; long-lived
|
|
128
|
+
runtimes use the in-process scheduler unless `ANALYTICS_ALERT_JOBS=0` is set.
|
|
129
|
+
External cron callers can POST `/api/analytics-alerts/run` with
|
|
130
|
+
`Authorization: Bearer $ANALYTICS_ALERTS_CRON_SECRET`.
|
|
110
131
|
|
|
111
132
|
## Application State
|
|
112
133
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Analytics
|
|
2
|
+
|
|
3
|
+
An open-source, agent-native alternative to Amplitude, Mixpanel, and Looker. Ask
|
|
4
|
+
analytics questions in plain English and get charts, dashboards, and re-runnable
|
|
5
|
+
investigations back — you own the code, the queries, and the data.
|
|
6
|
+
|
|
7
|
+
**Live app: [analytics.agent-native.com](https://analytics.agent-native.com)**
|
|
8
|
+
|
|
9
|
+
The agent connects to your data, writes and validates the SQL for you, and
|
|
10
|
+
renders the answer as a chart, table, metric, or saved dashboard panel. Anything
|
|
11
|
+
you can do in the UI, the agent can do through the same actions.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- Ask data questions in plain English and get charts, tables, or metrics back.
|
|
16
|
+
- Connect BigQuery, GA4, Mixpanel, Amplitude, PostHog, HubSpot, Jira, and more,
|
|
17
|
+
plus a built-in first-party event collector.
|
|
18
|
+
- Build reusable SQL dashboards with filters, saved views, and parametric queries.
|
|
19
|
+
- Save ad-hoc analyses as re-runnable investigations with question, instructions,
|
|
20
|
+
and findings.
|
|
21
|
+
- Maintain a living data dictionary so the agent uses real warehouse column names.
|
|
22
|
+
- Share dashboards per-user or per-org with viewer / editor / admin roles.
|
|
23
|
+
|
|
24
|
+
## Develop locally
|
|
25
|
+
|
|
26
|
+
Scaffold your own copy and run it:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @agent-native/core@latest create my-analytics --standalone --template analytics
|
|
30
|
+
cd my-analytics
|
|
31
|
+
pnpm install
|
|
32
|
+
pnpm dev
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Full docs: [agent-native.com/docs/template-analytics](https://agent-native.com/docs/template-analytics).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import {
|
|
3
|
+
getRequestOrgId,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
} from "@agent-native/core/server";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { deleteAnalyticsAlertRule } from "../server/lib/analytics-alerts";
|
|
9
|
+
|
|
10
|
+
export default defineAction({
|
|
11
|
+
description: "Delete a reusable first-party analytics alert rule.",
|
|
12
|
+
schema: z.object({
|
|
13
|
+
id: z.string().describe("Alert rule ID"),
|
|
14
|
+
}),
|
|
15
|
+
http: { method: "POST" },
|
|
16
|
+
run: async ({ id }) => {
|
|
17
|
+
const email = getRequestUserEmail();
|
|
18
|
+
if (!email) throw new Error("no authenticated user");
|
|
19
|
+
const orgId = getRequestOrgId() || null;
|
|
20
|
+
await deleteAnalyticsAlertRule(id, { email, orgId });
|
|
21
|
+
return { ok: true };
|
|
22
|
+
},
|
|
23
|
+
});
|