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
21 changes: 21 additions & 0 deletions .changeset/idempotent-write-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@understudy/protocol": minor
"@understudy/connector": minor
---

Idempotent write retries and a single write-classification source of truth.

- `@understudy/protocol` now exports `WRITE_COMMAND_TYPES` (and its
`WriteCommandType` union) as the one classification downstream layers derive
from, and reclassifies `scroll` / `switch_tab` as writes — so
`isWriteCommand` returns `true` for them. They are user-visible side effects:
a `dryRun` must simulate (not perform) them and an idempotent retry must
replay (not repeat) them, so a relative-`dy` `scroll` never double-scrolls.
No schema change.
- `@understudy/connector`'s `act` / `fillCredential` derive the wire
`commandId` from the breakwater idempotency key (`ik_<key>`) instead of a
random UUID, so a retry after a lost or unparseable response replays the
service's recorded write Event instead of executing the write twice.
Dry-runs keep random ids. The `act` union is now pinned at compile time to
the protocol's write class minus `fill_secret` (no divergence to reconcile,
now that `scroll`/`switch_tab` are protocol writes).
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
# v5/v6 majors run on the node24 action runtime; the v4 majors ran on
# node20, which GitHub now flags with a deprecation annotation.
- uses: actions/checkout@v5

# Reads the pnpm version from package.json "packageManager".
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
# v5/v6 majors run on the node24 action runtime (changesets/action@v1
# already does); the v4 majors ran on node20, which GitHub now flags
# with a deprecation annotation.
- uses: actions/checkout@v5
with:
# Changesets reads history to decide what changed since the last tag.
fetch-depth: 0

# Reads the pnpm version from package.json "packageManager".
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ dist/
# Cloudflare / Wrangler (added at M3)
.wrangler/
.dev.vars
# Operator-local secret backups (the deployed worker is canonical; these are
# recoverable copies of values Cloudflare never shows again). NEVER commit.
# Specific prefix so the tracked .dev.vars.example template is unaffected.
.secrets*

# WXT extension build (added at M2)
.wxt/
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ CALLER_TOKENS={"dev-caller-token":{"actor":"stub-consumer","tenantId":"dev-tenan
# prints (which embeds this token) into the M2 extension's connection
# settings.
EXTENSION_TOKENS={"dev-ext-token":"dev-tenant"}

# base64url-encoded 32-byte AES-256-GCM key that envelope-encrypts every
# vault value (src/vault.ts). This dev value decodes to the literal
# "dev-vault-master-key-0123456789!". Generate a real one with:
# node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
# Seed dev secrets with: node scripts/vault-put.mjs 'vault://ref' --local
VAULT_MASTER_KEY=ZGV2LXZhdWx0LW1hc3Rlci1rZXktMDEyMzQ1Njc4OSE
21 changes: 13 additions & 8 deletions apps/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,24 @@ Cloudflare Worker (Hono) + one Agents-SDK Durable Object per session (`SessionAg
| `tsconfig.json` | TS config (workers-types, bundler resolution, strict) | Adjusting compiler options |
| `vitest.config.ts` | Workers-pool test config (`@cloudflare/vitest-pool-workers`); imports `./test/tokens` | Adjusting test runner/pool config |
| `.dev.vars.example` | Template for local `wrangler dev` secrets; matches `stub-consumer.mjs` defaults | Setting up local dev |
| `src/index.ts` | Worker entry: Hono app (`/v1/sessions*`, `/health`) + `routeAgentRequest` for the session WS; re-exports `SessionAgent` | Adding/changing a route, changing auth order |
| `src/session.ts` | `SessionAgent` — the per-session Durable Object: WS auth, event routing, `dispatch`/`fillSecret` RPCs | Changing session lifecycle, dryRun behavior, fill_secret dispatch |
| `src/coordinator.ts` | `SessionCoordinator` — portable command↔event correlation interface, no Cloudflare imports | Understanding the portable seam, swapping the CF impl |
| `src/coordinator-cf.ts` | `CfSessionCoordinator` — CF impl: pending map + persisted awaiting-marker + hibernation reconciliation | Debugging a stuck/timed-out command, hibernation edge cases |
| `.secrets.production.env` | Operator-local backup of the DEPLOYED worker's secrets (gitignored via `.secrets*`; absent in a fresh clone; the worker is canonical) | Recovering/rotating prod secrets — see README "Secrets" |
| `src/index.ts` | Worker entry: Hono app (`/v1/sessions*`, `/health`) + `routeAgentRequest` with pre-accept WS/HTTP auth hooks (`gateAgentRequest`) + `DispatchOutcome`→status mapping; re-exports `SessionAgent` | Adding/changing a route, changing auth order, changing failure statuses |
| `src/session.ts` | `SessionAgent` — the per-session Durable Object: WS auth, event routing, `dispatch`/`fillSecret` RPCs (typed `DispatchOutcome`, write-replay cache) | Changing session lifecycle, dryRun behavior, fill_secret dispatch, idempotent replay |
| `src/coordinator.ts` | `SessionCoordinator` — portable command↔event correlation interface + failure-prefix constants, no Cloudflare imports | Understanding the portable seam, swapping the CF impl |
| `src/coordinator-cf.ts` | `CfSessionCoordinator` — CF impl: pending map (+ duplicate-in-flight guard) + persisted awaiting-marker + hibernation reconciliation | Debugging a stuck/timed-out command, hibernation edge cases |
| `src/auth.ts` | Caller bearer-token auth, sessionId mint/scope (HMAC), extension token verify | Changing auth, adding a token type, debugging 401/404 |
| `src/secrets.ts` | `resolveSecret` — vault lookup only, no dispatch | Changing the vault backend, debugging secret resolution failures |
| `src/types.ts` | Shared `Env`, `SessionState`, `SessionStatus`, `VaultBinding` | Adding a binding, changing DO state shape |
| `src/vault.ts` | AES-256-GCM envelope codec + `EncryptedKvVault`/`createVault` — KV holds ciphertext only | Changing the envelope format/key handling (mirror `scripts/vault-put.mjs`) |
| `src/base64url.ts` | base64url codec shared by auth.ts and vault.ts | Rarely — codec changes |
| `src/types.ts` | Shared `Env`, `SessionState` (incl. `completedWrites`), `SessionStatus`, `VaultBinding`, `DispatchOutcome` | Adding a binding, changing DO state shape, changing the RPC outcome union |
| `scripts/stub-consumer.mjs` | Throwaway Node runbook harness (not a workspace member) driving the API against a real extension | Running the attended M3 end-to-end verification |
| `test/service.test.ts` | Hono route tests: auth, tenant scoping, dryRun, fill_secret routing | Verifying/extending the command API |
| `test/session.test.ts` | `SessionAgent`/coordinator tests: WS auth, resolve correlation, timeout, hibernation-resume | Verifying/extending DO behavior |
| `scripts/vault-put.mjs` | Seeds one vault secret as an envelope via `wrangler kv key put` (plaintext from stdin; `--local` for dev) | Seeding/rotating vault values (never raw `kv key put`) |
| `test/service.test.ts` | Hono route tests: auth, tenant scoping, dryRun, fill_secret routing, pre-accept WS gate, idempotent write replay | Verifying/extending the command API |
| `test/session.test.ts` | `SessionAgent`/coordinator tests: in-DO WS auth (defense in depth), onClose stamping, resolve correlation, hibernation-resume | Verifying/extending DO behavior |
| `test/auth.test.ts` | Auth module unit tests | Verifying/extending auth.ts |
| `test/coordinator.test.ts` | Coordinator unit tests | Verifying/extending coordinator-cf.ts |
| `test/coordinator.test.ts` | Coordinator unit tests (timeout, duplicate guard, abandon, no-leak logging) | Verifying/extending coordinator-cf.ts |
| `test/secrets.test.ts` | Vault resolution unit tests | Verifying/extending secrets.ts |
| `test/vault.test.ts` | Envelope round-trip/tamper/wrong-key + `EncryptedKvVault` fail-closed tests | Verifying/extending vault.ts |
| `test/helpers.ts` | Workers-runtime test helpers (session stub, WS extraction) | Writing a new Workers-pool test |
| `test/tokens.ts` | Shared test-only token constants (used by vitest.config.ts and suites) | Adding a test caller/extension identity |
| `test/tsconfig.json` | Test typecheck project (extends root config, includes `test/**`) | Adjusting test typecheck scope |
Expand Down
Loading