Skip to content

Feature/security architecture#27

Open
jackm43 wants to merge 103 commits into
mainfrom
feature/security-architecture
Open

Feature/security architecture#27
jackm43 wants to merge 103 commits into
mainfrom
feature/security-architecture

Conversation

@jackm43

@jackm43 jackm43 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

No description provided.

jackm43 and others added 30 commits July 3, 2026 21:25
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a check workflow that runs npm run check and npm test on every
push and pull request. Disable remote bindings in the vitest pool so
the suite runs hermetically without Cloudflare credentials; the tests
already mock the AI binding, and the remote proxy session was the only
thing requiring auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pin actions/checkout and actions/setup-node to full commit SHAs so a
compromised tag cannot change what CI runs. Add Dependabot weekly
update checks for npm and github-actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /gateway/start and GET /gateway/health previously authenticated
with the raw Discord bot token, making the highest-privilege Discord
credential double as an API password. Authenticate with a dedicated
GATEWAY_CONTROL_TOKEN secret instead, failing closed with 401 when the
secret is unset. Operators must run
wrangler secret put GATEWAY_CONTROL_TOKEN and add the matching
1Password field used by deploy.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/bicture buffered model-returned image URLs with no size cap and no
timeout. Cap downloads at 25 MB via a content-length precheck and a
post-download byteLength check, mirroring ragjam's audio handling, and
abort the fetch after 30 seconds via AbortSignal.timeout. Add the same
timeout to ragjam's audio download, which had the cap but no timeout.
Violations throw so the existing error handling replies with the
failure message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every AI ingress (/ask, /bicture, /ragjam, gateway mentions, and
tracked-thread replies) now runs a shared pre-flight guard in
src/limits.ts before any model call or enqueue. It enforces an hourly
request limit (AI_RATE_LIMIT_PER_HOUR, default 20) via the new
rag_ai_requests table and a trailing-24h spend budget
(AI_DAILY_BUDGET_USD, default 1.00) summed from rag_ai_spend_events.
Spend events still pending cost reconciliation count as zero, and the
guard deliberately fails open on D1 errors: availability over
enforcement for a guild toy bot. The /rag command family stays
unlimited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete unused withTimeout and runChatModel from ai.ts, and unused
CONFIG_DEFAULTS/ConfigKey/isConfigKey from config.ts. Drop the ignored
_env parameter from loadConfig and update call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Share one interaction-based getInvokerDisplayName in rag-utils.ts across
ask/bicture/ragjam, move the duplicated errorDetails into logger.ts, and
export MAX_DISCORD_MESSAGE_LENGTH (1900) from types.ts. Ragjam's full
2000-char cap is renamed DISCORD_MESSAGE_HARD_LIMIT to keep the two
limits distinct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the triplicated ctx.waitUntil + editOriginalInteractionResponse
+ catch-and-apologise pattern in rag/ask/bicture with a single
handleDeferredInteraction helper in commands/deferred.ts. The helper
guards missing interaction credentials (rag keeps its synchronous
fallback), supports file attachments, and logs with per-command context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add runTrackedChatCompletion / runTrackedWebSearchCompletion in
tracked-ai.ts that create the spend source id, attach gateway metadata,
and record the spend event around each completion. Rewire the three
near-identical blocks in processAiQueueMessage, plus runAskCommand and
generateThreadTitle. Also inline the buildConversation wrapper, which
duplicated buildNormalThreadConversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a nullOnError option to discordJsonRequest and rebuild
fetchChannelMessages, fetchMessage, and fetchUsername on it instead of
hand-rolled fetch + ok-check + json parsing. postChannelMessage keeps
returning the raw Response since callers inspect status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the 10-branch if-chain in handleInteractionRequest with a
commandHandlers record keyed by command name, normalizing handler
signatures with thin arrow wrappers. discord.js is only imported by
scripts/register-commands.ts, never by the worker, so it belongs in
devDependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/mention.ts mixed mention parsing, thread persistence, conversation
building, and queue consumption. Split into four cohesive modules:

- src/threads.ts: the rag_ai_threads D1 store
- src/conversation.ts: conversation/context building and thread titles
- src/consumer.ts: AI queue message processing
- src/mention.ts: mention parsing and gateway entry only

getMessageAuthorDisplayName lives in mention.ts (not conversation.ts)
because handleGatewayMessageCreate needs it and conversation.ts imports
stripMentionTokens from mention.ts; keeping it there avoids a cycle.

Pure moves with mechanical import updates; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/index.test.ts (~3.2k lines) covered every area of the worker in
one file. Split into per-area suites:

- test/http-boundary.test.ts: routing, Discord signature verification,
  and gateway control auth (19 tests)
- test/commands-ai.test.ts: /ask, /bicture, /ragjam (13 tests)
- test/commands-rag.test.ts: the rag command family, /ragspend, and the
  spend worker (14 tests)
- test/gateway-mention.test.ts: mention parsing, gateway message
  handling, the AI queue consumer, and the gateway durable object
  (21 tests)
- test/limits.test.ts: the AI usage guard (11 tests)

Shared setup (request signing, mock env builder, DB mock) moves to
test/helpers.ts. Test bodies are unchanged; only imports were updated,
and the unused DiscordGateway import was dropped. Still 78 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every Cloudflare Queue message (AI jobs and spend jobs) now crosses the
wire as a Cap'n Proto EventEnvelope (src/contracts/envelope.capnp)
carrying version, type, id, occurredAt, source, actor, optional guildId,
and a union payload for thread_start/thread_reply/channel_reply/ragjam
and spend jobs. Serialisation uses capnp-es (pure-JS runtime, works in
workerd); the generated src/contracts/envelope.ts is committed so CI and
deploys never need the native capnp compiler. Regenerate with
`npm run contracts:build` (requires `capnp`, e.g. `brew install capnp`).

On top of the wire format, src/contracts/validate.ts enforces value
constraints Cap'n Proto cannot express: snowflake ids must match
/^\d{17,20}$/, free text (prompt/lyrics) is capped at 4000 chars,
usernames at 100. Validation runs at both encode (producer) and decode
(consumer) - zero trust between hops. isAiJob in validation.ts is
replaced by the stricter contracts decode path.

Decode failures return null; consumers log ai_job_invalid /
ai_spend_job_invalid and ack, mirroring the previous invalid-job path.
decode also pre-validates the capnp frame header before parsing because
capnp-es sizes its segment list from attacker-controlled bytes and can
be made to allocate unbounded memory otherwise.

Note: JSON messages already in flight during the deploy that ships this
will fail decode and be acked with a warn log - acceptable one-time
loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/ask no longer runs its whole AI pipeline inline in ctx.waitUntil. The
interaction handler now only: checks usage limits, derives the thread
title from the prompt (sanitizeThreadTitle fallback - no paid title
model call), creates the thread, records it in rag_ai_threads, edits
the deferred response with the thread link immediately, and enqueues an
"ask" job through the Cap'n Proto event envelope (new ask union member
in the schema; generated code regenerated).

The queue consumer handles the ask job: builds the ask conversation,
preserves the shouldUseAskWebSearch heuristic, runs the tracked
completion, sanitizes, posts into the thread, and records the
interaction like other kinds. If the model call fails, a failure notice
is posted into the thread.

thread_start jobs also use the prompt-derived title now.
generateThreadTitle and its tracked-title spend path are deleted - one
fewer model call per thread everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/rag previously ran a D1 batch (INSERT event + UPSERT totals) followed
by a separate SELECT of rag_count; /undorag did the same after its
DELETE + UPDATE batch. The upsert/update now carry RETURNING rag_count
and the count is read from the batch results, saving one D1 round trip
per command. Verified RETURNING works inside batch statements against
workerd's D1 in the vitest pool; the shared DB mock's batch() now
returns per-statement results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createBoundaryClient(policy) returns a fetch-shaped client that enforces
an egress policy before any network I/O: https-only, exact-host
allowlist (or wildcard for media), credential attachment so callers
never handle tokens, a default AbortSignal.timeout when the caller
passes no signal, and an optional response size cap enforced via
content-length precheck plus actual-byte verification while buffering.
Denials and failures are logged with a request context of
{identity, trustZone, method, host, outcome, status?} - never full URLs
with querystrings, and host only for egress-media.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/net/clients.ts builds per-identity boundary clients from Env,
memoized per Env and constructed lazily so a missing token only fails
the identity that needs it:

- discord-rest (discord.com, Bot token) carries discordJsonRequest,
  postChannelMessage, and fetchBotRoleIds; botHeaders is gone.
- discord-webhook (discord.com, no credential) carries
  editOriginalInteractionResponse, which now takes env for host policy
  and timeout despite needing no auth header.
- ai-gateway (gateway.ai.cloudflare.com, cf-aig-authorization) carries
  both REST paths in src/ai.ts.
- cloudflare-api (api.cloudflare.com, CLOUDFLARE_API_TOKEN) carries
  findGatewayLogCostMicros.
- media-download (any https host, no credential, 25MB cap, 30s timeout)
  carries bicture's image download and ragjam's audio download; their
  hand-rolled caps and timeouts are deleted. Ragjam keeps its
  null-fallback on oversize via the typed response_too_large violation
  and bicture keeps its throw behavior.

No module outside src/net/ reads DISCORD_BOT_TOKEN, CF_AIG_TOKEN, or
CLOUDFLARE_API_TOKEN anymore (the DO's gateway IDENTIFY payload and
Node-side scripts/ excepted), and no direct fetch remains outside
src/net/. This is the enforcement point where a future per-worker split
gives each worker only its own client and credential.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/bicture previously ran env.AI.run inline in the main worker's fetch path
via ctx.waitUntil. It now defers and enqueues an encoded bicture job like
/ragjam does, and the ai-jobs consumer generates the image, records the
spend event, and edits the original interaction response with the
attachment. Adds a BicturePayload kind to the Cap'n Proto envelope
(regenerated with contracts:build) with the same encode/validate rules as
ragjam. After this the main worker's fetch path does no AI work at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New ragbot-brain-worker (wrangler.brain.jsonc, src/brain-worker.ts) owns
the ai-jobs queue consumer with the same batch/retry/DLQ settings, plus
the D1, AI, and SPEND_JOBS producer bindings and the .md system-prompt
rules it needs. The main worker loses its queue() handler, ai-jobs
consumer, AI binding, and SPEND_JOBS producer (nothing in its fetch path
records spend any more); it keeps the AI_JOBS producer. CF_AIG_TOKEN now
belongs to the brain only; DISCORD_BOT_TOKEN is temporarily still needed
by the brain because it posts to Discord until a responder worker takes
over egress (documented in README). dev:all and deploy scripts include
the brain config, and the README documents per-worker secrets and the
one-time queue bootstrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New ragbot-responder-worker (src/responder-worker.ts) is the single
choke point for posting as the bot, with a deliberate hybrid transport:

- Text-only replies (channel/thread posts, text interaction edits) go
  through a new durable discord-outbox queue as encoded/validated
  envelopes (reply.channel_message, reply.interaction_edit). Queue
  messages cap at 128 KiB, which is why media does not go here.
- Media-bearing interaction edits (bicture image, ragjam audio) go over
  a service-binding RPC (Responder WorkerEntrypoint) taking the encoded
  envelope plus the attachment bytes: direct worker-to-worker, no
  network exposure, and a retry would regenerate the media anyway.

The responder is the only worker whose Discord credential can write and
the only place final output policy runs: brains ship raw model text and
the responder applies sanitizeAiText, the MAX_DISCORD_MESSAGE_LENGTH
cap, and allowed_mentions {parse: []} on egress.
rag_ai_interactions.response_text still records the sanitized text (the
brain computes the same pure policy function for the record); status
'ok' now means handed to the outbox.

The brain keeps DISCORD_BOT_TOKEN for read-only REST (thread history,
replied-to context, bot roles) and thread creation — documented honestly
in the README trust-zone table. The main worker's deferred /rag and
/ask interaction edits keep the scoped, short-lived webhook path; this
boundary is documented too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
handleGatewayMessageCreate (running inside the DiscordGateway DO) used to
do a D1 thread lookup, a Discord REST role fetch, the usage-limits check,
and denial posting. All of that moves to the brain:

- DO path: any non-bot MESSAGE_CREATE that passes isDiscordMessage is
  encoded as a new message.received contracts kind (ids, length-capped
  content, author, mentions, mention_roles, reply metadata, guild id) and
  enqueued to ai-jobs. The only pre-filter is pure and local: both reply
  paths require a non-empty prompt after mention stripping, so those are
  skipped. Thread tracking lives in D1, which the DO deliberately cannot
  see, so everything else is enqueued and the brain filters. This trades
  queue volume for isolation — acceptable for a single small guild.
- Brain: on message.received it does the thread lookup, bot-role fetch,
  mention resolution, and limits check, then processes the resolved
  thread_reply/channel_reply in-process (no re-enqueue). Denial notices
  go out via the discord-outbox.
- The DO keeps only WebSocket lifecycle + IDENTIFY, payload validation,
  and encode+enqueue; it no longer uses D1 or Discord REST. It stays
  hosted in the main worker: moving a DO class between scripts needs a
  risky transfer migration, documented in the README as a possible
  future step (deliberate deviation from RECOMMENDATIONS.md section 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each worker now lives in its own folder as a deployable identity,
split by trust layer: workers/public/gateway (Discord ingress +
DiscordGateway DO) and workers/services/{brain,responder,spend}.
Worker entrypoints are thin — they only wire fetch/queue/RPC handlers
to package functions.

Business logic moves under packages/:
- contracts: queue envelopes, schema, validation, shared types
- net: boundary client + client factories
- discord: Discord REST module
- ai: model calls, tracked AI, prompts/config, ask mode, spend
- domain: commands, conversation, consumer, mention, threads,
  limits, outbox, responder delivery, http verification
- logger: structured logging (leaf, used by net and everything above)

types/validation live in contracts and spend lives in ai so the
package graph stays acyclic (contracts <- net <- discord/ai <- domain
<- workers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror the workers/packages layout under test/: worker-boundary and
command-flow tests live in test/workers/, package-level tests in
test/packages/ (contracts, net/boundary-client, domain/limits).
Pure moves plus import-path fixes; still 114 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace per-handler boilerplate in the gateway's interaction handling
with declarative command specs in packages/domain/commands:

- context.ts: builds a CommandContext (invoker, displayName, channel,
  guild, interaction credentials, options) once at the boundary.
- registry.ts: CommandSpec type plus a shared pre-flight chain that
  runs option validation -> interaction credentials -> admin check ->
  usage limits, then dispatches by spec kind (enqueue / inline /
  deferred-inline).
- specs.ts: the whole command surface as data; /bicture and /ragjam
  are pure buildJob enqueue specs, while /ask (REST thread creation
  before enqueue) and /rag (ban logic + deferred DB flow) keep run
  escape hatches on the shared context.
- router.ts: looks up the spec and hands off to the registry.

On the consumer side the if/else job-kind chain becomes a
Record<kind, processor> map with one processor per AiJob kind.

Behavior is unchanged; adds registry-level tests for unknown command,
missing required option, admin denial, and missing interaction
credentials (114 -> 118 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-user hourly cap and per-user daily budget throttled legitimate
heavy use, which the owner does not care about on a single friends-only
server. Replace them with abuse-shaped guards: a per-user burst limit
(default 8 requests per trailing minute, AI_BURST_LIMIT_PER_MINUTE) that
catches floods and scripted spam while staying generous for humans, and
a global trailing-24h budget across all users (default $10,
AI_GLOBAL_DAILY_BUDGET_USD) as the wallet backstop if any account is
compromised. Burst counting reuses rag_ai_requests; the global budget
sums rag_ai_spend_events with no user filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ALLOWED_GUILD_IDS (comma-separated guild snowflakes) now gates every
ingress via packages/domain/guilds.ts: interactions get a friendly
"home server" refusal (PING exempt for endpoint verification), the
gateway Durable Object drops MESSAGE_CREATE events from non-allowed
guilds and DMs before enqueueing, and the brain repeats the check on
message.received as defense in depth between queue hops.

When set, the gate fails closed — non-snowflake entries are dropped so
a misconfigured value denies rather than allows. When unset, the gate
allows but logs a warning once per isolate, so existing deploys keep
working until the var is configured; set it to enforce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rag_command_bans previously only gated /rag. Move the active-ban lookup
into a shared packages/domain/bans.ts and wire it into the registry
pre-flight chain for every AI command (/ask, /bicture, /ragjam) and
into the brain's message.received path, so mentions and tracked-thread
replies from banned users are ignored (no notice on the gateway path).
Slash-command denials mirror /rag's message: "You cannot use AI
commands until <t:...>." The AI-path lookup fails open on D1 errors,
matching the usage guard's availability-first stance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Schema changes now flow through migrations/ applied with `wrangler d1
migrations apply`: 0001_initial.sql captures the full current schema
with IF NOT EXISTS statements throughout, so the first apply against
the existing production database is a safe no-op. The d1 scripts and
deploy.sh (via npm run d1:migrate:remote) switch from the old
`d1 execute --file=schema.sql` flow, all three DB-owning wrangler
configs point migrations_dir at the shared directory, and schema.sql
stays as a reference-only mirror.

Migration-strategy decision: no 0002 ALTER TABLE for the
rag_ai_interactions token-usage columns. SQLite has no ADD COLUMN IF
NOT EXISTS, the prod table may or may not already have the columns
(unknowable from the repo), and a recreate-and-copy would lose token
values if they exist — either variant could strand deploys or drop
data. recordAiInteraction therefore keeps its dual-INSERT fallback,
now documented, until prod's shape is verified by hand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jackm43 and others added 30 commits July 3, 2026 21:25
Introduce the connectors MachinePrincipal (application zone, single
registered service operation connector.invoke, verify-only receiver with a
committed keyring entry) and the connector.invoke event envelope carrying the
uniform phantom-token operations (grant / fetch / token / introspect / 3LO
begin+complete). Adds ConnectorInvokeJob wire type + total validation, capnp
struct + regenerated module, the fail-closed ConnectorResult surface, the
CONNECTORS + CONNECTOR_STORE bindings and GitHub App secrets on Env, and the
egress-connector boundary trust zone. No behaviour yet — machinery only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The core of packages/connectors: a uniform grant -> handle -> use model in
which a real provider credential NEVER leaves the broker. A caller (identity
token verified + Cedar-authorized) calls grant to exchange its identity for an
opaque, caller-bound handle; fetch/token/introspect present that handle, which
is re-verified against the caller on every use, re-authorized by Cedar, and
resolves the credential server-side. authorizedFetch injects it and calls the
provider through a per-connector host-allowlisted boundary client; every use is
audit-logged with the full actor chain.

Includes the four strategies (api_key, oauth2_client_credentials,
oauth2_authorization_code 3LO seam, github_app reference impl with RS256 App-JWT
minting supporting PKCS#8 and PKCS#1 keys), the declarative connector registry,
the DO-backed grant + encrypted 3LO token stores with an in-memory test double,
the per-isolate access-token cache, Cedar connector.* policies + brain->broker
service-hop bootstrap permits, and the caller-side connectorsClient helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The credential broker worker: a thin Connectors WorkerEntrypoint whose invoke
method registers the manifest and delegates to handleConnectorInvoke, plus the
ConnectorStore Durable Object backing the grant/token store. No route, no queue,
no D1 — reachable only over the CONNECTORS service binding, so no internet-facing
worker owns provider credentials. wrangler.jsonc binds SERVICE_REGISTRY (from
ragbot-registry-worker) and the in-worker ConnectorStore DO, bundles the .cedar
policies, and documents the GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY /
SERVICE_PUBLIC_KEYS / CONNECTORS_TOKEN_ENC_KEY secrets. Dry-run clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
github-jwt.test.ts proves the App JWT is a real RS256 signature verifiable by
the matching public key, with correct short-lived claims, and that BOTH PKCS#1
(wrapping path) and PKCS#8 (native) private keys import and sign. handler.test.ts
proves the gate fails closed at every step: a token not addressed to the broker
and a forged signature are rejected (401), a Cedar-unauthorized operation is
refused (403) before any credential runs, an unknown connector is 404, and a
handle presented by a service other than the one it was issued to is rejected
(the phantom-token binding). A full grant -> authorizedFetch proves the
credential is injected server-side, response headers are filtered (no Set-Cookie
leak), the handle response carries no secret, and every use is audit-logged with
the actor chain. +8 tests (216 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CONNECTORS.md documents the phantom-token model as the core pattern, the four
kinds, how authn+authz apply to every operation, the grant/use API + audit entry
shape, a step-by-step how-to-add-a-new-connector (api_key + oauth2
client-credentials examples) and how-to-add-a-caller, and the operator steps to
make the GitHub App connector live (create App, generate key, install, set
secrets, which worker binds the broker). Notes the Curity split-token variant
and the Cloudflare Secrets Store upgrade path. STATUS.md gains a
packages/connectors entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design-first (per decision): broker as the single inter-service token issuer via
an exchangeServiceToken RPC, sender-side Cedar decision + token caching, receivers
verify a cached broker JWKS, phased dual-verify migration from per-worker signing.
No live changes until approved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The seam through which the credential broker resolves every provider
credential: a SecretsProvider interface (get/optional set), a
secretsProvider(env, name) factory selecting a backend, and four
backends — wrangler-env (default), cloudflare-secret-store,
hashicorp-vault (KV v2 over a boundary client), and onepassword
(1Password Connect, since the Node-only SDK cannot run on workerd).
All fail closed: missing/unreachable -> null.

Wires SECRETS_STORE / VAULT_* / OP_CONNECT_* structurally onto Env and
adds egress-vault / egress-onepassword trust zones. Focused tests cover
provider selection, wrangler-env fallback, and per-backend fail-closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-worker Ed25519 signing is retained for service-to-service authentication
(distributed blast radius, no broker on the hot path, already live). The broker
is NOT made the central token issuer. The single-pattern goal is met at the
authorization layer (Cedar authorizes every hop and every connector op). Adopted
instead: built-in per-worker key rotation. Broker stays the authority for
external provider credentials only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reorganise the credential broker so provider-specific code lives in ONE
cohesive file per provider (packages/connectors/providers/<name>.ts),
each declaring the kinds/flows it supports behind the strategy interface:
  - github.ts   — github_app (JWT crypto + installation exchange + inject)
  - oauth2.ts   — oauth2_client_credentials + oauth2_authorization_code
  - api-key.ts  — api_key
The generic broker infra (handler, registry, strategy table, grant store,
Cedar gate, audit) stays at the package top level and calls into providers;
providers never touch identity/Cedar.

Connectors now resolve their credential through the secrets-provider
module: a registry entry carries a {provider, ref} secret reference and
the strategy calls secretsProvider(env, ref.provider).get(ref.ref). The
github-app entry defaults to {provider:"wrangler-env", ref:"..."} so
behaviour is unchanged. A null resolution fails closed (the secret-
resolution gate); added a handler test proving grant denies (500) when the
secret is unprovisioned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update CONNECTORS.md for the new packages/connectors/providers/ layout
(one cohesive file per provider, generic infra apart at the top level),
the {provider, ref} secret-reference model and how a new connector is
added, and the four secrets backends. Records the 1Password spike result:
the official @1password/sdk cannot run on workerd (its sdk-core loads a
~10MB WASM synchronously from disk via fs.readFileSync at module load),
so the onepassword provider is implemented against 1Password Connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rospection

The connectors admin surface needs to know, per backend, whether a secret can
be written at runtime and whether the backend is configured at all — without
ever handling a secret value here.

- onepassword: implement `set` via the Connect item-patch API, reusing the
  vault->item resolve walk. Replaces an existing field's value or appends a new
  CONCEALED field; throws when the vault/item cannot be resolved (a runtime
  write never conjures them). 1Password is now a runtime-writable backend.
- Add optional `configured()` to the SecretsProvider interface and implement it
  for cloudflare-secret-store, hashicorp-vault, and onepassword (wrangler-env is
  always configured — it is just `env`).
- Add `describeSecretsProviders(env)` -> [{name, writable, configured}], the
  single source the admin surface reads. `writable` is presence of `set`
  (cloudflare-secret-store stays read-only at runtime; wrangler-env is
  deploy-time only), `configured` is the backend's env/binding presence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds four Cedar-gated, audit-logged management ops to the credential broker,
carried on the existing connector.invoke envelope (no wire-schema change) and
running the SAME fail-closed pipeline as grant/fetch — authenticated identity
token, then a per-op connector.admin.* Cedar gate, then audit. No management op
ever returns a secret value.

- ConnectorOperation gains admin_list / admin_describe / admin_set_secret /
  admin_providers; ConnectorResult gains the matching secret-free result bodies
  (summaries, detail, provider capability, set-secret outcome). Validator
  accepts them (broker-wide ops bear no locator; describe/set-secret bear a
  connectorId).
- store.ts: a ConnectorConfigStore persists an admin-set {provider, ref}
  override (a reference, never a value). The registry is immutable code, so the
  handler overlays the override on EVERY credential-resolving path (grant/fetch/
  token/authorize) — the change takes effect and survives.
- handler.ts: dev-proxy added as the admin caller (crypto allowlist); the admin
  ops are gated by connector.admin.* against Connector::<id> (a "*" sentinel for
  broker-wide list/providers). setConnectorSecret surfaces the per-backend
  runtime write-capability HONESTLY: written (vault/1Password), provision_required
  (cloudflare-secret-store, re-pointed but not written), rejected (wrangler-env
  with a value — deploy-time only). Never fakes success; never logs the value.
- connectorsClient gains listConnectors/describeConnector/setConnectorSecret/
  getSecretsProviders.
- Cedar: dev-proxy connector.admin.* permits (connectors.cedar) + the
  dev-proxy -> connectors service hop (services.cedar), the broker's second caller.

Focused tests: the admin authz gate (dev-proxy allowed, brain denied) and the
full set-secret write-capability matrix. 230 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dev-proxy becomes the connectors broker's admin caller, mirroring exactly
how it drives the gateway command path.

- Add the devProxyToConnectors service client (edge->application, same
  DEV_PROXY_SIGNING_KEY) and the `connectors` manifest target; bind CONNECTORS
  (-> ragbot-connectors-worker#Connectors) on the dev-proxy wrangler config.
- Factor the layered-auth gate (Better Auth session + Access-identity binding)
  out of handleCommand into a shared authenticateSession, so the admin endpoints
  authenticate IDENTICALLY to /api/command — one gate, no drift.
- /api/connectors (GET), /api/connectors/{id} (GET), /api/connectors/{id}/secret
  (PUT, zod-validated), /api/secrets/providers (GET): each mints an on-behalf-of
  token (sub = the acting Discord admin) and invokes the broker over the binding.
  The secret value flows inward only and is never returned; set-secret maps the
  broker outcome to HTTP (written/referenced 200, provision_required 202,
  rejected 409).
- Reserve POST /api/connectors/{id}/grant, GET /api/connectors/{id}/installations,
  GET|POST /api/connectors/{id}/callback as documented-but-501 so the contract is
  stable ahead of the later grant/installations/callback work.
- Document it all in openapi.yaml and regenerate api-types; the PUT body is
  compile-linked to the SetConnectorSecretRequest schema via `satisfies`.

npm run check clean, 230 tests pass, both configs dry-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the single-page admin UI with a "Connectors" section shown after
sign-in, in the existing minimal vanilla-JS style (no external assets).

- Lists each connector with its kind/host, a configured/no-secret badge, the
  resolving backend, and its flows (GET /api/connectors + /api/secrets/providers).
- Per connector: a form to choose a secrets backend (non-writable backends are
  disabled for value entry and labelled "read-only at runtime" / "unconfigured"),
  enter a reference/locator, and enter/rotate a secret value, PUT to
  /api/connectors/{id}/secret with credentials:"include".
- Secret values are write-only: the field is cleared on submit and never
  displayed; the result shows only the broker's outcome (written / referenced /
  provision_required / rejected) and its detail message, and refreshes the badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CONNECTORS.md: a new "admin surface" section — the connector.admin.* ops and
  their Cedar actions, the config-store override that makes a set-secret survive,
  the per-backend set-secret capability matrix (written / provision_required /
  rejected), the /api/connectors/* + /api/secrets/providers HTTP API and its
  reserved 501 paths, the callback (ragbot-dev.jsmunro.me/api/connectors/{id}/
  callback) vs webhook (ragbot.jsmunro.me/webhooks/{id}) URL conventions, and the
  operator bind/deploy steps. Updated the SecretsProvider snippet + backends
  table: onepassword is now runtime-writable, set presence is the write signal.
- STATUS.md: refreshed the test count (230/26 files), and noted the broker admin
  surface + the dev-proxy as its admin caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Webhooks now converge on a dedicated ingress worker at webhooks.jsmunro.me
  with provider-prefixed routes /{provider}/{id} (e.g. /github/{id}), off the
  Discord-interaction gateway. Updated the CONNECTORS.md URL convention.
- Add TODO.md capturing the webhook-ingress design (own subdomain, broker-side
  connector.webhook.verify signature check, validate -> enqueue -> brain,
  idempotency/replay) plus the remaining admin-surface work (reserved 501
  endpoints, 3LO/Discord, bind CONNECTORS on the brain) and the separately
  tracked tasks (brain rename, Task 12 remainder, dev-proxy hardening).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…transport

Make the deployed refactor work end-to-end (/rag, /ask, @mentions):

- Processor: InteractionSession Durable Object (workflows worker) runs deferred
  commands and edits the interaction response, where EGRESS + WORKFLOWS_SIGNING_KEY
  live; the gateway ingress verifies the Discord signature, returns the type-5
  ack, and kicks the DO over a cross-script binding.
- Trusted transport: capability-gated binding/queue hops carry claims-only
  (unsigned) tokens read without signature verification -- the binding graph is
  the authentication. mintClaims / decodeIdentityClaims + a "trusted" client and
  server mode, trusted-by-default for binding/queue; http still verifies. Removes
  the internal signing-key dependency across every service hop.
- Gateway: only the current socket processes events and MESSAGE_CREATE is deduped
  by id, so a mention never double-replies.
- deploy.ts: workflows before gateway, attest before registry (cross-script DO
  deps). Egress trust tests retargeted to the http path + broker caller allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Ed25519 interaction-signature verification into @rag/ingress/discord as
verifyDiscordSignature (returns the parsed body or null), so the platform webhook
ingress can verify Discord without importing bot code — breaking the
connectors -> bot dependency cycle ahead of folding Discord interactions into
webhooks.jsmunro.me. The bot's verifyDiscordRequest now wraps it with its own
interaction-shape validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssor (Phase 2)

Route Discord interaction callbacks through the neutral webhooks ingress
(webhooks.jsmunro.me/{clientId}/interactions): verify the Ed25519 signature by
client id, answer PING with type-1, else return the type-5 ack and kick the
InteractionSession DO — carrying no bot domain code. The DO's new run() owns the
FULL command dispatch, all-deferred: every outcome (inline reply, Cedar/limit/
option/guild/unknown rejection) becomes an edit of the deferred reply as
workflows. Enqueue commands run their job processor inline (workflows has no
AI_JOBS producer). Shared authorizeAndLimit keeps executeCommand (legacy gateway
path) and the new path on one authorization authority.

Wire DISCORD_INTERACTION_PUBLIC_KEYS (ragbot app id -> public key) + the
cross-script INTERACTION_SESSION binding on the webhooks worker. Gateway /discord
stays as fallback until the Discord portal URL is repointed.

check green (acyclic); 276 tests incl. dispatch conversions + ingress
PING/type-5/bad-sig/unknown-app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 3)

Add packages/service-kit/identity/act-as-token.ts: mintActAs/verifyActAs plus
buildActAsContext, a 60s envelope-bound EdDSA JWS proving one application may act
as another. It names applications by open string ids (Discord/GitHub client ids,
connector ids) rather than widening the closed MachinePrincipal union or its
exhaustive policy maps — a parallel ActAsContext beside IdentityContext. Reuses
token.ts's crypto/codec helpers (envelopeSha256, b64url*, EdDSA sign/verify),
now exported, so there is one signing/parsing implementation.

Tests: round-trip with envelope binding + 60s window; refused off its envelope,
past expiry, at the wrong audience, with an unknown/pinned-mismatch issuer, and
under a foreign key.

check green (acyclic); 283 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the mention double-reply at its root. The gateway held the mention
idempotency guard as a 60s in-DO TTL, but the Discord websocket lives in a
Durable Object and reconnects regularly (evictions, heartbeat misses, op 7/9);
Discord's resume is at-least-once, so a MESSAGE_CREATE redelivered more than 60s
after first receipt slipped past the window and replied twice.

Move idempotency to the right layer: handleGatewayMessageCreate now kicks the
InteractionSession DO keyed by idFromName(messageId) instead of enqueuing to
AI_JOBS. The DO's claim() is a durable per-message guard (15-min TTL), so a
redelivered mention addresses the same DO and is dropped however long after the
first — regardless of reconnect churn. runMention resolves + replies via the
shared processMessageReceivedJob. Mentions and slash-commands now share one
processor DO; the gateway's 60s firstSeenMessage dedupe + sweep are removed.

The ai-jobs message.received consumer is retained to drain in-flight jobs from
the old path. check green; 283 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Discord now delivers interaction callbacks to the webhooks worker
(webhooks.jsmunro.me/{clientId}/interactions), verified and tested end-to-end,
so the gateway's /discord route is dead traffic. Remove it from the route
bindings (application-bindings.ts, regenerated routes.ts/openapi.ts/openapi.yaml),
drop the discordSignature security scheme + discordInteractionGuard wiring, and
delete the opportunistic HTTP wake-up (interactions no longer hit the gateway;
the cron + the DiscordGateway DO watchdog alarm keep the socket up). The gateway
keeps the DiscordGateway DO, /gateway/start|stop|health, and the mention ws path.

Tests: the command dispatch, ban/limit denial, guild, and signature-boundary
behaviours that were asserted through the retired /discord entry are covered on
the all-deferred processor path (session-dispatch) and the neutral ingress
(webhooks-interactions); the obsolete gateway-entry tests are removed and the
key content assertions (undorag, ragspendboard, ban/limit messages) re-added on
the new path. check green; 248 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ApplicationAuthority, a Durable Object addressed by idFromName(appId) —
one instance per registered application, that application's OIDC-style issuer.
It owns the member services allowed to act as the application (a Cedar-gated
set kept in the DO's own datastore, not enumerated in code) and its signing
key, and mints short-lived, envelope-bound act-as tokens. It returns only
tokens and public keys; the private key never leaves the DO.

Self-hosted rather than a Cloudflare-managed OAuth flow because act-as is
machine-to-machine (a service acting as an application, no human): every
CF-managed OAuth path — Access-for-SaaS OIDC, workers-oauth-provider, service
tokens — is built around a human authorization_code consent flow and offers no
client_credentials grant. So the authority signs the assertion itself
(identity/act-as-token.ts) and publishes its verifying key via jwks() so any
verifier resolves it with off-the-shelf JWKS tooling.

Kept the ApplicationRegistry directory singleton untouched (its list()
enumerates every app) — a parallel authority DO sidesteps re-keying the
directory to idFromName(appId) and the row backfill that would entail. This is
purely additive: no existing trusted binding/queue hop gains any verification.

- authority.ts: configure/addMember/removeMember/get/jwks + mint(intent,
  envelopeSha256), which Cedar-gates the member then signs the assertion.
- services.cedar: service.act-as action, permitted when
  resource.members.contains(principal); members supplied as entities by the DO
  at mint time. No hard-coded pairs, mirroring invoke/exchange.
- keyring.ts: actAsResolverFromEnv (string-issuer resolver over
  APPLICATION_PUBLIC_KEYS) + applicationPublicJwk publisher.
- act-as-token.ts: buildActAsContext accepts a precomputed envelopeSha256, so
  the authority mints from the hash alone — the payload never reaches it.
- registry worker: APPLICATION_AUTHORITY DO binding + migration v3;
  APPLICATION_SIGNING_KEYS / APPLICATION_PUBLIC_KEYS env types; DO exported
  through both barrels.

check green (acyclic); identity suite green. No new tests this pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the manually-provisioned APPLICATION_SIGNING_KEYS secret path. The
ApplicationAuthority DO now generates its own Ed25519 keypair on registration
(configure/first mint) via crypto.subtle.generateKey and persists it to its
durable storage; the private half never leaves the DO. Nothing is ever
hand-created or committed.

Verifiers resolve an issuer's key at runtime instead of from a keyring:
actAsResolverFromAuthority fetches the issuer application's public JWKS from its
authority DO over the binding, cached per isolate. The isolate is short-lived
(it dies when the worker's job finishes), so the cache cannot outlive a
rotation. APPLICATION_PUBLIC_KEYS remains only as an optional static override
(tests, pinning); normally unset.

- authority.ts: ensureSigningMaterial() generate-once-then-read from DO storage
  (single-threaded per instance, so race-free); jwks() serves the stored public
  key; mint() and configure() drive generation.
- keyring.ts: actAsResolverFromEnv -> actAsResolverFromAuthority (runtime JWKS
  fetch over a structural APPLICATION_AUTHORITY binding double, env override kept).
- contracts: drop APPLICATION_SIGNING_KEYS; APPLICATION_PUBLIC_KEYS now optional.

check green (acyclic); identity suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Phase 3)

Reshape the authority's mint gate from a manually-granted members set to an
authorized-registration model grounded in attestation. A client becomes able to
act as an application only through register(), which verifies the client's
artifact carries a production attestation (a local match against the
AttestationStore — no egress, per the pushed-and-stored attestation path) before
recording it. So "who may act as this application" is exactly "what the repo,
through CI attestation, says may" — not self-assertion.

act-as and on-behalf-of are one primitive: an authorized mint request that
differs only in the subject it carries. mint() gains an optional subject set as
the token `sub` (the application itself when omitted); every mint still runs the
Cedar service.act-as check, now driven by the registered (attested) clients.

- authority.ts: register(request) attestation-gated via verifyArtifactAttestation;
  ClientRegistration record replaces the manual member; configure() no longer
  seeds members (rights come only through register); mint() carries subject.
- removeMember kept for revocation; addMember removed.
- contracts: APPLICATION_AUTHORITY binding stub gains register(), drops addMember.

check green (acyclic); identity suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ase 3)

Make act-as live on a real boundary: a sender can mint and attach an act-as
token, and a receiver can verify it — both strictly opt-in, so no ordinary hop
changes behaviour.

Wire: ServiceMessage gains a backward-compatible actAsToken @2 :Text field
(regenerated service.ts; old senders omit it, receivers that don't opt in ignore
it). encode/decodeServiceMessage + wrapServiceMessage/parseServiceMessage thread
it as optional.

Send (client.ts): a new actAs option on createClient().to({ actAs: { appId,
subject? } }) asks the APPLICATION_AUTHORITY DO (structural binding double, kept
app-agnostic) to mint an envelope-bound token proving self may act as appId, and
carries it beside the identity token. Fails the hop closed if the authority is
unbound or refuses. subject sets on-behalf-of.

Receive (server.ts): verifyActAs/requireActAs config verifies a carried token
against the issuer application's JWKS (actAsResolverFromAuthority — runtime
fetch), checking aud == self, expiry, and envelope binding, then attaches the
verified ActAsContext to RequestContext.actAs; a present-but-invalid token
denies. Absent by default and ignored unless opted in.

check green (acyclic); 248 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant