Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
828898a
Open anonymous access through grant where clauses and per-grant BXL e…
habdelra Oct 8, 2026
4635e0f
WIP: migrate anonymous policy tests to grant expressions
habdelra Oct 9, 2026
e9f363d
Assert the grant edit form shows the platform's rate limit
habdelra Oct 9, 2026
0306ede
Merge remote-tracking branch 'origin/main' into cs-13651-replace-anon…
habdelra Oct 9, 2026
3dfa2c5
Answer a caller over one grant's limit with 429, and warn about an op…
habdelra Oct 9, 2026
0852d79
Pin the catalog test subset to the grant-expression OperationGrant
habdelra Oct 9, 2026
33fa8a3
Update tests for anonymous policy defaults
habdelra Oct 9, 2026
1032a70
Merge remote-tracking branch 'origin/main' into cs-13651-replace-anon…
habdelra Oct 9, 2026
7b3fb87
Pin catalog subset to policy grant changes
habdelra Oct 9, 2026
43b9aa9
test: cover anonymous policy grant fields
habdelra Oct 9, 2026
3b108cd
docs: add CS-13651 preview screenshots
habdelra Oct 9, 2026
436ba20
chore: remove PR screenshot artifacts
habdelra Oct 9, 2026
28497a9
build: pin catalog subset to paired PR
habdelra Oct 9, 2026
fc7c3c9
Fix anonymous policy admission handling
habdelra Oct 9, 2026
6b8bb05
Pin catalog subset to traffic fixes
habdelra Oct 9, 2026
078fc41
Pin catalog subset to formatted traffic fixes
habdelra Oct 9, 2026
8aa458f
Report the admitting anonymous write grant
habdelra Oct 9, 2026
996535a
Preserve anonymous grant limiter identities
habdelra Oct 9, 2026
c8d2749
Pin catalog subset validation commit
habdelra Oct 9, 2026
a0f7458
Require actor comparison for anonymous grants
habdelra Oct 9, 2026
4bc1abc
Charge anonymous traffic to a grant with budget, and tighten the anon…
habdelra Oct 11, 2026
132a18c
Scope an anonymous search by spent grants type by type
habdelra Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions .claude/skills/operations-diagnosis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ A user admitted after they should have lost access, on a grant that shows up her

## Callers who aren't signed in

A grant with `anonymous: true` admits a caller who authenticated nobody; the governed realm's `realm.json` sets the limit (`anonymousRateLimit`, falling back to `BOXEL_ANONYMOUS_RATE_LIMIT`, `300/60`) and the blocklist (`anonymousBlocklist`), and a write is made as the user its grant's `actingUser` key names in the realm's `config`. This section is about reading what those callers leave behind.
A grant admits a caller who authenticated nobody only when its `where` names them: for such a caller `actor()` is `"anonymous"`, and the grant's `where` compares `actor()` with `"anonymous"` in a way that can hold for one, as in `actor() == "anonymous"` or `OR(actor() == "anonymous", …)`. A guard such as `actor() != "anonymous"` keeps them out and opens nothing to them. Each such grant carries its own controls, as BXL expressions that may read the governed realm's `config` (`realmConfig("key")`) or the policy card's fields (`policy("field")`): `blocklist` (the addresses it refuses), `rateLimitRequests` and `rateLimitWindowSeconds` (each falling back to `BOXEL_ANONYMOUS_RATE_LIMIT`, `300/60`, when the grant leaves it out or it produces no whole number), and for a write, `actingUser` (the user the write is made as). This section is about reading what those callers leave behind.

### The record

Expand All @@ -315,25 +315,28 @@ An `anonymous-request` line for a request from a caller who isn't signed in that
- `admitted` — a grant admitted it and it was counted; `count` is the window's count after it.
- `infra` — one of the platform's own egress addresses: admitted, never counted or blocked.
- `refused` — admitted to the route, but no grant admitted what it asked for; the caller got the 401, or, for a `HEAD`, the realm's 200 discovery answer.
- `blocked` — the realm refused the address before any grant was asked; see `blockReason`.
- `rate-limited` — the address had used up its budget; nothing ran.
- `blocked` — every grant that could admit the request refused the address before anything about the target was read; see `blockReason`.
- `rate-limited` — the address had used up its budget through every grant that could admit it, or through the one that did; nothing ran.
- `unavailable` — the realm couldn't read or update the count, so it turned the caller away (503).
- `blockReason` — `blocklist`, `ip-undetermined` (the realm server couldn't work out an address: more trusted hops configured than `X-Forwarded-For` entries, or an entry that isn't an address), `blocklist-invalid` (an entry in the realm's blocklist isn't an address or range, which closes the realm to every such caller).
- `blockReason` — `blocklist`, `ip-undetermined` (the realm server couldn't work out an address: more trusted hops configured than `X-Forwarded-For` entries, or an entry that isn't an address), `blocklist-invalid` (a grant's blocklist produced something that isn't a list of addresses and ranges, which closes that grant to every such caller). Where grants refused for different reasons, the first grant's reason is recorded.
- `grant` — the grant the request was counted against, by its path in the policy card (`rules[i].grants[j]`): the first grant to admit it, or for a refusal before anything ran, the first that could have.
- `clientIP` (canonical, not reduced to its `/64`: an IPv4-mapped address reads as a dotted quad), `rateLimitKey` (what the budget is keyed on: the address, or an IPv6 caller's `/64`), `realmURL`, `operation` (what the route was admitted to, or `*` on `/_operations` and `_capabilities`, which run whatever the request names), `route` (`<METHOD> <path>`), `correlationId` (what the request carried in `x-boxel-logging-correlation-id`, which the host sends and a bot or a third-party page usually doesn't; join on `clientIP`, `route` and the timestamp when it is null).
- `limit` (`requests`, `windowSeconds`, `from: realm|platform`; `| json` flattens these to `limit_requests`, `limit_windowSeconds`, `limit_from`), `retryAfterSeconds`, `cost` (when a request counted more than one unit: a capability check's pairs, a batch's entries).
- `limit` (`requests`, `windowSeconds`, `requestsFrom` and `windowSecondsFrom`, each `grant` or `platform`; `| json` flattens these to `limit_requests`, `limit_windowSeconds`, `limit_requestsFrom`, `limit_windowSecondsFrom`), `retryAfterSeconds`, `cost` (when a request counted more than one unit: a capability check's pairs, a batch's entries).
- `actingUsers` — for an admitted write, the users it was made as.
- `actingUserFailures` — for a refused write, each grant key that named no one who may write the realm, with `key-missing`, `not-a-matrix-id` or `no-write`.
- `actingUserFailures` — for a refused write, each grant (by path) whose `actingUser` named no one who may write the realm, with `expression-failed` (the expression produced nothing, such as `realmConfig("key")` for a key the realm's `config` doesn't have), `not-a-matrix-id` (it produced something that isn't a user id, `"anonymous"` included) or `no-write` (the user it named may not write the realm).

Two plain-text warnings belong with it: `could not count a request to <realm> from a caller who isn't signed in, so it was turned away` (beside every `unavailable` record), and `<realm> could not say whether it admits a search from a caller who isn't signed in, so it admits none` (a federated search's admission that threw).
Three plain-text warnings belong with it: `could not count a request to <realm> from a caller who isn't signed in, so it was turned away` (beside every `unavailable` record), `<realm> could not say whether it admits a search from a caller who isn't signed in, so it admits none` (a federated search's admission that threw), and `the blocklist of the grant at <path> of <realm>'s policy isn't a list of IP addresses and CIDR ranges` (beside a `blocklist-invalid` block, naming what it produced).

### Traps

- **A realm whose policy opens nothing to such callers records nothing.** Their 401 never reaches admission. No line is not a missing line.
- **A realm anyone may read (`'*': ['read']`) records nothing for reads.** Its ACL admits the read; the policy, its limit and its blocklist never see it.
- **The budget is the realm's and the address's, shared by every grant.** A visitor rate-limited on a search was spending the same budget as their reads and form posts. Realms never share one: a federated search records one line per realm, each with its own `count`.
- **The budget is the grant's and the address's.** Each grant counts what it admits on its own, so a visitor rate-limited on a search through one grant can still read through another. A request is charged once, to the grant that admitted it (`grant`). Where several grants admit the same card, one whose budget is spent admits it only where none with budget left does, so a visitor over one grant's limit keeps reading through another that admits the same card, and gets 429 only once every grant that would admit it is spent. A search is scoped type by type the same way: for each type a grant with budget left scopes, the spent grants are left out, so rows only a spent grant admits on that type are not returned (no 429, no `meta.incomplete`); a type only spent grants scope answers 429. Realms never share a budget either: a federated search records one line per realm, each with its own `count`.
- **A grant that never names `"anonymous"` never admits such a caller**, whatever its `where` would say for one. A grant whose `where` is missing, `true`, compares `actor()` with a field, compares a field with the text `"anonymous"`, or only rules such a caller out (`actor() != "anonymous"`) is for signed-in callers only.
- **`refused` and `rate-limited` are not the same caller experience.** A refused caller saw the 401 a missing card gives; a rate-limited one saw 429 `rate-limited` with `Retry-After`. Neither did anything.
- **The hop configuration shows up two ways.** `ip-undetermined` usually means `BOXEL_TRUSTED_PROXY_HOPS` is set higher than the proxies in front of the realm server. Many visitors sharing one `clientIP` that is the load balancer's address means it is unset: with no trusted hops the address is the socket peer.
- **`blocklist-invalid` closes the whole realm** to every caller who isn't signed in until the realm's `realm.json` is fixed.
- **`blocklist-invalid` closes the grant** to every caller who isn't signed in until what its `blocklist` reads is fixed: the policy card, or the realm `config` value it names. A `realmConfig("key")` for a key the realm's `config` doesn't hold produces nothing, which closes the grant the same way; an empty string blocks nobody.
- **A rate limit that reads text falls back silently.** `rateLimitRequests` / `rateLimitWindowSeconds` must produce a number: a `config` value of `"300"` (text) is no limit, so the platform default applies with no warning, and the record's `requestsFrom` / `windowSecondsFrom` reads `platform`.
- **Capability checks count, explains don't.** A page that asks `canInvoke` about many controls spends a unit per pair.
- **A write's acting user is a real account.** Its index jobs run in that user's writer lane, and its reads wait for them. A realm that names a person who edits it sees their own edits slow down under visitor traffic.

Expand Down Expand Up @@ -362,21 +365,21 @@ From a laptop, narrow on `anonymous-request` with `tail-logs.sh --filter 'anonym

### Investigation: a public form says "Too many requests"

1. Find the realm's `rate-limited` lines for the time; read `limit` and `from`. `from: platform` means the realm sets nothing and the platform default applies.
1. Find the realm's `rate-limited` lines for the time; read `grant`, `limit` and its `requestsFrom` / `windowSecondsFrom`. `platform` means the grant sets nothing for that half (or its expression produced no whole number) and the platform default applies.
2. Count `admitted` lines per `rateLimitKey` for that realm over one `windowSeconds`, since that is what the budget is keyed on (an IPv6 visitor's addresses within one `/64` share it). One address spending the budget alone is a burst or a bot; many visitors sharing one `clientIP` is the hop misconfiguration above.
3. Check `cost`: a page asking a capability check about many controls, or a batch of many entries, spends more than one unit per request.
4. The fix is the realm's: raise `anonymousRateLimit` in its `realm.json`, or block the address. Nothing in the policy card changes the limit.
4. The fix is on the grant: raise its `rateLimitRequests` / `rateLimitWindowSeconds`, or the realm `config` value or policy-card field they read, or block the address. An explain with an empty `actor` reports each grant's limit and where each half comes from.

### Investigation: spam submissions — find the source and block it

1. Admitted writes for the realm, grouped by `rateLimitKey` and `clientIP`: select on the acting user (`| json actingUser="actingUsers[0]" | actingUser!=""`) rather than on `operation`, which is `*` for a batch posted to `/_operations`. An IPv6 sender is counted by its `/64`, so block the `/64`.
2. Add the addresses or ranges to the realm's `anonymousBlocklist`; the next request from them gets the 401 and a `blocked` line with `blockReason: blocklist`.
3. Confirm no `blocklist-invalid` lines follow the edit: one malformed entry closes the realm to every visitor.
2. Add the addresses or ranges to what the admitting grant's `blocklist` reads (its expression, the realm `config` value it names, or the policy-card field); the next request from them gets the 401 and a `blocked` line with `blockReason: blocklist`. Every grant that opens the route needs it, since a grant whose blocklist doesn't name the address still admits it.
3. Confirm no `blocklist-invalid` lines follow the edit: one malformed entry closes the grant to every visitor.

### Investigation: a public form's submissions are refused

1. Find the `refused` lines on the route. `actingUserFailures` names the cause when it is the acting user: `key-missing` (the realm's `config` has no such key), `not-a-matrix-id` (the value isn't a user id), `no-write` (the user lost write on the realm).
2. With no `actingUserFailures`, no grant admitted the write: the grant doesn't opt in, its `where` doesn't hold for what was sent, or it reads `actor()` (which never admits such a caller). Hand off to explain with an empty `actor`, which judges exactly that.
1. Find the `refused` lines on the route. `actingUserFailures` names the grant and the cause when it is the acting user: `expression-failed` (its `actingUser` produced nothing, such as a `realmConfig` key the realm's `config` doesn't hold), `not-a-matrix-id` (the value isn't a user id), `no-write` (the user lost write on the realm).
2. With no `actingUserFailures`, no grant admitted the write: no grant's `where` names `"anonymous"`, its `where` doesn't hold for what was sent, it has no `actingUser` (`anonymous-write-without-acting-user`), or its operation's program reads `actor()`. Hand off to explain with an empty `actor`, which judges exactly that and reports each grant's acting user.

### Investigation: the public page stopped showing cards

Expand Down
65 changes: 0 additions & 65 deletions packages/base/realm-config.gts
Original file line number Diff line number Diff line change
Expand Up @@ -411,34 +411,6 @@ export class RoutingRuleField extends FieldDef {
static edit = RoutingRuleEdit;
}

class AnonymousRateLimitAtom extends Component<typeof AnonymousRateLimitField> {
<template>
{{#if @model.requests}}
{{@model.requests}}
per
{{@model.windowSeconds}}s
{{/if}}
</template>
}

// How many invocations a caller who is not signed in may make against the
// realm per window, counted per caller address.
export class AnonymousRateLimitField extends FieldDef {
static displayName = 'Anonymous Rate Limit';

@field requests = contains(NumberField, {
description:
'Invocations a caller who is not signed in may make against this realm in one window, counted per caller address',
});

@field windowSeconds = contains(NumberField, {
description: 'The length of the window, in seconds',
});

static atom = AnonymousRateLimitAtom;
static embedded = AnonymousRateLimitAtom;
}

// The JSON spelling of one setting's value, which is what the table shows and
// what the editor reads back. A string is shown bare so an id or a path reads
// as itself, and quoted wherever reading the bare form back would produce
Expand Down Expand Up @@ -2001,25 +1973,6 @@ class RealmConfigIsolated extends Component<typeof RealmConfig> {
<PolicyStanding @config={{this.config}} />
</section>

<section class='section' data-test-realm-config-anonymous>
<h2 class='section-title'>Callers who aren't signed in</h2>
<p class='anonymous-limit' data-test-realm-config-anonymous-limit>
{{#if @model.anonymousRateLimit.requests}}
Limited to
<@fields.anonymousRateLimit @format='atom' />
per address.
{{else}}
Limited by the platform's default rate.
{{/if}}
</p>
{{#if @model.anonymousBlocklist.length}}
<ul class='rules' data-test-realm-config-anonymous-blocklist>
{{#each @model.anonymousBlocklist as |entry|}}
<li class='rule'>{{entry}}</li>
{{/each}}
</ul>
{{/if}}
</section>
</article>
<style scoped>
.realm-config-isolated {
Expand Down Expand Up @@ -2059,9 +2012,6 @@ class RealmConfigIsolated extends Component<typeof RealmConfig> {
.empty {
color: var(--boxel-450);
}
.anonymous-limit {
margin: 0 0 var(--boxel-sp-xs);
}
.policy {
display: grid;
gap: var(--boxel-sp-xs);
Expand Down Expand Up @@ -2133,21 +2083,6 @@ export class RealmConfig extends CardDef {
'The RealmPolicy card that governs this realm, by its URL or realm-prefixed id. Absent for a realm with no policy. Only the pointer lives here; the rules live on the card it names',
});

// How hard callers this realm's policy admits without a session may use it,
// and which of them it keeps out. They live here, on the realm, rather than
// on the policy, because the policy can live in another realm, and the
// writers of that card must not decide how this realm is protected. Writing
// this card takes the realm's own write permission, which no policy grants.
@field anonymousRateLimit = contains(AnonymousRateLimitField, {
description:
"Overrides the platform's rate limit for callers this realm's policy admits without signing in. Counted per caller address, for this realm alone. Absent, the platform's limit applies",
});

@field anonymousBlocklist = containsMany(StringField, {
description:
"IP addresses and CIDR ranges (192.0.2.7, 198.51.100.0/24, 2001:db8::/32) this realm refuses to admit without signing in, whatever its policy grants. An entry that is neither closes the realm to every caller who isn't signed in until it is fixed",
});

// What the policy this realm names compiles to, as the realm compiles it:
// the answer says when the policy is not in force at all, and why,
// including a pointer to a card the index does not hold or to one that is
Expand Down
21 changes: 17 additions & 4 deletions packages/bxl/src/bxl/bridge/request-context-native.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* The request-context builtins: `params`, `actor`, `instance` and
* `realmConfig`.
* The request-context builtins: `params`, `actor`, `instance`, `realmConfig`
* and `policy`.
*
* A mutation program can see the document it is editing through `.`. These
* four add the rest of what a card operation needs — what the caller sent,
Expand All @@ -22,6 +22,11 @@
* same way the other three do because it varies per invocation for the same
* reason they do: the realm is decided by the target, not by the type.
*
* `policy` is the policy card a realm's permissions are judged against, read
* the way `realmConfig` is: a keyed object of the card's fields. Only a policy
* host supplies it, so that a grant can keep a setting it uses — an address
* list, a limit — on the policy card itself.
*
* They are functions rather than `$`-prefixed variables, matching the form
* card operations are authored in, and named `params` rather than the more
* obvious `input` because the mutation profile denies `input`. Registry
Expand Down Expand Up @@ -64,11 +69,12 @@ const SLOT_DESCRIPTIONS: Record<ContextSlot, string> = {
actor: 'the caller identity',
instance: 'the stored document being edited',
realmConfig: 'the realm configuration',
policy: 'the policy card',
};

/**
* How to ask for a key that may legitimately be absent. `instance()` and
* `realmConfig()` hand back the whole object with no argument, so a program
* How to ask for a key that may legitimately be absent. `instance()`,
* `realmConfig()` and `policy()` hand back the whole object with no argument, so a program
* that wants a default rather than a failure has somewhere to go; a missing
* payload key has no such reading, since the operation declares its keys.
*
Expand All @@ -79,6 +85,7 @@ const SLOT_DESCRIPTIONS: Record<ContextSlot, string> = {
const OPTIONAL_KEY_HINTS: Partial<Record<ContextSlot, string>> = {
instance: 'Use `instance()` and index it if the key may be absent.',
realmConfig: 'Use `realmConfig()` and index it if the setting may be absent.',
policy: 'Use `policy()` and index it if the field may be absent.',
};

/** The slot's value, once the host is known to have supplied one. */
Expand Down Expand Up @@ -245,6 +252,12 @@ const bareNativeFilters: Record<string, BareNativeFilter> = {
'realmConfig/1': function* (_input, key) {
yield requireKey('realmConfig', 'realmConfig(key)', key);
},
'policy/0': function* () {
yield readable(slotObject('policy', 'policy()'));
},
'policy/1': function* (_input, key) {
yield requireKey('policy', 'policy(key)', key);
},
};

export const requestContextNativeFilters =
Expand Down
Loading
Loading