@agent-native/core 0.77.6 → 0.77.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +3 -3
  4. package/corpus/core/src/agent/thread-data-builder.ts +21 -2
  5. package/corpus/core/src/cli/create.ts +35 -1
  6. package/corpus/core/src/cli/skills.ts +8 -0
  7. package/corpus/core/src/client/AgentPanel.tsx +57 -25
  8. package/corpus/core/src/client/MultiTabAssistantChat.tsx +31 -1
  9. package/corpus/core/src/client/analytics.ts +24 -2
  10. package/corpus/core/src/client/use-agent-engine-configured.ts +29 -15
  11. package/corpus/core/src/client/use-chat-threads.ts +104 -55
  12. package/corpus/core/src/server/analytics.ts +27 -3
  13. package/corpus/core/src/sharing/access.ts +35 -13
  14. package/corpus/core/src/sharing/actions/share-resource.ts +32 -9
  15. package/corpus/core/src/sharing/actions/unshare-resource.ts +25 -2
  16. package/corpus/core/src/sharing/registry.ts +9 -0
  17. package/corpus/core/src/vite/client.ts +3 -0
  18. package/corpus/templates/analytics/actions/data-source-status.ts +25 -14
  19. package/corpus/templates/analytics/app/components/layout/NewAnalysisDialog.tsx +2 -2
  20. package/corpus/templates/analytics/app/lib/data-source-status.ts +47 -6
  21. package/corpus/templates/analytics/app/lib/data-sources.ts +14 -3
  22. package/corpus/templates/analytics/app/pages/DataSources.tsx +24 -5
  23. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +15 -43
  24. package/corpus/templates/analytics/changelog/2026-06-25-fixed-hubspot-data-source-status-so-private-app-tokens-legac.md +6 -0
  25. package/corpus/templates/analytics/netlify.toml +1 -0
  26. package/corpus/templates/analytics/server/lib/credential-keys.ts +3 -1
  27. package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +12 -3
  28. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +5 -2
  29. package/corpus/templates/assets/changelog/2026-06-25-create-now-returns-from-agent-settings-to-the-asset-chat-eve.md +6 -0
  30. package/corpus/templates/assets/netlify.toml +1 -0
  31. package/corpus/templates/brain/netlify.toml +1 -0
  32. package/corpus/templates/calendar/app/i18n-data.ts +12 -0
  33. package/corpus/templates/calendar/app/lib/clipboard.ts +58 -0
  34. package/corpus/templates/calendar/app/pages/AvailabilitySettings.tsx +15 -9
  35. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +6 -2
  36. package/corpus/templates/calendar/changelog/2026-06-25-booking-link-copy-buttons-no-longer-fail-when-clipboard-perm.md +6 -0
  37. package/corpus/templates/calendar/netlify.toml +1 -0
  38. package/corpus/templates/chat/netlify.toml +1 -0
  39. package/corpus/templates/clips/AGENTS.md +9 -7
  40. package/corpus/templates/clips/actions/add-vocabulary-term.ts +2 -2
  41. package/corpus/templates/clips/actions/archive-recording.ts +5 -2
  42. package/corpus/templates/clips/actions/clear-edits.ts +5 -2
  43. package/corpus/templates/clips/actions/create-folder.ts +7 -2
  44. package/corpus/templates/clips/actions/delete-folder.ts +7 -4
  45. package/corpus/templates/clips/actions/delete-recording-permanent.ts +5 -2
  46. package/corpus/templates/clips/actions/finalize-recording.ts +5 -2
  47. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -1
  48. package/corpus/templates/clips/actions/list-notifications.ts +9 -5
  49. package/corpus/templates/clips/actions/list-organization-state.ts +2 -1
  50. package/corpus/templates/clips/actions/list-recordings.ts +4 -1
  51. package/corpus/templates/clips/actions/move-recording.ts +5 -1
  52. package/corpus/templates/clips/actions/rename-folder.ts +6 -3
  53. package/corpus/templates/clips/actions/request-transcript.ts +5 -2
  54. package/corpus/templates/clips/actions/restore-recording.ts +5 -2
  55. package/corpus/templates/clips/actions/save-browser-diagnostics.ts +21 -8
  56. package/corpus/templates/clips/actions/set-chapters.ts +5 -2
  57. package/corpus/templates/clips/actions/set-thumbnail.ts +5 -2
  58. package/corpus/templates/clips/actions/split-recording.ts +5 -2
  59. package/corpus/templates/clips/actions/stitch-recordings.ts +6 -2
  60. package/corpus/templates/clips/actions/trash-recording.ts +5 -2
  61. package/corpus/templates/clips/actions/undo-edit.ts +5 -2
  62. package/corpus/templates/clips/actions/view-screen.ts +2 -1
  63. package/corpus/templates/clips/app/components/library/recording-card.tsx +24 -7
  64. package/corpus/templates/clips/app/lib/browser-diagnostics-capture.ts +5 -1
  65. package/corpus/templates/clips/changelog/2026-06-25-chrome-extension-recordings-now-upload-large-capture-bursts.md +6 -0
  66. package/corpus/templates/clips/changelog/2026-06-25-console-diagnostics-keep-more-useful-non-secret-details-when.md +6 -0
  67. package/corpus/templates/clips/changelog/2026-06-25-fixed-owner-recognition-so-clips-created-from-desktop-and-we.md +6 -0
  68. package/corpus/templates/clips/chrome-extension/package.json +3 -0
  69. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  70. package/corpus/templates/clips/chrome-extension/src/background.ts +85 -9
  71. package/corpus/templates/clips/chrome-extension/src/content-script.ts +55 -0
  72. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +79 -3
  73. package/corpus/templates/clips/chrome-extension/src/options.ts +10 -0
  74. package/corpus/templates/clips/chrome-extension/src/overlay.ts +6 -0
  75. package/corpus/templates/clips/chrome-extension/src/permission.ts +8 -1
  76. package/corpus/templates/clips/chrome-extension/src/popup.ts +65 -39
  77. package/corpus/templates/clips/chrome-extension/src/sentry.ts +206 -0
  78. package/corpus/templates/clips/chrome-extension/vite.config.ts +47 -0
  79. package/corpus/templates/clips/netlify.toml +1 -0
  80. package/corpus/templates/clips/server/db/index.ts +1 -0
  81. package/corpus/templates/clips/server/lib/google-calendar-oauth.ts +35 -23
  82. package/corpus/templates/clips/server/lib/public-agent-context.ts +8 -1
  83. package/corpus/templates/clips/server/lib/recordings.ts +18 -2
  84. package/corpus/templates/clips/server/lib/slack-oauth.ts +9 -6
  85. package/corpus/templates/clips/server/plugins/onboarding-calendar.ts +6 -2
  86. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +2 -2
  87. package/corpus/templates/clips/server/routes/api/recordings/[recordingId]/thumbnail.post.ts +5 -2
  88. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +5 -2
  89. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +5 -2
  90. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +5 -2
  91. package/corpus/templates/clips/shared/browser-diagnostics.ts +14 -4
  92. package/corpus/templates/content/netlify.toml +1 -0
  93. package/corpus/templates/design/netlify.toml +1 -0
  94. package/corpus/templates/dispatch/netlify.toml +1 -0
  95. package/corpus/templates/forms/netlify.toml +1 -0
  96. package/corpus/templates/macros/netlify.toml +1 -0
  97. package/corpus/templates/mail/app/components/email/EmailList.tsx +7 -3
  98. package/corpus/templates/mail/app/components/email/EmailThread.tsx +4 -1
  99. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +10 -2
  100. package/corpus/templates/mail/app/hooks/use-compose-state.ts +15 -5
  101. package/corpus/templates/mail/app/hooks/use-google-auth.ts +47 -9
  102. package/corpus/templates/mail/changelog/2026-06-25-account-avatars-stay-stable-while-mail-refreshes-google-acco.md +6 -0
  103. package/corpus/templates/mail/changelog/2026-06-25-mail-background-draft-saves-and-thread-prefetches-no-longer.md +6 -0
  104. package/corpus/templates/mail/netlify.toml +1 -0
  105. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +8 -0
  106. package/corpus/templates/plan/netlify.toml +1 -0
  107. package/corpus/templates/slides/netlify.toml +1 -0
  108. package/corpus/templates/videos/netlify.toml +1 -0
  109. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  110. package/dist/agent/thread-data-builder.js +23 -2
  111. package/dist/agent/thread-data-builder.js.map +1 -1
  112. package/dist/cli/create.d.ts.map +1 -1
  113. package/dist/cli/create.js +28 -1
  114. package/dist/cli/create.js.map +1 -1
  115. package/dist/cli/skills.d.ts +1 -1
  116. package/dist/cli/skills.d.ts.map +1 -1
  117. package/dist/cli/skills.js +8 -0
  118. package/dist/cli/skills.js.map +1 -1
  119. package/dist/client/AgentPanel.d.ts +8 -2
  120. package/dist/client/AgentPanel.d.ts.map +1 -1
  121. package/dist/client/AgentPanel.js +29 -15
  122. package/dist/client/AgentPanel.js.map +1 -1
  123. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  124. package/dist/client/MultiTabAssistantChat.js +4 -4
  125. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  126. package/dist/client/analytics.d.ts.map +1 -1
  127. package/dist/client/analytics.js +22 -2
  128. package/dist/client/analytics.js.map +1 -1
  129. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  130. package/dist/client/use-agent-engine-configured.js +23 -13
  131. package/dist/client/use-agent-engine-configured.js.map +1 -1
  132. package/dist/client/use-chat-threads.d.ts +4 -0
  133. package/dist/client/use-chat-threads.d.ts.map +1 -1
  134. package/dist/client/use-chat-threads.js +49 -4
  135. package/dist/client/use-chat-threads.js.map +1 -1
  136. package/dist/collab/routes.d.ts +2 -2
  137. package/dist/notifications/routes.d.ts +1 -1
  138. package/dist/observability/routes.d.ts +5 -5
  139. package/dist/resources/handlers.d.ts +2 -2
  140. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  141. package/dist/server/analytics.d.ts +4 -0
  142. package/dist/server/analytics.d.ts.map +1 -1
  143. package/dist/server/analytics.js +19 -3
  144. package/dist/server/analytics.js.map +1 -1
  145. package/dist/server/transcribe-voice.d.ts +1 -1
  146. package/dist/sharing/access.d.ts.map +1 -1
  147. package/dist/sharing/access.js +26 -12
  148. package/dist/sharing/access.js.map +1 -1
  149. package/dist/sharing/actions/share-resource.js +20 -9
  150. package/dist/sharing/actions/share-resource.js.map +1 -1
  151. package/dist/sharing/actions/unshare-resource.js +13 -2
  152. package/dist/sharing/actions/unshare-resource.js.map +1 -1
  153. package/dist/sharing/registry.d.ts +9 -0
  154. package/dist/sharing/registry.d.ts.map +1 -1
  155. package/dist/sharing/registry.js.map +1 -1
  156. package/dist/vite/client.d.ts.map +1 -1
  157. package/dist/vite/client.js +1 -0
  158. package/dist/vite/client.js.map +1 -1
  159. package/package.json +3 -3
@@ -0,0 +1,206 @@
1
+ import * as Sentry from "@sentry/browser";
2
+
3
+ declare const __CLIPS_SENTRY_DSN__: string;
4
+ declare const __CLIPS_SENTRY_ENVIRONMENT__: string;
5
+
6
+ type CaptureContext = {
7
+ tags?: Record<string, string | undefined>;
8
+ extra?: Record<string, unknown>;
9
+ contexts?: Record<string, Record<string, unknown>>;
10
+ };
11
+
12
+ const SENSITIVE_QUERY_PARAM_RE =
13
+ /(?:token|secret|password|passwd|pwd|key|api[-_]?key|code|state|session|auth|credential)/i;
14
+
15
+ let initialized = false;
16
+ let globalErrorCaptureInstalled = false;
17
+
18
+ function configuredDsn(): string {
19
+ return typeof __CLIPS_SENTRY_DSN__ === "string"
20
+ ? __CLIPS_SENTRY_DSN__.trim()
21
+ : "";
22
+ }
23
+
24
+ function extensionVersion(): string {
25
+ try {
26
+ return chrome.runtime.getManifest().version || "unknown";
27
+ } catch {
28
+ return "unknown";
29
+ }
30
+ }
31
+
32
+ function scrubUrl(
33
+ value: string,
34
+ options: { redactAllQueryValues?: boolean } = {},
35
+ ): string {
36
+ try {
37
+ const url = new URL(value);
38
+ for (const key of [...url.searchParams.keys()]) {
39
+ if (options.redactAllQueryValues || SENSITIVE_QUERY_PARAM_RE.test(key)) {
40
+ url.searchParams.set(key, "<redacted>");
41
+ }
42
+ }
43
+ return url.toString();
44
+ } catch {
45
+ return value;
46
+ }
47
+ }
48
+
49
+ function scrubEvent<T extends Sentry.Event>(event: T): T {
50
+ if (event.request?.url) {
51
+ event.request.url = scrubUrl(event.request.url);
52
+ }
53
+ if (Array.isArray(event.breadcrumbs)) {
54
+ for (const breadcrumb of event.breadcrumbs) {
55
+ const data = breadcrumb.data as Record<string, unknown> | undefined;
56
+ if (!data) continue;
57
+ for (const key of ["url", "from", "to"]) {
58
+ if (typeof data[key] === "string") {
59
+ data[key] = scrubUrl(data[key]);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ if (event.user) {
65
+ delete (event.user as Record<string, unknown>).ip_address;
66
+ }
67
+ return event;
68
+ }
69
+
70
+ function scrubScopeString(value: string): string {
71
+ const scrubbed = scrubUrl(value, { redactAllQueryValues: true });
72
+ if (scrubbed !== value) return scrubbed;
73
+ return value.replace(/https?:\/\/[^\s"'<>]+/g, (url) =>
74
+ scrubUrl(url, { redactAllQueryValues: true }),
75
+ );
76
+ }
77
+
78
+ function scrubScopeValue(
79
+ value: unknown,
80
+ seen = new WeakSet<object>(),
81
+ ): unknown {
82
+ if (typeof value === "string") return scrubScopeString(value);
83
+ if (Array.isArray(value)) {
84
+ return value.map((item) => scrubScopeValue(item, seen));
85
+ }
86
+ if (!value || typeof value !== "object") return value;
87
+ if (seen.has(value)) return "[Circular]";
88
+ if (Object.prototype.toString.call(value) !== "[object Object]") {
89
+ return value;
90
+ }
91
+ seen.add(value);
92
+ return Object.fromEntries(
93
+ Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
94
+ key,
95
+ scrubScopeValue(entry, seen),
96
+ ]),
97
+ );
98
+ }
99
+
100
+ function installGlobalErrorCapture(surface: string): void {
101
+ if (globalErrorCaptureInstalled) return;
102
+ globalErrorCaptureInstalled = true;
103
+ const target = globalThis as typeof globalThis & {
104
+ addEventListener?: (
105
+ type: string,
106
+ listener: EventListenerOrEventListenerObject,
107
+ ) => void;
108
+ };
109
+ if (typeof target.addEventListener !== "function") return;
110
+
111
+ target.addEventListener("error", (event) => {
112
+ const errorEvent = event as ErrorEvent;
113
+ const error =
114
+ errorEvent.error instanceof Error
115
+ ? errorEvent.error
116
+ : new Error(errorEvent.message || "Unhandled extension error");
117
+ captureExtensionError(error, {
118
+ tags: { surface, mechanism: "global-error" },
119
+ extra: {
120
+ filename: errorEvent.filename,
121
+ lineno: errorEvent.lineno,
122
+ colno: errorEvent.colno,
123
+ },
124
+ });
125
+ });
126
+
127
+ target.addEventListener("unhandledrejection", (event) => {
128
+ const rejection = event as PromiseRejectionEvent;
129
+ const error =
130
+ rejection.reason instanceof Error
131
+ ? rejection.reason
132
+ : new Error(String(rejection.reason ?? "Unhandled promise rejection"));
133
+ captureExtensionError(error, {
134
+ tags: { surface, mechanism: "unhandled-rejection" },
135
+ });
136
+ });
137
+ }
138
+
139
+ export function initExtensionSentry(surface: string): void {
140
+ if (initialized) return;
141
+ const dsn = configuredDsn();
142
+ if (!dsn) return;
143
+ initialized = true;
144
+
145
+ Sentry.init({
146
+ dsn,
147
+ environment:
148
+ typeof __CLIPS_SENTRY_ENVIRONMENT__ === "string" &&
149
+ __CLIPS_SENTRY_ENVIRONMENT__
150
+ ? __CLIPS_SENTRY_ENVIRONMENT__
151
+ : "production",
152
+ release: `clips-chrome-extension@${extensionVersion()}`,
153
+ sendDefaultPii: false,
154
+ skipBrowserExtensionCheck: true,
155
+ beforeSend(event) {
156
+ event.tags = {
157
+ ...event.tags,
158
+ app: "agent-native-clips",
159
+ template: "clips",
160
+ runtime: "chrome-extension",
161
+ surface,
162
+ };
163
+ return scrubEvent(event);
164
+ },
165
+ });
166
+
167
+ Sentry.setTag("app", "agent-native-clips");
168
+ Sentry.setTag("template", "clips");
169
+ Sentry.setTag("runtime", "chrome-extension");
170
+ Sentry.setTag("surface", surface);
171
+ installGlobalErrorCapture(surface);
172
+ }
173
+
174
+ export function captureExtensionError(
175
+ error: unknown,
176
+ context: CaptureContext = {},
177
+ ): string | undefined {
178
+ if (!initialized) return undefined;
179
+ try {
180
+ return Sentry.withScope((scope) => {
181
+ if (context.tags) {
182
+ for (const [key, value] of Object.entries(context.tags)) {
183
+ if (typeof value === "string") {
184
+ scope.setTag(key, scrubScopeString(value));
185
+ }
186
+ }
187
+ }
188
+ if (context.extra) {
189
+ for (const [key, value] of Object.entries(context.extra)) {
190
+ if (value !== undefined) scope.setExtra(key, scrubScopeValue(value));
191
+ }
192
+ }
193
+ if (context.contexts) {
194
+ for (const [key, value] of Object.entries(context.contexts)) {
195
+ scope.setContext(
196
+ key,
197
+ scrubScopeValue(value) as Record<string, unknown>,
198
+ );
199
+ }
200
+ }
201
+ return Sentry.captureException(error);
202
+ });
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ }
@@ -5,10 +5,57 @@ import { defineConfig } from "vite";
5
5
 
6
6
  const root = dirname(fileURLToPath(import.meta.url));
7
7
 
8
+ function firstNonEmpty(...values: Array<string | undefined>): string {
9
+ for (const value of values) {
10
+ const trimmed = value?.trim();
11
+ if (trimmed) return trimmed;
12
+ }
13
+ return "";
14
+ }
15
+
16
+ function resolveSentryDsn(): string {
17
+ const direct = firstNonEmpty(
18
+ process.env.SENTRY_CLIENT_DSN,
19
+ process.env.VITE_SENTRY_CLIENT_DSN,
20
+ process.env.VITE_SENTRY_DSN,
21
+ process.env.SENTRY_DSN,
22
+ );
23
+ if (direct) return direct;
24
+
25
+ const key = firstNonEmpty(
26
+ process.env.SENTRY_CLIENT_KEY,
27
+ process.env.VITE_SENTRY_CLIENT_KEY,
28
+ );
29
+ const projectId = firstNonEmpty(
30
+ process.env.SENTRY_PROJECT_ID,
31
+ process.env.VITE_SENTRY_PROJECT_ID,
32
+ );
33
+ const host = firstNonEmpty(
34
+ process.env.SENTRY_INGEST_HOST,
35
+ process.env.VITE_SENTRY_INGEST_HOST,
36
+ );
37
+ return key && projectId && host ? `https://${key}@${host}/${projectId}` : "";
38
+ }
39
+
40
+ function resolveSentryEnvironment(): string {
41
+ return (
42
+ firstNonEmpty(
43
+ process.env.SENTRY_ENVIRONMENT,
44
+ process.env.NETLIFY_CONTEXT,
45
+ process.env.VERCEL_ENV,
46
+ process.env.NODE_ENV,
47
+ ) || "production"
48
+ );
49
+ }
50
+
8
51
  export default defineConfig({
9
52
  root,
10
53
  base: "./",
11
54
  publicDir: "public",
55
+ define: {
56
+ __CLIPS_SENTRY_DSN__: JSON.stringify(resolveSentryDsn()),
57
+ __CLIPS_SENTRY_ENVIRONMENT__: JSON.stringify(resolveSentryEnvironment()),
58
+ },
12
59
  // Share recording primitives with the web app recorder (templates/clips/shared)
13
60
  // — matches the "@shared/*" tsconfig path so imports resolve in both builds.
14
61
  resolve: {
@@ -5,6 +5,7 @@ publish = "templates/clips/dist"
5
5
  functions = "templates/clips/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
  AUTO_CREATE_DEFAULT_ORG = "1"
@@ -14,6 +14,7 @@ registerShareableResource({
14
14
  titleColumn: "title",
15
15
  getResourcePath: (recording) => `/r/${recording.id}`,
16
16
  getDb,
17
+ ownerAccessIgnoresOrg: true,
17
18
  });
18
19
 
19
20
  registerShareableResource({
@@ -13,7 +13,11 @@ import { getQuery, type H3Event } from "h3";
13
13
 
14
14
  import { getDb, schema } from "../db/index.js";
15
15
  import { exchangeCode, getUserInfo } from "./google-calendar-client.js";
16
- import { getActiveOrganizationId } from "./recordings.js";
16
+ import {
17
+ getActiveOrganizationId,
18
+ normalizeOwnerEmail,
19
+ ownerEmailMatches,
20
+ } from "./recordings.js";
17
21
 
18
22
  export const CLIPS_GOOGLE_OAUTH_APP_ID = process.env.APP_NAME || "clips";
19
23
 
@@ -54,6 +58,7 @@ export async function handleGoogleCalendarCallback(
54
58
  "Your session expired during the OAuth flow. Sign in again and retry.",
55
59
  );
56
60
  }
61
+ const ownerEmail = normalizeOwnerEmail(userEmail);
57
62
 
58
63
  const clientId = process.env.GOOGLE_CLIENT_ID;
59
64
  const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
@@ -63,7 +68,7 @@ export async function handleGoogleCalendarCallback(
63
68
  );
64
69
  }
65
70
 
66
- return runWithRequestContext({ userEmail }, async () => {
71
+ return runWithRequestContext({ userEmail: ownerEmail }, async () => {
67
72
  const { redirectUri, returnUrl } = state;
68
73
 
69
74
  // 1. Exchange code -> tokens.
@@ -82,8 +87,29 @@ export async function handleGoogleCalendarCallback(
82
87
  const externalAccountId = profile.id;
83
88
  const accountEmail = profile.email;
84
89
 
85
- // 3. Persist tokens in app_secrets (encrypted at rest). NEVER write
86
- // tokens onto the calendar_accounts row.
90
+ // 3. Find an existing account case-insensitively so email-casing changes
91
+ // don't create duplicate calendar connections.
92
+ const db = getDb();
93
+ const orgId = await getActiveOrganizationId().catch(() => undefined);
94
+ const now = new Date().toISOString();
95
+ const [existing] = await db
96
+ .select({
97
+ id: schema.calendarAccounts.id,
98
+ ownerEmail: schema.calendarAccounts.ownerEmail,
99
+ })
100
+ .from(schema.calendarAccounts)
101
+ .where(
102
+ and(
103
+ eq(schema.calendarAccounts.provider, "google"),
104
+ eq(schema.calendarAccounts.externalAccountId, externalAccountId),
105
+ ownerEmailMatches(schema.calendarAccounts.ownerEmail, ownerEmail),
106
+ ),
107
+ );
108
+
109
+ // 4. Persist tokens in app_secrets (encrypted at rest). NEVER write
110
+ // tokens onto the calendar_accounts row. Existing rows may have stored
111
+ // mixed-case owner emails, so keep their secret scope stable.
112
+ const secretScopeEmail = existing?.ownerEmail ?? ownerEmail;
87
113
  const accessKey = calendarSecretKey("google", externalAccountId, "access");
88
114
  const refreshKey = calendarSecretKey(
89
115
  "google",
@@ -101,7 +127,7 @@ export async function handleGoogleCalendarCallback(
101
127
  scope: tokens.scope,
102
128
  }),
103
129
  scope: "user",
104
- scopeId: userEmail,
130
+ scopeId: secretScopeEmail,
105
131
  description: `Google Calendar access token for ${accountEmail}`,
106
132
  });
107
133
  if (tokens.refresh_token) {
@@ -109,26 +135,12 @@ export async function handleGoogleCalendarCallback(
109
135
  key: refreshKey,
110
136
  value: tokens.refresh_token,
111
137
  scope: "user",
112
- scopeId: userEmail,
138
+ scopeId: secretScopeEmail,
113
139
  description: `Google Calendar refresh token for ${accountEmail}`,
114
140
  });
115
141
  }
116
142
 
117
- // 4. Upsert the calendar_accounts row.
118
- const db = getDb();
119
- const orgId = await getActiveOrganizationId().catch(() => undefined);
120
- const now = new Date().toISOString();
121
- const [existing] = await db
122
- .select({ id: schema.calendarAccounts.id })
123
- .from(schema.calendarAccounts)
124
- .where(
125
- and(
126
- eq(schema.calendarAccounts.provider, "google"),
127
- eq(schema.calendarAccounts.externalAccountId, externalAccountId),
128
- eq(schema.calendarAccounts.ownerEmail, userEmail),
129
- ),
130
- );
131
-
143
+ // 5. Upsert the calendar_accounts row.
132
144
  if (existing) {
133
145
  await db
134
146
  .update(schema.calendarAccounts)
@@ -160,13 +172,13 @@ export async function handleGoogleCalendarCallback(
160
172
  lastSyncError: null,
161
173
  createdAt: now,
162
174
  updatedAt: now,
163
- ownerEmail: userEmail,
175
+ ownerEmail,
164
176
  orgId: orgId ?? null,
165
177
  visibility: "private",
166
178
  } as any);
167
179
  }
168
180
 
169
- return oauthCallbackResponse(event, accountEmail || userEmail, {
181
+ return oauthCallbackResponse(event, accountEmail || ownerEmail, {
170
182
  desktop,
171
183
  addAccount: true, // close-tab page; never switch the active session
172
184
  returnUrl,
@@ -51,6 +51,13 @@ export type PublicAgentAccessResult =
51
51
 
52
52
  const DEFAULT_MAX_AGENT_FRAME_MEDIA_BYTES = 200 * 1024 * 1024;
53
53
 
54
+ function sameOwnerEmail(
55
+ a: string | null | undefined,
56
+ b: string | null | undefined,
57
+ ): boolean {
58
+ return !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
59
+ }
60
+
54
61
  export class RecordingMediaFetchError extends Error {
55
62
  constructor(
56
63
  message: string,
@@ -195,7 +202,7 @@ export async function loadPublicAgentAccess(
195
202
 
196
203
  const session = await getSession(event).catch(() => null);
197
204
  const viewerIsOwner = Boolean(
198
- session?.email && session.email === recording.ownerEmail,
205
+ session?.email && sameOwnerEmail(session.email, recording.ownerEmail),
199
206
  );
200
207
 
201
208
  if (recording.visibility !== "public" && !viewerIsOwner) {
@@ -13,7 +13,23 @@ import { getDb, schema } from "../db/index.js";
13
13
  export function getCurrentOwnerEmail(): string {
14
14
  const email = getRequestUserEmail();
15
15
  if (!email) throw new Error("no authenticated user");
16
- return email;
16
+ return normalizeOwnerEmail(email);
17
+ }
18
+
19
+ export function normalizeOwnerEmail(email: string): string {
20
+ return email.trim().toLowerCase();
21
+ }
22
+
23
+ export function ownerEmailMatches(column: unknown, email: string) {
24
+ return sql`lower(${column as any}) = ${normalizeOwnerEmail(email)}`;
25
+ }
26
+
27
+ export function sameOwnerEmail(
28
+ a: string | null | undefined,
29
+ b: string | null | undefined,
30
+ ): boolean {
31
+ if (!a || !b) return false;
32
+ return normalizeOwnerEmail(a) === normalizeOwnerEmail(b);
17
33
  }
18
34
 
19
35
  export async function getEventOwnerContext(event: H3Event): Promise<{
@@ -258,7 +274,7 @@ export async function getRecordingOrThrow(id: string): Promise<RecordingRow> {
258
274
  eq(schema.recordings.id, id),
259
275
  // visibility check happens at the action layer via the framework
260
276
  // sharing helpers; this is just the ownership-or-visible fallback.
261
- eq(schema.recordings.ownerEmail, ownerEmail),
277
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
262
278
  ),
263
279
  );
264
280
  if (!row) throw new Error(`Recording not found: ${id}`);
@@ -25,6 +25,8 @@ import { getDb, schema } from "../db/index.js";
25
25
  import {
26
26
  getActiveOrganizationId,
27
27
  getOrganizationRoleForEmail,
28
+ ownerEmailMatches,
29
+ sameOwnerEmail,
28
30
  } from "./recordings.js";
29
31
  import type { SlackLinkSharedPayload } from "./slack-unfurls.js";
30
32
 
@@ -388,12 +390,13 @@ export async function listVisibleSlackInstallations(options: {
388
390
  userEmail: string;
389
391
  orgId?: string | null;
390
392
  }): Promise<SlackInstallationListItem[]> {
393
+ const ownerWhere = ownerEmailMatches(
394
+ schema.slackInstallations.ownerEmail,
395
+ options.userEmail,
396
+ );
391
397
  const scopeWhere = options.orgId
392
- ? or(
393
- eq(schema.slackInstallations.orgId, options.orgId),
394
- eq(schema.slackInstallations.ownerEmail, options.userEmail),
395
- )
396
- : eq(schema.slackInstallations.ownerEmail, options.userEmail);
398
+ ? or(eq(schema.slackInstallations.orgId, options.orgId), ownerWhere)
399
+ : ownerWhere;
397
400
 
398
401
  return getDb()
399
402
  .select({
@@ -484,7 +487,7 @@ export async function disconnectSlackInstallation(options: {
484
487
  .limit(1);
485
488
  if (!row) return null;
486
489
 
487
- const canDisconnectOwn = row.ownerEmail === options.userEmail;
490
+ const canDisconnectOwn = sameOwnerEmail(row.ownerEmail, options.userEmail);
488
491
  let canDisconnectOrg = false;
489
492
  if (row.orgId) {
490
493
  const role = await getOrganizationRoleForEmail(
@@ -79,11 +79,15 @@ export default async (): Promise<void> => {
79
79
  () => ({ db: null as any, schema: null as any }),
80
80
  );
81
81
  if (!db || !schema?.calendarAccounts) return false;
82
- const { eq } = await import("drizzle-orm");
82
+ const { ownerEmailMatches } = await import(
83
+ "../lib/recordings.js" as string
84
+ );
83
85
  const rows = await db
84
86
  .select({ id: schema.calendarAccounts.id })
85
87
  .from(schema.calendarAccounts)
86
- .where(eq(schema.calendarAccounts.ownerEmail, userEmail))
88
+ .where(
89
+ ownerEmailMatches(schema.calendarAccounts.ownerEmail, userEmail),
90
+ )
87
91
  .limit(1);
88
92
  return rows.length > 0;
89
93
  } catch {
@@ -33,7 +33,7 @@ import {
33
33
  } from "../../../shared/transcript-segments.js";
34
34
  import { getDb, schema } from "../../db/index.js";
35
35
  import { resolvePlayerVideoUrl } from "../../lib/player-video-url.js";
36
- import { parseSpaceIds } from "../../lib/recordings.js";
36
+ import { parseSpaceIds, sameOwnerEmail } from "../../lib/recordings.js";
37
37
  import { verifySharePassword } from "../../lib/share-password.js";
38
38
 
39
39
  function appPath(path: string): string {
@@ -142,7 +142,7 @@ export default defineEventHandler(async (event) => {
142
142
 
143
143
  const session = await getSession(event).catch(() => null);
144
144
  const viewerIsOwner = Boolean(
145
- session?.email && session.email === rec.ownerEmail,
145
+ session?.email && sameOwnerEmail(session.email, rec.ownerEmail),
146
146
  );
147
147
 
148
148
  if (rec.visibility !== "public" && !viewerIsOwner) {
@@ -24,7 +24,10 @@ import {
24
24
 
25
25
  import { parseEdits } from "../../../../../app/lib/timestamp-mapping.js";
26
26
  import { getDb, schema } from "../../../../db/index.js";
27
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
27
+ import {
28
+ getEventOwnerContext,
29
+ ownerEmailMatches,
30
+ } from "../../../../lib/recordings.js";
28
31
 
29
32
  const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
30
33
 
@@ -85,7 +88,7 @@ export default defineEventHandler(async (event: H3Event) => {
85
88
  .where(
86
89
  and(
87
90
  eq(schema.recordings.id, recordingId),
88
- eq(schema.recordings.ownerEmail, ownerEmail),
91
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
89
92
  ),
90
93
  );
91
94
 
@@ -20,7 +20,10 @@ import {
20
20
  } from "h3";
21
21
 
22
22
  import { getDb, schema } from "../../../../db/index.js";
23
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
23
+ import {
24
+ getEventOwnerContext,
25
+ ownerEmailMatches,
26
+ } from "../../../../lib/recordings.js";
24
27
 
25
28
  export default defineEventHandler(async (event: H3Event) => {
26
29
  const recordingId = getRouterParam(event, "recordingId");
@@ -47,7 +50,7 @@ export default defineEventHandler(async (event: H3Event) => {
47
50
  .where(
48
51
  and(
49
52
  eq(schema.recordings.id, recordingId),
50
- eq(schema.recordings.ownerEmail, ownerEmail),
53
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
51
54
  ),
52
55
  );
53
56
 
@@ -35,7 +35,10 @@ import {
35
35
  import finalizeRecording from "../../../../../actions/finalize-recording.js";
36
36
  import { getDb, schema } from "../../../../db/index.js";
37
37
  import { debugLog } from "../../../../lib/debug.js";
38
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
38
+ import {
39
+ getEventOwnerContext,
40
+ ownerEmailMatches,
41
+ } from "../../../../lib/recordings.js";
39
42
  import {
40
43
  shouldRejectVideoUploadWithoutStorage,
41
44
  STORAGE_SETUP_REQUIRED_REASON,
@@ -156,7 +159,7 @@ export default defineEventHandler(async (event: H3Event) => {
156
159
  .where(
157
160
  and(
158
161
  eq(schema.recordings.id, recordingId),
159
- eq(schema.recordings.ownerEmail, ownerEmail),
162
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
160
163
  ),
161
164
  );
162
165
 
@@ -40,7 +40,10 @@ import {
40
40
  } from "h3";
41
41
 
42
42
  import { getDb, schema } from "../../../../db/index.js";
43
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
43
+ import {
44
+ getEventOwnerContext,
45
+ ownerEmailMatches,
46
+ } from "../../../../lib/recordings.js";
44
47
 
45
48
  interface CompressionMeta {
46
49
  originalBytes?: number;
@@ -97,7 +100,7 @@ export default defineEventHandler(async (event: H3Event) => {
97
100
  .where(
98
101
  and(
99
102
  eq(schema.recordings.id, recordingId),
100
- eq(schema.recordings.ownerEmail, ownerEmail),
103
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
101
104
  ),
102
105
  );
103
106
 
@@ -20,14 +20,24 @@ const UNQUOTED_SECRET_VALUE_RE = new RegExp(
20
20
  "gi",
21
21
  );
22
22
 
23
- export function redactBrowserDiagnosticString(value: string): string {
24
- return value
23
+ export interface RedactBrowserDiagnosticStringOptions {
24
+ /** Redact every query value. Use for URL fields; leave off for console text. */
25
+ redactQueryValues?: boolean;
26
+ }
27
+
28
+ export function redactBrowserDiagnosticString(
29
+ value: string,
30
+ options: RedactBrowserDiagnosticStringOptions = {},
31
+ ): string {
32
+ const redacted = value
25
33
  .replace(AUTHORIZATION_SCHEME_RE, "$1$2<redacted>")
26
34
  .replace(/\b(bearer|basic)\s+[a-z0-9._~+/-]+=*/gi, "$1 <redacted>")
27
35
  .replace(DOUBLE_QUOTED_SECRET_VALUE_RE, '$1$2$1$3"<redacted>"')
28
36
  .replace(SINGLE_QUOTED_SECRET_VALUE_RE, "$1$2$1$3'<redacted>'")
29
- .replace(UNQUOTED_SECRET_VALUE_RE, "$1$2$1$3<redacted>")
30
- .replace(/([?&][^=\s&?#]+)=([^&\s#]+)/g, "$1=<redacted>");
37
+ .replace(UNQUOTED_SECRET_VALUE_RE, "$1$2$1$3<redacted>");
38
+ return options.redactQueryValues
39
+ ? redacted.replace(/([?&][^=\s&?#]+)=([^&\s#]+)/g, "$1=<redacted>")
40
+ : redacted;
31
41
  }
32
42
 
33
43
  export type BrowserDiagnosticConsoleLevel =
@@ -5,6 +5,7 @@ publish = "templates/content/dist"
5
5
  functions = "templates/content/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
  AGENT_RUN_SOFT_TIMEOUT_MS = "55000"
@@ -5,6 +5,7 @@ publish = "templates/design/dist"
5
5
  functions = "templates/design/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -5,6 +5,7 @@ publish = "templates/dispatch/dist"
5
5
  functions = "templates/dispatch/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -5,6 +5,7 @@ publish = "templates/forms/dist"
5
5
  functions = "templates/forms/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11