Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spiffworkflow-backend/bin/boot_server_in_docker
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ worker_count=4
if [[ "${SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA:-}" == "true" ]]; then
worker_count=1
fi
export SERVER_WORKER_COUNT="$worker_count"

if [[ -z "${SPIFFWORKFLOW_BACKEND_GIT_SSH_PRIVATE_KEY_PATH:-}" ]]; then
if [[ -n "${SPIFFWORKFLOW_BACKEND_GIT_SSH_PRIVATE_KEY:-}" ]]; then
Expand Down
1 change: 1 addition & 0 deletions spiffworkflow-backend/bin/run_server_locally
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ else
fi

uv run python "${script_dir}/bootstrap.py"
export SERVER_WORKER_COUNT="1"

# this line blocks
exec uv run uvicorn spiff_web_server:connexion_app \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,89 @@
# This should only be used for development and demonstration. SHOULD NOT BE USED IN PROD.

import os
import tempfile
from pathlib import Path


def _expected_worker_count() -> int:
configured_count = os.getenv("SERVER_WORKER_COUNT", "1")
try:
return int(configured_count)
except ValueError:
return 1


def _key_cache_dir() -> Path:
configured_dir = os.getenv("OPENID_KEY_CACHE_DIR")
if configured_dir:
return Path(configured_dir)
return Path(tempfile.gettempdir()) / "spiffworkflow-dev-openid-keys"


def _key_paths() -> tuple[Path, Path, Path]:
key_dir = _key_cache_dir()
return (
key_dir / "openid-private.pem",
key_dir / "openid-public.pem",
key_dir / ".lock",
)


def _read_key_pair_from_files(private_key_path: Path, public_key_path: Path) -> tuple[str, str] | None:
if private_key_path.exists() and public_key_path.exists():
return (private_key_path.read_text(), public_key_path.read_text())
return None
Comment on lines +33 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the cached PEM writes crash-safe.

_read_key_pair_from_files() treats any two existing files as valid, but both writers update the final PEM paths in place. If a write is interrupted, startup can accept truncated PEM data and the first OpenID request fails later. Please write temp files and replace() them, or validate the PEMs before returning them.

Also applies to: 53-56, 69-72

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spiffworkflow-backend/src/spiffworkflow_backend/config/openid/rsa_keys.py`
around lines 33 - 36, _read_key_pair_from_files currently treats any existing
files as valid which can accept truncated PEMs if writers update in-place; fix
the writers (the PEM-writing code at the other two occurrences referenced) to
write to a temporary file first, validate the PEM content (e.g., by attempting
to load/parse the private/public key) and then atomically replace the final path
with Path.replace(tmp_path, final_path). Also update _read_key_pair_from_files
(and the other read sites) to validate the PEMs after reading (e.g., attempt to
parse them) and return None if parsing fails so startup won't accept truncated
files.



def _load_or_create_file_backed_keys_with_lock() -> tuple[str, str]:
private_key_path, public_key_path, lock_path = _key_paths()
private_key_path.parent.mkdir(parents=True, exist_ok=True)

with lock_path.open("w") as lock_file:
import fcntl

fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)

existing_keys = _read_key_pair_from_files(private_key_path, public_key_path)
if existing_keys is not None:
return existing_keys

private_key, public_key = _generate_keys()
private_key_path.write_text(private_key)
public_key_path.write_text(public_key)
os.chmod(private_key_path, 0o600)
os.chmod(public_key_path, 0o644)
return (private_key, public_key)


def _load_or_create_file_backed_keys_without_lock() -> tuple[str, str]:
private_key_path, public_key_path, _lock_path = _key_paths()
private_key_path.parent.mkdir(parents=True, exist_ok=True)

existing_keys = _read_key_pair_from_files(private_key_path, public_key_path)
if existing_keys is not None:
return existing_keys

private_key, public_key = _generate_keys()
private_key_path.write_text(private_key)
public_key_path.write_text(public_key)
os.chmod(private_key_path, 0o600)
os.chmod(public_key_path, 0o644)
return (private_key, public_key)


def _load_or_create_file_backed_keys() -> tuple[str, str]:
try:
import fcntl # noqa: F401
except ImportError as err:
if _expected_worker_count() > 1:
raise RuntimeError(
"Built-in OpenID dev keys require OPENID_PRIVATE_KEY and OPENID_PUBLIC_KEY "
"when running with multiple workers on platforms without fcntl-based file locking."
) from err
return _load_or_create_file_backed_keys_without_lock()

return _load_or_create_file_backed_keys_with_lock()


def _generate_keys() -> tuple[str, str]:
Expand Down Expand Up @@ -32,15 +115,14 @@ def _generate_keys() -> tuple[str, str]:


def _initialize_keys() -> tuple[str, str]:
"""Initialize keys from environment or generate them."""
"""Initialize keys from environment or load a shared cached keypair."""
private_key = os.getenv("OPENID_PRIVATE_KEY")
public_key = os.getenv("OPENID_PUBLIC_KEY")

if not private_key or not public_key:
# Generate keys if not provided via environment
private_key, public_key = _generate_keys()
if private_key and public_key:
return (private_key, public_key)

return private_key, public_key
return _load_or_create_file_backed_keys()
Comment on lines +122 to +125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reject partial OpenID key configuration.

If exactly one of OPENID_PRIVATE_KEY / OPENID_PUBLIC_KEY is present, this silently ignores it and falls back to the cached/generated pair. That hides config mistakes and can pin a different keypair than the deployment intended.

🛠️ Proposed fix
 def _initialize_keys() -> tuple[str, str]:
     """Initialize keys from environment or load a shared cached keypair."""
     private_key = os.getenv("OPENID_PRIVATE_KEY")
     public_key = os.getenv("OPENID_PUBLIC_KEY")

-    if private_key and public_key:
+    if private_key is not None or public_key is not None:
+        if not private_key or not public_key:
+            raise RuntimeError(
+                "OPENID_PRIVATE_KEY and OPENID_PUBLIC_KEY must either both be set to non-empty values or both be unset."
+            )
         return (private_key, public_key)

     return _load_or_create_file_backed_keys()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spiffworkflow-backend/src/spiffworkflow_backend/config/openid/rsa_keys.py`
around lines 122 - 125, The current logic silently ignores a partial
configuration of OPENID_PRIVATE_KEY / OPENID_PUBLIC_KEY by returning
cached/generated keys; update the check around private_key and public_key (the
variables populated from OPENID_PRIVATE_KEY and OPENID_PUBLIC_KEY) so that if
exactly one is present you raise an explicit error (e.g.,
ValueError/RuntimeError) describing which key is missing instead of falling back
to _load_or_create_file_backed_keys(), otherwise if both are present return the
pair as before; ensure the exception message clearly names the missing env var
to aid debugging.



class OpenIdConfigsForDevOnly:
Expand Down
2 changes: 2 additions & 0 deletions spiffworkflow-frontend/src/services/FormattingService.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import DateAndTimeService from './DateAndTimeService';
import { TimeAgo } from '../helpers/timeago';

const spiffFormatFunctions: { [key: string]: Function } = {
convert_seconds_to_date_time_for_display: DateAndTimeService.formatDateTime,
convert_seconds_to_time_ago_for_display: TimeAgo.inWords,
convert_seconds_to_duration_for_display:
Comment on lines 4 to 7
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the formatter dispatch passes string captures
rg -nP 'spiffFormatFunctions\[spiffFormat\]\(originalValue\)|const spiffFormatFunctions|convert_seconds_to_time_ago_for_display' \
  spiffworkflow-frontend/src/services/FormattingService.tsx

# Verify TimeAgo.inWords contract assumes numeric input
rg -nP 'function inWords\s*\(timeAgo:\s*number\)|const milliseconds = timeAgo \* 1000' \
  spiffworkflow-frontend/src/helpers/timeago.ts

Repository: sartography/spiff-arena

Length of output: 349


🏁 Script executed:

#!/bin/bash
# Get full context of FormattingService.tsx around the formatter dispatch
cat -n spiffworkflow-frontend/src/services/FormattingService.tsx | head -40

Repository: sartography/spiff-arena

Length of output: 1373


🏁 Script executed:

#!/bin/bash
# Check DateAndTimeService signatures for the other formatters
rg -n 'formatDateTime|formatDurationForDisplay|ymdDateStringToConfiguredFormat' \
  spiffworkflow-frontend/src/services/DateAndTimeService.tsx | head -20

Repository: sartography/spiff-arena

Length of output: 328


🏁 Script executed:

#!/bin/bash
# Get full function signatures for DateAndTimeService formatters
sed -n '99p;209p;233p' spiffworkflow-frontend/src/services/DateAndTimeService.tsx

Repository: sartography/spiff-arena

Length of output: 224


🏁 Script executed:

#!/bin/bash
# Check if TimeAgo.inWords is called anywhere else directly
rg -n 'TimeAgo\.inWords|inWords' spiffworkflow-frontend/src --type tsx --type ts | grep -v 'spiffFormatFunctions\|import'

Repository: sartography/spiff-arena

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Check if TimeAgo.inWords is called anywhere else directly (without file type filter)
rg -n 'TimeAgo\.inWords|inWords' spiffworkflow-frontend/src | grep -v 'spiffFormatFunctions\|import' | head -20

Repository: sartography/spiff-arena

Length of output: 986


🏁 Script executed:

#!/bin/bash
# Check the types in TaskTable.tsx to see what entry.created_at_in_seconds is
rg -B5 -A5 'entry\.created_at_in_seconds' spiffworkflow-frontend/src/components/TaskTable.tsx | head -20

Repository: sartography/spiff-arena

Length of output: 606


🏁 Script executed:

#!/bin/bash
# Check what type entry.created_at_in_seconds is (looking for type definitions)
rg -n 'created_at_in_seconds' spiffworkflow-frontend/src --type-list -A2 -B2 | head -30

Repository: sartography/spiff-arena

Length of output: 456


🏁 Script executed:

#!/bin/bash
# Search for type definitions or interfaces that include created_at_in_seconds
rg -n 'created_at_in_seconds' spiffworkflow-frontend/src -A1 -B1 | head -40

Repository: sartography/spiff-arena

Length of output: 2077


🏁 Script executed:

#!/bin/bash
# Search for actual usage of SPIFF_FORMAT formatter to see if convert_seconds_to_time_ago_for_display is actually used
rg -n 'SPIFF_FORMAT.*convert_seconds_to_time_ago_for_display|convert_seconds_to_time_ago_for_display' spiffworkflow-frontend

Repository: sartography/spiff-arena

Length of output: 186


Add explicit numeric coercion for the new time-ago formatter.

The convert_seconds_to_time_ago_for_display formatter has a type mismatch: checkForSpiffFormats dispatches all formatters with a string argument, but TimeAgo.inWords expects a number. While this formatter is not currently used through SPIFF_FORMAT, the mismatch should be fixed to prevent latent bugs if the formatter is used in the future. Wrap the function with explicit coercion as proposed.

Proposed fix
-const spiffFormatFunctions: { [key: string]: Function } = {
+const spiffFormatFunctions: Record<string, (value: string) => string> = {
   convert_seconds_to_date_time_for_display: DateAndTimeService.formatDateTime,
-  convert_seconds_to_time_ago_for_display: TimeAgo.inWords,
+  convert_seconds_to_time_ago_for_display: (value: string) => {
+    const seconds = Number(value);
+    if (!Number.isFinite(seconds)) {
+      console.warn(`attempted: ${value}, but value is not a valid epoch-seconds number`);
+      return value;
+    }
+    return TimeAgo.inWords(seconds);
+  },
   convert_seconds_to_duration_for_display:
     DateAndTimeService.formatDurationForDisplay,
   convert_date_to_date_for_display:
     DateAndTimeService.ymdDateStringToConfiguredFormat,
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const spiffFormatFunctions: { [key: string]: Function } = {
convert_seconds_to_date_time_for_display: DateAndTimeService.formatDateTime,
convert_seconds_to_time_ago_for_display: TimeAgo.inWords,
convert_seconds_to_duration_for_display:
const spiffFormatFunctions: Record<string, (value: string) => string> = {
convert_seconds_to_date_time_for_display: DateAndTimeService.formatDateTime,
convert_seconds_to_time_ago_for_display: (value: string) => {
const seconds = Number(value);
if (!Number.isFinite(seconds)) {
console.warn(`attempted: ${value}, but value is not a valid epoch-seconds number`);
return value;
}
return TimeAgo.inWords(seconds);
},
convert_seconds_to_duration_for_display:
DateAndTimeService.formatDurationForDisplay,
convert_date_to_date_for_display:
DateAndTimeService.ymdDateStringToConfiguredFormat,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spiffworkflow-frontend/src/services/FormattingService.tsx` around lines 4 -
7, The formatter mapping spiffFormatFunctions defines
convert_seconds_to_time_ago_for_display currently pointing directly at
TimeAgo.inWords, but checkForSpiffFormats will call formatters with a string
argument while TimeAgo.inWords expects a number; update the spiffFormatFunctions
entry for convert_seconds_to_time_ago_for_display to wrap TimeAgo.inWords in a
small adapter that coerces the incoming string to a numeric value (e.g.,
Number(arg) or parseFloat(arg)) before calling TimeAgo.inWords so the adapter
accepts the string passed by checkForSpiffFormats and forwards a number to
TimeAgo.inWords.

DateAndTimeService.formatDurationForDisplay,
convert_date_to_date_for_display:
Expand Down
Loading