diff --git a/packages/realm-server/tests/anonymous-access-test.ts b/packages/realm-server/tests/anonymous-access-test.ts index 889180f0a22..33aec28e304 100644 --- a/packages/realm-server/tests/anonymous-access-test.ts +++ b/packages/realm-server/tests/anonymous-access-test.ts @@ -19,6 +19,7 @@ import { import { blocklistCloses, compileGrantExpression, + grantExpressionPreparations, readBlocklist, readRateLimitPart, settleTraffic, @@ -434,6 +435,67 @@ module(basename(import.meta.filename), function () { }); }); + test('a grant expression is prepared once, and reads its context afresh each time', async function (assert) { + let grant = { + blocklist: compiled('blocklist', 'realmConfig("blockedIps")'), + rateLimitRequests: compiled('rateLimitRequests', 'policy("requests")'), + rateLimitWindowSeconds: compiled('rateLimitWindowSeconds', '600'), + }; + let before = grantExpressionPreparations(); + let settled = []; + for (let requests of [5, 6, 7]) { + settled.push( + await settleTraffic( + grant, + { + realmConfig: { blockedIps: `192.0.2.${requests}` }, + policy: { requests }, + }, + PLATFORM, + ), + ); + } + assert.strictEqual( + grantExpressionPreparations() - before, + 3, + 'one preparation for each expression', + ); + assert.deepEqual( + settled.map((traffic) => traffic.limit.requests), + [5, 6, 7], + 'each settlement reads its own policy', + ); + assert.deepEqual( + settled.map((traffic) => blocksAddress(traffic, '192.0.2.6')), + [false, true, false], + 'and its own realm config', + ); + + let failing = compiled('blocklist', 'realmConfig("blockedIps")'); + let beforeFailing = grantExpressionPreparations(); + let unset = await settleTraffic( + { blocklist: failing }, + { realmConfig: {} }, + PLATFORM, + ); + assert.strictEqual( + unset.blocklist.failed, + 'BxlTransformError (evaluate)', + 'an absent setting throws when the expression runs', + ); + let fixed = await settleTraffic( + { blocklist: failing }, + { realmConfig: { blockedIps: CALLER } }, + PLATFORM, + ); + assert.strictEqual( + grantExpressionPreparations() - beforeFailing, + 1, + 'an expression that throws on its values is still prepared once', + ); + assert.true(blocksAddress(fixed, CALLER), 'and settles once they change'); + }); + test('a rate-limit expression that produces no limit leaves that half to the platform', async function (assert) { let grant = { rateLimitRequests: compiled( diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index 67126355310..6c920703627 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -16,6 +16,7 @@ import type { QueueRunner, Realm, } from '@cardstack/runtime-common'; +import { grantExpressionPreparations } from '@cardstack/runtime-common/card-operations/grant-expressions'; import { setAnonymousRequestSink, type AnonymousRequestEvent, @@ -437,6 +438,38 @@ module(basename(import.meta.filename), function (hooks) { ); }); + test("a grant's blocklist and limit are prepared once and settled for every request", async function (assert) { + await setNewsroomConfig(limitOf(50)); + assert.strictEqual((await readCard(PUBLISHED)).status, 200); + let before = grantExpressionPreparations(); + for (let i = 0; i < 3; i++) { + assert.strictEqual((await readCard(PUBLISHED)).status, 200); + } + assert.strictEqual( + grantExpressionPreparations() - before, + 0, + 'the later reads prepare nothing', + ); + assert.deepEqual( + articleRecords().map((r) => ({ + outcome: r.outcome, + limit: r.limit, + count: r.count, + })), + [1, 2, 3, 4].map((count) => ({ + outcome: 'admitted' as const, + limit: { + requests: 50, + windowSeconds: 600, + requestsFrom: 'grant' as const, + windowSecondsFrom: 'grant' as const, + }, + count, + })), + "every read is counted against the grant's own limit", + ); + }); + test('each grant counts the same address on its own, so one over its limit leaves the others alone', async function (assert) { await setNewsroomConfig(limitOf(1)); assert.strictEqual((await readCard(PUBLISHED)).status, 200); diff --git a/packages/runtime-common/card-operations/grant-expressions.ts b/packages/runtime-common/card-operations/grant-expressions.ts index 12a336b58e8..6a014512de1 100644 --- a/packages/runtime-common/card-operations/grant-expressions.ts +++ b/packages/runtime-common/card-operations/grant-expressions.ts @@ -4,7 +4,11 @@ import { type AddressRange, type AnonymousRateLimit, } from '../anonymous-access.ts'; -import { loadBxlTransform } from './transforms.ts'; +import { + loadBxlTransform, + type BxlTransformModule, + type PreparedTransformProgram, +} from './transforms.ts'; // ============================================================================ // The expressions a grant carries besides its `where`, which settle how it @@ -70,23 +74,51 @@ async function evaluateGrantExpression( context: GrantExpressionContext, ): Promise { try { - let bxl = await loadBxlTransform(); - let value = bxl.runBxlTransform( - expression.canonical, - null, - { - ...(context.realmConfig ? { realmConfig: context.realmConfig } : {}), - ...(context.policy ? { policy: context.policy } : {}), - ...(context.instance ? { instance: context.instance } : {}), - }, - { syntax: 'solidified' }, - ); + let program = preparedExpression(await loadBxlTransform(), expression); + let value = program.run(null, { + ...(context.realmConfig ? { realmConfig: context.realmConfig } : {}), + ...(context.policy ? { policy: context.policy } : {}), + ...(context.instance ? { instance: context.instance } : {}), + }); return { value }; } catch (e: unknown) { return { failed: errorKind(e) }; } } +// Each expression's program, parsed and checked once and run for every +// request it settles. Keyed on the compiled expression, so a program lives +// exactly as long as the compiled policy that holds it, and a recompiled +// policy prepares its own. An expression that fails to prepare is not held, +// and fails again on its next evaluation. +const preparedExpressions = new WeakMap< + CompiledGrantExpression, + PreparedTransformProgram +>(); + +// How many grant expression programs this process has prepared. A steady +// policy prepares each expression once however many requests settle it. +let preparations = 0; + +export function grantExpressionPreparations(): number { + return preparations; +} + +function preparedExpression( + bxl: BxlTransformModule, + expression: CompiledGrantExpression, +): PreparedTransformProgram { + let program = preparedExpressions.get(expression); + if (!program) { + program = bxl.prepareBxlTransform(expression.canonical, { + syntax: 'solidified', + }); + preparedExpressions.set(expression, program); + preparations++; + } + return program; +} + function errorKind(e: unknown): string { if (!(e instanceof Error)) { return typeof e;