@cytario/web 2.2.4 → 2.2.6

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/README.md CHANGED
@@ -230,6 +230,21 @@ cp .env.template .env # Pre-configured for the Podman cluster
230
230
  npm run dev
231
231
  ```
232
232
 
233
+ ### Session Cache (Redis/Valkey)
234
+
235
+ Sessions hold OAuth access/refresh/ID tokens and short-lived STS credentials. **TLS is required in production.** The app refuses to boot when `NODE_ENV !== "development"` unless one of the following is true:
236
+
237
+ | Env var | Value | Meaning |
238
+ | -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
239
+ | `REDIS_TLS` | `"true"` | Wrap the ioredis connection in TLS (recommended). |
240
+ | `REDIS_CA_CERT` | PEM | Optional CA bundle for self-signed deployments. Multi-line PEM string. |
241
+ | `REDIS_TLS_SERVER_NAME` | hostname | Optional SNI / certificate hostname override. |
242
+ | `REDIS_INSECURE_ALLOW_PLAINTEXT` | `"true"` | Explicit opt-out for trusted private networks. Logs a warning. Not for use on shared infrastructure. |
243
+
244
+ The local Podman cluster runs Valkey without TLS, which is allowed because `NODE_ENV=development`. Managed Valkey deployments (helm chart, AWS ElastiCache, etc.) should set `REDIS_TLS=true`. Valkey reuses the standard `6379` port for TLS when `tls.enabled` is set — it does not move the listener to `6380` and refuses plaintext on the same port — so leave `REDIS_PORT` at `6379` unless your provider explicitly publishes a separate TLS endpoint.
245
+
246
+ In the production cluster (see `cytario-infrastructure`, C-212) the Valkey leaf cert is signed by a cluster-internal CA managed by cert-manager. The CA's public cert is distributed to every namespace as a `cytario-internal-ca` ConfigMap by trust-manager, and the `cytario-web` helm chart's `redis.caCertConfigMap.{name,key}` wires it into the pod as `REDIS_CA_CERT` via `valueFrom.configMapKeyRef`. The app sees the PEM through the normal env var path — no code-side knowledge of the trust source is required.
247
+
233
248
  ### Database
234
249
 
235
250
  PostgreSQL with [Prisma ORM](https://www.prisma.io/). Connection configured via `DATABASE_URL` in `.env`.
@@ -1,6 +1,6 @@
1
1
  import Redis from "ioredis";
2
2
 
3
- import { cytarioConfig } from "~/config";
3
+ import { buildRedisOptions } from "./redisOptions";
4
4
 
5
5
  /**
6
6
  * Redis/Valkey client instance
@@ -14,55 +14,39 @@ import { cytarioConfig } from "~/config";
14
14
  * - REDIS_PORT: Server port (default: 6379)
15
15
  * - REDIS_USERNAME: Optional username for authenticated connections (Redis 6+ / Valkey)
16
16
  * - REDIS_PASSWORD: Optional password for authenticated connections
17
+ * - REDIS_TLS: Set to "true" to wrap the connection in TLS (required in production)
18
+ * - REDIS_CA_CERT: Optional PEM-encoded CA certificate (string) for self-signed deployments
19
+ * - REDIS_TLS_SERVER_NAME: Optional SNI / certificate hostname override
20
+ * - REDIS_INSECURE_ALLOW_PLAINTEXT: Set to "true" to opt out of the production TLS requirement
17
21
  *
18
22
  * @example
19
- * // Use with Redis without authentication
20
- * REDIS_HOST=redis.example.com
21
- * REDIS_PORT=6379
22
- *
23
- * @example
24
- * // Use with Valkey with authentication
23
+ * // Use with managed Valkey over TLS — Valkey reuses 6379 for TLS when tls.enabled is set
25
24
  * REDIS_HOST=valkey.example.com
26
25
  * REDIS_PORT=6379
27
26
  * REDIS_USERNAME=myuser
28
27
  * REDIS_PASSWORD=mypassword
28
+ * REDIS_TLS=true
29
29
  *
30
30
  * @example
31
- * // Use with Redis/Valkey with password-only authentication (legacy)
32
- * REDIS_HOST=redis.example.com
31
+ * // Local development without TLS (only allowed when NODE_ENV=development)
32
+ * REDIS_HOST=localhost
33
33
  * REDIS_PORT=6379
34
- * REDIS_PASSWORD=mypassword
35
34
  */
36
35
 
37
- const {
38
- redis: { host, port, username, password },
39
- } = cytarioConfig;
36
+ const options = buildRedisOptions(process.env);
40
37
 
41
- export const redis = new Redis({
42
- host,
43
- port,
44
- // Only include username if provided (Redis 6+ ACL support)
45
- ...(username && { username }),
46
- // Only include password if provided (backwards compatible)
47
- ...(password && { password }),
48
- maxRetriesPerRequest: 3,
49
- retryStrategy(times) {
50
- const delay = Math.min(times * 50, 2000);
51
- return delay;
52
- },
53
- lazyConnect: false,
54
- });
38
+ export const redis = new Redis(options);
55
39
 
56
- // Log connection errors
57
40
  redis.on("error", (err) => {
58
41
  console.error("Redis/Valkey connection error:", err);
59
42
  });
60
43
 
61
44
  redis.on("connect", () => {
62
- const authInfo = username
63
- ? ` (authenticated as ${username})`
64
- : password
45
+ const authInfo = options.username
46
+ ? ` (authenticated as ${options.username})`
47
+ : options.password
65
48
  ? " (authenticated)"
66
49
  : "";
67
- console.log(`Connected to Redis/Valkey at ${host}:${port}${authInfo}`);
50
+ const tlsInfo = options.tls ? " over TLS" : "";
51
+ console.log(`Connected to Redis/Valkey at ${options.host}:${options.port}${authInfo}${tlsInfo}`);
68
52
  });
@@ -0,0 +1,72 @@
1
+ import type { RedisOptions } from "ioredis";
2
+
3
+ /**
4
+ * Build ioredis connection options from environment variables.
5
+ *
6
+ * Recognised env vars:
7
+ * - `REDIS_HOST` (default: `localhost`)
8
+ * - `REDIS_PORT` (default: `6379`)
9
+ * - `REDIS_USERNAME` — optional ACL username
10
+ * - `REDIS_PASSWORD` — optional password
11
+ * - `REDIS_TLS` — `"true"` to wrap the connection in TLS
12
+ * - `REDIS_CA_CERT` — PEM-encoded CA certificate (string) used to verify
13
+ * the server when the cert chain is not in the system trust store
14
+ * - `REDIS_TLS_SERVER_NAME` — SNI / certificate hostname override
15
+ * - `REDIS_INSECURE_ALLOW_PLAINTEXT` — `"true"` to opt out of the
16
+ * production TLS requirement (escape hatch for trusted in-cluster
17
+ * networks; logs a warning)
18
+ *
19
+ * Fails fast outside development if TLS is off and the opt-out flag is
20
+ * not set — session blobs contain OAuth tokens and STS credentials and
21
+ * must not traverse plaintext links in production. See C-204.
22
+ */
23
+ export function buildRedisOptions(env: Record<string, string | undefined>): RedisOptions {
24
+ const host = env.REDIS_HOST || "localhost";
25
+ const port = Number(env.REDIS_PORT) || 6379;
26
+ const username = env.REDIS_USERNAME;
27
+ const password = env.REDIS_PASSWORD;
28
+ const tlsEnabled = env.REDIS_TLS === "true";
29
+ const caCert = env.REDIS_CA_CERT;
30
+ const tlsServerName = env.REDIS_TLS_SERVER_NAME;
31
+ const allowPlaintext = env.REDIS_INSECURE_ALLOW_PLAINTEXT === "true";
32
+ const nodeEnv = env.NODE_ENV;
33
+
34
+ const isLocalEnv = nodeEnv === "development" || nodeEnv === "test";
35
+
36
+ if (!tlsEnabled && !isLocalEnv && !allowPlaintext) {
37
+ throw new Error(
38
+ "Refusing to start: Redis/Valkey TLS is disabled. Set REDIS_TLS=true " +
39
+ "(recommended) or REDIS_INSECURE_ALLOW_PLAINTEXT=true to opt out. " +
40
+ "See C-204 / OWASP A02:2021.",
41
+ );
42
+ }
43
+
44
+ if (!tlsEnabled && allowPlaintext && !isLocalEnv) {
45
+ console.warn(
46
+ "REDIS_INSECURE_ALLOW_PLAINTEXT=true — session tokens will traverse " +
47
+ "an unencrypted connection. Only safe on a trusted private network.",
48
+ );
49
+ }
50
+
51
+ const options: RedisOptions = {
52
+ host,
53
+ port,
54
+ ...(username && { username }),
55
+ ...(password && { password }),
56
+ maxRetriesPerRequest: 3,
57
+ retryStrategy(times) {
58
+ const delay = Math.min(times * 50, 2000);
59
+ return delay;
60
+ },
61
+ lazyConnect: false,
62
+ };
63
+
64
+ if (tlsEnabled) {
65
+ options.tls = {
66
+ ...(caCert && { ca: caCert }),
67
+ ...(tlsServerName && { servername: tlsServerName }),
68
+ };
69
+ }
70
+
71
+ return options;
72
+ }
@@ -1,30 +1,42 @@
1
- import { AccessorContext } from "@deck.gl/core";
1
+ import { type AccessorContext, type Position } from "@deck.gl/core";
2
2
  import { parseSync } from "@loaders.gl/core";
3
3
  import { WKBLoader } from "@loaders.gl/wkt";
4
4
  import { type Table } from "apache-arrow";
5
+ import { type Geometry } from "geojson";
6
+
7
+ // Dedup per-row warnings — accessor fires per row per frame.
8
+ const warned = new Set<string>();
9
+ const warnOnce = (key: string, level: "warn" | "error", ...args: unknown[]) => {
10
+ if (warned.has(key)) return;
11
+ warned.add(key);
12
+ console[level](...args);
13
+ };
5
14
 
6
15
  export const getPolygon = (arrowTable: Table) => {
7
- // Polygon mode - parse WKB geometry and use SolidPolygonLayer
8
16
  const geomCol = arrowTable.getChild("geom")!;
9
17
 
10
- return (_d: unknown, { index }: AccessorContext<unknown>) => {
11
- // Parse WKB binary geometry
18
+ return (_d: unknown, { index }: AccessorContext<unknown>): Position[][] => {
12
19
  const wkbBuffer = geomCol.get(index);
13
- const geometry = parseSync(wkbBuffer, WKBLoader);
14
-
15
- // Type guard: check if it's a BinaryPolygonGeometry
16
- if (!("positions" in geometry)) {
17
- console.error("Unexpected geometry type:", geometry);
20
+ if (!wkbBuffer) {
21
+ warnOnce("empty-geom", "warn", "[getPolygon] empty geom column value");
18
22
  return [[]];
19
23
  }
20
24
 
21
- // Convert flat Float64Array to nested coordinate array
22
- // SolidPolygonLayer expects: [[[x1, y1], [x2, y2], ...]]
23
- const positions = geometry.positions.value;
24
- const coords: [number, number][] = [];
25
- for (let i = 0; i < positions.length; i += 2) {
26
- coords.push([positions[i], positions[i + 1]]);
25
+ const geometry = parseSync(wkbBuffer, WKBLoader) as Geometry;
26
+
27
+ if (geometry.type === "Polygon") {
28
+ return geometry.coordinates as Position[][];
29
+ }
30
+ if (geometry.type === "MultiPolygon") {
31
+ // SolidPolygonLayer one polygon per row — render first, preprocess upstream for full fidelity.
32
+ warnOnce(
33
+ "multipolygon",
34
+ "warn",
35
+ "[getPolygon] MultiPolygon downgraded to first polygon; preprocess to one row per polygon for full rendering",
36
+ );
37
+ return (geometry.coordinates[0] ?? [[]]) as Position[][];
27
38
  }
28
- return [coords]; // Wrap in array for simple polygon
39
+ warnOnce("unknown-type", "error", "[getPolygon] unexpected geometry type:", geometry);
40
+ return [[]];
29
41
  };
30
42
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cytario/web",
3
- "version": "2.2.4",
3
+ "version": "2.2.6",
4
4
  "description": "Cytario Web — scientific imaging data browser and viewer for OME-TIFF, OME-Zarr, Parquet and GeoTIFF on S3-compatible storage.",
5
5
  "license": "AGPL-3.0",
6
6
  "sideEffects": false,
@@ -84,8 +84,8 @@
84
84
  "@duckdb/node-api": "^1.5.0-r.1",
85
85
  "@hms-dbmi/viv": "^0.20.1",
86
86
  "@hookform/resolvers": "^5.2.2",
87
- "@loaders.gl/core": "^4.3.4",
88
- "@loaders.gl/wkt": "^4.3.4",
87
+ "@loaders.gl/core": "4.4.2",
88
+ "@loaders.gl/wkt": "4.4.2",
89
89
  "@prisma/adapter-pg": "^7.5.0",
90
90
  "@prisma/client": "^7.5.0",
91
91
  "@react-router/dev": "^7.13.2",