@cytario/web 2.1.7 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/app/.server/auth/README.md +0 -1
  3. package/app/.server/auth/authMiddleware.ts +6 -28
  4. package/app/.server/auth/getSessionCredentials.ts +11 -1
  5. package/app/.server/auth/sessionPolicy.ts +69 -0
  6. package/app/.server/corsPreflight.ts +136 -0
  7. package/app/.server/csp.ts +34 -0
  8. package/app/components/.client/ImageViewer/README.md +2 -2
  9. package/app/components/DirectoryView/DirectoryViewGrid.tsx +2 -11
  10. package/app/components/DirectoryView/DirectoryViewTree.tsx +18 -26
  11. package/app/components/DirectoryView/buildDirectoryTree.ts +89 -42
  12. package/app/components/DirectoryView/filterNodes.ts +4 -19
  13. package/app/components/DirectoryView/useLazyTreeNodes.ts +120 -0
  14. package/app/components/GlobalSearch/GlobalSearch.tsx +8 -3
  15. package/app/components/GlobalSearch/Suggestions.tsx +21 -3
  16. package/app/config.ts +0 -7
  17. package/app/entry.server.tsx +9 -4
  18. package/app/hooks/useInitConnections.ts +3 -3
  19. package/app/root.tsx +11 -6
  20. package/app/routes/connections/connection.form.tsx +9 -3
  21. package/app/routes/connections/connection.schema.ts +60 -11
  22. package/app/routes/connections/connections.clientLoader.ts +91 -0
  23. package/app/routes/connections/connections.loader.ts +19 -39
  24. package/app/routes/connections/connections.route.tsx +5 -0
  25. package/app/routes/connections/createConnection.action.ts +39 -13
  26. package/app/routes/connections/deleteConnection.action.ts +5 -1
  27. package/app/routes/connections/updateConnection.action.ts +50 -13
  28. package/app/routes/home/home.route.tsx +5 -1
  29. package/app/routes/layouts/protected.layout.tsx +15 -1
  30. package/app/routes/objects/objects.clientLoader.ts +73 -0
  31. package/app/routes/objects/objects.loader.ts +58 -92
  32. package/app/routes/objects/objects.route.tsx +41 -40
  33. package/app/routes/search.route.tsx +133 -28
  34. package/app/routes.ts +0 -4
  35. package/app/utils/connectionsStore/useConnectionsStore.ts +25 -62
  36. package/app/utils/credentialsRefresh.ts +53 -0
  37. package/app/utils/db/convertCsvToParquet.ts +24 -16
  38. package/app/utils/db/createDatabase.ts +25 -33
  39. package/app/utils/db/duckdbBundles.ts +42 -0
  40. package/app/utils/db/ensureSpatialLoaded.ts +28 -0
  41. package/app/utils/db/escapeSqlString.ts +4 -0
  42. package/app/utils/db/getBlobFromObjectNode.ts +15 -36
  43. package/app/utils/db/getTileDataWasm.ts +4 -5
  44. package/app/utils/db/sqlQueries.ts +6 -1
  45. package/app/utils/filterObjects.ts +2 -18
  46. package/app/utils/limitConcurrency.ts +28 -0
  47. package/app/utils/listObjectsClient.ts +318 -0
  48. package/app/utils/listingLimits.ts +7 -0
  49. package/app/utils/loadConnectionLevel.ts +50 -0
  50. package/app/utils/localFilesStore/useFileStore.ts +1 -6
  51. package/app/utils/pathUtils.ts +65 -0
  52. package/app/utils/resourceId.ts +14 -29
  53. package/app/utils/s3HostAllowlist.ts +116 -0
  54. package/app/utils/signedFetch.ts +159 -24
  55. package/package.json +2 -2
  56. package/prisma/seed.ts +3 -3
  57. package/public/duckdb-extensions/checksums.json +12 -0
  58. package/scripts/download-duckdb-extensions.mjs +188 -0
  59. package/scripts/prebuild.mjs +18 -10
  60. package/app/.server/auth/getPresignedUrl.ts +0 -21
  61. package/app/.server/auth/getS3Client.ts +0 -93
  62. package/app/routes/presign.route.tsx +0 -42
  63. package/app/utils/getObjects.ts +0 -24
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
- // Connection name: 2-60 chars, alphanumeric + hyphens + spaces,
4
- // no leading/trailing hyphens or spaces, no consecutive hyphens or spaces
3
+ import { isAllowedS3Host } from "~/utils/s3HostAllowlist";
4
+
5
5
  export const connectionNameSchema = z
6
6
  .string()
7
7
  .min(2, "Name must be at least 2 characters")
@@ -13,21 +13,75 @@ export const connectionNameSchema = z
13
13
  .refine((val) => !val.includes("--"), "Name must not contain consecutive hyphens")
14
14
  .refine((val) => !val.includes(" "), "Name must not contain consecutive spaces");
15
15
 
16
- // AWS ARN pattern validation
16
+ const isDevelopment = process.env.NODE_ENV === "development";
17
+ const HTTP_ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
18
+
19
+ // `https://` endpoints must also pass the S3 allowlist; without that gate the
20
+ // server-side CORS probe could fetch IMDS / RFC1918 / loopback URLs.
21
+ const endpointUrlSchema = z
22
+ .string()
23
+ .url("Invalid endpoint URL")
24
+ .refine(
25
+ (val) => {
26
+ let parsed: URL;
27
+ try {
28
+ parsed = new URL(val);
29
+ } catch {
30
+ return false;
31
+ }
32
+ if (parsed.protocol === "https:") return true;
33
+ if (parsed.protocol !== "http:") return false;
34
+ return isDevelopment || HTTP_ALLOWED_HOSTS.has(parsed.hostname);
35
+ },
36
+ {
37
+ message: "Endpoint must use https:// (http:// only allowed for localhost, 127.0.0.1, or ::1)",
38
+ },
39
+ )
40
+ .refine(
41
+ (val) => {
42
+ // Allowlist gate is https-only; the dev http carve-out above bypasses it
43
+ // intentionally so local MinIO keeps working.
44
+ let parsed: URL;
45
+ try {
46
+ parsed = new URL(val);
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (parsed.protocol !== "https:") return true;
51
+ return isAllowedS3Host(val);
52
+ },
53
+ {
54
+ message:
55
+ "Endpoint host is not in the cytario S3 allowlist. Ask the operator to add it to CYTARIO_ALLOWED_S3_HOSTS.",
56
+ },
57
+ );
58
+
17
59
  const arnPattern = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/;
18
60
 
19
- // S3 URI validation - extracts bucket name and optional prefix
20
61
  const s3UriSchema = z
21
62
  .string()
22
63
  .min(1, "S3 URI is required")
23
64
  .refine(
24
65
  (val) => {
25
- // Accept either "s3://bucket/path" or just "bucket/path" or "bucket"
26
66
  const cleaned = val.replace(/^s3:\/\//, "");
27
67
  const bucketName = cleaned.split("/")[0];
28
68
  return bucketName.length >= 3 && bucketName.length <= 63;
29
69
  },
30
70
  { message: "Invalid S3 URI - bucket name must be 3-63 characters" },
71
+ )
72
+ .refine(
73
+ (val) => {
74
+ // Reject IAM wildcards — a prefix like `tenant-a*` would expand the
75
+ // session policy's `StringLike` condition to neighbouring tenants.
76
+ const cleaned = val.replace(/^s3:\/\//, "");
77
+ const slashIdx = cleaned.indexOf("/");
78
+ if (slashIdx === -1) return true;
79
+ const prefix = cleaned.slice(slashIdx + 1);
80
+ return !/[*?]/.test(prefix);
81
+ },
82
+ {
83
+ message: "Prefix may not contain IAM wildcard characters (`*`, `?`)",
84
+ },
31
85
  );
32
86
 
33
87
  /** Auto-suggest a connection name from an S3 URI (e.g. "s3://my-bucket/path" → "my-bucket"). */
@@ -43,7 +97,6 @@ export function suggestName(s3Uri: string): string {
43
97
  .slice(0, 60);
44
98
  }
45
99
 
46
- // Helper to parse S3 URI into bucket name and prefix
47
100
  export const parseS3Uri = (uri: string): { bucketName: string; prefix: string } => {
48
101
  const cleaned = uri.replace(/^s3:\/\//, "");
49
102
  const [bucketName, ...prefixParts] = cleaned.split("/");
@@ -51,7 +104,6 @@ export const parseS3Uri = (uri: string): { bucketName: string; prefix: string }
51
104
  return { bucketName, prefix };
52
105
  };
53
106
 
54
- // Combined schema for final submission - AWS provider
55
107
  const awsFormSchema = z.object({
56
108
  name: connectionNameSchema,
57
109
  ownerScope: z.string().min(1, "Scope is required"),
@@ -62,7 +114,6 @@ const awsFormSchema = z.object({
62
114
  bucketEndpoint: z.string().default(""),
63
115
  });
64
116
 
65
- // Combined schema for final submission - MinIO provider
66
117
  const minioFormSchema = z.object({
67
118
  name: connectionNameSchema,
68
119
  ownerScope: z.string().min(1, "Scope is required"),
@@ -70,10 +121,9 @@ const minioFormSchema = z.object({
70
121
  s3Uri: s3UriSchema,
71
122
  bucketRegion: z.string().default(""),
72
123
  roleArn: z.string().default(""),
73
- bucketEndpoint: z.string().url("Invalid endpoint URL"),
124
+ bucketEndpoint: endpointUrlSchema,
74
125
  });
75
126
 
76
- // Discriminated union for type-safe conditional validation
77
127
  export const connectionSchema = z.discriminatedUnion("providerType", [
78
128
  awsFormSchema,
79
129
  minioFormSchema,
@@ -81,7 +131,6 @@ export const connectionSchema = z.discriminatedUnion("providerType", [
81
131
 
82
132
  export type ConnectBucketFormData = z.input<typeof connectionSchema>;
83
133
 
84
- // Default values for the form
85
134
  export const defaultFormValues: ConnectBucketFormData = {
86
135
  name: "",
87
136
  ownerScope: "",
@@ -0,0 +1,91 @@
1
+ import { _Object } from "@aws-sdk/client-s3";
2
+ import { Credentials } from "@aws-sdk/client-sts";
3
+ import { type ClientLoaderFunctionArgs } from "react-router";
4
+
5
+ import type { LoaderData, loadConnections } from "./connections.loader";
6
+ import type { ConnectionConfig } from "~/.generated/client";
7
+ import { TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
8
+ import { isImageFile } from "~/utils/fileType";
9
+ import { mapWithConcurrency } from "~/utils/limitConcurrency";
10
+ import { listObjectsClient } from "~/utils/listObjectsClient";
11
+ import { getPrefix } from "~/utils/pathUtils";
12
+ import { CorsLikelyError } from "~/utils/signedFetch";
13
+
14
+ const PREVIEW_CONCURRENCY = 4;
15
+
16
+ const isImagePreview = (obj: _Object) => isImageFile(obj.Key ?? "");
17
+
18
+ interface ConnectionProbeResult {
19
+ previewObj?: _Object;
20
+ status: "connected" | "error";
21
+ errorMessage?: string;
22
+ }
23
+
24
+ async function probeConnection(
25
+ config: ConnectionConfig,
26
+ credentials: Credentials,
27
+ signal?: AbortSignal,
28
+ ): Promise<ConnectionProbeResult> {
29
+ try {
30
+ const { contents } = await listObjectsClient(config, credentials, {
31
+ // Trailing slash required: the session policy only allows `<prefix>/`
32
+ // and `<prefix>/*`, so a bare `<prefix>` value 403s.
33
+ prefix: getPrefix(config.prefix),
34
+ recursive: true,
35
+ maxKeys: 100,
36
+ maxTotal: 100,
37
+ findFirst: isImagePreview,
38
+ signal,
39
+ });
40
+ return { previewObj: contents.find(isImagePreview), status: "connected" };
41
+ } catch (error) {
42
+ if (error instanceof CorsLikelyError) {
43
+ return {
44
+ status: "error",
45
+ errorMessage: "Browser blocked from reading the bucket — check the bucket's CORS policy.",
46
+ };
47
+ }
48
+ const message = error instanceof Error ? error.message : "Unknown error";
49
+ return { status: "error", errorMessage: message };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Per-connection preview + health probe. One bounded-concurrency
55
+ * `ListObjectsV2` doubles as the bucket-card preview and the connection
56
+ * health check; `CorsLikelyError` becomes a card-level CORS warning.
57
+ */
58
+ export async function enrichConnectionsWithPreviews({
59
+ request,
60
+ serverLoader,
61
+ }: ClientLoaderFunctionArgs): Promise<LoaderData> {
62
+ const server = await serverLoader<typeof loadConnections>();
63
+ const signal = request.signal;
64
+
65
+ const probes = await mapWithConcurrency(
66
+ server.connectionConfigs,
67
+ PREVIEW_CONCURRENCY,
68
+ async (config): Promise<ConnectionProbeResult> => {
69
+ if (signal.aborted) return { status: "connected" };
70
+ const creds = server.credentials[config.name];
71
+ if (!creds) {
72
+ return { status: "error", errorMessage: "No credentials available for this connection." };
73
+ }
74
+ return probeConnection(config, creds, signal);
75
+ },
76
+ );
77
+
78
+ const nodes: TreeNode[] = server.nodes.map((node, i) => {
79
+ const probe = probes[i];
80
+ return {
81
+ ...node,
82
+ ...(probe.previewObj ? { _Object: probe.previewObj } : {}),
83
+ connectionStatus: probe.status,
84
+ ...(probe.errorMessage ? { connectionErrorMessage: probe.errorMessage } : {}),
85
+ };
86
+ });
87
+
88
+ return { ...server, nodes };
89
+ }
90
+
91
+ enrichConnectionsWithPreviews.hydrate = true;
@@ -1,30 +1,12 @@
1
- import { _Object } from "@aws-sdk/client-s3";
2
1
  import { Credentials } from "@aws-sdk/client-sts";
3
2
  import { type LoaderFunctionArgs } from "react-router";
4
3
 
5
4
  import { ConnectionConfig } from "~/.generated/client";
6
5
  import { authContext } from "~/.server/auth/authMiddleware";
7
- import { getS3Client } from "~/.server/auth/getS3Client";
8
- import { ConnectionsCredentials } from "~/.server/auth/sessionStorage";
9
6
  import { TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
10
- import { isImageFile } from "~/utils/fileType";
11
- import { getObjects } from "~/utils/getObjects";
12
7
  import { getPinnedPaths } from "~/utils/pinnedPaths.server";
13
8
  import { getRecentlyViewed } from "~/utils/recentlyViewed.server";
14
9
 
15
- /** Find the first image file in a connection for the bucket card preview. */
16
- const fetchPreviewObject = async (
17
- config: ConnectionConfig,
18
- credentials: ConnectionsCredentials,
19
- userId: string,
20
- ): Promise<_Object | undefined> => {
21
- const creds = credentials[config.name];
22
- if (!creds) return undefined;
23
- const s3 = await getS3Client(config, creds, userId);
24
- const objects = await getObjects(config, s3, null, config.prefix || undefined, 100);
25
- return objects.find((obj) => isImageFile(obj.Key ?? ""));
26
- };
27
-
28
10
  export type SerializedRecentlyViewed = {
29
11
  id: number;
30
12
  connectionName: string;
@@ -43,7 +25,7 @@ export type SerializedPinnedPath = {
43
25
  lastModified: string | null;
44
26
  };
45
27
 
46
- export interface LoaderData {
28
+ export interface ServerLoaderData {
47
29
  nodes: TreeNode[];
48
30
  credentials: Record<string, Credentials>;
49
31
  connectionConfigs: ConnectionConfig[];
@@ -51,32 +33,28 @@ export interface LoaderData {
51
33
  pinnedPaths: SerializedPinnedPath[];
52
34
  }
53
35
 
36
+ export interface LoaderData extends ServerLoaderData {
37
+ /** Same shape as `nodes` but with per-bucket preview enrichment from the client probe. */
38
+ nodes: TreeNode[];
39
+ }
40
+
54
41
  export async function loadConnections({ context }: LoaderFunctionArgs) {
55
42
  const { connectionConfigs, credentials, user } = context.get(authContext);
56
43
  const userId = user.sub;
57
44
 
58
- const [previews, recentlyViewedRaw, pinnedPathsRaw] = await Promise.all([
59
- Promise.allSettled(
60
- connectionConfigs.map((config) => fetchPreviewObject(config, credentials, userId)),
61
- ),
45
+ const [recentlyViewedRaw, pinnedPathsRaw] = await Promise.all([
62
46
  getRecentlyViewed(userId, 20),
63
47
  getPinnedPaths(userId),
64
48
  ]);
65
49
 
66
- const nodes: TreeNode[] = connectionConfigs.map((config, i) => {
67
- const result = previews[i];
68
- const previewObj = result.status === "fulfilled" ? result.value : undefined;
69
-
70
- return {
71
- id: `${config.name}/`,
72
- connectionName: config.name,
73
- name: config.name,
74
- type: "bucket" as const,
75
- pathName: "",
76
- children: [],
77
- _Object: previewObj,
78
- };
79
- });
50
+ const nodes: TreeNode[] = connectionConfigs.map((config) => ({
51
+ id: `${config.name}/`,
52
+ connectionName: config.name,
53
+ name: config.name,
54
+ type: "bucket" as const,
55
+ pathName: "",
56
+ children: [],
57
+ }));
80
58
 
81
59
  const recentlyViewed: SerializedRecentlyViewed[] = recentlyViewedRaw.map((item) => ({
82
60
  id: item.id,
@@ -96,11 +74,13 @@ export async function loadConnections({ context }: LoaderFunctionArgs) {
96
74
  lastModified: pin.lastModified ? pin.lastModified.toISOString() : null,
97
75
  }));
98
76
 
99
- return {
77
+ const payload: ServerLoaderData = {
100
78
  nodes,
101
79
  credentials,
102
80
  connectionConfigs,
103
81
  recentlyViewed,
104
82
  pinnedPaths,
105
- } satisfies LoaderData;
83
+ };
84
+
85
+ return payload;
106
86
  }
@@ -31,8 +31,13 @@ export const action = async (args: ActionFunctionArgs) => {
31
31
  }
32
32
  };
33
33
 
34
+ export { enrichConnectionsWithPreviews as clientLoader } from "./connections.clientLoader";
34
35
  export { loadConnections as loader } from "./connections.loader";
35
36
 
37
+ // Response carries STS credentials — keep it out of every cache between origin
38
+ // and browser.
39
+ export const headers = () => ({ "Cache-Control": "no-store, private" });
40
+
36
41
  const title = "Storage Connections";
37
42
 
38
43
  export const meta: MetaFunction = () => [{ title: `${title} — Cytario` }];
@@ -5,10 +5,12 @@ import { Prisma } from "~/.generated/client";
5
5
  import { authContext } from "~/.server/auth/authMiddleware";
6
6
  import { sessionContext } from "~/.server/auth/sessionMiddleware";
7
7
  import { sessionStorage } from "~/.server/auth/sessionStorage";
8
+ import { describeCorsFailure, describeCorsWarning, probeBucketCors } from "~/.server/corsPreflight";
8
9
  import { prisma } from "~/.server/db/prisma";
10
+ import { cytarioConfig } from "~/config";
9
11
  import { canCreate } from "~/utils/authorization";
12
+ import { constructS3Url } from "~/utils/resourceId";
10
13
 
11
- /** Create a connection config (upserts on the composite unique key). */
12
14
  export async function createConnection(
13
15
  ownerScope: string,
14
16
  createdBy: string,
@@ -84,6 +86,24 @@ export const createAction = async ({ request, context }: ActionFunctionArgs) =>
84
86
  ? `https://s3.${data.bucketRegion}.amazonaws.com`
85
87
  : data.bucketEndpoint;
86
88
 
89
+ // Surface CORS misconfigurations at submit time rather than at first
90
+ // browser-side fetch (where they read as a generic "Failed to fetch").
91
+ const cytarioOrigin = cytarioConfig.endpoints.webapp;
92
+ const bucketUrl = constructS3Url({
93
+ bucketName,
94
+ region: data.providerType === "aws" ? data.bucketRegion : null,
95
+ endpoint,
96
+ });
97
+ const corsResult = await probeBucketCors(bucketUrl, cytarioOrigin);
98
+ if (!corsResult.ok) {
99
+ // CORS / allowlist failures span multiple fields (and AWS has no
100
+ // endpoint field) — surface as a form-level banner.
101
+ return {
102
+ formError: describeCorsFailure(corsResult, cytarioOrigin),
103
+ status: "error" as const,
104
+ };
105
+ }
106
+
87
107
  const newConfig = {
88
108
  name: data.name,
89
109
  bucketName,
@@ -96,10 +116,19 @@ export const createAction = async ({ request, context }: ActionFunctionArgs) =>
96
116
 
97
117
  await createConnection(data.ownerScope, user.sub, newConfig);
98
118
 
99
- session.set("notification", {
100
- status: "success",
101
- message: "Storage connection added successfully.",
102
- });
119
+ if (corsResult.warnings.length > 0) {
120
+ session.set("notification", {
121
+ status: "warning",
122
+ message: `Connection added. ${corsResult.warnings
123
+ .map((w) => describeCorsWarning(w, cytarioOrigin))
124
+ .join(" ")}`,
125
+ });
126
+ } else {
127
+ session.set("notification", {
128
+ status: "success",
129
+ message: "Storage connection added successfully.",
130
+ });
131
+ }
103
132
 
104
133
  return redirect(`/connections/${encodeURIComponent(data.name)}`, {
105
134
  headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
@@ -116,13 +145,10 @@ export const createAction = async ({ request, context }: ActionFunctionArgs) =>
116
145
 
117
146
  console.error("Error creating connection:", error);
118
147
 
119
- session.set("notification", {
120
- status: "error",
121
- message: "Error adding connection.",
122
- });
123
-
124
- return redirect(`/`, {
125
- headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
126
- });
148
+ // Keep the wizard mounted so the user's draft survives the error.
149
+ return {
150
+ formError: "Could not add the connection. Try again or check the server logs.",
151
+ status: "error" as const,
152
+ };
127
153
  }
128
154
  };
@@ -7,7 +7,6 @@ import { sessionStorage } from "~/.server/auth/sessionStorage";
7
7
  import { prisma } from "~/.server/db/prisma";
8
8
  import { canModify, canSee } from "~/utils/authorization";
9
9
 
10
- /** Delete a connection config by name. Checks visibility and modify authorization. */
11
10
  export async function deleteConnection(user: UserProfile, name: string) {
12
11
  const config = await prisma.connectionConfig.findUnique({
13
12
  where: { name },
@@ -49,6 +48,11 @@ export const deleteAction = async ({ request, context }: ActionFunctionArgs) =>
49
48
  throw error;
50
49
  }
51
50
 
51
+ // Drop cached STS credentials keyed by the removed name.
52
+ const credentials = session.get("credentials") ?? {};
53
+ delete credentials[connectionName];
54
+ session.set("credentials", credentials);
55
+
52
56
  session.set("notification", {
53
57
  status: "success",
54
58
  message: "Storage connection deleted.",
@@ -3,14 +3,20 @@ import { type ActionFunctionArgs, redirect } from "react-router";
3
3
  import { connectionSchema, parseS3Uri } from "./connection.schema";
4
4
  import { Prisma } from "~/.generated/client";
5
5
  import { authContext } from "~/.server/auth/authMiddleware";
6
- import { invalidateS3ClientsForBucket } from "~/.server/auth/getS3Client";
7
6
  import type { UserProfile } from "~/.server/auth/getUserInfo";
8
7
  import { sessionContext } from "~/.server/auth/sessionMiddleware";
9
8
  import { sessionStorage } from "~/.server/auth/sessionStorage";
9
+ import {
10
+ type CorsPreflightWarningReason,
11
+ describeCorsFailure,
12
+ describeCorsWarning,
13
+ probeBucketCors,
14
+ } from "~/.server/corsPreflight";
10
15
  import { prisma } from "~/.server/db/prisma";
16
+ import { cytarioConfig } from "~/config";
11
17
  import { canCreate, canModify, canSee } from "~/utils/authorization";
18
+ import { constructS3Url } from "~/utils/resourceId";
12
19
 
13
- /** Update a connection config. Cascades name changes to related records. */
14
20
  export async function updateConnection(
15
21
  user: UserProfile,
16
22
  originalName: string,
@@ -46,8 +52,7 @@ export async function updateConnection(
46
52
  const previousName = config.name;
47
53
  const previousBucketName = config.bucketName;
48
54
 
49
- // FKs on recentlyViewed/pinnedPath have ON UPDATE CASCADE,
50
- // so Postgres automatically cascades name changes to children.
55
+ // FKs on recentlyViewed / pinnedPath use ON UPDATE CASCADE.
51
56
  const updated = await prisma.connectionConfig.update({
52
57
  where: { id: config.id },
53
58
  data: {
@@ -79,7 +84,6 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
79
84
  };
80
85
  }
81
86
 
82
- // Validate the form data with the same schema as create
83
87
  const rawData = {
84
88
  name: String(formData.get("name") ?? ""),
85
89
  ownerScope: String(formData.get("ownerScope") ?? ""),
@@ -107,6 +111,31 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
107
111
  ? `https://s3.${validated.bucketRegion}.amazonaws.com`
108
112
  : validated.bucketEndpoint;
109
113
 
114
+ // Only re-probe CORS when the bucket URL actually changes — a transient
115
+ // bucket-side glitch must not block a pure name / scope / prefix edit.
116
+ const existingConfig = await prisma.connectionConfig.findUnique({
117
+ where: { name: originalName },
118
+ });
119
+ const endpointChanged = !existingConfig || existingConfig.endpoint !== endpoint;
120
+ const bucketNameChanged = !existingConfig || existingConfig.bucketName !== bucketName;
121
+ const cytarioOrigin = cytarioConfig.endpoints.webapp;
122
+ let corsWarnings: CorsPreflightWarningReason[] = [];
123
+ if (endpointChanged || bucketNameChanged) {
124
+ const bucketUrl = constructS3Url({
125
+ bucketName,
126
+ region: validated.providerType === "aws" ? validated.bucketRegion : null,
127
+ endpoint,
128
+ });
129
+ const corsResult = await probeBucketCors(bucketUrl, cytarioOrigin);
130
+ if (!corsResult.ok) {
131
+ return {
132
+ formError: describeCorsFailure(corsResult, cytarioOrigin),
133
+ status: "error" as const,
134
+ };
135
+ }
136
+ corsWarnings = corsResult.warnings;
137
+ }
138
+
110
139
  try {
111
140
  const updatedConfig = await updateConnection(user, originalName, {
112
141
  name: validated.name,
@@ -119,20 +148,27 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
119
148
  region: validated.providerType === "aws" ? validated.bucketRegion : null,
120
149
  });
121
150
 
122
- // Invalidate cached credentials (keyed by connection name) and the S3
123
- // client cache (keyed by bucket) for the previous identity.
151
+ // Drop cached credentials so authMiddleware re-mints under the new identity.
124
152
  const credentials = session.get("credentials") ?? {};
125
153
  delete credentials[updatedConfig.previousName];
126
154
  if (updatedConfig.name !== updatedConfig.previousName) {
127
155
  delete credentials[updatedConfig.name];
128
156
  }
129
157
  session.set("credentials", credentials);
130
- invalidateS3ClientsForBucket(user.sub, updatedConfig.previousBucketName);
131
158
 
132
- session.set("notification", {
133
- status: "success",
134
- message: "Connection updated successfully.",
135
- });
159
+ if (corsWarnings.length > 0) {
160
+ session.set("notification", {
161
+ status: "warning",
162
+ message: `Connection updated. ${corsWarnings
163
+ .map((w) => describeCorsWarning(w, cytarioOrigin))
164
+ .join(" ")}`,
165
+ });
166
+ } else {
167
+ session.set("notification", {
168
+ status: "success",
169
+ message: "Connection updated successfully.",
170
+ });
171
+ }
136
172
 
137
173
  return redirect(`/connections/${encodeURIComponent(updatedConfig.name)}`, {
138
174
  headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
@@ -150,8 +186,9 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
150
186
  }
151
187
 
152
188
  if (error instanceof Error) {
189
+ // Surface as a form-level banner — the user may have left step 1.
153
190
  return {
154
- errors: { ownerScope: [error.message] },
191
+ formError: error.message,
155
192
  status: "error" as const,
156
193
  };
157
194
  }
@@ -32,13 +32,17 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
32
32
  defaultShouldRevalidate,
33
33
  }) => {
34
34
  if (formAction) return defaultShouldRevalidate;
35
- // Revalidate when navigating back to home from another page
36
35
  if (currentUrl.pathname !== nextUrl.pathname) return true;
37
36
  return false;
38
37
  };
39
38
 
39
+ export { enrichConnectionsWithPreviews as clientLoader } from "~/routes/connections/connections.clientLoader";
40
40
  export { loadConnections as loader } from "~/routes/connections/connections.loader";
41
41
 
42
+ // Response carries STS credentials — keep it out of every cache between origin
43
+ // and browser.
44
+ export const headers = () => ({ "Cache-Control": "no-store, private" });
45
+
42
46
  export default function HomeRoute() {
43
47
  const { nodes, connectionConfigs, recentlyViewed, pinnedPaths } = useLoaderData<LoaderData>();
44
48
 
@@ -1,4 +1,9 @@
1
- import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router";
1
+ import {
2
+ Outlet,
3
+ useLoaderData,
4
+ type ClientLoaderFunctionArgs,
5
+ type LoaderFunctionArgs,
6
+ } from "react-router";
2
7
 
3
8
  import { ModalOutlet } from "./ModalOutlet";
4
9
  import { authContext, authMiddleware } from "~/.server/auth/authMiddleware";
@@ -6,11 +11,20 @@ import { useInitConnections } from "~/hooks/useInitConnections";
6
11
 
7
12
  export const middleware = [authMiddleware];
8
13
 
14
+ // Response carries STS credentials — keep it out of every cache between origin
15
+ // and browser.
16
+ export const headers = () => ({ "Cache-Control": "no-store, private" });
17
+
9
18
  export const loader = async ({ context }: LoaderFunctionArgs) => {
10
19
  const { connectionConfigs, credentials } = context.get(authContext);
11
20
  return { connectionConfigs, credentials };
12
21
  };
13
22
 
23
+ // Identity clientLoader — see `app/root.tsx`; works around RR's bulk-fetch
24
+ // short-circuit during the initial `clientLoader.hydrate` pass.
25
+ export const clientLoader = ({ serverLoader }: ClientLoaderFunctionArgs) =>
26
+ serverLoader<typeof loader>();
27
+
14
28
  export default function ProtectedLayout() {
15
29
  const { connectionConfigs, credentials } = useLoaderData<typeof loader>();
16
30
  useInitConnections(connectionConfigs, credentials);