Skip to content

feat: rc11 consolidation — derived system actions, terminal-error split, install.sh, guided setup#83

Merged
PaulDotterer merged 8 commits into
mainfrom
feat/rc11-consolidation
Apr 25, 2026
Merged

feat: rc11 consolidation — derived system actions, terminal-error split, install.sh, guided setup#83
PaulDotterer merged 8 commits into
mainfrom
feat/rc11-consolidation

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Bundles the four rc11 follow-ups so CodeRabbit gets one review per repo instead of four small ones.

#77 — Derived-projection system actions

Replaces the 8 scattered SyncUserSystemActions calls in user/SCIM handlers with a single store-side event listener. The listener classifies each persisted event into a SyncOp (per-user / per-cleanup / fan-out-all) via the new AffectedFromEvent function and dispatches asynchronously. A periodic reconciler (StartReconciliation, default 1m) is the safety net for events the classifier has not yet learned about, matching the existing search-indexer derived-projection pattern.

The listener spawns a goroutine per dispatch (so AppendEvent's post-commit path is never blocked) and bounds each goroutine with CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT (default 5m) so a wedged DB or signer cannot leak goroutines indefinitely. UserDeleted is deliberately NOT classified — CleanupDeletedUserActions needs the user projection loaded BEFORE the event is applied, so it stays in the handler that emits the event (with SCIM now using the same pattern via the new SystemActionsCleaner interface).

#78 — Partial-terminal-config warn

config.Validate() now returns (warnings []string, err error) and warns when only a subset of the three terminal vars is set, since silently treating that as "terminals disabled" caused operator confusion. Added TTYDomainExplicitlySet so the auto-derived URL template can be distinguished from an operator-set one.

#79gateway_not_registered error code

Splits the previous "device not connected" overloading. When LookupGatewayDial returns ErrNoGateway, the handler now probes LookupGatewayInternalURL:

  • both fail → gateway is gone (device_not_connected, transient)
  • internal URL exists, dial doesn't → gateway is alive but never published its terminal URL (gateway_not_registered, persistent misconfig — surfaces a hint to set GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE)

Paired with manchtools/power-manage-sdk and manchtools/power-manage-web PRs for the const + i18n key.

#80 — install.sh + guided setup.sh

New deploy/install.sh bootstrap installer. Refactored deploy/setup.sh with an interactive guided env loop (prompt_secret, prompt_string, prompt_yes_no), a clear_env_var helper, and a No-branch on the terminal prompt that explicitly clears GATEWAY_TTY_DOMAIN, GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE, and GATEWAY_WEB_LISTEN_ADDR on rerun (the previous cut silently no-op'd, leaving stale values from the first run). --no-prompt and non-TTY stdin preserve the CI path.

Review-round fixes already in this PR

  • Async listener (review round 1): the first cut invoked SyncAllUsersSystemActions synchronously inside the AppendEvent post-commit hook, which would have made small admin RPCs O(all-users). Now goroutine-dispatched.
  • Goroutine bounds (review round 2): context.Background() + no timeout would have leaked one goroutine per event under DB/signer wedge. Now bounded by CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT.
  • Terminal error split (review round 1): see feat: distinct error code for 'gateway terminal URL not registered' (rc11) #79 above. Earlier version returned the same device_not_connected code for both gateway-gone and gateway-alive-no-terminal-URL, hiding a config bug behind a transient-retry message.
  • setup.sh disable-clears (review round 1): see feat: bootstrap install.sh + guided setup.sh for fresh server deploys (rc11) #80 above.

Test plan

  • go build ./... clean across server / agent / sdk
  • go test ./internal/config/ — ok
  • go test -run AffectedFromEvent ./internal/api/ — 15 cases pass (every classifier branch + defensive missing-user_id)
  • go test -run TestStartTerminal_GatewayNotRegistered ./internal/api/ — 2 cases pass (persistent misconfig vs transient loss)
  • bash deploy/setup_test.sh — 6 cases pass (write_env_var add/update/preserve, clear_env_var existing/missing, disable-terminals-clears-three-vars)
  • CodeRabbit review

Closes #77, #78, #79, #80.

Summary by CodeRabbit

  • New Features

    • Optional automatic periodic system-action reconciliation with configurable interval/timeout; permission-change events now trigger async sync.
    • New installer with guided and non-interactive setup, plus a quickstart bootstrap flow.
  • Bug Fixes

    • SCIM user deletion performs best-effort cleanup of related system actions.
    • Terminal/gateway errors now return clearer, actionable hints.
  • Documentation

    • Added comprehensive quickstart and install guidance.
  • Tests

    • New tests covering event-to-sync classification and terminal gateway error cases.

…gateway_not_registered code, install.sh + guided setup.sh

Bundles four rc11 issues into one PR per the project's CodeRabbit
efficiency preference. Each section below maps to a tracking issue.

#77 — system actions as a derived projection
   System actions (pm-tty-*, USER provision, SSH access) become a
   pure projection of user / role / group / SCIM source state.
   Handlers no longer mutate the manager directly; instead, a
   post-commit event listener fires SyncUserSystemActions for the
   affected users automatically.

   * internal/store/store.go — generalises OnEventAppended into a
     listener slice via RegisterEventListener; existing search-index
     hook keeps working unchanged.
   * internal/api/system_actions_listener.go (new) — AffectedFromEvent
     classifier mapping every permission-shaping event type to its
     SyncOp (per-user, fan-out → all, deliberate no-op for
     UserDeleted). 15-case unit test locks in the contract.
   * internal/api/system_actions.go — adds StartReconciliation, the
     periodic safety net (1m default, 5m sweep timeout, atomic
     overlap guard, per-sweep context). Demotes the per-sweep
     success log from Info to Debug so the tight cadence doesn't
     spam.
   * cmd/control/main.go — registers the listener post-commit and
     starts the reconciler. Also rewires scim.NewHandler to receive
     the SystemActionManager so SCIM's user-delete path can finally
     call CleanupDeletedUserActions.
   * internal/scim/handler.go + users.go — defines a narrow
     SystemActionsCleaner interface (no api → scim cycle), threads
     it through, calls cleanup on the SCIM delete path. SCIM
     previously bypassed cleanup entirely, leaving orphan pm-tty-*
     and USER actions on every device the deleted user touched.
   * internal/api/user_handler.go — removes all 8 scattered
     SyncUserSystemActions calls (the listener covers them).

#78 — partial terminal config warn
   When an operator sets one or two of the three terminal env vars
   (GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE, GATEWAY_WEB_LISTEN_ADDR,
   GATEWAY_TTY_DOMAIN) but not all, the gateway used to silently
   skip terminal registration. Operators only discovered it when
   StartTerminal failed hours later with a generic toast. rc11
   surfaces the partial state at startup:

   * internal/config/config.go — Validate() now returns
     (warnings []string, err error). Adds TTYDomainExplicitlySet
     field tracking whether GATEWAY_TTY_DOMAIN was set in the env
     (separate from the always-falls-back-to-GATEWAY_DOMAIN
     behavior of TraefikTTYHost). New partial-config detection
     emits a warning naming the missing var(s); all-set and
     all-unset stay silent.
   * cmd/gateway/main.go — logs each warning at Warn before the
     fatal-check.
   * internal/config/config_test.go — TestValidate_PartialTerminalConfig
     covers all 7 partial-config combinations.

#79 — gateway_not_registered error code
   StartTerminal's "gateway has no terminal URL registered" path
   used to return the generic ErrInternal code. Web mapped that to
   "An unexpected error occurred" — useless. Real diagnosis:
   GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE wasn't set on the gateway.

   * internal/api/errors.go — new ErrGatewayNotRegistered.
   * internal/api/terminal_handler.go — terminal_handler.go:274
     now returns the new code with a server-side message that
     spells out the operator action. The other ErrInternal site at
     line 279 (registry unreachable) stays — that one IS a generic
     infra failure.
   * Matching const lands in sdk/ts/errors.ts (sdk PR) and
     paraglide / map entries land in web/src/lib/errors.ts (web
     PR). The errors-parity test exercises both sides.

#80 — bootstrap install.sh + guided setup.sh
   Fresh installs used to require ~10 manual env edits across
   multiple unrelated sections, each with its own footgun (URL-safe
   indexer password, distinct TTY domain, hex-only encryption
   key). rc11 ships a one-line installer:

       curl -fsSL .../install.sh | sudo bash

   * deploy/install.sh (new) — preflight (docker + compose plugin
     present, daemon reachable; does NOT install docker), download
     deploy/ tree from the chosen RELEASE_TAG (default latest-rc),
     init .env, run setup.sh, pull images, docker compose up -d,
     print summary with URLs.
   * deploy/setup.sh — adds guided mode that prompts for missing
     values, auto-generates strong defaults for every secret
     (openssl rand for postgres/valkey/JWT/encryption-key/admin
     password; -hex 32 for the URL-safe-required indexer password
     and the encryption key), validates URL-safety / hex /
     hostname constraints inline, and auto-composes the
     GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE from the chosen TTY
     domain so the operator never types {id} by hand. --no-prompt
     and non-TTY stdin preserve the existing CI-friendly path.
   * deploy/QUICKSTART.md (new) — operator-facing one-page guide.
   * deploy/.env.example — documents the two new
     CONTROL_SYSTEM_ACTION_RECONCILE_* env vars.

Tests: full server build clean. Targeted suite (config validate
matrix, system-action listener classifier, error-code parity,
existing terminal/login/registration regressions) all green.

Closes #77, #78, #79, #80.
…p.sh disable-clears

Pre-push review round 1 surfaced three issues on the rc11
consolidation commit. All three legit; fixed before push.

1. Synchronous listener turns admin RPCs into O(all-users) work
   (internal/store/store.go fan-out path, internal/api/system_actions_listener.go).

   The store fires listeners synchronously inside AppendEvent. The
   system-action listener's SyncOpSyncAll branch calls
   SyncAllUsersSystemActions on a fan-out event (RoleUpdated,
   RoleDeleted, UserGroupRoleAssigned/Revoked, UserGroupDeleted,
   ServerSettingUpdated). On a 10k-user fleet that's a multi-second
   blocking call on the request path — the operator's "save role"
   click waits for the full fleet sweep.

   Detach the entire dispatch into a goroutine. context.Background()
   rather than the AppendEvent ctx because the RPC may have already
   returned (cancelling its ctx) by the time the sync runs; we want
   the work to complete regardless. The periodic reconciler is the
   safety net if the goroutine itself dies before logging.

   Per-user paths (SyncOpSyncUser) are also async for consistency —
   the work is small per-user but inconsistent semantics across
   listener branches would have been a footgun.

2. ErrGatewayNotRegistered conflates two distinct cases
   (internal/api/terminal_handler.go).

   The previous cut returned ErrGatewayNotRegistered for both:
     a. Persistent — gateway alive, terminal URL never published
        (operator forgot GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE).
     b. Transient — gateway died between the device→gateway lookup
        and the gateway→URL lookup.
   Same code → same UI message → operator gets a config-error toast
   for what was actually a retry-and-recover situation.

   Probe the internal-URL key for the same gatewayID to distinguish:
   if internal is present, the gateway is alive and the missing
   terminal URL is persistent (case a → ErrGatewayNotRegistered with
   the "set the env" message). If the internal probe also returns
   ErrNoGateway, the gateway is gone (case b → ErrDeviceNotConnected
   with a retry-shortly message). Other probe failures fall through
   to the generic registry-unavailable path.

3. Guided setup can't disable terminals on a rerun
   (deploy/setup.sh).

   If .env already had GATEWAY_TTY_DOMAIN /
   GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE / GATEWAY_WEB_LISTEN_ADDR
   set from a prior run, answering No to "Enable remote terminal
   sessions?" only skipped writing — it didn't clear the existing
   values. Operator thought terminals were off; gateway still
   published its terminal URL on next boot.

   Add clear_env_var helper that removes a key=value line entirely.
   The No path now explicitly clears all three terminal env vars
   when present, with a log line confirming the change so the
   operator can see the disable actually took effect.

Targeted tests still green: config Validate matrix, system-action
listener classifier (15 cases), error-code parity, terminal token
single-use, login bypass regression, gateway URL validator.
The async system-action listener spawned goroutines with context.Background()
and no timeout. A wedged DB or signer would have leaked one goroutine per
event indefinitely, and a burst of role/group changes could pile up. Bound
each dispatch to a configurable timeout (default 5m, matching the periodic
reconciler) via CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT. Goroutine still uses
a fresh Background-rooted context — the originating RPC may have returned
before the sync runs, so the timeout is the only stop signal.

Tests:
- terminal_handler_test.go: split persistent-misconfig vs transient-loss
  paths (the rc11 #79 review fix). Asserts ErrGatewayNotRegistered with the
  GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE hint when the gateway is alive but
  has not published its terminal URL, and a transient retry message when
  the gateway has disappeared.
- deploy/setup_test.sh: bash-level regression suite for the rc11 #80
  review fix (clear_env_var was added so the disable-terminals branch
  actually clears all three vars on rerun, not silently no-op). Covers
  write_env_var add/update/comment-preserve, clear_env_var existing/missing,
  and the disable-terminals-clears-three-vars scenario specifically.
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@PaulDotterer has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 25 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 3 minutes and 25 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c32c4ab-20a3-485a-b556-d99a227d0443

📥 Commits

Reviewing files that changed from the base of the PR and between 713c265 and 441a882.

📒 Files selected for processing (2)
  • deploy/setup_test.sh
  • internal/api/terminal_handler.go
📝 Walkthrough

Walkthrough

Replaces direct handler-triggered system-action syncs with a post-commit store event listener and an optional periodic reconciler. Adds store listener registration, event→sync classification, listener dispatch with timeouts/semaphores/coalescing, reconciliation scheduling/guards, SCIM delete cleanup wiring, config warnings, and deploy/installer additions.

Changes

Cohort / File(s) Summary
Store listener infra
internal/store/store.go
Adds EventListener type, RegisterEventListener, and fireListeners; append methods invoke listeners post-commit with panic isolation.
System-actions listener & tests
internal/api/system_actions_listener.go, internal/api/system_actions_listener_test.go
Adds SyncOp enum, AffectedFromEvent classifier, and SystemActionListener that spawns timeout-bound, semaphore-capped goroutines with fan-out coalescing; unit tests for classifier behaviors.
Periodic reconciler
internal/api/system_actions.go
Adds StartReconciliation(ctx, interval, sweepTimeout) with ticker, atomic overlap guard, per-sweep timeout, and logging adjustments.
Control startup & config
cmd/control/main.go, cmd/gateway/main.go, internal/config/config.go, internal/config/config_test.go, deploy/.env.example
Wires SystemActionListener at boot, runs one-shot startup sync, starts optional reconciliation, introduces reconcile env/flags, changes Config.Validate() to return warnings+error, and surfaces warnings in gateway startup.
SCIM delete cleanup
internal/scim/handler.go, internal/scim/users.go, internal/scim/handler_test.go
SCIM handler accepts optional SystemActionsCleaner, reads user projection before delete and calls CleanupDeletedUserActions (best-effort) after append; tests assert cleaner invocation.
User RPC handlers
internal/api/user_handler.go
Removes direct SyncUserSystemActions calls from user-mutating handlers; synchronization delegated to post-commit listener.
Terminal/gateway error split & tests
internal/api/errors.go, internal/api/terminal_handler.go, internal/api/terminal_handler_test.go
Adds ErrGatewayNotRegistered, probes internal gateway URL to distinguish misconfiguration vs transient loss, and adds tests asserting distinct error codes and operator hints.
Deploy & installer tooling
deploy/install.sh, deploy/setup.sh, deploy/setup_test.sh, deploy/QUICKSTART.md
Adds bootstrap install.sh, interactive setup.sh with validation and --no-prompt, a test harness for env helpers, quickstart docs, and .env.example entries for reconcile interval/timeout.
Misc tests & minor UX
internal/api/system_actions.go (tests), internal/scim/*_test.go, internal/config/*_test.go
Adds/updates unit tests covering reconciliation, classifier, SCIM cleanup, and config warning behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Handler as Mutating<br/>RPC Handler
    participant Store as Store
    participant Listener as SystemAction<br/>Listener
    participant Manager as SystemAction<br/>Manager

    Handler->>Store: AppendEvent(permission-shaping)
    activate Store
    Store->>Store: commit transaction
    Store->>Store: fireListeners(ctx, event)
    deactivate Store

    activate Listener
    Note over Listener: AffectedFromEvent → (op, userIDs)\nspawn non-blocking goroutine (timeout, semaphore)
    alt SyncOpSyncUser
        Listener->>Manager: SyncUserSystemActions(ctx, userID)
    else SyncOpSyncAll
        Listener->>Manager: SyncAllUsersSystemActions(ctx)
    else SyncOpCleanupUser
        Listener->>Manager: CleanupDeletedUserActions(ctx, userID)
    end
    Manager-->>Listener: result (errors logged)
    deactivate Listener

    Handler-->>Handler: RPC returns (post-commit)
Loading
sequenceDiagram
    participant Boot as Control Server<br/>Startup
    participant Store as Store
    participant Manager as SystemAction<br/>Manager

    Boot->>Manager: SyncAllUsersSystemActions(ctx)  -- one-shot
    Manager-->>Boot: initial sync complete (Info)

    Boot->>Store: RegisterEventListener(SystemActionListener)
    Boot->>Manager: StartReconciliation(ctx, interval, timeout)
    activate Manager
    loop every interval (if interval>0)
        Manager->>Manager: if sweep in-flight -> log & skip
        Manager->>Manager: ctx_sweep := context.WithTimeout(ctx, timeout)
        Manager->>Manager: SyncAllUsersSystemActions(ctx_sweep)
        Manager->>Manager: clear in-flight guard
    end
    deactivate Manager
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hop on events when roles are flipped,

I nudge missed actions where syncs once slipped.
A listener springs, a reconciler hums,
Timeouts and guards keep the busy drums.
Carrots reconciled — a happy rabbit thumbs!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the four major changes: rc11 consolidation with derived system actions, terminal-error split, install.sh, and guided setup.
Linked Issues check ✅ Passed The PR implements all primary objectives from linked issue #77: event-listener API, AffectedFromEvent classifier, listener wiring at boot, handler-side sync removal, periodic reconciliation with timeout/interval config, and logging demotions.
Out of Scope Changes check ✅ Passed All changes align with the stated objectives: system-action listener infrastructure (#77), config validation warnings (#78), terminal error splitting (#79), and deployment scripts (#80). No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rc11-consolidation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@PaulDotterer

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (8)
internal/store/store.go (1)

35-39: Document the ctx lifetime contract for listeners.

The ctx passed to listeners is the caller's request context, which gets canceled as soon as AppendEvent returns. Listeners that do anything beyond a quick in-memory dispatch (like SystemActionListener, which spawns a goroutine with context.Background()) need to be aware of this. Worth a one-liner on the EventListener doc to spell it out so future listener authors don't accidentally use the passed ctx for downstream DB calls and get surprising cancellations.

 // EventListener is a post-commit hook fired after a successful
 // AppendEvent. Listeners are invoked synchronously in registration
 // order; a slow listener will stall subsequent listeners on the same
-// event. None are allowed to mutate the event row.
+// event. None are allowed to mutate the event row.
+//
+// The ctx passed in is the caller's request context and will be
+// cancelled when AppendEvent returns; listeners that perform
+// asynchronous work must derive their own context (typically
+// context.Background with an explicit timeout).
 type EventListener func(ctx context.Context, ev PersistedEvent)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/store/store.go` around lines 35 - 39, Update the EventListener doc
comment to state the ctx lifetime contract: note that the ctx passed to
EventListener is the caller's request context and will be canceled as soon as
AppendEvent returns, so listeners must not rely on it for long-running work or
downstream DB calls; for background work (e.g. in SystemActionListener which
spawns goroutines) create a new context (context.Background() or a derived
context with its own cancellation) and avoid mutating the PersistedEvent.
deploy/install.sh (2)

103-103: Use single quotes for the trap body (Shellcheck SC2064).

$tmpdir happens to be set on the previous line and never reassigned, so the current double-quoted form works, but defensively single-quoting expands the variable at trap-fire time, which is the intent for a cleanup hook and what Shellcheck flags here.

-    trap "rm -rf '$tmpdir'" EXIT
+    trap 'rm -rf "$tmpdir"' EXIT
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deploy/install.sh` at line 103, The trap currently uses double quotes so
$tmpdir is expanded immediately; change the trap invocation to use single quotes
for the body (trap 'rm -rf "$tmpdir"' EXIT) so the tmpdir variable is expanded
at trap execution time; update the trap call where tmpdir is set and referenced
(the trap line referencing tmpdir) to use single-quoted body to satisfy
Shellcheck SC2064 and ensure correct cleanup behavior.

228-228: Simplify the logs command to not require path qualification.

The docker compose command automatically detects compose.yml or other standard compose filenames in the current directory. Since the script already uses cd "$INSTALL_DIR" elsewhere, this command can be simplified:

-    echo "  Logs:          docker compose -f $INSTALL_DIR/compose.yml logs -f"
+    echo "  Logs:          (cd $INSTALL_DIR && docker compose logs -f)"

This makes the command clearer and more robust to future changes in the compose filename.

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

In `@deploy/install.sh` at line 228, Update the echo that prints the logs command
to remove the path-qualified compose file; replace the current message that
references "docker compose -f $INSTALL_DIR/compose.yml logs -f" with a
simplified "docker compose logs -f" since the script already cds into
INSTALL_DIR and docker compose will auto-detect compose.yml (reference the echo
line that prints the Logs command and the INSTALL_DIR/compose.yml filename).
cmd/control/main.go (1)

780-784: Guard against SystemActionReconcileTimeout=0 to avoid an already-cancelled per-sweep context.

The default is 5*time.Minute, but operators can set CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT=0 (or a negative duration) via env. Unlike SystemActionListener, which falls back to defaultListenerSyncTimeout when syncTimeout <= 0, SystemActionManager.StartReconciliation passes the value straight to context.WithTimeout(ctx, sweepTimeout) — a 0 timeout produces an immediately-expired context, so every sweep would fail with context deadline exceeded and only the listener path would actually do work. Same concern for SystemActionReconcileInterval: a negative value would panic in time.NewTicker (only <= 0 is checked as "disabled" in StartReconciliation, treating negative as disabled — verify this matches the doc string).

Consider clamping these to sane minimums (or rejecting them) in parseFlags, the same way DynamicGroupEvalInterval is bounded a few lines below.

♻️ Proposed clamp
+	// rc11 `#77`: a 0 sweep timeout would make context.WithTimeout return an
+	// already-cancelled context on every tick. Clamp to the default rather
+	// than silently breaking the reconciler.
+	if cfg.SystemActionReconcileInterval < 0 {
+		cfg.SystemActionReconcileInterval = 0 // disabled
+	}
+	if cfg.SystemActionReconcileTimeout <= 0 {
+		cfg.SystemActionReconcileTimeout = 5 * time.Minute
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/control/main.go` around lines 780 - 784, Clamp user-provided
system-action reconcile flags in parseFlags: if cfg.SystemActionReconcileTimeout
<= 0 set it to a sane minimum (e.g. the same fallback used by the listener or a
small positive duration) and if cfg.SystemActionReconcileInterval <= 0 treat it
as disabled consistently (or clamp to a minimum >0 if you prefer enabling); this
prevents passing a zero/negative sweepTimeout into
SystemActionManager.StartReconciliation (which calls context.WithTimeout and
produces an immediately-canceled context) and matches the bounding behavior used
for DynamicGroupEvalInterval.
deploy/setup.sh (1)

314-330: Use a same-directory temp file for atomic .env rewrite.

The comment promises an atomic update, but mktemp defaults to $TMPDIR (typically /tmp), which is often a separate mount from $SCRIPT_DIR. When that's the case, mv "$tmp" "$envfile" falls back to a non-atomic copy-then-unlink and also overwrites the file with the temp file's permissions/ownership rather than preserving .env's.

Creating the temp inside SCRIPT_DIR keeps mv as a same-filesystem rename(2) (atomic POSIX-wise) and lets you copy mode/owner from the original first. Same fix applies to clear_env_var.

♻️ Proposed fix
 write_env_var() {
     local key="$1" value="$2" envfile="$SCRIPT_DIR/.env"
     if grep -qE "^${key}=" "$envfile"; then
-        # Use a non-/ delimiter for sed because values frequently contain /
         local tmp
-        tmp="$(mktemp)"
+        tmp="$(mktemp "${envfile}.XXXXXX")"
         awk -v k="$key" -v v="$value" '
             BEGIN { found = 0 }
             $0 ~ "^"k"=" { print k"="v; found = 1; next }
             { print }
             END { if (!found) print k"="v }
         ' "$envfile" > "$tmp"
+        # Preserve original mode so .env stays 0600 across rewrites.
+        chmod --reference="$envfile" "$tmp" 2>/dev/null || chmod 600 "$tmp"
         mv "$tmp" "$envfile"
     else
         printf '%s=%s\n' "$key" "$value" >> "$envfile"
     fi
 }
internal/api/system_actions_listener.go (3)

56-73: Minor: fall back to StreamID parsing when event.data["user_id"] is missing.

For UserRoleAssigned/UserRoleRevoked and UserGroupMemberAdded/UserGroupMemberRemoved, the classifier returns SyncOpNone when the data payload is missing or malformed. The comment notes this is intentional ("read the data payload to avoid parsing the colon-joined StreamID"), and the periodic reconciler is a safety net — so this is correct.

Worth considering, though: if a future emitter regresses on the data-payload contract, the listener silently no-ops for up to one reconcile interval (default 1m). For the role case, StreamID is user_id:role_id and a strings.Cut(e.StreamID, ":") fallback would give the same answer at no extra cost and tighten the failure mode to be self-correcting. Same idea isn't possible for group membership since the stream is the group, not the user.

Not blocking — flagging in case it's worth the defensive fallback for the role case.

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

In `@internal/api/system_actions_listener.go` around lines 56 - 73, The handler
currently returns SyncOpNone when userIDFromEventData(e) is empty for
"UserRoleAssigned"/"UserRoleRevoked"; add a defensive fallback that parses the
event StreamID (which is "user_id:role_id") to extract the user id using
strings.Cut(e.StreamID, ":") and, if that yields a non-empty uid, return
SyncOpSyncUser, []string{uid} instead of SyncOpNone; keep the group-member cases
unchanged (no fallback) and reference the case labels, userIDFromEventData,
e.StreamID, and SyncOpSyncUser when making the change.

150-193: Unbounded goroutine spawn + no overlap guard for fan-out events.

The listener intentionally drops AppendEvent's context and spawns a fresh goroutine per event (good), but there's no upper bound on concurrency. Two real-world scenarios this matters for:

  1. Burst of per-user events. A bulk role assignment / SCIM sync that emits, say, 5k UserRoleAssigned events spawns 5k concurrent SyncUserSystemActions calls — each of which holds a DB connection and exercises the action signer. Easy to exhaust the pgx pool or saturate the signer well below DB limits.
  2. Repeated fan-out events. Multiple RoleUpdated / ServerSettingUpdated events in quick succession each kick off a full SyncAllUsersSystemActions, all running in parallel. The periodic reconciler uses an atomic.Bool to prevent overlapping sweeps (per system_actions.go:83-115); the listener's fan-out path has no such guard, so N admin clicks → N concurrent O(all-users) sweeps stepping on each other.

Recommend (a) a buffered worker pool (or errgroup with SetLimit) so the listener back-pressures rather than fans out unbounded, and (b) reusing the reconciler's "running" flag for the SyncOpSyncAll path so consecutive fan-out events coalesce instead of stack.

♻️ Sketch (worker pool + coalesce SyncAll)
// At construction time:
sem := make(chan struct{}, 16)        // bounded concurrency
var syncAllRunning atomic.Bool        // coalesce fan-outs

return func(_ context.Context, e store.PersistedEvent) {
    op, userIDs := AffectedFromEvent(e)
    if op == SyncOpNone {
        return
    }
    if op == SyncOpSyncAll && !syncAllRunning.CompareAndSwap(false, true) {
        // a sweep is already in flight; this event will be picked up by it
        // (or by the next reconciler tick).
        return
    }
    select {
    case sem <- struct{}{}:
    default:
        logger.Warn("system-action listener: backpressure, dropping event (reconciler will catch up)",
            "event_type", e.EventType, "event_id", e.ID)
        if op == SyncOpSyncAll {
            syncAllRunning.Store(false)
        }
        return
    }
    go func() {
        defer func() { <-sem }()
        if op == SyncOpSyncAll {
            defer syncAllRunning.Store(false)
        }
        // ... existing dispatch ...
    }()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/system_actions_listener.go` around lines 150 - 193,
SystemActionListener currently spawns an unbounded goroutine per event (via the
anon goroutine in SystemActionListener after AffectedFromEvent), which can
exhaust DB/signing resources for many SyncUserSystemActions or run multiple
SyncAllUsersSystemActions concurrently; change it to use a bounded concurrency
semaphore/worker pool (e.g., a buffered channel or errgroup.SetLimit) to limit
concurrent goroutines and apply backpressure (log+drop when pool is full), and
reuse the reconciler's running flag (atomic.Bool) for the SyncOpSyncAll path so
SyncAllUsersSystemActions coalesces (CompareAndSwap to avoid starting another
sweep and ensure the flag is cleared after the sweep completes); ensure the
semaphore slot is always released and the syncAll flag is cleared in all return
paths so no leaks occur.

154-162: Use context.WithoutCancel to preserve request ID correlation in async operations.

The Background-rooted goroutine context correctly drops cancellation after the RPC returns, but it also discards the request ID stored in the AppendEvent context via the RequestID middleware. This breaks observability—the "sync user failed" / "sync all users failed" error logs cannot be correlated to the RPC that triggered the event.

Since the project targets Go 1.25 (well above the 1.21 requirement), context.WithoutCancel(parent) is available and stable. It preserves context values while detaching from cancellation, which matches the intent here.

♻️ Suggested change
-    return func(_ context.Context, e store.PersistedEvent) {
+    return func(parent context.Context, e store.PersistedEvent) {
         op, userIDs := AffectedFromEvent(e)
         if op == SyncOpNone {
             return
         }
 
         go func() {
-            ctx, cancel := context.WithTimeout(context.Background(), syncTimeout)
+            // Preserve request-id and other context values from the AppendEvent ctx but
+            // detach from its cancellation — RPC has already returned.
+            ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), syncTimeout)
             defer cancel()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/system_actions_listener.go` around lines 154 - 162, The
goroutine currently uses context.Background() which drops request-scoped values
(like RequestID); rename the ignored parameter "_" to "ctx" in the outer handler
func signature and replace context.Background() with context.WithoutCancel(ctx)
when creating the goroutine's ctx (still use context.WithTimeout and defer
cancel as before) so the async work is detached from cancellation but preserves
request values for logging in functions like AffectedFromEvent and the
subsequent sync error logs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@deploy/install.sh`:
- Around line 139-148: After extracting the tarball with tar -xzf "$tarball" ...
'*/deploy/*', add a verification step that at least one known-required file from
the deploy payload (for example the expected setup script like setup.sh or a
marker file) exists in "$INSTALL_DIR" (or the extracted deploy subdir); if the
file is missing, call the existing logging function (e.g., log_error) with a
clear message that the tarball did not contain the deploy/ payload and exit
nonzero (exit 1) to prevent continuing into restore/copy and running setup.sh
against an empty directory.

In `@deploy/QUICKSTART.md`:
- Around line 22-55: The docs show env var prefixes that only apply to the local
curl process and won't be seen by sudo; update examples to pass the vars to the
command run under sudo (or export + sudo -E). Replace the lines using
"RELEASE_TAG=... curl ... | sudo bash" and "RELEASE_TAG=... sudo bash
/opt/power-manage/install.sh" with working forms such as "curl -fsSL ... | sudo
RELEASE_TAG=v2026.05 bash" and "sudo RELEASE_TAG=v2026.06 bash
/opt/power-manage/install.sh", and mention the alternative "export NO_PROMPT=1
&& sudo -E bash ..." if you prefer preserving the environment; reference the
RELEASE_TAG, NO_PROMPT, INSTALL_DIR and install.sh usages so the installer
receives the variables.

In `@deploy/setup_test.sh`:
- Around line 71-83: The current pattern runs the test in a subshell (...) and
then inspects $? which dead-ends under set -e; change the control flow to
evaluate the subshell directly in the if condition so the command's exit is
handled by the if instead of triggering set -e; specifically replace the "( ...
)" + subsequent "if [[ $? -eq 0 ]]" logic with "if ( <run test command> ); then"
and move the PASS_COUNT/FAIL_COUNT increments into the corresponding then/else
branches (referencing the existing subshell invocation and the PASS_COUNT and
FAIL_COUNT variables).
- Line 17: The variable SCRIPT_DIR_REAL is defined but never used; either remove
the assignment to SCRIPT_DIR_REAL or use it where appropriate (for example
reference SCRIPT_DIR_REAL in a debug/log echo to show the script invocation
directory or replace other dirname "${BASH_SOURCE[0]}" uses with
SCRIPT_DIR_REAL). Locate the assignment to SCRIPT_DIR_REAL and either delete
that line or wire the variable into an existing logging/debug statement so the
value is actually read (ensuring no other logic relies on a different variable
like SCRIPT_DIR).

In `@deploy/setup.sh`:
- Line 428: The current computation of default_tty using
default_tty="tty.${CONTROL_DOMAIN#*.}" (and the similar admin default
admin@${CONTROL_DOMAIN#*.}) misbehaves when CONTROL_DOMAIN has no dot because
${CONTROL_DOMAIN#*.} yields the whole string; update the logic to detect whether
CONTROL_DOMAIN contains a dot (e.g., test with a shell pattern) and only strip
the left-most label when a dot exists, otherwise fall back to a safer value
(empty suffix or a sensible default) before constructing default_tty and the
admin email; adjust the assignments where default_tty and the admin email
default are set (referencing default_tty, CONTROL_DOMAIN, and the admin email
default occurrence) so single-label CONTROL_DOMAIN produces a graceful fallback
instead of embedding the whole hostname.

In `@internal/api/system_actions.go`:
- Around line 83-112: The StartReconciliation loop currently uses
context.WithTimeout(ctx, sweepTimeout) without checking sweepTimeout, so a
non‑positive sweepTimeout creates an already-canceled context and prevents
SyncAllUsersSystemActions from running; update StartReconciliation to validate
and clamp sweepTimeout (e.g. if sweepTimeout <= 0 set it to a sensible default
or minimum like time.Second or a configured fallback) before starting the
goroutine so that context.WithTimeout always receives a positive duration, and
keep all existing uses of running, ticker and SyncAllUsersSystemActions
unchanged.

In `@internal/store/store.go`:
- Around line 75-82: fireListeners currently calls OnEventAppended and each
entry in listeners synchronously so any panic in a listener bubbles up and can
fail AppendEvent/AppendEventWithVersion; change fireListeners to invoke the
legacy OnEventAppended and each listener in separate goroutines
(fire-and-forget) and wrap each goroutine's call in a defer/recover to catch and
log panics without propagating them, passing the same ctx and row values (avoid
mutating row) so post-commit notifications are non-blocking and isolated per the
EventListener design.

---

Nitpick comments:
In `@cmd/control/main.go`:
- Around line 780-784: Clamp user-provided system-action reconcile flags in
parseFlags: if cfg.SystemActionReconcileTimeout <= 0 set it to a sane minimum
(e.g. the same fallback used by the listener or a small positive duration) and
if cfg.SystemActionReconcileInterval <= 0 treat it as disabled consistently (or
clamp to a minimum >0 if you prefer enabling); this prevents passing a
zero/negative sweepTimeout into SystemActionManager.StartReconciliation (which
calls context.WithTimeout and produces an immediately-canceled context) and
matches the bounding behavior used for DynamicGroupEvalInterval.

In `@deploy/install.sh`:
- Line 103: The trap currently uses double quotes so $tmpdir is expanded
immediately; change the trap invocation to use single quotes for the body (trap
'rm -rf "$tmpdir"' EXIT) so the tmpdir variable is expanded at trap execution
time; update the trap call where tmpdir is set and referenced (the trap line
referencing tmpdir) to use single-quoted body to satisfy Shellcheck SC2064 and
ensure correct cleanup behavior.
- Line 228: Update the echo that prints the logs command to remove the
path-qualified compose file; replace the current message that references "docker
compose -f $INSTALL_DIR/compose.yml logs -f" with a simplified "docker compose
logs -f" since the script already cds into INSTALL_DIR and docker compose will
auto-detect compose.yml (reference the echo line that prints the Logs command
and the INSTALL_DIR/compose.yml filename).

In `@internal/api/system_actions_listener.go`:
- Around line 56-73: The handler currently returns SyncOpNone when
userIDFromEventData(e) is empty for "UserRoleAssigned"/"UserRoleRevoked"; add a
defensive fallback that parses the event StreamID (which is "user_id:role_id")
to extract the user id using strings.Cut(e.StreamID, ":") and, if that yields a
non-empty uid, return SyncOpSyncUser, []string{uid} instead of SyncOpNone; keep
the group-member cases unchanged (no fallback) and reference the case labels,
userIDFromEventData, e.StreamID, and SyncOpSyncUser when making the change.
- Around line 150-193: SystemActionListener currently spawns an unbounded
goroutine per event (via the anon goroutine in SystemActionListener after
AffectedFromEvent), which can exhaust DB/signing resources for many
SyncUserSystemActions or run multiple SyncAllUsersSystemActions concurrently;
change it to use a bounded concurrency semaphore/worker pool (e.g., a buffered
channel or errgroup.SetLimit) to limit concurrent goroutines and apply
backpressure (log+drop when pool is full), and reuse the reconciler's running
flag (atomic.Bool) for the SyncOpSyncAll path so SyncAllUsersSystemActions
coalesces (CompareAndSwap to avoid starting another sweep and ensure the flag is
cleared after the sweep completes); ensure the semaphore slot is always released
and the syncAll flag is cleared in all return paths so no leaks occur.
- Around line 154-162: The goroutine currently uses context.Background() which
drops request-scoped values (like RequestID); rename the ignored parameter "_"
to "ctx" in the outer handler func signature and replace context.Background()
with context.WithoutCancel(ctx) when creating the goroutine's ctx (still use
context.WithTimeout and defer cancel as before) so the async work is detached
from cancellation but preserves request values for logging in functions like
AffectedFromEvent and the subsequent sync error logs.

In `@internal/store/store.go`:
- Around line 35-39: Update the EventListener doc comment to state the ctx
lifetime contract: note that the ctx passed to EventListener is the caller's
request context and will be canceled as soon as AppendEvent returns, so
listeners must not rely on it for long-running work or downstream DB calls; for
background work (e.g. in SystemActionListener which spawns goroutines) create a
new context (context.Background() or a derived context with its own
cancellation) and avoid mutating the PersistedEvent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e87c706c-fd81-4c46-89d9-8cc5042acb4f

📥 Commits

Reviewing files that changed from the base of the PR and between 26accec and cf87a76.

📒 Files selected for processing (20)
  • cmd/control/main.go
  • cmd/gateway/main.go
  • deploy/.env.example
  • deploy/QUICKSTART.md
  • deploy/install.sh
  • deploy/setup.sh
  • deploy/setup_test.sh
  • internal/api/errors.go
  • internal/api/system_actions.go
  • internal/api/system_actions_listener.go
  • internal/api/system_actions_listener_test.go
  • internal/api/terminal_handler.go
  • internal/api/terminal_handler_test.go
  • internal/api/user_handler.go
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/scim/handler.go
  • internal/scim/handler_test.go
  • internal/scim/users.go
  • internal/store/store.go

Comment thread deploy/install.sh
Comment thread deploy/QUICKSTART.md
Comment thread deploy/setup_test.sh Outdated
Comment thread deploy/setup_test.sh
Comment thread deploy/setup.sh Outdated
Comment thread internal/api/system_actions.go
CodeRabbit's full review caught a critical test-harness bug, two major
findings, and several smaller ones. Bundling all fixes here so the next
review round only has to cover the remaining concurrency design.

Critical
--------
- deploy/setup_test.sh: the run_case wrapper used `( ... ); if [[ $? -eq 0 ]]`
  under `set -euo pipefail`. Bash's set-e kills the parent on the
  subshell's non-zero exit *before* $? is read, so the suite would have
  bailed mid-run on the first real failure and FAIL_COUNT / the summary
  line were unreachable. Switched to `if (...); then` (the idiomatic
  shell pattern that suspends set-e for the conditional). Added a
  synthetic always-failing case + tally adjustment so the FAIL path is
  exercised on every run — proves the harness behaves as documented.

Major
-----
- internal/store/store.go: fireListeners invoked listeners synchronously
  with no recover(). A panic in any listener (or the legacy
  OnEventAppended hook) propagated up through AppendEvent and failed
  the RPC even though the event was already committed — broke the
  "event committed → RPC succeeds" contract the CQRS layer relies on.
  Wrapped each invocation in a per-listener defer/recover that logs to
  stderr; documented the ctx-lifetime + panic-isolation contract on
  EventListener.

- deploy/QUICKSTART.md: the documented form
  `RELEASE_TAG=v2026.05 curl … | sudo bash` does NOT pass the variable
  through. sudo's env_reset strips the var from the environment of the
  process it spawns, so the prefix only scoped to `curl` (which doesn't
  read RELEASE_TAG). Operators copy-pasting silently got `latest-rc`
  with no error. Rewrote all four examples to the working form
  `curl … | sudo VAR=… bash` and added a one-line note explaining
  why the placement matters.

Minor
-----
- deploy/install.sh: tar `--wildcards '*/deploy/*'` exits 0 on empty
  match (e.g. a future tag that renames deploy/). Added post-extract
  sanity check on setup.sh + .env.example — fails loudly instead of
  letting the operator chase a confusing "setup.sh: command not found"
  later. Also fixed trap quoting (SC2064) so $tmpdir expands at
  trap-fire time.

- deploy/setup.sh: write_env_var/clear_env_var used `mktemp` (defaults
  to $TMPDIR), so `mv` fell back to non-atomic copy-then-unlink when
  /tmp is a separate mount, *and* overwrote .env's mode/owner with the
  temp file's. Switched to `mktemp "${envfile}.XXXXXX"` for a same-fs
  rename(2), and copy mode via `chmod --reference="$envfile"` so .env
  stays 0600 across re-runs.

- internal/api/system_actions.go: StartReconciliation passed
  sweepTimeout straight to context.WithTimeout. A 0/negative timeout
  produced an already-cancelled context every tick — the durability
  safety net silently failed every interval. Added an in-function
  clamp that falls back to interval-as-ceiling, defending in depth on
  top of the parseFlags clamp.

- cmd/control/main.go: clamp SystemActionReconcileTimeout / Interval
  in parseFlags (mirroring DynamicGroupEvalInterval). Negative interval
  → disabled; non-positive timeout → 5m default.

Listener concurrency (rc11 #77 round 3)
---------------------------------------
- internal/api/system_actions_listener.go:
  - Switched goroutine ctx from context.Background() to
    context.WithoutCancel(parent) so request-id and other request-
    scoped values flow into "sync user/all failed" error logs and
    correlate back to the triggering RPC.
  - Bounded the in-flight goroutine count via a 16-slot semaphore.
    Without this, a SCIM bulk role assignment emitting thousands of
    UserRoleAssigned events would have spawned thousands of concurrent
    SyncUserSystemActions calls, saturating the pgx pool / signer.
    Over-cap events are dropped + logged; the periodic reconciler
    catches up within one interval, which is the right back-pressure
    trade-off.
  - Added an atomic.Bool to coalesce SyncOpSyncAll dispatches. Multiple
    consecutive RoleUpdated / ServerSettingUpdated events used to stack
    N concurrent O(all-users) sweeps stepping on each other; now
    subsequent fan-outs return early while one is in flight, matching
    the reconciler's tick-coalesce pattern.
  - Added a defensive StreamID-prefix fallback for UserRoleAssigned/
    UserRoleRevoked when event.data["user_id"] is missing (the stream
    id is `user_id:role_id`, so strings.Cut gives the same answer at
    no cost). Group-membership cases keep no fallback because the
    stream id is the group, not the user.

Tests
-----
- deploy/setup_test.sh: 7 cases now (6 real + 1 synthetic FAIL).
- internal/api/system_actions_listener_test.go: replaced the
  "missing user_id → SyncOpNone" assertion (no longer holds) with
  three cases covering the new fallback semantics (data-missing →
  StreamID prefix; no colon in StreamID → SyncOpNone; group-member
  with missing data still → SyncOpNone).
@PaulDotterer

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (12)
internal/api/terminal_handler_test.go (1)

388-390: Optional: assert on the structured error code rather than message substring.

Both tests assert the operator-facing message text via assert.Contains(connectErr.Error(), ...), which couples them to the exact wording. The handler attaches a structured pm.ErrorDetail{Code: ...} via apiErrorCtx, so the persistent case can assert Code == ErrGatewayNotRegistered and the transient case Code == ErrDeviceNotConnected directly — that's the actual contract the SDK/web client switches on, and it survives copy edits to the human-readable message. The substring check is still useful as a secondary assertion (the env-var name is the actionable hint), but the code assertion should be primary.

♻️ Sketch of structured-detail assertion
// Pull the first ErrorDetail and unmarshal into pm.ErrorDetail.
require.NotEmpty(t, connectErr.Details())
var detail pm.ErrorDetail
_, err = connectErr.Details()[0].Value(&detail)
require.NoError(t, err)
assert.Equal(t, api.ErrGatewayNotRegistered, detail.Code)

Also applies to: 432-435

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

In `@internal/api/terminal_handler_test.go` around lines 388 - 390, The test
should assert the structured error code from the gRPC Connect error instead of
only checking the human message: pull connectErr.Details(), require it's
non-empty, unmarshal the first detail into a pm.ErrorDetail and assert
detail.Code equals the expected constant (use api.ErrGatewayNotRegistered for
the persistent-misconfig case and api.ErrDeviceNotConnected for the transient
case), and optionally keep the existing assert.Contains(connectErr.Error(),
"GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE") as a secondary, actionable hint check;
locate these changes around the existing connectErr assertions in the test
(symbols: connectErr, pm.ErrorDetail, api.ErrGatewayNotRegistered,
api.ErrDeviceNotConnected).
internal/scim/handler_test.go (1)

36-36: No test exercises the SCIM-delete cleanup path.

Every test passes nil here, so the rc11 #77 fix — "SCIM was previously bypassing the system-actions cleanup entirely on user deletion" — has no regression test. A trivial fake implementing SystemActionsCleaner would let TestDeleteUser_Success (line 302) assert that CleanupDeletedUserActions is actually invoked with the loaded user projection when the last identity link is removed, and conversely that it is not invoked when other links remain. That's the exact regression the PR is meant to prevent.

🔧 Sketch
type fakeCleaner struct {
    calls []db.UsersProjection
}
func (f *fakeCleaner) CleanupDeletedUserActions(_ context.Context, u db.UsersProjection) error {
    f.calls = append(f.calls, u)
    return nil
}
// In a new test: setupSCIM wires &fakeCleaner{}, exercise DELETE on a single-link user,
// assert len(fake.calls) == 1 and fake.calls[0].ID == userID.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/scim/handler_test.go` at line 36, Tests never exercise the SCIM
delete cleanup path because NewHandler is constructed with nil systemActions;
add a fake SystemActionsCleaner to the SCIM handler tests and assert its
CleanupDeletedUserActions is called only when the last identity link is removed.
Implement a minimal fake type that records calls and inject it into
scim.NewHandler in TestDeleteUser_Success (and add a complementary case where
multiple links remain), then after performing the DELETE request assert the
fake's calls length and that the recorded db.UsersProjection.ID equals the
deleted user ID; ensure the fake implements CleanupDeletedUserActions(ctx
context.Context, u db.UsersProjection) error and returns nil so it can be used
in setupSCIM wiring.
deploy/setup_test.sh (1)

47-75: Inlined helpers diverge from setup.sh — chmod preservation is silently untested.

The comment at lines 44–46 claims these tests "would catch a divergence as a FAIL anyway," but the inlined write_env_var/clear_env_var here drop the chmod --reference="$envfile" "$tmp" 2>/dev/null || chmod 600 "$tmp" lines that the canonical deploy/setup.sh versions (lines 322–338, 345–360) use to preserve the .env file's permissions across the atomic-replace. That permission preservation is exactly the kind of subtle regression a smoke test should pin: a future edit to setup.sh that breaks the chmod path would still pass this harness.

Either copy the chmod lines into the inlined helpers and add an assertion that the resulting .env mode survives the round-trip, or source setup.sh with a guard to skip main so the test exercises the real implementation. The inline-and-hope-they-stay-in-sync approach is a maintenance footgun worth flagging.

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

In `@deploy/setup_test.sh` around lines 47 - 75, The inlined helpers write_env_var
and clear_env_var drop the chmod preservation used in deploy/setup.sh so add the
missing atomic-replace permission-preservation logic (i.e., after writing to the
temp file run the same chmod --reference="$envfile" "$tmp" 2>/dev/null || chmod
600 "$tmp" before mv "$tmp" "$envfile") and add an assertion in the test that
the .env file mode is preserved across the round-trip; alternatively replace the
inline helpers by sourcing deploy/setup.sh with a guard to avoid running main so
the test exercises the real write_env_var and clear_env_var implementations
instead of duplicating them.
deploy/setup.sh (2)

362-378: Secret prompt echoes typed input — consider read -s for the No-path.

When the operator answers "n" to the auto-generate prompt, the manually typed secret (Postgres password, JWT secret, encryption key, admin password) is echoed to the terminal. On a shared/recorded session this leaks the freshly-typed secret. The auto-generate path doesn't have this issue because the value never traverses stdin.

🔒 Suggested change
     else
-        read -r -p "    Enter value: " REPLY_VALUE
+        read -r -s -p "    Enter value: " REPLY_VALUE
+        echo
     fi

(echo after read -s adds the newline that -s suppresses.)

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

In `@deploy/setup.sh` around lines 362 - 378, The prompt_secret function currently
uses read -r to accept manual secret input which echoes the secret; change the
"no" branch so it uses a silent read (read -s) to set REPLY_VALUE when the user
opts to enter a value, then echo a newline after the silent read to restore
prompt formatting; keep the existing placeholder check (is_placeholder),
generation path (eval "$gen_cmd") and assignments to REPLY_VALUE unchanged, and
ensure the prompt text remains the same so behavior for generation vs manual
entry is preserved.

540-556: Unknown flags are silently ignored.

The case has no fall-through to error on unrecognized arguments, so ./setup.sh --noprompt (typo) silently runs guided mode. Minor robustness gap.

📝 Suggested fix
     case "$arg" in
         --no-prompt) NO_PROMPT=1 ;;
         -h|--help)
             ...
             ;;
+        *)
+            log_error "Unknown argument: $arg"
+            log_error "  See: $0 --help"
+            exit 2
+            ;;
     esac
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deploy/setup.sh` around lines 540 - 556, The script currently iterates over
arguments (for arg in "$@") and branches via the case statement but ignores
unknown flags; update the case in setup.sh to add a default branch (e.g., *)
that prints a clear error mentioning the unrecognized argument ($arg), shows
usage or hint, and exits non-zero; reference the existing NO_PROMPT variable and
the --no-prompt / -h|--help patterns so the new default handler rejects typos
like --noprompt and prevents silent acceptance.
internal/api/system_actions_listener_test.go (2)

236-246: Consider slices.Equal from the standard library.

Go 1.21+ ships slices.Equal which is functionally identical to equalStringSlices. Replacing the helper trims the test file slightly. Purely cosmetic; ignore if you prefer the explicit helper for readability.

-import (
-	"encoding/json"
-	"testing"
+import (
+	"encoding/json"
+	"slices"
+	"testing"

-			if !equalStringSlices(gotUsers, tc.wantUsers) {
+			if !slices.Equal(gotUsers, tc.wantUsers) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/system_actions_listener_test.go` around lines 236 - 246, Replace
the custom equalStringSlices helper with the standard library helper
slices.Equal: remove the equalStringSlices function and call slices.Equal(a, b)
where it was used; add the "slices" import to the test file (Go 1.21+) so the
code compiles, and ensure any references to equalStringSlices are updated to
slices.Equal.

24-225: Solid coverage; consider adding the remaining per-user/fan-out event types as a regression net.

The 17 cases cover all four SyncOp outcomes plus the StreamID-fallback edge cases for user_role and the no-fallback case for user_group. Several event types in AffectedFromEvent's switch (e.g. UserEnabled, UserProvisioningSettingsUpdated, UserSshSettingsUpdated, UserProfileUpdated, UserSshKeyRemoved, UserEmailChanged, UserGroupMemberRemoved, UserGroupRoleRevoked, UserGroupQueryUpdated) share a switch case with one already tested, so they're covered transitively today — but a future refactor that promotes one of them to a separate case (or accidentally drops one from the case list) wouldn't trip a test.

Optional: a tiny table-driven loop that asserts each event-type literal individually would lock that contract down without much extra code.

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

In `@internal/api/system_actions_listener_test.go` around lines 24 - 225,
TestAffectedFromEvent lacks explicit single-event tests for many event-type
literals handled by AffectedFromEvent, risking regressions if cases are
refactored; add a small table-driven subtest inside TestAffectedFromEvent that
iterates over the remaining event-type strings (e.g.
"UserEnabled","UserProvisioningSettingsUpdated","UserSshSettingsUpdated","UserProfileUpdated","UserSshKeyRemoved","UserEmailChanged","UserGroupMemberRemoved","UserGroupRoleRevoked","UserGroupQueryUpdated",
etc.) and for each constructs a minimal store.PersistedEvent and asserts
AffectedFromEvent returns the expected SyncOp (SyncOpSyncUser, SyncOpSyncAll or
SyncOpNone) and expected users where applicable; reference the existing
AffectedFromEvent call and SyncOp constants in the new loop so the test locks
the mapping of event-type literals to SyncOp outcomes.
internal/store/store.go (1)

36-83: Listener API is well-documented; the "not safe for concurrent registration" contract is fragile though.

The documented lifecycle (register at boot, then start serving) works for the current single-call-site wiring in cmd/control/main.go, but it's an implicit invariant the Go race detector won't catch if a future caller registers after AppendEvent is in flight (concurrent slice append + range read is a data race). Two practical options if you want to harden this without changing the boot path:

  • Lock listeners with a sync.RWMutex (RLock around range in fireListeners, Lock around append in RegisterEventListener) — cheap, well-trodden.
  • Or rename to RegisterEventListenerAtBoot / panic if called after a sentinel "started" flag flips, to make misuse loud.

Not blocking — current call sites obey the contract. Flagging because the field is exported-by-implication via a public method, so a future package adopter could trip it.

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

In `@internal/store/store.go` around lines 36 - 83, The
RegisterEventListener/listeners slice is racy if callers append while
fireListeners ranges the slice; add a sync.RWMutex to Store (e.g., listenersMu)
and use listenersMu.Lock() when appending in RegisterEventListener and
listenersMu.RLock() around the iteration in fireListeners (and any other
readers) to prevent concurrent append/range races; alternatively, if you prefer
a fail-fast approach, add a started bool on Store and have RegisterEventListener
panic if started is true (flip started when AppendEvent begins) — implement the
locking approach unless you intentionally want the stricter
panic-on-late-register behavior.
deploy/QUICKSTART.md (1)

6-6: Casing inconsistency for the GitHub org slug.

This file uses MANCHTOOLS (uppercase) on lines 6 and 62, while deploy/install.sh line 28 uses manchtools (lowercase). GitHub treats org slugs case-insensitively for HTTP fetches, so both work, but pinning canonical case avoids subtle issues with mirrors/proxies, redirect caches, and anchor-checking link checkers. Pick one (the lowercase form matches the slug stored in GITHUB_REPO and PR objectives) and use it everywhere.

📝 Suggested doc fix
-curl -fsSL https://raw.githubusercontent.com/MANCHTOOLS/power-manage-server/main/deploy/install.sh | sudo bash
+curl -fsSL https://raw.githubusercontent.com/manchtools/power-manage-server/main/deploy/install.sh | sudo bash
-git clone https://github.com/MANCHTOOLS/power-manage-server.git
+git clone https://github.com/manchtools/power-manage-server.git

Also applies to: 62-62, 239-239

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

In `@deploy/QUICKSTART.md` at line 6, Replace the uppercase GitHub org slug
"MANCHTOOLS" with the lowercase "manchtools" in deploy/QUICKSTART.md where the
curl/install command and other references use the org slug (e.g., the curl
command string and the other occurrences called out in the comment); ensure
consistency with deploy/install.sh which uses "manchtools" and with the
GITHUB_REPO canonical slug so all instances in QUICKSTART.md exactly match the
lowercase form.
deploy/install.sh (1)

215-221: Sourcing .env under set -euo pipefail is mildly fragile.

set -u is enabled by set -euo pipefail at line 23, so any line in .env that references an unset variable (e.g., KEY=${OTHER:?} or KEY=$LITERAL_DOLLAR_SIGN typos) will abort the script. This is unlikely with the values setup.sh writes, but if an operator hand-edits .env and introduces a stray $ it becomes hard to diagnose. Optional: temporarily relax set +u around the source, then set -u again.

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

In `@deploy/install.sh` around lines 215 - 221, In print_summary(), avoid aborts
from undefined vars when sourcing ./.env under set -euo pipefail by temporarily
disabling "nounset": run set +u before you set -a and source ./.env, then
restore strict nounset with set -u after you unset -a; i.e., wrap the source
./.env between set +u / set -u while keeping the export behavior via set -a so
sourcing won't fail on accidental $VAR references.
cmd/control/main.go (1)

514-528: OnEventAppended is reassigned after RegisterEventListener — be aware of the ordering implication.

fireListeners invokes OnEventAppended first and then the registered listeners. Between line 381 (RegisterEventListener for system actions) and line 516 (assignment of OnEventAppended for search indexing), any committed events fire only the system-action listener; events emitted before line 381 fire neither (caught by the startup sweep). This is the current intended boot behavior, but if future code starts emitting events between lines 381–516 that need search-indexing on first occurrence, it'll silently miss them.

Optional refactor: move the search-indexer wiring into a RegisterEventListener call too (instead of writing to the public OnEventAppended field) and place it next to the system-action registration so the order is explicit and concurrent-safe by construction.

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

In `@cmd/control/main.go` around lines 514 - 528, OnEventAppended is being
reassigned after RegisterEventListener which creates an ordering gap where
events emitted between the system-action listener registration and the
reassignment won't be indexed; change the search-index wiring to register as a
listener via st.RegisterEventListener instead of assigning st.OnEventAppended,
move that RegisterEventListener call next to the system-action registration so
ordering is explicit, and inside the listener call searchIdx.EnqueueReindex with
the same payload (search.ScopeAuditEvent, id, taskqueue.SearchEntityData with
EventType/StreamType/ActorType/ActorID/StreamID/OccurredAt) and remove the
direct assignment to OnEventAppended to avoid the race/ordering issue.
internal/api/system_actions_listener.go (1)

178-184: Remove unused st *store.Store parameter from SystemActionListener.

The st argument is never referenced in the function body—only mgr, logger, and syncTimeout are used. The caller passes st purely for syntactic convenience when registering the listener, which is awkward and risks confusion.

Proposed change

Update the function signature in internal/api/system_actions_listener.go:

-func SystemActionListener(mgr *SystemActionManager, st *store.Store, logger *slog.Logger, syncTimeout time.Duration) store.EventListener {
+func SystemActionListener(mgr *SystemActionManager, logger *slog.Logger, syncTimeout time.Duration) store.EventListener {

Update the doc comment example:

-//	st.RegisterEventListener(api.SystemActionListener(svc.SystemActions(), st, logger, cfg.SystemActionReconcileTimeout))
+//	st.RegisterEventListener(api.SystemActionListener(svc.SystemActions(), logger, cfg.SystemActionReconcileTimeout))

Update the call site in cmd/control/main.go:381:

 		st.RegisterEventListener(api.SystemActionListener(
 			svc.SystemActions(),
-			st,
 			logger.With("component", "system_action_listener"),
 			cfg.SystemActionReconcileTimeout,
 		))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/system_actions_listener.go` around lines 178 - 184, Remove the
unused parameter st *store.Store from the SystemActionListener function
signature and all references to it: update the function declaration for
SystemActionListener(mgr *SystemActionManager, logger *slog.Logger, syncTimeout
time.Duration) (removing st), adjust the doc comment/example that shows calling
SystemActionListener to match the new signature, and update the call site (e.g.,
where SystemActionListener is invoked in cmd/control/main.go) to stop passing
the store argument so the call matches the new parameter list; ensure compile by
rebuilding and fixing any related import or variable name discrepancies caused
by the signature change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@deploy/install.sh`:
- Around line 5-6: The install flow pipes install.sh into a non-TTY bash so the
child script ./setup.sh inherits the pipe as stdin and cannot present
interactive prompts (setup.sh uses NO_PROMPT and check_env), causing premature
failure; modify the install.sh invocation of ./setup.sh (the call around the
current ./setup.sh launch) to redirect stdin from /dev/tty when available (e.g.
test for /dev/tty or that a tty exists and run "./setup.sh </dev/tty") so
setup.sh’s prompt loop reads from the controlling terminal while preserving
non-interactive behavior when no tty is present.

---

Nitpick comments:
In `@cmd/control/main.go`:
- Around line 514-528: OnEventAppended is being reassigned after
RegisterEventListener which creates an ordering gap where events emitted between
the system-action listener registration and the reassignment won't be indexed;
change the search-index wiring to register as a listener via
st.RegisterEventListener instead of assigning st.OnEventAppended, move that
RegisterEventListener call next to the system-action registration so ordering is
explicit, and inside the listener call searchIdx.EnqueueReindex with the same
payload (search.ScopeAuditEvent, id, taskqueue.SearchEntityData with
EventType/StreamType/ActorType/ActorID/StreamID/OccurredAt) and remove the
direct assignment to OnEventAppended to avoid the race/ordering issue.

In `@deploy/install.sh`:
- Around line 215-221: In print_summary(), avoid aborts from undefined vars when
sourcing ./.env under set -euo pipefail by temporarily disabling "nounset": run
set +u before you set -a and source ./.env, then restore strict nounset with set
-u after you unset -a; i.e., wrap the source ./.env between set +u / set -u
while keeping the export behavior via set -a so sourcing won't fail on
accidental $VAR references.

In `@deploy/QUICKSTART.md`:
- Line 6: Replace the uppercase GitHub org slug "MANCHTOOLS" with the lowercase
"manchtools" in deploy/QUICKSTART.md where the curl/install command and other
references use the org slug (e.g., the curl command string and the other
occurrences called out in the comment); ensure consistency with
deploy/install.sh which uses "manchtools" and with the GITHUB_REPO canonical
slug so all instances in QUICKSTART.md exactly match the lowercase form.

In `@deploy/setup_test.sh`:
- Around line 47-75: The inlined helpers write_env_var and clear_env_var drop
the chmod preservation used in deploy/setup.sh so add the missing atomic-replace
permission-preservation logic (i.e., after writing to the temp file run the same
chmod --reference="$envfile" "$tmp" 2>/dev/null || chmod 600 "$tmp" before mv
"$tmp" "$envfile") and add an assertion in the test that the .env file mode is
preserved across the round-trip; alternatively replace the inline helpers by
sourcing deploy/setup.sh with a guard to avoid running main so the test
exercises the real write_env_var and clear_env_var implementations instead of
duplicating them.

In `@deploy/setup.sh`:
- Around line 362-378: The prompt_secret function currently uses read -r to
accept manual secret input which echoes the secret; change the "no" branch so it
uses a silent read (read -s) to set REPLY_VALUE when the user opts to enter a
value, then echo a newline after the silent read to restore prompt formatting;
keep the existing placeholder check (is_placeholder), generation path (eval
"$gen_cmd") and assignments to REPLY_VALUE unchanged, and ensure the prompt text
remains the same so behavior for generation vs manual entry is preserved.
- Around line 540-556: The script currently iterates over arguments (for arg in
"$@") and branches via the case statement but ignores unknown flags; update the
case in setup.sh to add a default branch (e.g., *) that prints a clear error
mentioning the unrecognized argument ($arg), shows usage or hint, and exits
non-zero; reference the existing NO_PROMPT variable and the --no-prompt /
-h|--help patterns so the new default handler rejects typos like --noprompt and
prevents silent acceptance.

In `@internal/api/system_actions_listener_test.go`:
- Around line 236-246: Replace the custom equalStringSlices helper with the
standard library helper slices.Equal: remove the equalStringSlices function and
call slices.Equal(a, b) where it was used; add the "slices" import to the test
file (Go 1.21+) so the code compiles, and ensure any references to
equalStringSlices are updated to slices.Equal.
- Around line 24-225: TestAffectedFromEvent lacks explicit single-event tests
for many event-type literals handled by AffectedFromEvent, risking regressions
if cases are refactored; add a small table-driven subtest inside
TestAffectedFromEvent that iterates over the remaining event-type strings (e.g.
"UserEnabled","UserProvisioningSettingsUpdated","UserSshSettingsUpdated","UserProfileUpdated","UserSshKeyRemoved","UserEmailChanged","UserGroupMemberRemoved","UserGroupRoleRevoked","UserGroupQueryUpdated",
etc.) and for each constructs a minimal store.PersistedEvent and asserts
AffectedFromEvent returns the expected SyncOp (SyncOpSyncUser, SyncOpSyncAll or
SyncOpNone) and expected users where applicable; reference the existing
AffectedFromEvent call and SyncOp constants in the new loop so the test locks
the mapping of event-type literals to SyncOp outcomes.

In `@internal/api/system_actions_listener.go`:
- Around line 178-184: Remove the unused parameter st *store.Store from the
SystemActionListener function signature and all references to it: update the
function declaration for SystemActionListener(mgr *SystemActionManager, logger
*slog.Logger, syncTimeout time.Duration) (removing st), adjust the doc
comment/example that shows calling SystemActionListener to match the new
signature, and update the call site (e.g., where SystemActionListener is invoked
in cmd/control/main.go) to stop passing the store argument so the call matches
the new parameter list; ensure compile by rebuilding and fixing any related
import or variable name discrepancies caused by the signature change.

In `@internal/api/terminal_handler_test.go`:
- Around line 388-390: The test should assert the structured error code from the
gRPC Connect error instead of only checking the human message: pull
connectErr.Details(), require it's non-empty, unmarshal the first detail into a
pm.ErrorDetail and assert detail.Code equals the expected constant (use
api.ErrGatewayNotRegistered for the persistent-misconfig case and
api.ErrDeviceNotConnected for the transient case), and optionally keep the
existing assert.Contains(connectErr.Error(),
"GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE") as a secondary, actionable hint check;
locate these changes around the existing connectErr assertions in the test
(symbols: connectErr, pm.ErrorDetail, api.ErrGatewayNotRegistered,
api.ErrDeviceNotConnected).

In `@internal/scim/handler_test.go`:
- Line 36: Tests never exercise the SCIM delete cleanup path because NewHandler
is constructed with nil systemActions; add a fake SystemActionsCleaner to the
SCIM handler tests and assert its CleanupDeletedUserActions is called only when
the last identity link is removed. Implement a minimal fake type that records
calls and inject it into scim.NewHandler in TestDeleteUser_Success (and add a
complementary case where multiple links remain), then after performing the
DELETE request assert the fake's calls length and that the recorded
db.UsersProjection.ID equals the deleted user ID; ensure the fake implements
CleanupDeletedUserActions(ctx context.Context, u db.UsersProjection) error and
returns nil so it can be used in setupSCIM wiring.

In `@internal/store/store.go`:
- Around line 36-83: The RegisterEventListener/listeners slice is racy if
callers append while fireListeners ranges the slice; add a sync.RWMutex to Store
(e.g., listenersMu) and use listenersMu.Lock() when appending in
RegisterEventListener and listenersMu.RLock() around the iteration in
fireListeners (and any other readers) to prevent concurrent append/range races;
alternatively, if you prefer a fail-fast approach, add a started bool on Store
and have RegisterEventListener panic if started is true (flip started when
AppendEvent begins) — implement the locking approach unless you intentionally
want the stricter panic-on-late-register behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aad4f995-3118-494e-bd7c-9e84102eff1f

📥 Commits

Reviewing files that changed from the base of the PR and between 26accec and 6d6650f.

📒 Files selected for processing (20)
  • cmd/control/main.go
  • cmd/gateway/main.go
  • deploy/.env.example
  • deploy/QUICKSTART.md
  • deploy/install.sh
  • deploy/setup.sh
  • deploy/setup_test.sh
  • internal/api/errors.go
  • internal/api/system_actions.go
  • internal/api/system_actions_listener.go
  • internal/api/system_actions_listener_test.go
  • internal/api/terminal_handler.go
  • internal/api/terminal_handler_test.go
  • internal/api/user_handler.go
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/scim/handler.go
  • internal/scim/handler_test.go
  • internal/scim/users.go
  • internal/store/store.go

Comment thread deploy/install.sh
CodeRabbit's second pass after round-3 caught one real bug + a dozen
nitpicks worth taking. Applied all of them here.

Major
-----
- deploy/install.sh: when the installer is invoked via the documented
  `curl -fsSL .../install.sh | sudo bash` flow, install.sh's stdin is
  the pipe — and so is setup.sh's inherited stdin. setup.sh's
  `[[ "$NO_PROMPT" -eq 0 && -t 0 ]]` correctly auto-falls-back to
  non-interactive mode, but with a fresh .env (just copied from
  .env.example), check_env then aborts on the CHANGE_ME placeholders
  with a confusing "POSTGRES_PASSWORD must be set" message. Net effect:
  the QUICKSTART one-liner could not actually reach guided setup.
  Added a `</dev/tty` redirect when /dev/tty is readable so the prompt
  loop reads from the controlling terminal even when install.sh was
  piped. CI / true non-interactive contexts (no /dev/tty) still go
  through the existing fallback.

Minor
-----
- deploy/install.sh: print_summary's `source ./.env` ran under
  `set -u`, so a typo'd `$VAR` reference in .env would abort the
  installer's success path. Wrapped the source with `set +u` / `set -u`
  while keeping `set -a` for export.
- deploy/setup.sh prompt_secret: manual-entry No-branch echoed the
  typed secret to the terminal — minor leak risk on shared/recorded
  sessions. Switched to `read -r -s` + explicit newline.
- deploy/setup.sh arg parsing: `--noprompt` (typo) silently ran guided
  mode. Added a `*)` default branch that prints the unknown flag and
  exits 2.
- internal/store/store.go: documented "register at boot, then serve"
  contract relied on call-site discipline; Go's race detector cannot
  catch a future concurrent registration. Added a sync.RWMutex around
  listeners + OnEventAppended. fireListeners snapshots under RLock and
  dispatches outside the mutex so slow listeners don't block readers.
- cmd/control/main.go: the search-indexer wired into st.OnEventAppended
  via direct field assignment AFTER the system-action
  RegisterEventListener call. Migrated to a second
  RegisterEventListener call so both consumers share the same wiring,
  the same mutex, and the same panic-recovery wrapper. Eliminates the
  ordering-gap concern between the two registrations.
- internal/api/system_actions_listener.go: removed unused
  `st *store.Store` parameter from SystemActionListener (only mgr,
  logger, syncTimeout are used). Updated the doc-comment example and
  the cmd/control/main.go call site.
- deploy/setup_test.sh: the inlined helpers had drifted from
  setup.sh's canonical implementation — they dropped the
  `chmod --reference="$envfile"` line that preserves .env's 0600
  mode across rewrites. The "tests would FAIL on divergence" claim
  was therefore not true for this specific regression class. Synced
  the inlined helpers back to setup.sh and added two new cases
  (case_write_env_var_preserves_mode_0600,
  case_clear_env_var_preserves_mode_0600) that fail if a future edit
  drops the chmod call.
- deploy/QUICKSTART.md: lowercased the GitHub org slug to match
  install.sh's canonical `manchtools` (was inconsistent uppercase
  MANCHTOOLS in two places).

Tests
-----
- internal/api/terminal_handler_test.go: replaced message-substring
  assertions with structured Code assertions via
  connectErr.Details()[0].Value().(*pm.ErrorDetail). The Code is the
  actual contract the SDK + web client switch on — substring
  assertions stay as a secondary check that the operator hint is
  present. Added the errorCode helper.
- internal/api/system_actions_listener_test.go: replaced
  equalStringSlices with stdlib slices.Equal. Added
  TestAffectedFromEvent_PerLiteral that asserts every event-type
  literal handled by AffectedFromEvent's switch maps to the expected
  SyncOp. Locks the per-literal contract so a future refactor that
  drops one from a shared case is observable.
- internal/scim/handler_test.go: rc11 #77's SCIM-side cleanup wiring
  had no regression test (every existing test passed nil for
  systemActions). Added fakeSystemActionsCleaner + setupSCIMWithCleaner
  variant + TestDeleteUser_LastLink_TriggersSystemActionsCleanup that
  exercises the DELETE → CleanupDeletedUserActions(loaded projection)
  path end-to-end. Asserts call count == 1 and the projection ID
  matches the deleted user — the exact regression the PR is meant to
  prevent.

Verification
------------
- go build ./... clean across server / sdk / agent
- go vet ./... clean
- go test -race ./internal/api/ -run TestAffectedFromEvent : pass
- go test ./internal/api/ -run 'TestAffectedFromEvent|TestStartTerminal_GatewayNotRegistered' : 30+ subcases pass
- go test ./internal/scim/ -run TestDeleteUser : both cases pass
- bash deploy/setup_test.sh : 8 real + 1 synthetic; all passing
Closes the last CodeRabbit nit from PR #83 that I'd previously
deferred as non-blocking. Adds a parent_domain helper and uses it for
the TTY domain default and the admin email default so single-label
hostnames (`localhost`, `controlplane`) don't yield self-referential
or odd defaults.

- parent_domain "control.example.com" → "example.com"
- parent_domain "localhost"            → "" (single label, no parent)
- parent_domain "a.b.c.example.com"    → "b.c.example.com"

For default_tty: empty when CONTROL_DOMAIN is single-label, forcing
the operator to type something meaningful instead of accepting
`tty.localhost` (which would never resolve and would just shadow the
control host on a single-label local-test setup).

For admin email: `admin@<parent>` for dotted domains, `admin@<full>`
for single-label cases — `admin@localhost` is a valid local-delivery
address so we keep something usable rather than blanking the field.

Bash test coverage: three new cases pin the helper's three branches
(dotted / single-label / deep subdomain). Setup test suite is now
11 real cases + 1 synthetic.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (5)
internal/api/terminal_handler_test.go (1)

456-457: Optional: the "retry" positive-substring assertion is brittle.

The negative assertion on GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE (lines 454–455) is high-signal — it directly guards the regression that motivated the split. The positive "retry" check, however, couples the test to operator-facing copy that the helper comment at lines 26–27 explicitly says is "free to evolve with copy edits". A reasonable rewording like "please try again later" or "temporarily unavailable" would fail this assertion without any behavior change.

Since connect.CodeUnavailable already encodes the retry semantics for clients, and ErrDeviceNotConnected carries the differentiation, consider dropping the positive substring or loosening it to a small alternation.

♻️ Suggested loosening
-	assert.Contains(t, connectErr.Error(), "retry",
-		"transient path message should hint at retry")
+	msg := strings.ToLower(connectErr.Error())
+	assert.True(t,
+		strings.Contains(msg, "retry") || strings.Contains(msg, "try again") || strings.Contains(msg, "temporarily"),
+		"transient path message should hint at retry semantics, got: %q", connectErr.Error())

Or simply drop it — CodeUnavailable + ErrDeviceNotConnected + the negative assertion already lock the contract.

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

In `@internal/api/terminal_handler_test.go` around lines 456 - 457, The test's
positive substring assertion assert.Contains(t, connectErr.Error(), "retry") is
brittle; remove that assertion (or replace it with a small alternation if you
prefer) and rely on the existing negative check against
GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE together with connect.CodeUnavailable and
ErrDeviceNotConnected to enforce the contract — locate the check in
terminal_handler_test.go that asserts connectErr.Error() contains "retry" and
delete or relax it so the test no longer depends on operator-facing copy.
cmd/control/main.go (1)

849-860: Consider mirroring the DynamicGroupEvalInterval min/max clamp pattern.

SystemActionReconcileInterval is only clamped against negative values. A misconfigured CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL=1ms would create a ticker firing every millisecond, repeatedly invoking SyncAllUsersSystemActions and likely overwhelming the DB (the atomic-bool guard in StartReconciliation would skip overlapping sweeps but still produce a flood of warning logs every tick). The neighbouring DynamicGroupEvalInterval block at lines 841-847 enforces a sensible floor (30m) and ceiling (8h). A floor (e.g., 10s) on the reconcile interval would similarly make the operator-facing knob safe-by-default.

Not blocking — the default of 1 minute is reasonable and operators are unlikely to set this to a pathological value, but worth considering for parity.

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

In `@cmd/control/main.go` around lines 849 - 860, Mirror the
DynamicGroupEvalInterval clamp for system actions: in the same init/config
section where cfg.SystemActionReconcileInterval and
cfg.SystemActionReconcileTimeout are set, enforce a sensible minimum and maximum
for cfg.SystemActionReconcileInterval (e.g., clamp to a floor of 10s and a
ceiling of 8h similar to DynamicGroupEvalInterval) instead of only disallowing
negatives; keep the current fallback for SystemActionReconcileTimeout but ensure
StartReconciliation/SyncAllUsersSystemActions behavior remains unchanged (the
atomic-bool guard still handles overlaps).
internal/scim/handler_test.go (1)

48-52: lastCall() will panic on empty calls slice.

f.calls[len(f.calls)-1] indexes [-1] if no call has been recorded, producing a panic instead of a clean test failure. The current test asserts callCount == 1 via require.Equal before invoking lastCall(), so it's safe today, but a future test that forgets the guard will fail with a panic stack trace rather than a readable assertion error.

♻️ Optional hardening
 func (f *fakeSystemActionsCleaner) lastCall() db.UsersProjection {
 	f.mu.Lock()
 	defer f.mu.Unlock()
+	if len(f.calls) == 0 {
+		return db.UsersProjection{}
+	}
 	return f.calls[len(f.calls)-1]
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/scim/handler_test.go` around lines 48 - 52, The helper
fakeSystemActionsCleaner.lastCall currently panics when f.calls is empty by
indexing f.calls[len(f.calls)-1]; change lastCall to guard against empty slice
(e.g., if len(f.calls) == 0 { return db.UsersProjection{} }) so it returns a
safe zero-value instead of panicking, or alternatively return an (value, bool)
or error; update any callers/tests accordingly (references:
fakeSystemActionsCleaner.lastCall and f.calls).
internal/api/system_actions_listener_test.go (1)

234-321: Per-literal test is a good guard against accidental case-list regressions.

The literal-by-literal sweep catches the failure mode where a future refactor consolidates the switch and silently drops one literal from a shared case (which the structured TestAffectedFromEvent would miss because it only samples one literal per branch). One minor observation: there's no case here for malformed JSON in event.Data (e.g., []byte("{invalid")), which would exercise the json.Unmarshal error path in userIDFromEventData at system_actions_listener.go lines 117-120 — currently untested. Not blocking.

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

In `@internal/api/system_actions_listener_test.go` around lines 234 - 321, Add a
unit test that sends a malformed JSON payload to AffectedFromEvent to exercise
the json.Unmarshal error path in userIDFromEventData: create a PersistedEvent
for a data-driven user event (e.g., EventType "UserRoleAssigned", StreamType
"user_role", StreamID "user-y:role-z") with Data set to invalid bytes like
[]byte("{invalid") and assert the returned op is SyncOpSyncUser and the users
fallback to ["user-y"]; place this inside TestAffectedFromEvent_PerLiteral near
the other per-data cases so it explicitly covers the error branch in
userIDFromEventData and prevents regressions.
deploy/setup_test.sh (1)

44-88: Consider source-guarding setup.sh so the harness can test the real helpers.

The inlined copies of write_env_var, clear_env_var, and parent_domain (lines 50-88) duplicate the production implementations at deploy/setup.sh:319-326,338-354,361-374. The comment above acknowledges the drift risk, and round-3 review already caught one such drift on the chmod --reference call. As long as the test inlines, a regression in setup.sh that's not mirrored into the harness will pass green.

Since we already gate execution with if [[ "$NO_PROMPT" -eq 0 && -t 0 ]] etc., wrapping the bottom of setup.sh in a "main only when executed, not when sourced" sentinel would let setup_test.sh source the real functions and delete the inline copies.

♻️ Sketch

In deploy/setup.sh (bottom of file):

-main "$@"
+# Only run main when executed directly; allow sourcing for tests.
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+    main "$@"
+fi

In deploy/setup_test.sh run_case subshell:

-        # Inline the helper definitions we need to test. Must stay in
-        # sync with setup.sh — including the same-fs mktemp +
-        # chmod-reference dance that preserves .env's mode across
-        # rewrites. ...
-        write_env_var() { ... }
-        clear_env_var() { ... }
-        parent_domain() { ... }
+        # Source the real helpers so a regression in setup.sh shows
+        # up here instead of being masked by an out-of-date inline copy.
+        # shellcheck disable=SC1091
+        source "$(dirname "${BASH_SOURCE[0]}")/setup.sh"

Note: the top-level for arg in "$@" parser at setup.sh:583-606 would also need to be moved into a function or guarded the same way so sourcing doesn't reject the test harness's own argv.

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

In `@deploy/setup_test.sh` around lines 44 - 88, The test harness currently
inlines write_env_var, clear_env_var, and parent_domain, risking silent drift;
instead add a "main-only when executed" sentinel to deploy/setup.sh (so its
helpers are defined on source) and move or guard the top-level argv parsing (the
for arg in "$@" parser) behind the same sentinel or into a function, then remove
the duplicated helper definitions from deploy/setup_test.sh and have it source
deploy/setup.sh (keeping the existing NO_PROMPT/tTY checks intact) so the tests
exercise the real implementations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@deploy/setup.sh`:
- Around line 560-576: The final summary block prints a stale ADMIN_EMAIL and
reprints stored admin passwords on reruns; fix by (1) after calling
write_env_var for ADMIN_EMAIL (in the prompt_string/ADMIN_EMAIL sequence) update
the shell variable from the prompt result (e.g., set ADMIN_EMAIL="$REPLY_VALUE"
or otherwise mirror what you did for CONTROL_DOMAIN/GATEWAY_DOMAIN) so the
displayed "Email:" shows the value just written, and (2) change how
prompt_secret is used so the caller knows whether the password was newly
generated/entered this run (add/return a flag like REPLY_GENERATED or have
prompt_secret set a sentinel variable) and only show the "write this down NOW"
banner and echo the password when that flag is true; leave admin_pass populated
for other logic but suppress printing when the password was from the existing
.env (use is_placeholder only to detect placeholder, not to infer "just
created").

In `@internal/api/system_actions_listener.go`:
- Around line 212-244: The spawned goroutine that calls
mgr.SyncUserSystemActions and mgr.SyncAllUsersSystemActions lacks panic
recovery, so wrap the goroutine body with a top-level defer that recovers
panics, logs the panic and stack trace via the same logger used in this file,
and prevents the panic from propagating; ensure the existing deferred cleanup
(reading from sem and, when op == SyncOpSyncAll, syncAllInFlight.Store(false))
still runs even if a panic occurs by placing the recover defer before or
combined with those defers so both the recovery log and resource-release actions
execute; update the anonymous goroutine that handles cases SyncOpSyncUser,
SyncOpCleanupUser, and SyncOpSyncAll to include this defensive recover logic
around calls to mgr.SyncUserSystemActions and mgr.SyncAllUsersSystemActions.
- Around line 58-82: The comment is out-of-date and group-member handlers can
see composite StreamID values, so add the same defensive fallback used in the
role handlers: inside the "UserGroupMemberAdded"/"UserGroupMemberRemoved"
branch, after calling userIDFromEventData(e) and seeing an empty uid, attempt to
split e.StreamID with strings.Cut(e.StreamID, ":") and use the left part as the
user id if present; then return SyncOpSyncUser with that uid (otherwise keep
returning SyncOpNone). Update the branch that handles these events and reference
userIDFromEventData, e.StreamID, and the SyncOpSyncUser/SyncOpNone returns.

In `@internal/store/store.go`:
- Around line 112-138: fireListeners dispatches listeners synchronously which
lets a slow listener (the audit indexing listener that calls
searchIdx.EnqueueReindex in cmd/control/main.go) block AppendEvent and RPCs; fix
by making the audit/indexing listener non-blocking at its registration site:
wrap the call to searchIdx.EnqueueReindex inside a goroutine (spawned from the
listener registered with Store.RegisterEventListener / OnEventAppended) and make
it best-effort (catch/recover panics and log failures) so fireListeners and
functions like OnEventAppended remain synchronous but cheap; reference
searchIdx.EnqueueReindex, the listener registration in cmd/control/main.go, and
fireListeners in internal/store/store.go when making this change.

---

Nitpick comments:
In `@cmd/control/main.go`:
- Around line 849-860: Mirror the DynamicGroupEvalInterval clamp for system
actions: in the same init/config section where cfg.SystemActionReconcileInterval
and cfg.SystemActionReconcileTimeout are set, enforce a sensible minimum and
maximum for cfg.SystemActionReconcileInterval (e.g., clamp to a floor of 10s and
a ceiling of 8h similar to DynamicGroupEvalInterval) instead of only disallowing
negatives; keep the current fallback for SystemActionReconcileTimeout but ensure
StartReconciliation/SyncAllUsersSystemActions behavior remains unchanged (the
atomic-bool guard still handles overlaps).

In `@deploy/setup_test.sh`:
- Around line 44-88: The test harness currently inlines write_env_var,
clear_env_var, and parent_domain, risking silent drift; instead add a "main-only
when executed" sentinel to deploy/setup.sh (so its helpers are defined on
source) and move or guard the top-level argv parsing (the for arg in "$@"
parser) behind the same sentinel or into a function, then remove the duplicated
helper definitions from deploy/setup_test.sh and have it source deploy/setup.sh
(keeping the existing NO_PROMPT/tTY checks intact) so the tests exercise the
real implementations.

In `@internal/api/system_actions_listener_test.go`:
- Around line 234-321: Add a unit test that sends a malformed JSON payload to
AffectedFromEvent to exercise the json.Unmarshal error path in
userIDFromEventData: create a PersistedEvent for a data-driven user event (e.g.,
EventType "UserRoleAssigned", StreamType "user_role", StreamID "user-y:role-z")
with Data set to invalid bytes like []byte("{invalid") and assert the returned
op is SyncOpSyncUser and the users fallback to ["user-y"]; place this inside
TestAffectedFromEvent_PerLiteral near the other per-data cases so it explicitly
covers the error branch in userIDFromEventData and prevents regressions.

In `@internal/api/terminal_handler_test.go`:
- Around line 456-457: The test's positive substring assertion
assert.Contains(t, connectErr.Error(), "retry") is brittle; remove that
assertion (or replace it with a small alternation if you prefer) and rely on the
existing negative check against GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE together
with connect.CodeUnavailable and ErrDeviceNotConnected to enforce the contract —
locate the check in terminal_handler_test.go that asserts connectErr.Error()
contains "retry" and delete or relax it so the test no longer depends on
operator-facing copy.

In `@internal/scim/handler_test.go`:
- Around line 48-52: The helper fakeSystemActionsCleaner.lastCall currently
panics when f.calls is empty by indexing f.calls[len(f.calls)-1]; change
lastCall to guard against empty slice (e.g., if len(f.calls) == 0 { return
db.UsersProjection{} }) so it returns a safe zero-value instead of panicking, or
alternatively return an (value, bool) or error; update any callers/tests
accordingly (references: fakeSystemActionsCleaner.lastCall and f.calls).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2132a632-675d-496e-b407-9e1451c4162d

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6650f and 0e244ac.

📒 Files selected for processing (10)
  • cmd/control/main.go
  • deploy/QUICKSTART.md
  • deploy/install.sh
  • deploy/setup.sh
  • deploy/setup_test.sh
  • internal/api/system_actions_listener.go
  • internal/api/system_actions_listener_test.go
  • internal/api/terminal_handler_test.go
  • internal/scim/handler_test.go
  • internal/store/store.go
✅ Files skipped from review due to trivial changes (2)
  • deploy/QUICKSTART.md
  • deploy/install.sh

Comment thread deploy/setup.sh
Comment thread internal/api/system_actions_listener.go Outdated
Comment thread internal/api/system_actions_listener.go
Comment thread internal/store/store.go
CodeRabbit's incremental review on the round-4 commits caught a real
admin-credential leak, two listener correctness gaps, and a handful of
worthwhile robustness improvements. Implemented all 4 actionable + 5
nitpick findings.

Major
-----
- deploy/setup.sh: the final summary block had two display bugs.
    1. ADMIN_EMAIL was sourced from .env BEFORE the prompt ran and
       never re-read, so on a fresh install the "Email:" line printed
       empty next to the freshly chosen password. Fix: mirror
       ADMIN_EMAIL="$REPLY_VALUE" after write_env_var, matching what
       CONTROL_DOMAIN/GATEWAY_DOMAIN already do.
    2. The "write this down NOW; not shown again" banner gated on
       is_placeholder($admin_pass), which is false when the existing
       password was simply kept verbatim — so EVERY rerun re-printed
       the stored bootstrap password to stdout / install logs / scroll
       buffer. Fix: prompt_secret now sets REPLY_GENERATED=1 only when
       the operator generates or types a value this run; the banner
       gates on that flag instead of inferring intent from the value.

- internal/api/system_actions_listener.go: comment claimed "group
  membership uses the group ID as StreamID, so user_id is data-only,"
  but SCIM, the API user-group handler, and testutil all emit
  composite "groupID:userID" — only IDP uses bare groupID. A future
  emitter that drops data.user_id while keeping the composite form
  would silently SyncOpNone for up to one reconcile interval. Added
  the symmetric StreamID-suffix fallback (taking the part AFTER the
  colon, not before like the role case takes the part BEFORE).

- internal/api/system_actions_listener.go: dispatch goroutine had no
  panic recovery. fireListeners' defer/recover only protects the
  listener function, which returned the moment we spawned the
  goroutine — a panic in mgr.SyncUserSystemActions or
  SyncAllUsersSystemActions (nil deref, library bug, etc.) would
  have crashed the entire control server. Added a top-level recover
  that logs the panic with event metadata and lets the sem release +
  syncAllInFlight reset still run.

Minor
-----
- internal/store/store.go is dispatched synchronously, so a slow
  listener body extends every state-changing RPC's tail latency.
  cmd/control/main.go's audit-index listener was the worst case
  because EnqueueReindex hits Valkey on every AppendEvent. Wrapped
  the EnqueueReindex call in a goroutine at the registration site
  (with its own recover) so a Valkey RTT spike can't stall RPCs. The
  taskqueue client has its own per-call timeouts, and the work is
  already best-effort, so detaching is safe.

- cmd/control/main.go: clamped SystemActionReconcileInterval to
  [10s, 8h] when non-zero (mirroring the DynamicGroupEvalInterval
  pattern). Without the floor, a misconfigured 1ms tick would have
  produced a flood of ticker events; the StartReconciliation atomic
  guard would skip overlapping sweeps but still log a warning every
  millisecond.

Tests
-----
- internal/api/terminal_handler_test.go: dropped the brittle
  `assert.Contains(connectErr.Error(), "retry")` substring assertion
  on the transient-loss case. CodeUnavailable + ErrDeviceNotConnected
  + the negative substring against GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE
  already lock the contract — the positive copy match was just a
  hostage to operator-message edits.

- internal/scim/handler_test.go: lastCall() guards against an empty
  slice, returning the zero value so a future test that forgets to
  assert callCount first fails with a readable diff instead of a
  panic stack trace.

- internal/api/system_actions_listener_test.go: added three new cases:
    * group-member event with composite StreamID + missing data.user_id
      → SyncOpSyncUser (covers the new fallback)
    * UserGroupMemberRemoved twin
    * UserRoleAssigned with malformed JSON in Data → falls back to
      StreamID prefix (exercises the json.Unmarshal error path in
      userIDFromEventData that was previously untested)

Architecture
------------
- deploy/setup.sh: wrapped argv parsing in a parse_flags function and
  the bottom-of-file invocation in `if [[ "${BASH_SOURCE[0]}" ==
  "${0}" ]]; then ... fi`. setup.sh is now safely sourceable — its
  helpers load without running main or rejecting the harness's argv.

- deploy/setup_test.sh: deleted ALL inlined helper copies. The
  harness now sources setup.sh and exercises the real bodies, so a
  future regression in setup.sh (the chmod-preservation drift caught
  in round 3, e.g.) shows up as a test failure here instead of being
  masked by an out-of-date inline copy.

  Order of source vs. SCRIPT_DIR override matters: the override has
  to come AFTER the source, because setup.sh sets SCRIPT_DIR at the
  top to its own install dir. The pre-source override I'd written
  first was silently overwritten and the helpers wrote into the real
  deploy/.env — tests still went green because the helpers worked
  against the wrong file. Added case_isolation_writes_in_tmpdir as a
  guard so this can't regress silently again.

Verification
------------
- go build ./... clean
- go vet ./... clean
- go test -race ./internal/api/ -run AffectedFromEvent : pass (no races)
- go test ./internal/api/ -run 'TestAffectedFromEvent|TestStartTerminal_GatewayNotRegistered' : 30+ subcases pass
- go test ./internal/scim/ -run TestDeleteUser : both pass
- bash deploy/setup_test.sh : 12 real + 1 synthetic, all passing
- ./deploy/setup.sh --help still works (source-guard regression check)
@PaulDotterer

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
internal/api/system_actions_listener.go (1)

64-66: ⚠️ Potential issue | 🟡 Minor

Stale comment: role case is no longer the only place the fallback is meaningful.

The comment claims "The role case is the only place this fallback is meaningful — group membership uses the group ID as StreamID, so user_id is data-only," but the very next case (lines 75-93) now has a symmetric StreamID fallback for composite "groupID:userID" emitters (added per a previous review round). Update or drop the parenthetical so the rationale matches what the group branch actually does.

📝 Suggested wording
-		// up to one reconcile interval." The role case is the only
-		// place this fallback is meaningful — group membership uses
-		// the group ID as StreamID, so user_id is data-only.
+		// up to one reconcile interval." The group-member case below
+		// applies the same defensive pattern for its
+		// "groupID:userID" composite emitters.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/system_actions_listener.go` around lines 64 - 66, The comment
referencing "The role case is the only place this fallback is meaningful" is
stale because the group branch now also falls back to StreamID for composite
"groupID:userID" emitters; update the parenthetical in the comment (near the
StreamID fallback logic and the group membership branch handling composite
emitters) to reflect that the fallback is meaningful for both role and composite
group:user emitters, or remove the parenthetical entirely so the rationale
matches the actual StreamID fallback behavior.
🧹 Nitpick comments (6)
cmd/control/main.go (1)

866-885: Considera advertir cuando interval < sweepTimeout ya están sobreescritos por el clamp.

El clamp es defensivo y correcto, pero silencia errores de configuración. Si un operador escribe CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL=1ms esperando un comportamiento agresivo, ahora recibirá 10s sin saberlo. Un slog.Warn aquí (similar al patrón de envBool/envDuration arriba) ayudaría a depurar configuraciones mal interpretadas:

♻️ Refactor sugerido
 if cfg.SystemActionReconcileInterval < 0 {
+    slog.Warn("CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL was negative, treating as disabled (0)",
+        "value", cfg.SystemActionReconcileInterval)
     cfg.SystemActionReconcileInterval = 0 // treat as disabled, matching StartReconciliation
 } else if cfg.SystemActionReconcileInterval > 0 {
     if cfg.SystemActionReconcileInterval < 10*time.Second {
+        slog.Warn("CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL clamped up to 10s minimum",
+            "value", cfg.SystemActionReconcileInterval)
         cfg.SystemActionReconcileInterval = 10 * time.Second
     } else if cfg.SystemActionReconcileInterval > 8*time.Hour {
+        slog.Warn("CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL clamped down to 8h maximum",
+            "value", cfg.SystemActionReconcileInterval)
         cfg.SystemActionReconcileInterval = 8 * time.Hour
     }
 }
 if cfg.SystemActionReconcileTimeout <= 0 {
+    slog.Warn("CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT was non-positive, defaulting to 5m",
+        "value", cfg.SystemActionReconcileTimeout)
     cfg.SystemActionReconcileTimeout = 5 * time.Minute
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/control/main.go` around lines 866 - 885, Detect when the configured
SystemActionReconcileInterval was clamped or when the (possibly-clamped)
SystemActionReconcileInterval is less than SystemActionReconcileTimeout and emit
a warning via slog.Warn so operators know their values were adjusted;
specifically, capture the original interval parsed (before modifying
cfg.SystemActionReconcileInterval), perform the existing clamp logic, then if
original != cfg.SystemActionReconcileInterval or
cfg.SystemActionReconcileInterval < cfg.SystemActionReconcileTimeout call
slog.Warn with a clear message referencing SystemActionReconcileInterval and
SystemActionReconcileTimeout (same file’s cfg variables and StartReconciliation
semantics) so the log explains the original value and the applied clamped value.
deploy/setup.sh (2)

484-494: Reescritura silenciosa de GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE y GATEWAY_WEB_LISTEN_ADDR en re-ejecuciones.

Cuando el operador re-ejecuta setup.sh, responde Sí a "Enable remote terminal sessions?" y mantiene un GATEWAY_TTY_DOMAIN ya configurado, las líneas 488 y 493 sobrescriben incondicionalmente la plantilla de URL y la dirección del listener — incluso si el operador había personalizado esos valores manualmente en .env (puerto distinto, esquema, prefijo de ruta). El comportamiento de prompt_string al "ya configurado, manteniendo" no aplica aquí porque estos dos valores nunca se preguntan.

No es bloqueante (el bloque de comentarios señala que la composición es intencional), pero sería más amigable comprobar si ya están establecidos con un valor no-placeholder antes de reescribirlos:

♻️ Refactor sugerido
-        write_env_var GATEWAY_TTY_DOMAIN "$REPLY_VALUE"
-        local tty_dom="$REPLY_VALUE"
-
-        # Auto-compose the URL template. Operator never types {id}.
-        write_env_var GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE "wss://${tty_dom}/gw/{id}/terminal"
-        echo "    ✓ GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE composed automatically."
-
-        # The TTY HTTP listener inside the container — Traefik
-        # terminates public TLS and forwards cleartext.
-        write_env_var GATEWAY_WEB_LISTEN_ADDR ":8443"
-        echo "    ✓ GATEWAY_WEB_LISTEN_ADDR set to :8443."
+        write_env_var GATEWAY_TTY_DOMAIN "$REPLY_VALUE"
+        local tty_dom="$REPLY_VALUE"
+
+        # Auto-compose the URL template only if the operator hasn't
+        # already customized it. Operator never types {id} from scratch.
+        if is_placeholder "${GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE:-}"; then
+            write_env_var GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE "wss://${tty_dom}/gw/{id}/terminal"
+            echo "    ✓ GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE composed automatically."
+        else
+            echo "    ✓ GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE already set, keeping."
+        fi
+
+        # The TTY HTTP listener inside the container — Traefik
+        # terminates public TLS and forwards cleartext.
+        if [[ -z "${GATEWAY_WEB_LISTEN_ADDR:-}" ]]; then
+            write_env_var GATEWAY_WEB_LISTEN_ADDR ":8443"
+            echo "    ✓ GATEWAY_WEB_LISTEN_ADDR set to :8443."
+        else
+            echo "    ✓ GATEWAY_WEB_LISTEN_ADDR already set, keeping."
+        fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deploy/setup.sh` around lines 484 - 494, Actualmente el script siempre
reescribe GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE y GATEWAY_WEB_LISTEN_ADDR usando
write_env_var tras leer GATEWAY_TTY_DOMAIN; cambia esto para que antes de llamar
a write_env_var compruebes el valor existente de esas dos variables (leer desde
el .env o la función/variable que almacene valores actuales) y sólo
compongas/reescribas si están vacías o coinciden con un patrón/placeholder
auto-generado; en caso contrario, preserva el valor personalizado del operador.
Refiérete a GATEWAY_TTY_DOMAIN, GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE,
GATEWAY_WEB_LISTEN_ADDR y la invocación write_env_var en el bloque donde
actualmente se hace la composición para implementar la comprobación y la rama de
no-sobrescritura.

543-548: Validación de JWT_SECRET aborta setup.sh para valores existentes legítimos pero cortos.

prompt_secret retorna el valor actual sin cambios cuando ya está establecido y no es un placeholder, y luego la línea 544 valida la longitud. Si un operador heredó un JWT_SECRET de una versión anterior que no imponía 32 caracteres mínimos, la re-ejecución de setup.sh aborta con "must be at least 32 characters" sin opción de regenerar — esto es la primera vez que el operador ve la nueva regla.

Considera ofrecer regeneración interactiva en lugar de abortar (similar al patrón de prompt_secret):

♻️ Refactor sugerido
-    prompt_secret "JWT secret (JWT_SECRET, min 32 chars)" "openssl rand -base64 48" "${JWT_SECRET:-}"
-    if [[ ${`#REPLY_VALUE`} -lt 32 ]]; then
-        log_error "JWT_SECRET must be at least 32 characters; got ${`#REPLY_VALUE`}. Aborting."
-        exit 1
-    fi
-    write_env_var JWT_SECRET "$REPLY_VALUE"
+    prompt_secret "JWT secret (JWT_SECRET, min 32 chars)" "openssl rand -base64 48" "${JWT_SECRET:-}"
+    if [[ ${`#REPLY_VALUE`} -lt 32 ]]; then
+        log_warn "Existing JWT_SECRET is ${`#REPLY_VALUE`} chars (< 32). The control server will refuse to start."
+        if prompt_yes_no "Regenerate JWT_SECRET now?"; then
+            REPLY_VALUE="$(openssl rand -base64 48)"
+            REPLY_GENERATED=1
+        else
+            log_error "JWT_SECRET must be at least 32 characters; aborting."
+            exit 1
+        fi
+    fi
+    write_env_var JWT_SECRET "$REPLY_VALUE"

El mismo patrón aplica a INDEXER_POSTGRES_PASSWORD (líneas 533-537) y CONTROL_ENCRYPTION_KEY (líneas 552-555), aunque la probabilidad de heredar valores inválidos en esos dos es menor.

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

In `@deploy/setup.sh` around lines 543 - 548, The current length check for
JWT_SECRET aborts setup when an existing (short) secret is present; change the
flow to offer interactive regeneration instead: after calling prompt_secret and
getting REPLY_VALUE for JWT_SECRET, if ${`#REPLY_VALUE`} < 32 then prompt the user
with a clear choice to regenerate a new secret (suggest using "openssl rand
-base64 48") or keep the existing value; if they choose regenerate, set
REPLY_VALUE to the newly generated value and re-validate (loop until length >=
32 or the user explicitly accepts a short secret), and only abort if the user
cancels; apply the same interactive regeneration pattern to
INDEXER_POSTGRES_PASSWORD and CONTROL_ENCRYPTION_KEY, using their respective
default generators and write_env_var to persist the final chosen value.
deploy/QUICKSTART.md (1)

6-6: Inconsistencia de mayúsculas/minúsculas en la URL de GitHub respecto a deploy/install.sh.

deploy/install.sh línea 5 usa MANCHTOOLS/power-manage-server (mayúsculas) mientras que aquí (línea 6) y en GITHUB_REPO dentro de install.sh (línea 28) se usa manchtools/power-manage-server (minúsculas). Aunque GitHub normaliza el caso del propietario en la mayoría de los redireccionamientos HTTP, tener tres formas distintas en la documentación de instalación añade fricción al diagnóstico cuando un proxy corporativo o un mirror agresivo respeta el caso. Sugiero unificar a la forma canónica del repositorio (la que usa la PR: manchtools).

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

In `@deploy/QUICKSTART.md` at line 6, Unifica la capitalización del nombre del
repositorio a la forma canónica "manchtools/power-manage-server": replace the
URL string in QUICKSTART.md
("https://raw.githubusercontent.com/manchtools/power-manage-server/main/deploy/install.sh")
so it uses all-lowercase "manchtools", and also ensure the GITHUB_REPO variable
inside deploy/install.sh and any other occurrences (e.g., the
`deploy/install.sh` reference and the curl command in QUICKSTART.md) use the
same lowercase form to avoid inconsistent casing across docs and scripts.
deploy/install.sh (1)

169-177: Mensaje engañoso cuando se ejecuta con NO_PROMPT=1 y .env ya existe.

init_env imprime "guided setup will only prompt for missing values" incluso cuando luego run_setup se invoca con --no-prompt, en cuyo caso no hay prompts. No es funcionalmente incorrecto, pero confunde en el flujo CI / re-runs no interactivos. Podrías condicionar el mensaje a NO_PROMPT.

♻️ Refactor sugerido
 init_env() {
     cd "$INSTALL_DIR"
     if [[ ! -f .env ]]; then
         cp .env.example .env
-        log_info "Created .env from .env.example — guided setup will fill it in."
+        if [[ "$NO_PROMPT" == "1" ]]; then
+            log_info "Created .env from .env.example — fill in real values before re-running with NO_PROMPT=1."
+        else
+            log_info "Created .env from .env.example — guided setup will fill it in."
+        fi
     else
-        log_info ".env already exists — guided setup will only prompt for missing values."
+        if [[ "$NO_PROMPT" == "1" ]]; then
+            log_info ".env already exists — validating directly (NO_PROMPT=1)."
+        else
+            log_info ".env already exists — guided setup will only prompt for missing values."
+        fi
     fi
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deploy/install.sh` around lines 169 - 177, The log message in init_env
currently always says "guided setup will only prompt for missing values" even
when NO_PROMPT=1 is used (which causes run_setup to run with --no-prompt);
update init_env to detect the NO_PROMPT environment variable (or equivalent
flag) and emit a different message when non-interactive: if NO_PROMPT is
set/true, log something like ".env already exists — guided setup will run
non-interactively and not prompt" otherwise keep the existing message about
prompting for missing values; reference the init_env function and the NO_PROMPT
flag so the message aligns with how run_setup is invoked.
internal/api/system_actions_listener.go (1)

246-253: SyncOpSyncUser loop shares one timeout across all userIDs.

AffectedFromEvent currently returns at most one user ID for SyncOpSyncUser, so this is not a live bug. But the API permits a slice, and if a future classifier case yields multiple IDs, a single syncTimeout covering the entire loop means later users can be silently skipped (ctx.Err() == context.DeadlineExceeded) once the budget is consumed by earlier ones — exactly the kind of bounded-drift case the listener API is supposed to surface (or hand to the reconciler). Consider either documenting the "one ID per event" contract on the return type, or scoping the deadline per-user.

♻️ Optional per-user scoping
 			case SyncOpSyncUser:
 				for _, uid := range userIDs {
-					if err := mgr.SyncUserSystemActions(ctx, uid); err != nil {
+					uctx, ucancel := context.WithTimeout(context.WithoutCancel(parent), syncTimeout)
+					if err := mgr.SyncUserSystemActions(uctx, uid); err != nil {
 						logger.Error("system-action listener: sync user failed",
 							"user_id", uid, "event_type", e.EventType, "event_id", e.ID, "error", err)
 					}
+					ucancel()
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/system_actions_listener.go` around lines 246 - 253, The
SyncOpSyncUser loop uses a shared timeout for all userIDs which can let one slow
user exhaust the deadline and silently skip others; inside the case for
SyncOpSyncUser, scope the deadline per-user by creating a new context with
context.WithTimeout(ctx, syncTimeout) for each uid, call
mgr.SyncUserSystemActions with that per-user ctx, and ensure you call cancel()
(or defer cancel in a short-lived closure) each iteration; alternatively, if you
intend to keep a single-ID invariant, add a clear comment on the contract for
userIDs returned by AffectedFromEvent and validate/assert len(userIDs) <= 1 in
the SyncOpSyncUser branch (referencing SyncOpSyncUser, userIDs, syncTimeout, and
mgr.SyncUserSystemActions).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@deploy/setup_test.sh`:
- Around line 226-234: Replace the heuristic that assumes a single failure is
the synthetic meta-test by explicitly tracking the meta-test outcome (e.g., set
META_FAIL=1 or 0 based on the result of case_meta_failure) and compute
REAL_FAILS=$((FAIL_COUNT - META_FAIL)); ensure REAL_FAILS is not negative (clamp
to 0), update the summary echo to show the meta-test status and real fail count,
and keep the final exit condition as [[ $REAL_FAILS -eq 0 ]] so the script only
reports success when no real tests failed.

In `@internal/api/terminal_handler.go`:
- Around line 287-294: The branch that checks
h.registry.LookupGatewayInternalURL and immediately returns
ErrGatewayNotRegistered when the internal URL exists but the terminal URL is
missing can misclassify transient key-skew; update the logic inside that branch
to perform a short dampened retry before returning the
persistent-misconfiguration error: when internalErr == nil and terminal URL
missing, retry a few times (e.g., 2–3 attempts) with small sleeps/backoff and
re-check the terminal key via the same registry lookup path (the code around
h.registry.LookupGatewayInternalURL in terminal_handler.go), and only after
retries still fail return the apiErrorCtx(ErrGatewayNotRegistered, ...); this
keeps the rest of the handler unchanged but avoids false positives from
independent goroutine ticker drift.

---

Duplicate comments:
In `@internal/api/system_actions_listener.go`:
- Around line 64-66: The comment referencing "The role case is the only place
this fallback is meaningful" is stale because the group branch now also falls
back to StreamID for composite "groupID:userID" emitters; update the
parenthetical in the comment (near the StreamID fallback logic and the group
membership branch handling composite emitters) to reflect that the fallback is
meaningful for both role and composite group:user emitters, or remove the
parenthetical entirely so the rationale matches the actual StreamID fallback
behavior.

---

Nitpick comments:
In `@cmd/control/main.go`:
- Around line 866-885: Detect when the configured SystemActionReconcileInterval
was clamped or when the (possibly-clamped) SystemActionReconcileInterval is less
than SystemActionReconcileTimeout and emit a warning via slog.Warn so operators
know their values were adjusted; specifically, capture the original interval
parsed (before modifying cfg.SystemActionReconcileInterval), perform the
existing clamp logic, then if original != cfg.SystemActionReconcileInterval or
cfg.SystemActionReconcileInterval < cfg.SystemActionReconcileTimeout call
slog.Warn with a clear message referencing SystemActionReconcileInterval and
SystemActionReconcileTimeout (same file’s cfg variables and StartReconciliation
semantics) so the log explains the original value and the applied clamped value.

In `@deploy/install.sh`:
- Around line 169-177: The log message in init_env currently always says "guided
setup will only prompt for missing values" even when NO_PROMPT=1 is used (which
causes run_setup to run with --no-prompt); update init_env to detect the
NO_PROMPT environment variable (or equivalent flag) and emit a different message
when non-interactive: if NO_PROMPT is set/true, log something like ".env already
exists — guided setup will run non-interactively and not prompt" otherwise keep
the existing message about prompting for missing values; reference the init_env
function and the NO_PROMPT flag so the message aligns with how run_setup is
invoked.

In `@deploy/QUICKSTART.md`:
- Line 6: Unifica la capitalización del nombre del repositorio a la forma
canónica "manchtools/power-manage-server": replace the URL string in
QUICKSTART.md
("https://raw.githubusercontent.com/manchtools/power-manage-server/main/deploy/install.sh")
so it uses all-lowercase "manchtools", and also ensure the GITHUB_REPO variable
inside deploy/install.sh and any other occurrences (e.g., the
`deploy/install.sh` reference and the curl command in QUICKSTART.md) use the
same lowercase form to avoid inconsistent casing across docs and scripts.

In `@deploy/setup.sh`:
- Around line 484-494: Actualmente el script siempre reescribe
GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE y GATEWAY_WEB_LISTEN_ADDR usando
write_env_var tras leer GATEWAY_TTY_DOMAIN; cambia esto para que antes de llamar
a write_env_var compruebes el valor existente de esas dos variables (leer desde
el .env o la función/variable que almacene valores actuales) y sólo
compongas/reescribas si están vacías o coinciden con un patrón/placeholder
auto-generado; en caso contrario, preserva el valor personalizado del operador.
Refiérete a GATEWAY_TTY_DOMAIN, GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE,
GATEWAY_WEB_LISTEN_ADDR y la invocación write_env_var en el bloque donde
actualmente se hace la composición para implementar la comprobación y la rama de
no-sobrescritura.
- Around line 543-548: The current length check for JWT_SECRET aborts setup when
an existing (short) secret is present; change the flow to offer interactive
regeneration instead: after calling prompt_secret and getting REPLY_VALUE for
JWT_SECRET, if ${`#REPLY_VALUE`} < 32 then prompt the user with a clear choice to
regenerate a new secret (suggest using "openssl rand -base64 48") or keep the
existing value; if they choose regenerate, set REPLY_VALUE to the newly
generated value and re-validate (loop until length >= 32 or the user explicitly
accepts a short secret), and only abort if the user cancels; apply the same
interactive regeneration pattern to INDEXER_POSTGRES_PASSWORD and
CONTROL_ENCRYPTION_KEY, using their respective default generators and
write_env_var to persist the final chosen value.

In `@internal/api/system_actions_listener.go`:
- Around line 246-253: The SyncOpSyncUser loop uses a shared timeout for all
userIDs which can let one slow user exhaust the deadline and silently skip
others; inside the case for SyncOpSyncUser, scope the deadline per-user by
creating a new context with context.WithTimeout(ctx, syncTimeout) for each uid,
call mgr.SyncUserSystemActions with that per-user ctx, and ensure you call
cancel() (or defer cancel in a short-lived closure) each iteration;
alternatively, if you intend to keep a single-ID invariant, add a clear comment
on the contract for userIDs returned by AffectedFromEvent and validate/assert
len(userIDs) <= 1 in the SyncOpSyncUser branch (referencing SyncOpSyncUser,
userIDs, syncTimeout, and mgr.SyncUserSystemActions).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ecf634a0-774d-403c-9713-fd893e82d6c3

📥 Commits

Reviewing files that changed from the base of the PR and between 26accec and 713c265.

📒 Files selected for processing (20)
  • cmd/control/main.go
  • cmd/gateway/main.go
  • deploy/.env.example
  • deploy/QUICKSTART.md
  • deploy/install.sh
  • deploy/setup.sh
  • deploy/setup_test.sh
  • internal/api/errors.go
  • internal/api/system_actions.go
  • internal/api/system_actions_listener.go
  • internal/api/system_actions_listener_test.go
  • internal/api/terminal_handler.go
  • internal/api/terminal_handler_test.go
  • internal/api/user_handler.go
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/scim/handler.go
  • internal/scim/handler_test.go
  • internal/scim/users.go
  • internal/store/store.go

Comment thread deploy/setup_test.sh
Comment thread internal/api/terminal_handler.go
Two minor CodeRabbit findings on round 5. Untracked server/scripts/
seed_action_library.sh stays out of this PR — separate test-infra
dev-tool that doesn't belong in rc11's runtime scope.

Round 6 finding 1 — terminal_handler.go:294
-------------------------------------------
The persistent-vs-transient terminal-error split classified a
"terminal URL missing but internal URL present" state as persistent
operator misconfig immediately. But the two registry keys
(pm:gateway:terminal:<id> and pm:gateway:internal:<id>) are
refreshed by independent goroutines with independent tickers — same
TTL and same refresh interval, but ticker drift can let the
terminal-URL key expire ~tens of ms before the internal-URL key
gets re-extended. That race surfaced as a false-positive operator
alarm ("set GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE") for what was
actually transient skew.

Added a drift-tolerant retry: when the internal-URL probe says the
gateway is alive, re-look up the terminal URL up to 3 times with
30ms backoff before classifying as persistent. Total worst-case
latency added to the legitimate persistent-misconfig path: 90ms.
Retries respect ctx cancellation and bail on non-ErrNoGateway
errors so a wedged registry doesn't extend the wait. If the
terminal URL recovers during the retry window we return it
normally — operator never sees a misclassified error.

A structural fix (couple both registrations into one shared refresh
goroutine) would eliminate the drift class entirely; deferred to
2026.06 — touches gateway boot wiring that's out of rc11 scope.

Round 6 finding 2 — deploy/setup_test.sh:234
--------------------------------------------
The "synthetic only" detection used FAIL_COUNT == 1 → REAL_FAILS = 0,
which can mask a regression: if a real test fails AND a future
refactor accidentally makes the synthetic case pass (return 1 →
return 0), both conditions yield FAIL_COUNT == 1 but real_fails != 0
and the suite reports green.

Replaced the heuristic with explicit before/after tracking around
the meta-test invocation. If META_FAILED != 1 the harness exits 2
with an "expected synthetic failure to fail" diagnostic — the only
way the meta-test can pass is if the test framework itself is
broken, and that should fail loudly rather than silently invert the
real-fails count.

Verified
--------
- bash deploy/setup_test.sh: 12 real + 1 synthetic, all expected
- go test ./internal/api/ -run TestStartTerminal_GatewayNotRegistered:
  both persistent + transient cases pass; persistent path takes
  ~90ms longer due to retry loop (within timeout budget)
- go vet ./... clean
- go build ./... clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: system actions as a derived projection — event listener + periodic reconciler safety net

1 participant