Răsfoiți Sursa

fix(web): retain launch token across reloads

Tianyi Cui 4 săptămâni în urmă
părinte
comite
3b3b493a96

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
-2026-08-24-browser-token-authentication.md: 515990b81f3c92b7f3c422dcc40acfed1e5996ac
-2026-08-24-browser-token-authentication.zh.md: 8066e2c3f62245779c6a03b5160c41e4bbc94ba5
+2026-08-24-browser-token-authentication.md: 69f24c148a1054f99447f1f9f2a6d2e5540c83db
+2026-08-24-browser-token-authentication.zh.md: e9b96af21e913c1c3e482940c9f65d0d37506c81

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md

@@ -12,11 +12,11 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
 
 `dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
 
-Each Connection process generates a random launch token. `dsh-web-app` prints and opens the normal root URL with that token in the query. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
+Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
 
 The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling.
 
-The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is recreated only by a valid process-token exchange. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
+The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is created when Connection starts; after runtime deletion, the next valid process-token exchange or process start recreates it. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
 
 The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker.
 
@@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im
 
 ## Verification
 
-Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
+Unit coverage pins process-token retention across Connection reloads, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
 
 ## Alternatives considered
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md

@@ -12,11 +12,11 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
 
 `dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
 
-每个 Connection 进程生成随机启动令牌。`dsh-web-app` 打印并打开 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
+每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
 
 cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。
 
-HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录只能由有效进程令牌交换重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
+HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录在 Connection 启动时创建;运行期删除后,由下一次有效进程令牌交换或进程启动重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
 
 页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。
 
@@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 
 
 ## 验证
 
-单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
+单元覆盖 Connection 重载时保留进程令牌、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: a4eeb7ffbe09253d7f0f979cd8dc681b4ae038ac
-config-catalog.zh.md: 8490829a7881e6503fdf7b7652c679e5d3cc0f3c
+config-catalog.md: aaeb30309288b2864c510cf7ebf6674988c568d1
+config-catalog.zh.md: ec649715bc74f2eb11c366b2eeca5026afdcdd6f

+ 1 - 1
docs/config-catalog.md

@@ -3143,7 +3143,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts)
+Source: [`packages/bundle/web-app/src/index.ts:45`](../packages/bundle/web-app/src/index.ts)
 
 <a id="deepseek-aidsh-web-fetch-http"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -3145,7 +3145,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts)
+来源:[`packages/bundle/web-app/src/index.ts:45`](../packages/bundle/web-app/src/index.ts)
 
 <a id="deepseek-aidsh-web-fetch-http"></a>
 

+ 3 - 0
packages/bundle/web-app/src/index.ts

@@ -33,6 +33,7 @@ export const name = 'web-app'
 
 /** This dsh installation's root, from either this package's source or built entry. */
 const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
+const ANNOUNCED_ROOTS = new WeakSet<Context>()
 
 /** Runtime service that releases Web rows after bind-dependent values resolve. */
 const WEB_RUNTIME_SERVICE = 'webRuntime'
@@ -266,6 +267,7 @@ export function apply(ctx: Context, config: Config): void {
       // route owner are still mounting. Await Loader settlement first; a
       // hand-built tree without a Loader is already the complete tree.
       const announceReady = (): void => {
+        if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return
         const webUrl = localWebUrl(connectionCtx)
         const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl)
         // Reuse the exact LAN snapshot provided to the /api trust fence.
@@ -274,6 +276,7 @@ export function apply(ctx: Context, config: Config): void {
         const lanUrl = lanCandidate === undefined
           ? undefined
           : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`)
+        ANNOUNCED_ROOTS.add(connectionCtx.root)
         if (config.printUrl) {
           console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`)
         }

+ 18 - 0
packages/bundle/web-app/tests/web-app.spec.ts

@@ -216,6 +216,24 @@ describe('web-app runtime glue', () => {
     await ctx.fiber.dispose()
   })
 
+  it('does not publish readiness again when Connection reloads', async () => {
+    stageDist()
+    const ctx = new Context()
+    ctx.provide('webServer', fakeHttpServer().server)
+    const first = ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) })
+    await first
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] }))
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(log).toHaveBeenCalledTimes(1)
+
+    await first.dispose()
+    await ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) })
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(log).toHaveBeenCalledTimes(1)
+    await ctx.fiber.dispose()
+  })
+
   it.each([
     ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'],
     ['SSH_TTY', '/dev/pts/3'],

+ 28 - 3
packages/client/connection/src/browser-auth.ts

@@ -17,6 +17,7 @@ const COOKIE_PREFIX = 'dsh-auth-'
 const COOKIE_PAYLOAD_VERSION = 1
 const STORED_SECRET_VERSION = 1
 const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/
+const PROCESS_LAUNCH_TOKENS = new WeakMap<object, string>()
 
 interface StoredSecretPayload {
   readonly version: typeof STORED_SECRET_VERSION
@@ -48,6 +49,14 @@ function decodeBase64Url(value: string): Buffer | undefined {
   return encodeBase64Url(decoded) === value ? decoded : undefined
 }
 
+function processLaunchToken(owner: object): string {
+  const existing = PROCESS_LAUNCH_TOKENS.get(owner)
+  if (existing !== undefined) return existing
+  const created = encodeBase64Url(randomBytes(SECRET_BYTES))
+  PROCESS_LAUNCH_TOKENS.set(owner, created)
+  return created
+}
+
 function header(
   headers: ConnectionTrustRequest['headers'],
   name: string,
@@ -156,13 +165,15 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und
  * process restart.
  */
 export class BrowserAuth {
-  private readonly launchToken = encodeBase64Url(randomBytes(SECRET_BYTES))
+  private readonly launchToken: string
   private readonly maxAgeMilliseconds: number
 
   private constructor(
+    processOwner: object,
     private readonly credentials: CredentialProvider,
     maxAgeDays: number,
   ) {
+    this.launchToken = processLaunchToken(processOwner)
     this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS
     if (!Number.isSafeInteger(this.maxAgeMilliseconds)
       || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) {
@@ -173,12 +184,17 @@ export class BrowserAuth {
   /**
    * Initialize browser authentication and create its durable signing secret
    * when this Harness home has none.
+   * @param processOwner - root application context retaining one token across Connection reloads.
    * @param credentials - persistent credential provider for the Web profile.
    * @param maxAgeDays - positive absolute browser-cookie lifetime in days.
    * @returns initialized authentication owner with a fresh process token.
    */
-  static async create(credentials: CredentialProvider, maxAgeDays: number): Promise<BrowserAuth> {
-    const auth = new BrowserAuth(credentials, maxAgeDays)
+  static async create(
+    processOwner: object,
+    credentials: CredentialProvider,
+    maxAgeDays: number,
+  ): Promise<BrowserAuth> {
+    const auth = new BrowserAuth(processOwner, credentials, maxAgeDays)
     await auth.ensureSecret()
     return auth
   }
@@ -232,6 +248,15 @@ export class BrowserAuth {
         res.end()
         return false
       }
+      if (req.method === 'GET' && url.pathname === '/' && await this.isAuthenticated(req)) {
+        res.writeHead(303, {
+          'cache-control': 'no-store',
+          'location': '/',
+          'referrer-policy': 'no-referrer',
+        })
+        res.end()
+        return false
+      }
       this.writeUnauthorized(req, res)
       return false
     }

+ 1 - 1
packages/client/connection/src/index.ts

@@ -93,7 +93,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
   const connection = new HostConnectionService(
     ctx,
     trustedHosts,
-    await BrowserAuth.create(ctx.credentials, cookieMaxAgeDays),
+    await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),
   )
   const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
     async fetch(request) {

+ 39 - 11
packages/client/connection/tests/browser-auth.host.spec.ts

@@ -74,6 +74,14 @@ function credentials(store: RecordCredentials): CredentialProvider {
   return store as unknown as CredentialProvider
 }
 
+function createAuth(
+  store: RecordCredentials,
+  maxAgeDays = 30,
+  processOwner: object = {},
+): Promise<BrowserAuth> {
+  return BrowserAuth.create(processOwner, credentials(store), maxAgeDays)
+}
+
 function request(url: string, authority = '127.0.0.1:3080', init?: {
   cookie?: string
   method?: string
@@ -108,7 +116,8 @@ afterEach(() => {
 describe('BrowserAuth', () => {
   it('mints one process token and a persistent authority-bound cookie', async () => {
     const store = new RecordCredentials()
-    const first = await BrowserAuth.create(credentials(store), 30)
+    const processOwner = {}
+    const first = await createAuth(store, 30, processOwner)
     const login = await exchange(first)
 
     expect(login.state).toMatchObject({
@@ -129,14 +138,33 @@ describe('BrowserAuth', () => {
     expect(await first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false)
     expect(await first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false)
 
-    const restarted = await BrowserAuth.create(credentials(store), 30)
+    const reloaded = await createAuth(store, 30, processOwner)
+    expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl)
+    expect(await reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
+
+    const restarted = await createAuth(store)
     expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token'))
       .not.toBe(new URL(login.launchUrl).searchParams.get('token'))
     expect(await restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
+    const staleUrl = new URL(login.launchUrl)
+    const redirected = response()
+    expect(await restarted.authorizeIndex(request(
+      `${staleUrl.pathname}${staleUrl.search}`,
+      '127.0.0.1:3080',
+      { cookie: login.cookie },
+    ), redirected.value)).toBe(false)
+    expect(redirected.state).toEqual({
+      status: 303,
+      headers: {
+        'cache-control': 'no-store',
+        'location': '/',
+        'referrer-policy': 'no-referrer',
+      },
+    })
   })
 
   it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => {
-    const auth = await BrowserAuth.create(credentials(new RecordCredentials()), 30)
+    const auth = await createAuth(new RecordCredentials())
     const { cookie } = await exchange(auth)
     const allowed = response()
     expect(await auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true)
@@ -166,7 +194,7 @@ describe('BrowserAuth', () => {
     vi.useFakeTimers()
     vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z'))
     const store = new RecordCredentials()
-    const auth = await BrowserAuth.create(credentials(store), 30)
+    const auth = await createAuth(store)
     const { cookie } = await exchange(auth)
     const [name, value] = cookie.split('=') as [string, string]
 
@@ -194,7 +222,7 @@ describe('BrowserAuth', () => {
       }))).toBe(false)
     }
 
-    const shorter = await BrowserAuth.create(credentials(store), 1)
+    const shorter = await createAuth(store, 1)
     expect(await shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
     vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z'))
     expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
@@ -204,7 +232,7 @@ describe('BrowserAuth', () => {
 
   it('revokes on record deletion and creates a new secret on the next token exchange', async () => {
     const store = new RecordCredentials()
-    const auth = await BrowserAuth.create(credentials(store), 30)
+    const auth = await createAuth(store)
     const first = await exchange(auth)
     await store.deleteRecord()
     expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
@@ -218,21 +246,21 @@ describe('BrowserAuth', () => {
   it('fails loud on an invalid owner record instead of replacing it', async () => {
     const unsupported = new RecordCredentials()
     unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' }
-    await expect(BrowserAuth.create(credentials(unsupported), 30)).rejects.toThrow(/unsupported format/u)
+    await expect(createAuth(unsupported)).rejects.toThrow(/unsupported format/u)
 
     const malformed = new RecordCredentials()
     malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } }
-    await expect(BrowserAuth.create(credentials(malformed), 30)).rejects.toThrow(/invalid secret/u)
+    await expect(createAuth(malformed)).rejects.toThrow(/invalid secret/u)
 
     const nonString = new RecordCredentials()
     nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } }
-    await expect(BrowserAuth.create(credentials(nonString), 30)).rejects.toThrow(/invalid secret/u)
+    await expect(createAuth(nonString)).rejects.toThrow(/invalid secret/u)
 
     const discarded = new RecordCredentials()
     discarded.discardWrites = true
-    await expect(BrowserAuth.create(credentials(discarded), 30)).rejects.toThrow(/was not created/u)
+    await expect(createAuth(discarded)).rejects.toThrow(/was not created/u)
 
-    await expect(BrowserAuth.create(credentials(new RecordCredentials()), Number.MAX_SAFE_INTEGER))
+    await expect(createAuth(new RecordCredentials(), Number.MAX_SAFE_INTEGER))
       .rejects.toThrow(/safe timestamp range/u)
   })
 })