Skip to content

Realtime gateway: sharee access-check endpoint + SSE reconnect cursor#2336

Open
mrkoreye wants to merge 22 commits into
mainfrom
korey/moar-updates-realtime
Open

Realtime gateway: sharee access-check endpoint + SSE reconnect cursor#2336
mrkoreye wants to merge 22 commits into
mainfrom
korey/moar-updates-realtime

Conversation

@mrkoreye

Copy link
Copy Markdown
Contributor

Summary

Two follow-ups for the hosted Realtime Sync Gateway, both opt-in. Apps without hosted-realtime config are unchanged. These unblock the corresponding wiring in the ai-services gateway (ai-codegen PR #5746).

What's in this PR

Sharee visibility (can-see)
The hosted gateway is a generic multi-tenant service and has no copy of an app's shareable-resource registry, so it cannot resolve sharee access itself. It now asks the app:

  • New GET /_agent-native/can-see, mounted by core-routes. It verifies a gateway access-check token against the app's per-project HMAC secret, runs the app's own registry-based resolveAccess, and returns { allowed }. Fails closed (allowed: false) on an unknown resource type or lookup error, 404s when the app has no realtime secret, and is Cache-Control: private, no-store.
  • New signGatewayAccessToken / verifyGatewayAccessToken (exported from ./server/short-lived-token). Per-project HMAC key, a typ discriminator so they can't be swapped for subscribe or media tokens, and the full access query (resourceType, resourceId, userEmail, orgId) bound into the signature so the app authenticates the params, not just the caller.

SSE reconnect replay
The hosted client opened the gateway stream with ?token= only, so the gateway's connect-time catch-up ran with since=0 and deferred the reconnect gap to the next poll. use-db-sync now sends &since=<cursor> on the hosted SSE URL (first connect stays at 0, nothing to replay), so the gateway replays the gap on connect. The gateway already reads since, so no gateway change is needed.

Why

Share-aware delivery is a spec acceptance criterion, and the gateway had no way to honor it without the app's access logic. Keeping resolveAccess in the app (source of truth for shares, ownership, and visibility) and exposing it behind a signed endpoint is the correct boundary. The reconnect change turns a poll-cadence delay into an immediate replay.

Backward compatibility

Opt-in only. can-see returns 404 unless the app is provisioned with a realtime secret, the new token type is additive, and the SSE since is only appended on the hosted-gateway path. Self-hosted and non-hosted apps are untouched.

Testing

  • short-lived-token.spec.ts: gateway access-check token round-trip, wrong key, tampered query, wrong type, expiry.
  • gateway-access-check.spec.ts: allowed / denied / fail-closed-on-throw, 401 on bad or missing token, 404 without a secret, 405 on non-GET.
  • Existing use-db-sync suite green (no regression from the SSE URL change).
  • 51 tests across the touched specs pass. Changeset included (@agent-native/core minor).

Follow-up

After this publishes, the ai-services gateway gets its resolveAccess injected to call can-see (staged, small change). Until then the gateway delivers owner and org-scoped events; sharee-only events wait on that wiring.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

builder-io-integration[bot]

This comment was marked as outdated.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@steve8708

Copy link
Copy Markdown
Contributor

Code review

Two minor, non-blocking suggestions:

  1. verifyGatewayAccessToken doesn't bind the token's projectId claim against an expected value, unlike its sibling verifyRealtimeSubscribeToken a few lines up, which rejects with wrong_project on a mismatch specifically as protection against key mix-ups ("even if keys ever overlapped"). Right now this is harmless in practice — verification runs against a single app's own per-project secret, and projectId is never read back out of the result in gateway-access-check.ts — but adding the same binding would keep the two token types consistent and cheap to keep safe if the secret lookup ever becomes multi-tenant.

/** Verify a gateway access-check token against the app's per-project `key`. */
export function verifyGatewayAccessToken(
token: string,
key: string,
): GatewayAccessVerifyResult {
if (!key) return { ok: false, reason: "no_key" };
if (typeof token !== "string" || !token.includes(".")) {
return { ok: false, reason: "malformed" };
}
const [payloadStr, sig] = token.split(".", 2);
if (!payloadStr || !sig) return { ok: false, reason: "malformed" };
if (!timingSafeEqualB64(sig, hmacB64(payloadStr, key))) {
return { ok: false, reason: "bad_signature" };
}
let claims: DecodedGatewayAccessClaims;
try {
claims = JSON.parse(base64UrlDecode(payloadStr).toString("utf8"));
} catch {
return { ok: false, reason: "bad_payload" };
}
if (claims.typ !== GATEWAY_ACCESS_TOKEN_TYPE) {
return { ok: false, reason: "wrong_type" };
}
if (typeof claims.exp !== "number")
return { ok: false, reason: "bad_payload" };
if (claims.exp * 1000 < Date.now()) return { ok: false, reason: "expired" };
if (
!claims.projectId ||
!claims.resourceType ||
!claims.resourceId ||
!claims.userEmail
) {
return { ok: false, reason: "bad_payload" };
}
return {
ok: true,
projectId: claims.projectId,
resourceType: claims.resourceType,
resourceId: claims.resourceId,
userEmail: claims.userEmail,
orgId: claims.orgId,

(compare with the sibling check it omits:

}
if (claims.projectId !== expected.projectId) {
return { ok: false, reason: "wrong_project" };
}
)

  1. The new header comments in gateway-access-check.ts and the "Gateway access-check tokens" section of short-lived-token.ts are near-duplicates of the same rationale (6-7 lines each) — a bit past CLAUDE.md's "a comment that earns its place to a line or two" guidance. Worth trimming one and cross-referencing the other rather than repeating the design rationale in both places.

/**
* `GET /_agent-native/can-see?token=<gateway-access-token>` — the hosted
* Realtime Gateway has no copy of this app's shareable-resource registry, so it
* cannot resolve sharee visibility itself. It signs a token with the app's
* per-project HMAC secret (binding the full access query) and asks here; the
* app runs `resolveAccess` and answers `{ allowed }`. The token is the auth —
* only a holder of the per-project secret can mint it.
*/

// ── Gateway access-check tokens ──────────────────────────────────────────────
//
// The hosted gateway has no access to an app's shareable-resource registry, so
// it cannot resolve sharee visibility itself. It signs one of these with the
// app's per-project key and calls the app's `/_agent-native/can-see`, which runs
// `resolveAccess` and answers. The full access query is bound into the token so
// the app authenticates the params, not merely the caller.

@netlify

This comment has been minimized.

builder-io-integration[bot]

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

One additional cursor-contract issue to fix or explicitly resolve before enabling this path:

The new hosted SSE reconnect URL now sends the client cursor via since, but gateway versions are still derived independently by each writer (max(previous + 1, Date.now())). If a client receives cursor 100, reconnects to another gateway instance, and that instance later persists an event with version 90, the gateway's version > since read skips that event permanently. Deterministic event IDs deduplicate duplicate writes but do not establish global ordering.

Please use a DB-assigned monotonic sequence, or overlap reads below the cursor and deduplicate by deterministic event ID, before treating reconnect replay as lossless. This is separate from the existing stale-EventSource URL finding.

References: client reconnect change, version allocation, gateway implementation.

@steve8708
steve8708 force-pushed the korey/moar-updates-realtime branch from ed98910 to be9709b Compare July 23, 2026 00:09
@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@mrkoreye

Copy link
Copy Markdown
Contributor Author

Two minor, non-blocking suggestions

Both done, plus the stale-stream item from the follow-up review:

  1. projectId binding: Implemented rather than deferred. verifyGatewayAccessToken now takes an optional expected projectId and rejects with wrong_project on mismatch, matching verifyRealtimeSubscribeToken. The can-see endpoint passes its own project id when it can resolve it from env (sync, no added cost on the check path) and skips the binding for scoped-secret apps where it can't, so nothing regresses there. Covered by token-level and endpoint-level tests.

  2. Duplicate doc rationale: Trimmed the gateway-access-check.ts header to what the endpoint does and pointed it at the token section in short-lived-token.ts, so the design rationale lives in one place.

  3. Stale EventSource onerror (from the second review pass): Fixed with a source-identity guard at the top of onerror, before any state update, so a replaced or closed stream firing late can neither flip the connected state nor tear down the current healthy stream. Regression test included.

@mrkoreye

Copy link
Copy Markdown
Contributor Author

One additional cursor-contract issue to fix or explicitly resolve before enabling this path

Agreed it had to be resolved, so I implemented it in this PR rather than deferring. New dbAssignedVersions option on AppSyncState (default off): durable versions are allocated from a one-row Postgres allocator, GREATEST(v + 1, epoch_ms_now, floor), inside the same autocommit insert statement, so version order equals commit order across every writer of an app DB.

Design notes, since you called out both candidate mechanisms:

  • Not a plain sequence, on purpose. Versions have to stay on the epoch-ms scale (the version seed mixes updated_at timestamps and live client cursors already sit at ~1.75e12), and a sequence serializes allocation, not commit visibility, which is the actual hole: a stalled transaction can commit below a cursor that already passed it. The single-statement row-lock allocator closes that, and single-statement is required anyway because the gateway reaches app DBs through a pooled, transactionless connection.
  • Buffer and emit defer until the allocated version returns, so no provisional clock version ever reaches a client (cursors are max-only, so provisional-then-restamp would recreate the bug).
  • A deterministic-id dedupe loser adopts the winner's version via ON CONFLICT DO UPDATE ... RETURNING, so it never emits a phantom version to its own clients.
  • The version seed lifts the allocator to the app-clock updated_at domain before seeding cursors, so a skew-fast writer's rows can't put cursors above the allocator.
  • DB failure falls back to clock allocation (logged), so the in-process fast path never stalls; the ordering guarantee is soft exactly while the DB is unhealthy.

Gating: app instances enable it automatically under hosted transport; the gateway side is a one-line opt-in in ai-services that lands with the core version bump, and the LD flag stays off until both are in. Covered by 11 tests including two skewed writers sharing one allocator and the reconnect spec.

@mrkoreye

Copy link
Copy Markdown
Contributor Author

@steve8708 made some big changes based on the suggestions, this PR could use a full new review

builder-io-integration[bot]

This comment was marked as outdated.

@netlify

This comment has been minimized.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Builder reviewed your changes and found 1 potential issue 🔴

Review Details

Code Review Summary

This incremental review covers the expanded PR #2336 scope: opt-in Postgres DB-assigned sync versions, serialized/deferred durable event writes, clock fallback behavior, hosted SSE reconnect hardening, and the gateway access-check route. The allocator's single-statement update/insert, deterministic-ID winner-version behavior, seed alignment, and hosted-only gating are generally sound. The existing local path remains unchanged when hosted transport configuration is absent. Two reviewers independently examined the allocator and poll flow; one confirmed a high-severity cursor-integrity issue in the fallback path, while another found no issue and specifically considered the allocator, dedupe, seed, and reconnect behavior sound.

Key Finding

🔴 HIGH — Fallback version can advance a client cursor beyond durable DB versions. If the allocating statement commits but the client call times out, or if allocation fails transiently, recordWithDbVersion emits a locally clock-assigned version while the durable row may retain a lower DB-assigned version and the shared allocator is not advanced to the emitted fallback value. A client receiving that event advances its max-only cursor; subsequent events from another writer with versions below that fallback cursor can then be filtered permanently. Reusing the event ID prevents duplication but does not restore the version invariant. The fallback should recover/reuse the durable row's version when possible, or advance the shared allocator before exposing the fallback version.

Risk level: High, due to shared realtime data ordering and authorization-sensitive changes. The HMAC access-check and stale EventSource protections remain well-tested. Browser verification was attempted, but Chrome automation was unavailable; server-side route checks and 43 targeted unit tests passed.

Comment on lines +977 to +991
if (version == null) {
// Availability fallback: a DB blip must not stall the in-process fast
// path. Clock-allocate and emit; the allocator's GREATEST(v+1, now)
// re-aligns the domain on recovery. The ordering guarantee is soft
// exactly while the DB is unhealthy.
this.dbVersionFallbacks++;
if (this.dbVersionFallbacks === 1) {
console.warn(
"[agent-native] sync version allocation failed; falling back to clock-assigned versions",
);
}
this.version = Math.max(this.version + 1, Date.now());
const entry: ChangeEvent = { ...event, version: this.version };
this.commitEntryForChain(entry);
void this.persistSyncEvent(entry, dedupeKey, id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Do not emit a fallback cursor above the shared allocator

If the allocating statement commits but the client call times out, or allocation fails after the allocator has advanced, this fallback emits a local clock version while persistSyncEvent can leave the durable row at its lower DB-assigned version. A client can advance its max-only cursor past subsequent events from other writers and permanently filter them. Recover the committed row's version/retry the same ID before emitting, or advance the shared allocator to the fallback version before exposing it.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, this was the real gap left after the id-reuse fix. Two changes: on a failed allocating call the writer first reads back the row by the shared id, so a commit-then-timeout emits the actual allocator-assigned version instead of a clock one (no fallback at all in that case). Only a genuinely unrecoverable failure clock-falls-back now, and it lifts the shared allocator to the fallback value before emitting, so other writers' next allocations land above any cursor the emit advances. A hard-down DB is still the documented soft window since the lift fails with everything else. Both paths covered by tests.

@mrkoreye
mrkoreye requested a review from steve8708 July 23, 2026 22:48
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