Skip to content

Commit dcbab30

Browse files
fix(bugbash-p3): instanode-web P3 sweep — a11y, favicon, nav/pricing drift, SSE, 429 hint (#98)
Covers every remaining instanode-web P3 finding from BUGHUNT-REPORT-2026-05-18 not already handled by PR #96 (Wave D) or PR #97 (P2 wave): - Global :focus-visible ring in tokens.css — keyboard/AT users had no consistent focus indicator anywhere (W3 T10, WCAG 2.4.7). - favicon.ico + site.webmanifest — browsers' implicit /favicon.ico request 404'd; no web manifest existed (W5 T10). - P3-08: MarketingPage Pro card "multi-env (dev/staging/prod + custom)" drifted from PricingPage's "dev/staging/prod" — aligned to the honest framing. - P3-09: MarketingPage Team card rendered an empty <a href=""> dead CTA pill and the "talk to us" teaser had no contact link — Team CTA now a disabled "Coming soon" pill, teaser links mailto:hello@instanode.dev. - P3-02: getResource() fired a /credentials fetch that 400'd for webhook/storage/queue resources — gated to db/redis/mongo via a named CREDENTIALED_RESOURCE_TYPES set. - streamSSE only matched `data: ` (with space); the SSE spec makes the space optional — no-space `data:` lines were silently dropped. - markdown renderer: content-repo cross-links written as `/use-cases/x.md` hit the SPA catch-all and dead-ended on the homepage — normalizeInternalHref strips a trailing `.md` from internal links. - TeamPage unguarded Promise.all — no .catch(), no cancellation guard; a failed load left the page silently empty. Now mirrors the ResourcesPage/DeploymentsPage load pattern with an error banner. - _headers: rewrote the comment to make unambiguous that the file is a non-operational migration artifact on GitHub Pages. - 429/Retry-After user-facing hint (P2 deferred as P3): new retryHint.ts helper turns the retry-delay into a human "retry in Ns" string; wired into TeamPage's error banner. Reads the delay defensively so it works with or without PR #97's APIError.retryAfter field. Tests: markdown .md-strip cases, retryHint unit tests added. Gates: tsc --noEmit clean · vite build clean · vitest 645 passed, 3 skipped, 0 failed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1596773 commit dcbab30

13 files changed

Lines changed: 348 additions & 26 deletions

index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@
2323
<meta name="twitter:description" content="Zero-setup infrastructure for AI agents. Provision real Postgres, Redis, MongoDB, queues, storage, and deployed apps with a single HTTP call." />
2424
<meta name="twitter:image" content="https://instanode.dev/apple-touch-icon.png" />
2525

26+
<!-- favicon.ico is listed first so the browser's implicit /favicon.ico
27+
request (fired regardless of <link> tags) resolves to a real file
28+
instead of 404ing — BugBash P3 (W5 T10). -->
29+
<link rel="icon" href="/favicon.ico" sizes="any" />
2630
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
2731
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
2832
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
33+
<link rel="manifest" href="/site.webmanifest" />
2934
<link rel="preconnect" href="https://fonts.googleapis.com" />
3035
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
3136
<link

public/_headers

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
# GitHub Pages serves these files; this _headers is documentation only.
2-
# GitHub does not honor Netlify-style _headers files at runtime — kept here
3-
# so future readers (or a Netlify/Cloudflare Pages migration) have a record
4-
# of the intended Content-Type for plain-text assets the LLM crawlers fetch.
1+
# ─────────────────────────────────────────────────────────────────────
2+
# NON-OPERATIONAL FILE — this _headers does nothing on the live site.
3+
#
4+
# instanode.dev is hosted on GitHub Pages (see public/CNAME +
5+
# .github/workflows — actions/deploy-pages). GitHub Pages does NOT honor
6+
# Netlify/Cloudflare-style `_headers` files at runtime; it cannot be
7+
# configured to emit custom response headers at all.
8+
#
9+
# This is harmless in practice: GitHub Pages already serves `.txt`
10+
# assets with `Content-Type: text/plain; charset=utf-8` by default, so
11+
# the LLM crawlers that fetch /llms.txt and /llms-full.txt receive the
12+
# correct content type with or without this file.
13+
#
14+
# The file is kept ONLY as a migration artifact: if instanode.dev ever
15+
# moves to Netlify or Cloudflare Pages (both of which DO honor this
16+
# format), the intended header rules below are ready to take effect.
17+
# Until such a migration, treat this file as documentation, not config.
18+
# BugBash P3 (W5 T10): the previous comment under-stated this — a reader
19+
# could mistake the file for an active header config.
20+
# ─────────────────────────────────────────────────────────────────────
521
/llms.txt
622
Content-Type: text/plain; charset=utf-8
723
/llms-full.txt

public/favicon.ico

2.57 KB
Binary file not shown.

public/site.webmanifest

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "instanode",
3+
"short_name": "instanode",
4+
"description": "Zero-setup infrastructure for AI agents. Provision real Postgres, Redis, MongoDB, queues, storage, and deployed apps with a single HTTP call.",
5+
"start_url": "/",
6+
"scope": "/",
7+
"display": "standalone",
8+
"background_color": "#08080a",
9+
"theme_color": "#08080a",
10+
"icons": [
11+
{
12+
"src": "/favicon-32.png",
13+
"sizes": "32x32",
14+
"type": "image/png"
15+
},
16+
{
17+
"src": "/apple-touch-icon.png",
18+
"sizes": "180x180",
19+
"type": "image/png"
20+
}
21+
]
22+
}

src/api/index.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// real error banner instead of lying with mock data.
99

1010
import type {
11-
Resource, DashboardStack, StackStatus,
11+
Resource, ResourceType, DashboardStack, StackStatus,
1212
DashboardDeployment, DeploymentStatus, DeploymentFailure,
1313
DashboardTeam, BillingDetails, Invoice,
1414
TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem,
@@ -505,6 +505,21 @@ export async function inviteMember(_body: { email: string; role: string }): Prom
505505
type ResourceListResp = { ok: boolean; items: any[]; total: number }
506506
type ResourceGetResp = { ok: boolean; item: any }
507507

508+
// CREDENTIALED_RESOURCE_TYPES — the resource types whose
509+
// GET /api/v1/resources/:id/credentials endpoint returns a usable
510+
// `connection_url`. BugBash P3-02: getResource() fired the credentials
511+
// fetch unconditionally, so webhook / storage / queue resources (which
512+
// do not expose a connection_url on that endpoint) returned a 400 on
513+
// every detail-page open — spurious 400s in the API logs and NR
514+
// telemetry. Gating the fetch to these three types removes the noise
515+
// without changing behaviour for db/redis/mongo (the catch below still
516+
// guards genuine permission-hidden cases).
517+
const CREDENTIALED_RESOURCE_TYPES: ReadonlySet<ResourceType> = new Set<ResourceType>([
518+
'postgres',
519+
'redis',
520+
'mongodb',
521+
])
522+
508523
function adaptResource(r: any): Resource {
509524
return {
510525
id: r.id,
@@ -537,13 +552,18 @@ export async function listResources(env?: string): Promise<{ ok: true; items: Re
537552

538553
export async function getResource(id: string): Promise<{ ok: true; resource: Resource }> {
539554
const r = await call<ResourceGetResp>(`/api/v1/resources/${id}`)
540-
// The agent API splits credentials into a separate endpoint.
555+
// The agent API splits credentials into a separate endpoint, but only
556+
// db/redis/mongo expose a connection_url there — webhook/storage/queue
557+
// 400 on it (BugBash P3-02). Gate the fetch on resource_type so we
558+
// don't generate a spurious 400 on every detail-page open.
541559
let connection_url: string | undefined
542-
try {
543-
const c = await call<{ connection_url: string }>(`/api/v1/resources/${id}/credentials`)
544-
connection_url = c.connection_url
545-
} catch {
546-
/* credentials may be hidden for some resource types */
560+
if (CREDENTIALED_RESOURCE_TYPES.has(r.item?.resource_type)) {
561+
try {
562+
const c = await call<{ connection_url: string }>(`/api/v1/resources/${id}/credentials`)
563+
connection_url = c.connection_url
564+
} catch {
565+
/* credentials may still be hidden (permissions, paused, etc.) */
566+
}
547567
}
548568
return { ok: true, resource: adaptResource({ ...r.item, connection_url }) }
549569
}

src/lib/markdown.test.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,27 @@ describe('inline — token rendering', () => {
8989
})
9090

9191
it('renders [text](url) as <a>', () => {
92-
expect(htmlInline('see [docs](/docs.md)')).toBe('see <a href="/docs.md">docs</a>')
92+
expect(htmlInline('see [docs](/docs)')).toBe('see <a href="/docs">docs</a>')
93+
})
94+
95+
// BugBash P3: content-repo cross-links are written two ways —
96+
// `/use-cases/foo` and `/use-cases/foo.md`. The `.md` form would hit
97+
// the SPA catch-all and dead-end on the homepage; normalizeInternalHref
98+
// strips the trailing `.md` from internal links so both resolve.
99+
it('strips a trailing .md from internal links', () => {
100+
expect(htmlInline('see [docs](/docs.md)')).toBe('see <a href="/docs">docs</a>')
101+
expect(htmlInline('[uc](/use-cases/foo-bar.md)'))
102+
.toBe('<a href="/use-cases/foo-bar">uc</a>')
103+
})
104+
105+
it('preserves a query/hash after a stripped .md suffix', () => {
106+
expect(htmlInline('[x](/blog/post.md#section)'))
107+
.toBe('<a href="/blog/post#section">x</a>')
108+
})
109+
110+
it('does NOT strip .md from external links', () => {
111+
expect(htmlInline('[raw](https://example.com/readme.md)'))
112+
.toBe('<a href="https://example.com/readme.md">raw</a>')
93113
})
94114

95115
it('renders [text](https://...) as external <a>', () => {

src/lib/markdown.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export function inline(text: string, keyPrefix = 'i'): ReactNode {
149149
} else {
150150
const href = m[2]
151151
if (isSafeHref(href)) {
152-
parts.push(<a key={k} href={href}>{m[1]}</a>)
152+
parts.push(<a key={k} href={normalizeInternalHref(href)}>{m[1]}</a>)
153153
} else {
154154
// Unsafe href (e.g. javascript:) — render as plain text
155155
parts.push(matched)
@@ -177,3 +177,22 @@ function isSafeHref(href: string): boolean {
177177
if (/^https?:\/\//i.test(href)) return true
178178
return false
179179
}
180+
181+
/* normalizeInternalHref — fixes the .md-suffix inconsistency in
182+
* content-repo cross-links (BugBash P3).
183+
*
184+
* Blog posts and use-case pages in the content repo link to sibling
185+
* pages two ways: some authors write `/use-cases/foo` (the real SPA
186+
* route), others write `/use-cases/foo.md` (the source filename). The
187+
* `.md` form hits the SPA catch-all and silently falls back to the
188+
* homepage — a dead internal link.
189+
*
190+
* We strip a trailing `.md` from internal hrefs (those starting with
191+
* `/`) so both authoring styles resolve to the same working route.
192+
* External http(s) links and anchors are left untouched — a real `.md`
193+
* file on an external host is a legitimate target. A query/hash after
194+
* the `.md` is preserved (e.g. `/blog/x.md#section` → `/blog/x#section`). */
195+
function normalizeInternalHref(href: string): string {
196+
if (!href.startsWith('/')) return href
197+
return href.replace(/\.md(?=$|[?#])/, '')
198+
}

src/lib/retryHint.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { retryAfterSeconds, isRateLimited, formatRetryHint } from './retryHint'
3+
4+
describe('retryAfterSeconds', () => {
5+
it('reads a numeric retryAfter field', () => {
6+
expect(retryAfterSeconds({ retryAfter: 30 })).toBe(30)
7+
})
8+
9+
it('ceils a fractional retryAfter to whole seconds', () => {
10+
expect(retryAfterSeconds({ retryAfter: 12.4 })).toBe(13)
11+
})
12+
13+
it('accepts retryAfter of 0', () => {
14+
expect(retryAfterSeconds({ retryAfter: 0 })).toBe(0)
15+
})
16+
17+
it('ignores a negative retryAfter field', () => {
18+
expect(retryAfterSeconds({ retryAfter: -5 })).toBeNull()
19+
})
20+
21+
it('falls back to a "retry after Ns" hint in the message', () => {
22+
expect(retryAfterSeconds({ message: 'rate limited (retry after 45s)' })).toBe(45)
23+
})
24+
25+
it('falls back to a "retry in Ns" hint in the message', () => {
26+
expect(retryAfterSeconds({ message: 'too many requests, retry in 8 seconds' })).toBe(8)
27+
})
28+
29+
it('returns null when no hint is present', () => {
30+
expect(retryAfterSeconds({ message: 'something else broke' })).toBeNull()
31+
expect(retryAfterSeconds(new Error('plain error'))).toBeNull()
32+
})
33+
34+
it('never throws on non-object input', () => {
35+
expect(retryAfterSeconds(null)).toBeNull()
36+
expect(retryAfterSeconds(undefined)).toBeNull()
37+
expect(retryAfterSeconds('a string')).toBeNull()
38+
})
39+
})
40+
41+
describe('isRateLimited', () => {
42+
it('is true for an error with status 429', () => {
43+
expect(isRateLimited({ status: 429 })).toBe(true)
44+
})
45+
46+
it('is false for other statuses and non-objects', () => {
47+
expect(isRateLimited({ status: 500 })).toBe(false)
48+
expect(isRateLimited({ status: 402 })).toBe(false)
49+
expect(isRateLimited(null)).toBe(false)
50+
expect(isRateLimited('429')).toBe(false)
51+
})
52+
})
53+
54+
describe('formatRetryHint', () => {
55+
it('renders sub-minute delays in seconds', () => {
56+
expect(formatRetryHint(1)).toBe('Please retry in 1 second.')
57+
expect(formatRetryHint(30)).toBe('Please retry in 30 seconds.')
58+
expect(formatRetryHint(59)).toBe('Please retry in 59 seconds.')
59+
})
60+
61+
it('rolls up minute-plus delays to minutes', () => {
62+
expect(formatRetryHint(60)).toBe('Please retry in about 1 minute.')
63+
expect(formatRetryHint(150)).toBe('Please retry in about 3 minutes.')
64+
})
65+
66+
it('handles 0 and null', () => {
67+
expect(formatRetryHint(0)).toBe('You can retry now.')
68+
expect(formatRetryHint(null)).toBe('Please retry in a moment.')
69+
expect(formatRetryHint(-1)).toBe('Please retry in a moment.')
70+
})
71+
})

src/lib/retryHint.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/* retryHint.ts — user-facing "retry in Ns" support for HTTP 429.
2+
*
3+
* BugBash: the data layer for 429 / Retry-After handling is wired in the
4+
* APIError envelope (a `retryAfter` seconds field, parsed from the
5+
* `Retry-After` response header). This module is the small, presentation-
6+
* side counterpart — it turns that number into a human countdown string
7+
* the dashboard can render next to a rate-limited error.
8+
*
9+
* It is deliberately defensive about where the number comes from:
10+
*
11+
* 1. `err.retryAfter` — the structured field on APIError (preferred).
12+
* 2. a "(retry after Ns)" / "retry in Ns" fragment in `err.message` —
13+
* a fallback for errors that carry the hint only in their text.
14+
*
15+
* Reading the field through a loose `unknown` shape (rather than
16+
* importing APIError) keeps this module decoupled: it works whether or
17+
* not a given build's APIError exposes `retryAfter`, and never throws on
18+
* a plain Error or a non-error value.
19+
*/
20+
21+
// RETRY_HINT_RE — matches a retry-delay hint embedded in an error
22+
// message, e.g. "rate limited (retry after 30s)" or "retry in 12
23+
// seconds". Capture group 1 is the integer seconds.
24+
const RETRY_HINT_RE = /retry\s+(?:after|in)\s+(\d+)\s*s/i
25+
26+
/** retryAfterSeconds — best-effort extraction of a non-negative retry
27+
* delay (in whole seconds) from a thrown value. Returns null when no
28+
* hint is present. */
29+
export function retryAfterSeconds(err: unknown): number | null {
30+
if (!err || typeof err !== 'object') return null
31+
32+
const field = (err as { retryAfter?: unknown }).retryAfter
33+
if (typeof field === 'number' && Number.isFinite(field) && field >= 0) {
34+
return Math.ceil(field)
35+
}
36+
37+
const message = (err as { message?: unknown }).message
38+
if (typeof message === 'string') {
39+
const m = message.match(RETRY_HINT_RE)
40+
if (m) {
41+
const secs = Number(m[1])
42+
if (Number.isFinite(secs) && secs >= 0) return secs
43+
}
44+
}
45+
46+
return null
47+
}
48+
49+
/** isRateLimited — true when the error is an HTTP 429. Reads `status`
50+
* defensively so it works on APIError without importing it. */
51+
export function isRateLimited(err: unknown): boolean {
52+
return Boolean(err) && typeof err === 'object' &&
53+
(err as { status?: unknown }).status === 429
54+
}
55+
56+
/** formatRetryHint — turns a seconds count into a short human phrase.
57+
* `null`/negative input yields a generic fallback so callers can always
58+
* render a non-empty string for a 429. */
59+
export function formatRetryHint(seconds: number | null): string {
60+
if (seconds == null || seconds < 0) return 'Please retry in a moment.'
61+
if (seconds === 0) return 'You can retry now.'
62+
if (seconds < 60) {
63+
return `Please retry in ${seconds} second${seconds === 1 ? '' : 's'}.`
64+
}
65+
const mins = Math.ceil(seconds / 60)
66+
return `Please retry in about ${mins} minute${mins === 1 ? '' : 's'}.`
67+
}

src/lib/sseStream.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,17 @@ export function streamSSE(path: string, handlers: SSEHandlers, signal?: AbortSig
6363
while ((nl = buffer.indexOf('\n')) >= 0) {
6464
const raw = buffer.slice(0, nl).trimEnd()
6565
buffer = buffer.slice(nl + 1)
66-
// SSE lines look like: `data: <payload>` (or empty between events).
67-
if (raw.startsWith('data: ')) {
68-
handlers.onLine(raw.slice(6))
66+
// SSE lines look like `data: <payload>` (or empty between
67+
// events). BugBash P3: the spec (WHATWG, EventSource §9.2.6)
68+
// makes the single space after the colon OPTIONAL — a server
69+
// emitting `data:<payload>` (no space) is conformant. The old
70+
// `startsWith('data: ')` check silently dropped every
71+
// no-space line. Match the `data:` prefix and strip at most
72+
// one leading space from the value, per the spec.
73+
if (raw.startsWith('data:')) {
74+
let value = raw.slice(5)
75+
if (value.startsWith(' ')) value = value.slice(1)
76+
handlers.onLine(value)
6977
}
7078
}
7179
}

0 commit comments

Comments
 (0)