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
11 changes: 11 additions & 0 deletions .changeset/linear-trims-168.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cloudhal/core-ts": patch
"@cloudhal/analytics": patch
"@cloudhal/astro-renderer": patch
"@cloudhal/cli": patch
"@cloudhal/comments": patch
---

Slug and name trims run in one linear pass instead of `/^-+|-+$/g` (CodeQL js/polynomial-redos, #168). This covers `slugify`, `foldName` and `slugifyFilename` in core-ts (the new `trimChar` is exported from `@cloudhal/core-ts/names`), heading ids in astro-renderer, and Studio's option, media and entry slugs. Results are unchanged. These trims were already linear in practice, because a run of other characters was collapsed to one `-` first. These inputs were quadratic and are linear now: analytics' `datasetName` on a long run of `_` inside a deployment name; json-render component keys in `fromJsonRender` (`./interop`); trailing whitespace in directive blocks and on comment lines; trailing `/` in request paths (the playground guard and route matching in api, analytics paths, the renderer's link localizing and redirect splats, the docs Worker). The new `trimEndChar` is exported from `@cloudhal/core-ts/names` too.

Also in 0.6.3, with no changeset at the time (#161): Studio's "Not published" badges and its "unknown" scheduler state show their status dot again. They used a Badge variant without a dot colour and rendered no dot.
6 changes: 6 additions & 0 deletions .changeset/mcp-step-up-168.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/agent-runtime": patch
"@cloudhal/api": patch
---

MCP OAuth step-up follow-ups (RFC-0044 phase 1, #168). These stay latent until phase 2 gives connections a `grantId`. A policy denial for missing scopes now carries `missing`, the required scopes that neither the caller's scopes nor an own variant satisfy. The new `stepUpScopes(policy, principal, tool, args)` turns that into the scopes a token should ask for, using the same rules as the handler: a propose call's `proposeScopes`, the own variant the member's role holds (`content:write:own` for an Author, `content:draft:own` for a Contributor, `site:publish:own` for an Author's publish) and the member's role on the call's site (RFC-0034 A2), capped by their installation role as consent caps a token (an installation Contributor who is an Editor on one site is asked for `content:draft:own` there). A call that would still be denied with those scopes, such as a write in plan mode or a tool outside the declared skill, is not challenged. The `/mcp` step-up 403 asks for exactly that set and now leaves an audit row, like other denials. `createApi` throws when `mcp.oauth.resource` is not the public URL of `/mcp`. A playground refuses both OAuth protected-resource documents (`403 {error: "playground", block: "mcp"}`), as its route table already said.
6 changes: 6 additions & 0 deletions .changeset/rate-limits-168.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/storage": patch
"@cloudhal/api": patch
---

Cloudhal's own rate-limit counters move out of Better Auth's `rateLimit` table into `rate_limits` (migration `0030_rate_limits`, #168). This covers the hourly cap of 3 reset mails per address, the per-minute buckets of the public token endpoints and each agent key's window. Better Auth prunes `rateLimit` by its own 60 s window, so the hourly reset-mail cap used to restart about a minute after any sign-in from the same address. Each hit counts in one statement (an upsert with `RETURNING`). Because the keys come from client addresses and submitted email addresses, about one hit in 32 also deletes up to 500 expired counters through an `expires_at` index. The migration deletes the old `cloudhal:` rows from `rateLimit`, so each counter starts over once at upgrade.
7 changes: 5 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ on:

permissions: {}

# A new push to a pull request cancels that pull request's running analysis. A push to main and
# the weekly run each get a group of their own (the run id): a concurrency group also cancels a
# *pending* run when another one queues, so sharing `refs/heads/main` would drop analyses of main.
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
group: codeql-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
analyze:
Expand Down
10 changes: 6 additions & 4 deletions apps/docs/src/worker/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export function pageId(input: string, selfHost?: string): string | undefined {
if (!HOSTS.has(url.hostname) && url.host !== selfHost) return undefined;
value = url.pathname;
}
const path = value
const stem = value
.replace(/[?#].*$/, "")
.replace(/^\/+/, "")
.replace(/(^|\/)index\.md$/, "")
.replace(/\.md$/, "")
.replace(/\/+$/, "")
.toLowerCase();
.replace(/\.md$/, "");
// A linear trim of trailing "/" (`/\/+$/` backtracks on a long run of "/" inside the path).
let end = stem.length;
while (end > 0 && stem[end - 1] === "/") end--;
const path = stem.slice(0, end).toLowerCase();
if (path === "" || path === "index") return "index";
if (!ID.test(path) || path.split("/").some((s) => s === "." || s === "..")) return undefined;
return path;
Expand Down
18 changes: 10 additions & 8 deletions apps/studio/src/lib/field-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import type { FieldDefinition } from "@cloudhal/core-contracts";
import { FIELD_LIMITS } from "@cloudhal/core-ts/fields";
import { trimChar } from "@cloudhal/core-ts/names";
import {
type FieldType,
fieldKeyFrom,
Expand Down Expand Up @@ -120,14 +121,15 @@ export function withOptionLabel(
if (j !== i) return o;
const value =
isDefault && !o.valueEdited
? label
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ı/g, "i")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 128)
? trimChar(
label
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ı/g, "i")
.replace(/[^a-z0-9]+/g, "-"),
"-",
).slice(0, 128)
: o.value;
return { ...o, value, labels: { ...o.labels, [locale]: label } };
});
Expand Down
8 changes: 4 additions & 4 deletions apps/studio/src/lib/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
normalizeMediaType,
RASTER_TYPES,
} from "@cloudhal/core-ts/media";
import { trimChar } from "@cloudhal/core-ts/names";
import { numberFormat } from "@cloudhal/messages/format";
import { type AssetListItem, type AssetPatch, api } from "./api.ts";
import { type ImageRef, previewImageUrl } from "./seo.ts";
Expand Down Expand Up @@ -389,12 +390,11 @@ export function assetPatch(form: AssetForm, kind: AssetKind): AssetPatch {

/** A URL slug from a file name (lower-case words joined by hyphens), for the slug field. */
export function slugify(input: string): string {
return input
const dashed = input
.toLowerCase()
.replace(/ı/g, "i")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, ASSET_LIMITS.slug);
.replace(/[^a-z0-9]+/g, "-");
return trimChar(dashed, "-").slice(0, ASSET_LIMITS.slug);
}
16 changes: 7 additions & 9 deletions apps/studio/src/lib/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PublishStatus } from "@cloudhal/core-contracts";
import { trimChar } from "@cloudhal/core-ts/names";

/** Allowed editorial transitions (RFC-0001 lifecycle). */
export const TRANSITIONS: Record<PublishStatus, PublishStatus[]> = {
Expand All @@ -18,13 +19,10 @@ export function needsApproval(to: PublishStatus): boolean {
}

export function slugify(input: string): string {
return (
input
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 128) || "untitled"
);
const dashed = input
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-");
return trimChar(dashed, "-").slice(0, 128) || "untitled";
}
2 changes: 1 addition & 1 deletion docs/adr/0012-cloudflare-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@ Decision 6 pins the direct `miniflare` devDependency (the D1, R2 and Durable Obj

- **sharp.** Every Miniflare release pins sharp exactly. 4.20260730.0, still Miniflare's npm `latest`, pins 0.35.2, and the 5.20261006.0 alpha pins 0.35.4. Both are below 0.35.5, the fix for GHSA-wq5f-xc86-pv6w (librsvg), and 0.35.2 is also below 0.35.4, the fix for GHSA-rgj7-g3m4-5g8c (libheif). Miniflare loads sharp only for its Images binding and its `cf.image` fetch mock. Cloudhal configures no Images binding and makes no `cf.image` fetches: the `/cdn-cgi/image/` URLs from `@cloudhal/astro-renderer` are resolved at the edge. `pnpm-workspace.yaml` therefore overrides `sharp@<0.35.5` to exactly 0.35.5. That version was already in the lockfile through Astro, and the calls Miniflare makes behave the same on 0.35.2, 0.35.4 and 0.35.5 (checked locally on 2026-10-25). Overrides are not published. A project that uses `@cloudhal/storage/testing` with Miniflare 4 keeps Miniflare's sharp unless it adds the same override. A new project resolves the starter's `wrangler ^4.148.0` to a release whose Miniflare pins 0.35.5. Remove the override when `pnpm why -r sharp` shows nothing below 0.35.5 without it.
- **wrangler** stays at 4.148.0 in this change. 4.152 is a minor move: it changes the Miniflare under `createTestHarness` (5.20261019.2-beta), needs `@cloudflare/vite-plugin` 1.65 and `@cloudflare/workers-types` ≥ 5.20261019.1, and uploads source maps on deploy by default (4.150). It gets its own change, verified with the CLI's Cloudflare suites. (wrangler 4.152.0 landed 2026-11-08; wrangler 4.158.0 and @astrojs/cloudflare 14.4.4 follow together, because 14.4 resolves @cloudflare/vite-plugin 1.67, whose peer range is wrangler ^4.156.1.)
- **undici.** The undici versions Miniflare pins (7.28.0 in 4.20260730.0, 7.29.1 in the 5.x alpha) are accepted for local development and tests: Miniflare uses undici to reach its own workerd on localhost and, outside tests, to fetch Cloudflare's `cf.json` from workers.cloudflare.com, a trusted endpoint (set `CLOUDFLARE_CF_FETCH_ENABLED=false` to turn that off). The fixed 7.30.1 is a minor move inside `dispatchFetch`, which the D1 harness and Miniflare's binding proxy depend on (the Durable Object harness bypasses it since #116), so an override waits for its own serial test run.
- **undici.** The undici versions Miniflare pins (7.28.0 in 4.20260730.0, 7.29.1 in the 5.x alpha) are accepted for local development and tests: Miniflare uses undici to reach its own workerd on localhost and, outside tests, to fetch Cloudflare's `cf.json` from workers.cloudflare.com, a trusted endpoint (set `CLOUDFLARE_CF_FETCH_ENABLED=false` to turn that off). The fixed 7.30.1 is a minor move inside `dispatchFetch`, which the D1 harness and Miniflare's binding proxy depend on (the Durable Object harness bypasses it since #116), so an override waits for its own serial test run. *Update 2026-11-08:* that run happened. #159 (Dependabot alerts #5–#32) added the override `"undici@<7.30.1": 7.30.2` to `pnpm-workspace.yaml`, so Miniflare's pinned 7.28.0 resolves to 7.30.2 in monorepo installs; the D1 and Durable Object harnesses passed on it. Like the sharp override it is not published, and it goes when Miniflare 4 is no longer pinned.

Sources: https://github.com/advisories/GHSA-wq5f-xc86-pv6w · https://github.com/advisories/GHSA-rgj7-g3m4-5g8c · https://pnpm.io/settings#overrides · `npm view miniflare dist-tags`, `npm view sharp time`, `npm view wrangler time` (checked 2026-10-25) · Miniflare 4.20260730.0 `dist/src/index.js` (`imagesLocalFetcher`, `cfImageLocalFetcher`, `setupCf`).
33 changes: 26 additions & 7 deletions docs/rfcs/0044-mcp-oauth-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ not apply, cacheable for an hour:
- `/.well-known/oauth-protected-resource` (root form, the fallback the 2025-11-25 and later
revisions require clients to probe; Claude's docs probe it too).

Both are served without CORS headers. That matches `/mcp`, which refuses any request whose
`Origin` is not its own (DNS-rebinding protection): the clients this RFC serves are native apps
and servers, which do not need CORS, and a browser page on another origin that could read the
document still could not call `/mcp`. `McpMountOptions.oauth.resource` must be the public URL of
the mounted `/mcp` (its path ends in `/mcp`; a proxy may add a prefix): `createApi` throws
otherwise, since every token's audience is bound to it.

```json
{
"resource": "https://studio.example.com/mcp",
Expand Down Expand Up @@ -150,9 +157,19 @@ WWW-Authenticate: Bearer error="insufficient_scope", scope="site:publish",
```

This is possible without a second policy engine because each tool's scopes are static
(`toolPolicies(defineTools())`, ADR-0011): the route reads the JSON-RPC method and tool name of
the single message a stateless request carries and compares the tool's scopes with the token's.
Every other denial (role, mode, `:own`, approvals) stays a JSON-RPC result as today. Clients then
(`toolPolicies(defineTools())`, ADR-0011): the route reads the JSON-RPC method, tool name and
arguments of the single message a stateless request carries and asks the policy engine
(`stepUpScopes` in `@cloudhal/agent-runtime`) which scopes the denial lacks. The answer follows
the same rules as the handler's decision: a propose call needs its `proposeScopes`, an own
variant satisfies a tool that allows one (an Author is asked for `site:publish:own`, a
Contributor for `content:draft:own`), and the member's role is their role on the call's site
(RFC-0034 A2). Only scopes both that site role and the installation role give are asked for,
because consent caps a token by the installation role and `forSite` then caps it by the site
role: an installation Contributor who is an Editor on one site is asked for `content:draft:own`
there, not `content:write`. No challenge is sent when the call would still be denied once the
token has those scopes (plan mode, a skill that does not cover the tool). Every other denial
(role, mode, `:own`, approvals) stays a JSON-RPC result as today. The 403 leaves an audit row
like every other denial. Clients then
re-authorize with the union of their scopes (the SDK's `computeScopeUnion`; the draft revision
makes the union normative). A scope the role does not hold is never challenged: the client would
loop. The challenge is emitted together for all scopes the tool needs ("servers SHOULD include all
Expand Down Expand Up @@ -409,7 +426,7 @@ attestation, per-grant IP allow-lists.
`better-call` 1.4.5), because the three plugins peer-depend on `^1.7.18`;
`test/better-auth-pins.test.ts` keeps the family consistent. New catalog entries:
`@better-auth/mcp`, `@better-auth/oauth-provider`, `@better-auth/cimd`, `jose`.
- **`packages/storage/migrations/0030_oauth.ts`**: the DDL for `oauthClient`,
- **`packages/storage/migrations/0031_oauth.ts`**: the DDL for `oauthClient`,
`oauthAccessToken`, `oauthRefreshToken`, `oauthConsent`, `oauthClientAssertion`,
`oauthResource`, `oauthClientResource` and `jwks`, generated with
`getMigrations(auth.options).compileMigrations()` on 1.7.18 and checked in, as `0002_auth` and
Expand Down Expand Up @@ -470,7 +487,7 @@ CREATE INDEX agent_grants_user ON agent_grants (user_id);
| Phase | Ships | Amends |
|---|---|---|
| 1 · Discovery | `WWW-Authenticate` with `resource_metadata` and `scope` on `/mcp` 401; both PRM documents; the 403 step-up challenge; `ApiDeps.mcp.oauth` option (issuer, resource) that turns them on; the suite's discovery cases. Until Phase 2 the issuer answers 404, so clients fail at "authorization server metadata", a diagnosable error | ADR-0004 addendum: "No OAuth provider" gains "superseded by ADR-00NN (RFC-0044)" |
| 2 · Authorization server | Better Auth bump; `mcp()` + `jwt()` in `createInstance`; migration `0030_oauth`; `agent_grants`; token → `Principal`; consent screen; `/api/agent-grants`; `cloudhal studio` issuer from `--base-url` (#143); shared suite on sqlite + D1 | ADR-0010 "MCP authorization (Sprint 5)" paragraph (DCR fallback, no DPoP in v1); ADR-0011 §5–6 row "OAuth 2.1 … Sprint 6" → done; new ADR-00NN |
| 2 · Authorization server | Better Auth bump; `mcp()` + `jwt()` in `createInstance`; migration `0031_oauth`; `agent_grants`; token → `Principal`; consent screen; `/api/agent-grants`; `cloudhal studio` issuer from `--base-url` (#143); shared suite on sqlite + D1 | ADR-0010 "MCP authorization (Sprint 5)" paragraph (DCR fallback, no DPoP in v1); ADR-0011 §5–6 row "OAuth 2.1 … Sprint 6" → done; new ADR-00NN |
| 3 · Registration and Studio | `cimd()` with the two transports; DCR with limits and pruning; "Connected agents"; `McpClientSetup` tab; `docs/guides/mcp-oauth.md` and the docs site's clients page; hardening guide (secret rotation, allow-list, `CLOUDHAL_MCP_DCR`); e2e test; changeset | `packages/mcp-server/README.md` auth section; `docs/guides/cloudflare-hardening.md` |

Each phase is one PR; Phase 1 is independent of the Better Auth bump and can land first.
Expand Down Expand Up @@ -577,8 +594,10 @@ sequenceDiagram
`agent-runtime`, not a contract) gains the optional `grantId`.
- **Agent keys:** unchanged, `x-api-key` and `Authorization: Bearer <key>` keep working;
`resolvePrincipal` distinguishes a JWT by shape. The agent-keys suite stays green unchanged.
- **Data:** additive tables (§7). Existing deployments get the challenge on `/mcp` after Phase 1
with no data change; an installation that never enables the AS loses nothing.
- **Data:** additive tables (§7). Phase 1 ships the challenge behind `McpMountOptions.oauth`,
and no runtime sets that option until Phase 2 gives it an issuer; existing deployments get the
challenge on `/mcp` once a runtime sets it (Phase 2), with no data change. An installation that
never enables the AS loses nothing.
- **API:** `/api/agent-grants` is new; `/api/instance` `features` gains `mcpOauth: true|false`
so Studio shows the "Connect by URL" tab only where it works.
- **CLI:** `cloudhal studio --base-url` (#143) is the dependency; `cloudhal mcp clients create`
Expand Down
1 change: 1 addition & 0 deletions packages/agent-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export * from "./policy.ts";
export * from "./roles.ts";
export * from "./site-access.ts";
export * from "./skills.ts";
export * from "./step-up.ts";
export * from "./taxonomy.ts";
23 changes: 21 additions & 2 deletions packages/agent-runtime/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ const OWN_VARIANTS: Partial<Record<Scope, readonly { scope: Scope; level: "own"
"releases:publish": [{ scope: "releases:publish:own", level: "own" }],
};

/** The own variants of `scope` that satisfy `tool` with a limit, strongest first (none without `own`). */
export function ownVariantsOf(tool: ToolPolicy, scope: Scope): Scope[] {
return tool.own ? (OWN_VARIANTS[scope] ?? []).map((v) => v.scope) : [];
}

/**
* What a call does, decided from its arguments (RFC-0007 §10.2), for tools whose policy depends
* on them (`moderate_comments` may hide in `edits` mode but never approve):
Expand Down Expand Up @@ -307,7 +312,16 @@ export type ApprovalCause = "tool" | "destructive" | "skill" | "manual-mode" | "

export type Decision =
| { kind: "allow"; limit?: OwnLimit }
| { kind: "deny"; reason: string; code?: "missing_scope" | "mode" | "skill" }
| {
kind: "deny";
reason: string;
code?: "missing_scope" | "mode" | "skill";
/**
* `missing_scope` only: the required scopes (`proposeScopes` for a propose call) that
* neither the caller's scopes nor an own variant of them satisfy (RFC-0044 §1 step-up).
*/
missing?: Scope[];
}
| {
kind: "approval-required";
reason: string;
Expand Down Expand Up @@ -379,7 +393,12 @@ export class PolicyEngine {
missing.push(s);
}
if (missing.length > 0)
return { kind: "deny", reason: `missing scope ${missing.join(", ")}`, code: "missing_scope" };
return {
kind: "deny",
reason: `missing scope ${missing.join(", ")}`,
code: "missing_scope",
missing,
};
if (principal.skill) {
// A skill may only call tools inside the scopes it declared (own variants count).
const declared = principal.skill.scopes;
Expand Down
Loading
Loading