Skip to content

feat(cli): add [auth.smtp] section to config-as-code with env() password resolution#123

Open
tonychang04 wants to merge 4 commits into
mainfrom
feat/insforge-toml-smtp
Open

feat(cli): add [auth.smtp] section to config-as-code with env() password resolution#123
tonychang04 wants to merge 4 commits into
mainfrom
feat/insforge-toml-smtp

Conversation

@tonychang04
Copy link
Copy Markdown
Contributor

@tonychang04 tonychang04 commented May 12, 2026

Summary

Step 2 of the SMTP config-as-code initiative. Adds [auth.smtp] to the declarative TOML surface with secret-aware password handling.

Companion PR: InsForge/InsForge#1258 (backend — exposes smtpConfig in admin /api/metadata). Required for this CLI feature to work — without it, config apply correctly puts SMTP changes in `skipped[]` per the capability gate.

What ships

Schema

Capability gate

  • metadataSupports() extended to probe raw.auth.smtpConfig presence
  • SMTP changes against an older backend (no smtpConfig in metadata) land in skipped[] with the upgrade message; no PUT issued — same protocol as redirect URLs

env() ref resolution

  • New resolveEnvRef(envRef, fieldPath) in src/lib/config-secrets.ts
  • Calls /api/secrets/:name pre-flight before any PUT — missing/inactive secrets fail fast with actionable error (SECRET_NOT_FOUND / SECRET_EMPTY) naming the exact secrets add command
  • Resolution source is InsForge secrets store only (not local env vars) — secrets are shared per-project across teammates and CI

Diff layer

  • DiffChange discriminated union: auth (redirect URLs) | auth.smtp (whole-object upsert)
  • Force-resend password semantics: when TOML carries password = \"env(NAME)\", every `apply` resolves the current secrets-store value and re-sends. Reason: backend ciphertext can't be diffed against client-side, and re-sending matches user mental model ("I just rotated SMTP_PASSWORD, re-apply should propagate it")
  • Password slot in plan output is always an opaque marker (`(set)`, `(unset)`, `env(NAME)`, `(unchanged)`) — actual values never appear

Export

  • When backend reports `hasPassword: true`, emits `password = "env(SMTP_PASSWORD)"` as a deterministic placeholder + discovery comment (`# password is managed via secrets — run \`insforge secrets add SMTP_PASSWORD \``) so the workflow is learnable from a fresh export without reading skill docs
  • When `hasPassword: false`, omits the password line entirely

Apply

  • New variant in `applyChange()` for `auth.smtp`: builds the upsert body, resolves env() ref pre-flight, PUTs `/api/auth/smtp-config`
  • Omitting `password` from the TOML omits it from the PUT body — backend's upsert preserves the existing encrypted value (matches our "absent = preserve" semantics)

Version bump

0.1.75 → 0.1.76

Tests

  • 51 config-flow unit tests pass (was 38 pre-SMTP)
  • New coverage: SMTP schema parsing + literal-password rejection, TOML roundtrip with placeholder, diff force-resend semantics, env() resolution failure modes (404 / empty / non-200), apply path with secrets-store mock, capability skip when backend predates the field
  • Build clean (`npm run build`)
  • Lint clean (`npx eslint src/` — 0 errors; 39 pre-existing warnings unrelated)

Test plan

  • Unit tests cover schema, capability gate, diff, env() resolution, apply, export
  • E2E against local backend with #1258 (deferred — docker not available locally this session; recommend post-merge of #1258, before tagging the npm release)
  • Run `config apply` against a cloud project once #1258 is deployed

Design notes captured in code

The trickier decisions are documented inline:

  • Force-resend rationale: `src/lib/config-diff.ts` `diffSmtp()`
  • env() resolution from InsForge secrets store (not local env): `src/lib/config-secrets.ts` `resolveEnvRef()` JSDoc
  • Default-keep vs explicit-set for password: `src/commands/config/apply.ts` `applyChange()` comment
  • Why password is never round-tripped on export: `src/commands/config/export.ts` SMTP block

Followups (out of scope here)

  • Skills PR: re-introduce the SMTP TOML example we trimmed last session — now it actually works. Document the placeholder convention, `hasPassword` round-trip, and force-resend plan output.
  • E2E after #1258 is deployed.

🤖 Generated with Claude Code


Summary by cubic

Adds [auth.smtp] to insforge.toml with secret-aware env(NAME) password handling and CLI apply/export. Also fixes secret lookup to return SECRET_NOT_FOUND and adds a safe typeof-guard in export to avoid crashes on malformed metadata.

  • New Features

    • Schema: [auth.smtp] with enabled, host, port, username, password (must be env(NAME)), sender_email, sender_name, min_interval_seconds; literal passwords are rejected.
    • Secrets: env(NAME) resolves only from the InsForge secrets store before apply; clear errors for missing (SECRET_NOT_FOUND) or inactive (SECRET_EMPTY); no local env usage.
    • Apply: PUT /api/auth/smtp-config; omitting password preserves the current encrypted value; when password = "env(NAME)" is present, it is force-resent on each apply; plan output uses opaque markers only.
    • Export: when a password exists server-side, emits password = "env(SMTP_PASSWORD)" with a discovery comment; otherwise omits the line.
    • Capability gate: SMTP changes are applied/exported only when /api/metadata exposes auth.smtpConfig; otherwise they appear in skipped[].
  • Bug Fixes

    • Secret lookup now detects 4xx paths where ossFetch throws: missing secrets surface as SECRET_NOT_FOUND with an insforge secrets add NAME "<value>" hint.
    • Export now typeof-guards auth before checking for smtpConfig to avoid a TypeError on malformed metadata; falls back to skipped[] as intended.

Written for commit 88842d8. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Add auth.smtp support: SMTP settings in config files (host, port, username, sender, min interval). Export and apply include an auth.smtp section, show focused SMTP diffs, and render password as an env-secret placeholder when present.
    • Passwords resolved from secrets during apply; missing/empty secrets produce clear errors with actionable hints.
  • Chores
    • Bumped package version to 0.1.76.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0bea52ac-9d59-4ab6-bca8-2ecc3eaaa3b6

📥 Commits

Reviewing files that changed from the base of the PR and between b30bd9c and 88842d8.

📒 Files selected for processing (1)
  • src/commands/config/export.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands/config/export.ts

Walkthrough

This PR adds SMTP email configuration support to the InsForge CLI config system, extending the existing config-as-code pipeline to handle SMTP fields in insforge.toml, validate them against backend capabilities, compute diffs, resolve secrets, and apply changes to the backend.

Changes

SMTP Configuration Support

Layer / File(s) Summary
SMTP types, schema validation, and support detection
src/lib/config-schema.ts, src/lib/config-diff.ts, src/lib/config-capabilities.ts
SmtpConfig interface with optional SMTP fields; AuthConfig.smtp extension; SmtpDiffView and LiveSmtpState diff view types; LiveConfig mirrors live backend auth structure; metadataSupports and changePath detect and format auth.smtp changes.
TOML parsing and serialization for SMTP
src/lib/config-toml.ts, src/lib/config-toml.test.ts
parseConfigToml validates SMTP fields and env()-style password references; stringifyConfigToml renders [auth.smtp] sections with deterministic order and secrets-management comments; round-trip tests verify parsing and serialization.
Secrets resolution for env() references
src/lib/config-secrets.ts, src/lib/config-secrets.test.ts
New resolveEnvRef fetches decrypted values from /api/secrets backend, throws specific CLIError codes for 404/empty/network failures with actionable hints, and succeeds with secret value; error paths and env-ref defensiveness tested.
Config diffing for SMTP changes
src/lib/config-diff.ts, src/lib/config-diff.test.ts, src/lib/config-format.ts
diffSmtp compares live SMTP state against TOML, emits single auth.smtp change or no-op; password force-resend triggered by env refs; renderLiveSmtp and renderFileSmtp convert states to SmtpDiffView; formatChange renders SMTP diffs as multi-line blocks showing differing fields; comprehensive test coverage for no-op, force-resend, and combined redirect+SMTP scenarios.
Export command SMTP support
src/commands/config/export.ts, src/commands/config/export.test.ts
registerConfigExportCommand fetches RawMetadataResponse metadata, probes for smtpConfig, emits [auth.smtp] section with env(SMTP_PASSWORD) placeholder when hasPassword is true, records auth.smtp as skipped when absent; password omission, presence, and backward compatibility with older backends tested.
Apply command SMTP change handling
src/commands/config/apply.ts, src/commands/config/apply.test.ts
registerConfigApplyCommand constructs LiveConfig from metadata, diffs against TOML; applyChange handles auth.smtp by pre-resolving password secrets via resolveEnvRef (failing early on missing secrets), omitting password to preserve backend encrypted state, and PUTting to /api/auth/smtp-config; exhaustiveness check validates all change types; secret resolution, missing secret errors, password omission, and backend capability gating tested.
Package version bump
package.json
Version incremented from 0.1.75 to 0.1.76.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • InsForge/CLI#109: Yes—both PRs modify the same insforge config export/plan/apply pipeline (notably src/commands/config/apply.ts, export.ts, src/lib/config-capabilities.ts, src/lib/config-diff.ts, and src/lib/config-format.ts), with the main PR extending the MVP’s [auth] allowed_redirect_urls flow to additionally generate/apply auth.smtp changes.

Suggested reviewers

  • jwfing

Poem

🐰 A rabbit's ode to email
Env refs hop from field to vault,
Secrets fetched without a fault,
SMTP diffs now sing in code,
TOML, backend—together strode! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.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 accurately describes the main feature: adding SMTP configuration support to the CLI's config-as-code system with environment variable password resolution.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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/insforge-toml-smtp

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/config/apply.ts (1)

102-113: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preflight SMTP secret resolution before applying any change to prevent partial applies.

With mixed batches, auth.allowed_redirect_urls can be PUT first, then fail on SMTP secret resolution later. That leaves the run partially applied.

Suggested direction
+// 1) Pre-resolve all secret refs for planned SMTP changes before any PUT.
+const resolvedSmtpPasswords = new Map<DiffChange, string>();
+for (const change of result.changes) {
+  if (change.section === 'auth.smtp' && change.passwordEnvRef) {
+    const value = await resolveEnvRef(`env(${change.passwordEnvRef})`, 'auth.smtp.password');
+    resolvedSmtpPasswords.set(change, value);
+  }
+}
+
 // 2) Apply changes only after preflight succeeds.
 for (const change of result.changes) {
   ...
-  await applyChange(change);
+  await applyChange(change, resolvedSmtpPasswords.get(change));
   applied.push(change);
 }

And update applyChange to accept an optional pre-resolved password and avoid resolving during mutation.

Also applies to: 186-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.ts` around lines 102 - 113, Preflight resolution of
SMTP secrets for every change before any mutation: iterate result.changes and
for each change whose path may require secrets (e.g., SMTP/password fields)
resolve the secret ahead of time and collect failures into skipped so no partial
applies occur; then call applyChange(change, preResolvedSecret?) instead of
resolving inside applyChange. Update the applyChange function signature to
accept an optional preResolvedPassword (or credentials object) and ensure it
uses that value if provided and does not attempt resolution during the mutation.
Apply the same preflight logic to the second batch processing block that also
iterates result.changes (the other loop around applyChange) so both places
resolve SMTP secrets before making any PUTs and mark failing resolutions as
skipped.
🧹 Nitpick comments (1)
src/lib/config-secrets.test.ts (1)

83-137: ⚡ Quick win

Add a malformed-JSON lookup test for resolveEnvRef.

You’re asserting major failure modes already; adding a 200 + invalid JSON case will lock in stable error behavior for backend/proxy anomalies.

Proposed test case
 describe('resolveEnvRef', () => {
+  it('throws SECRET_LOOKUP_FAILED when 200 response is not valid JSON', async () => {
+    ossFetchMock.mockResolvedValueOnce(new Response('not-json', { status: 200 }));
+    await expect(
+      resolveEnvRef('env(SMTP_PASSWORD)', 'auth.smtp.password'),
+    ).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' });
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-secrets.test.ts` around lines 83 - 137, Add a test inside the
describe('resolveEnvRef') block that simulates a 200 response with malformed
JSON from ossFetchMock (e.g., ossFetchMock.mockResolvedValueOnce(new
Response('invalid', { status: 200 })) ) when calling
resolveEnvRef('env(BROKEN)', 'auth.smtp.password') and assert it rejects with
the same error shape used for HTTP lookup failures (e.g., toMatchObject({ code:
'SECRET_LOOKUP_FAILED' })); reference resolveEnvRef, ossFetchMock and the
existing SECRET_LOOKUP_FAILED assertions to mirror the error expectation and
placement alongside the other cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/config/export.ts`:
- Around line 83-84: The guard using "'smtpConfig' in authSlice" can throw if
authSlice is not an object; update the conditional in export command (the block
referencing authSlice and smtpConfig) to first verify authSlice is a non-null
object (e.g., typeof authSlice === 'object' && authSlice !== null) before using
the 'in' operator and accessing authSlice.smtpConfig, so malformed metadata
yields a controlled CLI error path rather than a runtime TypeError.

In `@src/lib/config-format.ts`:
- Around line 51-53: The note rendering incorrectly nests env(...) when
c.passwordEnvRef already includes env(...) — normalize c.passwordEnvRef before
pushing the line: check and strip any surrounding "env(" and trailing ")" (or
detect if it already starts with "env(") so you always produce a single env(...)
wrapper; update the code around the lines.push call that references
c.passwordEnvRef to use the normalized value (e.g., compute a local
normalizedPasswordRef and then use lines.push(`    (password force-resent from
env(${normalizedPasswordRef}))`)) so duplicate env(...) is avoided.

In `@src/lib/config-schema.ts`:
- Around line 113-118: The SMTP port validation currently only checks for
integer type in the block handling 'port' and allows out-of-range values; update
the check in the same conditional (the code that assigns out.port) to validate
that obj.port is an integer within the TCP port range 1..65535 and throw
ConfigValidationError('auth.smtp.port', '<message>') when it's not; keep the
existing type/integer validation but extend it to enforce the range before
assigning out.port so invalid values like -1 or 70000 are rejected.

In `@src/lib/config-secrets.ts`:
- Around line 130-131: The JSON parsing for the secret response is unguarded:
wrap the call to res.json() in a try/catch and validate HTTP status before
trusting the body so parsing errors or non-2xx responses produce a consistent
CLIError; specifically, in the function that handles the fetch response (the
block using res and const body = (await res.json()) as { value?: string }),
first check res.ok and throw a CLIError with context if not ok, then try to
parse res.json() inside a try/catch and on any exception or if body.value is not
a non-empty string throw a CLIError describing the failure to retrieve/parse the
secret (include res.status/res.statusText or the caught error.message for
debugging).

In `@src/lib/config-toml.ts`:
- Around line 56-61: The emitted guidance hardcodes "SMTP_PASSWORD" even though
smtp.password contains the actual env reference; update the logic that builds
the comment (where lines.push is called for smtp.password in config-toml.ts) to
extract the secret/env name from smtp.password (the env(...) string) and
interpolate that name into the comment instead of the literal SMTP_PASSWORD,
falling back to a generic message if extraction fails. Ensure you reference and
parse smtp.password (the env(...) value) to compute the secret name used in the
comment and then push the comment using that extracted name.

---

Outside diff comments:
In `@src/commands/config/apply.ts`:
- Around line 102-113: Preflight resolution of SMTP secrets for every change
before any mutation: iterate result.changes and for each change whose path may
require secrets (e.g., SMTP/password fields) resolve the secret ahead of time
and collect failures into skipped so no partial applies occur; then call
applyChange(change, preResolvedSecret?) instead of resolving inside applyChange.
Update the applyChange function signature to accept an optional
preResolvedPassword (or credentials object) and ensure it uses that value if
provided and does not attempt resolution during the mutation. Apply the same
preflight logic to the second batch processing block that also iterates
result.changes (the other loop around applyChange) so both places resolve SMTP
secrets before making any PUTs and mark failing resolutions as skipped.

---

Nitpick comments:
In `@src/lib/config-secrets.test.ts`:
- Around line 83-137: Add a test inside the describe('resolveEnvRef') block that
simulates a 200 response with malformed JSON from ossFetchMock (e.g.,
ossFetchMock.mockResolvedValueOnce(new Response('invalid', { status: 200 })) )
when calling resolveEnvRef('env(BROKEN)', 'auth.smtp.password') and assert it
rejects with the same error shape used for HTTP lookup failures (e.g.,
toMatchObject({ code: 'SECRET_LOOKUP_FAILED' })); reference resolveEnvRef,
ossFetchMock and the existing SECRET_LOOKUP_FAILED assertions to mirror the
error expectation and placement alongside the other cases.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c19f54ee-fe1e-4af7-91cf-bbe4d02adda8

📥 Commits

Reviewing files that changed from the base of the PR and between 5d3c306 and 449e49d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (14)
  • package.json
  • src/commands/config/apply.test.ts
  • src/commands/config/apply.ts
  • src/commands/config/export.test.ts
  • src/commands/config/export.ts
  • src/lib/config-capabilities.ts
  • src/lib/config-diff.test.ts
  • src/lib/config-diff.ts
  • src/lib/config-format.ts
  • src/lib/config-schema.ts
  • src/lib/config-secrets.test.ts
  • src/lib/config-secrets.ts
  • src/lib/config-toml.test.ts
  • src/lib/config-toml.ts

Comment thread src/commands/config/export.ts Outdated
Comment thread src/lib/config-format.ts
Comment on lines +51 to +53
if (c.passwordEnvRef) {
lines.push(` (password force-resent from env(${c.passwordEnvRef}))`);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize passwordEnvRef before rendering the force-resend note.

Current formatting can produce env(env(...)) depending on what passwordEnvRef carries.

Proposed fix
     if (c.passwordEnvRef) {
-      lines.push(`    (password force-resent from env(${c.passwordEnvRef}))`);
+      const ref = c.passwordEnvRef.startsWith('env(')
+        ? c.passwordEnvRef
+        : `env(${c.passwordEnvRef})`;
+      lines.push(`    (password force-resent from ${ref})`);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-format.ts` around lines 51 - 53, The note rendering
incorrectly nests env(...) when c.passwordEnvRef already includes env(...) —
normalize c.passwordEnvRef before pushing the line: check and strip any
surrounding "env(" and trailing ")" (or detect if it already starts with "env(")
so you always produce a single env(...) wrapper; update the code around the
lines.push call that references c.passwordEnvRef to use the normalized value
(e.g., compute a local normalizedPasswordRef and then use lines.push(`   
(password force-resent from env(${normalizedPasswordRef}))`)) so duplicate
env(...) is avoided.

Comment thread src/lib/config-schema.ts
Comment on lines +113 to +118
if ('port' in obj) {
if (typeof obj.port !== 'number' || !Number.isInteger(obj.port)) {
throw new ConfigValidationError('auth.smtp.port', 'must be an integer');
}
out.port = obj.port;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Constrain SMTP port to valid TCP range.

auth.smtp.port accepts any integer right now, including invalid values like -1 or 70000.

Proposed fix
   if ('port' in obj) {
-    if (typeof obj.port !== 'number' || !Number.isInteger(obj.port)) {
-      throw new ConfigValidationError('auth.smtp.port', 'must be an integer');
+    if (
+      typeof obj.port !== 'number' ||
+      !Number.isInteger(obj.port) ||
+      obj.port < 1 ||
+      obj.port > 65535
+    ) {
+      throw new ConfigValidationError('auth.smtp.port', 'must be an integer between 1 and 65535');
     }
     out.port = obj.port;
   }
📝 Committable suggestion

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

Suggested change
if ('port' in obj) {
if (typeof obj.port !== 'number' || !Number.isInteger(obj.port)) {
throw new ConfigValidationError('auth.smtp.port', 'must be an integer');
}
out.port = obj.port;
}
if ('port' in obj) {
if (
typeof obj.port !== 'number' ||
!Number.isInteger(obj.port) ||
obj.port < 1 ||
obj.port > 65535
) {
throw new ConfigValidationError('auth.smtp.port', 'must be an integer between 1 and 65535');
}
out.port = obj.port;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-schema.ts` around lines 113 - 118, The SMTP port validation
currently only checks for integer type in the block handling 'port' and allows
out-of-range values; update the check in the same conditional (the code that
assigns out.port) to validate that obj.port is an integer within the TCP port
range 1..65535 and throw ConfigValidationError('auth.smtp.port', '<message>')
when it's not; keep the existing type/integer validation but extend it to
enforce the range before assigning out.port so invalid values like -1 or 70000
are rejected.

Comment thread src/lib/config-secrets.ts
Comment on lines +130 to +131
const body = (await res.json()) as { value?: string };
if (typeof body.value !== 'string' || body.value.length === 0) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard JSON parsing for successful secret lookups.

A 2xx response with invalid/empty JSON will currently throw an untyped parse error instead of a consistent CLIError, which makes config apply failure handling brittle.

Proposed fix
-  const body = (await res.json()) as { value?: string };
+  let body: { value?: string };
+  try {
+    body = (await res.json()) as { value?: string };
+  } catch {
+    throw new CLIError(
+      `failed to resolve env(${secretName}) for ${fieldPath}: invalid response body`,
+      1,
+      'SECRET_LOOKUP_FAILED',
+    );
+  }
📝 Committable suggestion

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

Suggested change
const body = (await res.json()) as { value?: string };
if (typeof body.value !== 'string' || body.value.length === 0) {
let body: { value?: string };
try {
body = (await res.json()) as { value?: string };
} catch {
throw new CLIError(
`failed to resolve env(${secretName}) for ${fieldPath}: invalid response body`,
1,
'SECRET_LOOKUP_FAILED',
);
}
if (typeof body.value !== 'string' || body.value.length === 0) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-secrets.ts` around lines 130 - 131, The JSON parsing for the
secret response is unguarded: wrap the call to res.json() in a try/catch and
validate HTTP status before trusting the body so parsing errors or non-2xx
responses produce a consistent CLIError; specifically, in the function that
handles the fetch response (the block using res and const body = (await
res.json()) as { value?: string }), first check res.ok and throw a CLIError with
context if not ok, then try to parse res.json() inside a try/catch and on any
exception or if body.value is not a non-empty string throw a CLIError describing
the failure to retrieve/parse the secret (include res.status/res.statusText or
the caught error.message for debugging).

Comment thread src/lib/config-toml.ts
Comment on lines +56 to +61
if (smtp.password !== undefined) {
// password is always an env() ref at this point (schema validator rejects
// literals at parse time). Emit a comment about how to set the actual
// value so the user can discover the workflow from a fresh export.
lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
lines.push(`password = ${JSON.stringify(smtp.password)}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid hardcoding SMTP_PASSWORD in emitted guidance.

The rendered comment can disagree with the actual password = "env(...)" value when users choose a different secret name.

Proposed fix
-    lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
+    lines.push('# password is managed via secrets — run `insforge secrets add <NAME> <value>`');
📝 Committable suggestion

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

Suggested change
if (smtp.password !== undefined) {
// password is always an env() ref at this point (schema validator rejects
// literals at parse time). Emit a comment about how to set the actual
// value so the user can discover the workflow from a fresh export.
lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
lines.push(`password = ${JSON.stringify(smtp.password)}`);
if (smtp.password !== undefined) {
// password is always an env() ref at this point (schema validator rejects
// literals at parse time). Emit a comment about how to set the actual
// value so the user can discover the workflow from a fresh export.
lines.push('# password is managed via secrets — run `insforge secrets add <NAME> <value>`');
lines.push(`password = ${JSON.stringify(smtp.password)}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-toml.ts` around lines 56 - 61, The emitted guidance hardcodes
"SMTP_PASSWORD" even though smtp.password contains the actual env reference;
update the logic that builds the comment (where lines.push is called for
smtp.password in config-toml.ts) to extract the secret/env name from
smtp.password (the env(...) string) and interpolate that name into the comment
instead of the literal SMTP_PASSWORD, falling back to a generic message if
extraction fails. Ensure you reference and parse smtp.password (the env(...)
value) to compute the secret name used in the comment and then push the comment
using that extracted name.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

4 issues found across 15 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/config/export.ts">

<violation number="1" location="src/commands/config/export.ts:83">
P2: Guard `authSlice` as an object before using the `in` operator to avoid runtime `TypeError` on malformed metadata responses.</violation>
</file>

<file name="src/lib/config-secrets.ts">

<violation number="1" location="src/lib/config-secrets.ts:102">
P1: `resolveEnvRef` relies on `ossFetch` returning 404 responses, but `ossFetch` throws on non-2xx, so the 404/`!res.ok` branches here are unreachable and missing secrets are misclassified as `SECRET_LOOKUP_FAILED`.</violation>
</file>

<file name="src/lib/config-toml.ts">

<violation number="1" location="src/lib/config-toml.ts:60">
P3: Avoid hardcoding `SMTP_PASSWORD` in the emitted guidance so instructions don't conflict with a custom `env(...)` secret name.</violation>
</file>

<file name="src/lib/config-schema.ts">

<violation number="1" location="src/lib/config-schema.ts:114">
P2: Validate `auth.smtp.port` against the valid TCP range (1-65535); integer-only validation currently allows invalid ports like -1 and 70000.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

Comment thread src/lib/config-secrets.ts
Comment thread src/commands/config/export.ts Outdated
Comment thread src/lib/config-schema.ts
Comment on lines +114 to +115
if (typeof obj.port !== 'number' || !Number.isInteger(obj.port)) {
throw new ConfigValidationError('auth.smtp.port', 'must be an integer');
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 12, 2026

Choose a reason for hiding this comment

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

P2: Validate auth.smtp.port against the valid TCP range (1-65535); integer-only validation currently allows invalid ports like -1 and 70000.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/config-schema.ts, line 114:

<comment>Validate `auth.smtp.port` against the valid TCP range (1-65535); integer-only validation currently allows invalid ports like -1 and 70000.</comment>

<file context>
@@ -62,5 +84,83 @@ function validateAuth(input: unknown): AuthConfig {
+  }
+
+  if ('port' in obj) {
+    if (typeof obj.port !== 'number' || !Number.isInteger(obj.port)) {
+      throw new ConfigValidationError('auth.smtp.port', 'must be an integer');
+    }
</file context>
Suggested change
if (typeof obj.port !== 'number' || !Number.isInteger(obj.port)) {
throw new ConfigValidationError('auth.smtp.port', 'must be an integer');
if (
typeof obj.port !== 'number' ||
!Number.isInteger(obj.port) ||
obj.port < 1 ||
obj.port > 65535
) {
throw new ConfigValidationError('auth.smtp.port', 'must be an integer between 1 and 65535');
Fix with Cubic

Comment thread src/lib/config-toml.ts
// password is always an env() ref at this point (schema validator rejects
// literals at parse time). Emit a comment about how to set the actual
// value so the user can discover the workflow from a fresh export.
lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 12, 2026

Choose a reason for hiding this comment

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

P3: Avoid hardcoding SMTP_PASSWORD in the emitted guidance so instructions don't conflict with a custom env(...) secret name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/config-toml.ts, line 60:

<comment>Avoid hardcoding `SMTP_PASSWORD` in the emitted guidance so instructions don't conflict with a custom `env(...)` secret name.</comment>

<file context>
@@ -32,7 +37,36 @@ export function stringifyConfigToml(config: InsforgeConfig): string {
+    // password is always an env() ref at this point (schema validator rejects
+    // literals at parse time). Emit a comment about how to set the actual
+    // value so the user can discover the workflow from a fresh export.
+    lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
+    lines.push(`password = ${JSON.stringify(smtp.password)}`);
+  }
</file context>
Suggested change
lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
lines.push('# password is managed via secrets — run `insforge secrets add <NAME> <value>`');
Fix with Cubic

ossFetch throws on any non-2xx response, swallowing the status code. The
resolveEnvRef path that previously checked res.status === 404 never fired
— missing secrets always landed in SECRET_LOOKUP_FAILED.

Recover the signal from the thrown error's message instead (the backend's
NOT_FOUND path uses "Secret not found: NAME" wording). Verified against
live local backend on the SMTP branch:

  $ insforge config apply  # with env(NONEXISTENT) in TOML
  code: SECRET_NOT_FOUND
  msg: auth.smtp.password references env(NONEXISTENT) but no such secret
       exists.
    fix: insforge secrets add NONEXISTENT "<value>"

Unit tests updated to mock ossFetch as throwing (matches real behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/commands/config/apply.test.ts (1)

24-29: ⚡ Quick win

Make missing-secret mock error shape closer to real ossFetch failures.

The mocked throw message (Secret not found: ...) is a bit synthetic compared to non-2xx throw paths. Aligning it with the real thrown format (including 404 signal) will better guard against regressions in resolveEnvRef error parsing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.test.ts` around lines 24 - 29, The mock in
src/commands/config/apply.test.ts should throw an error shaped like real
ossFetch non-2xx failures so resolveEnvRef can parse the 404 signal; change the
thrown Error in the test (currently "Secret not found: ${key}") to include the
HTTP status indicator (e.g. include "404" or "status: 404" and a Not Found
phrase) so error-parsing paths in resolveEnvRef and any ossFetch-related logic
will behave the same as real failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/config/apply.test.ts`:
- Around line 93-97: The shared test state variable nextMetadataResponse is not
reset between tests; update the beforeEach block (the one that calls
vi.clearAllMocks(), secretsStore.clear(), and sets tmp) to also reset
nextMetadataResponse (e.g. set to undefined/null or its initial value) so each
test starts with a clean metadata state and cannot inherit prior test responses.

In `@src/lib/config-secrets.test.ts`:
- Around line 124-127: The test in src/lib/config-secrets.test.ts that currently
does ossFetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
should instead mock the fetch as a rejected promise to mirror runtime behavior;
replace that resolved Response with ossFetchMock.mockRejectedValueOnce(new
Error('boom')) (or an Error-like rejection) so the test exercising the
SECRET_LOOKUP_FAILED mapping uses a thrown error like the real ossFetch and
accurately reflects the code paths in the functions that handle ossFetch
failures.

---

Nitpick comments:
In `@src/commands/config/apply.test.ts`:
- Around line 24-29: The mock in src/commands/config/apply.test.ts should throw
an error shaped like real ossFetch non-2xx failures so resolveEnvRef can parse
the 404 signal; change the thrown Error in the test (currently "Secret not
found: ${key}") to include the HTTP status indicator (e.g. include "404" or
"status: 404" and a Not Found phrase) so error-parsing paths in resolveEnvRef
and any ossFetch-related logic will behave the same as real failures.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 715005df-00aa-491b-9ed3-213fc682fdd2

📥 Commits

Reviewing files that changed from the base of the PR and between 449e49d and b30bd9c.

📒 Files selected for processing (3)
  • src/commands/config/apply.test.ts
  • src/lib/config-secrets.test.ts
  • src/lib/config-secrets.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/config-secrets.ts

Comment on lines 93 to 97
beforeEach(() => {
vi.clearAllMocks();
secretsStore.clear();
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset nextMetadataResponse in beforeEach to prevent cross-test coupling.

nextMetadataResponse is shared mutable state but isn’t reset per test. Add a reset in beforeEach so future tests can’t accidentally inherit previous metadata.

Suggested patch
 beforeEach(() => {
   vi.clearAllMocks();
   secretsStore.clear();
+  nextMetadataResponse = {};
   tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
 });
📝 Committable suggestion

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

Suggested change
beforeEach(() => {
vi.clearAllMocks();
secretsStore.clear();
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});
beforeEach(() => {
vi.clearAllMocks();
secretsStore.clear();
nextMetadataResponse = {};
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.test.ts` around lines 93 - 97, The shared test
state variable nextMetadataResponse is not reset between tests; update the
beforeEach block (the one that calls vi.clearAllMocks(), secretsStore.clear(),
and sets tmp) to also reset nextMetadataResponse (e.g. set to undefined/null or
its initial value) so each test starts with a clean metadata state and cannot
inherit prior test responses.

Comment on lines +124 to +127
it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
ossFetchMock.mockResolvedValueOnce(
new Response('boom', { status: 500 }),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mock non-404 failures as rejected promises to mirror runtime behavior.

This test currently simulates HTTP 500 via a resolved Response, but runtime behavior (per this PR’s own contract) is that ossFetch throws on non-2xx. Keeping this as a rejection makes the test representative and avoids false confidence in SECRET_LOOKUP_FAILED mapping.

Suggested test adjustment
   it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
-    ossFetchMock.mockResolvedValueOnce(
-      new Response('boom', { status: 500 }),
-    );
+    ossFetchMock.mockRejectedValueOnce(
+      new Error('Request failed with status 500'),
+    );
     await expect(
       resolveEnvRef('env(WHATEVER)', 'auth.smtp.password'),
     ).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' });
   });
📝 Committable suggestion

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

Suggested change
it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
ossFetchMock.mockResolvedValueOnce(
new Response('boom', { status: 500 }),
);
it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
ossFetchMock.mockRejectedValueOnce(
new Error('Request failed with status 500'),
);
await expect(
resolveEnvRef('env(WHATEVER)', 'auth.smtp.password'),
).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' });
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-secrets.test.ts` around lines 124 - 127, The test in
src/lib/config-secrets.test.ts that currently does
ossFetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })) should
instead mock the fetch as a rejected promise to mirror runtime behavior; replace
that resolved Response with ossFetchMock.mockRejectedValueOnce(new
Error('boom')) (or an Error-like rejection) so the test exercising the
SECRET_LOOKUP_FAILED mapping uses a thrown error like the real ossFetch and
accurately reflects the code paths in the functions that handle ossFetch
failures.

Mirrors the existing guard on the allowed_redirect_urls branch — without
it, a malformed metadata response (e.g. auth: "x") would TypeError on
'smtpConfig' in authSlice instead of cleanly landing in skipped[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant