Skip to content

fix(api): allow mixed date/block bounds on get_actions sort=asc - #174

Merged
igorls merged 2 commits into
devfrom
fix/get-actions-mixed-bounds
Jun 2, 2026
Merged

fix(api): allow mixed date/block bounds on get_actions sort=asc#174
igorls merged 2 commits into
devfrom
fix/get-actions-mixed-bounds

Conversation

@igorls

@igorls igorls commented Jun 1, 2026

Copy link
Copy Markdown
Member

Summary

An operator running Hyperion in production hit the sort=asc query guard and asked how to query a range older than max_asc_window_days. The documented escape hatch is to bound the query with block numbers instead of dates (block bounds legitimately bypass the recency window). But trying it surfaced a real bug:

GET /v2/history/get_actions?...&after=<block number>&before=<ISO date>&sort=asc
→ 400 {"error":"Invalid time value [after]"}

applyTimeFilter picked a single date-OR-block branch based on whether either bound contained 'T'. So when before was a date, the whole query took the date branch and the block-number after was fed to new Date("<block>").toISOString(), which throws. You could only use block numbers if both bounds were block numbers.

Fix

Classify each bound independently:

  • ISO date strings (containing T) → @timestamp range
  • bare positive integers → block_num range

The two range filters are AND-ed, so a block-number after and an ISO-date before now compose correctly. Applied to both v1 and v2.

  • v1 also gains real block_num filtering — previously a block-number after/before was silently dropped into the @timestamp range (interpreted by ES as epoch-millis ≈ 1970, i.e. effectively no filter), even though the guard already advertised block numbers as valid bounds.
  • The sort=asc "after" date must be within the last N days error (v1 + v2) now points operators at the block-number workaround.

The 90-day guard itself is unchanged — this is purely a correctness fix to applyTimeFilter plus a clearer error message. No change to the DoS protection sort=asc enforces.

Testing

  • bun test tests/unit/query-guards.test.ts23/23 pass (6 new applyTimeFilter tests covering mixed and same-type bounds, normalization, and the empty case).
  • Verified live against a production node: the original report reproduced (400 ... within the last 90 days), the broken mix reproduced (400 Invalid time value [after]), and the same-type workarounds (both-block-numbers, sort=desc) returned 200 with correct ordering.

Behavior matrix (after fix)

after before result
ISO date ISO date single @timestamp range
block num block num single block_num range
block num ISO date block_num gte + @timestamp lte (previously 400)
ISO date block num @timestamp gte + block_num lte (previously 400)
no range filter

Combining a block-number bound with an ISO-date bound on get_actions
(e.g. `after=<block>&before=<ISO date>`) returned `400 Invalid time
value [after]`. `applyTimeFilter` chose a single date-OR-block branch
based on whether *either* bound contained "T", so a block number passed
alongside a date was fed to `new Date(...)` and threw.

Each bound is now classified independently: ISO date strings -> the
@timestamp range, bare positive integers -> the block_num range. The two
can be mixed in both v1 and v2. This makes the documented escape hatch
for querying ranges older than `max_asc_window_days` usable: block-number
bounds legitimately bypass the recency window, and can now sit next to a
date bound.

Also:
- v1 now applies real block_num filtering (a block-number after/before
  was previously dropped into the @timestamp range, silently matching all).
- the `sort=asc ... must be within the last N days` error (v1 + v2) now
  points operators at the block-number workaround.
- 6 new unit tests covering mixed/same-type bounds in applyTimeFilter.
Copilot AI review requested due to automatic review settings June 1, 2026 23:13

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request enables mixing date/timestamp and block-number bounds for the after and before parameters in both v1 and v2 get_actions routes, resolving an issue where mixed bounds caused a 400 error. It also updates the error message for queries older than the maximum window to suggest block numbers as a workaround and adds corresponding unit tests. Feedback on the PR highlights a fragile classification logic in both routes: using .includes('T') to identify dates and parseInt for block numbers can incorrectly classify date strings like '2026-06-01' as block numbers. It is recommended to check for valid positive integers first and treat all other values as dates.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +273 to +286
if (after) {
if (typeof after === 'string' && after.includes('T')) {
tsRange['gte'] = after;
} else if (parseInt(after) > 0) {
blockRange['gte'] = after;
}
}
if (before) {
if (typeof before === 'string' && before.includes('T')) {
tsRange['lte'] = before;
} else if (parseInt(before) > 0) {
blockRange['lte'] = before;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current classification logic relies on after.includes('T') to identify dates and parseInt(after) > 0 to identify block numbers. This is fragile and incorrect. For example, a valid date string like "2026-06-01" (which does not contain 'T') will fail the first check, and parseInt("2026-06-01") will return 2026, incorrectly classifying it as a block number.

Instead, we should classify block numbers first by checking if the value is a valid positive integer using Number.isInteger(Number(value)) && Number(value) > 0 (consistent with isValidBound used elsewhere in the codebase), and treat any other value as a date/timestamp.

Suggested change
if (after) {
if (typeof after === 'string' && after.includes('T')) {
tsRange['gte'] = after;
} else if (parseInt(after) > 0) {
blockRange['gte'] = after;
}
}
if (before) {
if (typeof before === 'string' && before.includes('T')) {
tsRange['lte'] = before;
} else if (parseInt(before) > 0) {
blockRange['lte'] = before;
}
}
if (after) {
if (Number.isInteger(Number(after)) && Number(after) > 0) {
blockRange['gte'] = after;
} else {
tsRange['gte'] = after;
}
}
if (before) {
if (Number.isInteger(Number(before)) && Number(before) > 0) {
blockRange['lte'] = before;
} else {
tsRange['lte'] = before;
}
}

Comment on lines +91 to +113
if (query['after']) {
if (typeof query['after'] === 'string' && query['after'].includes('T')) {
try {
_lte = new Date(query['before']).toISOString();
tsRange['gte'] = new Date(query['after']).toISOString();
} catch (e: any) {
badRequest(e.message + ' [before]');
badRequest(e.message + ' [after]');
}
} else if (parseInt(query['after']) > 0) {
blockRange['gte'] = query['after'];
}
if (query['after']) {
}

if (query['before']) {
if (typeof query['before'] === 'string' && query['before'].includes('T')) {
try {
_gte = new Date(query['after']).toISOString();
tsRange['lte'] = new Date(query['before']).toISOString();
} catch (e: any) {
badRequest(e.message + ' [after]');
badRequest(e.message + ' [before]');
}
} else if (parseInt(query['before']) > 0) {
blockRange['lte'] = query['before'];
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current classification logic relies on query['after'].includes('T') to identify dates and parseInt(query['after']) > 0 to identify block numbers. This is fragile and incorrect. For example, a valid date string like "2026-06-01" (which does not contain 'T') will fail the first check, and parseInt("2026-06-01") will return 2026, incorrectly classifying it as a block number.

Instead, we should classify block numbers first by checking if the value is a valid positive integer using Number.isInteger(Number(value)) && Number(value) > 0 (consistent with isValidBound used elsewhere in the codebase), and treat any other value as a date/timestamp.

        if (query['after']) {
            if (Number.isInteger(Number(query['after'])) && Number(query['after']) > 0) {
                blockRange['gte'] = query['after'];
            } else {
                try {
                    tsRange['gte'] = new Date(query['after']).toISOString();
                } catch (e: any) {
                    badRequest(e.message + ' [after]');
                }
            }
        }

        if (query['before']) {
            if (Number.isInteger(Number(query['before'])) && Number(query['before']) > 0) {
                blockRange['lte'] = query['before'];
            } else {
                try {
                    tsRange['lte'] = new Date(query['before']).toISOString();
                } catch (e: any) {
                    badRequest(e.message + ' [before]');
                }
            }
        }

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bab7b50bb2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +276 to +277
} else if (parseInt(after) > 0) {
blockRange['gte'] = after;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow block bounds through the v1 schema

For the v1 endpoint, this new block-number branch is not reachable for normal requests because src/api/routes/v1-history/get_actions/index.ts still declares both after and before as type: 'string', format: 'date-time'. With Fastify schema validation enabled on this route, a POST body such as { "sort": "asc", "after": "437506277" } is rejected before getActions runs, so the documented block-number workaround and the new v1 block_num filtering do not actually work through the API. The v1 schema needs to accept block-number strings/numbers as well as date-times.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes get_actions time-bounding so after/before can be mixed between ISO timestamps and block numbers (especially important for sort=asc where block bounds are the documented workaround for querying older ranges).

Changes:

  • Update v2 applyTimeFilter to classify after and before independently and apply @timestamp and/or block_num ranges accordingly.
  • Update v1 get_actions query construction to support true block_num range filtering and mixed bound types.
  • Add unit tests for applyTimeFilter mixed-bound behavior; update sort=asc guard error messaging; document the fix in the changelog.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
tests/unit/query-guards.test.ts Adds unit coverage for applyTimeFilter mixed date/block bounds and normalization cases.
src/api/routes/v2-history/get_actions/functions.ts Fixes applyTimeFilter to support mixed bound types; updates sort=asc recency error message.
src/api/routes/v1-history/get_actions/get_actions.ts Implements mixed-bound filtering and real block_num range filtering; updates sort=asc recency error message.
CHANGELOG.md Documents the mixed-bound fix, the v1 block_num filtering correction, and the improved operator guidance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 98 to 100
} else if (parseInt(query['after']) > 0) {
blockRange['gte'] = query['after'];
}
Comment on lines 110 to 112
} else if (parseInt(query['before']) > 0) {
blockRange['lte'] = query['before'];
}
Comment on lines 279 to 283
if (!isNaN(afterDate.getTime())) {
const maxAge = Date.now() - (maxAscWindowDays * 86400000);
if (afterDate.getTime() < maxAge) {
badRequest(`sort=asc "after" date must be within the last ${maxAscWindowDays} days`);
badRequest(`sort=asc "after" date must be within the last ${maxAscWindowDays} days — use block numbers for "after"/"before" to query older ranges`);
}
Comment on lines +274 to +278
if (typeof after === 'string' && after.includes('T')) {
tsRange['gte'] = after;
} else if (parseInt(after) > 0) {
blockRange['gte'] = after;
}
Comment on lines +281 to +285
if (typeof before === 'string' && before.includes('T')) {
tsRange['lte'] = before;
} else if (parseInt(before) > 0) {
blockRange['lte'] = before;
}
Comment on lines 212 to 216
if (!isNaN(afterDate.getTime())) {
const maxAge = Date.now() - (maxAscWindowDays * 86400000);
if (afterDate.getTime() < maxAge) {
return {error: `sort=asc "after" date must be within the last ${maxAscWindowDays} days`};
return {error: `sort=asc "after" date must be within the last ${maxAscWindowDays} days — use block numbers for "after"/"before" to query older ranges`};
}
…ema, guard window)

Review feedback from gemini-code-assist, chatgpt-codex-connector, and
copilot-pull-request-reviewer on PR #174:

- Strict block-number classification (Gemini high; Copilot x4): replace the
  `.includes('T')` / `parseInt(...) > 0` heuristic with a shared
  `isBlockNumber()` using `Number.isInteger(Number(v)) && Number(v) > 0`.
  `parseInt("2026-01-01")` was 2026, so a date without a `T` was misread as
  block 2026 and the raw string then sent to the block_num range. `Number()`
  is strict (NaN for "2026-01-01"), so non-integers are correctly treated as
  dates. Applied to v1 and v2.

- Guard/filter alignment (Copilot x2): the `sort=asc` recency window only
  fired for strings containing `T`, so `after=2020-01-01` (or `after=0`,
  which parses to year 2000) bypassed the guard. The window now applies to
  any non-block `after` bound, matching applyTimeFilter's classification.

- v1 schema (Codex P2): `after`/`before` were pinned to `format: date-time`,
  so block-number bounds were rejected by schema validation before the
  handler ran — the v1 block-number workaround was unreachable. Relaxed to
  `type: string` (mirrors v2). Descriptions on both v1 and v2 now mention
  block numbers.

Tests: 28/28 pass (added no-T date window checks, no-T classification
regression, and isBlockNumber unit coverage).
@igorls

igorls commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

Thanks for the reviews — addressed in 2247ffc. Summary of how each finding was handled:

1. Fragile classification — parseInt + .includes('T') (gemini-code-assist 🔴 high, Copilot ×4 on functions.ts:100/112, get_actions.ts:278/285)
Fixed. parseInt("2026-01-01") returned 2026, so a date without a T was misclassified as block number 2026 and the raw string was then sent to the block_num range. Replaced the heuristic with a shared, strict helper:

export function isBlockNumber(v: any): boolean {
    return Number.isInteger(Number(v)) && Number(v) > 0;
}

Number("2026-01-01") is NaN, so anything that isn't a bare positive integer is now treated as a date. Block-number bounds → block_num, everything else → @timestamp. Applied to both v1 and v2; consistent with isValidBound.

2. v1 schema rejects block numbers (chatgpt-codex-connector P2, get_actions.ts:277)
Good catch — this made the v1 block-number path unreachable. after/before were pinned to type: 'string', format: 'date-time', so { "after": "437506277" } was rejected by schema validation before the handler ran. Relaxed both to type: 'string' (mirrors v2, which is why v2 already accepted block numbers). Descriptions on v1 and v2 now read … (ISO8601) or block number.

3. Guard ↔ filter mismatch lets dates without T bypass the window (Copilot, functions.ts:283, get_actions.ts:216)
Fixed. The sort=asc recency window only fired for strings containing T, so after=2020-01-01 — and after=0, which new Date() resolves to year 2000 — slipped past the guard entirely. The window now applies to any non-block after bound (after && !isBlockNumber(after)), matching applyTimeFilter's classification.

Tests: 28/28 pass (bun test tests/unit/query-guards.test.ts). Added coverage for no-T date window enforcement, the no-T classification regression, and isBlockNumber itself. The previous after="0" → asc test was updated to assert rejection, reflecting the closed guard hole.

Re: Gemini's "classify integers first, else date" suggestion — adopted that exact ordering, using Number() rather than parseInt for the strictness Copilot also called out.

@igorls
igorls merged commit 75ab43d into dev Jun 2, 2026
2 checks passed
@igorls
igorls deleted the fix/get-actions-mixed-bounds branch June 2, 2026 00:32
@igorls igorls mentioned this pull request Jun 2, 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.

2 participants