@agent-native/core 0.77.6 → 0.77.8

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 (177) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +29 -0
  3. package/corpus/core/package.json +3 -3
  4. package/corpus/core/src/agent/production-agent.ts +18 -0
  5. package/corpus/core/src/agent/thread-data-builder.ts +21 -2
  6. package/corpus/core/src/cli/create.ts +35 -1
  7. package/corpus/core/src/cli/skills.ts +8 -0
  8. package/corpus/core/src/client/AgentPanel.tsx +57 -25
  9. package/corpus/core/src/client/MultiTabAssistantChat.tsx +31 -1
  10. package/corpus/core/src/client/analytics.ts +24 -2
  11. package/corpus/core/src/client/use-agent-engine-configured.ts +29 -15
  12. package/corpus/core/src/client/use-chat-threads.ts +104 -55
  13. package/corpus/core/src/db/ddl-guard.ts +190 -0
  14. package/corpus/core/src/secrets/storage.ts +36 -2
  15. package/corpus/core/src/server/analytics.ts +27 -3
  16. package/corpus/core/src/settings/store.ts +40 -6
  17. package/corpus/core/src/sharing/access.ts +35 -13
  18. package/corpus/core/src/sharing/actions/share-resource.ts +32 -9
  19. package/corpus/core/src/sharing/actions/unshare-resource.ts +25 -2
  20. package/corpus/core/src/sharing/registry.ts +9 -0
  21. package/corpus/core/src/vite/client.ts +3 -0
  22. package/corpus/templates/analytics/actions/data-source-status.ts +25 -14
  23. package/corpus/templates/analytics/app/components/layout/NewAnalysisDialog.tsx +2 -2
  24. package/corpus/templates/analytics/app/lib/data-source-status.ts +47 -6
  25. package/corpus/templates/analytics/app/lib/data-sources.ts +14 -3
  26. package/corpus/templates/analytics/app/pages/DataSources.tsx +24 -5
  27. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +15 -43
  28. package/corpus/templates/analytics/changelog/2026-06-25-fixed-hubspot-data-source-status-so-private-app-tokens-legac.md +6 -0
  29. package/corpus/templates/analytics/netlify.toml +1 -0
  30. package/corpus/templates/analytics/server/lib/credential-keys.ts +3 -1
  31. package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +12 -3
  32. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +5 -2
  33. package/corpus/templates/assets/changelog/2026-06-25-create-now-returns-from-agent-settings-to-the-asset-chat-eve.md +6 -0
  34. package/corpus/templates/assets/netlify.toml +1 -0
  35. package/corpus/templates/brain/netlify.toml +1 -0
  36. package/corpus/templates/calendar/app/i18n-data.ts +12 -0
  37. package/corpus/templates/calendar/app/lib/clipboard.ts +58 -0
  38. package/corpus/templates/calendar/app/pages/AvailabilitySettings.tsx +15 -9
  39. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +6 -2
  40. package/corpus/templates/calendar/changelog/2026-06-25-booking-link-copy-buttons-no-longer-fail-when-clipboard-perm.md +6 -0
  41. package/corpus/templates/calendar/netlify.toml +1 -0
  42. package/corpus/templates/chat/netlify.toml +1 -0
  43. package/corpus/templates/clips/AGENTS.md +9 -7
  44. package/corpus/templates/clips/actions/add-vocabulary-term.ts +2 -2
  45. package/corpus/templates/clips/actions/archive-recording.ts +5 -2
  46. package/corpus/templates/clips/actions/clear-edits.ts +5 -2
  47. package/corpus/templates/clips/actions/create-folder.ts +7 -2
  48. package/corpus/templates/clips/actions/delete-folder.ts +7 -4
  49. package/corpus/templates/clips/actions/delete-recording-permanent.ts +5 -2
  50. package/corpus/templates/clips/actions/finalize-recording.ts +5 -2
  51. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -1
  52. package/corpus/templates/clips/actions/list-notifications.ts +9 -5
  53. package/corpus/templates/clips/actions/list-organization-state.ts +2 -1
  54. package/corpus/templates/clips/actions/list-recordings.ts +4 -1
  55. package/corpus/templates/clips/actions/move-recording.ts +5 -1
  56. package/corpus/templates/clips/actions/rename-folder.ts +6 -3
  57. package/corpus/templates/clips/actions/request-transcript.ts +5 -2
  58. package/corpus/templates/clips/actions/restore-recording.ts +5 -2
  59. package/corpus/templates/clips/actions/save-browser-diagnostics.ts +21 -8
  60. package/corpus/templates/clips/actions/set-chapters.ts +5 -2
  61. package/corpus/templates/clips/actions/set-thumbnail.ts +5 -2
  62. package/corpus/templates/clips/actions/split-recording.ts +5 -2
  63. package/corpus/templates/clips/actions/stitch-recordings.ts +6 -2
  64. package/corpus/templates/clips/actions/trash-recording.ts +5 -2
  65. package/corpus/templates/clips/actions/undo-edit.ts +5 -2
  66. package/corpus/templates/clips/actions/view-screen.ts +2 -1
  67. package/corpus/templates/clips/app/components/library/recording-card.tsx +24 -7
  68. package/corpus/templates/clips/app/lib/browser-diagnostics-capture.ts +5 -1
  69. package/corpus/templates/clips/changelog/2026-06-25-chrome-extension-recordings-now-upload-large-capture-bursts.md +6 -0
  70. package/corpus/templates/clips/changelog/2026-06-25-console-diagnostics-keep-more-useful-non-secret-details-when.md +6 -0
  71. package/corpus/templates/clips/changelog/2026-06-25-fixed-owner-recognition-so-clips-created-from-desktop-and-we.md +6 -0
  72. package/corpus/templates/clips/chrome-extension/package.json +3 -0
  73. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  74. package/corpus/templates/clips/chrome-extension/src/background.ts +85 -9
  75. package/corpus/templates/clips/chrome-extension/src/content-script.ts +55 -0
  76. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +79 -3
  77. package/corpus/templates/clips/chrome-extension/src/options.ts +10 -0
  78. package/corpus/templates/clips/chrome-extension/src/overlay.ts +6 -0
  79. package/corpus/templates/clips/chrome-extension/src/permission.ts +8 -1
  80. package/corpus/templates/clips/chrome-extension/src/popup.ts +65 -39
  81. package/corpus/templates/clips/chrome-extension/src/sentry.ts +206 -0
  82. package/corpus/templates/clips/chrome-extension/vite.config.ts +47 -0
  83. package/corpus/templates/clips/netlify.toml +1 -0
  84. package/corpus/templates/clips/server/db/index.ts +1 -0
  85. package/corpus/templates/clips/server/lib/google-calendar-oauth.ts +35 -23
  86. package/corpus/templates/clips/server/lib/public-agent-context.ts +8 -1
  87. package/corpus/templates/clips/server/lib/recordings.ts +18 -2
  88. package/corpus/templates/clips/server/lib/slack-oauth.ts +9 -6
  89. package/corpus/templates/clips/server/plugins/onboarding-calendar.ts +6 -2
  90. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +2 -2
  91. package/corpus/templates/clips/server/routes/api/recordings/[recordingId]/thumbnail.post.ts +5 -2
  92. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +5 -2
  93. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +5 -2
  94. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +5 -2
  95. package/corpus/templates/clips/shared/browser-diagnostics.ts +14 -4
  96. package/corpus/templates/content/netlify.toml +1 -0
  97. package/corpus/templates/design/netlify.toml +1 -0
  98. package/corpus/templates/dispatch/netlify.toml +1 -0
  99. package/corpus/templates/forms/netlify.toml +1 -0
  100. package/corpus/templates/macros/netlify.toml +1 -0
  101. package/corpus/templates/mail/app/components/email/EmailList.tsx +7 -3
  102. package/corpus/templates/mail/app/components/email/EmailThread.tsx +4 -1
  103. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +10 -2
  104. package/corpus/templates/mail/app/hooks/use-compose-state.ts +15 -5
  105. package/corpus/templates/mail/app/hooks/use-google-auth.ts +47 -9
  106. package/corpus/templates/mail/changelog/2026-06-25-account-avatars-stay-stable-while-mail-refreshes-google-acco.md +6 -0
  107. package/corpus/templates/mail/changelog/2026-06-25-mail-background-draft-saves-and-thread-prefetches-no-longer.md +6 -0
  108. package/corpus/templates/mail/netlify.toml +1 -0
  109. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +8 -0
  110. package/corpus/templates/plan/netlify.toml +1 -0
  111. package/corpus/templates/slides/netlify.toml +1 -0
  112. package/corpus/templates/videos/netlify.toml +1 -0
  113. package/dist/agent/production-agent.d.ts.map +1 -1
  114. package/dist/agent/production-agent.js +18 -0
  115. package/dist/agent/production-agent.js.map +1 -1
  116. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  117. package/dist/agent/thread-data-builder.js +23 -2
  118. package/dist/agent/thread-data-builder.js.map +1 -1
  119. package/dist/cli/create.d.ts.map +1 -1
  120. package/dist/cli/create.js +28 -1
  121. package/dist/cli/create.js.map +1 -1
  122. package/dist/cli/skills.d.ts +1 -1
  123. package/dist/cli/skills.d.ts.map +1 -1
  124. package/dist/cli/skills.js +8 -0
  125. package/dist/cli/skills.js.map +1 -1
  126. package/dist/client/AgentPanel.d.ts +8 -2
  127. package/dist/client/AgentPanel.d.ts.map +1 -1
  128. package/dist/client/AgentPanel.js +29 -15
  129. package/dist/client/AgentPanel.js.map +1 -1
  130. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  131. package/dist/client/MultiTabAssistantChat.js +4 -4
  132. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  133. package/dist/client/analytics.d.ts.map +1 -1
  134. package/dist/client/analytics.js +22 -2
  135. package/dist/client/analytics.js.map +1 -1
  136. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  137. package/dist/client/use-agent-engine-configured.js +23 -13
  138. package/dist/client/use-agent-engine-configured.js.map +1 -1
  139. package/dist/client/use-chat-threads.d.ts +4 -0
  140. package/dist/client/use-chat-threads.d.ts.map +1 -1
  141. package/dist/client/use-chat-threads.js +49 -4
  142. package/dist/client/use-chat-threads.js.map +1 -1
  143. package/dist/collab/routes.d.ts +2 -2
  144. package/dist/db/ddl-guard.d.ts +88 -0
  145. package/dist/db/ddl-guard.d.ts.map +1 -0
  146. package/dist/db/ddl-guard.js +180 -0
  147. package/dist/db/ddl-guard.js.map +1 -0
  148. package/dist/notifications/routes.d.ts +1 -1
  149. package/dist/observability/routes.d.ts +5 -5
  150. package/dist/progress/routes.d.ts +1 -1
  151. package/dist/resources/handlers.d.ts +3 -3
  152. package/dist/secrets/storage.d.ts.map +1 -1
  153. package/dist/secrets/storage.js +26 -2
  154. package/dist/secrets/storage.js.map +1 -1
  155. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  156. package/dist/server/analytics.d.ts +4 -0
  157. package/dist/server/analytics.d.ts.map +1 -1
  158. package/dist/server/analytics.js +19 -3
  159. package/dist/server/analytics.js.map +1 -1
  160. package/dist/server/transcribe-voice.d.ts +1 -1
  161. package/dist/settings/store.d.ts.map +1 -1
  162. package/dist/settings/store.js +32 -6
  163. package/dist/settings/store.js.map +1 -1
  164. package/dist/sharing/access.d.ts.map +1 -1
  165. package/dist/sharing/access.js +26 -12
  166. package/dist/sharing/access.js.map +1 -1
  167. package/dist/sharing/actions/share-resource.js +20 -9
  168. package/dist/sharing/actions/share-resource.js.map +1 -1
  169. package/dist/sharing/actions/unshare-resource.js +13 -2
  170. package/dist/sharing/actions/unshare-resource.js.map +1 -1
  171. package/dist/sharing/registry.d.ts +9 -0
  172. package/dist/sharing/registry.d.ts.map +1 -1
  173. package/dist/sharing/registry.js.map +1 -1
  174. package/dist/vite/client.d.ts.map +1 -1
  175. package/dist/vite/client.js +1 -0
  176. package/dist/vite/client.js.map +1 -1
  177. package/package.json +3 -3
@@ -74,6 +74,7 @@ export interface UseChatThreadsOptions {
74
74
 
75
75
  const ACTIVE_THREAD_KEY = "agent-chat-active-thread";
76
76
  const THREADS_UPDATED_EVENT = "agent-chat:threads-updated";
77
+ const THREADS_PAGE_SIZE = 50;
77
78
 
78
79
  function emitThreadsUpdated() {
79
80
  if (typeof window === "undefined") return;
@@ -215,6 +216,10 @@ export function useChatThreads(
215
216
  }
216
217
 
217
218
  const [threads, setThreads] = useState<ChatThreadSummary[]>([]);
219
+ const [hasMoreThreads, setHasMoreThreads] = useState(false);
220
+ const [isLoadingMoreThreads, setIsLoadingMoreThreads] = useState(false);
221
+ const [threadsLoadError, setThreadsLoadError] = useState<string | null>(null);
222
+ const nextThreadsOffsetRef = useRef(0);
218
223
  const threadsRef = useRef<ChatThreadSummary[]>(threads);
219
224
  threadsRef.current = threads;
220
225
 
@@ -414,66 +419,106 @@ export function useChatThreads(
414
419
  threads,
415
420
  ]);
416
421
 
417
- const fetchThreads = useCallback(async () => {
418
- try {
419
- const res = await fetch(`${apiUrl}/threads`);
420
- if (!res.ok) return;
421
- const data = await res.json();
422
- setThreads((prev) => {
423
- const loaded = (data.threads ?? []) as ChatThreadSummary[];
424
- const loadedIds = new Set(loaded.map((t) => t.id));
425
- // Preserve any optimistic threads we've created this session that
426
- // haven't shown up in the server list yet — the server only learns
427
- // about a thread when the user actually sends a message and the
428
- // agent run's `persistSubmittedUserMessage` writes the row.
429
- const optimisticOnly = prev.filter(
430
- (t) => newlyCreatedRef.current.has(t.id) && !loadedIds.has(t.id),
422
+ const fetchThreads = useCallback(
423
+ async (options?: { append?: boolean }) => {
424
+ try {
425
+ const offset = options?.append ? nextThreadsOffsetRef.current : 0;
426
+ const res = await fetch(
427
+ offset > 0
428
+ ? `${apiUrl}/threads?offset=${offset}`
429
+ : `${apiUrl}/threads`,
431
430
  );
432
- // Reconcile each server thread against our local copy. If the local
433
- // copy has a newer updatedAt or higher messageCount, keep those
434
- // fields the server probably hasn't observed the user's latest
435
- // send yet, and naively replacing makes the recent-chats list
436
- // visibly jump back to older timestamps right after a send.
437
- const merged = loaded.map((server) => {
438
- const local = prev.find((t) => t.id === server.id);
439
- if (!local) return server;
440
- const next = { ...server };
441
- if (local.updatedAt > server.updatedAt) {
442
- next.updatedAt = local.updatedAt;
443
- }
444
- if (userRenamedThreadIdsRef.current.has(server.id) && local.title) {
445
- next.title = local.title;
446
- }
447
- if (pendingPinnedAtRef.current.has(server.id)) {
448
- next.pinnedAt = pendingPinnedAtRef.current.get(server.id) ?? null;
449
- }
450
- if (pendingArchivedAtRef.current.has(server.id)) {
451
- next.archivedAt =
452
- pendingArchivedAtRef.current.get(server.id) ?? null;
453
- }
454
- if (local.messageCount > server.messageCount) {
455
- next.messageCount = local.messageCount;
456
- if (local.preview) next.preview = local.preview;
457
- if (local.title) next.title = local.title;
431
+ if (!res.ok) {
432
+ if (!options?.append) {
433
+ setThreadsLoadError("Could not load chat history.");
458
434
  }
459
- // Preserve optimistic scope: when the server creates the row
460
- // on first message it does so without scope, and the next PUT
461
- // (saveThreadData) writes the local scope back. In the brief
462
- // window between those, the server list returns scope: null
463
- // while the user is clearly working inside a deck — keep the
464
- // local value so the tab bar doesn't blink unscoped.
465
- if (local.scope && !server.scope) {
466
- next.scope = local.scope;
435
+ return;
436
+ }
437
+ const data = await res.json();
438
+ setThreadsLoadError(null);
439
+ const loaded = (data.threads ?? []) as ChatThreadSummary[];
440
+ if (!options?.append) {
441
+ nextThreadsOffsetRef.current = loaded.length;
442
+ } else {
443
+ nextThreadsOffsetRef.current += loaded.length;
444
+ }
445
+ setHasMoreThreads(loaded.length >= THREADS_PAGE_SIZE);
446
+ setThreads((prev) => {
447
+ const loadedIds = new Set(loaded.map((t) => t.id));
448
+ // Preserve any optimistic threads we've created this session that
449
+ // haven't shown up in the server list yet — the server only learns
450
+ // about a thread when the user actually sends a message and the
451
+ // agent run's `persistSubmittedUserMessage` writes the row.
452
+ const optimisticOnly = prev.filter(
453
+ (t) => newlyCreatedRef.current.has(t.id) && !loadedIds.has(t.id),
454
+ );
455
+ // Reconcile each server thread against our local copy. If the local
456
+ // copy has a newer updatedAt or higher messageCount, keep those
457
+ // fields — the server probably hasn't observed the user's latest
458
+ // send yet, and naively replacing makes the recent-chats list
459
+ // visibly jump back to older timestamps right after a send.
460
+ const merged = loaded.map((server) => {
461
+ const local = prev.find((t) => t.id === server.id);
462
+ if (!local) return server;
463
+ const next = { ...server };
464
+ if (local.updatedAt > server.updatedAt) {
465
+ next.updatedAt = local.updatedAt;
466
+ }
467
+ if (userRenamedThreadIdsRef.current.has(server.id) && local.title) {
468
+ next.title = local.title;
469
+ }
470
+ if (pendingPinnedAtRef.current.has(server.id)) {
471
+ next.pinnedAt = pendingPinnedAtRef.current.get(server.id) ?? null;
472
+ }
473
+ if (pendingArchivedAtRef.current.has(server.id)) {
474
+ next.archivedAt =
475
+ pendingArchivedAtRef.current.get(server.id) ?? null;
476
+ }
477
+ if (local.messageCount > server.messageCount) {
478
+ next.messageCount = local.messageCount;
479
+ if (local.preview) next.preview = local.preview;
480
+ if (local.title) next.title = local.title;
481
+ }
482
+ // Preserve optimistic scope: when the server creates the row
483
+ // on first message it does so without scope, and the next PUT
484
+ // (saveThreadData) writes the local scope back. In the brief
485
+ // window between those, the server list returns scope: null
486
+ // while the user is clearly working inside a deck — keep the
487
+ // local value so the tab bar doesn't blink unscoped.
488
+ if (local.scope && !server.scope) {
489
+ next.scope = local.scope;
490
+ }
491
+ return next;
492
+ });
493
+ if (options?.append) {
494
+ const existingIds = new Set(prev.map((t) => t.id));
495
+ return sortThreadSummaries([
496
+ ...prev,
497
+ ...merged.filter((t) => !existingIds.has(t.id)),
498
+ ]);
467
499
  }
468
- return next;
500
+ return [...optimisticOnly, ...merged];
469
501
  });
470
- return [...optimisticOnly, ...merged];
471
- });
472
- return data.threads as ChatThreadSummary[];
473
- } catch {
474
- return undefined;
502
+ return data.threads as ChatThreadSummary[];
503
+ } catch {
504
+ if (!options?.append) {
505
+ setThreadsLoadError("Could not load chat history.");
506
+ }
507
+ return undefined;
508
+ }
509
+ },
510
+ [apiUrl],
511
+ );
512
+
513
+ const loadMoreThreads = useCallback(async (): Promise<void> => {
514
+ if (isLoadingMoreThreads || !hasMoreThreads) return;
515
+ setIsLoadingMoreThreads(true);
516
+ try {
517
+ await fetchThreads({ append: true });
518
+ } finally {
519
+ setIsLoadingMoreThreads(false);
475
520
  }
476
- }, [apiUrl]);
521
+ }, [fetchThreads, hasMoreThreads, isLoadingMoreThreads]);
477
522
 
478
523
  // Initial load: load threads from server, then reconcile against the
479
524
  // saved active thread.
@@ -1158,10 +1203,14 @@ export function useChatThreads(
1158
1203
  saveThreadData,
1159
1204
  generateTitle,
1160
1205
  searchThreads,
1206
+ loadMoreThreads,
1161
1207
  getThreadShareState,
1162
1208
  createThreadShareLink,
1163
1209
  revokeThreadShareLink,
1164
1210
  refreshThreads,
1211
+ hasMoreThreads,
1212
+ isLoadingMoreThreads,
1213
+ threadsLoadError,
1165
1214
  isNewThread,
1166
1215
  };
1167
1216
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Guards for on-demand `ensureTable()` DDL so the common already-migrated path
3
+ * takes NO `ACCESS EXCLUSIVE` lock on Postgres.
4
+ *
5
+ * Lives in its own module (like `./widen-columns.js`) so stores can import it
6
+ * without every `vi.mock("../db/client.js")` test needing to stub it: the
7
+ * helpers resolve `isPostgres()` / `getDbExec()` through `client.js`, so a test
8
+ * that mocks the client to SQLite (`isPostgres: () => false`) makes the
9
+ * Postgres-only existence checks no-ops automatically.
10
+ *
11
+ * ## Why
12
+ *
13
+ * `ensureTable()` runs once per process on first DB touch. In a long-lived Node
14
+ * server the cost is paid once and is invisible. In a Netlify `-background`
15
+ * function the process is fresh, so this is the FIRST touch — and the
16
+ * `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN` DDL it issues takes an
17
+ * `ACCESS EXCLUSIVE` lock on the shared Neon Postgres database. Behind a
18
+ * concurrent connection that already holds a conflicting lock, that DDL can
19
+ * block ~indefinitely (observed >16s hangs in the bg worker vs ~1s inline).
20
+ *
21
+ * The tables/columns are essentially always already present in production, so
22
+ * the DDL is redundant in the hot path. These helpers let a store cheaply check
23
+ * `information_schema` first and only issue DDL when something is actually
24
+ * missing — and when DDL must run, wrap it in a short `lock_timeout` so a
25
+ * contended lock fails fast instead of hanging.
26
+ *
27
+ * All of this is Postgres-only behaviour gated on `isPostgres()`. On SQLite
28
+ * (local dev) there is no such lock problem, so callers keep their existing
29
+ * behaviour there.
30
+ */
31
+
32
+ import { isPostgres, getDbExec, type DbExec } from "./client.js";
33
+
34
+ const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
+
36
+ /**
37
+ * True when running against Postgres AND the given table already exists in the
38
+ * `public` schema. Returns `false` on SQLite (callers gate their own behaviour
39
+ * there), for invalid identifiers, or when `information_schema` is unreadable —
40
+ * the conservative answer "not known to exist" makes the caller fall through to
41
+ * its idempotent `CREATE TABLE IF NOT EXISTS`, preserving today's behaviour.
42
+ *
43
+ * This is a plain read (no lock), so it never blocks on an `ACCESS EXCLUSIVE`
44
+ * lock the way `CREATE`/`ALTER` would.
45
+ */
46
+ export async function pgTableExists(
47
+ table: string,
48
+ injectedClient?: DbExec,
49
+ ): Promise<boolean> {
50
+ if (!isPostgres() || !PLAIN_IDENTIFIER.test(table)) return false;
51
+ const client = injectedClient ?? getDbExec();
52
+ try {
53
+ const { rows } = await client.execute({
54
+ sql: `SELECT 1 FROM information_schema.tables
55
+ WHERE table_schema = 'public' AND table_name = ? LIMIT 1`,
56
+ args: [table],
57
+ });
58
+ return rows.length > 0;
59
+ } catch {
60
+ // information_schema unreadable (permissions / non-standard backend) —
61
+ // report "unknown" so the caller falls back to IF NOT EXISTS.
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * True when running against Postgres AND the given column already exists on the
68
+ * given table in the `public` schema. Returns `false` on SQLite, for invalid
69
+ * identifiers, or when `information_schema` is unreadable.
70
+ *
71
+ * Plain read — no lock taken.
72
+ */
73
+ export async function pgColumnExists(
74
+ table: string,
75
+ column: string,
76
+ injectedClient?: DbExec,
77
+ ): Promise<boolean> {
78
+ if (!isPostgres()) return false;
79
+ if (!PLAIN_IDENTIFIER.test(table) || !PLAIN_IDENTIFIER.test(column)) {
80
+ return false;
81
+ }
82
+ const client = injectedClient ?? getDbExec();
83
+ try {
84
+ const { rows } = await client.execute({
85
+ sql: `SELECT 1 FROM information_schema.columns
86
+ WHERE table_schema = 'public' AND table_name = ? AND column_name = ?
87
+ LIMIT 1`,
88
+ args: [table, column],
89
+ });
90
+ return rows.length > 0;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * True when running against Postgres AND an index with the given name already
98
+ * exists in the `public` schema. Returns `false` on SQLite, for invalid
99
+ * identifiers, or when `pg_indexes` is unreadable.
100
+ *
101
+ * `CREATE INDEX` (without CONCURRENTLY) takes a `SHARE` lock that blocks
102
+ * writes, so on a fresh background-worker process behind a concurrent
103
+ * connection this can hang just like a `CREATE`/`ALTER` would; checking first
104
+ * skips the lock on the already-migrated hot path.
105
+ */
106
+ export async function pgIndexExists(
107
+ indexName: string,
108
+ injectedClient?: DbExec,
109
+ ): Promise<boolean> {
110
+ if (!isPostgres() || !PLAIN_IDENTIFIER.test(indexName)) return false;
111
+ const client = injectedClient ?? getDbExec();
112
+ try {
113
+ const { rows } = await client.execute({
114
+ sql: `SELECT 1 FROM pg_indexes
115
+ WHERE schemaname = 'public' AND indexname = ? LIMIT 1`,
116
+ args: [indexName],
117
+ });
118
+ return rows.length > 0;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /** True when an error looks like a Postgres `lock_timeout` (SQLSTATE 55P03). */
125
+ export function isLockTimeoutError(err: unknown): boolean {
126
+ const anyErr = err as { code?: unknown; message?: unknown } | null;
127
+ if (anyErr?.code === "55P03") return true;
128
+ const msg = String(anyErr?.message ?? anyErr ?? "");
129
+ return /lock[_ ]?timeout|canceling statement due to lock timeout/i.test(msg);
130
+ }
131
+
132
+ /**
133
+ * Run a DDL statement that MUST execute (the schema is actually missing), with
134
+ * a short `lock_timeout` so a contended `ACCESS EXCLUSIVE` lock fails fast
135
+ * instead of hanging the whole process.
136
+ *
137
+ * Postgres path: wrap the DDL in an explicit transaction and set
138
+ * `SET LOCAL lock_timeout` so the timeout is scoped to THIS transaction only
139
+ * and reset automatically on COMMIT/ROLLBACK — it never leaks onto the pooled
140
+ * (session-reused) connection the way a bare `SET lock_timeout` would. If the
141
+ * DbExec has no `transaction` (shouldn't happen for Postgres, but defensively),
142
+ * fall back to a session `SET` + `RESET` in a finally. A lock-timeout error is
143
+ * swallowed: the table/column is virtually always already correct by the time a
144
+ * contended boot retries, and the caller's memoization should still resolve so
145
+ * the path isn't retried in a tight loop. Any non-lock-timeout error rethrows.
146
+ *
147
+ * SQLite path: no lock problem — just run the DDL directly.
148
+ *
149
+ * @returns `true` if the DDL ran to completion, `false` if it was skipped due to
150
+ * a lock-timeout (so the caller can decide whether to log).
151
+ */
152
+ export async function runGuardedDdl(
153
+ ddl: string,
154
+ options: { lockTimeout?: string; injectedClient?: DbExec } = {},
155
+ ): Promise<boolean> {
156
+ const client = options.injectedClient ?? getDbExec();
157
+ if (!isPostgres()) {
158
+ await client.execute(ddl);
159
+ return true;
160
+ }
161
+
162
+ const lockTimeout = options.lockTimeout ?? "3s";
163
+ try {
164
+ if (typeof client.transaction === "function") {
165
+ await client.transaction(async (tx) => {
166
+ // SET LOCAL is transaction-scoped: it reverts on COMMIT/ROLLBACK and
167
+ // never persists on the pooled connection.
168
+ await tx.execute(`SET LOCAL lock_timeout = '${lockTimeout}'`);
169
+ await tx.execute(ddl);
170
+ });
171
+ } else {
172
+ // Defensive fallback: no transaction support. Use a session SET and
173
+ // guarantee a RESET so the timeout never leaks onto a reused connection.
174
+ try {
175
+ await client.execute(`SET lock_timeout = '${lockTimeout}'`);
176
+ await client.execute(ddl);
177
+ } finally {
178
+ await client.execute(`RESET lock_timeout`).catch(() => {});
179
+ }
180
+ }
181
+ return true;
182
+ } catch (err) {
183
+ if (isLockTimeoutError(err)) {
184
+ // Contended lock — the schema is virtually always already correct by now.
185
+ // Proceed; the caller's memoization still resolves so we don't loop.
186
+ return false;
187
+ }
188
+ throw err;
189
+ }
190
+ }
@@ -15,6 +15,11 @@
15
15
  import { randomUUID } from "node:crypto";
16
16
 
17
17
  import { getDbExec, isPostgres } from "../db/client.js";
18
+ import {
19
+ pgColumnExists,
20
+ pgTableExists,
21
+ runGuardedDdl,
22
+ } from "../db/ddl-guard.js";
18
23
  import {
19
24
  encryptSecretValue as encryptValue,
20
25
  decryptSecretValue as decryptValue,
@@ -34,10 +39,39 @@ async function ensureTable(): Promise<void> {
34
39
  const client = getDbExec();
35
40
  // Postgres version of the CREATE TABLE — the generic `INTEGER` maps to
36
41
  // BIGINT on Postgres, which we need for millisecond timestamps.
37
- const sql = isPostgres()
42
+ const createSql = isPostgres()
38
43
  ? APP_SECRETS_CREATE_SQL.replace(/\bINTEGER\b/g, "BIGINT")
39
44
  : APP_SECRETS_CREATE_SQL;
40
- await client.execute(sql);
45
+
46
+ if (isPostgres()) {
47
+ // Hot path: in production the table and both additive columns are
48
+ // virtually always already present. Issuing `CREATE`/`ALTER` would
49
+ // still take an ACCESS EXCLUSIVE lock — which, in a fresh background
50
+ // worker process behind a concurrent connection on the shared Neon DB,
51
+ // can block ~indefinitely. So check `information_schema` first (a plain
52
+ // read, no lock) and run DDL ONLY for what is actually missing. When
53
+ // DDL must run, `runGuardedDdl` wraps it in a transaction-scoped
54
+ // `lock_timeout` so a contended lock fails fast instead of hanging.
55
+ if (!(await pgTableExists("app_secrets"))) {
56
+ await runGuardedDdl(createSql);
57
+ }
58
+ if (!(await pgColumnExists("app_secrets", "description"))) {
59
+ await runGuardedDdl(
60
+ `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS description TEXT`,
61
+ );
62
+ }
63
+ if (!(await pgColumnExists("app_secrets", "url_allowlist"))) {
64
+ await runGuardedDdl(
65
+ `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS url_allowlist TEXT`,
66
+ );
67
+ }
68
+ return;
69
+ }
70
+
71
+ // SQLite (local dev): no ACCESS EXCLUSIVE lock problem, keep the original
72
+ // create-then-additive-alter behaviour. SQLite has no
73
+ // `ADD COLUMN IF NOT EXISTS`, so the ALTERs stay wrapped in try/catch.
74
+ await client.execute(createSql);
41
75
 
42
76
  // Additive migration: description column (for ad-hoc keys)
43
77
  try {
@@ -3,6 +3,10 @@
3
3
  * Supported environment variables:
4
4
  * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID
5
5
  *
6
+ * Netlify configuration-file env vars are build-time only for serverless
7
+ * functions, so the Vite plugin also bakes this public value into SSR bundles
8
+ * as `__AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__`.
9
+ *
6
10
  * Amplitude and Sentry are initialized client-side via their npm packages
7
11
  * (see `packages/core/src/client/analytics.ts`). Only GA requires script
8
12
  * tag injection because the gtag.js loader must be a `<script src>`.
@@ -17,16 +21,36 @@
17
21
  * ```
18
22
  */
19
23
 
24
+ declare const __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__: string | undefined;
25
+
26
+ function normalizeMeasurementId(value: string | undefined): string | null {
27
+ const trimmed = value?.trim();
28
+ return trimmed ? trimmed : null;
29
+ }
30
+
31
+ function getGaMeasurementId(): string | null {
32
+ return (
33
+ normalizeMeasurementId(process.env.GA_MEASUREMENT_ID) ||
34
+ normalizeMeasurementId(
35
+ typeof __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__ === "string"
36
+ ? __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__
37
+ : undefined,
38
+ )
39
+ );
40
+ }
41
+
20
42
  function getGaScript(): string | null {
21
- const id = process.env.GA_MEASUREMENT_ID;
43
+ const id = getGaMeasurementId();
22
44
  if (!id) return null;
45
+ const srcId = encodeURIComponent(id);
46
+ const jsId = JSON.stringify(id);
23
47
  return (
24
- `<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>` +
48
+ `<script async src="https://www.googletagmanager.com/gtag/js?id=${srcId}"></script>` +
25
49
  `<script>` +
26
50
  `window.dataLayer=window.dataLayer||[];` +
27
51
  `function gtag(){dataLayer.push(arguments);}` +
28
52
  `gtag('js',new Date());` +
29
- `gtag('config','${id}');` +
53
+ `gtag('config',${jsId});` +
30
54
  `if(typeof sessionStorage!=='undefined'&&sessionStorage.getItem('__an_signin')){` +
31
55
  `sessionStorage.removeItem('__an_signin');` +
32
56
  `gtag('event','sign_in');` +
@@ -1,6 +1,11 @@
1
1
  import { EventEmitter } from "events";
2
2
 
3
3
  import { getDbExec, isPostgres, intType } from "../db/client.js";
4
+ import {
5
+ pgIndexExists,
6
+ pgTableExists,
7
+ runGuardedDdl,
8
+ } from "../db/ddl-guard.js";
4
9
  import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
5
10
 
6
11
  let _initPromise: Promise<void> | undefined;
@@ -20,17 +25,46 @@ async function ensureTable(): Promise<void> {
20
25
  _initPromise = (async () => {
21
26
  const client = getDbExec();
22
27
  const table = settingsTable();
23
- await client.execute(`
28
+ const createSql = `
24
29
  CREATE TABLE IF NOT EXISTS ${table} (
25
30
  key TEXT PRIMARY KEY,
26
31
  value TEXT NOT NULL,
27
32
  updated_at ${intType()} NOT NULL
28
33
  )
29
- `);
30
- // Older deployments (pre BIGINT-compat) have a 32-bit `updated_at`; on
31
- // Postgres the `Date.now()` written on every setSetting overflows int4.
32
- // Widen in place (no-op once done / on fresh DBs). Pass the unqualified
33
- // name the helper scopes to the `public` schema.
34
+ `;
35
+
36
+ if (isPostgres()) {
37
+ // Hot path: the `settings` table and its poll index are virtually
38
+ // always already present in production. Issuing `CREATE TABLE`/
39
+ // `CREATE INDEX` still takes a lock that, in a fresh background-worker
40
+ // process behind a concurrent connection on the shared Neon DB, can
41
+ // block ~indefinitely (ACCESS EXCLUSIVE for CREATE TABLE; a write-
42
+ // blocking SHARE lock for CREATE INDEX). So check `information_schema`/
43
+ // `pg_indexes` first (plain reads, no lock) and run DDL ONLY for what
44
+ // is actually missing. `runGuardedDdl` bounds any DDL that must run
45
+ // with a transaction-scoped `lock_timeout` so a contended lock fails
46
+ // fast instead of hanging. `settingsTable()` is `public.settings` on
47
+ // Postgres; the existence checks use the unqualified table name.
48
+ if (!(await pgTableExists("settings"))) {
49
+ await runGuardedDdl(createSql);
50
+ }
51
+ // Older deployments (pre BIGINT-compat) have a 32-bit `updated_at`; on
52
+ // Postgres the `Date.now()` written on every setSetting overflows int4.
53
+ // widenIntColumnsToBigInt already probes information_schema and only
54
+ // ALTERs columns that are still int4 — a no-op on fresh/widened DBs.
55
+ await widenIntColumnsToBigInt("settings", ["updated_at"]);
56
+ // Index for the poll watermark query: `SELECT MAX(updated_at)`.
57
+ if (!(await pgIndexExists("settings_updated_at_idx"))) {
58
+ await runGuardedDdl(
59
+ `CREATE INDEX IF NOT EXISTS settings_updated_at_idx ON ${table} (updated_at)`,
60
+ );
61
+ }
62
+ return;
63
+ }
64
+
65
+ // SQLite (local dev): no lock problem — keep the original behaviour.
66
+ await client.execute(createSql);
67
+ // No-op on SQLite (INTEGER is already 64-bit).
34
68
  await widenIntColumnsToBigInt("settings", ["updated_at"]);
35
69
  // Index for the poll watermark query: `SELECT MAX(updated_at) FROM settings`.
36
70
  // MAX on an indexed column avoids a full-table scan on every poll cycle.
@@ -71,6 +71,15 @@ export function resolveRegisteredAccessContext(
71
71
  return reg?.resolveAccessContext ? reg.resolveAccessContext(ctx) : ctx;
72
72
  }
73
73
 
74
+ function normalizeEmailForAccess(email: string | undefined): string | null {
75
+ const normalized = email?.trim().toLowerCase();
76
+ return normalized || null;
77
+ }
78
+
79
+ function emailColumnMatches(column: any, email: string): SQL {
80
+ return sql`lower(${column}) = ${email}`;
81
+ }
82
+
74
83
  /**
75
84
  * Build a Drizzle `WHERE` clause that admits rows the current user can see.
76
85
  * Pass the ownable resource table and its shares table; optional min role
@@ -103,15 +112,16 @@ export function accessFilter(
103
112
  const reg = findRegistrationByTable(resourceTable);
104
113
  const ctx = resolveRegisteredAccessContext(reg, rawCtx);
105
114
  const { userEmail, orgId } = ctx;
115
+ const normalizedUserEmail = normalizeEmailForAccess(userEmail);
106
116
  const publicAllowed = reg?.allowPublic !== false;
107
117
  const includePublic = (options.includePublic ?? false) && publicAllowed;
108
118
  const clauses: SQL[] = [];
109
119
 
110
- if (userEmail) {
120
+ if (normalizedUserEmail) {
111
121
  clauses.push(
112
122
  and(
113
- eq(resourceTable.ownerEmail, userEmail),
114
- ownerScopeFilter(resourceTable, ctx),
123
+ emailColumnMatches(resourceTable.ownerEmail, normalizedUserEmail),
124
+ ownerScopeFilter(reg, resourceTable, ctx),
115
125
  )!,
116
126
  );
117
127
  }
@@ -128,13 +138,13 @@ export function accessFilter(
128
138
  );
129
139
  }
130
140
  }
131
- if (userEmail) {
141
+ if (normalizedUserEmail) {
132
142
  const shareScope = restrictedShareScopeSql(reg, resourceTable, ctx);
133
143
  clauses.push(
134
144
  sql`exists (select 1 from ${sharesTable}
135
145
  where ${sharesTable.resourceId} = ${resourceTable.id}
136
146
  and ${sharesTable.principalType} = 'user'
137
- and ${sharesTable.principalId} = ${userEmail}
147
+ and lower(${sharesTable.principalId}) = ${normalizedUserEmail}
138
148
  and ${shareScope}
139
149
  and ${minRoleSql(minRole)})`,
140
150
  );
@@ -154,7 +164,12 @@ export function accessFilter(
154
164
  return or(...clauses) ?? sql`1=0`;
155
165
  }
156
166
 
157
- function ownerScopeFilter(resourceTable: any, ctx: AccessContext): SQL {
167
+ function ownerScopeFilter(
168
+ reg: ShareableResourceRegistration | undefined,
169
+ resourceTable: any,
170
+ ctx: AccessContext,
171
+ ): SQL {
172
+ if (reg?.ownerAccessIgnoresOrg === true) return sql`1=1`;
158
173
  if (ctx.orgId) {
159
174
  // Rows created before org-scoping, or in solo mode, have no org_id. Keep
160
175
  // them manageable by their owner after the owner joins or switches into an
@@ -167,7 +182,12 @@ function ownerScopeFilter(resourceTable: any, ctx: AccessContext): SQL {
167
182
  return sql`${resourceTable.orgId} IS NULL`;
168
183
  }
169
184
 
170
- function ownerMatchesActiveScope(resource: any, ctx: AccessContext): boolean {
185
+ function ownerMatchesActiveScope(
186
+ reg: ShareableResourceRegistration | undefined,
187
+ resource: any,
188
+ ctx: AccessContext,
189
+ ): boolean {
190
+ if (reg?.ownerAccessIgnoresOrg === true) return true;
171
191
  const resourceOrgId = resource?.orgId ?? null;
172
192
  if (!resourceOrgId) return true;
173
193
  return ctx.orgId === resourceOrgId;
@@ -233,11 +253,12 @@ export async function resolveAccess(
233
253
  if (!resource) return null;
234
254
 
235
255
  const { userEmail, orgId } = ctx;
256
+ const normalizedUserEmail = normalizeEmailForAccess(userEmail);
236
257
 
237
258
  if (
238
- userEmail &&
239
- resource.ownerEmail === userEmail &&
240
- ownerMatchesActiveScope(resource, ctx)
259
+ normalizedUserEmail &&
260
+ normalizeEmailForAccess(resource.ownerEmail) === normalizedUserEmail &&
261
+ ownerMatchesActiveScope(reg, resource, ctx)
241
262
  ) {
242
263
  return { role: "owner", resource };
243
264
  }
@@ -265,16 +286,17 @@ async function highestShareRole(
265
286
  resource: any,
266
287
  ): Promise<ShareRole | null> {
267
288
  const { userEmail, orgId } = ctx;
268
- if (!userEmail && !orgId) return null;
289
+ const normalizedUserEmail = normalizeEmailForAccess(userEmail);
290
+ if (!normalizedUserEmail && !orgId) return null;
269
291
  if (!explicitSharesAllowedForResource(reg, resource, ctx)) return null;
270
292
  const db = reg.getDb() as any;
271
293
 
272
294
  const principalClauses: ReturnType<typeof and>[] = [];
273
- if (userEmail) {
295
+ if (normalizedUserEmail) {
274
296
  principalClauses.push(
275
297
  and(
276
298
  eq(reg.sharesTable.principalType, "user"),
277
- eq(reg.sharesTable.principalId, userEmail),
299
+ emailColumnMatches(reg.sharesTable.principalId, normalizedUserEmail),
278
300
  ),
279
301
  );
280
302
  }