From bcb7557797c03de4caa94c8355bb451ca25dbbc5 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 10 Sep 2026 16:45:15 -0400 Subject: [PATCH 1/5] Scope and bound the card-read indexing drain to the requester's own writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module edit whose invalidation set spans a realm's module graph was making the realm unreadable for everyone: getCard and the card-HTML endpoint drained ALL in-flight incremental indexing, unbounded, before reading the index (CS-12934). The drain exists only for read-your-writes after a deferred +source POST, and the index stays consistent throughout (incremental jobs write to boxel_index_working and swap on completion), so parking every reader behind any pending job was far coarser than the guarantee requires. Now each incremental job is tagged with the matrix user whose HTTP write produced it, checkPermission records the effective requester identity on the RequestContext (including an identity-only token parse on the public-permission early return), and the read endpoints wait only on the requester's own pending jobs, bounded by a budget (default 10s, realm-option-tunable for tests). Readers with no verifiable identity conservatively wait — bounded — on all pending jobs; prerender-originated reads skip the gate entirely since their tab holds a render slot the awaited job may itself need. Co-Authored-By: Claude Fable 5 --- packages/realm-server/tests/helpers/index.ts | 19 ++ .../tests/read-index-drain-test.ts | 309 ++++++++++++++++++ .../tests/realm-index-updater-test.ts | 68 ++++ .../runtime-common/realm-index-updater.ts | 42 ++- packages/runtime-common/realm.ts | 168 ++++++++-- 5 files changed, 579 insertions(+), 27 deletions(-) create mode 100644 packages/realm-server/tests/read-index-drain-test.ts diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index e1c783a26f2..34a3826f100 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -1265,6 +1265,7 @@ export async function createRealm({ fullIndexOnStartup, mediaCacheAdapter, screenshotSyncWaitMs, + readIndexDrainBudgetMs, }: { dir: string; definitionLookup: DefinitionLookup; @@ -1304,6 +1305,9 @@ export async function createRealm({ // Shrinks the `_screenshot/` route's on-demand sync-wait budget so tests // can exercise the 503 + Retry-After path without holding real time. screenshotSyncWaitMs?: number; + // Shrinks the card read endpoints' read-your-writes indexing-drain budget + // so tests can exercise the bounded-wait path without holding real time. + readIndexDrainBudgetMs?: number; }): Promise<{ realm: Realm; adapter: RealmAdapter }> { await insertPermissions(dbAdapter, new URL(realmURL), permissions); @@ -1386,6 +1390,9 @@ export async function createRealm({ { ...(fullIndexOnStartup ? { fullIndexOnStartup: true as const } : {}), ...(screenshotSyncWaitMs !== undefined ? { screenshotSyncWaitMs } : {}), + ...(readIndexDrainBudgetMs !== undefined + ? { readIndexDrainBudgetMs } + : {}), }, ); if (worker) { @@ -1435,6 +1442,7 @@ export async function runTestRealmServer({ }, prerenderer: providedPrerenderer, mediaCacheAdapter, + readIndexDrainBudgetMs, }: { testRealmDir: string; realmsRootPath: string; @@ -1458,6 +1466,7 @@ export async function runTestRealmServer({ }; prerenderer?: Prerenderer; mediaCacheAdapter?: MediaCacheAdapter; + readIndexDrainBudgetMs?: number; }) { stripTlsEnvVars(); let prerenderer = providedPrerenderer ?? (await getTestPrerenderer()); @@ -1499,6 +1508,7 @@ export async function runTestRealmServer({ audioSizeLimitBytes, videoSizeLimitBytes, mediaCacheAdapter, + readIndexDrainBudgetMs, }); await testRealm.logInToMatrix(); @@ -2165,6 +2175,7 @@ type InternalPermissionedRealmSetupOptions = { audioSizeLimitBytes?: number; videoSizeLimitBytes?: number; mediaCacheAdapter?: MediaCacheAdapter; + readIndexDrainBudgetMs?: number; }; async function startPermissionedRealmFixture( @@ -2184,6 +2195,7 @@ async function startPermissionedRealmFixture( audioSizeLimitBytes, videoSizeLimitBytes, mediaCacheAdapter, + readIndexDrainBudgetMs, }: InternalPermissionedRealmSetupOptions, ): Promise<{ testRealmServer: Awaited>; @@ -2254,6 +2266,7 @@ async function startPermissionedRealmFixture( videoSizeLimitBytes, prerenderer, mediaCacheAdapter, + readIndexDrainBudgetMs, }); let request = supertest(testRealmServer.testRealmHttpServer); @@ -2323,6 +2336,7 @@ export function setupPermissionedRealm( audioSizeLimitBytes, videoSizeLimitBytes, mediaCacheAdapter, + readIndexDrainBudgetMs, }: { permissions: RealmPermissions; realmURL?: URL; @@ -2352,6 +2366,7 @@ export function setupPermissionedRealm( audioSizeLimitBytes?: number; videoSizeLimitBytes?: number; mediaCacheAdapter?: MediaCacheAdapter; + readIndexDrainBudgetMs?: number; }, ) { let testRealmServer: Awaited>; @@ -2382,6 +2397,7 @@ export function setupPermissionedRealm( audioSizeLimitBytes, videoSizeLimitBytes, mediaCacheAdapter, + readIndexDrainBudgetMs, }); testRealmServer = server; @@ -2436,6 +2452,9 @@ function permissionedRealmTemplateCacheKey( fileSizeLimitBytes: options.fileSizeLimitBytes ?? null, audioSizeLimitBytes: options.audioSizeLimitBytes ?? null, videoSizeLimitBytes: options.videoSizeLimitBytes ?? null, + // `readIndexDrainBudgetMs` is deliberately absent: it tunes request-time + // wait behavior on the live realm and leaves no trace in the template + // database, so keying on it would only fragment the template cache. prerenderer: prerendererCacheKeyPart(options.prerenderer), }); } diff --git a/packages/realm-server/tests/read-index-drain-test.ts b/packages/realm-server/tests/read-index-drain-test.ts new file mode 100644 index 00000000000..66d5d56f003 --- /dev/null +++ b/packages/realm-server/tests/read-index-drain-test.ts @@ -0,0 +1,309 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import type { RealmHttpServer as Server } from '../server.ts'; +import type { Realm } from '@cardstack/runtime-common'; +import { + setupPermissionedRealmCached, + closeServer, + createJWT, + withRealmPath, + type RealmRequest, +} from './helpers/index.ts'; +import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms.ts'; + +// The card read endpoints (card+json / card+html GET) gate on the +// requester's OWN in-flight incremental indexing — scoped and bounded — via +// Realm.drainRequestersOwnIndexing. These tests pin the gate's three +// behaviors at the HTTP boundary: another user's pending indexing never +// holds a read, the writer's own pending indexing does, and the hold is +// bounded by the realm's readIndexDrainBudgetMs. +// +// The updater's gates are stubbed per test rather than racing real jobs: a +// real incremental job settles as fast as the worker runs it, so "the read +// did not wait" could never be asserted deterministically against one. +// (RealmIndexUpdater's own tagging/filtering is covered in +// realm-index-updater-test.ts.) + +const DRAIN_BUDGET_MS = 4_000; +// A stubbed gate that resolves after this long distinguishes "the read +// waited for the gate" (elapsed >= GATE_RESOLVE_MS) from "the read skipped +// it" (elapsed well under), with margin against a slow cold request. +const GATE_RESOLVE_MS = 500; +// "Did not wait" assertions allow up to this much for the request itself; +// far above a warm request's actual cost, far below the gate/budget. +const NO_WAIT_CEILING_MS = 2_000; + +function resolveAfter(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const NEVER: Promise = new Promise(() => {}); + +// Shadow the updater's gate methods with instance properties; delete them to +// restore the prototype implementations. +function stubUpdaterGates( + realm: Realm, + stubs: { + initiatedBy?: (user: string) => Promise | undefined; + all?: () => Promise | undefined; + }, +): () => void { + let updater = realm.realmIndexUpdater as any; + if (stubs.initiatedBy) { + updater.incrementalIndexingInitiatedBy = stubs.initiatedBy; + } + if (stubs.all) { + updater.incrementalIndexing = stubs.all; + } + return () => { + delete updater.incrementalIndexingInitiatedBy; + delete updater.incrementalIndexing; + }; +} + +module(basename(import.meta.filename), function () { + module('card read drain | permissioned realm', function (hooks) { + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + }); + + setupPermissionedRealmCached(hooks, { + fixture: 'realistic', + realmURL, + permissions: { + hassan: ['read', 'write'], + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + readIndexDrainBudgetMs: DRAIN_BUDGET_MS, + onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: any; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + request = withRealmPath(args.request, realmURL); + }, + }); + + async function getPersonAs( + user: string, + permissions: ('read' | 'write')[], + accept = 'application/vnd.card+json', + ) { + return request + .get('/person-1') + .set('Accept', accept) + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, user, permissions)}`, + ); + } + + test("a reader is not held by another user's in-flight indexing", async function (assert) { + // Warm the endpoint so the timed request below measures the drain, not + // cold module/doc assembly. + let warm = await getPersonAs('john', ['read']); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + // hassan has a (never-settling) job in flight; the unscoped gate is + // also held open, so a read that (wrongly) consulted it would hold for + // the full budget. + let restore = stubUpdaterGates(testRealm, { + initiatedBy: (user) => (user === 'hassan' ? NEVER : undefined), + all: () => NEVER, + }); + try { + let startedAt = Date.now(); + let response = await getPersonAs('john', ['read']); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + elapsed < NO_WAIT_CEILING_MS, + `read returned without waiting on the writer's job (took ${elapsed}ms)`, + ); + + startedAt = Date.now(); + let htmlResponse = await getPersonAs( + 'john', + ['read'], + 'application/vnd.card+html', + ); + elapsed = Date.now() - startedAt; + assert.strictEqual( + htmlResponse.status, + 200, + `card+html HTTP 200: ${htmlResponse.text}`, + ); + assert.true( + elapsed < NO_WAIT_CEILING_MS, + `card+html read returned without waiting on the writer's job (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + + test("the writer's own read waits for their in-flight indexing", async function (assert) { + let warm = await getPersonAs('hassan', ['read', 'write']); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + let gateResolved = false; + let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { + gateResolved = true; + }); + let restore = stubUpdaterGates(testRealm, { + initiatedBy: (user) => (user === 'hassan' ? gate : undefined), + }); + try { + let startedAt = Date.now(); + let response = await getPersonAs('hassan', ['read', 'write']); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + gateResolved, + 'the read did not return before the gate settled', + ); + assert.true( + elapsed >= GATE_RESOLVE_MS - 20, + `read held until the writer's own job settled (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + + test("the writer's read proceeds on the current index generation once the budget expires", async function (assert) { + let warm = await getPersonAs('hassan', ['read', 'write']); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + let restore = stubUpdaterGates(testRealm, { + initiatedBy: (user) => (user === 'hassan' ? NEVER : undefined), + }); + try { + let startedAt = Date.now(); + let response = await getPersonAs('hassan', ['read', 'write']); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + elapsed >= DRAIN_BUDGET_MS - 50, + `read held for the drain budget (took ${elapsed}ms)`, + ); + assert.true( + elapsed < DRAIN_BUDGET_MS + NO_WAIT_CEILING_MS, + `read was released by the budget, not the gate (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + }); + + module('card read drain | public readable realm', function (hooks) { + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + }); + + setupPermissionedRealmCached(hooks, { + fixture: 'realistic', + realmURL, + permissions: { + '*': ['read'], + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + readIndexDrainBudgetMs: DRAIN_BUDGET_MS, + onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: any; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + request = withRealmPath(args.request, realmURL); + }, + }); + + test('an anonymous reader conservatively waits, bounded, on any pending indexing', async function (assert) { + let warm = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + // With no verifiable identity the drain cannot rule the requester out + // as the writer, so it covers every pending incremental job. + let gateResolved = false; + let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { + gateResolved = true; + }); + let restore = stubUpdaterGates(testRealm, { + all: () => gate, + }); + try { + let startedAt = Date.now(); + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + gateResolved, + 'the anonymous read did not return before the gate settled', + ); + assert.true( + elapsed >= GATE_RESOLVE_MS - 20, + `anonymous read held for pending indexing (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + + test("a token-carrying reader on a public realm is identified and skips other writers' indexing", async function (assert) { + let warm = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + // Public read authorizes without parsing the token, but the drain still + // learns who the requester is from the token they sent — so john is + // ruled out as the writer and skips both gates. + let restore = stubUpdaterGates(testRealm, { + initiatedBy: (user) => (user === 'hassan' ? NEVER : undefined), + all: () => NEVER, + }); + try { + let startedAt = Date.now(); + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'john', ['read'])}`, + ); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + elapsed < NO_WAIT_CEILING_MS, + `identified reader skipped the pending-indexing hold (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + }); +}); diff --git a/packages/realm-server/tests/realm-index-updater-test.ts b/packages/realm-server/tests/realm-index-updater-test.ts index 441318fd8ab..05d83e16980 100644 --- a/packages/realm-server/tests/realm-index-updater-test.ts +++ b/packages/realm-server/tests/realm-index-updater-test.ts @@ -199,6 +199,74 @@ module(basename(import.meta.filename), function (hooks) { ); }); + test('incrementalIndexingInitiatedBy scopes the gate to the tagged user', async function (assert) { + let { queue, waiters } = makeStubQueue(); + let updater = new RealmIndexUpdater({ + realm: makeStubRealm(), + dbAdapter: {} as DBAdapter, + queue, + }); + + let { settled } = await updater.enqueueUpdate( + [new URL(`${realmURL}doomed.txt`)], + { initiatedBy: '@test-writer:localhost' }, + ); + settled.catch(() => {}); + + assert.notStrictEqual( + updater.incrementalIndexingInitiatedBy('@test-writer:localhost'), + undefined, + "the writer's own gate reflects their in-flight job", + ); + assert.strictEqual( + updater.incrementalIndexingInitiatedBy('@test-bystander:localhost'), + undefined, + "another user's gate does not see the job", + ); + assert.notStrictEqual( + updater.incrementalIndexing(), + undefined, + 'the unscoped gate still covers every pending job', + ); + + waiters[0].rejectFromResult(serializedWorkerError); + await updater.incrementalIndexing(); + + assert.strictEqual( + updater.incrementalIndexingInitiatedBy('@test-writer:localhost'), + undefined, + "the writer's gate is drained once the job settles", + ); + }); + + test('an untagged job is invisible to every user-scoped gate', async function (assert) { + let { queue, waiters } = makeStubQueue(); + let updater = new RealmIndexUpdater({ + realm: makeStubRealm(), + dbAdapter: {} as DBAdapter, + queue, + }); + + let { settled } = await updater.enqueueUpdate([ + new URL(`${realmURL}doomed.txt`), + ]); + settled.catch(() => {}); + + assert.strictEqual( + updater.incrementalIndexingInitiatedBy('@test-writer:localhost'), + undefined, + 'a system-originated job never blocks a user-scoped gate', + ); + assert.notStrictEqual( + updater.incrementalIndexing(), + undefined, + 'the unscoped gate covers the untagged job', + ); + + waiters[0].rejectFromResult(serializedWorkerError); + await updater.incrementalIndexing(); + }); + test('a failing copy job throws from copy() and resolves the gate', async function (assert) { let { queue, waiters } = makeStubQueue(); let updater = new RealmIndexUpdater({ diff --git a/packages/runtime-common/realm-index-updater.ts b/packages/runtime-common/realm-index-updater.ts index 947b0c0cd2e..b997a66a899 100644 --- a/packages/runtime-common/realm-index-updater.ts +++ b/packages/runtime-common/realm-index-updater.ts @@ -65,7 +65,16 @@ export class RealmIndexUpdater { // unrelated request happens to be awaiting the write-path gate, and // (b) become an unhandled promise rejection whenever a deferred-indexing // job fails while nothing is draining the gate. - #incrementalIndexingDeferreds = new Set>(); + // Each incremental/copy deferred is tagged with the matrix user whose HTTP + // write produced the job, when known. Read endpoints use the tag to scope + // their read-your-writes drain to the requesting user's own writes + // (`incrementalIndexingInitiatedBy`) instead of parking every reader behind + // whatever indexing happens to be in flight. System-originated jobs (file + // watcher, realm copy) carry no tag. + #incrementalIndexingDeferreds = new Map< + Deferred, + { initiatedBy?: string } + >(); #fullIndexingDeferreds = new Set>(); constructor({ @@ -116,7 +125,7 @@ export class RealmIndexUpdater { // hundreds of jobs and stall every PATCH for hours). indexing() { let pending = [ - ...this.#incrementalIndexingDeferreds, + ...this.#incrementalIndexingDeferreds.keys(), ...this.#fullIndexingDeferreds, ]; if (pending.length === 0) { @@ -137,12 +146,28 @@ export class RealmIndexUpdater { return undefined; } return Promise.all( - [...this.#incrementalIndexingDeferreds].map( + [...this.#incrementalIndexingDeferreds.keys()].map( (deferred) => deferred.promise, ), ).then(() => undefined); } + // Awaits only the incremental jobs whose write was initiated by `user` — + // the read-your-writes slice of `incrementalIndexing()`. Returns undefined + // when this user has nothing in flight, even while other users' or + // system-originated jobs are pending: those jobs can only make the caller's + // read fresher-than-requested, never wrong, because the production index + // rows stay live (and consistent) until the working-table swap lands. + incrementalIndexingInitiatedBy(user: string): Promise | undefined { + let pending = [...this.#incrementalIndexingDeferreds.entries()] + .filter(([, { initiatedBy }]) => initiatedBy === user) + .map(([deferred]) => deferred.promise); + if (pending.length === 0) { + return undefined; + } + return Promise.all(pending).then(() => undefined); + } + publishFullIndex( priority = systemInitiatedPriority, opts?: { clearLastModified?: boolean; awaitedByPublish?: boolean }, @@ -247,10 +272,16 @@ export class RealmIndexUpdater { // job's own rejection still propagates through `settled`. onFailed?: (error: unknown) => Promise | void; clientRequestId?: string | null; + // Matrix user whose HTTP write produced this job, when known. Scopes + // the read endpoints' read-your-writes drain — see + // #incrementalIndexingDeferreds. + initiatedBy?: string | null; }, ): Promise<{ settled: Promise }> { let indexingDeferred = new Deferred(); - this.#incrementalIndexingDeferreds.add(indexingDeferred); + this.#incrementalIndexingDeferreds.set(indexingDeferred, { + initiatedBy: opts?.initiatedBy ?? undefined, + }); let snapshotVersion = this.#ignoreDataVersion; let job: Job; try { @@ -328,6 +359,7 @@ export class RealmIndexUpdater { meta: { generation?: number }, ) => Promise; clientRequestId?: string | null; + initiatedBy?: string | null; }, ): Promise { let { settled } = await this.enqueueUpdate(urls, opts); @@ -339,7 +371,7 @@ export class RealmIndexUpdater { onInvalidation?: (invalidatedURLs: URL[]) => Promise, ): Promise<{ generation?: number }> { let indexingDeferred = new Deferred(); - this.#incrementalIndexingDeferreds.add(indexingDeferred); + this.#incrementalIndexingDeferreds.set(indexingDeferred, {}); try { let args: CopyArgs = { realmURL: this.#realm.url, diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 9c2b0792721..492b7814cf6 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -418,6 +418,15 @@ const ARCHIVED_SEAL_EXEMPT_PATHS = new Set(['_readiness-check', '_session']); // carries its own, longer one — a published realm's HTML can take minutes and // its callers are pollers with deadlines to match. const READINESS_REQUEST_BUDGET_MS = 10_000; +// How long a card read holds its connection on the read-your-writes indexing +// drain (see `drainRequestersOwnIndexing`) before serving the current index +// generation anyway. Bounded for the same reason the readiness gates are: an +// unbounded hold is worse than a slightly stale answer. The index stays +// consistent throughout — incremental jobs write into the working table and +// only swap on completion — so a read that outlives the budget serves the +// previous generation, and the index event that follows the swap refreshes +// live clients. +const READ_INDEX_DRAIN_BUDGET_MS = 10_000; const MODULE_ETAG_VARIANT = 'module'; const SOURCE_ETAG_VARIANT = 'source'; // Card+JSON ETag is `"-[-]:card"` @@ -850,6 +859,12 @@ export interface WriteOptions { // the JSON-API card handlers do not, writing a single file and instances // respectively. waitForIndex?: boolean | null; + // The matrix user (post assume-user) whose request produced this write, + // when known — `requestContext.authenticatedUser` at the HTTP handlers. + // Tags the resulting incremental index job so read endpoints can scope + // their read-your-writes drain to this user's own reads. Absent for + // system-originated writes. + initiatingUser?: string | null; } export interface RealmAdapter { @@ -938,6 +953,12 @@ interface Options { // SCREENSHOT_SYNC_WAIT_BUDGET_MS; tests shrink it to exercise the timeout // path without holding real time. screenshotSyncWaitMs?: number; + // How long a card read (card+json / card+html GET) holds its connection + // waiting on the requester's own in-flight incremental indexing before + // serving the current index generation anyway. Defaults to + // READ_INDEX_DRAIN_BUDGET_MS; tests shrink it to exercise the timeout path + // without holding real time. + readIndexDrainBudgetMs?: number; } interface UpdateItem { @@ -950,7 +971,19 @@ export interface MatrixConfig { username: string; } -export type RequestContext = { realm: Realm; permissions: RealmPermissions }; +export type RequestContext = { + realm: Realm; + permissions: RealmPermissions; + // The effective matrix user this request runs as (post `X-Boxel-Assume-User` + // indirection), recorded by `checkPermission` when it sees a verifiable + // token. Undefined when the request was authorized without one — a public + // endpoint or public-permission realm where no (valid) token accompanied + // the request, or a realm-internal (isLocal) dispatch. Used to scope the + // read endpoints' read-your-writes indexing drain to the requester's own + // writes; it is identity, not authority — authorization decisions never + // read it. + authenticatedUser?: string; +}; export class Realm { #startedUp = new Deferred(); @@ -1064,6 +1097,7 @@ export class Realm { #virtualNetwork: VirtualNetwork; #mediaCacheAdapter: MediaCacheAdapter | undefined; #screenshotSyncWaitMs: number; + #readIndexDrainBudgetMs: number; #cachedRealmInfo: RealmInfo | null = null; // md5 of the JSON-stringified `#cachedRealmInfo`. Folded into the // card+json ETag so any path that nulls `#cachedRealmInfo` (e.g. @@ -1199,6 +1233,8 @@ export class Realm { this.#mediaCacheAdapter = mediaCacheAdapter; this.#screenshotSyncWaitMs = opts?.screenshotSyncWaitMs ?? SCREENSHOT_SYNC_WAIT_BUDGET_MS; + this.#readIndexDrainBudgetMs = + opts?.readIndexDrainBudgetMs ?? READ_INDEX_DRAIN_BUDGET_MS; let owner: string | undefined; let _fetch = fetcher( virtualNetwork.fetch, @@ -1705,6 +1741,7 @@ export class Realm { opts?: { delete?: true; clientRequestId?: string | null; + initiatedBy?: string | null; }, ): Promise<{ invalidations: string[]; generation?: number }> { if (urls.length === 0) { @@ -1716,6 +1753,7 @@ export class Realm { await this.#realmIndexUpdater.update(urls, { ...(opts?.delete ? { delete: true } : {}), clientRequestId: opts?.clientRequestId ?? null, + initiatedBy: opts?.initiatedBy ?? null, onInvalidation: async (invalidatedURLs: URL[], meta) => { // Drop the searchCards in-flight map: the worker's batch.done() // swap landed in this realm's boxel_index, so any pending @@ -1755,6 +1793,7 @@ export class Realm { opts: { delete?: true; clientRequestId?: string | null; + initiatedBy?: string | null; onSettled?: ( invalidations: string[], meta: { generation?: number }, @@ -1773,6 +1812,7 @@ export class Realm { let { settled } = await this.#realmIndexUpdater.enqueueUpdate(urls, { ...(opts?.delete ? { delete: true } : {}), clientRequestId: opts?.clientRequestId ?? null, + initiatedBy: opts?.initiatedBy ?? null, onInvalidation: async (invalidatedURLs: URL[], meta) => { await this.clearRealmIndexCachesAndBroadcast(); await this.touchSourceRealmUpdatedAt(); @@ -2336,10 +2376,12 @@ export class Realm { let invalidations: Set = new Set(); let indexGeneration: number | undefined; let clientRequestId: string | null = options?.clientRequestId ?? null; + let initiatingUser: string | null = options?.initiatingUser ?? null; let performIndex = async () => { let { invalidations: workingInvalidations, generation } = await this.updateIndexAndCollectInvalidations(urls, { clientRequestId, + initiatedBy: initiatingUser, }); invalidations = new Set([...invalidations, ...workingInvalidations]); indexGeneration = generation ?? indexGeneration; @@ -2573,6 +2615,7 @@ export class Realm { urls, { clientRequestId, + initiatedBy: initiatingUser, // Route the post-worker broadcast through onSettled so it runs // INSIDE the indexing deferred lifecycle. Without this, the // broadcast would fire from an outer .then() after the deferred @@ -2932,6 +2975,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: true, waitForIndex, + initiatingUser: requestContext.authenticatedUser ?? null, }); } catch (e: any) { if (e instanceof CardError) { @@ -3048,7 +3092,7 @@ export class Realm { async delete( path: LocalPath, - options?: { waitForIndex?: boolean }, + options?: { waitForIndex?: boolean; initiatingUser?: string | null }, ): Promise { await this.#dbAdapter.withWriteLock(this.url, () => this._deleteUnlocked(path, options), @@ -3057,7 +3101,7 @@ export class Realm { private async _deleteUnlocked( path: LocalPath, - options?: { waitForIndex?: boolean }, + options?: { waitForIndex?: boolean; initiatingUser?: string | null }, ): Promise { let url = this.paths.fileURL(path); this.sendIndexInitiationEvent(url.href); @@ -3077,6 +3121,7 @@ export class Realm { let { invalidations, generation } = await this.updateIndexAndCollectInvalidations([url], { delete: true, + initiatedBy: options?.initiatingUser ?? null, }); this.broadcastIncrementalInvalidationEvent(invalidations, { generation }); } else { @@ -3090,6 +3135,7 @@ export class Realm { [url], { delete: true, + initiatedBy: options?.initiatingUser ?? null, onSettled: (deferredInvalidations, meta) => { this.broadcastIncrementalInvalidationEvent(deferredInvalidations, { generation: meta.generation, @@ -4969,6 +5015,26 @@ export class Realm { (requiredPermission === 'write' && realmPermissions['*']?.includes('write'))) ) { + // Authorized without needing a token — but when the caller sent one + // anyway, capture who they are for `requestContext.authenticatedUser`. + // Identity only (see the RequestContext field): verification failures + // here must not fail a request that public permissions already + // authorized, so they simply leave the identity unset. The + // `X-Boxel-Assume-User` indirection is not honored on this path — + // honoring it requires the assume-user permission check the main path + // runs, and identity capture must stay free of matrix round-trips. + let publicAuthHeader = request.headers.get('Authorization'); + if (publicAuthHeader) { + try { + let publicToken = this.#adapter.verifyJWT( + publicAuthHeader.replace('Bearer ', ''), + this.#realmSecretSeed, + ); + requestContext.authenticatedUser = publicToken.user; + } catch (e) { + // fall through with no identity + } + } return; } @@ -5045,6 +5111,7 @@ export class Realm { AuthenticationErrorMessages.PermissionMismatch, ); } + requestContext.authenticatedUser = user; return; } @@ -5060,6 +5127,7 @@ export class Realm { // if the client is the realm matrix user then we permit all actions if (user === this.#matrixClientUserId) { + requestContext.authenticatedUser = user; return; } @@ -5085,6 +5153,7 @@ export class Realm { 'Insufficient permissions to perform this action', ); } + requestContext.authenticatedUser = user; } catch (e: any) { if (e?.constructor?.name === 'TokenExpiredError') { this.#log.warn( @@ -5118,6 +5187,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: false, waitForIndex: false, + initiatingUser: requestContext.authenticatedUser ?? null, }, ); return createResponse({ @@ -5148,6 +5218,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: false, waitForIndex: false, + initiatingUser: requestContext.authenticatedUser ?? null, }, ); return createResponse({ @@ -5402,7 +5473,10 @@ export class Realm { if (!handle) { return notFound(request, requestContext, `${localName} not found`); } - await this.delete(handle.path, { waitForIndex: false }); + await this.delete(handle.path, { + waitForIndex: false, + initiatingUser: requestContext.authenticatedUser ?? null, + }); return createResponse({ body: null, init: { status: 204 }, @@ -5945,6 +6019,7 @@ export class Realm { } let [{ lastModified, created }] = await this.writeMany(files, { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), + initiatingUser: requestContext.authenticatedUser ?? null, ...(duringPrerender ? { waitForIndex: false } : {}), }); @@ -6276,6 +6351,7 @@ export class Realm { // connection). let [{ lastModified, created }] = await this._batchWriteUnlocked(files, { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), + initiatingUser: requestContext.authenticatedUser ?? null, ...(duringPrerender ? { waitForIndex: false } : {}), }); let doc: SingleCardDocument; @@ -6478,21 +6554,69 @@ export class Realm { } } + // Read-your-writes gate for the card read endpoints (card+json / + // card+html GET). The +source POST indexes deferred (it returns once the + // bytes are durable), so a GET that immediately follows the same client's + // definition rewrite would otherwise read a stale snapshot — e.g. a + // post-rename instance still serialized under the old schema. Waiting is a freshness courtesy, not a correctness + // requirement: incremental jobs write into the working table and the + // production rows stay live (and mutually consistent) until the completed + // batch swaps in, so a read during indexing serves the previous + // generation, never a torn one. That shapes both bounds here: + // + // - Scoped to the requester. Only the user whose own write is in flight + // has a read-your-writes expectation; every other reader takes the + // current generation immediately. A write-heavy user (or a module edit + // whose invalidation set spans the realm's module graph) must not park + // every reader of the realm behind their job. When the requester's + // identity is unknown (no verifiable token on a public realm, or a + // realm-internal dispatch), the drain conservatively covers all + // pending incremental jobs — the writer might be behind any of them. + // - Bounded. The job being awaited can also sit queued behind other + // realms' work in a saturated worker pool; past the budget the read + // proceeds on the current generation and the index event that follows + // the swap refreshes the client. + // + // A prerender-originated request skips the gate entirely: its tab holds a + // render slot that the pending job may itself be waiting on, so any wait + // here is at best dead time and at worst a deadlock held for the budget. + private async drainRequestersOwnIndexing( + request: Request, + requestContext: RequestContext, + ): Promise { + if (isDuringPrerenderRequest(request)) { + return; + } + let user = requestContext.authenticatedUser; + let pending = + user !== undefined + ? this.#realmIndexUpdater.incrementalIndexingInitiatedBy(user) + : this.incrementalIndexing(); + if (!pending) { + return; + } + let waitStartedAt = Date.now(); + if ( + !(await settledBy(pending, waitStartedAt + this.#readIndexDrainBudgetMs)) + ) { + this.#log.warn( + `card read for ${request.url} proceeding after waiting ${ + Date.now() - waitStartedAt + }ms on in-flight incremental indexing${ + user !== undefined ? ` initiated by ${user}` : '' + }; serving the current index generation`, + ); + } + } + private async getCard( request: Request, requestContext: RequestContext, ): Promise { - // Drain any in-flight incremental indexing before reading the card from - // the index. With CS-11003's deferred +source POST, an immediately- - // following GET +json after a definition rewrite would otherwise read - // a stale snapshot — e.g. a post-rename instance still serialized - // under the old schema. Read endpoints serve the realm's canonical - // indexed view; the small wait when indexing is genuinely pending is - // the right tradeoff vs returning stale state. - let pending = this.incrementalIndexing(); - if (pending) { - await pending; - } + // Read-your-writes: wait (scoped, bounded) on the requester's own + // in-flight incremental indexing before reading the card from the + // index. See drainRequestersOwnIndexing. + await this.drainRequestersOwnIndexing(request, requestContext); let requestedLocalPath = this.paths.local(new URL(request.url)); let requestedHadJsonExtension = requestedLocalPath.endsWith('.json'); // `.json` requests always 302 to the canonical no-extension URL, @@ -6771,12 +6895,10 @@ export class Realm { ? SupportedMimeType.FileMetaHtml : SupportedMimeType.CardHtml; - // Read endpoints serve the realm's canonical indexed view — drain any - // in-flight incremental indexing first, exactly as `getCard` does. - let pending = this.incrementalIndexing(); - if (pending) { - await pending; - } + // Read-your-writes: wait (scoped, bounded) on the requester's own + // in-flight incremental indexing first, exactly as `getCard` does. See + // drainRequestersOwnIndexing. + await this.drainRequestersOwnIndexing(request, requestContext); let htmlQuery: HtmlQuery; let fieldset: SearchEntryFieldset; @@ -6944,7 +7066,9 @@ export class Realm { return notFound(request, requestContext); } let path = this.paths.local(url) + '.json'; - await this.delete(path); + await this.delete(path, { + initiatingUser: requestContext.authenticatedUser ?? null, + }); return createResponse({ body: null, init: { status: 204 }, From 92924afa398e9e58eb6910c4ea20276cb38297e5 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 10 Sep 2026 17:20:42 -0400 Subject: [PATCH 2/5] Address review: anonymous readers skip the gate; identity stays unset under assume-user; gate telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on the draft surfaced three identity-edge decisions and a coverage gap, all adopted: - A caller with no Authorization header provably isn't the writer behind any tagged job (every tagged job comes from an authenticated write), so anonymous reads now skip the gate instead of taking the conservative full-budget hold — that hold was the pathology this change removes, landing on published realms, og:image fetches, and crawlers. - On the public-permission path, a token accompanied by X-Boxel-Assume-User no longer records the bearer as the identity: the write it pairs with is tagged with the assumed user (writes always take the main path), so recording the bearer would silently skip read-your-writes. Identity stays unset and the conservative gate covers the ambiguity. - The write-half of the feature (handlers tagging jobs with requestContext.authenticatedUser) now has an authenticated end-to-end test; the public-writable rename flow can't cover it since its job is untagged and its reader unidentified. - The card+html leg of the no-wait test gets its own warm-up so it isn't timed cold. Also adds impact telemetry Luke asked for: one realm:read-index-gate key=value line per card read that arrives while incremental indexing is pending (outcome= skipped-prerender/skipped-not-writer/skipped-anonymous/ settled/budget-expired, waitMs on waits; budget-expired at warn). Skipped outcomes count reads the unscoped gate would have parked; waited ones price the writer-scoped hold. Steady-state reads emit nothing. Co-Authored-By: Claude Fable 5 --- .../tests/read-index-drain-test.ts | 184 +++++++++++++++++- packages/runtime-common/realm.ts | 114 ++++++++--- 2 files changed, 261 insertions(+), 37 deletions(-) diff --git a/packages/realm-server/tests/read-index-drain-test.ts b/packages/realm-server/tests/read-index-drain-test.ts index 66d5d56f003..064b1407775 100644 --- a/packages/realm-server/tests/read-index-drain-test.ts +++ b/packages/realm-server/tests/read-index-drain-test.ts @@ -109,10 +109,20 @@ module(basename(import.meta.filename), function () { } test("a reader is not held by another user's in-flight indexing", async function (assert) { - // Warm the endpoint so the timed request below measures the drain, not - // cold module/doc assembly. + // Warm both endpoints so the timed requests below measure the drain, + // not cold module/doc assembly. let warm = await getPersonAs('john', ['read']); assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + let warmHtml = await getPersonAs( + 'john', + ['read'], + 'application/vnd.card+html', + ); + assert.strictEqual( + warmHtml.status, + 200, + `card+html warm-up GET: ${warmHtml.text}`, + ); // hassan has a (never-settling) job in flight; the unscoped gate is // also held open, so a read that (wrongly) consulted it would hold for @@ -160,8 +170,12 @@ module(basename(import.meta.filename), function () { let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { gateResolved = true; }); + // The unscoped gate must report the job as pending too: a real tagged + // job always appears in both gates, and the drain's fast-path probe + // reads the unscoped one. let restore = stubUpdaterGates(testRealm, { initiatedBy: (user) => (user === 'hassan' ? gate : undefined), + all: () => gate, }); try { let startedAt = Date.now(); @@ -187,6 +201,7 @@ module(basename(import.meta.filename), function () { let restore = stubUpdaterGates(testRealm, { initiatedBy: (user) => (user === 'hassan' ? NEVER : undefined), + all: () => NEVER, }); try { let startedAt = Date.now(); @@ -205,6 +220,84 @@ module(basename(import.meta.filename), function () { restore(); } }); + + // End-to-end (no stubs): the authenticated write handlers must tag their + // deferred indexing jobs with the writer, or this same-user + // write-then-read sees the pre-write schema. The public-writable variant + // of this flow cannot cover the tagging — an unauthenticated write + // produces an untagged job and an unidentified read takes the + // conservative all-jobs hold, so it passes with the tags removed. + test("an authenticated writer's follow-up read sees their own deferred write indexed", async function (assert) { + let auth = () => + `Bearer ${createJWT(testRealm, 'hassan', ['read', 'write'])}`; + let cardSource = (secondField: string) => ` + import { contains, field, CardDef } from '@cardstack/base/card-api'; + import StringField from '@cardstack/base/string'; + + export class DrainTestCard extends CardDef { + @field field1 = contains(StringField); + @field ${secondField} = contains(StringField); + } + `; + + let createDef = await request + .post('/drain-test-card.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', auth()) + .send(cardSource('field2')); + assert.strictEqual(createDef.status, 204, `HTTP 204: ${createDef.text}`); + + let createInstance = await request + .post('/') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', auth()) + .send({ + data: { + type: 'card', + attributes: { field1: 'a', field2: 'b' }, + meta: { + adoptsFrom: { + module: `${realmURL.href}drain-test-card`, + name: 'DrainTestCard', + }, + }, + }, + }); + assert.strictEqual( + createInstance.status, + 201, + `HTTP 201: ${createInstance.text}`, + ); + let id = createInstance.body.data.id as string; + + // The +source POST answers once the bytes are durable; its indexing is + // the deferred job the follow-up GET must wait out. + let renameField = await request + .post('/drain-test-card.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', auth()) + .send(cardSource('field2a')); + assert.strictEqual( + renameField.status, + 204, + `HTTP 204: ${renameField.text}`, + ); + + let readBack = await request + .get(new URL(id).pathname) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', auth()); + assert.strictEqual(readBack.status, 200, `HTTP 200: ${readBack.text}`); + let attributes = readBack.body.data.attributes; + assert.true( + 'field2a' in attributes, + `post-rename schema is served: ${JSON.stringify(attributes)}`, + ); + assert.false( + 'field2' in attributes, + `pre-rename field is gone: ${JSON.stringify(attributes)}`, + ); + }); }); module('card read drain | public readable realm', function (hooks) { @@ -238,14 +331,44 @@ module(basename(import.meta.filename), function () { }, }); - test('an anonymous reader conservatively waits, bounded, on any pending indexing', async function (assert) { + test('an anonymous reader skips the pending-indexing hold', async function (assert) { + let warm = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + // Every tagged job comes from an authenticated write, so a caller with + // no Authorization header at all provably isn't the writer behind any + // of them — the gate has nothing to hold the read for. + let restore = stubUpdaterGates(testRealm, { + initiatedBy: () => NEVER, + all: () => NEVER, + }); + try { + let startedAt = Date.now(); + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + elapsed < NO_WAIT_CEILING_MS, + `anonymous read skipped the pending-indexing hold (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + + test('a reader with an unverifiable token conservatively waits, bounded, on any pending indexing', async function (assert) { let warm = await request .get('/person-1') .set('Accept', 'application/vnd.card+json'); assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); - // With no verifiable identity the drain cannot rule the requester out - // as the writer, so it covers every pending incremental job. + // A token that fails verification leaves the requester unknown — they + // presented credentials, so they might be the writer — and the drain + // covers every pending incremental job. let gateResolved = false; let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { gateResolved = true; @@ -257,16 +380,61 @@ module(basename(import.meta.filename), function () { let startedAt = Date.now(); let response = await request .get('/person-1') - .set('Accept', 'application/vnd.card+json'); + .set('Accept', 'application/vnd.card+json') + .set('Authorization', 'Bearer not-a-real-token'); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + gateResolved, + 'the unidentified read did not return before the gate settled', + ); + assert.true( + elapsed >= GATE_RESOLVE_MS - 20, + `unidentified read held for pending indexing (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + + test('a token accompanied by X-Boxel-Assume-User is not trusted for identity on the public path', async function (assert) { + let warm = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + // Public read authorizes without the assume-user permission check, so + // the indirection can't be honored — and recording the bearer instead + // would desynchronize the read identity from the assumed-user tag the + // same client's writes carry. The requester stays unknown and takes + // the conservative bounded hold. + let gateResolved = false; + let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { + gateResolved = true; + }); + let restore = stubUpdaterGates(testRealm, { + initiatedBy: () => undefined, + all: () => gate, + }); + try { + let startedAt = Date.now(); + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('X-Boxel-Assume-User', 'someone-else') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'john', ['read'])}`, + ); let elapsed = Date.now() - startedAt; assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); assert.true( gateResolved, - 'the anonymous read did not return before the gate settled', + 'the assume-user read did not return before the gate settled', ); assert.true( elapsed >= GATE_RESOLVE_MS - 20, - `anonymous read held for pending indexing (took ${elapsed}ms)`, + `assume-user read held for pending indexing (took ${elapsed}ms)`, ); } finally { restore(); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 492b7814cf6..fead9f7ae69 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -983,6 +983,14 @@ export type RequestContext = { // writes; it is identity, not authority — authorization decisions never // read it. authenticatedUser?: string; + // Set by `checkPermission` when the request presented no Authorization + // header at all. Distinguishes a provably credential-less caller (who + // cannot be the writer behind any user-tagged indexing job) from a caller + // whose identity is merely unknown — a token that failed verification, an + // assume-user indirection the public path cannot validate, or a + // realm-internal dispatch that never ran `checkPermission`. Identity, not + // authority, like `authenticatedUser`. + anonymous?: true; }; export class Realm { @@ -996,6 +1004,9 @@ export class Realm { #adapter: RealmAdapter; #router: Router; #log = logger('realm'); + // One line per card read that arrives while incremental indexing is + // pending — see drainRequestersOwnIndexing for the outcome grammar. + #readGateLog = logger('realm:read-index-gate'); #perfLog = logger('perf'); #updateItems: UpdateItem[] = []; #flushUpdateEvents: Promise | undefined; @@ -5015,16 +5026,23 @@ export class Realm { (requiredPermission === 'write' && realmPermissions['*']?.includes('write'))) ) { - // Authorized without needing a token — but when the caller sent one - // anyway, capture who they are for `requestContext.authenticatedUser`. - // Identity only (see the RequestContext field): verification failures - // here must not fail a request that public permissions already - // authorized, so they simply leave the identity unset. The - // `X-Boxel-Assume-User` indirection is not honored on this path — - // honoring it requires the assume-user permission check the main path - // runs, and identity capture must stay free of matrix round-trips. + // Authorized without needing a token. Record what we can about who the + // caller is for the read endpoints' indexing gate (identity, not + // authority — see RequestContext): a verifiable token identifies them, + // and no Authorization header at all marks them anonymous. Two cases + // deliberately leave both fields unset, landing the caller in the + // gate's conservative bucket: a token that fails verification (must + // not fail a request that public permissions already authorized), and + // a request carrying `X-Boxel-Assume-User` — honoring the indirection + // requires the assume-user permission check the main path runs (and + // identity capture must stay free of matrix round-trips), while + // recording the bearer instead would desynchronize this identity from + // the assumed-user tag the same client's writes carry, since writes + // always take the main path. let publicAuthHeader = request.headers.get('Authorization'); - if (publicAuthHeader) { + if (!publicAuthHeader) { + requestContext.anonymous = true; + } else if (!request.headers.get('X-Boxel-Assume-User')) { try { let publicToken = this.#adapter.verifyJWT( publicAuthHeader.replace('Bearer ', ''), @@ -6568,10 +6586,16 @@ export class Realm { // has a read-your-writes expectation; every other reader takes the // current generation immediately. A write-heavy user (or a module edit // whose invalidation set spans the realm's module graph) must not park - // every reader of the realm behind their job. When the requester's - // identity is unknown (no verifiable token on a public realm, or a - // realm-internal dispatch), the drain conservatively covers all - // pending incremental jobs — the writer might be behind any of them. + // every reader of the realm behind their job. A provably anonymous + // caller (no Authorization header) skips the gate too: every tagged + // job comes from an authenticated write, so a credential-less caller + // cannot be its writer, and the untagged jobs (file watcher, realm + // copy) have no reader with a read-your-writes claim on them. Only + // when the requester is genuinely unknown — a token that failed + // verification, an assume-user indirection the public path cannot + // validate, or a realm-internal dispatch — does the drain + // conservatively cover all pending incremental jobs, since the writer + // might be behind any of them. // - Bounded. The job being awaited can also sit queued behind other // realms' work in a saturated worker pool; past the budget the read // proceeds on the current generation and the index event that follows @@ -6580,32 +6604,64 @@ export class Realm { // A prerender-originated request skips the gate entirely: its tab holds a // render slot that the pending job may itself be waiting on, so any wait // here is at best dead time and at worst a deadlock held for the budget. + // + // Every read that arrives while incremental indexing is pending emits one + // `realm:read-index-gate` key=value line (`outcome=` skipped-prerender / + // skipped-not-writer / skipped-anonymous / settled / budget-expired, with + // `waitMs=` on the waited outcomes) — the skipped outcomes count reads an + // unscoped gate would have parked, the waited ones measure what the + // writer-scoped hold actually costs. Steady-state reads (nothing pending) + // emit nothing. budget-expired logs at warn; the rest at info. private async drainRequestersOwnIndexing( request: Request, requestContext: RequestContext, ): Promise { + let anyPending = this.incrementalIndexing(); + if (!anyPending) { + return; + } + let emit = ( + level: 'info' | 'warn', + outcome: string, + fragment: string = '', + ) => + this.#readGateLog[level]( + `outcome=${outcome}${fragment} url=${request.url}`, + ); if (isDuringPrerenderRequest(request)) { + emit('info', 'skipped-prerender'); return; } let user = requestContext.authenticatedUser; - let pending = - user !== undefined - ? this.#realmIndexUpdater.incrementalIndexingInitiatedBy(user) - : this.incrementalIndexing(); - if (!pending) { + let pending: Promise | undefined; + let scope: 'own' | 'all'; + if (user !== undefined) { + pending = this.#realmIndexUpdater.incrementalIndexingInitiatedBy(user); + if (!pending) { + emit('info', 'skipped-not-writer'); + return; + } + scope = 'own'; + } else if (requestContext.anonymous) { + emit('info', 'skipped-anonymous'); return; + } else { + pending = anyPending; + scope = 'all'; } let waitStartedAt = Date.now(); - if ( - !(await settledBy(pending, waitStartedAt + this.#readIndexDrainBudgetMs)) - ) { - this.#log.warn( - `card read for ${request.url} proceeding after waiting ${ - Date.now() - waitStartedAt - }ms on in-flight incremental indexing${ - user !== undefined ? ` initiated by ${user}` : '' - }; serving the current index generation`, - ); + let settled = await settledBy( + pending, + waitStartedAt + this.#readIndexDrainBudgetMs, + ); + let fragment = + ` scope=${scope} waitMs=${Date.now() - waitStartedAt}` + + (user !== undefined ? ` user=${user}` : ''); + if (settled) { + emit('info', 'settled', fragment); + } else { + // Past the budget the read proceeds on the current index generation. + emit('warn', 'budget-expired', fragment); } } @@ -7887,7 +7943,7 @@ export class Realm { // pushes a fix and immediately polls this endpoint could otherwise // see a stale snapshot — either still reporting an error the just- // pushed fix cleared, or missing a fresh failure from the same write. - // Same hazard publishability() guards against (see realm.ts:5629). + // Same hazard publishability() guards against. let pending = this.incrementalIndexing(); if (pending) { await pending; From 7e76658d3e155e030de1af79ba79cea734de5b1f Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 10 Sep 2026 18:05:10 -0400 Subject: [PATCH 3/5] Tag anonymous writes with a shared principal instead of skipping anonymous reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on the previous revision surfaced that skipping the read gate for credential-less callers broke anonymous read-your-writes (a +source POST then GET on a public-writable realm could serve the pre-write schema), while the conservative fallback it replaced held every anonymous reader for the full budget during an identified user's reindex — the pathology this branch removes. The sentinel design serves both: a write authorized without credentials tags its indexing job with ANONYMOUS_REQUESTER, and a credential-less read waits (bounded) only on jobs so tagged. Untagged system jobs (file watcher, realm copy) hold no scoped reader. The requesterPrincipal helper derives the principal identically on the write and read sides. Also tags the _invalidate handler's job, adds a wiring spy test covering every HTTP write route's job tag, warms the card+html leg before timing it, and holds the unscoped gate open in the writer-wait tests so the gate's fast-path pending probe sees the stubbed job. Co-Authored-By: Claude Fable 5 --- .../tests/read-index-drain-test.ts | 224 +++++++++++++++++- packages/runtime-common/realm.ts | 119 ++++++---- 2 files changed, 291 insertions(+), 52 deletions(-) diff --git a/packages/realm-server/tests/read-index-drain-test.ts b/packages/realm-server/tests/read-index-drain-test.ts index 064b1407775..99781d01542 100644 --- a/packages/realm-server/tests/read-index-drain-test.ts +++ b/packages/realm-server/tests/read-index-drain-test.ts @@ -2,7 +2,7 @@ import QUnit from 'qunit'; const { module, test } = QUnit; import { basename } from 'path'; import type { RealmHttpServer as Server } from '../server.ts'; -import type { Realm } from '@cardstack/runtime-common'; +import { ANONYMOUS_REQUESTER, type Realm } from '@cardstack/runtime-common'; import { setupPermissionedRealmCached, closeServer, @@ -298,6 +298,178 @@ module(basename(import.meta.filename), function () { `pre-rename field is gone: ${JSON.stringify(attributes)}`, ); }); + + // Pins the wiring between requestContext.authenticatedUser and the job + // tag for every HTTP write route: a handler that drops its + // `initiatingUser` argument silently disables read-your-writes for that + // route, which no black-box read test can catch deterministically. The + // spy shadows `enqueueUpdate` (the synchronous `update` path calls + // through it, so one spy covers both), delegates to the prototype, and + // captures each job's `initiatedBy`. + test('every HTTP write route tags its indexing job with the writer', async function (assert) { + let auth = () => + `Bearer ${createJWT(testRealm, 'hassan', ['read', 'write'])}`; + let updater = testRealm.realmIndexUpdater as any; + let proto = Object.getPrototypeOf(updater); + let tags = new Map(); + let currentOp = 'none'; + updater.enqueueUpdate = function (urls: URL[], opts?: any) { + tags.set(currentOp, opts?.initiatedBy); + return proto.enqueueUpdate.call(this, urls, opts); + }; + let drainJobs = async () => { + let pending = testRealm.incrementalIndexing(); + if (pending) { + await pending; + } + }; + try { + currentOp = 'source POST'; + let sourcePost = await request + .post('/tag-probe.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', auth()).send(` + import { contains, field, CardDef } from '@cardstack/base/card-api'; + import StringField from '@cardstack/base/string'; + + export class TagProbe extends CardDef { + @field name = contains(StringField); + } + `); + assert.strictEqual( + sourcePost.status, + 204, + `source POST: ${sourcePost.text}`, + ); + await drainJobs(); + + currentOp = 'card POST'; + let cardPost = await request + .post('/') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', auth()) + .send({ + data: { + type: 'card', + attributes: { name: 'probe' }, + meta: { + adoptsFrom: { + module: `${realmURL.href}tag-probe`, + name: 'TagProbe', + }, + }, + }, + }); + assert.strictEqual(cardPost.status, 201, `card POST: ${cardPost.text}`); + let cardPath = new URL(cardPost.body.data.id as string).pathname; + + currentOp = 'card PATCH'; + let cardPatch = await request + .patch(cardPath) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', auth()) + .send({ + data: { + type: 'card', + attributes: { name: 'probe2' }, + meta: { + adoptsFrom: { + module: `${realmURL.href}tag-probe`, + name: 'TagProbe', + }, + }, + }, + }); + assert.strictEqual( + cardPatch.status, + 200, + `card PATCH: ${cardPatch.text}`, + ); + + currentOp = '_invalidate POST'; + let invalidatePost = await request + .post('/_invalidate') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', auth()) + .send({ + data: { attributes: { urls: [`${realmURL.href}tag-probe.gts`] } }, + }); + assert.strictEqual( + invalidatePost.status, + 204, + `_invalidate POST: ${invalidatePost.text}`, + ); + + currentOp = '_atomic POST'; + let atomicPost = await request + .post('/_atomic') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', auth()) + .send({ + 'atomic:operations': [ + { + op: 'add', + href: 'atomic-tag-probe.txt', + data: { + type: 'source', + attributes: { content: 'tag probe payload' }, + meta: {}, + }, + }, + ], + }); + assert.strictEqual( + atomicPost.status, + 201, + `_atomic POST: ${atomicPost.text}`, + ); + await drainJobs(); + + currentOp = 'card DELETE'; + let cardDelete = await request + .delete(cardPath) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', auth()); + assert.strictEqual( + cardDelete.status, + 204, + `card DELETE: ${cardDelete.text}`, + ); + + currentOp = 'source DELETE'; + let sourceDelete = await request + .delete('/tag-probe.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', auth()); + assert.strictEqual( + sourceDelete.status, + 204, + `source DELETE: ${sourceDelete.text}`, + ); + await drainJobs(); + + for (let op of [ + 'source POST', + 'card POST', + 'card PATCH', + '_invalidate POST', + '_atomic POST', + 'card DELETE', + 'source DELETE', + ]) { + assert.strictEqual( + tags.get(op), + 'hassan', + `${op} tagged its indexing job with the writer (got ${JSON.stringify( + tags.get(op), + )})`, + ); + } + } finally { + await drainJobs(); + delete updater.enqueueUpdate; + } + }); }); module('card read drain | public readable realm', function (hooks) { @@ -331,17 +503,17 @@ module(basename(import.meta.filename), function () { }, }); - test('an anonymous reader skips the pending-indexing hold', async function (assert) { + test("an anonymous reader skips identified users' pending indexing", async function (assert) { let warm = await request .get('/person-1') .set('Accept', 'application/vnd.card+json'); assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); - // Every tagged job comes from an authenticated write, so a caller with - // no Authorization header at all provably isn't the writer behind any - // of them — the gate has nothing to hold the read for. + // A credential-less caller acts as the shared anonymous principal, so + // an identified user's in-flight job holds nothing for them. let restore = stubUpdaterGates(testRealm, { - initiatedBy: () => NEVER, + initiatedBy: (user) => + user === ANONYMOUS_REQUESTER ? undefined : NEVER, all: () => NEVER, }); try { @@ -353,7 +525,45 @@ module(basename(import.meta.filename), function () { assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); assert.true( elapsed < NO_WAIT_CEILING_MS, - `anonymous read skipped the pending-indexing hold (took ${elapsed}ms)`, + `anonymous read skipped the identified user's hold (took ${elapsed}ms)`, + ); + } finally { + restore(); + } + }); + + test('an anonymous reader waits on anonymous-initiated indexing', async function (assert) { + let warm = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); + + // Anonymous write-then-read stays consistent: a credential-less write + // tags its job with the anonymous principal, and a credential-less + // read waits on jobs so tagged. + let gateResolved = false; + let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { + gateResolved = true; + }); + let restore = stubUpdaterGates(testRealm, { + initiatedBy: (user) => + user === ANONYMOUS_REQUESTER ? gate : undefined, + all: () => gate, + }); + try { + let startedAt = Date.now(); + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + let elapsed = Date.now() - startedAt; + assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); + assert.true( + gateResolved, + 'the anonymous read did not return before the gate settled', + ); + assert.true( + elapsed >= GATE_RESOLVE_MS - 20, + `anonymous read held for anonymous-initiated indexing (took ${elapsed}ms)`, ); } finally { restore(); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index fead9f7ae69..a05b4143956 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -859,11 +859,13 @@ export interface WriteOptions { // the JSON-API card handlers do not, writing a single file and instances // respectively. waitForIndex?: boolean | null; - // The matrix user (post assume-user) whose request produced this write, - // when known — `requestContext.authenticatedUser` at the HTTP handlers. - // Tags the resulting incremental index job so read endpoints can scope - // their read-your-writes drain to this user's own reads. Absent for - // system-originated writes. + // The read-your-writes principal whose request produced this write — the + // effective matrix user (post assume-user), or ANONYMOUS_REQUESTER for a + // credential-less write on a public-writable realm; the HTTP handlers + // derive it via `requesterPrincipal(requestContext)`. Tags the resulting + // incremental index job so read endpoints can scope their read-your-writes + // drain to this principal's own reads. Absent for system-originated + // writes. initiatingUser?: string | null; } @@ -984,15 +986,24 @@ export type RequestContext = { // read it. authenticatedUser?: string; // Set by `checkPermission` when the request presented no Authorization - // header at all. Distinguishes a provably credential-less caller (who - // cannot be the writer behind any user-tagged indexing job) from a caller - // whose identity is merely unknown — a token that failed verification, an - // assume-user indirection the public path cannot validate, or a - // realm-internal dispatch that never ran `checkPermission`. Identity, not - // authority, like `authenticatedUser`. + // header at all. Distinguishes a provably credential-less caller — who + // acts as the shared ANONYMOUS_REQUESTER principal for read-your-writes + // purposes — from a caller whose identity is merely unknown: a token that + // failed verification, an assume-user indirection the public path cannot + // validate, or a realm-internal dispatch that never ran `checkPermission`. + // Identity, not authority, like `authenticatedUser`. anonymous?: true; }; +// The read-your-writes principal shared by every credential-less caller. +// A write authorized without credentials (a public-writable realm) tags its +// indexing job with this value, and a credential-less read waits only on +// jobs so tagged — anonymous write-then-read stays consistent, while +// anonymous reads never park behind an identified user's reindex. Angle +// brackets keep it outside the space of real user identifiers (a matrix id +// cannot contain them), so no token-bearing user can collide with it. +export const ANONYMOUS_REQUESTER = ''; + export class Realm { #startedUp = new Deferred(); #matrixClient: MatrixClient; @@ -1933,7 +1944,9 @@ export class Realm { } let { invalidations, generation } = - await this.updateIndexAndCollectInvalidations(urls); + await this.updateIndexAndCollectInvalidations(urls, { + initiatedBy: this.requesterPrincipal(requestContext) ?? null, + }); this.broadcastIncrementalInvalidationEvent(invalidations, { generation }); return createResponse({ @@ -2986,7 +2999,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: true, waitForIndex, - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, }); } catch (e: any) { if (e instanceof CardError) { @@ -5205,7 +5218,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: false, waitForIndex: false, - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, }, ); return createResponse({ @@ -5236,7 +5249,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: false, waitForIndex: false, - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, }, ); return createResponse({ @@ -5493,7 +5506,7 @@ export class Realm { } await this.delete(handle.path, { waitForIndex: false, - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, }); return createResponse({ body: null, @@ -6037,7 +6050,7 @@ export class Realm { } let [{ lastModified, created }] = await this.writeMany(files, { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, ...(duringPrerender ? { waitForIndex: false } : {}), }); @@ -6369,7 +6382,7 @@ export class Realm { // connection). let [{ lastModified, created }] = await this._batchWriteUnlocked(files, { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, ...(duringPrerender ? { waitForIndex: false } : {}), }); let doc: SingleCardDocument; @@ -6582,20 +6595,21 @@ export class Realm { // batch swaps in, so a read during indexing serves the previous // generation, never a torn one. That shapes both bounds here: // - // - Scoped to the requester. Only the user whose own write is in flight - // has a read-your-writes expectation; every other reader takes the - // current generation immediately. A write-heavy user (or a module edit - // whose invalidation set spans the realm's module graph) must not park - // every reader of the realm behind their job. A provably anonymous - // caller (no Authorization header) skips the gate too: every tagged - // job comes from an authenticated write, so a credential-less caller - // cannot be its writer, and the untagged jobs (file watcher, realm - // copy) have no reader with a read-your-writes claim on them. Only - // when the requester is genuinely unknown — a token that failed - // verification, an assume-user indirection the public path cannot - // validate, or a realm-internal dispatch — does the drain - // conservatively cover all pending incremental jobs, since the writer - // might be behind any of them. + // - Scoped to the requester. Only the principal whose own write is in + // flight has a read-your-writes expectation; every other reader takes + // the current generation immediately. A write-heavy user (or a module + // edit whose invalidation set spans the realm's module graph) must not + // park every reader of the realm behind their job. Credential-less + // callers share the ANONYMOUS_REQUESTER principal: an anonymous read + // waits only on anonymous writes (so anonymous write-then-read on a + // public-writable realm stays consistent) and never on an identified + // user's reindex. System-originated jobs (file watcher, realm copy) + // are untagged and hold no scoped reader — no one has a + // read-your-writes claim on them. Only when the requester is genuinely + // unknown — a token that failed verification, an assume-user + // indirection the public path cannot validate, or a realm-internal + // dispatch — does the drain conservatively cover all pending + // incremental jobs, since the writer might be behind any of them. // - Bounded. The job being awaited can also sit queued behind other // realms' work in a saturated worker pool; past the budget the read // proceeds on the current generation and the index event that follows @@ -6607,11 +6621,28 @@ export class Realm { // // Every read that arrives while incremental indexing is pending emits one // `realm:read-index-gate` key=value line (`outcome=` skipped-prerender / - // skipped-not-writer / skipped-anonymous / settled / budget-expired, with - // `waitMs=` on the waited outcomes) — the skipped outcomes count reads an - // unscoped gate would have parked, the waited ones measure what the - // writer-scoped hold actually costs. Steady-state reads (nothing pending) - // emit nothing. budget-expired logs at warn; the rest at info. + // skipped-not-writer / settled / budget-expired, with `waitMs=` and the + // requester principal on the waited outcomes) — the skipped outcomes count + // reads an unscoped gate would have parked, the waited ones measure what + // the requester-scoped hold actually costs. Steady-state reads (nothing + // pending) emit nothing. budget-expired logs at warn; the rest at info. + // The read-your-writes principal this request acts as: the effective user, + // or the shared anonymous principal for a credential-less request on a + // public-permission realm. Undefined when the requester is genuinely + // unknown — a token that failed verification, an assume-user indirection + // the public path cannot validate, or a realm-internal dispatch that never + // ran checkPermission. Write handlers tag their indexing jobs with this + // (see WriteOptions.initiatingUser) and the read gate waits on jobs so + // tagged, so the two sides must derive it identically. + private requesterPrincipal( + requestContext: RequestContext, + ): string | undefined { + return ( + requestContext.authenticatedUser ?? + (requestContext.anonymous ? ANONYMOUS_REQUESTER : undefined) + ); + } + private async drainRequestersOwnIndexing( request: Request, requestContext: RequestContext, @@ -6632,19 +6663,17 @@ export class Realm { emit('info', 'skipped-prerender'); return; } - let user = requestContext.authenticatedUser; + let requester = this.requesterPrincipal(requestContext); let pending: Promise | undefined; let scope: 'own' | 'all'; - if (user !== undefined) { - pending = this.#realmIndexUpdater.incrementalIndexingInitiatedBy(user); + if (requester !== undefined) { + pending = + this.#realmIndexUpdater.incrementalIndexingInitiatedBy(requester); if (!pending) { emit('info', 'skipped-not-writer'); return; } scope = 'own'; - } else if (requestContext.anonymous) { - emit('info', 'skipped-anonymous'); - return; } else { pending = anyPending; scope = 'all'; @@ -6656,7 +6685,7 @@ export class Realm { ); let fragment = ` scope=${scope} waitMs=${Date.now() - waitStartedAt}` + - (user !== undefined ? ` user=${user}` : ''); + (requester !== undefined ? ` user=${requester}` : ''); if (settled) { emit('info', 'settled', fragment); } else { @@ -7123,7 +7152,7 @@ export class Realm { } let path = this.paths.local(url) + '.json'; await this.delete(path, { - initiatingUser: requestContext.authenticatedUser ?? null, + initiatingUser: this.requesterPrincipal(requestContext) ?? null, }); return createResponse({ body: null, From 30dfce7d67c06f1e96bbfd99a4a84f8ab9094698 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 10 Sep 2026 19:56:55 -0400 Subject: [PATCH 4/5] Make atomic write-then-read tests synchronous where the read is credential-less MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four atomic-endpoints tests POST /_atomic with a JWT and then GET the written card with no Authorization header. The read gate scopes read-your-writes to the requester's principal, and a credential-less read acts as the shared anonymous principal — it does not wait on the authenticated write's indexing job, so the GET raced the deferred job and read the index before the row landed. Real clients read with the session they wrote with; these tests mixed identities. Use the atomic endpoint's ?waitForIndex=true so the write itself is synchronous. Co-Authored-By: Claude Fable 5 --- .../tests/atomic-endpoints-test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/realm-server/tests/atomic-endpoints-test.ts b/packages/realm-server/tests/atomic-endpoints-test.ts index 8a36bb1f009..f53007d23a5 100644 --- a/packages/realm-server/tests/atomic-endpoints-test.ts +++ b/packages/realm-server/tests/atomic-endpoints-test.ts @@ -340,8 +340,11 @@ module(basename(import.meta.filename), function () { }, ], }; + // The follow-up GET is credential-less, so it doesn't share this + // authenticated write's read-your-writes principal and won't wait + // on its indexing — make the write itself synchronous instead. let response = await request - .post('/_atomic') + .post('/_atomic?waitForIndex=true') .set('Accept', SupportedMimeType.JSONAPI) .set( 'Authorization', @@ -394,8 +397,11 @@ module(basename(import.meta.filename), function () { ], }; + // Synchronous write for the same reason as the single-instance + // test: the credential-less GETs below don't wait on this + // authenticated write's indexing. let response = await request - .post('/_atomic') + .post('/_atomic?waitForIndex=true') .set('Accept', SupportedMimeType.JSONAPI) .set( 'Authorization', @@ -697,8 +703,10 @@ module(basename(import.meta.filename), function () { }, ], }; + // Synchronous write: the credential-less card+json GET below + // doesn't wait on this authenticated write's indexing. let response = await request - .post('/_atomic') + .post('/_atomic?waitForIndex=true') .set('Accept', SupportedMimeType.JSONAPI) .set( 'Authorization', @@ -775,8 +783,10 @@ module(basename(import.meta.filename), function () { }, ], }; + // Synchronous write: the credential-less card+json GET below + // doesn't wait on this authenticated write's indexing. let response = await request - .post('/_atomic') + .post('/_atomic?waitForIndex=true') .set('Accept', SupportedMimeType.JSONAPI) .set( 'Authorization', From f8281fb18f82c2ca4748fdaa6ad042f10a6d9008 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 10 Sep 2026 23:22:18 -0400 Subject: [PATCH 5/5] Anonymous readers skip the read gate outright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public anonymous-writable realms are not a configuration we support, so a provably credential-less caller can never have a write in flight — which means they never have a read-your-writes claim on pending indexing. The read gate now answers them with skipped-not-writer immediately instead of scoping them to a shared anonymous principal. That deletes the ANONYMOUS_REQUESTER concept entirely: the constant, the requesterPrincipal helper (the write sites read requestContext.authenticatedUser directly), the anonymous-principal tagging of credential-less writes (their jobs are now untagged, like system-originated ones), and the anonymous write-then-read consistency argument in the comments. The anonymous flag on RequestContext stays: it is what distinguishes a provably credential-less caller (skip) from one whose identity is merely unknown (conservative bounded hold). The two anonymous gate tests collapse into one stronger case: with every updater gate held open, a credential-less read still returns immediately. One existing test depended on the removed behavior — the card-source suite's definition-change test wrote and read back credential-lessly on a public-writable realm — and now authenticates that write/read pair as the same user, keeping authorization on the realm's public write permission. Co-Authored-By: Claude Fable 5 --- .../tests/card-source-endpoints-test.ts | 19 +++- .../tests/read-index-drain-test.ts | 61 +++---------- packages/runtime-common/realm.ts | 88 ++++++++----------- 3 files changed, 63 insertions(+), 105 deletions(-) diff --git a/packages/realm-server/tests/card-source-endpoints-test.ts b/packages/realm-server/tests/card-source-endpoints-test.ts index 5add92a9509..d377274f569 100644 --- a/packages/realm-server/tests/card-source-endpoints-test.ts +++ b/packages/realm-server/tests/card-source-endpoints-test.ts @@ -935,11 +935,20 @@ module(basename(import.meta.filename), function () { } let id = maybeId; - // modify field + // modify field. The +source POST indexes deferred, and only an + // identified reader waits on their own pending indexing (a + // credential-less read never waits — anonymous writes are + // unsupported), so this write and the read that depends on it + // authenticate as the same user; authorization still comes from + // the realm's public write permission. { let response = await request .post('/test-card.gts') - .set('Accept', 'application/vnd.card+source').send(` + .set('Accept', 'application/vnd.card+source') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`, + ).send(` import { contains, field, CardDef } from '@cardstack/base/card-api'; import StringField from '@cardstack/base/string'; @@ -956,7 +965,11 @@ module(basename(import.meta.filename), function () { { let response = await request .get(new URL(id).pathname) - .set('Accept', 'application/vnd.card+json'); + .set('Accept', 'application/vnd.card+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`, + ); assert.strictEqual(response.status, 200, 'HTTP 200 status'); let json = response.body; diff --git a/packages/realm-server/tests/read-index-drain-test.ts b/packages/realm-server/tests/read-index-drain-test.ts index 99781d01542..35c84565e52 100644 --- a/packages/realm-server/tests/read-index-drain-test.ts +++ b/packages/realm-server/tests/read-index-drain-test.ts @@ -2,7 +2,7 @@ import QUnit from 'qunit'; const { module, test } = QUnit; import { basename } from 'path'; import type { RealmHttpServer as Server } from '../server.ts'; -import { ANONYMOUS_REQUESTER, type Realm } from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common'; import { setupPermissionedRealmCached, closeServer, @@ -223,10 +223,10 @@ module(basename(import.meta.filename), function () { // End-to-end (no stubs): the authenticated write handlers must tag their // deferred indexing jobs with the writer, or this same-user - // write-then-read sees the pre-write schema. The public-writable variant - // of this flow cannot cover the tagging — an unauthenticated write - // produces an untagged job and an unidentified read takes the - // conservative all-jobs hold, so it passes with the tags removed. + // write-then-read sees the pre-write schema. Only an authenticated flow + // can exercise the tagging — a credential-less write produces an + // untagged job and a credential-less read skips the gate entirely + // (anonymous writes are unsupported). test("an authenticated writer's follow-up read sees their own deferred write indexed", async function (assert) { let auth = () => `Bearer ${createJWT(testRealm, 'hassan', ['read', 'write'])}`; @@ -503,17 +503,18 @@ module(basename(import.meta.filename), function () { }, }); - test("an anonymous reader skips identified users' pending indexing", async function (assert) { + test('an anonymous reader never waits on pending indexing', async function (assert) { let warm = await request .get('/person-1') .set('Accept', 'application/vnd.card+json'); assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); - // A credential-less caller acts as the shared anonymous principal, so - // an identified user's in-flight job holds nothing for them. + // A provably credential-less caller can have no write in flight + // (anonymous writes are unsupported), so the gate skips them without + // consulting any scope — every gate is held open here, and the read + // must still return immediately. let restore = stubUpdaterGates(testRealm, { - initiatedBy: (user) => - user === ANONYMOUS_REQUESTER ? undefined : NEVER, + initiatedBy: () => NEVER, all: () => NEVER, }); try { @@ -525,45 +526,7 @@ module(basename(import.meta.filename), function () { assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); assert.true( elapsed < NO_WAIT_CEILING_MS, - `anonymous read skipped the identified user's hold (took ${elapsed}ms)`, - ); - } finally { - restore(); - } - }); - - test('an anonymous reader waits on anonymous-initiated indexing', async function (assert) { - let warm = await request - .get('/person-1') - .set('Accept', 'application/vnd.card+json'); - assert.strictEqual(warm.status, 200, `warm-up GET: ${warm.text}`); - - // Anonymous write-then-read stays consistent: a credential-less write - // tags its job with the anonymous principal, and a credential-less - // read waits on jobs so tagged. - let gateResolved = false; - let gate = resolveAfter(GATE_RESOLVE_MS).then(() => { - gateResolved = true; - }); - let restore = stubUpdaterGates(testRealm, { - initiatedBy: (user) => - user === ANONYMOUS_REQUESTER ? gate : undefined, - all: () => gate, - }); - try { - let startedAt = Date.now(); - let response = await request - .get('/person-1') - .set('Accept', 'application/vnd.card+json'); - let elapsed = Date.now() - startedAt; - assert.strictEqual(response.status, 200, `HTTP 200: ${response.text}`); - assert.true( - gateResolved, - 'the anonymous read did not return before the gate settled', - ); - assert.true( - elapsed >= GATE_RESOLVE_MS - 20, - `anonymous read held for anonymous-initiated indexing (took ${elapsed}ms)`, + `anonymous read skipped every pending-indexing hold (took ${elapsed}ms)`, ); } finally { restore(); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index a05b4143956..05358808df7 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -859,13 +859,13 @@ export interface WriteOptions { // the JSON-API card handlers do not, writing a single file and instances // respectively. waitForIndex?: boolean | null; - // The read-your-writes principal whose request produced this write — the - // effective matrix user (post assume-user), or ANONYMOUS_REQUESTER for a - // credential-less write on a public-writable realm; the HTTP handlers - // derive it via `requesterPrincipal(requestContext)`. Tags the resulting - // incremental index job so read endpoints can scope their read-your-writes - // drain to this principal's own reads. Absent for system-originated - // writes. + // The matrix user whose request produced this write — the effective user + // (post assume-user) that `checkPermission` records on the request context + // as `authenticatedUser`. Tags the resulting incremental index job so read + // endpoints can scope their read-your-writes drain to this user's own + // reads. Absent for system-originated writes and for credential-less + // writes, whose jobs no reader waits on (anonymous writes are unsupported; + // see `drainRequestersOwnIndexing`). initiatingUser?: string | null; } @@ -986,24 +986,17 @@ export type RequestContext = { // read it. authenticatedUser?: string; // Set by `checkPermission` when the request presented no Authorization - // header at all. Distinguishes a provably credential-less caller — who - // acts as the shared ANONYMOUS_REQUESTER principal for read-your-writes - // purposes — from a caller whose identity is merely unknown: a token that - // failed verification, an assume-user indirection the public path cannot - // validate, or a realm-internal dispatch that never ran `checkPermission`. - // Identity, not authority, like `authenticatedUser`. + // header at all. Anonymous writes are unsupported (no realm grants `*` + // write in practice), so a provably credential-less caller has no + // read-your-writes claim and the read gate skips them outright — unlike a + // caller whose identity is merely unknown: a token that failed + // verification, an assume-user indirection the public path cannot + // validate, or a realm-internal dispatch that never ran `checkPermission`, + // all of which take the conservative bounded hold. Identity, not + // authority, like `authenticatedUser`. anonymous?: true; }; -// The read-your-writes principal shared by every credential-less caller. -// A write authorized without credentials (a public-writable realm) tags its -// indexing job with this value, and a credential-less read waits only on -// jobs so tagged — anonymous write-then-read stays consistent, while -// anonymous reads never park behind an identified user's reindex. Angle -// brackets keep it outside the space of real user identifiers (a matrix id -// cannot contain them), so no token-bearing user can collide with it. -export const ANONYMOUS_REQUESTER = ''; - export class Realm { #startedUp = new Deferred(); #matrixClient: MatrixClient; @@ -1945,7 +1938,7 @@ export class Realm { let { invalidations, generation } = await this.updateIndexAndCollectInvalidations(urls, { - initiatedBy: this.requesterPrincipal(requestContext) ?? null, + initiatedBy: requestContext.authenticatedUser ?? null, }); this.broadcastIncrementalInvalidationEvent(invalidations, { generation }); @@ -2999,7 +2992,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: true, waitForIndex, - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, }); } catch (e: any) { if (e instanceof CardError) { @@ -5218,7 +5211,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: false, waitForIndex: false, - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, }, ); return createResponse({ @@ -5249,7 +5242,7 @@ export class Realm { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), serializeFile: false, waitForIndex: false, - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, }, ); return createResponse({ @@ -5506,7 +5499,7 @@ export class Realm { } await this.delete(handle.path, { waitForIndex: false, - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, }); return createResponse({ body: null, @@ -6050,7 +6043,7 @@ export class Realm { } let [{ lastModified, created }] = await this.writeMany(files, { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, ...(duringPrerender ? { waitForIndex: false } : {}), }); @@ -6382,7 +6375,7 @@ export class Realm { // connection). let [{ lastModified, created }] = await this._batchWriteUnlocked(files, { clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, ...(duringPrerender ? { waitForIndex: false } : {}), }); let doc: SingleCardDocument; @@ -6599,11 +6592,11 @@ export class Realm { // flight has a read-your-writes expectation; every other reader takes // the current generation immediately. A write-heavy user (or a module // edit whose invalidation set spans the realm's module graph) must not - // park every reader of the realm behind their job. Credential-less - // callers share the ANONYMOUS_REQUESTER principal: an anonymous read - // waits only on anonymous writes (so anonymous write-then-read on a - // public-writable realm stays consistent) and never on an identified - // user's reindex. System-originated jobs (file watcher, realm copy) + // park every reader of the realm behind their job. A provably + // credential-less caller has no read-your-writes claim at all — + // anonymous writes are unsupported — so an anonymous read skips the + // gate outright and never parks behind an identified user's reindex. + // System-originated jobs (file watcher, realm copy) // are untagged and hold no scoped reader — no one has a // read-your-writes claim on them. Only when the requester is genuinely // unknown — a token that failed verification, an assume-user @@ -6626,23 +6619,6 @@ export class Realm { // reads an unscoped gate would have parked, the waited ones measure what // the requester-scoped hold actually costs. Steady-state reads (nothing // pending) emit nothing. budget-expired logs at warn; the rest at info. - // The read-your-writes principal this request acts as: the effective user, - // or the shared anonymous principal for a credential-less request on a - // public-permission realm. Undefined when the requester is genuinely - // unknown — a token that failed verification, an assume-user indirection - // the public path cannot validate, or a realm-internal dispatch that never - // ran checkPermission. Write handlers tag their indexing jobs with this - // (see WriteOptions.initiatingUser) and the read gate waits on jobs so - // tagged, so the two sides must derive it identically. - private requesterPrincipal( - requestContext: RequestContext, - ): string | undefined { - return ( - requestContext.authenticatedUser ?? - (requestContext.anonymous ? ANONYMOUS_REQUESTER : undefined) - ); - } - private async drainRequestersOwnIndexing( request: Request, requestContext: RequestContext, @@ -6663,7 +6639,13 @@ export class Realm { emit('info', 'skipped-prerender'); return; } - let requester = this.requesterPrincipal(requestContext); + if (requestContext.anonymous) { + // A provably credential-less caller has no write in flight to wait on + // (anonymous writes are unsupported), so they are never the writer. + emit('info', 'skipped-not-writer'); + return; + } + let requester = requestContext.authenticatedUser; let pending: Promise | undefined; let scope: 'own' | 'all'; if (requester !== undefined) { @@ -7152,7 +7134,7 @@ export class Realm { } let path = this.paths.local(url) + '.json'; await this.delete(path, { - initiatingUser: this.requesterPrincipal(requestContext) ?? null, + initiatingUser: requestContext.authenticatedUser ?? null, }); return createResponse({ body: null,