Skip to content

fix(link): poll for real database password after project becomes active#122

Merged
tonychang04 merged 2 commits into
mainfrom
fix/poll-real-database-password
May 12, 2026
Merged

fix(link): poll for real database password after project becomes active#122
tonychang04 merged 2 commits into
mainfrom
fix/poll-real-database-password

Conversation

@tonychang04
Copy link
Copy Markdown
Contributor

@tonychang04 tonychang04 commented May 12, 2026

Symptom

User reported that on first link after `create --auth better-auth`, `.env.local` was written with a masked password:

```
DATABASE_URL=postgresql://postgres:********@4e7yvep2.us-east.database.insforge.app:5432/insforge?sslmode=require
```

This URL is unusable — BA's pg pool can't authenticate with it. Re-running `link` does fix it (0.1.73's masked-pattern refresh kicks in), but the first-run experience is broken.

Root cause

The platform flips a project to `status=active` as soon as the container is up, before `/api/metadata/database-password` is populated. During that window the endpoint itself returns ``. Our `spliceDatabasePassword` silently spliced `` into the masked URL — same masked URL out.

Fix

  • Add `isMaskedDatabasePassword(value)` — matches `/^*+$/`
  • Treat a masked response from `/database-password` the same as "not ready"
  • Poll `/database-password` up to 9 times at 2s intervals (~20s budget) before giving up

Most cloud projects finish provisioning within a few seconds, so the common case adds no latency. If the endpoint never returns a real password, we fall back to `null` and the user can re-run `link` — at which point 0.1.73's refresh logic handles the masked URL.

Test plan

  • `npm run build` clean
  • `npm test` — 272/272 pass (8 in oss.test.ts, +4 new for `isMaskedDatabasePassword`)
  • `npm run lint` clean

🤖 Generated with Claude Code


Summary by cubic

Fixes first-time link writing a masked Postgres URL by polling for the real database password after a project becomes active. Prevents unusable postgresql://...:********@... in .env.local and avoids auth failures.

  • Bug Fixes

    • Treat ******** (any run of *) from /api/metadata/database-password as not ready.
    • Poll up to ~20s for a real password; if still missing, return null instead of writing a masked URL.
    • Added isMaskedDatabasePassword and a one-shot fetch helper; updated getDatabaseConnectionString; tests added.
  • Dependencies

    • Bump @insforge/cli to 0.1.76.

Written for commit 7c68220. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Database password retrieval now polls for up to ~20 seconds when credentials are masked during provisioning, improving setup reliability.
    • Connection attempts now tolerate masked credentials and wait for a usable password instead of immediately failing.
  • Tests

    • Added comprehensive tests for masked password detection.

Review Change Stack

Review Change Stack

After `create --auth better-auth` (or any first-time link against a newly
provisioned cloud project), the platform flips `status=active` before
`/api/metadata/database-password` has the real password populated. During
that window the endpoint returns `********` itself, our splice into the
masked URL was a silent no-op, and `.env.local` ended up with
`postgres:********@<host>/insforge?sslmode=require` — an unusable URL
that BA's pg pool can't authenticate with.

Detect the masked-password response and poll briefly (9 retries × 2s =
~20s) before giving up. Most cloud projects finalize within a few
seconds, so the common case sees no added latency. If the endpoint never
returns a real password we fall back to null, the manifest default
(localhost) is written, and the user can re-run `link` later — at which
point 0.1.73's masked-pattern refresh kicks in.

Adds 8 tests covering the new `isMaskedDatabasePassword` helper and
keeps the existing `spliceDatabasePassword` coverage intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@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: 5ba710cf-1e93-4b80-954e-b13e14b4133b

📥 Commits

Reviewing files that changed from the base of the PR and between 90ed153 and 7c68220.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • package.json
✅ Files skipped from review due to trivial changes (1)
  • package.json

Walkthrough

This PR adds masked-password detection and polling to handle the provisioning window where the platform's password endpoint may return a run-of-asterisks placeholder. It introduces isMaskedDatabasePassword, refactors getDatabaseConnectionString() to fetch the URL then poll the password with retries/delays, and adds tests and a version bump.

Changes

Masked Database Password Handling

Layer / File(s) Summary
Mask detection helper and tests
src/lib/api/oss.ts, src/lib/api/oss.test.ts
isMaskedDatabasePassword(value: string): boolean detects passwords that consist only of * characters; tests validate standard and variable-length masks while rejecting real passwords and empty strings.
Password fetching and polling with mask detection
src/lib/api/oss.ts
fetchDatabasePasswordOnce() calls /api/metadata/database-password and returns the password only if it is a non-empty, unmasked string (otherwise null); getDatabaseConnectionString() now fetches the masked URL first, then polls for an unmasked password with ~9 attempts over ~20 seconds, returning null if none found.

Package Version

Layer / File(s) Summary
Version field update
package.json
Updates version from 0.1.750.1.76.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • InsForge/CLI#105: Earlier changes to getDatabaseConnectionString—related foundation for this polling/mask handling update.
  • InsForge/CLI#108: Concurrent fetch approach that previously fetched password and URL together; this PR replaces that with sequential fetch + polling.
  • InsForge/CLI#114: Related edits around masked-password handling and splicing logic in the same module.

Suggested reviewers

  • jwfing

Poem

🐇 I nibble at stars of passwords masked,

Waiting and checking, patient and tasked.
I hop and I poll through the provisioning night,
Till real text returns and all is alight.
🍃 — A rabbit's small, vigilant delight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding polling for an unmasked database password after project activation, which directly addresses the PR's core fix to prevent masked passwords from being written to .env.local.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 fix/poll-real-database-password

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: 1

🧹 Nitpick comments (1)
src/lib/api/oss.test.ts (1)

32-49: ⚡ Quick win

Add behavior tests for polling/fallback path in getDatabaseConnectionString()

Current additions validate the helper, but not the end-to-end polling decision logic. Please add tests for: masked-then-real password, always-masked timeout fallback, and non-retryable endpoint-missing fallback.

🤖 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/api/oss.test.ts` around lines 32 - 49, Add end-to-end tests for
getDatabaseConnectionString that exercise its polling and fallback logic: (1)
"masked-then-real" — mock the password fetch endpoint used by
getDatabaseConnectionString to return a masked value (use
isMaskedDatabasePassword to detect) for the first N calls and then a real
password, use Jest fake timers/advanceTimersByTime to simulate polling and
assert the returned connection string uses the real password; (2) "always-masked
timeout fallback" — mock the endpoint to always return a masked value, advance
timers past the retry timeout and assert getDatabaseConnectionString falls back
to the configured fallback (env/secret) connection string; (3) "non-retryable
endpoint-missing fallback" — mock the endpoint to return a non-retryable error
(e.g., 404 or explicit endpoint-missing response) on first call and assert
getDatabaseConnectionString immediately uses the fallback. Locate tests near
existing isMaskedDatabasePassword tests in src/lib/api/oss.test.ts and stub the
same HTTP/fetch helper used by getDatabaseConnectionString so tests are
deterministic.
🤖 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/lib/api/oss.ts`:
- Around line 98-104: The current loop repeatedly retries on any null from
fetchDatabasePasswordOnce(), causing unnecessary delay for deterministic
failures; update fetchDatabasePasswordOnce() to distinguish retryable vs
non-retryable outcomes (e.g., return { password, retryable } or throw a
PasswordFetchError with a retryable flag) and then change the polling logic that
uses password, POLL_ATTEMPTS and POLL_DELAY_MS to stop retrying immediately when
the result indicates non-retryable failure (break/throw), while only looping
when retryable is true and attempt < POLL_ATTEMPTS.

---

Nitpick comments:
In `@src/lib/api/oss.test.ts`:
- Around line 32-49: Add end-to-end tests for getDatabaseConnectionString that
exercise its polling and fallback logic: (1) "masked-then-real" — mock the
password fetch endpoint used by getDatabaseConnectionString to return a masked
value (use isMaskedDatabasePassword to detect) for the first N calls and then a
real password, use Jest fake timers/advanceTimersByTime to simulate polling and
assert the returned connection string uses the real password; (2) "always-masked
timeout fallback" — mock the endpoint to always return a masked value, advance
timers past the retry timeout and assert getDatabaseConnectionString falls back
to the configured fallback (env/secret) connection string; (3) "non-retryable
endpoint-missing fallback" — mock the endpoint to return a non-retryable error
(e.g., 404 or explicit endpoint-missing response) on first call and assert
getDatabaseConnectionString immediately uses the fallback. Locate tests near
existing isMaskedDatabasePassword tests in src/lib/api/oss.test.ts and stub the
same HTTP/fetch helper used by getDatabaseConnectionString so tests are
deterministic.
🪄 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: 5c531084-453c-4158-95e4-45d3b47a5dd0

📥 Commits

Reviewing files that changed from the base of the PR and between 5d3c306 and 90ed153.

📒 Files selected for processing (2)
  • src/lib/api/oss.test.ts
  • src/lib/api/oss.ts

Comment thread src/lib/api/oss.ts
Comment on lines +98 to +104
let password = await fetchDatabasePasswordOnce();
const POLL_ATTEMPTS = 9;
const POLL_DELAY_MS = 2_000;
for (let attempt = 0; password === null && attempt < POLL_ATTEMPTS; attempt++) {
await new Promise((r) => setTimeout(r, POLL_DELAY_MS));
password = await fetchDatabasePasswordOnce();
}
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

Avoid retrying non-retryable password fetch failures

Line 101 retries on every null, but fetchDatabasePasswordOnce() returns null for both transient “not ready” and deterministic failures (like endpoint missing). That can add ~18s avoidable latency on older/self-hosted backends.

Proposed direction
-async function fetchDatabasePasswordOnce(): Promise<string | null> {
+type DatabasePasswordFetchResult = {
+  password: string | null;
+  retryable: boolean;
+};
+
+async function fetchDatabasePasswordOnce(): Promise<DatabasePasswordFetchResult> {
   try {
-    const res = await ossFetch('/api/metadata/database-password');
+    const res = await ossFetch('/api/metadata/database-password');
     const body = await res.json() as { databasePassword?: string };
     const pw = body.databasePassword;
-    if (typeof pw !== 'string' || !pw || isMaskedDatabasePassword(pw)) return null;
-    return pw;
+    if (typeof pw !== 'string' || !pw || isMaskedDatabasePassword(pw)) {
+      return { password: null, retryable: true };
+    }
+    return { password: pw, retryable: false };
   } catch {
-    return null;
+    // Distinguish non-retryable cases (e.g. route not available) if possible.
+    return { password: null, retryable: false };
   }
 }
@@
-    let password = await fetchDatabasePasswordOnce();
+    let { password, retryable } = await fetchDatabasePasswordOnce();
@@
-    for (let attempt = 0; password === null && attempt < POLL_ATTEMPTS; attempt++) {
+    for (let attempt = 0; password === null && retryable && attempt < POLL_ATTEMPTS; attempt++) {
       await new Promise((r) => setTimeout(r, POLL_DELAY_MS));
-      password = await fetchDatabasePasswordOnce();
+      ({ password, retryable } = await fetchDatabasePasswordOnce());
     }
🤖 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/api/oss.ts` around lines 98 - 104, The current loop repeatedly
retries on any null from fetchDatabasePasswordOnce(), causing unnecessary delay
for deterministic failures; update fetchDatabasePasswordOnce() to distinguish
retryable vs non-retryable outcomes (e.g., return { password, retryable } or
throw a PasswordFetchError with a retryable flag) and then change the polling
logic that uses password, POLL_ATTEMPTS and POLL_DELAY_MS to stop retrying
immediately when the result indicates non-retryable failure (break/throw), while
only looping when retryable is true and attempt < POLL_ATTEMPTS.

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.

No issues found across 2 files

Tip: cubic could auto-approve low-risk PRs like this, if it thinks it's safe to merge. Learn more

Ship the masked-password poll fix above (no first-run masked DATABASE_URL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tonychang04 tonychang04 enabled auto-merge (squash) May 12, 2026 21:18
Copy link
Copy Markdown
Member

@jwfing jwfing left a comment

Choose a reason for hiding this comment

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

LGTM, approved.

@tonychang04 tonychang04 merged commit 2c2beec into main May 12, 2026
4 checks passed
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.

2 participants