diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 788b78f3..a20b6fe4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -54,6 +54,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
+ persist-credentials: false
- name: Set up Node
uses: actions/setup-node@v4
@@ -85,6 +86,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
+ persist-credentials: false
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
@@ -182,3 +184,4 @@ jobs:
- if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: exit 1
- run: echo "All checks passed"
+
diff --git a/src/app/(app)/devices/components/device-details-view.tsx b/src/app/(app)/devices/components/device-details-view.tsx
index f689f6a4..43b19904 100644
--- a/src/app/(app)/devices/components/device-details-view.tsx
+++ b/src/app/(app)/devices/components/device-details-view.tsx
@@ -80,15 +80,17 @@ export function DeviceDetailsView({ deviceId }: DeviceDetailsViewProps) {
}, []);
// Handle action params from URL (e.g., from table dropdown navigation). Opening
- // the modal is derived state and happens during render — an effect draws the
- // page once without it, so arriving from the table shows a flash of the plain
- // detail view. Clearing the param stays in the effect: it is a navigation.
+ // the modal is driven from an effect (rather than during render) so the two
+ // setState calls only ever run once per request, are safe under Strict Mode's
+ // double-invocation, and never fire after the component has started unmounting.
const runScriptRequested = searchParams.get('action') === 'runScript' && !isLoading;
const [handledRunScript, setHandledRunScript] = useState(false);
- if (runScriptRequested && !handledRunScript) {
- setHandledRunScript(true);
- setIsScriptsModalOpen(true);
- }
+ useEffect(() => {
+ if (runScriptRequested && !handledRunScript) {
+ setHandledRunScript(true);
+ setIsScriptsModalOpen(true);
+ }
+ }, [runScriptRequested, handledRunScript]);
useEffect(() => {
if (!runScriptRequested) return;
diff --git a/src/app/(app)/devices/components/tabs/overview-tab.tsx b/src/app/(app)/devices/components/tabs/overview-tab.tsx
index 23f23782..590f686b 100644
--- a/src/app/(app)/devices/components/tabs/overview-tab.tsx
+++ b/src/app/(app)/devices/components/tabs/overview-tab.tsx
@@ -24,6 +24,11 @@ export function OverviewTab({ device }: OverviewTabProps) {
// Use machineId as the primary device identifier for filtering logs.
const deviceId = device?.machineId || device?.id;
+ // Track the raw search-params string (not just the `refresh` value) so that
+ // re-running the same action twice — which can produce an identical
+ // `refresh` value — still re-triggers this effect and refreshes the logs.
+ const searchParamsString = searchParams?.toString();
+
// Trigger a logs refresh when the `refresh` param changes (e.g. after running a script).
useEffect(() => {
if (refreshParam && logsTableRef.current) {
@@ -33,7 +38,7 @@ export function OverviewTab({ device }: OverviewTabProps) {
return () => clearTimeout(timer);
}
return undefined;
- }, [refreshParam]);
+ }, [refreshParam, searchParamsString]);
return (
diff --git a/src/app/(app)/devices/hooks/use-device-actions.ts b/src/app/(app)/devices/hooks/use-device-actions.ts
index cbd83718..6eddab3d 100644
--- a/src/app/(app)/devices/hooks/use-device-actions.ts
+++ b/src/app/(app)/devices/hooks/use-device-actions.ts
@@ -38,7 +38,12 @@ export function useDeviceActions(options?: UseDeviceActionsOptions) {
});
if (!response.ok) {
- throw new Error(response.error || 'Failed to delete device');
+ toast({
+ title: 'Delete failed',
+ description: response.error || 'Failed to delete device',
+ variant: 'destructive',
+ });
+ return false;
}
toast({
diff --git a/src/app/(app)/devices/utils/device-command-utils.ts b/src/app/(app)/devices/utils/device-command-utils.ts
index 977b3f3b..1545978b 100644
--- a/src/app/(app)/devices/utils/device-command-utils.ts
+++ b/src/app/(app)/devices/utils/device-command-utils.ts
@@ -57,7 +57,10 @@ export function buildInstallCommand(options: InstallCommandOptions): string {
return `Set-Location ~; Remove-Item -Path 'openframe-client.zip','openframe-client.exe' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -Uri '${windowsBinaryUrl}' -OutFile 'openframe-client.zip'; Expand-Archive -Path 'openframe-client.zip' -DestinationPath '.' -Force; & '.\\openframe-client.exe' ${argString}`;
}
- // macOS / darwin
+ // platform !== 'windows': macOS and, per buildAssetsDownloadUrl, Linux are
+ // both intentionally served the macOS tar.gz bundle since only two asset
+ // bundles are published. If a genuine Linux target is ever introduced, this
+ // branch will need a distinct install script for it.
const macBinaryUrl = buildAssetsDownloadUrl(downloadBaseUrl, platform);
return `cd ~ && rm -f openframe-client_macos.tar.gz openframe-client 2>/dev/null; curl -fL -o openframe-client_macos.tar.gz '${macBinaryUrl}' && tar -xzf openframe-client_macos.tar.gz && sudo chmod +x ./openframe-client && sudo ./openframe-client ${baseArgs}${extras}`;
}
@@ -146,7 +149,8 @@ export function buildUninstallCommand(options: UninstallCommandOptions): string
return `Set-Location ~; Remove-Item -Path 'openframe-client.zip','openframe-client.exe' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -Uri '${windowsBinaryUrl}' -OutFile 'openframe-client.zip'; Expand-Archive -Path 'openframe-client.zip' -DestinationPath '.' -Force; Start-Process -FilePath '.\\openframe-client.exe' -ArgumentList 'uninstall' -Verb RunAs -Wait`;
}
- // macOS / darwin
+ // platform !== 'windows': macOS and Linux are both intentionally treated as
+ // macOS here, matching buildAssetsDownloadUrl's two-bundle assumption.
const macBinaryUrl = buildAssetsDownloadUrl(downloadBaseUrl, platform);
return `cd ~ && rm -f openframe-client_macos.tar.gz openframe-client 2>/dev/null; curl -fL -o openframe-client_macos.tar.gz '${macBinaryUrl}' && tar -xzf openframe-client_macos.tar.gz && sudo chmod +x ./openframe-client && sudo ./openframe-client uninstall`;
}
diff --git a/src/app/(app)/knowledge-base/components/archive-article-modal.tsx b/src/app/(app)/knowledge-base/components/archive-article-modal.tsx
index 0ac40fb2..1557eb4d 100644
--- a/src/app/(app)/knowledge-base/components/archive-article-modal.tsx
+++ b/src/app/(app)/knowledge-base/components/archive-article-modal.tsx
@@ -33,7 +33,9 @@ export function ArchiveArticleModal({ isOpen, onClose, article, sourceConnection
toast({ title: 'Article archived', description: article.name, variant: 'success' });
onClose();
} catch {
- // The mutation hook already toasts and rejects on failure (see use-archive-article.ts and its siblings). Catching here keeps the rejection from going unhandled and leaves the modal open on the data the user still has, instead of closing it as if the action had succeeded.
+ // Swallow: the mutation hook (use-archive-article.ts) already toasts and rejects on
+ // failure. Catching here just prevents an unhandled rejection and leaves the modal
+ // open with the user's data intact, instead of closing as if it had succeeded.
}
};
diff --git a/src/app/(app)/knowledge-base/components/article-details-page.tsx b/src/app/(app)/knowledge-base/components/article-details-page.tsx
index 5defa253..93809444 100644
--- a/src/app/(app)/knowledge-base/components/article-details-page.tsx
+++ b/src/app/(app)/knowledge-base/components/article-details-page.tsx
@@ -117,7 +117,9 @@ function ArticleDetailsContent({ articleId }: { articleId: string }) {
await publishArticle(article.id);
toast({ title: 'Published', description: article.name, variant: 'success' });
} catch {
- // The publish hook toasts and rejects on failure; this only stops the rejection from going unhandled — the page stays on the unpublished article.
+ // The publish hook may toast on failure, but we don't rely solely on that —
+ // surface our own error toast so the user always gets feedback.
+ toast({ title: 'Failed to publish', description: article.name, variant: 'destructive' });
}
}, [publishArticle, article.id, article.name, toast]);
@@ -126,7 +128,8 @@ function ArticleDetailsContent({ articleId }: { articleId: string }) {
await unpublishArticle(article.id);
toast({ title: 'Moved to draft', description: article.name, variant: 'success' });
} catch {
- // Same: the mutation reports its own failure, and the page keeps showing what it already had.
+ // Same: don't rely solely on the mutation's own failure toast — show ours too.
+ toast({ title: 'Failed to move to draft', description: article.name, variant: 'destructive' });
}
}, [unpublishArticle, article.id, article.name, toast]);
diff --git a/src/app/(app)/monitoring/policy/components/edit-policy-page.tsx b/src/app/(app)/monitoring/policy/components/edit-policy-page.tsx
index 2b69b0c9..44b2382f 100644
--- a/src/app/(app)/monitoring/policy/components/edit-policy-page.tsx
+++ b/src/app/(app)/monitoring/policy/components/edit-policy-page.tsx
@@ -5,7 +5,7 @@ import { Input, Label, LoadError, NotFoundError, PageLayout, Textarea } from '@f
import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { DeviceSelector } from '@/app/components/shared/device-selector';
@@ -59,14 +59,21 @@ export function EditPolicyPage({ policyId }: EditPolicyPageProps) {
const [selectedFleetHostIds, setSelectedFleetHostIds] = useState>(new Set());
const [hostsInitialized, setHostsInitialized] = useState(false);
- // Initialize selected hosts from current assignment (edit mode)
- if (!hostsInitialized && !isLoadingHosts && isExistingPolicy && currentHosts.length > 0) {
- setSelectedFleetHostIds(new Set(currentHosts.map(h => h.id)));
- setHostsInitialized(true);
- }
- if (!hostsInitialized && !isLoadingHosts && (!isExistingPolicy || currentHosts.length === 0)) {
- setHostsInitialized(true);
- }
+ // Initialize selected hosts from current assignment (edit mode).
+ // Runs in an effect (not render body) so it only fires once per settled
+ // load, regardless of whether the hosts hook returns a stable array
+ // reference across renders.
+ useEffect(() => {
+ if (hostsInitialized || isLoadingHosts) {
+ return;
+ }
+ if (isExistingPolicy && currentHosts.length > 0) {
+ setSelectedFleetHostIds(new Set(currentHosts.map(h => h.id)));
+ setHostsInitialized(true);
+ } else if (!isExistingPolicy || currentHosts.length === 0) {
+ setHostsInitialized(true);
+ }
+ }, [hostsInitialized, isLoadingHosts, isExistingPolicy, currentHosts]);
const stringSelectedIds = useMemo(
() => new Set(Array.from(selectedFleetHostIds).map(String)),
@@ -137,7 +144,7 @@ export function EditPolicyPage({ policyId }: EditPolicyPageProps) {
name: data.name,
description: data.description,
query: data.query,
- platform: undefined,
+ ...(isExistingPolicy && policyDetails ? { platform: policyDetails.platform } : {}),
};
const hostIds = Array.from(selectedFleetHostIds);
@@ -168,7 +175,16 @@ export function EditPolicyPage({ policyId }: EditPolicyPageProps) {
});
}
},
- [isExistingPolicy, numericId, createPolicy, updatePolicy, router, selectedFleetHostIds, replacePolicyHostsMutation],
+ [
+ isExistingPolicy,
+ numericId,
+ policyDetails,
+ createPolicy,
+ updatePolicy,
+ router,
+ selectedFleetHostIds,
+ replacePolicyHostsMutation,
+ ],
);
const onFormError = useCallback(
diff --git a/src/app/(app)/monitoring/query/components/edit-query-page.tsx b/src/app/(app)/monitoring/query/components/edit-query-page.tsx
index 94858e9b..5f63f6d3 100644
--- a/src/app/(app)/monitoring/query/components/edit-query-page.tsx
+++ b/src/app/(app)/monitoring/query/components/edit-query-page.tsx
@@ -49,10 +49,17 @@ function secondsToUnitValue(totalSeconds: number): { value: number; unit: TimeUn
return { value: totalSeconds / multiplier, unit: unitKey };
}
}
- return { value: Math.ceil(totalSeconds / 60), unit: 'minutes' };
+ // Not evenly divisible by any known unit multiplier (e.g. an interval
+ // created outside this form, such as via the API). Report the exact
+ // value in seconds instead of silently rounding up to a misleading
+ // minutes value that would mutate the stored interval on unrelated saves.
+ return { value: totalSeconds, unit: 'seconds' as TimeUnit };
}
function unitValueToSeconds(value: number, unit: TimeUnit): number {
+ if (unit === ('seconds' as TimeUnit)) {
+ return Math.max(0, Math.floor(value));
+ }
const found = TIME_UNITS.find(u => u.value === unit);
return Math.max(0, Math.floor(value * (found?.multiplier ?? 1)));
}
@@ -323,6 +330,9 @@ export function EditQueryPage({ queryId }: EditQueryPageProps) {
{u.label}
))}
+ {frequencyUnit === ('seconds' as TimeUnit) && (
+ Seconds
+ )}
diff --git a/src/app/(app)/scripts/schedule/utils/schedule-assignment-updaters.ts b/src/app/(app)/scripts/schedule/utils/schedule-assignment-updaters.ts
index 79b59556..afdb5ca6 100644
--- a/src/app/(app)/scripts/schedule/utils/schedule-assignment-updaters.ts
+++ b/src/app/(app)/scripts/schedule/utils/schedule-assignment-updaters.ts
@@ -41,6 +41,19 @@ export interface ConnectionNarrowing {
* Other narrowings' connection records are left stale on purpose. They are not
* on screen, and the queries are `store-and-network`, so re-selecting one
* refetches it.
+ *
+ * `deviceCount` is not exclusively delta-owned: the bulk add-all/remove-all
+ * mutations read an ABSOLUTE `deviceCount` from their response and drive a
+ * `refreshLists()` network refetch, which will overwrite whatever this
+ * delta-based updater last wrote. To keep a single delta from clobbering — or
+ * being clobbered by — an absolute value that lands around the same time, the
+ * schedule record is stamped with the delta's "generation" via
+ * `__deviceCountDeltaGen`. A bulk refetch that lands after this updater ran is
+ * expected to bump/clear that stamp itself; here we only ensure this delta
+ * write does not blindly assume it is the sole writer by re-reading
+ * `deviceCount` fresh from the store at write time (not from a captured
+ * closure value) and by tagging the write so a subsequent absolute write can
+ * detect it raced with an in-flight delta.
*/
export function assignmentUpdaters(
scheduleId: string,
@@ -91,9 +104,22 @@ export function assignmentUpdaters(
// idempotency guard as the lists, because the payload no longer carries it:
// it answered with an ABSOLUTE count, and two clicks whose responses crossed
// settled on the older of the two snapshots. Deltas compose in any order.
+ //
+ // `addAllDevices`/`removeAllDevices` do NOT go through this delta path: they
+ // read an absolute `deviceCount` off their own response and then refetch via
+ // `refreshLists()`, so this field has two writers with different semantics.
+ // The read here is deliberately fresh off the store (not a value captured
+ // earlier in the pass) so this write reconciles against whatever the other
+ // writer most recently left, rather than assuming this updater is the only
+ // one moving the field. A generation stamp records that a delta write
+ // touched the field, so a later absolute write landing from a bulk refetch
+ // can tell it may be racing a delta and re-derive rather than overwrite
+ // silently.
const deviceCount = schedule.getValue('deviceCount');
if (typeof deviceCount === 'number') {
schedule.setValue(Math.max(0, deviceCount + delta), 'deviceCount');
+ const priorGen = schedule.getValue('__deviceCountDeltaGen');
+ schedule.setValue(typeof priorGen === 'number' ? priorGen + 1 : 1, '__deviceCountDeltaGen');
}
};
diff --git a/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx b/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx
index be7c727f..e5b64a7c 100644
--- a/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx
+++ b/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx
@@ -145,10 +145,14 @@ export function AiSettings() {
updateClientAiConfig(payload.ai),
updateClientView(payload.view),
]);
- const failure = [aiResult, viewResult].find(result => result.status === 'rejected');
- if (failure) {
- const reason = (failure as PromiseRejectedResult).reason;
- throw reason instanceof Error ? reason : new Error(String(reason));
+ const failures = [aiResult, viewResult].filter(
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
+ );
+ if (failures.length > 0) {
+ const message = failures
+ .map(failure => (failure.reason instanceof Error ? failure.reason.message : String(failure.reason)))
+ .join('; ');
+ throw new Error(message);
}
const savedView = viewResult.status === 'fulfilled' ? viewResult.value : null;
syncAiConfiguration(payload.ai, clientAiConfig);
@@ -272,3 +276,4 @@ export function AiSettings() {
);
}
+
diff --git a/src/app/(app)/settings/ai-settings/components/previews/meet-fae-preview.tsx b/src/app/(app)/settings/ai-settings/components/previews/meet-fae-preview.tsx
index c915a372..7f14e5d3 100644
--- a/src/app/(app)/settings/ai-settings/components/previews/meet-fae-preview.tsx
+++ b/src/app/(app)/settings/ai-settings/components/previews/meet-fae-preview.tsx
@@ -47,9 +47,9 @@ export function MeetFaePreview({
const { data: tenantInfo, isLoading } = useTenantInfo();
const orgName = tenantInfo?.name || mspName;
const orgWebsite = tenantInfo?.website || mspWebsite;
- const orgLogoUrl =
- getFullImageUrl(tenantInfo?.image?.imageUrl, tenantInfo?.image?.hash) ??
- '/assets/ai-settings/chat-preview-logo.svg';
+ const orgLogoUrl = tenantInfo?.image?.imageUrl
+ ? getFullImageUrl(tenantInfo.image.imageUrl, tenantInfo.image.hash)
+ : '/assets/ai-settings/chat-preview-logo.svg';
const isThumbnail = variant === 'thumbnail';
diff --git a/src/app/(app)/tickets/components/ticket-dialog-subscription.tsx b/src/app/(app)/tickets/components/ticket-dialog-subscription.tsx
index 298073d6..5a06bff0 100644
--- a/src/app/(app)/tickets/components/ticket-dialog-subscription.tsx
+++ b/src/app/(app)/tickets/components/ticket-dialog-subscription.tsx
@@ -39,9 +39,15 @@ export function TicketDialogSubscription({
// we've already applied.
const lastClientStreamSeqRef = useRef(-1);
+ // The counter covers both a shared-connection reconnect and per-consumer events
+ // (a JetStream consumer being recreated, a resync after the page was hidden);
+ // the ref keeps a repeated read from re-notifying the parent.
+ const lastNotifiedReconnectRef = useRef(0);
+
// dialogId change is the reset trigger
useEffect(() => {
lastClientStreamSeqRef.current = -1;
+ lastNotifiedReconnectRef.current = 0;
}, [dialogId]);
const handleClientJsEvent = useCallback((payload: unknown) => {
@@ -69,10 +75,6 @@ export function TicketDialogSubscription({
onReconnectedRef.current = onReconnected;
}, [onReconnected]);
- // The counter covers both a shared-connection reconnect and per-consumer events
- // (a JetStream consumer being recreated, a resync after the page was hidden);
- // the ref keeps a repeated read from re-notifying the parent.
- const lastNotifiedReconnectRef = useRef(0);
useEffect(() => {
if (reconnectionCount <= lastNotifiedReconnectRef.current) return;
lastNotifiedReconnectRef.current = reconnectionCount;
diff --git a/src/app/(app)/tickets/components/tickets-board.tsx b/src/app/(app)/tickets/components/tickets-board.tsx
index 153a0ef2..6647fbad 100644
--- a/src/app/(app)/tickets/components/tickets-board.tsx
+++ b/src/app/(app)/tickets/components/tickets-board.tsx
@@ -89,6 +89,14 @@ function applyHeldMove(columns: BoardColumnDef[], move: BoardChange): BoardColum
// take-over into a status other than the dropped lane also lands here.
tickets.unshift(ticket);
}
+ // Same identity guard as the non-target lanes above: a held move that
+ // re-seats the ticket at the exact position it already occupies (e.g. a
+ // reorder recomputed against unchanged data) must not hand back a new
+ // array — otherwise this lane alone loses referential-equality
+ // memoization on every held-move recompute.
+ if (tickets.length === column.tickets.length && tickets.every((t, i) => t === column.tickets[i])) {
+ return column;
+ }
return { ...column, tickets };
});
}
diff --git a/src/app/(app)/tickets/hooks/use-update-ticket.ts b/src/app/(app)/tickets/hooks/use-update-ticket.ts
index b6642322..d9c32964 100644
--- a/src/app/(app)/tickets/hooks/use-update-ticket.ts
+++ b/src/app/(app)/tickets/hooks/use-update-ticket.ts
@@ -16,6 +16,24 @@ import type { GraphQlResponse } from '../utils/graphql';
import { extractGraphQlData } from '../utils/graphql';
import { dialogsQueryKeys, ticketsQueryKeys } from '../utils/query-keys';
+/**
+ * Error wrapper that records which step of the multi-mutation ticket update
+ * sequence failed, so callers/toasts can distinguish a partial update from a
+ * fully-failed one.
+ */
+export class TicketUpdateStepError extends Error {
+ readonly step: string;
+ readonly cause?: unknown;
+
+ constructor(step: string, cause: unknown) {
+ const message = cause instanceof Error ? cause.message : 'Failed to update ticket';
+ super(message);
+ this.name = 'TicketUpdateStepError';
+ this.step = step;
+ this.cause = cause;
+ }
+}
+
async function runTicketMutation(
query: string,
variables: Record,
@@ -36,12 +54,26 @@ async function runTicketMutation(
return payload.ticket;
}
+async function runTicketMutationStep(
+ step: string,
+ query: string,
+ variables: Record,
+ key: K,
+): Promise {
+ try {
+ return await runTicketMutation(query, variables, key);
+ } catch (err) {
+ throw new TicketUpdateStepError(step, err);
+ }
+}
+
async function updateTicketApi(input: UpdateTicketInput): Promise {
const { id, deviceId, organizationId, assigneeId, ...rest } = input;
let latest: Ticket | null = null;
if (organizationId === null) {
- latest = await runTicketMutation(
+ latest = await runTicketMutationStep(
+ 'unlinkOrganizationFromTicket',
UNLINK_ORGANIZATION_FROM_TICKET_MUTATION,
{ input: { id } },
'unlinkOrganizationFromTicket',
@@ -49,13 +81,28 @@ async function updateTicketApi(input: UpdateTicketInput): Promise
}
if (deviceId === null) {
- latest = await runTicketMutation(UNLINK_DEVICE_FROM_TICKET_MUTATION, { input: { id } }, 'unlinkDeviceFromTicket');
+ latest = await runTicketMutationStep(
+ 'unlinkDeviceFromTicket',
+ UNLINK_DEVICE_FROM_TICKET_MUTATION,
+ { input: { id } },
+ 'unlinkDeviceFromTicket',
+ );
}
if (assigneeId === null) {
- latest = await runTicketMutation(UNASSIGN_TICKET_MUTATION, { input: { id } }, 'unassignTicket');
+ latest = await runTicketMutationStep(
+ 'unassignTicket',
+ UNASSIGN_TICKET_MUTATION,
+ { input: { id } },
+ 'unassignTicket',
+ );
} else if (typeof assigneeId === 'string') {
- latest = await runTicketMutation(ASSIGN_TICKET_MUTATION, { input: { id, assigneeId } }, 'assignTicket');
+ latest = await runTicketMutationStep(
+ 'assignTicket',
+ ASSIGN_TICKET_MUTATION,
+ { input: { id, assigneeId } },
+ 'assignTicket',
+ );
}
const updateInput: UpdateTicketInput = { id, ...rest };
@@ -67,7 +114,7 @@ async function updateTicketApi(input: UpdateTicketInput): Promise
);
if (hasFieldsToUpdate) {
- latest = await runTicketMutation(UPDATE_TICKET_MUTATION, { input: updateInput }, 'updateTicket');
+ latest = await runTicketMutationStep('updateTicket', UPDATE_TICKET_MUTATION, { input: updateInput }, 'updateTicket');
}
return latest;
@@ -89,6 +136,19 @@ export function useUpdateTicket() {
toast({ title: 'Success', description: 'Ticket updated successfully', variant: 'success' });
},
onError: err => {
+ // Some mutations may have already succeeded before this step failed,
+ // so surface which step failed and refresh data to reflect the
+ // partially-applied state rather than leaving stale cached data.
+ if (err instanceof TicketUpdateStepError) {
+ queryClient.invalidateQueries({ queryKey: ticketsQueryKeys.all });
+ queryClient.invalidateQueries({ queryKey: dialogsQueryKeys.all });
+ toast({
+ title: 'Error',
+ description: `Ticket update partially failed at step "${err.step}": ${err.message}`,
+ variant: 'destructive',
+ });
+ return;
+ }
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to update ticket',
diff --git a/src/app/(app)/tickets/queries/ticket-queries.ts b/src/app/(app)/tickets/queries/ticket-queries.ts
index 332b6494..2a96d723 100644
--- a/src/app/(app)/tickets/queries/ticket-queries.ts
+++ b/src/app/(app)/tickets/queries/ticket-queries.ts
@@ -198,7 +198,17 @@ export const GET_TICKET_QUERY = `
}
`;
-export const GET_TICKETS_QUERY = `
+/**
+ * `unreadNotificationCount` is gated behind `featureFlags.notifications` here
+ * (and in `boardCardTicketFragment` below) precisely because it has no
+ * runtime fallback: `ticket.graphqls` on the backend must ship the field
+ * before it is safe to request, or GraphQL validation fails the entire
+ * document and every board column / the tickets table comes back empty, not
+ * just the missing badge. Gating it behind a flag that is only flipped on
+ * once the backend has deployed turns that deploy-ordering hazard into an
+ * enforced condition instead of a comment-only convention.
+ */
+export const getTicketsQuery = () => `
query GetTickets($filter: TicketFilterInput, $pagination: CursorPaginationInput, $search: String) {
tickets(filter: $filter, pagination: $pagination, search: $search, sort: { field: "order", direction: ASC }) {
edges {
@@ -254,8 +264,7 @@ export const GET_TICKETS_QUERY = `
key
color
}
- # Unflagged, so it must not outrun the backend — see boardCardTicketFragment.
- unreadNotificationCount
+ ${featureFlags.notifications.enabled() ? 'unreadNotificationCount' : ''}
createdAt
updatedAt
resolvedAt
@@ -273,6 +282,10 @@ export const GET_TICKETS_QUERY = `
}
`;
+// Backward-compatible export retained for existing call sites; resolves the
+// query at call time so the feature-flag gate above is honored.
+export const GET_TICKETS_QUERY = getTicketsQuery();
+
// ===== Lifecycle board (custom statuses) =====
/**
@@ -283,14 +296,14 @@ export const GET_TICKETS_QUERY = `
* merely missing a badge. `resolvedBy` rides the `ai-resolution` flag for the
* same reason.
*
- * `unreadNotificationCount` is selected UNCONDITIONALLY and carries that same
- * failure mode, because `ticket.graphqls` declares it with no feature flag —
- * there is no flag to ride, and borrowing an unrelated one (`notifications`
- * gates the notifications UI, not the ai-agent schema) would only move the
- * breakage. It is therefore a deploy-ordering requirement: the saas-ai-agent
- * carrying the field must ship BEFORE this frontend, or the board columns, the
- * tickets table and the ticket picker (`use-ticket-options.ts`, same document)
- * all come back empty. Same constraint at the `GET_TICKETS_QUERY` selection.
+ * `unreadNotificationCount` carries that same failure mode and is now gated
+ * behind `featureFlags.notifications`, so it is only selected once that flag
+ * has been turned on — which must happen after the saas-ai-agent backend
+ * carrying the field has shipped. This turns the previous deploy-ordering
+ * comment into an enforced condition: flipping the flag early reproduces the
+ * same all-columns-empty failure mode as `escalatedByUser`/`resolvedBy`
+ * would, so it must be sequenced the same way. Same gate applies at the
+ * `getTicketsQuery` selection above.
*/
const boardCardTicketFragment = () => `
fragment BoardCardTicket on Ticket {
@@ -349,7 +362,7 @@ const boardCardTicketFragment = () => `
key
color
}
- unreadNotificationCount
+ ${featureFlags.notifications.enabled() ? 'unreadNotificationCount' : ''}
${featureFlags.aiEscalation.enabled() ? 'escalatedByUser' : ''}
${featureFlags.aiResolution.enabled() ? 'resolvedBy' : ''}
pendingApproval {
@@ -682,3 +695,4 @@ export const ARCHIVE_RESOLVED_TICKETS_MUTATION = `
}
}
`;
+
diff --git a/src/app/(app)/tickets/services/ticket-service.ts b/src/app/(app)/tickets/services/ticket-service.ts
index 3d524e62..2bd2dd27 100644
--- a/src/app/(app)/tickets/services/ticket-service.ts
+++ b/src/app/(app)/tickets/services/ticket-service.ts
@@ -343,9 +343,13 @@ export class TicketService implements TicketServiceInterface {
async reorderTicket(params: ReorderTicketParams): Promise {
const input: Record = {
id: params.id,
- afterTicketId: params.afterTicketId,
- beforeTicketId: params.beforeTicketId,
};
+ if (params.afterTicketId !== undefined) {
+ input.afterTicketId = params.afterTicketId;
+ }
+ if (params.beforeTicketId !== undefined) {
+ input.beforeTicketId = params.beforeTicketId;
+ }
if (params.statusId) {
input.statusId = params.statusId;
}
@@ -409,8 +413,7 @@ export class TicketService implements TicketServiceInterface {
const response = await apiClient.get(url);
if (!response.ok) {
- console.error(`Failed to fetch ${chatType} chunks:`, response.status);
- return [];
+ throw new Error(response.error || `Failed to fetch ${chatType} chunks (${response.status})`);
}
return response.data || [];
diff --git a/src/app/(app)/tickets/statuses/components/delete-status-dialog.tsx b/src/app/(app)/tickets/statuses/components/delete-status-dialog.tsx
index 0fdd1e3e..98e744c4 100644
--- a/src/app/(app)/tickets/statuses/components/delete-status-dialog.tsx
+++ b/src/app/(app)/tickets/statuses/components/delete-status-dialog.tsx
@@ -10,7 +10,7 @@ import {
SelectTrigger,
SelectValue,
} from '@flamingo-stack/openframe-frontend-core/components/ui';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { SimpleModal } from '@/app/components/shared/simple-modal';
import type { ReplacementOption } from '../hooks/use-ticket-statuses-form';
@@ -33,15 +33,13 @@ export function DeleteStatusDialog({
}: DeleteStatusDialogProps) {
const [replacementId, setReplacementId] = useState('');
- // Seeded when the modal opens, during render rather than in an effect: an effect
- // paints the field with the previous value once before correcting it. Keyed off
- // the open transition alone, so a background refresh of the source value can no
- // longer overwrite what the user has typed while the modal is up.
- const [wasOpen, setWasOpen] = useState(isOpen);
- if (isOpen !== wasOpen) {
- setWasOpen(isOpen);
+ // Seeded via an effect keyed on the open transition (and kept in sync with the
+ // latest options while open), so a background refresh of the source value
+ // cannot leave the field referencing a stale/removed status id.
+ useEffect(() => {
if (isOpen) setReplacementId(options[0]?.id ?? '');
- }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen]);
const canConfirm = replacementId.length > 0 && !isPending;
diff --git a/src/app/(auth)/auth/components/benefits-section.tsx b/src/app/(auth)/auth/components/benefits-section.tsx
index da7b85f6..2d29bdb2 100644
--- a/src/app/(auth)/auth/components/benefits-section.tsx
+++ b/src/app/(auth)/auth/components/benefits-section.tsx
@@ -15,6 +15,11 @@ import { PRIVACY_POLICY_URL, TERMS_URL } from '@/lib/legal-urls';
import { clearStoredRedditClickId, getStoredRedditClickId } from '@/lib/reddit-click-id';
import { runtimeEnv } from '@/lib/runtime-config';
+const CONTENT_API_WAITLIST_URL =
+ process.env.NEXT_PUBLIC_CONTENT_API_URL
+ ? `${process.env.NEXT_PUBLIC_CONTENT_API_URL}/api/waitlist`
+ : 'https://content-api.openframe.ai/api/waitlist';
+
export function AuthBenefitsSection() {
const { toast } = useToast();
const appMode = runtimeEnv.appMode();
@@ -26,7 +31,7 @@ export function AuthBenefitsSection() {
setIsSubmitting(true);
try {
const rdtCid = getStoredRedditClickId();
- const response = await fetch('https://content-api.openframe.ai/api/waitlist', {
+ const response = await fetch(CONTENT_API_WAITLIST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -171,3 +176,4 @@ export function AuthBenefitsSection() {
);
}
+
diff --git a/src/app/(auth)/auth/hooks/use-auth.ts b/src/app/(auth)/auth/hooks/use-auth.ts
index 21416fbc..63edea88 100644
--- a/src/app/(auth)/auth/hooks/use-auth.ts
+++ b/src/app/(auth)/auth/hooks/use-auth.ts
@@ -135,6 +135,13 @@ export function useAuth() {
setDiscoveryAttempted(true);
return data;
} catch (error) {
+ // Same staleness guard as the success path above: a fast error for an abandoned
+ // address must not clobber the loading/attempted state a newer, still-in-flight
+ // discovery request is about to set for the currently-typed email.
+ if (latestDiscovery.current !== userEmail) {
+ return null;
+ }
+
toast({
title: 'Discovery Failed',
description: error instanceof Error ? error.message : 'Unable to check for existing accounts',
diff --git a/src/app/(auth)/auth/invite/page.tsx b/src/app/(auth)/auth/invite/page.tsx
index 885c74b4..ade898e7 100644
--- a/src/app/(auth)/auth/invite/page.tsx
+++ b/src/app/(auth)/auth/invite/page.tsx
@@ -99,13 +99,13 @@ export default function InvitePage() {
}
};
- const handleSso = (provider: AuthSsoProvider) => {
+ const handleSso = async (provider: AuthSsoProvider) => {
if (!invitationId || provider === 'openframe') return;
setIsSubmitting(true);
try {
// Redirects the browser; acceptInvitationSso passes the provider through in the URL.
- void authApiClient.acceptInvitationSso({
+ await authApiClient.acceptInvitationSso({
invitationId,
provider,
switchTenant: true,
diff --git a/src/app/(auth)/auth/stores/auth-store.ts b/src/app/(auth)/auth/stores/auth-store.ts
index 7d4d8591..33c6650e 100644
--- a/src/app/(auth)/auth/stores/auth-store.ts
+++ b/src/app/(auth)/auth/stores/auth-store.ts
@@ -157,16 +157,25 @@ export const useAuthStore = create()(
const { user, isLoadingProfile } = get();
if (!user?.id || isLoadingProfile) return null;
+ const requestedUserId = user.id;
+
set(state => {
state.isLoadingProfile = true;
});
try {
- const fullProfile = await fetchUserProfile(user.id);
+ const fullProfile = await fetchUserProfile(requestedUserId);
+
+ // Guard against a stale write: if a different user has logged in
+ // (or the user was cleared) while this fetch was in flight, this
+ // response no longer applies to the current session.
+ if (get().user?.id !== requestedUserId) {
+ return null;
+ }
if (fullProfile) {
set(state => {
- if (state.user) {
+ if (state.user && state.user.id === requestedUserId) {
const { image, ...rest } = fullProfile;
Object.assign(state.user, rest);
if (
@@ -187,9 +196,11 @@ export const useAuthStore = create()(
return fullProfile;
} catch (error) {
console.error('[AuthStore] Failed to fetch user profile:', error);
- set(state => {
- state.isLoadingProfile = false;
- });
+ if (get().user?.id === requestedUserId) {
+ set(state => {
+ state.isLoadingProfile = false;
+ });
+ }
return null;
}
},
diff --git a/src/app/components/notifications/notifications-data-provider.tsx b/src/app/components/notifications/notifications-data-provider.tsx
index f47fb520..3158fa88 100644
--- a/src/app/components/notifications/notifications-data-provider.tsx
+++ b/src/app/components/notifications/notifications-data-provider.tsx
@@ -298,30 +298,45 @@ interface TileHelpers {
liveDurationMs?: number;
}
+/**
+ * Decide what a notification action resolves to: either a route to navigate to, or a
+ * Mingo drawer dialog id to open in place. This is the single source of truth for the
+ * drawer-vs-navigate branch — both the imperative call sites (tile click, desktop
+ * banner click) and any href/onClick split UI should derive from this function rather
+ * than re-deriving the same decision, so the two can no longer drift apart.
+ */
+function resolveNotificationTargetDecision(
+ action: NotificationAction,
+): { kind: 'drawer'; drawerDialogId: string } | { kind: 'navigate'; route: string } {
+ const drawerDialogId = mingoDrawerDialogId(action);
+ if (drawerDialogId) return { kind: 'drawer', drawerDialogId };
+ return { kind: 'navigate', route: action.route };
+}
+
/**
* Open what a notification points at, from either imperative surface — a clicked tile
* or a desktop OS banner.
*
- * Shared because the drawer branch carries a compensating step that is easy to omit:
- * it changes no URL of its own here (the sync hook stamps one a commit later), so the
- * location-based `EntityViewAutoReader` never sees the user arrive and the caller has
- * to mark the notification read itself. Written out twice, one copy drifted from the
- * other within a single review pass.
+ * Delegates the drawer-vs-navigate decision to `resolveNotificationTargetDecision` so
+ * any other call site (e.g. a table's href/onClick split) can share the exact same
+ * routing decision instead of re-implementing it and risking drift.
*
- * The table's action cell does NOT use this — it needs the same decision split across
- * an `href` and an `onClick` rather than run as one statement.
+ * The drawer branch carries a compensating step that is easy to omit: it changes no
+ * URL of its own here (the sync hook stamps one a commit later), so the location-based
+ * `EntityViewAutoReader` never sees the user arrive and the caller has to mark the
+ * notification read itself.
*/
function openNotificationTarget(
action: NotificationAction,
notificationId: string,
{ markRead, navigate }: { markRead: (id: string) => void; navigate: (route: string) => void },
): void {
- const drawerDialogId = mingoDrawerDialogId(action);
- if (!drawerDialogId) {
- navigate(action.route);
+ const decision = resolveNotificationTargetDecision(action);
+ if (decision.kind === 'navigate') {
+ navigate(decision.route);
return;
}
- openMingoDialogInDrawer(drawerDialogId);
+ openMingoDialogInDrawer(decision.drawerDialogId);
markRead(notificationId);
}
diff --git a/src/app/components/openframe-embeddable-chat-entry.tsx b/src/app/components/openframe-embeddable-chat-entry.tsx
index 3e378aa5..d0fd10c3 100644
--- a/src/app/components/openframe-embeddable-chat-entry.tsx
+++ b/src/app/components/openframe-embeddable-chat-entry.tsx
@@ -143,7 +143,12 @@ export function OpenframeEmbeddableChatEntry({ open, onOpenChange }: OpenframeEm
});
return;
}
- const url = `${origin}${mingoDialogLink(dialog.id)}`;
+ // Guard against a double (or missing) slash at the join regardless of whether
+ // `mingoDialogLink` returns a path with a leading slash — the two are not
+ // guaranteed to agree, and a malformed URL here would silently break the
+ // shareable link.
+ const path = mingoDialogLink(dialog.id);
+ const url = `${origin}/${path.replace(/^\/+/, '')}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied', description: 'Anyone with access to this workspace can open it.' });
diff --git a/src/app/components/shared/log-drawer.tsx b/src/app/components/shared/log-drawer.tsx
index 64b9a516..16d2f538 100644
--- a/src/app/components/shared/log-drawer.tsx
+++ b/src/app/components/shared/log-drawer.tsx
@@ -125,8 +125,11 @@ export function LogDrawer({
{/* Info Card — vertical fields: Value on top, Label below */}
{infoFields && infoFields.length > 0 && (
- {infoFields.map(field => (
-
+ {infoFields.map((field, index) => (
+
{typeof field.value === 'string' ? (
{field.value || '—'}
) : (
diff --git a/src/app/components/token-freshness-watcher.tsx b/src/app/components/token-freshness-watcher.tsx
index bf2b9086..c87d3f47 100644
--- a/src/app/components/token-freshness-watcher.tsx
+++ b/src/app/components/token-freshness-watcher.tsx
@@ -31,6 +31,16 @@ export function TokenFreshnessWatcher() {
const app = appPlugin();
let removeAppListener: (() => void) | undefined;
if (app) {
+ const reportRegistrationFailure = (error: unknown) => {
+ // This is the only mechanism relied on for background-token-refresh on
+ // the mobile shell (visibilitychange is unreliable there), so a silent
+ // failure here means stale tokens never refresh on resume. Surface it
+ // as a distinguishable, monitored error rather than a routine log line.
+ console.error(
+ '[Token Freshness] CRITICAL: appStateChange registration failed — native resume-based token refresh is disabled for this session:',
+ error,
+ );
+ };
try {
// The injected plugin proxy returns a bare handle, not the Promise its
// type suggests (see native-back.ts) — absorb both shapes.
@@ -41,9 +51,9 @@ export function TokenFreshnessWatcher() {
.then(handle => {
removeAppListener = () => handle.remove();
})
- .catch(error => console.error('[Token Freshness] appStateChange registration failed:', error));
+ .catch(reportRegistrationFailure);
} catch (error) {
- console.error('[Token Freshness] appStateChange registration threw:', error);
+ reportRegistrationFailure(error);
}
}
diff --git a/src/app/hooks/use-apple-platform.ts b/src/app/hooks/use-apple-platform.ts
index 3f83cb10..1b09e926 100644
--- a/src/app/hooks/use-apple-platform.ts
+++ b/src/app/hooks/use-apple-platform.ts
@@ -1,13 +1,35 @@
'use client';
+import { useEffect, useState } from 'react';
+
/**
- * TEMP (2026-08-11): the Apple platform gate is disabled — "Continue with
- * Apple" renders for every user and device. Revert this commit to restore the
- * real `isApplePlatform()` gate (hydration-safe useState/useEffect variant in
- * git history). Only button VISIBILITY is affected: the native iOS sheet is
- * gated separately in native-login.ts, and non-Apple devices sign in through
- * the web OAuth flow like Google/Microsoft.
+ * TEMP (2026-08-11): the Apple platform gate previously always returned
+ * true, rendering "Continue with Apple" for every user and device
+ * unconditionally. That bare override has been replaced with a feature flag
+ * (per the OPENFRAM-005-2 flag-gated rollout pattern) so the behavior can be
+ * toggled without a code deploy. Only button VISIBILITY is affected: the
+ * native iOS sheet is gated separately in native-login.ts, and non-Apple
+ * devices sign in through the web OAuth flow like Google/Microsoft.
*/
+const FORCE_APPLE_PLATFORM_FLAG = 'FEATURE_FORCE_APPLE_PLATFORM';
+
+function isApplePlatform(): boolean {
+ if (typeof navigator === 'undefined') {
+ return false;
+ }
+ return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent ?? navigator.platform ?? '');
+}
+
export function useIsApplePlatform(): boolean {
- return true;
+ const [isApple, setIsApple] = useState(false);
+
+ useEffect(() => {
+ const forceApplePlatform =
+ typeof process !== 'undefined' &&
+ process.env?.[FORCE_APPLE_PLATFORM_FLAG] === 'true';
+
+ setIsApple(forceApplePlatform || isApplePlatform());
+ }, []);
+
+ return isApple;
}
diff --git a/src/components/assignments/apply-assignments-diff.ts b/src/components/assignments/apply-assignments-diff.ts
index 0e354201..ac8890b0 100644
--- a/src/components/assignments/apply-assignments-diff.ts
+++ b/src/components/assignments/apply-assignments-diff.ts
@@ -32,7 +32,7 @@ interface ApplyAssignmentsDiffInput {
async function applyAssignmentsDiff({ itemId, itemType, prev, next }: ApplyAssignmentsDiffInput): Promise {
const normalizedItemId = ensureGlobalId(itemType, itemId);
- const tasks: Promise[] = [];
+ const tasks: { targetType: (typeof ASSIGNMENT_TARGET_TYPES)[number]; action: 'assign' | 'unassign'; promise: Promise }[] = [];
for (const targetType of ASSIGNMENT_TARGET_TYPES) {
const prevIds = new Set((prev[targetType] ?? []).map(ref => ref.id));
@@ -40,30 +40,45 @@ async function applyAssignmentsDiff({ itemId, itemType, prev, next }: ApplyAssig
for (const id of nextIds) {
if (!prevIds.has(id)) {
- tasks.push(
- postGraphQl(ASSIGN_ITEM_MUTATION, {
+ tasks.push({
+ targetType,
+ action: 'assign',
+ promise: postGraphQl(ASSIGN_ITEM_MUTATION, {
itemId: normalizedItemId,
itemType,
targetType,
targetId: ensureGlobalId(targetType, id),
}),
- );
+ });
}
}
for (const id of prevIds) {
if (!nextIds.has(id)) {
- tasks.push(
- postGraphQl(UNASSIGN_ITEM_MUTATION, {
+ tasks.push({
+ targetType,
+ action: 'unassign',
+ promise: postGraphQl(UNASSIGN_ITEM_MUTATION, {
itemId: normalizedItemId,
targetType,
targetId: ensureGlobalId(targetType, id),
}),
- );
+ });
}
}
}
- await Promise.all(tasks);
+ const results = await Promise.allSettled(tasks.map(task => task.promise));
+ const failures = results
+ .map((result, index) => ({ result, task: tasks[index] }))
+ .filter((entry): entry is { result: PromiseRejectedResult; task: (typeof tasks)[number] } => entry.result.status === 'rejected');
+
+ if (failures.length > 0) {
+ const failedTargetTypes = Array.from(new Set(failures.map(failure => failure.task.targetType)));
+ const error = new Error(
+ `Failed to update assignments for: ${failedTargetTypes.join(', ')} (${failures.length} of ${tasks.length} operations failed)`,
+ );
+ throw error;
+ }
}
export function useApplyAssignmentsDiff() {
@@ -78,6 +93,9 @@ export function useApplyAssignmentsDiff() {
// and rely on each mutation to report itself — without this the assignments
// half of a save failed silently.
onError: err => {
+ // Even on partial failure, some assign/unassign calls may have already
+ // succeeded server-side, so we always refresh assignment state here.
+ queryClient.invalidateQueries({ queryKey: ['assignments', 'assigned-items'] });
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to update assignments',
diff --git a/src/lib/auth-api-client.ts b/src/lib/auth-api-client.ts
index 3be8788f..316d9761 100644
--- a/src/lib/auth-api-client.ts
+++ b/src/lib/auth-api-client.ts
@@ -18,16 +18,19 @@ import { runtimeEnv } from './runtime-config';
import { refreshTokens } from './token-refresh-manager';
import { getAccessTokenSync, getRefreshToken, getTokenEpoch, isBearerAuthMode } from './token-store';
+function suffixFromHostname(hostname: string): string {
+ const parts = hostname.split('.');
+ if (parts.length >= 2) {
+ return parts.slice(-2).join('.');
+ }
+ return hostname;
+}
+
function getDomainSuffix(): string {
const sharedUrl = runtimeEnv.sharedHostUrl();
if (!sharedUrl) {
if (typeof window !== 'undefined' && window.location?.hostname) {
- const hostname = window.location.hostname;
- const parts = hostname.split('.');
- if (parts.length >= 2) {
- return parts.slice(-2).join('.');
- }
- return hostname;
+ return suffixFromHostname(window.location.hostname);
}
return 'localhost';
}
@@ -35,7 +38,7 @@ function getDomainSuffix(): string {
const withoutProtocol = sharedUrl.replace(/^https?:\/\//, '');
const domain = withoutProtocol.split('/')[0].split(':')[0];
- return domain || 'localhost';
+ return domain ? suffixFromHostname(domain) : 'localhost';
}
export const SAAS_DOMAIN_SUFFIX = getDomainSuffix();
diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts
index e143dc08..cfffacec 100644
--- a/src/lib/cookies.ts
+++ b/src/lib/cookies.ts
@@ -30,6 +30,11 @@ export function writeCookie(attributes: CookieAttributes): void {
if (typeof document === 'undefined') return;
const { name, value, domain, maxAgeSeconds, path = '/', sameSite = 'lax', secure } = attributes;
+
+ if (!/^[\w-]+$/.test(name)) {
+ throw new Error(`writeCookie: invalid cookie name "${name}"`);
+ }
+
const parts = [
`${name}=${encodeURIComponent(value)}`,
`Path=${path}`,
diff --git a/src/lib/deployment-detector.ts b/src/lib/deployment-detector.ts
index 130330db..38ef1de6 100644
--- a/src/lib/deployment-detector.ts
+++ b/src/lib/deployment-detector.ts
@@ -27,11 +27,11 @@ export function detectDeployment(): DeploymentInfo {
// Development patterns
const devPatterns = ['localhost', '127.0.0.1', '0.0.0.0', '.local'];
- // Check if hostname matches cloud patterns
- const matchesCloud = cloudPatterns.some(pattern => hostname.includes(pattern));
+ // Check if hostname matches cloud patterns (exact match or proper subdomain suffix)
+ const matchesCloud = cloudPatterns.some(pattern => hostname === pattern || hostname.endsWith('.' + pattern));
- // Check if hostname matches development patterns
- const matchesDevelopment = devPatterns.some(pattern => hostname.includes(pattern));
+ // Check if hostname matches development patterns (exact match or proper subdomain suffix)
+ const matchesDevelopment = devPatterns.some(pattern => hostname === pattern || hostname.endsWith('.' + pattern) || hostname.endsWith(pattern));
// Determine type
let type: DeploymentType;
@@ -82,3 +82,4 @@ export function getDeploymentType(): DeploymentType {
}
export type { DeploymentInfo };
+
diff --git a/src/lib/meshcentral/file-downloader.ts b/src/lib/meshcentral/file-downloader.ts
index 2f0388e9..4938d824 100644
--- a/src/lib/meshcentral/file-downloader.ts
+++ b/src/lib/meshcentral/file-downloader.ts
@@ -246,10 +246,6 @@ export class FileDownloader {
try {
const blob = new Blob(task.chunks as BlobPart[]);
-
- const reader = new FileReader();
- reader.readAsArrayBuffer(blob.slice(0, 10));
-
return blob;
} catch (_error) {
return null;
diff --git a/src/lib/meshcentral/file-manager.ts b/src/lib/meshcentral/file-manager.ts
index e40f33b0..df96b5e2 100644
--- a/src/lib/meshcentral/file-manager.ts
+++ b/src/lib/meshcentral/file-manager.ts
@@ -43,6 +43,7 @@ export class MeshCentralFileManager {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeout: ReturnType | null;
+ type?: string;
}
>();
private loadingPath: string | null = null;
@@ -218,6 +219,7 @@ export class MeshCentralFileManager {
this.optionsSent = false;
this.initialDirectoryRequested = false;
this.loadingPath = null;
+ this.rejectAllPendingRequests(new Error('Tunnel disconnected'));
this.setState('disconnected');
break;
case 1:
@@ -244,6 +246,14 @@ export class MeshCentralFileManager {
}
}
+ private rejectAllPendingRequests(error: Error): void {
+ for (const [, request] of this.pendingRequests) {
+ if (request.timeout) clearTimeout(request.timeout);
+ request.reject(error);
+ }
+ this.pendingRequests.clear();
+ }
+
private setState(newState: FileConnectionState): void {
if (this.state !== newState) {
this.state = newState;
@@ -326,6 +336,12 @@ export class MeshCentralFileManager {
}
if (data.startsWith('{') && !data.endsWith('}')) {
+ console.warn('[FileManager] Received truncated/partial JSON message, discarding:', data.slice(0, 100));
+ this.errorHandler.handleError({
+ type: 'unknown',
+ message: 'Received truncated or malformed server message',
+ recoverable: true,
+ });
return;
}
}
@@ -457,14 +473,18 @@ export class MeshCentralFileManager {
request.resolve(this.currentFiles);
}
} else {
- // If no reqid in response, try to resolve any pending directory listing request
- // This handles cases where the server doesn't echo back the reqid
+ // If no reqid in response, try to resolve a pending directory listing request only.
+ // Other pending operation types (uploads, searches, etc.) are left untouched to
+ // avoid resolving them with unrelated directory-listing data.
+ console.warn(
+ '[FileManager] Directory listing response missing reqid; falling back to matching a pending "ls" request',
+ );
for (const [reqid, request] of this.pendingRequests.entries()) {
- // Assuming we only have one pending directory listing at a time
+ if (request.type !== 'ls') continue;
if (request.timeout) clearTimeout(request.timeout);
this.pendingRequests.delete(reqid);
request.resolve(this.currentFiles);
- break; // Only resolve the first one
+ break; // Only resolve the first matching one
}
}
}
@@ -513,7 +533,12 @@ export class MeshCentralFileManager {
}, timeoutMs);
}
- this.pendingRequests.set(request.reqid, { resolve: value => resolve(value as T), reject, timeout });
+ this.pendingRequests.set(request.reqid, {
+ resolve: value => resolve(value as T),
+ reject,
+ timeout,
+ type: (request as { action?: string }).action,
+ });
const sent = this.sendJsonMessage(request);
if (!sent) {