Skip to content

Commit 6b84929

Browse files
committed
CS-11264: route _realm-auth through reconciler.lookupOrMount
Pre-Phase-3 (CS-10894), every realm in the registry was eagerly mounted at boot, so iterating the in-memory `realms[]` to resolve a permission row's URL was equivalent to consulting the registry. After Phase 3 only `pinned=true` rows mount at boot; non-pinned (source / published) rows wait for a request-path `reconciler.lookupOrMount()` to bring them in. `_realm-auth` was left calling `allRealms.find(...)` directly — so after a realm-server restart, an owner's first `boxel-cli` push/publish fails with `No realm token available` because the auth handler silently skips the permission row whose URL isn't yet in `realms[]`. The matching `realm_user_permissions` row is intact; the gap is purely in this handler's view of mounted realms. Restore the pre-Phase-3 contract that calling `/_realm-auth` is sufficient to interact with any realm the user has permissions on by routing through `reconciler.lookupOrMount(realmUrl)`. Pinned rows resolve via the `mounted` fast-path (O(1)); non-pinned rows pay one cold-mount cost on first hit, the same cost any other first request would pay. Mount failures Sentry-capture-and-skip, mirroring the existing `ensureSessionRoom` failure handling. Adds a `testingOnlyEvictRealmFromRealmsList(url)` shim on `RealmServer` so the regression test can simulate the post- restart "row in DB, realm not yet in this process's realms[]" state without re-running disk mount / matrix login. Regression test in `realm-auth-test.ts` evicts the test realm from `realms[]`, hits `/_realm-auth`, and asserts a JWT is issued. Confirmed it fails on the pre-fix code path and passes on the fix.
1 parent 2996098 commit 6b84929

3 files changed

Lines changed: 81 additions & 4 deletions

File tree

packages/realm-server/handlers/handle-realm-auth.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { CreateRoutesArgs } from '../routes';
44
import {
55
SupportedMimeType,
66
fetchUserPermissions,
7+
type Realm,
78
} from '@cardstack/runtime-common';
89
import type { RealmServerTokenClaim } from 'utils/jwt';
910
import { getUserByMatrixUserId } from '@cardstack/billing/billing-queries';
@@ -14,11 +15,10 @@ import * as Sentry from '@sentry/node';
1415
export default function handleRealmAuth({
1516
dbAdapter,
1617
realmSecretSeed,
17-
realms,
18+
reconciler,
1819
serverURL,
1920
}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise<void> {
2021
return async function (ctxt: Koa.Context, _next: Koa.Next) {
21-
let allRealms = realms;
2222
let token = ctxt.state.token as RealmServerTokenClaim;
2323
let { user: matrixUserId } = token;
2424
let user = await getUserByMatrixUserId(dbAdapter, matrixUserId);
@@ -42,7 +42,18 @@ export default function handleRealmAuth({
4242
for (let [realmUrl, permissions] of Object.entries(
4343
permissionsForAllRealms,
4444
)) {
45-
let realm = allRealms.find((r) => r.url === realmUrl);
45+
let realm: Realm | undefined;
46+
try {
47+
realm = await reconciler.lookupOrMount(realmUrl);
48+
} catch (error) {
49+
Sentry.withScope((scope) => {
50+
scope.setExtra('realmUrl', realmUrl);
51+
scope.setExtra('matrixUserId', matrixUserId);
52+
scope.setExtra('permissionsForAllRealms', permissionsForAllRealms);
53+
Sentry.captureException(error);
54+
});
55+
continue;
56+
}
4657
if (!realm) {
4758
console.error(
4859
`Permissions found pointing to unknown realm ${realmUrl}`,

packages/realm-server/server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,19 @@ export class RealmServer {
543543
}
544544
}
545545

546+
// Simulate the post-restart "this realm isn't in this process's
547+
// realms[] yet" state without tearing down its disk mount, indexer,
548+
// or matrix client. realm-auth-test uses this to prove
549+
// _realm-auth resolves through the reconciler instead of realms[].
550+
// The realm stays in reconciler.mounted so lookupOrMount() returns
551+
// it via the fast path.
552+
testingOnlyEvictRealmFromRealmsList(url: string): void {
553+
let idx = this.realms.findIndex((r) => r.url === url);
554+
if (idx !== -1) {
555+
this.realms.splice(idx, 1);
556+
}
557+
}
558+
546559
// Test-only accessor for the request-path realm resolver. Exposed so
547560
// lazy-mount integration tests can drive findOrMountRealm directly
548561
// without spinning up an HTTP listener + mocked Koa context.

packages/realm-server/tests/realm-auth-test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import {
1313
testRealmHref,
1414
} from './helpers';
1515
import { createJWT as createRealmServerJWT } from '../utils/jwt';
16+
import type { RealmServer } from '../server';
1617

1718
module(basename(__filename), function () {
1819
module('realm auth handler', function (hooks) {
1920
let dbAdapter: PgAdapter;
2021
let request: SuperTest<SupertestTest>;
22+
let testRealmServer: RealmServer;
2123
const matrixUserId = '@firsttimer:localhost';
2224

2325
setupPermissionedRealmCached(hooks, {
@@ -27,9 +29,14 @@ module(basename(__filename), function () {
2729
[matrixUserId]: ['read', 'write'],
2830
'@node-test_realm:localhost': ['read', 'realm-owner'],
2931
},
30-
onRealmSetup: ({ dbAdapter: adapter, request: req }) => {
32+
onRealmSetup: ({
33+
dbAdapter: adapter,
34+
request: req,
35+
testRealmServer: server,
36+
}) => {
3137
dbAdapter = adapter;
3238
request = req;
39+
testRealmServer = server.testRealmServer;
3340
},
3441
});
3542

@@ -86,5 +93,51 @@ module(basename(__filename), function () {
8693
'session room is persisted after the realm auth request',
8794
);
8895
});
96+
97+
// CS-11264 regression: after a realm-server restart, a non-pinned
98+
// realm is not in this process's realms[] until something triggers a
99+
// lazy mount via the request path. Pre-fix, _realm-auth iterated
100+
// realms[] directly and silently skipped any realm not yet mounted on
101+
// this instance — so an owner's first post-restart boxel-cli call
102+
// (push/publish/sync) failed with "No realm token available". Post-
103+
// fix, the handler routes through reconciler.lookupOrMount, so any
104+
// realm the reconciler can resolve (already mounted, or registered
105+
// via knownByUrl, or directly readable from realm_registry) yields a
106+
// session token.
107+
test('POST /_realm-auth issues a token for a realm reachable only via the reconciler (post-restart)', async function (assert) {
108+
sinon
109+
.stub(MatrixClient.prototype, 'createDM')
110+
.resolves('!post-restart-session-room:localhost');
111+
sinon.stub(MatrixClient.prototype, 'sendEvent').resolves();
112+
sinon.stub(MatrixClient.prototype, 'getJoinedRooms').resolves({
113+
joined_rooms: [],
114+
});
115+
sinon.stub(MatrixClient.prototype, 'joinRoom').resolves();
116+
117+
// Simulate the post-restart state where the realm is still
118+
// mounted in the reconciler (which knows about it from boot or a
119+
// prior lazy mount on another caller) but absent from this
120+
// process's realms[] snapshot.
121+
testRealmServer.testingOnlyEvictRealmFromRealmsList(testRealmHref);
122+
123+
let response = await request
124+
.post('/_realm-auth')
125+
.set('Accept', 'application/json')
126+
.set('Content-Type', 'application/json')
127+
.set(
128+
'Authorization',
129+
`Bearer ${createRealmServerJWT(
130+
{ user: matrixUserId, sessionRoom: 'server-session-room' },
131+
realmSecretSeed,
132+
)}`,
133+
)
134+
.send('{}');
135+
136+
assert.strictEqual(response.status, 200, 'HTTP 200 status');
137+
assert.ok(
138+
response.body[testRealmHref],
139+
'response includes a JWT for the realm even though it was absent from realms[]',
140+
);
141+
});
89142
});
90143
});

0 commit comments

Comments
 (0)