@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +29 -0
- package/corpus/core/package.json +3 -3
- package/corpus/core/src/agent/production-agent.ts +18 -0
- package/corpus/core/src/agent/thread-data-builder.ts +21 -2
- package/corpus/core/src/cli/create.ts +35 -1
- package/corpus/core/src/cli/skills.ts +8 -0
- package/corpus/core/src/client/AgentPanel.tsx +57 -25
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +31 -1
- package/corpus/core/src/client/analytics.ts +24 -2
- package/corpus/core/src/client/use-agent-engine-configured.ts +29 -15
- package/corpus/core/src/client/use-chat-threads.ts +104 -55
- package/corpus/core/src/db/ddl-guard.ts +190 -0
- package/corpus/core/src/secrets/storage.ts +36 -2
- package/corpus/core/src/server/analytics.ts +27 -3
- package/corpus/core/src/settings/store.ts +40 -6
- package/corpus/core/src/sharing/access.ts +35 -13
- package/corpus/core/src/sharing/actions/share-resource.ts +32 -9
- package/corpus/core/src/sharing/actions/unshare-resource.ts +25 -2
- package/corpus/core/src/sharing/registry.ts +9 -0
- package/corpus/core/src/vite/client.ts +3 -0
- package/corpus/templates/analytics/actions/data-source-status.ts +25 -14
- package/corpus/templates/analytics/app/components/layout/NewAnalysisDialog.tsx +2 -2
- package/corpus/templates/analytics/app/lib/data-source-status.ts +47 -6
- package/corpus/templates/analytics/app/lib/data-sources.ts +14 -3
- package/corpus/templates/analytics/app/pages/DataSources.tsx +24 -5
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +15 -43
- package/corpus/templates/analytics/changelog/2026-06-25-fixed-hubspot-data-source-status-so-private-app-tokens-legac.md +6 -0
- package/corpus/templates/analytics/netlify.toml +1 -0
- package/corpus/templates/analytics/server/lib/credential-keys.ts +3 -1
- package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +12 -3
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +5 -2
- package/corpus/templates/assets/changelog/2026-06-25-create-now-returns-from-agent-settings-to-the-asset-chat-eve.md +6 -0
- package/corpus/templates/assets/netlify.toml +1 -0
- package/corpus/templates/brain/netlify.toml +1 -0
- package/corpus/templates/calendar/app/i18n-data.ts +12 -0
- package/corpus/templates/calendar/app/lib/clipboard.ts +58 -0
- package/corpus/templates/calendar/app/pages/AvailabilitySettings.tsx +15 -9
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +6 -2
- package/corpus/templates/calendar/changelog/2026-06-25-booking-link-copy-buttons-no-longer-fail-when-clipboard-perm.md +6 -0
- package/corpus/templates/calendar/netlify.toml +1 -0
- package/corpus/templates/chat/netlify.toml +1 -0
- package/corpus/templates/clips/AGENTS.md +9 -7
- package/corpus/templates/clips/actions/add-vocabulary-term.ts +2 -2
- package/corpus/templates/clips/actions/archive-recording.ts +5 -2
- package/corpus/templates/clips/actions/clear-edits.ts +5 -2
- package/corpus/templates/clips/actions/create-folder.ts +7 -2
- package/corpus/templates/clips/actions/delete-folder.ts +7 -4
- package/corpus/templates/clips/actions/delete-recording-permanent.ts +5 -2
- package/corpus/templates/clips/actions/finalize-recording.ts +5 -2
- package/corpus/templates/clips/actions/import-loom-recording.ts +2 -1
- package/corpus/templates/clips/actions/list-notifications.ts +9 -5
- package/corpus/templates/clips/actions/list-organization-state.ts +2 -1
- package/corpus/templates/clips/actions/list-recordings.ts +4 -1
- package/corpus/templates/clips/actions/move-recording.ts +5 -1
- package/corpus/templates/clips/actions/rename-folder.ts +6 -3
- package/corpus/templates/clips/actions/request-transcript.ts +5 -2
- package/corpus/templates/clips/actions/restore-recording.ts +5 -2
- package/corpus/templates/clips/actions/save-browser-diagnostics.ts +21 -8
- package/corpus/templates/clips/actions/set-chapters.ts +5 -2
- package/corpus/templates/clips/actions/set-thumbnail.ts +5 -2
- package/corpus/templates/clips/actions/split-recording.ts +5 -2
- package/corpus/templates/clips/actions/stitch-recordings.ts +6 -2
- package/corpus/templates/clips/actions/trash-recording.ts +5 -2
- package/corpus/templates/clips/actions/undo-edit.ts +5 -2
- package/corpus/templates/clips/actions/view-screen.ts +2 -1
- package/corpus/templates/clips/app/components/library/recording-card.tsx +24 -7
- package/corpus/templates/clips/app/lib/browser-diagnostics-capture.ts +5 -1
- package/corpus/templates/clips/changelog/2026-06-25-chrome-extension-recordings-now-upload-large-capture-bursts.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-25-console-diagnostics-keep-more-useful-non-secret-details-when.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-25-fixed-owner-recognition-so-clips-created-from-desktop-and-we.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +3 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/background.ts +85 -9
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +55 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +79 -3
- package/corpus/templates/clips/chrome-extension/src/options.ts +10 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +6 -0
- package/corpus/templates/clips/chrome-extension/src/permission.ts +8 -1
- package/corpus/templates/clips/chrome-extension/src/popup.ts +65 -39
- package/corpus/templates/clips/chrome-extension/src/sentry.ts +206 -0
- package/corpus/templates/clips/chrome-extension/vite.config.ts +47 -0
- package/corpus/templates/clips/netlify.toml +1 -0
- package/corpus/templates/clips/server/db/index.ts +1 -0
- package/corpus/templates/clips/server/lib/google-calendar-oauth.ts +35 -23
- package/corpus/templates/clips/server/lib/public-agent-context.ts +8 -1
- package/corpus/templates/clips/server/lib/recordings.ts +18 -2
- package/corpus/templates/clips/server/lib/slack-oauth.ts +9 -6
- package/corpus/templates/clips/server/plugins/onboarding-calendar.ts +6 -2
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +2 -2
- package/corpus/templates/clips/server/routes/api/recordings/[recordingId]/thumbnail.post.ts +5 -2
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +5 -2
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +5 -2
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +5 -2
- package/corpus/templates/clips/shared/browser-diagnostics.ts +14 -4
- package/corpus/templates/content/netlify.toml +1 -0
- package/corpus/templates/design/netlify.toml +1 -0
- package/corpus/templates/dispatch/netlify.toml +1 -0
- package/corpus/templates/forms/netlify.toml +1 -0
- package/corpus/templates/macros/netlify.toml +1 -0
- package/corpus/templates/mail/app/components/email/EmailList.tsx +7 -3
- package/corpus/templates/mail/app/components/email/EmailThread.tsx +4 -1
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +10 -2
- package/corpus/templates/mail/app/hooks/use-compose-state.ts +15 -5
- package/corpus/templates/mail/app/hooks/use-google-auth.ts +47 -9
- package/corpus/templates/mail/changelog/2026-06-25-account-avatars-stay-stable-while-mail-refreshes-google-acco.md +6 -0
- package/corpus/templates/mail/changelog/2026-06-25-mail-background-draft-saves-and-thread-prefetches-no-longer.md +6 -0
- package/corpus/templates/mail/netlify.toml +1 -0
- package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +8 -0
- package/corpus/templates/plan/netlify.toml +1 -0
- package/corpus/templates/slides/netlify.toml +1 -0
- package/corpus/templates/videos/netlify.toml +1 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +18 -0
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/thread-data-builder.d.ts.map +1 -1
- package/dist/agent/thread-data-builder.js +23 -2
- package/dist/agent/thread-data-builder.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +28 -1
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/skills.d.ts +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +8 -0
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AgentPanel.d.ts +8 -2
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +29 -15
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +4 -4
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +22 -2
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +23 -13
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/client/use-chat-threads.d.ts +4 -0
- package/dist/client/use-chat-threads.d.ts.map +1 -1
- package/dist/client/use-chat-threads.js +49 -4
- package/dist/client/use-chat-threads.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/db/ddl-guard.d.ts +88 -0
- package/dist/db/ddl-guard.d.ts.map +1 -0
- package/dist/db/ddl-guard.js +180 -0
- package/dist/db/ddl-guard.js.map +1 -0
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +26 -2
- package/dist/secrets/storage.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +2 -2
- package/dist/server/analytics.d.ts +4 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +19 -3
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/settings/store.d.ts.map +1 -1
- package/dist/settings/store.js +32 -6
- package/dist/settings/store.js.map +1 -1
- package/dist/sharing/access.d.ts.map +1 -1
- package/dist/sharing/access.js +26 -12
- package/dist/sharing/access.js.map +1 -1
- package/dist/sharing/actions/share-resource.js +20 -9
- package/dist/sharing/actions/share-resource.js.map +1 -1
- package/dist/sharing/actions/unshare-resource.js +13 -2
- package/dist/sharing/actions/unshare-resource.js.map +1 -1
- package/dist/sharing/registry.d.ts +9 -0
- package/dist/sharing/registry.d.ts.map +1 -1
- package/dist/sharing/registry.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +1 -0
- package/dist/vite/client.js.map +1 -1
- 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(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
500
|
+
return [...optimisticOnly, ...merged];
|
|
469
501
|
});
|
|
470
|
-
return [
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
}, [
|
|
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
|
|
42
|
+
const createSql = isPostgres()
|
|
38
43
|
? APP_SECRETS_CREATE_SQL.replace(/\bINTEGER\b/g, "BIGINT")
|
|
39
44
|
: APP_SECRETS_CREATE_SQL;
|
|
40
|
-
|
|
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 =
|
|
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=${
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 (
|
|
120
|
+
if (normalizedUserEmail) {
|
|
111
121
|
clauses.push(
|
|
112
122
|
and(
|
|
113
|
-
|
|
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 (
|
|
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} = ${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
239
|
-
resource.ownerEmail ===
|
|
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
|
-
|
|
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 (
|
|
295
|
+
if (normalizedUserEmail) {
|
|
274
296
|
principalClauses.push(
|
|
275
297
|
and(
|
|
276
298
|
eq(reg.sharesTable.principalType, "user"),
|
|
277
|
-
|
|
299
|
+
emailColumnMatches(reg.sharesTable.principalId, normalizedUserEmail),
|
|
278
300
|
),
|
|
279
301
|
);
|
|
280
302
|
}
|