Thread Access

src/thread-access.ts answers one question: may this caller create, read, mutate, or destroy this thread?

That is a different question from the one middleware answers ("may this caller run this route"), and it is keyed on a different thing. A thread has no owning route: every endpoint that starts a turn overwrites the thread's route metadata, so any caller allowed to run any route on a thread can move that identity onto a route of their choosing. Thread authorization is therefore keyed on the thread object, and lives in its own file with its own failure policy.

Without a policy file, every thread endpoint is open to anyone who can name a thread id — and ids are neither secret nor collision-proof (t- plus four random bytes). This page is how you close that.

The shape

src/thread-access.ts
import { defineThreadAccess, deny, permit, type ThreadAccessRequest } from "@dawn-ai/sdk"
import { principalOf } from "./auth.js" // shared with src/middleware.ts
 
const owned = async (req: ThreadAccessRequest) => {
  const user = await principalOf(req.headers)
  if (!user) return deny()
  // `thread: undefined` reaches `delete` too. Denying it FIRST, ahead of the
  // admin branch, is what keeps "not yours" and "does not exist" the same
  // answer; an admin allowed to delete a row that never existed reopens the
  // existence oracle this default closes.
  if (req.thread === undefined) return deny()
  const owner = req.thread.access?.ownerId
  if (owner === undefined) return user.isAdmin ? permit() : deny() // legacy thread
  if (owner === user.id) return permit()
  if (req.action === "read" && user.isAdmin) return permit()
  return deny()
}
 
export default defineThreadAccess({
  create: async (req) => {
    const user = await principalOf(req.headers)
    return user ? permit({ ownerId: user.id, org: user.org }) : deny()
  },
  // Also handles the post-create `update` recheck: the row just stamped has
  // `ownerId === user.id`, so `owned` permits it; a row the store handed back on
  // an id collision carries someone else's, so `owned` denies and the caller
  // never receives a thread they do not own.
  fallback: owned,
})

Dawn probes four paths, in order: src/thread-access.ts, src/thread-access.js, thread-access.ts, thread-access.js. The default export wins; a named threadAccess export is the fallback.

create-dawn-app scaffolds this file, and the src/auth.ts it imports, as src/thread-access.ts.example and src/auth.ts.example. Drop both .example suffixes to activate them — Dawn probes for the exact names above, so an unrenamed scaffold changes nothing. They ship inert because a deny-by-default policy denies every request from a caller the app cannot yet authenticate, and a generated app has no identity provider on its first run.

fallback is required. "I forgot to handle delete" is a compile error rather than a silent allow — or a silent deny — on every request of that action.

What the policy receives

FieldNotes
action"create", "read", "update" or "delete" — which handler was selected.
operationThe specific endpoint, e.g. "thread.state", for policies that need finer grain than action.
threadIdundefined only on POST /threads, whose id is server-generated.
threadThe stored row, or undefined when no row exists.
headersLowercase keys, repeated headers joined with ", ".
method, urlThe originating request's method, and its path plus query.
requestedMetadataClient-supplied metadata on a create, already stripped of Dawn's reserved key. undefined everywhere else.
resumingtrue when this request carries a resume credential and will continue a parked turn. Always a boolean. See below.

resuming: which requests continue a parked turn

resuming is true when the request answers a parked human-approval prompt — it carries an interruptId/resumeKey credential and will continue an already-interrupted run rather than start a fresh one. It is false on every other request, and it is never absent, so a policy writes if (req.resuming) and never ?? false.

Both doors resume. Two different endpoints continue a parked turn, and only one of them says so in its operation:

Requestoperationresuming
POST /threads/:thread_id/resumerun.resumealways true
POST /agui/{routeId} carrying a resume arrayrun.aguitrue
POST /agui/{routeId} with no resumerun.aguifalse
everything elsefalse

That middle row is the reason this field exists. An AG-UI resume reports run.agui, exactly as an ordinary AG-UI turn does — the only thing that distinguishes them is the request body, which a policy never sees. So a policy that wants resumes held to a higher bar — step-up auth, a second approver, extra logging — must check req.resuming, not req.operation. Keying that rule on operation === "run.resume" leaves every CopilotKit-driven resume ungoverned.

operation and resuming answer different questions and neither replaces the other: operation is endpoint identity ("which door did this come through"), resuming is request shape ("what is in the body").

An endpoint that gates more than once for a single request — the gate before its side effects, the mid-flight recheck, the implicit create's recheck — reports the same resuming at every one of them. One request, one value.

ts
import { defineThreadAccess, deny, permit } from "@dawn-ai/sdk"
 
export default defineThreadAccess({
  // ...
  update: (req) => {
    if (!owns(req)) return deny()
    // Resuming a parked approval is the moment the agent gets to act, so it
    // costs a fresh step-up — on BOTH doors, which `operation` alone cannot see.
    if (req.resuming && !hasStepUp(req.headers)) return deny({ status: 403 })
    return permit()
  },
  fallback: (req) => (owns(req) ? permit() : deny()),
})

thread.metadata is client-supplied and untrusted — anyone who can create a thread can write anything into it. Authorize against thread.access, which is the stamp your own create decision returned. Dawn stores it under a reserved key (dawn:access) that it strips from client input on every create path, so a client cannot forge one, and it is lifted out of metadata before your policy sees the row.

thread.access is undefined for a thread created before you adopted a policy. Dawn does not guess what that should mean — decide it explicitly. The two sane answers are admin-only (the owned example above) or a one-time backfill (below).

Comparing headers

Repeated headers arrive joined. X-User-Id: victim plus X-User-Id: attacker is the single string "victim, attacker". That is safe under === and unsafe under includes, startsWith or split(",") — which is exactly what a hand-rolled parser reaches for. Compare with strict equality, and prefer a signed token over a trusted header wherever the deployment allows it.

Denials

deny() produces 404 for a read, 403 for everything else.

EndpointDefault deny
POST /threads403 thread_access_denied
GET /threads/:thread_id404, the same body a genuine miss returns
GET /threads/:thread_id/state404, the same body a missing checkpoint returns
DELETE /threads/:thread_id403 thread_access_denied
POST /threads/:thread_id/cancel403 thread_access_denied
GET /threads/:thread_id/pending_interrupts404 thread_not_found, the same body a genuine miss returns
POST /threads/:thread_id/runs/stream403 thread_access_denied
POST /threads/:thread_id/runs/wait403 thread_access_denied
POST /threads/:thread_id/resume403 thread_access_denied
POST /agui/:routeId403 thread_access_denied

The read default is 404 so a denial cannot be told apart from a miss, which is what stops anyone enumerating thread ids. The 403s sit on endpoints where the caller has already named a specific thread and asked to change it.

deny({ status, body }) overrides both. status accepts only 403 or 404 — a policy cannot mint a 200, a 500 or a redirect — and anything else falls back to the per-action default.

The policy runs on every gated request, including when the row is missing. On /state that matters for a second reason: the checkpointer is a separate store from the threads store, so a transcript can exist for a thread whose row is gone, and skipping the gate would serve it ungated.

Failure modes

A policy that throws becomes a 500 and the endpoint's real work never runs. That is fail-closed and honest — a 403 would hide a broken policy behind what looks like a working one.

A policy that returns something that is neither a well-formed allow nor a well-formed deny (a missing return on one branch, a copy-pasted { action: "continue" }) denies at the per-action default and logs a warning naming the operation, the thread id and the value. It is deliberately not pinned to 403: forcing 403 on a read would make a broken policy answer differently from a working one and hand back the enumeration oracle.

A policy that hangs is not defended. Dawn imposes no timeout on a policy call, so a slow identity provider degrades into stuck requests. Put your own timeout around any network call in a policy and fail closed on it.

Load failures

Route middleware that fails to import degrades to "no middleware". An authorization policy must not: a syntax error, a missing dependency or a thrown environment assertion would boot the app with every thread world-writable and no log line. So the loader decides existence with a filesystem check before the import, and an import failure can then only mean "the policy is broken".

  • No policy file on disk — no gate, exactly today's behavior.
  • A policy file that fails to import — the boot fails with DAWN_E3003.
  • A policy file that imports but binds no usable policy — the boot fails with DAWN_E3003.

Four cases are distinguishable at a glance in the message: no default or threadAccess export; the bound value is not an object; fallback is missing or is not a function; a per-action key is present but is not a function.

There is no path on which a policy you wrote resolves to "allow everything". See Error codes.

Every boot logs which layer the policy came from, or that there is none — it is the one signal that says a policy vanished:

text
Dawn: thread access policy bound from src/thread-access.ts
Dawn: no thread access policy (all thread endpoints are open)

Build targets

dawn build --target langsmith fails with DAWN_E1005 while a policy file exists, and always will: that target materializes per-route graphs and no Dawn HTTP layer, so there is nowhere for the hook to run, and a build that silently dropped it would deploy every thread endpoint ungated. Put equivalent authorization at the LangSmith platform boundary instead.

hono and vercel carry the policy. They share one emitter, and the built manifest has a slot for it: the policy rides in as a static import, and the generated entry point records that the build saw one. If a manifest generated before the app grew a policy is later deployed beside a newer entry point, the boot fails rather than coming up silently ungated — there is no disk to probe on a bundled runtime, so nothing else would notice.

The node target needs nothing special; its emitted server reaches the same disk probe dawn dev does. An app with no policy file builds for every target exactly as before. See Deployment and Edge and Hono.

Adopting a policy on an existing app

Threads created before the policy have access === undefined. Either handle that branch (admin-only is the usual answer) or backfill.

Backfill is an operator script that constructs the threads store directly. Every in-runtime path to the reserved key is deliberately shut: there is no HTTP endpoint for metadata updates, and the runtime asserts that none of its own metadata patches carry the key.

scripts/backfill-thread-access.ts
import { THREAD_ACCESS_METADATA_KEY } from "@dawn-ai/sdk"
import { createThreadsStore } from "@dawn-ai/sqlite-storage"
 
const store = createThreadsStore({ path: ".dawn/threads.sqlite" })
for (const thread of await store.listThreads()) {
  if (thread.metadata[THREAD_ACCESS_METADATA_KEY] !== undefined) continue
  await store.updateMetadata(thread.thread_id, {
    [THREAD_ACCESS_METADATA_KEY]: { ownerId: "operator-assigned-owner" },
  })
}

This is in the same class as dawn inspect and dawn memory: a local operator with filesystem or database access, documented rather than defended.

Identifiers, never secrets

GET /threads/:thread_id returns the raw thread, reserved key included. That is deliberate — hiding it would break round-tripping and make the stamp undebuggable, and that endpoint is gated by the very policy the stamp feeds. Put identifiers in a stamp. Do not put anything in it whose disclosure to a caller your read policy admits would matter.

The run endpoints and middleware

The run endpoints — POST /threads/:thread_id/runs/stream, /runs/wait, /resume and POST /agui/{routeId} — plus GET /threads/:thread_id/pending_interrupts are on this policy as well as route middleware. The two compose as AND: middleware answers "may this caller run this route", the policy answers "may this caller touch this thread", and a request needs both. Neither replaces the other, so keep middleware doing the per-caller work it does today.

A run.* operation on a thread that exists arrives under action: "update" — starting a turn mutates the thread. Three of these endpoints (/runs/stream, /runs/wait, POST /agui/{routeId}) also create the thread when the id names no row, and Dawn asks about that under action: "create", then again as the update recheck that follows every create — the same two-step POST /threads uses. /resume is the exception: it needs an already-parked thread, creates nothing, and is only ever an update.

The stamp your create handler returns is written into the row, exactly as it is on POST /threads. So a thread born on a run endpoint has an owner from its first turn, and access === undefined keeps its one meaning: created before you adopted a policy.

GET /threads/:thread_id/pending_interrupts composes both checks as AND — the route that parked the interrupts must admit the caller and this policy must permit the read — and which one refused is deliberately not visible in the response: a thread-access deny returns the handler's own 404 thread_not_found, the same bytes a genuine miss returns, while a route-identity refusal returns whatever your middleware returns. Dawn logs neither, so log the denial inside your own policy if you need to tell them apart.