Skip to content

fix: correct Tap ack format and reconnect immediately on write errors#3

Closed
Kzoeps wants to merge 22 commits intomainfrom
fix/tap-ack-issue
Closed

fix: correct Tap ack format and reconnect immediately on write errors#3
Kzoeps wants to merge 22 commits intomainfrom
fix/tap-ack-issue

Conversation

@Kzoeps
Copy link
Copy Markdown
Collaborator

@Kzoeps Kzoeps commented Apr 7, 2026

Problem

The Tap consumer was sending acks as plain integers (e.g. "12345") but the Tap server's read loop calls ws.ReadJSON(&msg) expecting a JSON object: {"type":"ack","id":12345}. When the server received the malformed ack, its ReadJSON failed, the read goroutine closed the connection, and every subsequent ack write from the consumer hit the dead socket — producing a cascade of hundreds of "connection reset by peer" warnings before the connection was finally dropped.

The exponential backoff (1s → 2s → 4s → ... → 2min) then made recovery slow, and since no events were ever acked, Tap kept re-delivering the full backlog from the start on every reconnect.

Root Cause

Found by reading the Tap server source (cmd/tap/types.go and server.go):

// What the server expects:
type WsResponse struct {
    Type WsReponseType `json:"type"`
    ID   uint          `json:"id"`
}

// What we were sending:
ackMsg := fmt.Sprintf("%d", event.ID)  // "12345"

// What we should send:
ackMsg := fmt.Sprintf(`{"type":"ack","id":%d}`, event.ID)  // {"type":"ack","id":12345}

Changes

  • Correct ack format{"type":"ack","id":%d} matching the Tap server's WsResponse struct
  • Immediate reconnect on write errorsisWriteError() detects ack write failures and returns from runOnce() immediately instead of continuing to read on a dead connection, preventing the cascade of hundreds of identical error logs
  • Backoff resets on successful dial — previously only reset on clean close (err == nil); now resets whenever the dial itself succeeded (connected=true) so a long-running session that drops with a network error reconnects at 1s, not the last escalated interval

Tests

  • Updated TestConsumer_ReceivesAndAcksRecordEvent and TestConsumer_ReceivesAndAcksIdentityEvent to expect JSON ack format
  • Added TestConsumer_AckFormat — verifies the ack parses as valid JSON with correct type and id fields
  • Added TestConsumer_WriteErrorCausesImmediateReconnect — verifies the consumer reconnects immediately after a write failure rather than looping through more dead-connection reads

Summary by CodeRabbit

  • New Features

    • Added confirmation dialog for delete operations with updated icon styling.
    • Expanded lexicon schema support with new definitions for richtext, organizational records, and document structures.
  • Bug Fixes

    • Improved connection resilience with updated acknowledgment handling and reconnection logic.
  • Configuration

    • Updated environment variable configuration for public client URL management with fallback support.
    • Increased deployment health check timeout for better stability.
  • Chores

    • Updated Go toolchain version to 1.26.

Kzoeps added 3 commits April 6, 2026 16:41
The TAP /channel WebSocket endpoint requires Basic Auth when
TAP_ADMIN_PASSWORD is set. The consumer was sending no Authorization
header, causing a 401 which gorilla/websocket surfaces as 'bad handshake'.

Wire cfg.TapAdminPassword into ConsumerConfig.Password and build the
Authorization header in runOnce() before dialing, matching the pattern
already used by AdminClient for HTTP requests.
…ecord drops

Three interconnected bugs caused health check failures when acks were enabled
and records to be silently dropped:

1. Empty-body create/update events caused hard ParseEvent errors → never acked
   → Tap's un-acked queue grew → Tap's /health degraded → Railway health check
   failed. Fix: allow these events to parse successfully; handler skips them
   gracefully (nothing to store without a body) and returns nil so they get acked.

2. Records with empty CID (Tap omits cid field on some events) were silently
   skipped by the Insert dedup check: existingCID("") == cid("") → Skipped.
   New records were never written to the DB. Fix: only skip when cid != "".
   Also log Skipped results at DEBUG for observability.

3. /health proxied to Tap's admin HTTP API, so any Tap restart/blip caused
   Railway to fail the health check and restart hyperindex. Fix: /health now
   reflects hyperindex's own health only. Tap sidecar status moves to /stats
   where it is visible but non-blocking.
…errors

The Tap server expects acks as JSON objects: {"type":"ack","id":<id>}
but the consumer was sending bare integers (e.g. "12345"). When the server's
ReadJSON received a plain number it failed to unmarshal, causing its read loop
to close the disconnected channel and tear down the WebSocket. Every subsequent
ack write from the consumer hit the dead connection, producing a cascade of
'connection reset by peer' errors before the read loop eventually noticed.

Three fixes in this commit:
- Correct ack format: fmt.Sprintf(`{"type":"ack","id":%d}`, event.ID)
- Immediate reconnect on write errors: isWriteError() detects ack failures and
  returns from runOnce() at once rather than looping on dead-connection reads
- Backoff resets on successful dial (connected=true) not only on clean close,
  so a long session that drops with a read error reconnects at minBackoff (1s)

Tests updated to expect JSON ack format; two new tests added:
TestConsumer_AckFormat and TestConsumer_WriteErrorCausesImmediateReconnect.
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperindex-atproto-client Ready Ready Preview, Comment Apr 14, 2026 9:41am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71226ff1-fb4a-437b-ba8a-594f3e2bfdf7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors environment variable configuration in the client application (replacing PUBLIC_URL with NEXT_PUBLIC_CLIENT_URL and adding fallback logic), enhances the lexicons page with a deletion confirmation dialog, updates Go language version to 1.26, refactors consumer reconnection logic with JSON-formatted acknowledgments, modifies GraphQL object schema generation, and adds extensive test lexicon definitions for various protocol modules.

Changes

Cohort / File(s) Summary
Environment Variable Configuration
client/.env.example, client/src/lib/env.ts, client/src/app/api/oauth/client-metadata.json/route.ts, client/src/lib/auth/client.ts
Renamed PUBLIC_URL to NEXT_PUBLIC_CLIENT_URL with fallback to normalized NEXT_PUBLIC_VERCEL_BRANCH_URL, affecting OAuth client metadata generation and public URL resolution across multiple client modules.
UI Deletion Confirmation
client/src/app/lexicons/page.tsx
Added confirmation modal dialog for lexicon deletion operations, updated delete icon styling and sizing, and modified TreeBranch delete handler to gate mutations on user confirmation.
Consumer Connection Management
internal/tap/consumer.go, internal/tap/consumer_test.go
Refactored reconnection backoff logic to reset on successful connection establishment, changed ack format from plain numeric IDs to JSON {"type":"ack","id":<id>}, improved error handling with write-error detection, and added comprehensive test coverage for new behaviors.
GraphQL Schema Generation
internal/graphql/types/object.go
Added synthetic empty field for empty object type definitions, modified union type naming convention to append "Union" suffix for improved schema clarity.
Language and Configuration Updates
go.mod, railway.toml
Updated Go module version from 1.25 to 1.26 with toolchain directive; increased Railway health check timeout from 10 to 60 seconds.
Lexicon Test Definitions
testdata/lexicons/app/bsky/richtext/facet.json, testdata/lexicons/app/certified/..., testdata/lexicons/org/hypercerts/..., testdata/lexicons/org/hyperboards/..., testdata/lexicons/pub/leaflet/...
Added 20+ new lexicon schema definitions for rich-text facets, certified profiles/badges, hypercerts claims/collections/contexts, hyperboards, and Leaflet document blocks; updated existing badge and collection schemas with stronger constraints and reference refinements.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as Lexicons Page
    participant Modal as Confirmation Modal
    participant Mutation as Delete Mutation
    
    User->>UI: Click delete button for lexicon
    UI->>UI: Set confirmNsid state
    UI->>Modal: Render modal (confirmNsid non-null)
    User->>Modal: Click "Delete" button
    Modal->>Mutation: Call deleteMutation.mutate(confirmNsid)
    Mutation->>Mutation: Execute deletion
    Modal->>UI: Clear confirmNsid state
    UI->>UI: Hide modal
Loading
sequenceDiagram
    participant Consumer as TAP Consumer
    participant WebSocket as WebSocket Server
    participant Event as Event Stream
    
    Consumer->>WebSocket: Dial connection (runOnce)
    WebSocket-->>Consumer: Connection established (connected=true)
    Event-->>Consumer: Receive event
    Consumer->>Consumer: Process event
    Consumer->>WebSocket: Send ack (JSON format)
    Note over Consumer: "Backoff resets only when<br/>connected=true, not on errors"
    WebSocket-->>Consumer: Server closes
    Consumer->>Consumer: Reset reconnect backoff
    Consumer->>WebSocket: Attempt reconnect with minimal delay
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 The URLs now flow through branded channels,
Confirmations pause before deletion's call,
Consumers acknowledge in JSON's new dance,
Gophers stride forward in version halls,
Lexicons bloom across testdata's expanse! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix: correct Tap ack format and reconnect immediately on write errors' directly and concisely summarizes the main changes: correcting acknowledgement message format and improving error handling for write failures in the Tap consumer.

✏️ 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/tap-ack-issue

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.

@Kzoeps
Copy link
Copy Markdown
Collaborator Author

Kzoeps commented Apr 13, 2026

@coderabbitai please review this

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

@Kzoeps: Sure! I'll review the changes in this PR right away.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

Caution

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

⚠️ Outside diff range comments (1)
client/src/lib/auth/client.ts (1)

119-129: ⚠️ Potential issue | 🟠 Major

Fail fast in production when PUBLIC_CLIENT_URL is unset.

Line 128 falls back to 127.0.0.1 outside local dev. With the env rename, a missing production variable can now generate localhost client_id/redirect/JWKS URLs and break OAuth discovery/callbacks.

💡 Proposed fix
 export const createClient = async () => {
   const publicUrl = env.PUBLIC_CLIENT_URL;
   // Must use 127.0.0.1 per RFC 8252 for ATProto OAuth localhost development
   const localhostUrl = `http://127.0.0.1:${env.PORT}`;
   const enc = encodeURIComponent;

   // Detect if we're running on localhost (dev mode)
   const isLocalDev = process.env.NODE_ENV !== "production";
+  if (!isLocalDev && !publicUrl) {
+    throw new Error("PUBLIC_CLIENT_URL must be set in production");
+  }

   // Use localhost URL in development, production URL otherwise
-  const url = isLocalDev ? localhostUrl : publicUrl || localhostUrl;
+  const url = isLocalDev ? localhostUrl : publicUrl;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/lib/auth/client.ts` around lines 119 - 129, The current logic sets
url = isLocalDev ? localhostUrl : publicUrl || localhostUrl which silently falls
back to localhost in production; update the logic to throw an error when running
in production and env.PUBLIC_CLIENT_URL is not set. Specifically, keep
publicUrl, localhostUrl and isLocalDev but change how url is computed so that if
!isLocalDev and !publicUrl you throw a clear error (including mention of
PUBLIC_CLIENT_URL) instead of using localhostUrl; otherwise set url to publicUrl
in production or localhostUrl in dev.
🧹 Nitpick comments (14)
testdata/lexicons/org/hypercerts/claim/rights.json (1)

31-32: Validation constraints look reasonable.

The maxLength: 10000 and maxGraphemes: 1000 pair enforces both storage limits and user-perceived character count. The 10:1 ratio accommodates multi-byte UTF-8 characters and complex grapheme clusters (emoji, combining marks).

If you want to verify these constraints work as intended across different character sets, you could test with:

  • Simple ASCII text (~1 byte per grapheme)
  • Multi-byte Unicode text (~2-3 bytes per grapheme)
  • Complex emoji sequences (~10+ bytes per grapheme)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testdata/lexicons/org/hypercerts/claim/rights.json` around lines 31 - 32, Add
unit/integration tests that verify the schema constraints "maxLength" (10000)
and "maxGraphemes" (1000) are enforced correctly across character sets: create
test inputs for simple ASCII, multi-byte Unicode (e.g., accented/2-3 byte
chars), and complex emoji/combining sequences, and assert that messages
exceeding 10000 bytes are rejected while those exceeding 1000 graphemes are
rejected (and valid inputs within both limits are accepted); update or add tests
around the validation logic that reads "maxLength" and "maxGraphemes" so the
schema in rights.json behaves as intended.
client/src/app/lexicons/page.tsx (1)

446-457: Consider adding keyboard accessibility for the modal.

The dialog would benefit from:

  1. Escape key handling to close the modal
  2. Focus trap to keep keyboard navigation within the modal while open
  3. Auto-focus on the Cancel button (or dialog container) when opened
Escape key handler example
// Add useEffect for Escape key
useEffect(() => {
  if (!confirmNsid) return;
  const handleEscape = (e: KeyboardEvent) => {
    if (e.key === "Escape") setConfirmNsid(null);
  };
  document.addEventListener("keydown", handleEscape);
  return () => document.removeEventListener("keydown", handleEscape);
}, [confirmNsid]);

For focus trapping, consider using a library like @radix-ui/react-dialog or react-focus-lock.

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

In `@client/src/app/lexicons/page.tsx` around lines 446 - 457, The modal rendered
when confirmNsid is truthy lacks keyboard accessibility; add an Escape key
handler (useEffect watching confirmNsid that calls setConfirmNsid(null) on
Escape), implement a focus trap for the dialog (use a library like
react-focus-lock or switch to `@radix-ui/react-dialog` to ensure focus stays
inside while open), and auto-focus the Cancel button by attaching a ref to that
button and focusing it in a useEffect when confirmNsid becomes non-null; apply
these changes around the JSX that renders the dialog (the block with
role="dialog", aria-modal, confirmNsid and setConfirmNsid references).
internal/graphql/types/object.go (1)

268-268: Avoid unconditional public union-type rename without migration handling.

Appending "Union" changes schema type names and can break downstream introspection/codegen consumers. Consider suffixing only on collision, or documenting this as a breaking schema change.

Possible collision-aware naming approach
- unionName := lexicon.ToTypeName(contextLexiconID) + capitalizeFirst(fieldName) + "Union"
+ baseUnionName := lexicon.ToTypeName(contextLexiconID) + capitalizeFirst(fieldName)
+ unionName := baseUnionName
+ if _, exists := b.mapper.GetObjectType(baseUnionName); exists {
+   unionName = baseUnionName + "Union"
+ }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/graphql/types/object.go` at line 268, The current unconditional
construction of unionName using lexicon.ToTypeName(contextLexiconID) +
capitalizeFirst(fieldName) + "Union" can break consumers; change the logic in
the union-name creation (where unionName is set) to detect name collisions and
only append the "Union" suffix when a conflict exists (e.g., check the existing
type registry/map or schema for an existing type with the base name), or
generate a unique, collision-aware name (e.g., attempt baseName =
lexicon.ToTypeName(contextLexiconID)+capitalizeFirst(fieldName) and if that
exists, use baseName+"Union" or another deterministic suffix); adjust the code
surrounding unionName, lexicon.ToTypeName and capitalizeFirst to perform this
collision check rather than always renaming.
internal/tap/consumer.go (2)

170-174: Non-idiomatic return order (error, bool) — consider documenting or restructuring.

Go convention places error as the last return value. The current signature (error, bool) may confuse readers. The comment explains the semantics well, but consider either:

  1. Restructuring to (connected bool, err error), or
  2. Returning a small struct if more context is needed later

This is a minor style concern; the current implementation works correctly.

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

In `@internal/tap/consumer.go` around lines 170 - 174, The runOnce method on type
Consumer currently returns (error, bool) which is non-idiomatic in Go; change
the signature of Consumer.runOnce from runOnce(ctx context.Context) (error,
bool) to runOnce(ctx context.Context) (connected bool, err error) (or
alternately return a small struct with those fields) and update all call sites
that currently unpack the returns (look for usages of c.runOnce(...)) to match
the new order so callers still use the connected value to decide reconnection
backoff and handle err last.

306-316: String-based error detection is fragile but acceptable for this use case.

The isWriteError function relies on matching substrings in error messages. While this approach is brittle (error message text could change in future refactors), it works correctly here because you control the error messages in dispatch() and writeText().

Consider documenting this coupling or using sentinel errors for more robust detection in the future.

♻️ Alternative using sentinel errors (optional)
// Define sentinel errors at package level
var (
    errAckWriteFailed      = errors.New("ack write failed")
    errWriteDeadlineFailed = errors.New("write deadline failed")
)

// In dispatch():
if err := c.writeText(conn, ackMsg); err != nil {
    return fmt.Errorf("%w: event %d: %w", errAckWriteFailed, event.ID, err)
}

// In writeText():
if err := conn.SetWriteDeadline(...); err != nil {
    return fmt.Errorf("%w: %w", errWriteDeadlineFailed, err)
}

// isWriteError becomes:
func isWriteError(err error) bool {
    return errors.Is(err, errAckWriteFailed) || errors.Is(err, errWriteDeadlineFailed)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/tap/consumer.go` around lines 306 - 316, isWriteError currently
matches substrings which is fragile; replace that with sentinel errors: add
package-level sentinel vars (e.g., errAckWriteFailed, errWriteDeadlineFailed),
update dispatch() to wrap ack write failures with fmt.Errorf("%w: ...",
errAckWriteFailed) and update writeText() to wrap SetWriteDeadline and write
failures with fmt.Errorf("%w: ...", errWriteDeadlineFailed) (preserving original
error for context), then change isWriteError(err error) to return errors.Is(err,
errAckWriteFailed) || errors.Is(err, errWriteDeadlineFailed). This keeps
behavior identical but makes detection robust and locatable via the symbols
isWriteError, dispatch, writeText, errAckWriteFailed, and
errWriteDeadlineFailed.
testdata/lexicons/pub/leaflet/richtext/facet.json (1)

50-59: Consider adding URI format validation to the link facet.

The link definition's uri property is a plain string without format validation, while atMention.atURI correctly uses "format": "uri". This inconsistency could allow invalid URLs.

♻️ Proposed fix
     "link": {
       "type": "object",
       "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.",
       "required": ["uri"],
       "properties": {
         "uri": {
-          "type": "string"
+          "type": "string",
+          "format": "uri"
         }
       }
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testdata/lexicons/pub/leaflet/richtext/facet.json` around lines 50 - 59, The
link facet's uri property currently lacks schema validation; update the "link"
object schema (property "uri") to include a URI format check by adding "format":
"uri" (matching the approach used in atMention.atURI) so the validator enforces
valid URLs; ensure the change is applied to the "link" definition in the lexicon
JSON (property name "uri" under the "link" facet).
railway.toml (1)

6-6: Re-check failure-detection tradeoff for 60s healthcheck timeout.

Line 6 improves tolerance for slow boot, but it also delays restart on hard failures. Validate this against observed startup/health latency so MTTR doesn’t regress.

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

In `@railway.toml` at line 6, The healthcheckTimeout setting (healthcheckTimeout)
was increased to 60s which may improve boot tolerance but will also delay
restarts on hard failures; re-evaluate and pick a value based on measured
startup and health probe latencies (e.g., median+3σ) or make it configurable
per-environment, document the rationale inline as a comment, and if you need
faster MTTR reduce the timeout (e.g., to a value closer to observed worst-case
startup) or add an env var to override healthcheckTimeout so prod vs staging can
differ.
testdata/lexicons/pub/leaflet/blocks/horizontalRule.json (1)

7-8: Optional cleanup: omit empty required list.

For an empty-object schema, Line 7 ("required": []) is redundant and can be removed for clarity.

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

In `@testdata/lexicons/pub/leaflet/blocks/horizontalRule.json` around lines 7 - 8,
The JSON schema for the horizontal rule block contains a redundant empty
"required" array; remove the "required": [] entry from the horizontalRule.json
schema (the object that currently lists "required" and "properties") so the
schema is cleaner and only keeps "properties": {} for the empty-object schema.
testdata/lexicons/pub/leaflet/blocks/code.json (1)

9-10: Consider bounding plaintext size.

Line 9 currently allows unbounded content. Adding a maxLength helps prevent oversized block payloads and keeps document validation predictable.

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

In `@testdata/lexicons/pub/leaflet/blocks/code.json` around lines 9 - 10, The
plaintext property in the code block schema is currently unbounded; add a
maxLength to the "plaintext" schema entry (the "plaintext" string type) to limit
payload size—choose an appropriate ceiling (e.g., 2000–10000 chars) and add
"maxLength": <number> to the "plaintext" object so validation rejects oversized
content.
testdata/lexicons/pub/leaflet/blocks/math.json (1)

9-10: Consider adding a size limit to tex.

Line 9 accepts unbounded TeX input. A maxLength constraint will help keep validation and storage behavior controlled.

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

In `@testdata/lexicons/pub/leaflet/blocks/math.json` around lines 9 - 10, The
"tex" string property in the JSON schema currently allows unbounded input;
update the "tex" property definition to include a maxLength constraint (e.g.,
"maxLength": 2048 or another agreed limit) so validation and storage are
bounded, and adjust any tests or consumers that assume unlimited length
accordingly; reference the "tex" property in this schema when making the change.
testdata/lexicons/org/hyperboards/board.json (1)

44-60: Clarify background source invariants to avoid ambiguous rendering.

Line 50 (backgroundImage) and Line 55 (backgroundIframeUrl) can both be set at once, while Line 44 (backgroundType) implies a single active source. Add an explicit rule (or precedence) in schema/docs to keep render behavior deterministic.

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

In `@testdata/lexicons/org/hyperboards/board.json` around lines 44 - 60, Add
explicit invariant text to the schema/docs to make background source
deterministic: declare that backgroundType, backgroundImage and
backgroundIframeUrl are mutually related so only one source is active—when
backgroundType == "image" the renderer must use backgroundImage and ignore
backgroundIframeUrl (and backgroundImage SHOULD be present), when backgroundType
== "iframe" the renderer must use backgroundIframeUrl and ignore backgroundImage
(and backgroundIframeUrl SHOULD be present); alternatively specify a clear
precedence rule (e.g., backgroundType wins over any set values) and mention
which fields are required/ignored in each case so renderers can behave
deterministically for backgroundType, backgroundImage and backgroundIframeUrl.
testdata/lexicons/pub/leaflet/pages/linearDocument.json (1)

12-18: Consider adding array bounds to prevent oversized document payloads.

main.blocks and position.block are currently unbounded. Adding practical limits helps keep payload validation predictable and resilient.

💡 Example hardening
         "blocks": {
           "type": "array",
+          "maxLength": 1000,
           "items": {
             "type": "ref",
             "ref": "#block"
           }
         }
@@
         "block": {
           "type": "array",
+          "maxLength": 64,
           "items": {
             "type": "integer"
           }
         },

Also applies to: 86-94

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

In `@testdata/lexicons/pub/leaflet/pages/linearDocument.json` around lines 12 -
18, The schema defines unbounded arrays for "blocks" (items ref "#block") and
similar arrays referenced by "main.blocks" and "position.block"; add practical
array bounds (e.g., maxItems and optionally minItems) to these array schemas to
limit payload size and prevent oversized documents, updating the "blocks"
definition and any other occurrences (main.blocks, position.block) that use the
same ref so they inherit or explicitly declare the maxItems constraint.
testdata/lexicons/org/hypercerts/context/measurement.json (1)

7-7: Align subjects cardinality with the “one or more records” description.

Current schema allows subjects: []. If one-or-more is required when present, add a minimum length to match the contract.

💡 Proposed tweak
           "subjects": {
             "type": "array",
             "description": "Strong references to the records this measurement refers to (e.g. activities, projects, or claims).",
             "items": {
               "type": "ref",
               "ref": "com.atproto.repo.strongRef"
             },
+            "minLength": 1,
             "maxLength": 100
           },

Also applies to: 13-21

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

In `@testdata/lexicons/org/hypercerts/context/measurement.json` at line 7, The
schema's "subjects" array under the "Measurement data related to one or more
records (e.g. activities, projects, etc.)." description currently allows an
empty array; add "minItems": 1 to the "subjects" array definition in
measurement.json so that when "subjects" is present it must contain one or more
entries, and apply the same minItems: 1 change to the other "subjects"
occurrences referenced (lines 13-21) so the contract "one or more records" is
enforced consistently.
testdata/lexicons/org/hypercerts/claim/activity.json (1)

49-56: Bound the contributors array.

Every other top-level user-controlled list here is capped, but contributors is currently unbounded. Adding a maxLength keeps payload size predictable and avoids one record growing arbitrarily large.

Suggested fix
           "contributors": {
             "type": "array",
             "description": "An array of contributor objects, each containing contributor information, weight, and contribution details.",
+            "maxLength": 1000,
             "items": {
               "type": "ref",
               "ref": "#contributor"
             }
           },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testdata/lexicons/org/hypercerts/claim/activity.json` around lines 49 - 56,
The contributors array in the schema is unbounded which allows arbitrarily large
payloads; update the "contributors" definition to add a sensible cap (e.g.,
"maxItems") so the array cannot grow without limit—locate the "contributors"
property in activity.json and add a "maxItems" value appropriate for your domain
(and optionally "minItems" if needed) to enforce the bound while leaving the
existing "items" ref to "#contributor" unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/app/api/oauth/client-metadata.json/route.ts`:
- Around line 7-10: The code currently assigns url = publicUrl ||
`http://127.0.0.1:${env.PORT}` and thus can serve localhost OAuth metadata in
production; change the logic so the localhost fallback is only used in
non-production (e.g., when NODE_ENV !== 'production' or env.NODE !==
'production') and in production require env.PUBLIC_CLIENT_URL to be present
(throw or return an error if missing). Update the assignments around publicUrl,
url, and isConfidential (which references env.ATPROTO_JWK_PRIVATE) so that
publicUrl is mandatory in production before building client metadata and only
construct the 127.0.0.1:${env.PORT} fallback when running in development/local
environments.

In `@client/src/app/lexicons/page.tsx`:
- Around line 481-486: The click handler nullifies confirmNsid immediately which
closes the modal before deleteMutation completes; instead remove the immediate
setConfirmNsid(null) from the onClick that calls
deleteMutation.mutate(confirmNsid) and move the setConfirmNsid(null) into the
deleteMutation's onSettled callback (or onSuccess/onError as appropriate) so the
modal stays open while isConfirmDeleting is true and is cleared after the
mutation finishes; update the mutation definition that creates deleteMutation to
include an onSettled handler that calls setConfirmNsid(null) and any error
handling/display logic.

In `@client/src/lib/env.ts`:
- Around line 15-20: The scheme checks for vercelBranchUrl
(normalizedVercelBranchUrl) and the similar client URL code are too brittle:
they don't trim input and are case-sensitive (so " HTTPS://..." or "HTTPS://..."
bypasses detection). Fix by trimming the env strings (use
vercelBranchUrl.trim()) and perform a case-insensitive test like const hasScheme
= trimmed.toLowerCase().startsWith("http://") ||
trimmed.toLowerCase().startsWith("https://"); then set normalizedVercelBranchUrl
= hasScheme ? trimmed : `https://${trimmed}` (and apply the same trimmed +
case-insensitive logic to the matching public client URL variable). Ensure you
guard empty values so you don't prepend to an empty string.

In `@go.mod`:
- Around line 3-5: The go.mod change to "go 1.26" and "toolchain go1.26.0" may
break builds if other places pin older Go versions; update all CI/workflow
definitions (.github/workflows), Dockerfiles (runtime/build images),
devcontainer/configs, Makefile bootstrap scripts, and any toolchain/version
files (e.g., actions/setup-go usage, golangci-lint image tags) to use Go 1.26 or
make them accept the toolchain, and run CI locally (or via act) and rebuild
Docker images to confirm compatibility before merging.

In `@testdata/lexicons/app/certified/defs.json`:
- Around line 7-15: The description for the schema defs.did is incorrect (it
says "A Decentralized Identifier (DID) string." while the schema type is
"object"); update the description to accurately reflect that this is an object
with a nested string property (e.g., "An object containing a 'did' string
property representing a Decentralized Identifier (format: did)"), ensuring the
wording references the nested property and its format so it matches the "type":
"object" and the "properties": { "did": { "type": "string", "format": "did" } }
in the defs.did schema.

In `@testdata/lexicons/app/certified/link/evm.json`:
- Around line 13-18: The schema's "address" and "signature" fields only enforce
length; add application-level validation in the verifier path (e.g., in your
proof validation function such as verifyProof / verifyEvmProof) to reject
invalid formats: validate "address" matches 0x-prefixed hex of exactly 40 hex
chars (regex ^0x[0-9a-fA-F]{40}$) and validate "signature" is 0x-prefixed hex of
either 128 or 130 hex chars (exactly 130 or 132 characters total, allow only
^0x[0-9a-fA-F]{128}$ or ^0x[0-9a-fA-F]{130}$), and optionally perform EIP-55
checksum verification for addresses; return/throw a clear validation error
before accepting proofs if checks fail.

In `@testdata/lexicons/org/hypercerts/context/attachment.json`:
- Line 11: The attachment schema currently allows empty attachments because
"content" was removed from the "required" array; update the schema for the
"attachment" object to prevent empty records by adding "content" back into the
"required" array (so the required list includes "title", "createdAt", and
"content") or alternatively enforce a constraint that at least one of "content",
"shortDescription", or "description" must be present; modify the "required"
array or add a "oneOf"/"anyOf" clause accordingly on the attachment schema to
ensure attachments cannot be saved with only title and createdAt.

In `@testdata/lexicons/org/hypercerts/context/evaluation.json`:
- Around line 72-90: The score object's properties ("min", "max", "value") were
changed to strings which removes numeric validation and breaks downstream
numeric semantics; update the "score" schema so "min", "max", and "value" use
"type": "number" (not "string") and add appropriate JSON Schema numeric
constraints (e.g., "minimum"/"maximum" or "exclusiveMinimum"/"exclusiveMaximum"
as needed) so the schema enforces numeric values and preserves numeric
filter/sort behavior for the score object.

In `@testdata/lexicons/pub/leaflet/blocks/image.json`:
- Around line 24-33: The aspectRatio schema currently allows zero or negative
integers for "width" and "height"; update the "aspectRatio" object's "width" and
"height" property schemas (names: "aspectRatio", "width", "height") to enforce
positive dimensions by adding a minimum constraint (e.g., "minimum": 1 or
"exclusiveMinimum": 0) to each property so only integers >= 1 are valid.

---

Outside diff comments:
In `@client/src/lib/auth/client.ts`:
- Around line 119-129: The current logic sets url = isLocalDev ? localhostUrl :
publicUrl || localhostUrl which silently falls back to localhost in production;
update the logic to throw an error when running in production and
env.PUBLIC_CLIENT_URL is not set. Specifically, keep publicUrl, localhostUrl and
isLocalDev but change how url is computed so that if !isLocalDev and !publicUrl
you throw a clear error (including mention of PUBLIC_CLIENT_URL) instead of
using localhostUrl; otherwise set url to publicUrl in production or localhostUrl
in dev.

---

Nitpick comments:
In `@client/src/app/lexicons/page.tsx`:
- Around line 446-457: The modal rendered when confirmNsid is truthy lacks
keyboard accessibility; add an Escape key handler (useEffect watching
confirmNsid that calls setConfirmNsid(null) on Escape), implement a focus trap
for the dialog (use a library like react-focus-lock or switch to
`@radix-ui/react-dialog` to ensure focus stays inside while open), and auto-focus
the Cancel button by attaching a ref to that button and focusing it in a
useEffect when confirmNsid becomes non-null; apply these changes around the JSX
that renders the dialog (the block with role="dialog", aria-modal, confirmNsid
and setConfirmNsid references).

In `@internal/graphql/types/object.go`:
- Line 268: The current unconditional construction of unionName using
lexicon.ToTypeName(contextLexiconID) + capitalizeFirst(fieldName) + "Union" can
break consumers; change the logic in the union-name creation (where unionName is
set) to detect name collisions and only append the "Union" suffix when a
conflict exists (e.g., check the existing type registry/map or schema for an
existing type with the base name), or generate a unique, collision-aware name
(e.g., attempt baseName =
lexicon.ToTypeName(contextLexiconID)+capitalizeFirst(fieldName) and if that
exists, use baseName+"Union" or another deterministic suffix); adjust the code
surrounding unionName, lexicon.ToTypeName and capitalizeFirst to perform this
collision check rather than always renaming.

In `@internal/tap/consumer.go`:
- Around line 170-174: The runOnce method on type Consumer currently returns
(error, bool) which is non-idiomatic in Go; change the signature of
Consumer.runOnce from runOnce(ctx context.Context) (error, bool) to runOnce(ctx
context.Context) (connected bool, err error) (or alternately return a small
struct with those fields) and update all call sites that currently unpack the
returns (look for usages of c.runOnce(...)) to match the new order so callers
still use the connected value to decide reconnection backoff and handle err
last.
- Around line 306-316: isWriteError currently matches substrings which is
fragile; replace that with sentinel errors: add package-level sentinel vars
(e.g., errAckWriteFailed, errWriteDeadlineFailed), update dispatch() to wrap ack
write failures with fmt.Errorf("%w: ...", errAckWriteFailed) and update
writeText() to wrap SetWriteDeadline and write failures with fmt.Errorf("%w:
...", errWriteDeadlineFailed) (preserving original error for context), then
change isWriteError(err error) to return errors.Is(err, errAckWriteFailed) ||
errors.Is(err, errWriteDeadlineFailed). This keeps behavior identical but makes
detection robust and locatable via the symbols isWriteError, dispatch,
writeText, errAckWriteFailed, and errWriteDeadlineFailed.

In `@railway.toml`:
- Line 6: The healthcheckTimeout setting (healthcheckTimeout) was increased to
60s which may improve boot tolerance but will also delay restarts on hard
failures; re-evaluate and pick a value based on measured startup and health
probe latencies (e.g., median+3σ) or make it configurable per-environment,
document the rationale inline as a comment, and if you need faster MTTR reduce
the timeout (e.g., to a value closer to observed worst-case startup) or add an
env var to override healthcheckTimeout so prod vs staging can differ.

In `@testdata/lexicons/org/hyperboards/board.json`:
- Around line 44-60: Add explicit invariant text to the schema/docs to make
background source deterministic: declare that backgroundType, backgroundImage
and backgroundIframeUrl are mutually related so only one source is active—when
backgroundType == "image" the renderer must use backgroundImage and ignore
backgroundIframeUrl (and backgroundImage SHOULD be present), when backgroundType
== "iframe" the renderer must use backgroundIframeUrl and ignore backgroundImage
(and backgroundIframeUrl SHOULD be present); alternatively specify a clear
precedence rule (e.g., backgroundType wins over any set values) and mention
which fields are required/ignored in each case so renderers can behave
deterministically for backgroundType, backgroundImage and backgroundIframeUrl.

In `@testdata/lexicons/org/hypercerts/claim/activity.json`:
- Around line 49-56: The contributors array in the schema is unbounded which
allows arbitrarily large payloads; update the "contributors" definition to add a
sensible cap (e.g., "maxItems") so the array cannot grow without limit—locate
the "contributors" property in activity.json and add a "maxItems" value
appropriate for your domain (and optionally "minItems" if needed) to enforce the
bound while leaving the existing "items" ref to "#contributor" unchanged.

In `@testdata/lexicons/org/hypercerts/claim/rights.json`:
- Around line 31-32: Add unit/integration tests that verify the schema
constraints "maxLength" (10000) and "maxGraphemes" (1000) are enforced correctly
across character sets: create test inputs for simple ASCII, multi-byte Unicode
(e.g., accented/2-3 byte chars), and complex emoji/combining sequences, and
assert that messages exceeding 10000 bytes are rejected while those exceeding
1000 graphemes are rejected (and valid inputs within both limits are accepted);
update or add tests around the validation logic that reads "maxLength" and
"maxGraphemes" so the schema in rights.json behaves as intended.

In `@testdata/lexicons/org/hypercerts/context/measurement.json`:
- Line 7: The schema's "subjects" array under the "Measurement data related to
one or more records (e.g. activities, projects, etc.)." description currently
allows an empty array; add "minItems": 1 to the "subjects" array definition in
measurement.json so that when "subjects" is present it must contain one or more
entries, and apply the same minItems: 1 change to the other "subjects"
occurrences referenced (lines 13-21) so the contract "one or more records" is
enforced consistently.

In `@testdata/lexicons/pub/leaflet/blocks/code.json`:
- Around line 9-10: The plaintext property in the code block schema is currently
unbounded; add a maxLength to the "plaintext" schema entry (the "plaintext"
string type) to limit payload size—choose an appropriate ceiling (e.g.,
2000–10000 chars) and add "maxLength": <number> to the "plaintext" object so
validation rejects oversized content.

In `@testdata/lexicons/pub/leaflet/blocks/horizontalRule.json`:
- Around line 7-8: The JSON schema for the horizontal rule block contains a
redundant empty "required" array; remove the "required": [] entry from the
horizontalRule.json schema (the object that currently lists "required" and
"properties") so the schema is cleaner and only keeps "properties": {} for the
empty-object schema.

In `@testdata/lexicons/pub/leaflet/blocks/math.json`:
- Around line 9-10: The "tex" string property in the JSON schema currently
allows unbounded input; update the "tex" property definition to include a
maxLength constraint (e.g., "maxLength": 2048 or another agreed limit) so
validation and storage are bounded, and adjust any tests or consumers that
assume unlimited length accordingly; reference the "tex" property in this schema
when making the change.

In `@testdata/lexicons/pub/leaflet/pages/linearDocument.json`:
- Around line 12-18: The schema defines unbounded arrays for "blocks" (items ref
"#block") and similar arrays referenced by "main.blocks" and "position.block";
add practical array bounds (e.g., maxItems and optionally minItems) to these
array schemas to limit payload size and prevent oversized documents, updating
the "blocks" definition and any other occurrences (main.blocks, position.block)
that use the same ref so they inherit or explicitly declare the maxItems
constraint.

In `@testdata/lexicons/pub/leaflet/richtext/facet.json`:
- Around line 50-59: The link facet's uri property currently lacks schema
validation; update the "link" object schema (property "uri") to include a URI
format check by adding "format": "uri" (matching the approach used in
atMention.atURI) so the validator enforces valid URLs; ensure the change is
applied to the "link" definition in the lexicon JSON (property name "uri" under
the "link" facet).
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ddd0e6fe-037e-4653-b9ee-66ce381ec92c

📥 Commits

Reviewing files that changed from the base of the PR and between 821beec and 3a06bdd.

📒 Files selected for processing (52)
  • client/.env.example
  • client/src/app/api/oauth/client-metadata.json/route.ts
  • client/src/app/lexicons/page.tsx
  • client/src/lib/auth/client.ts
  • client/src/lib/env.ts
  • go.mod
  • internal/graphql/types/object.go
  • internal/tap/consumer.go
  • internal/tap/consumer_test.go
  • railway.toml
  • testdata/lexicons/app/bsky/richtext/facet.json
  • testdata/lexicons/app/certified/actor/organization.json
  • testdata/lexicons/app/certified/actor/profile.json
  • testdata/lexicons/app/certified/badge/award.json
  • testdata/lexicons/app/certified/badge/definition.json
  • testdata/lexicons/app/certified/badge/response.json
  • testdata/lexicons/app/certified/defs.json
  • testdata/lexicons/app/certified/link/evm.json
  • testdata/lexicons/app/certified/location.json
  • testdata/lexicons/org/hyperboards/board.json
  • testdata/lexicons/org/hyperboards/displayProfile.json
  • testdata/lexicons/org/hypercerts/claim/activity.json
  • testdata/lexicons/org/hypercerts/claim/contribution.json
  • testdata/lexicons/org/hypercerts/claim/contributorInformation.json
  • testdata/lexicons/org/hypercerts/claim/rights.json
  • testdata/lexicons/org/hypercerts/collection.json
  • testdata/lexicons/org/hypercerts/context/acknowledgement.json
  • testdata/lexicons/org/hypercerts/context/attachment.json
  • testdata/lexicons/org/hypercerts/context/evaluation.json
  • testdata/lexicons/org/hypercerts/context/measurement.json
  • testdata/lexicons/org/hypercerts/defs.json
  • testdata/lexicons/org/hypercerts/funding/receipt.json
  • testdata/lexicons/org/hypercerts/helper/workScopeTag.json
  • testdata/lexicons/org/hypercerts/workscope/cel.json
  • testdata/lexicons/org/hypercerts/workscope/tag.json
  • testdata/lexicons/pub/leaflet/blocks/blockquote.json
  • testdata/lexicons/pub/leaflet/blocks/bskyPost.json
  • testdata/lexicons/pub/leaflet/blocks/button.json
  • testdata/lexicons/pub/leaflet/blocks/code.json
  • testdata/lexicons/pub/leaflet/blocks/header.json
  • testdata/lexicons/pub/leaflet/blocks/horizontalRule.json
  • testdata/lexicons/pub/leaflet/blocks/iframe.json
  • testdata/lexicons/pub/leaflet/blocks/image.json
  • testdata/lexicons/pub/leaflet/blocks/math.json
  • testdata/lexicons/pub/leaflet/blocks/orderedList.json
  • testdata/lexicons/pub/leaflet/blocks/page.json
  • testdata/lexicons/pub/leaflet/blocks/poll.json
  • testdata/lexicons/pub/leaflet/blocks/text.json
  • testdata/lexicons/pub/leaflet/blocks/unorderedList.json
  • testdata/lexicons/pub/leaflet/blocks/website.json
  • testdata/lexicons/pub/leaflet/pages/linearDocument.json
  • testdata/lexicons/pub/leaflet/richtext/facet.json
💤 Files with no reviewable changes (1)
  • testdata/lexicons/org/hypercerts/helper/workScopeTag.json

Comment on lines +7 to 10
const publicUrl = env.PUBLIC_CLIENT_URL;
const url = publicUrl || `http://127.0.0.1:${env.PORT}`;
const isConfidential = !!publicUrl && !!env.ATPROTO_JWK_PRIVATE;

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

Avoid serving localhost OAuth metadata in production.

Line 8 uses localhost fallback regardless of deployment mode. If PUBLIC_CLIENT_URL is missing in production, this endpoint returns invalid client_id/redirect/JWKS values.

💡 Proposed fix
 export async function GET() {
   const publicUrl = env.PUBLIC_CLIENT_URL;
-  const url = publicUrl || `http://127.0.0.1:${env.PORT}`;
+  const isLocalDev = process.env.NODE_ENV !== "production";
+  if (!isLocalDev && !publicUrl) {
+    return NextResponse.json(
+      { error: "PUBLIC_CLIENT_URL must be set in production" },
+      { status: 500 }
+    );
+  }
+  const url = publicUrl || `http://127.0.0.1:${env.PORT}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/api/oauth/client-metadata.json/route.ts` around lines 7 - 10,
The code currently assigns url = publicUrl || `http://127.0.0.1:${env.PORT}` and
thus can serve localhost OAuth metadata in production; change the logic so the
localhost fallback is only used in non-production (e.g., when NODE_ENV !==
'production' or env.NODE !== 'production') and in production require
env.PUBLIC_CLIENT_URL to be present (throw or return an error if missing).
Update the assignments around publicUrl, url, and isConfidential (which
references env.ATPROTO_JWK_PRIVATE) so that publicUrl is mandatory in production
before building client metadata and only construct the 127.0.0.1:${env.PORT}
fallback when running in development/local environments.

Comment on lines +481 to +486
onClick={() => {
if (confirmNsid) {
deleteMutation.mutate(confirmNsid);
setConfirmNsid(null);
}
}}
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

Modal closes before deletion completes.

Calling setConfirmNsid(null) immediately after mutate() closes the modal before the deletion request finishes. The isConfirmDeleting loading state never becomes visible since confirmNsid is nullified right away. If the deletion fails, the user won't understand why because the modal is already gone.

Move setConfirmNsid(null) to the mutation's onSettled callback instead.

Proposed fix

Update the mutation definition:

 const deleteMutation = useMutation({
   mutationFn: (nsid: string) =>
     graphqlClient.request(DELETE_LEXICON, { nsid }),
   onMutate: (nsid) => setDeletingNsid(nsid),
   onSuccess: (_, nsid) => {
     setSuccess(`Deleted ${nsid}`);
     setError(null);
     if (expandedId === nsid) setExpandedId(null);
     queryClient.invalidateQueries({ queryKey: ["lexicons"] });
     setTimeout(() => setSuccess(null), 3000);
   },
   onError: (err: Error) => {
     setError(err.message);
     setSuccess(null);
   },
-  onSettled: () => setDeletingNsid(null),
+  onSettled: () => {
+    setDeletingNsid(null);
+    setConfirmNsid(null);
+  },
 });

And remove the immediate setConfirmNsid(null) from the onClick:

 onClick={() => {
   if (confirmNsid) {
     deleteMutation.mutate(confirmNsid);
-    setConfirmNsid(null);
   }
 }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/lexicons/page.tsx` around lines 481 - 486, The click handler
nullifies confirmNsid immediately which closes the modal before deleteMutation
completes; instead remove the immediate setConfirmNsid(null) from the onClick
that calls deleteMutation.mutate(confirmNsid) and move the setConfirmNsid(null)
into the deleteMutation's onSettled callback (or onSuccess/onError as
appropriate) so the modal stays open while isConfirmDeleting is true and is
cleared after the mutation finishes; update the mutation definition that creates
deleteMutation to include an onSettled handler that calls setConfirmNsid(null)
and any error handling/display logic.

Comment on lines +15 to +20
const vercelBranchUrl = process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL || "";
const publicClientUrl = process.env.NEXT_PUBLIC_CLIENT_URL || "";
const normalizedVercelBranchUrl =
vercelBranchUrl && !vercelBranchUrl.startsWith("http://") && !vercelBranchUrl.startsWith("https://")
? `https://${vercelBranchUrl}`
: vercelBranchUrl;
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

Normalize branch URL more defensively before fallback.

Line 18’s scheme check is case-sensitive and untrimmed. Inputs like HTTPS://... or leading spaces can become invalid after prefixing.

💡 Proposed fix
 const vercelBranchUrl = process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL || "";
 const publicClientUrl = process.env.NEXT_PUBLIC_CLIENT_URL || "";
+const trimmedVercelBranchUrl = vercelBranchUrl.trim();
 const normalizedVercelBranchUrl =
-  vercelBranchUrl && !vercelBranchUrl.startsWith("http://") && !vercelBranchUrl.startsWith("https://")
-    ? `https://${vercelBranchUrl}`
-    : vercelBranchUrl;
+  trimmedVercelBranchUrl && !/^https?:\/\//i.test(trimmedVercelBranchUrl)
+    ? `https://${trimmedVercelBranchUrl}`
+    : trimmedVercelBranchUrl;
@@
-  PUBLIC_CLIENT_URL: publicClientUrl || normalizedVercelBranchUrl,
+  PUBLIC_CLIENT_URL: publicClientUrl.trim() || normalizedVercelBranchUrl,

Also applies to: 27-27

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

In `@client/src/lib/env.ts` around lines 15 - 20, The scheme checks for
vercelBranchUrl (normalizedVercelBranchUrl) and the similar client URL code are
too brittle: they don't trim input and are case-sensitive (so " HTTPS://..." or
"HTTPS://..." bypasses detection). Fix by trimming the env strings (use
vercelBranchUrl.trim()) and perform a case-insensitive test like const hasScheme
= trimmed.toLowerCase().startsWith("http://") ||
trimmed.toLowerCase().startsWith("https://"); then set normalizedVercelBranchUrl
= hasScheme ? trimmed : `https://${trimmed}` (and apply the same trimmed +
case-insensitive logic to the matching public client URL variable). Ensure you
guard empty values so you don't prepend to an empty string.

Comment on lines +3 to +5
go 1.26

toolchain go1.26.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

❓ Verification inconclusive

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== go.mod (version directives) =="
awk 'NR>=1 && NR<=12 {print NR ":" $0}' go.mod

echo
echo "== Potential Go version pins in repo config =="
rg -n --iglob '*.yml' --iglob '*.yaml' --iglob 'Dockerfile*' --iglob 'Makefile*' \
  'setup-go|go-version|FROM golang:|GO_VERSION|GOTOOLCHAIN|go1\.[0-9]+'

Repository: GainForest/hyperindex

Length of output: 1796


Verify Go 1.26/toolchain support across CI and runtime images before merge.

This upgrade can hard-fail builds if any workflow, Docker image, or local bootstrap still pins an older Go version.

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

In `@go.mod` around lines 3 - 5, The go.mod change to "go 1.26" and "toolchain
go1.26.0" may break builds if other places pin older Go versions; update all
CI/workflow definitions (.github/workflows), Dockerfiles (runtime/build images),
devcontainer/configs, Makefile bootstrap scripts, and any toolchain/version
files (e.g., actions/setup-go usage, golangci-lint image tags) to use Go 1.26 or
make them accept the toolchain, and run CI locally (or via act) and rebuild
Docker images to confirm compatibility before merging.

Comment on lines +7 to +15
"type": "object",
"description": "A Decentralized Identifier (DID) string.",
"required": ["did"],
"properties": {
"did": {
"type": "string",
"format": "did",
"description": "The DID string value.",
"maxLength": 256
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== References to app.certified.defs#did =="
rg -n 'app\.certified\.defs#did'

echo
echo "== Potential scalar-DID assumptions in lexicons =="
rg -nP --iglob '*.json' '"format"\s*:\s*"did"' testdata/lexicons

Repository: GainForest/hyperindex

Length of output: 1796


🏁 Script executed:

# Attempt alternative verification of the review comment's claims
# Check if we can get more context about this change

# 1. First, check if the file exists and examine its structure
find . -name "defs.json" -path "*/app/certified/*" 2>/dev/null | head -20

# 2. Search for references to did format across json files
find . -name "*.json" -type f 2>/dev/null | head -50

Repository: GainForest/hyperindex

Length of output: 2498


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Content of testdata/lexicons/app/certified/defs.json ==="
cat -n testdata/lexicons/app/certified/defs.json

echo
echo "=== Search for references to app.certified.defs#did ==="
rg 'app\.certified\.defs#did' --type json -n

echo
echo "=== Search for other did format definitions ==="
rg '"format"\s*:\s*"did"' --type json -n

echo
echo "=== Git history of the defs.json file (last 5 commits) ==="
git log --oneline -5 -- testdata/lexicons/app/certified/defs.json

echo
echo "=== Check git diff for this file ==="
git diff HEAD~1 testdata/lexicons/app/certified/defs.json | head -100

Repository: GainForest/hyperindex

Length of output: 2148


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Examining files that reference app.certified.defs#did ==="

echo -e "\n--- testdata/lexicons/org/hypercerts/funding/receipt.json ---"
cat testdata/lexicons/org/hypercerts/funding/receipt.json | head -40

echo -e "\n--- testdata/lexicons/app/certified/badge/definition.json (relevant section) ---"
cat testdata/lexicons/app/certified/badge/definition.json | head -70

echo -e "\n--- testdata/lexicons/app/certified/badge/award.json ---"
cat testdata/lexicons/app/certified/badge/award.json | head -30

echo -e "\n--- testdata/lexicons/org/hypercerts/context/evaluation.json ---"
cat testdata/lexicons/org/hypercerts/context/evaluation.json | head -35

Repository: GainForest/hyperindex

Length of output: 6400


Description inaccuracy: object type incorrectly described as "string".

Line 8 describes the defs.did definition as "A Decentralized Identifier (DID) string." but the actual type is "object" with a nested string property. This is misleading. The definition is correctly consumed by 6 referencing files that all expect and use it as an object type.

Wording fix
-      "description": "A Decentralized Identifier (DID) string.",
+      "description": "Object containing a Decentralized Identifier (DID) string.",
📝 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
"type": "object",
"description": "A Decentralized Identifier (DID) string.",
"required": ["did"],
"properties": {
"did": {
"type": "string",
"format": "did",
"description": "The DID string value.",
"maxLength": 256
"type": "object",
"description": "Object containing a Decentralized Identifier (DID) string.",
"required": ["did"],
"properties": {
"did": {
"type": "string",
"format": "did",
"description": "The DID string value.",
"maxLength": 256
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testdata/lexicons/app/certified/defs.json` around lines 7 - 15, The
description for the schema defs.did is incorrect (it says "A Decentralized
Identifier (DID) string." while the schema type is "object"); update the
description to accurately reflect that this is an object with a nested string
property (e.g., "An object containing a 'did' string property representing a
Decentralized Identifier (format: did)"), ensuring the wording references the
nested property and its format so it matches the "type": "object" and the
"properties": { "did": { "type": "string", "format": "did" } } in the defs.did
schema.

Comment on lines +13 to +18
"address": {
"type": "string",
"description": "EVM wallet address (0x-prefixed, with EIP-55 checksum recommended).",
"minLength": 42,
"maxLength": 42
},
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

🧩 Analysis chain

🌐 Web query:

AT Protocol Lexicon string schemas: what constraint keywords are supported for regex-like validation (e.g., hex strings with required 0x prefix), and what is the recommended way to enforce exact allowed lengths such as 130 or 132 only?

💡 Result:

AT Protocol Lexicon string schemas support the following constraint keywords for validation: minLength, maxLength (UTF-8 bytes), minGraphemes, maxGraphemes (Unicode graphemes), format (with predefined values: at-identifier, at-uri, cid, datetime, did, handle, nsid, tid, uri, language), knownValues (suggested values, non-enforcing), enum (closed set), const, default. No built-in "regex-like" constraint keyword (e.g., regex or pattern) is supported for arbitrary regex validation, including hex strings with 0x prefix. Predefined formats like "cid" validate base58-encoded CIDs (no 0x), but not hex. For custom patterns like "0x" + 64/66 hex chars (130/132 chars total), use a plain string type and enforce in application code, or combine minLength/maxLength if exact length matches. The recommended way to enforce exact allowed lengths (e.g., 130 or 132 only) is to set minLength and maxLength to the same value, e.g., "minLength": 130, "maxLength": 130. This works for byte lengths; use grapheme constraints if needed for visual consistency. Style guide advises against redundant format + length limits, but exact length via minLength=maxLength is standard. Validation implementations (e.g., SDKs) count UTF-8 bytes accurately.

Citations:


🏁 Script executed:

cat -n testdata/lexicons/app/certified/link/evm.json

Repository: GainForest/hyperindex

Length of output: 4073


Address and signature fields lack format validation; only length constraints are enforced.

Lines 16-17, 40-41, and 64-65 enforce length only via minLength/maxLength. The signature field (lines 40-41) allows lengths 130–132, which includes 131 (invalid for 64 or 65 byte hex with 0x prefix). The address fields allow any 42-character string, not just valid hex with 0x prefix.

AT Protocol Lexicon does not support regex or pattern validation keywords. To enforce exact signature lengths (130 or 132 only, not 131) and validate hex format with 0x prefix for addresses, implement application-level validation in the verifier path before accepting proofs.

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

In `@testdata/lexicons/app/certified/link/evm.json` around lines 13 - 18, The
schema's "address" and "signature" fields only enforce length; add
application-level validation in the verifier path (e.g., in your proof
validation function such as verifyProof / verifyEvmProof) to reject invalid
formats: validate "address" matches 0x-prefixed hex of exactly 40 hex chars
(regex ^0x[0-9a-fA-F]{40}$) and validate "signature" is 0x-prefixed hex of
either 128 or 130 hex chars (exactly 130 or 132 characters total, allow only
^0x[0-9a-fA-F]{128}$ or ^0x[0-9a-fA-F]{130}$), and optionally perform EIP-55
checksum verification for addresses; return/throw a clear validation error
before accepting proofs if checks fail.

"record": {
"type": "object",
"required": ["title", "content", "createdAt"],
"required": ["title", "createdAt"],
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

Don't allow empty attachment records.

With content removed from required and both shortDescription and description still optional, this schema now accepts records that contain only title and createdAt. That makes “attachment” records valid even when they attach nothing.

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

In `@testdata/lexicons/org/hypercerts/context/attachment.json` at line 11, The
attachment schema currently allows empty attachments because "content" was
removed from the "required" array; update the schema for the "attachment" object
to prevent empty records by adding "content" back into the "required" array (so
the required list includes "title", "createdAt", and "content") or alternatively
enforce a constraint that at least one of "content", "shortDescription", or
"description" must be present; modify the "required" array or add a
"oneOf"/"anyOf" clause accordingly on the attachment schema to ensure
attachments cannot be saved with only title and createdAt.

Comment on lines +72 to +90
"score": {
"type": "object",
"description": "Overall score for an evaluation on a numeric scale.",
"required": ["min", "max", "value"],
"properties": {
"min": {
"type": "string",
"description": "Minimum value of the scale as a numeric string (e.g. '0', '1').",
"maxLength": 50
},
"max": {
"type": "string",
"description": "Maximum value of the scale as a numeric string (e.g. '5', '10').",
"maxLength": 50
},
"value": {
"type": "string",
"description": "Score within the inclusive range [min, max] as a numeric string (e.g. '3.7').",
"maxLength": 50
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

Keep score numerically typed.

Changing min, max, and value to unconstrained strings means the schema now accepts non-numeric values, and downstream code will treat these fields as strings instead of numbers. internal/lexicon/types.go:70-123 only gives strings length validation, so this loses numeric validation/filter/sort semantics for score.

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

In `@testdata/lexicons/org/hypercerts/context/evaluation.json` around lines 72 -
90, The score object's properties ("min", "max", "value") were changed to
strings which removes numeric validation and breaks downstream numeric
semantics; update the "score" schema so "min", "max", and "value" use "type":
"number" (not "string") and add appropriate JSON Schema numeric constraints
(e.g., "minimum"/"maximum" or "exclusiveMinimum"/"exclusiveMaximum" as needed)
so the schema enforces numeric values and preserves numeric filter/sort behavior
for the score object.

Comment on lines +24 to +33
"aspectRatio": {
"type": "object",
"required": ["width", "height"],
"properties": {
"width": {
"type": "integer"
},
"height": {
"type": "integer"
}
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

Enforce positive aspectRatio dimensions.

Line 28 and Line 31 currently allow 0 and negative values. That permits invalid image ratios and can cause downstream layout/division issues.

Suggested schema tightening
     "aspectRatio": {
       "type": "object",
       "required": ["width", "height"],
       "properties": {
         "width": {
-          "type": "integer"
+          "type": "integer",
+          "minimum": 1
         },
         "height": {
-          "type": "integer"
+          "type": "integer",
+          "minimum": 1
         }
       }
     }
📝 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
"aspectRatio": {
"type": "object",
"required": ["width", "height"],
"properties": {
"width": {
"type": "integer"
},
"height": {
"type": "integer"
}
"aspectRatio": {
"type": "object",
"required": ["width", "height"],
"properties": {
"width": {
"type": "integer",
"minimum": 1
},
"height": {
"type": "integer",
"minimum": 1
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testdata/lexicons/pub/leaflet/blocks/image.json` around lines 24 - 33, The
aspectRatio schema currently allows zero or negative integers for "width" and
"height"; update the "aspectRatio" object's "width" and "height" property
schemas (names: "aspectRatio", "width", "height") to enforce positive dimensions
by adding a minimum constraint (e.g., "minimum": 1 or "exclusiveMinimum": 0) to
each property so only integers >= 1 are valid.

fix: stabilize records keyset pagination across timestamp formats
@Kzoeps
Copy link
Copy Markdown
Collaborator Author

Kzoeps commented Apr 14, 2026

closing this pr to run ci and have a better description

@Kzoeps Kzoeps closed this Apr 14, 2026
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