npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@codefusion-cc/console

v0.31.0

Published

Connect a Cloudflare Workers app to CodeFusion Console: admin screens from a JSON manifest, staff, visit statistics, browser failures, diagnostics and its deployed version; and the codefusion-console command line

Readme

@codefusion-cc/console

Connects a Cloudflare Workers app to CodeFusion Console. The app keeps no console code of its own: it declares its admin screens in a JSON manifest, and this package serves them and covers the rest of what the console monitors.

| The console's page | What the app provides | |---|---| | Resources (lists, records, actions) | manifest.json and a handler per resource and action (consoleAdmin) | | Failures, Health, Logs | Its Workers as the console's tail consumer. Browser errors come through connectBrowser and createConsoleRoutes | | Logs saved searches, endpoint checks | diagnostics in the manifest, and log lines written with diagnosticLog | | Secrets (which are missing, new values, Realtime and R2 credentials) | secrets in the manifest: each Worker secret it needs and how a value is made | | Visits | connectBrowser on its pages and createConsoleRoutes in its Worker | | Calls (WebRTC on Cloudflare Realtime) | createMediaSampler on each call connection and recordMediaReport where its server hears from it | | Deployments | /version.json from versionFile() in its Vite build; deployed by GitHub Actions, a deploy job that names its environment: (the console's docs/TELEMETRY.md) | | Staff inside the app | staffDirectory for roles, invites, the team, visits, the audit log and the month's Realtime budget |

Install

npm install @codefusion-cc/console

The manifest

manifest.json describes the app's resources, their fields, lists, filters, record pages and actions, plus optional diagnostics and secrets. The console's docs/MANIFEST.md documents the format. For editor completion:

{ "$schema": "../node_modules/@codefusion-cc/console/app-manifest.schema.json", "manifestVersion": 1, "app": { "id": "myapp", "name": "My app" }, "resources": [] }

parseManifest(value) from @codefusion-cc/console validates a manifest in code.

The Worker

// worker/index.js
import { consoleAdmin, createConsoleRoutes } from '@codefusion-cc/console/worker'
import manifest from './console/manifest.json' with { type: 'json' }

// What the console reads and moderates the app through. Each handler gets { env, actor }; actions also get
// { ids, input, reason }. The manifest's permission, scope and requireReason are checked before a handler runs.
export const ConsoleAdmin = consoleAdmin({
  manifest,
  resources: {
    users: {
      list: (query, { env }) => listUsers(env, query), // { items, nextCursor?, total? }
      get: (id, { env }) => getUser(env, id), // a record, or null
      actions: { ban: ({ env, ids, reason }) => banUsers(env, ids, reason) }, // { ok, message? }
    },
  },
})

// POST /api/visits and /api/browser-failures, from the app's own pages.
const consoleRoutes = createConsoleRoutes({
  appId: 'myapp',
  pages: ['home', 'settings'], // the page names the browser side reports
  bindings: { visitorSecret: 'VISITOR_HASH_SECRET' }, // optional: count consenting visitors apart
})

export default {
  async fetch(request, env, ctx) {
    const answer = await consoleRoutes.fetch(request, env, ctx) // null for any other path
    if (answer) return answer
    // …the app's own routes
  },
}

Both routes accept only the page's own origin by default; pass the app's own check as the fourth argument (consoleRoutes.fetch(request, env, ctx, origin => allowed.has(origin))). They answer 204 whatever happens, and do nothing in a deployment missing the binding they need.

Staff inside the app, and log lines for the Logs page's saved searches:

import { diagnosticLog, staffDirectory } from '@codefusion-cc/console/worker'

const staff = staffDirectory(env, { appId: 'myapp' })
const roles = await staff.roles(accountId) // { role, permissions, … }, or null: the console says not staff
// It rejects when the console fails or is slow, so an outage never reads as "not staff": a check of who may act,
// or who is protected from it, refuses. Only a caller that merely shows staff tools catches it:
const showTools = await staff.roles(accountId).catch(() => null)
// Asked on every request of a staff member? `cacheMs` keeps each answer that long per isolate,
// and a role changed in the console reaches the app that much later.
const cachedStaff = staffDirectory(env, { appId: 'myapp', cacheMs: 30_000 })
await staff.recordAction({ accountId, kind: 'record.action', resource: 'users', action: 'ban', outcome: 'ok' })
const result = await staff.redeemInvite(token, accountId) // from the console's invite link
// An app the console links by address (below) calls this at each sign-in whose provider verified the address:
const link = await staff.linkVerifiedEmail(accountId, verifiedEmail) // linked | already | none | refused + code
await staff.unlinkAccount(accountId) // when the app deletes an account: unlinked | none | refused + code
// An admin's own page inside the app, always as the acting account; the console checks its permissions.
const team = await staff.team(actorAccountId) // members:manage: { ok, team } or { ok: false, code }
await staff.setTeamRole(actorAccountId, accountId, 'moderator', 'Their name') // null removes; staff inside the app only
const visits = await staff.visits(actorAccountId, '7d') // telemetry:read: the Visits page's counts

const pushLog = diagnosticLog('push') // a manifest search with "prefix": "push " finds these
pushLog('failed', 'web.push.apple.com 410', 'warn')

Staff by a verified address

An app whose entry in the console's config/apps.json says "staffLink": "verified-email" takes no invite links. The owner adds a person's address on the app's Staff page; the person signs in to the app with a provider that verified that address (Google), and the app tells the console:

// Only right after a sign-in whose provider said the address is verified, with the address from that token:
// never an address the account typed, changed, or signed up with by password or a mail link.
const link = await staff.linkVerifiedEmail(account.publicId, google.email)

| Answer | Means | The app | |---|---|---| | linked | The address is a member's with a role on this app (or an owner's); the account is theirs now | asks roles() | | already | This account was linked to that member before | asks roles() | | none | Nobody on the staff has this address (or they are disabled): the console kept and logged nothing | goes on as for any customer | | refused, member-linked | The member is linked to another account of this app; that link stays, and the Staff page shows this account next to it | goes on without staff tools; someone who manages the staff unlinks the old account there | | refused, account-linked | Another call linked the account to another member at the same moment | asks roles(); the next sign-in settles it | | refused, not-enabled | The console does not link this app by address | a configuration mistake: report it as a failure | | refused, caller | The binding names no app, or another one than appId | a configuration mistake: report it as a failure | | refused, invalid | No account id ([A-Za-z0-9_-]{8,128}), or no address: not one, or one with a space, a control or an invisible character | a programming mistake: report it as a failure |

It rejects, like roles(), when the console fails, does not answer in timeoutMs, or answers something else; without the binding it answers none. A sign-in never fails because of it: catch the rejection, go on without staff tools, and let the next sign-in call again (a call made twice links once and answers already). Any answer or rejection drops the account's cached roles().

let staffRoles = null
try {
  const link = await staff.linkVerifiedEmail(account.publicId, google.email)
  if (link.status === 'refused' && link.code !== 'member-linked') console.error('staff link refused', link.code)
  staffRoles = await staff.roles(account.publicId) // whatever the answer: the console decides who is staff
} catch (error) {
  console.error('staff link failed', error) // the console is down or slow: signed in, no staff tools this time
}

The address rule. The address must be the one on the Staff page, character for character, except that ASCII letters match in either case. Nothing else is folded: no dot or plus tag ([email protected] and [email protected] are two addresses here, though one mailbox at Gmail), no letter outside ASCII or its case, no look-alike letter, no domain in its other spelling. An address with a space, a control or an invisible character anywhere is invalid: nothing is trimmed. A miss is always none, so the cost of the rule is that the Staff page needs the address as the person's Google account has it.

What changes a link. A role changed on the Staff page is the account's role at the next roles() (after cacheMs, when the app keeps answers). A role removed ends the link too: given back, it takes a new sign-in, and the account of before is not staff by itself. An account that signs in under another address than its member's loses that link at that call, whoever the new address is. When the app deletes an account it calls staff.unlinkAccount(accountId), so the next account under the address is linked instead of member-linked; it rejects like the link, and then the link that stays is shown and ended on the Staff page.

What the console trusts. That whoever can deploy a Worker to the Cloudflare account is trusted (a binding's props are set by a deploy of the calling Worker and by nothing its code does at run time), and that the app passes only an address its sign-in provider verified for that account at that sign-in. The console cannot check the second: an app that passed an unverified address would let anyone who can type a staff member's address become staff. With Google that means email_verified is true, and for an address outside gmail.com that is not a Workspace account's (hd), Google itself does not vouch that the mailbox is still the person's. Every link, every link the console or the app ends, and each refused account or binding is in the console's audit log (member.link, member.unlink) with the app, the member and the account id, a refused account once and a refused binding once a day; none leaves nothing, so the console learns nothing about customers.

The binding names its app in props, which only the app's wrangler.jsonc sets and its code cannot change at run time. A binding that names an app is served as that app only, in every call, and an app that links by address is served through no other binding:

{ "binding": "CONSOLE", "service": "codefusion-console", "entrypoint": "StaffDirectory", "props": { "appId": "myapp" } }

wrangler.jsonc

{
  // Failures, Health and Logs: the console reads every invocation.
  "tail_consumers": [{ "service": "codefusion-console" }],
  "observability": { "enabled": true },
  "services": [
    { "binding": "CONSOLE", "service": "codefusion-console", "entrypoint": "StaffDirectory" },
    { "binding": "CONSOLE_TELEMETRY", "service": "codefusion-console", "entrypoint": "AppTelemetry" }
  ],
  "analytics_engine_datasets": [
    { "binding": "VISITS", "dataset": "cfc_visits" },
    { "binding": "MEDIA_USAGE", "dataset": "cfc_media" } // only for an app with calls
  ],
  "ratelimits": [{ "name": "BROWSER_FAILURE_RATE_LIMITER", "namespace_id": "…", "simple": { "limit": 10, "period": 60 } }],
  "vars": { "APP_ENV": "production" }, // as the console's config/apps.json names this deployment
  // With privateSourceMaps(): the Worker, not the asset store, answers /_console/*, so the maps stay private.
  "assets": { "binding": "ASSETS", "directory": "./dist", "run_worker_first": ["/api/*", "/_console/*"] }
}

Other binding names go in createConsoleRoutes({ bindings: { environment, visits, telemetry, failureRateLimiter, assets } }) and staffDirectory(env, { binding }).

Both report paths are public and always answer 204. A visit is at most 2 KiB and a failure report 16 KiB (VISIT_MAX_BYTES, BROWSER_FAILURE_MAX_BYTES), counted in bytes of UTF-8 while the body is read: a body that passes its limit, whatever its Content-Length said, is cancelled there. The page cuts a visit's campaign and UTM fields and a report's stack to fit, so a long link or a stack in other scripts arrives shorter, not dropped. BROWSER_FAILURE_RATE_LIMITER counts a report per network (an IPv6 address by its /64) before its body is read, so a connection over the limit costs no reading.

The pages

// src/console.ts, imported early, before routing rewrites the address (it carries the campaign)
import { connectBrowser } from '@codefusion-cc/console/browser'

import { updates } from './updates.ts' // watchAppUpdates() from @codefusion-cc/app-update/browser

export const reporting = connectBrowser({ appId: 'myapp', version: `v${VERSION} · ${COMMIT}`, updates })
reporting.installBrowserFailureReporting()

// On each screen: reporting.setErrorPage('settings'); reporting.recordView('settings', signedIn)
// On sign-in: reporting.recordSignIn(). In a render error boundary: reporting.reportBrowserFailure(error, 'render')
// A consent banner: statsChoice(), setStatsChoice('granted' | 'denied'), reopenStatsChoice(), subscribeStatsChoice(fn),
// and recordConsent(page, signedIn) right after a yes.

A tab whose scripts a deploy replaced reloads into the new build through updates (@codefusion-cc/app-update), and that failure is then not reported; one the reload didn't fix is.

An app whose errors can name something private only it recognizes (what someone opened, a file path) passes its own mask, which runs on each message, stack and rejected value's field before the package's masking: connectBrowser({ appId, version, mask: (text) => text.replace(/"[^"\n]*"/g, '"…"') }).

Error reports mask email addresses, query strings, fragments and long ids before they leave the page, skip errors of browser extensions (their scripts, or their APIs' own words, as Safari's stackless "Invalid call to runtime.sendMessage()") and of other sites' scripts when no frame is the page's own, and stop after ten per page load. Views carry a random visitor id only after the visitor allowed statistics; the id is kept for at most 180 days under <appId>-visitor-v1, and the choice under <appId>-stats-consent-v1. The Worker stores a keyed hash of the id, never the id, and no IP address or user agent. Describe both in the app's privacy policy.

Provider usage

An app that sends audio or text to a provider with a limit on streams open at once (Soniox today) reports each stream to the console, so its Soniox page can show the peak of streams, refusals, usage, cost and speed per app, and say when to ask the provider for more. Add the dataset to wrangler.jsonc:

"analytics_engine_datasets": [{ "binding": "PROVIDER_USAGE", "dataset": "cfc_provider" }]
import { createProviderUsage, usageOutcomeOf } from '@codefusion-cc/console/worker'

const usage = createProviderUsage({ appId: 'neko', env: env.ENVIRONMENT, provider: 'soniox', dataset: env.PROVIDER_USAGE })

const stream = usage.open({ kind: 'stt', model: 'stt-rt-v5' }) // when the socket to the provider is open
stream.firstResult() // at the first transcript (or the first audio)
stream.beat() // wherever the app already loops; writes at most every 20 s
stream.add({ unitsIn: audioMs, unitsOut: chars, costUsd }) // what was used since the last call; it adds up
stream.end('ok') // once, however the stream ends; 'limit' | 'auth' | 'timeout' | 'budget' | 'error' otherwise
usage.refused('stt', usageOutcomeOf(429)) // the provider turned a stream away before it opened

Every call is synchronous, writes at most one data point, never throws into the stream and writes nothing without a dataset. No audio or text leaves the app: a data point holds the app, a stream id, counts, times and an outcome. A stream whose app dies without an end stops counting 75 s after its last beat. One open is one socket to the provider: a stream opened again after a drop is a new open, and each holds its slot from the open to the end.

Calls

An app whose calls go through a WebRTC SFU (Cloudflare Realtime) shows who is in them, at what quality, and what they send and receive on the console's Calls page. Each browser connection samples its own counters and hands the sample to the app's server where the two already talk, say the connection's keep-alive: no new request, and at most one data point per connection every MEDIA_REPORT_MS (30 s).

// The page, per RTCPeerConnection: 'send' for the one that publishes, 'receive' for the one that subscribes.
import { createMediaSampler } from '@codefusion-cc/console/browser'

const stats = createMediaSampler(pc, 'send')
// With each keep-alive: the sample read last time, if any. Reading happens in the background, so this never waits.
const sample = stats.take()
socket.send(JSON.stringify({ op: 'ping', ...(sample && { stats: sample }) }))
// The server, where it takes the keep-alive (a Durable Object, say). Never throws.
import { mediaReportDue, recordMediaReport } from '@codefusion-cc/console/worker'

const since = connection.statsAt ?? connection.startedAt // kept with the connection
if (frame.stats !== undefined && mediaReportDue(since, now)) {
  connection.statsAt = now // in the write the keep-alive already makes
  await recordMediaReport({ appId: 'myapp', env: env.APP_ENV, dataset: env.MEDIA_USAGE, secret: env.MY_HASH_SECRET }, {
    call: roomId,               // counts calls apart
    participant: connectionId,  // counts people apart: a socket or session, never an account id
    direction: 'send',          // as the browser's sampler
    tier: 'hd',                 // the quality the app gave this person, when it has tiers
    sample: frame.stats,        // as it arrived
    since, now,
  })
}

The server decides how often a connection reports (not sooner than 20 s after its last) and how much time a report may cover (what has passed since since); bytes are cut to what a connection can carry in that time, so a modified client can overstate only its own traffic, and only so much. Nothing is written without the dataset or the secret.

A sample holds bytes of audio and video, the tracks that carried them, the largest picture (by its shorter side: 720 for 1280×720 or 720×1280), frames a second, whether it went through a TURN relay and how many of its bytes did (relayedBytes), and why the browser sent less than asked (bandwidth, cpu). The call and participant ids are stored only as keyed hashes. Nothing else about the person is sent.

Peer to peer. A call whose media goes straight between people has a connection to each other person. Give the sampler a function that lists them, one sampler to send and one to receive, and the person stays one sender and one receiver on the Calls page, however many people they talk to. The server says so with transport: 'p2p': Cloudflare bills only what went through a relay, which the page counts from relayedBytes (a relay at either end of a connection counts).

const sent = createMediaSampler(() => peers.values(), 'send')
const received = createMediaSampler(() => peers.values(), 'receive')
// On the server: recordMediaReport(config, { ...report, transport: 'p2p', kind: 'random', sample })

Kinds of call. kind is the app's name for the kind of call (random, call, room: lowercase, up to 24 characters), and the Calls page shows each apart; the console's config/apps.json names them for people ("calls": { "kinds": { "random": "Random chats 1:1" } }).

Staying inside the free allowance. staffDirectory(env, { appId }).realtimeBudget() answers this month's Realtime egress (SFU and TURN) of the console's Cloudflare account against the free 1,000 GB, as Cloudflare counts it: { ok: true, budget: { usedBytes, freeBytes, lastDayBytes, projectedBytes, monthStart, monthEnd, readAt } }, or { ok: false, code: 'unavailable' } when the console cannot say. It never rejects, callers at the same time share one question, and an isolate keeps the answer budgetCacheMs (a minute; a failure a fifth of that). After that the last answer comes at once while a new one is asked for, and it stays in use while the console cannot say, for budgetStaleMs (an hour; 0 waits for every answer): a caller on a hot path never waits on the console. An app can send its calls peer to peer as the month nears the end of the allowance.

The build

// vite.config.js
import { versionFile } from '@codefusion-cc/console/vite'

export default defineConfig({ plugins: [react(), versionFile(), privateSourceMaps()] }) // serves /version.json { version, commit }

privateSourceMaps() builds hidden source maps and moves them to _console/source-maps/ in the build, without the source text (sourcesContent): mapping a stack needs only the files and lines. The pages don't point at them, and createConsoleRoutes answers 404 there, once run_worker_first covers /_console/* (above) and the Worker passes those requests to it. A Worker that sends only some paths to consoleRoutes.fetch checks isConsoleRoute(pathname), or its asset store serves the maps. When a page reports a failure, the Worker reads the map from its ASSETS and reports the stack in the app's own files (at src/notifications/push.ts:66:47), so the console shows, and links, the lines that failed. The maps never leave the app.

buildIdentity() gives the same { version, commit } for the app's own build defines. The commit is the one the CI names (Workers Builds, GitHub Actions, Cloudflare Pages), else git's HEAD; in a GitHub Actions pull_request build it is the pull request's own head commit, not the merge commit GitHub builds it as, which is on no branch, so a preview shows a commit its branch has.

A deploy job then waits until the site serves that commit, and fails when it never does (a route still on the old version, a cache), in place of a curl loop of its own:

- run: npx codefusion-console wait-for-deploy https://shop.example   # its /version.json; --within 60 (seconds)

It asks every 5 seconds past any cache, says what the site serves meanwhile, and ends with a GitHub error annotation naming it. --commit <sha> waits for another commit.

A build made without Vite (esbuild, a script) does the same with writeVersionFile(dir) and moveSourceMaps(dir, { to, root }): build the maps without a sourceMappingURL comment (esbuild's sourcemap: 'external', sourcesContent: false), then move them. to puts them in the Worker's own static assets when the pages are served elsewhere, such as Cloudflare Pages, and root makes their sources relative to the repository, wherever the build put its scripts:

// scripts/build.mjs
import { moveSourceMaps, writeVersionFile } from '@codefusion-cc/console/vite'

const { commit } = writeVersionFile('site/dist') // define it for the page as __APP_COMMIT__
moveSourceMaps('site/dist', { to: 'server/private-assets', root: '.' })
// server/wrangler.jsonc: "assets": { "binding": "ASSETS", "directory": "./private-assets", "run_worker_first": true }

Tests

import { consoleAdminErrors } from '@codefusion-cc/console/worker'
// Every resource and action in the manifest has a handler, and the manifest is valid:
expect(consoleAdminErrors({ manifest, resources })).toEqual([])

The app's Worker tests can bind a stand-in for the console, which records what the app sent:

// vitest.workers.config
miniflare: { workers: [{
  name: 'codefusion-console', modules: true, compatibilityDate: '2026-07-23',
  scriptPath: './node_modules/@codefusion-cc/console/dist/testing/consoleStub.js',
}] }
// env.CONSOLE.setStaff({ accountId, role }), env.CONSOLE.addInvite({ token, role }), env.CONSOLE.recorded(),
// env.CONSOLE_TELEMETRY.reported()
// Linking by address: env.CONSOLE.setStaffEmail({ email, role }) makes linkVerifiedEmail answer `linked` (then
// `already`, and `refused` for a second account); any other address is `none`, and ends a link the account had;
// unlinkAccount answers as the console; env.CONSOLE.unlink({ accountId }) is the Staff page's unlink;
// env.CONSOLE.setLinkRefusal({ code }) refuses every link; env.CONSOLE.setStaffLookupsFailing({ failing: true }) is the console down.

/worker imports cloudflare:workers. Tests that load it in plain Node (outside @cloudflare/vitest-plugin) alias that module to the package's stand-in (WorkerEntrypoint and DurableObject, so the app's own entrypoints and Durable Object classes load too) and inline the package, so the alias reaches it:

// vitest.config
resolve: { alias: { 'cloudflare:workers': fileURLToPath(import.meta.resolve('@codefusion-cc/console/testing/cloudflare-workers')) } },
test: { server: { deps: { inline: ['@codefusion-cc/console'] } } },

In the console

Adding the app to the console itself is a pull request there: an entry in config/apps.json (its Workers, environments, addresses and repository) and a service binding to its ConsoleAdmin in wrangler.jsonc. See the console's README.

The command line

The package also installs codefusion-console: the console's MCP tools (failures, logs, request traces, deployments, records) from a shell, as you. In the app's repository:

npx codefusion-console login                      # once: the browser signs in through Cloudflare Access
npx codefusion-console list_failures myapp        # every tool is a command
npx codefusion-console help get_failure           # its options, from the console
npx codefusion-console search_logs myapp --level error --range 24h > errors.txt

The sign-in renews itself for as long as the console's Access grant lasts. A tool that changes things asks first, or takes --yes. One that needs npm's second factor (npm_publishing) opens npm's page in your browser to confirm with your security key, passkey or authenticator and waits for it, again each time npm asks partway; from a script it prints the page and the --continue to run the same command with. Where npm offers no page it asks for a code from your authenticator, or takes --otp. An answer that carries a picture or a file (dispatch_attachment) is saved with --out <path>. Exit codes: 0 answered, 1 failed or refused, 2 wrong command, 3 sign-in needed. --console <url> reaches another console, e.g. http://localhost:8787 for npm run dev's.

A command that will wait for you in the terminal can be answered from your phone instead, on its Dispatch item:

npm install -g @codefusion-cc/console && codefusion-console relay install   # once per Mac (launchd)
codefusion-console run --item pr-93 -- sudo xcodebuild -license accept      # its prompts show on item pr-93

run shows the command here as usual; hidden prompts get a password box, y/N buttons, "Press ENTER" a button and sign-in links an Open button, and your answer is sealed in the browser to this Mac's key. npm's confirmation page shows as Confirm on npm. With --renew-npm-link, for a command safe to run again, run starts it again for a new link when npm's runs out (three times in a row, then it waits for you). It needs macOS for now. A new relay (each relay install makes a new key) connects once you trust it on Dispatch ("Trust this Mac", with your passkey); relay install waits for that, and relay status says while it waits.

The console can ask the relay for a named job, never a command of its own: npm-trust (each package of the npm scope made to trust its repository's Release workflow, with npm's one confirmation on this Mac) and relay-update. Both run by hand too:

Claude Code's own permission prompts come to Dispatch too, answered from the phone, with its PermissionRequest hook (codefusion-console permission-hook, in ~/.claude/settings.json with "timeout": 600): the prompt in the terminal stays, and the first answer counts; allowing takes the owner's passkey; it never allows by itself.

codefusion-console npm-trust --dry-run   # what it would change
codefusion-console relay update          # the newest @codefusion-cc/console, and the relay restarted

A command that needs Cloudflare gets a short-lived token of its own, minted with exactly the scopes it names (r2, workers, d1, kv, pages; :read or :edit; tail:read and logs:read; routes:edit and domains:edit for a deploy's routes or custom domains), sealed to this Mac's relay key and deleted when it exits. One that writes waits for the owner's tap on Dispatch first; one that only reads is minted at once:

codefusion-console run --item you-photo-upload --cloudflare r2:edit -- npm run images:upload
codefusion-console cloudflare workers:edit,d1:edit --ttl 30m -- npx wrangler deploy

The relay also sends the conversations of this Mac's Claude Code sessions to their Dispatch session pages, as they are written. The console's docs/DISPATCH.md has how it works. The console's docs/MCP.md has the rest.