Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 19 additions & 19 deletions .claude/skills/tail-logs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Tail Loki logs from a laptop. The script handles auth (SSM bearer token), URL re
- Correlating a request id, job id, or realm name across services
- Cross-checking that something just shipped a log line you expect

If the user is asking what *changed* (deploy diffs, code history) rather than what *happened*, this is the wrong tool — go to git/PR APIs instead.
If the user is asking what _changed_ (deploy diffs, code history) rather than what _happened_, this is the wrong tool — go to git/PR APIs instead.

## How to invoke

Expand All @@ -39,19 +39,19 @@ Common flag patterns:
./scripts/tail-logs.sh --env production --service synapse --since 30m --no-follow --confirm
```

| Flag | Default | Notes |
|------|---------|-------|
| `--env` | (required) | `local`, `staging`, `production` |
| `--service` | (required) | `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` (or any local container name) |
| `--realm` | unset | Filter to a single realm. Only set on tasks where realm is meaningful. |
| `--worker-id` | unset | Per-Fargate-task id; only available on workers. |
| `--filter` | unset | LogQL line-filter (`\|=` literal substring). Mutually exclusive with `--regex`. |
| `--regex` | unset | LogQL line-regex (`\|~`). |
| `--since` | `15m` | `30s`, `15m`, `1h`, `2d` — anything matching `^\d+[smhd]$`. |
| `--limit` | `200` | Max lines per batch. |
| `--no-follow` | follow on | One-shot mode for diagnostics. **Always use `--no-follow` from a skill** — follow mode is interactive. |
| `--json` | text | Raw Loki response per batch. Use when you need to parse beyond the formatted line. |
| `--confirm` | n/a | Required for `--env production`. |
| Flag | Default | Notes |
| ------------- | ---------- | ------------------------------------------------------------------------------------------------------ |
| `--env` | (required) | `local`, `staging`, `production` |
| `--service` | (required) | `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` (or any local container name) |
| `--realm` | unset | Filter to a single realm. Only set on tasks where realm is meaningful. |
| `--worker-id` | unset | One worker process, `<instance-id>-pid-<pid>`; only available on workers. |
| `--filter` | unset | LogQL line-filter (`\|=` literal substring). Mutually exclusive with `--regex`. |
| `--regex` | unset | LogQL line-regex (`\|~`). |
| `--since` | `15m` | `30s`, `15m`, `1h`, `2d` — anything matching `^\d+[smhd]$`. |
| `--limit` | `200` | Max lines per batch. |
| `--no-follow` | follow on | One-shot mode for diagnostics. **Always use `--no-follow` from a skill** — follow mode is interactive. |
| `--json` | text | Raw Loki response per batch. Use when you need to parse beyond the formatted line. |
| `--confirm` | n/a | Required for `--env production`. |

## Auth requirements

Expand All @@ -69,12 +69,12 @@ Labels are sorted alphabetically inside the brackets. With `--json`, you get the

## Patterns by task family

| You want to... | Suggested invocation |
|---|---|
| You want to... | Suggested invocation |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Find the last 100 errors in any staging service | `--env staging --service <each one> --regex '(?i)error\|exception\|fatal' --since 1h --limit 100 --no-follow` (run per service; LogQL doesn't take an OR over labels in one query) |
| Watch a slow indexer batch | `--env staging --service realm-server --regex 'duration_ms=[0-9]{4,}'` |
| See what one Fargate worker is doing | `--env staging --service worker --worker-id <id> --since 30m --no-follow` |
| Tail synapse | `--env staging --service synapse --since 15m --no-follow` |
| Watch a slow indexer batch | `--env staging --service realm-server --regex 'duration_ms=[0-9]{4,}'` |
| See what one Fargate worker is doing | `--env staging --service worker --worker-id <id> --since 30m --no-follow` |
| Tail synapse | `--env staging --service synapse --since 15m --no-follow` |

## Limits and caveats

Expand Down
22 changes: 22 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ const {
CJS_GLOBALS_IN_ESM,
} = require('./eslint/erasable-syntax-selectors.cjs');
const { DATA_TEST_SELECTORS } = require('./eslint/data-test-selectors.cjs');
const {
CLOUD_PROVIDER_SDK_RULE,
RUNTIME_PACKAGE_FILES,
CLOUD_PROVIDER_ADAPTER_MODULES,
} = require('./eslint/cloud-provider-sdk-imports.cjs');

module.exports = {
root: true,
Expand Down Expand Up @@ -100,5 +105,22 @@ module.exports = {
'no-restricted-globals': ['error', ...CJS_GLOBALS_IN_ESM],
},
},
{
// Cloud provider SDKs only in adapter modules (ADR 003 §1.2). The
// runtime packages with their own config, `ai-bot`, `host` and
// `boxel-ui`, re-declare it.
files: RUNTIME_PACKAGE_FILES,
excludedFiles: ['**/tests/**'],
plugins: ['@cardstack/boxel'],
rules: {
'@cardstack/boxel/no-cloud-provider-sdk': CLOUD_PROVIDER_SDK_RULE,
},
},
{
files: CLOUD_PROVIDER_ADAPTER_MODULES,
rules: {
'@cardstack/boxel/no-cloud-provider-sdk': 'off',
},
},
],
};
177 changes: 177 additions & 0 deletions docs/adr/003-reach-cloud-services-through-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
status: proposed
date: 2026-10-18
scope:
- packages/realm-server/**
- packages/runtime-common/**
- packages/postgres/**
- packages/billing/**
- packages/ai-bot/**
- packages/bot-runner/**
- packages/host/app/**
- packages/base/**
- packages/bxl/**
- packages/boxel-ui/**
---

# 003. Reach proprietary cloud services only through adapters

Our organization deploys Boxel on AWS, but Boxel is designed as a federation
of realm servers that other people host
([ADR 004](004-federate-realm-servers-each-paired-with-its-own-homeserver.md)),
and they may not use AWS. So the runtime code must not depend on any one
provider. Each proprietary cloud service the runtime uses sits behind an
interface. The interface is named for the capability that Boxel needs. An
adapter implements the interface for one provider, and a local adapter
implements it for development and tests. Services that speak an open protocol
(Postgres, a POSIX filesystem, HTTP, the S3 API) need no interface, but the
code must use only what that protocol defines. The runtime must not detect
which platform hosts it. To run a realm server on a different provider, a host
writes adapters and changes configuration. It does not rewrite the code that
calls the adapters.

## 1. Decision

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
"SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when,
and only when, they appear in all capitals, as shown here.

### 1.1. Scope

This ADR governs runtime code: code that runs in a deployed service or in the
browser. Infrastructure-as-code, deploy configuration such as
`packages/host/config/deploy.js` and the realm-server's deployed
`scripts/start-*.sh`, CI workflows, and operational runbooks and skills are
provider-specific by nature, and this ADR does not govern them.

### 1.2. Proprietary services sit behind an adapter

Runtime code MUST reach a proprietary cloud service, such as KMS, only through
an interface. The interface MUST be named and shaped for the capability that
Boxel needs, for example `SecretsKeyProvider` or `MediaCacheAdapter`. It MUST
NOT be named or shaped for the service. An interface MUST NOT expose provider
types, request or command objects, resource identifiers such as ARNs, or
provider error codes. An adapter MUST translate provider errors into the
interface's own results or errors.

A provider SDK (`@aws-sdk/*`, and the SDK of any other cloud provider) MUST be
imported only in an adapter module, or in a module that speaks the S3 API
(§1.3). A lint rule MUST enforce this. The rule's allowlist,
`eslint/cloud-provider-sdk-imports.cjs`, names each such module. A change to
the allowlist is a reviewed change to the boundary.

### 1.3. Open protocols are the boundary

Runtime code MAY reach a service that speaks an open, widely implemented
protocol directly, without an interface. These protocols include Postgres, a
POSIX filesystem, HTTP, and the S3 API. Code that uses such a service MUST use
only what the protocol defines. It MUST NOT depend on extensions that only one
provider offers, such as engine-specific features of a managed Postgres or
S3 features that S3-compatible stores do not implement.

An S3 client MUST take its endpoint, region, and path-style addressing from
configuration, so that it can reach any S3-compatible store. It MUST get
credentials from configuration or the SDK's default chain, and it MUST NOT
assume a particular source for them. The runtime SHOULD construct S3 clients
through `packages/realm-server/lib/s3-client.ts`.

### 1.4. Every capability runs locally

Every interface from §1.2 MUST have a local adapter that runs without cloud
credentials. Development and CI MUST use it. For an S3-API store,
development and CI MAY instead use a local S3-compatible server or a
local-disk adapter.

### 1.5. Configuration selects the adapter

One factory per capability MUST choose the adapter from configuration at
startup. Code outside the factory MUST depend on the interface, and it MUST
NOT know which adapter is in use. Incomplete configuration for an adapter MUST
fail startup. It MUST NOT fall back silently to another adapter. Settings that
only one adapter reads MAY be named for its provider, such as
`SECRETS_KMS_KEY_ARN`. Only that adapter's factory MAY read them.

### 1.6. No assumptions about the hosting platform

Runtime code MUST NOT detect its platform from environment variables that a
provider injects, such as `ECS_CONTAINER_METADATA_URI`. Code MUST NOT name a
provider-specific request header or user agent, such as `ELB-HealthChecker`.
When behavior depends on one, the deployment configuration MUST name it. A
fact about the deployment, such as an instance id or whether the server is
deployed, MUST come from a provider-neutral setting or derivation, such as the
server's own URL.

### 1.7. New dependencies

A new dependency on a hosted service SHOULD use an open protocol rather than a
proprietary API. A new proprietary dependency MUST meet §1.2–§1.5 when it is
added. This ADR does not require an adapter for another provider until a
deployment needs one.

## 2. Rationale

Boxel is designed as a federation of realm servers (ADR 004). People and
organizations other than ours will host their own realm servers, which work
with ours and with the rest of Boxel. They will pick their own hosting: another
cloud provider, their own data center, or a single machine. The realm server
they run is the same code we run, so every AWS assumption in it becomes a
barrier to joining the federation. A host that has to fork the code to replace
one service falls behind our releases, and out of step with the servers it
federates with.

The same rule keeps our own options open. Cost, a customer's data-residency
requirement, or a service that another provider offers better can each make a
move worthwhile for us too.

Each piece of code that is coupled to one provider raises the cost of hosting
anywhere else. Coupling also spreads, because new code copies the code around
it. Today only a few runtime modules touch AWS. This makes the rule cheap to
adopt now, and the cost of adopting it will only increase.

The two existing seams show the shape of the rule. `MediaCacheAdapter` and
`SecretsKeyProvider` each have a cloud adapter and a local one. A factory
chooses between them from the environment. The local adapter makes the rule
cheap to keep. Development and CI already run without AWS, so the interface
is used by two implementations from the start, and it cannot become a thin
wrapper over one provider's API.

The S3 API counts as an open protocol because many object stores implement
it, including Cloudflare R2, MinIO, Backblaze B2, and Google Cloud Storage
through its XML API. An interface in front of it would add a layer with no
gain in portability. What it does need is a configurable endpoint (§1.3).

The import rule is a lint rule because the boundary can break quietly. An
SDK import in a large diff is easy to miss in review, and agents write much of
this code. A lint failure states the rule at the point where the code breaks
it.

## 3. Considered options

- **Adapters for proprietary services, open protocols as the boundary
(chosen).** Portability work goes only where it is needed, and code stays
direct where a standard already makes it portable.
- **Call the provider directly, and port when needed.** This costs nothing
now, but the port would fall to each outside host, and coupling spreads.
Assumptions about the platform, such as which environment variables exist,
stay invisible until the code runs somewhere else.
- **A generic multi-cloud library.** Its lowest-common-denominator API is
wider than what Boxel needs. It does not model capabilities such as
envelope-key unwrapping, and it adds a dependency that must track every
provider. Small interfaces of our own are cheaper.
- **A Boxel interface in front of the S3 API as well.** Rejected; see §2.
- **Interfaces for everything external, including Postgres and the
filesystem.** These are already portable standards. An interface would cost
effort without making the code more portable.
- **Self-hosted open-source services only, such as MinIO or Vault, in
production.** This avoids proprietary APIs, but at an operational cost that
adapters make unnecessary.
- **A dedicated health-check path instead of a configured user agent.** This
is the more common pattern. But it moves the load balancer's probe, so the
application and the infrastructure must change in a set order. A
configured user agent keeps the probe where it is, and a different load
balancer needs only a new setting.

## 4. Remediation

No known gaps.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ or backfill one.
| ADR | Title | Status |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------- |
| [001](001-record-architecture-decisions.md) | Record architecture decisions as short, normative ADRs | proposed |
| [003](003-reach-cloud-services-through-adapters.md) | Reach proprietary cloud services only through adapters | proposed |
| [005](005-authorize-key-changes-only-with-a-session-card-code-cant-reach.md) | Authorize changes to a user's keys only with a session that card code can't reach | proposed |
8 changes: 5 additions & 3 deletions docs/realm-server-health-signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ signal answers which question, and what to do with each.
### `GET /` — the load balancer's check

`healthCheck` in `packages/realm-server/middleware/index.ts` answers `OK` from
memory to any request whose user-agent starts with `ELB-HealthChecker`, ahead of
the host-app and realm-serving middleware. It touches no database, no filesystem,
and no realm state.
memory to any request whose user-agent starts with a prefix listed in
`HEALTH_CHECK_USER_AGENTS`, ahead of the host-app and realm-serving middleware.
The deployed start scripts set it to `ELB-HealthChecker`, and a deployed server
with it unset logs a warning at boot. It touches no
database, no filesystem, and no realm state.

It is served **by the event loop**, so it answers only when the loop has capacity
to answer. That is what makes it the right signal for routing: a target that
Expand Down
43 changes: 43 additions & 0 deletions eslint/cloud-provider-sdk-imports.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';

// Keeps cloud provider SDKs inside the modules that adapt them (ADR 003
// §1.2). Runtime code reaches a proprietary cloud service only through an
// interface named for the capability Boxel needs, so loading a provider's SDK
// belongs only in that interface's adapter, or in a module that speaks the S3
// API (ADR 003 §1.3). `@cardstack/boxel/no-cloud-provider-sdk` reports every
// way of loading one: static import, re-export, dynamic `import()`, and
// `require()`.
//
// Adding a module to CLOUD_PROVIDER_ADAPTER_MODULES moves the boundary, so it
// is a reviewed change, not a way to silence the rule.

const CLOUD_PROVIDER_SDK_RULE = [
'error',
{ patterns: ['@aws-sdk/*', 'aws-sdk', '@google-cloud/*', '@azure/*'] },
];

// The runtime packages ADR 003 governs that lint through an eslintrc config,
// relative to the repository root. `host` and `ai-bot` are `root: true` and
// `boxel-ui` uses a flat config, so each of those declares the rule itself.
const RUNTIME_PACKAGE_FILES = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RUNTIME_PACKAGE_FILES leaves out runtime packages that ADR §1.1 governs: packages/base, packages/bxl and packages/boxel-ui.

ADR §1.1 covers 'code that runs in a deployed service or in the browser', and §1.2 says a lint rule MUST enforce the SDK confinement. A import { S3Client } from '@aws-sdk/client-s3' added to packages/base (card code that the realm server loads and the browser runs) or to packages/bxl (which the realm server evaluates) still lints clean, because those packages inherit the root config but don't match any of the listed globs.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 0bc2d97632. The rule covers base and bxl through the root config, and boxel-ui through its flat config. ADR 003's scope names all three.

'packages/realm-server/**/*',
'packages/runtime-common/**/*',
'packages/postgres/**/*',
'packages/billing/**/*',
'packages/bot-runner/**/*',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] The rule covers packages/bot-runner, but CI never runs ESLint there. That package's lint script is only ember-tsc --noEmit, so an SDK import in bot-runner would pass CI, and §1.2's "a lint rule MUST enforce this" doesn't hold for it. Running ESLint directly on a probe import in bot-runner does report the error, so only the CI script is missing.

To fix it, add "lint:js": "eslint . --report-unused-disable-directives --cache", and have lint run both steps through concurrently, as billing does. eslint . in bot-runner then reports 6 no-useless-escape errors, all on one string literal in tests/bot-runner-test.ts, and --fix clears them.

Regression against this PR's own ADR, non-blocking but cheap to fix here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in b538115028. bot-runner now has lint:js, and lint runs it alongside lint:types, so CI enforces the SDK rule there. The escaped quotes in tests/bot-runner-test.ts are cleaned up.

'packages/base/**/*',
'packages/bxl/**/*',
];

const CLOUD_PROVIDER_ADAPTER_MODULES = [
'packages/realm-server/lib/s3-client.ts',
'packages/realm-server/lib/secrets-at-rest/providers.ts',
'packages/realm-server/media-cache/s3-adapter.ts',
'packages/realm-server/prerender/artifact-sink.ts',
];

module.exports = {
CLOUD_PROVIDER_SDK_RULE,
RUNTIME_PACKAGE_FILES,
CLOUD_PROVIDER_ADAPTER_MODULES,
};
1 change: 1 addition & 0 deletions mise-tasks/services/worker
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ NODE_ENV=development \
--port="${WORKER_PORT}" \
--matrixURL="${MATRIX_URL_VAL}" \
--prerendererUrl="${PRERENDER_MGR_URL}" \
--indexingDashboard \
\
--fromUrl='https://cardstack.com/base/' \
--toUrl="${REALM_BASE_URL}/base/" \
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@babel/core": "catalog:",
"@babel/parser": "catalog:",
"@babel/traverse": "catalog:",
"@cardstack/eslint-plugin-boxel": "workspace:*",
"@glimmer/component": "catalog:",
"@glimmer/syntax": "^0.95.0",
"@glimmer/tracking": "^1.1.2",
Expand Down
Loading
Loading