orez-sync-cf-host 0.5.16 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,14 +19,72 @@ required second transaction to advance the LMID marker.
19
19
 
20
20
  ## Wake channel and eviction
21
21
 
22
- `GET /<namespace>/wake?clientID=<id>` upgrades to a Durable Object hibernating
23
- WebSocket. Socket attachments carry only the client ID. A committed push sends a
24
- text `wake` frame to all connected clients except the pusher; a scheduler window
22
+ `GET /<namespace>/wake?clientID=<id>&wakeToken=<capability>` upgrades to a
23
+ Durable Object hibernating WebSocket after `authorizeWake` accepts the
24
+ capability. Browser consumers should mint a short-lived, namespace-scoped token
25
+ at their authenticated edge because the native WebSocket constructor cannot set
26
+ an authorization header. Socket attachments carry only the client ID. A
27
+ committed push sends a text `wake` frame to all connected clients except the
28
+ pusher; a scheduler window
25
29
  coalesces a burst into one frame per socket. `ping` receives `pong`. The message
26
30
  contains no state and carries no correctness weight: clients pull after a wake
27
31
  and retain their safety poll. `ctx.getWebSockets()` plus serialized attachments
28
32
  means sockets remain discoverable after hibernation/re-instantiation.
29
33
 
34
+ ### Consumer-minted wake capabilities
35
+
36
+ The consumer Worker owns both token minting and verification. Add an
37
+ authenticated edge route that signs the namespace and a short expiry, typically
38
+ 30 to 60 seconds, with a secret that never reaches the browser. Return only the
39
+ signed token:
40
+
41
+ ```ts
42
+ // consumer edge route, after normal session authentication
43
+ const expiresAt = Date.now() + 60_000
44
+ const token = await signWakeToken({ namespace, userID, expiresAt }, env.WAKE_SECRET)
45
+ return Response.json({ token, expiresAt })
46
+ ```
47
+
48
+ Pass a mint callback to the canonical HTTP transport. It calls `getToken()` for
49
+ every socket attempt, including reconnects, so short-lived tokens are never
50
+ reused after the wake connection drops:
51
+
52
+ ```ts
53
+ ensureHttpPullTransport({
54
+ origin: syncOrigin,
55
+ pullIntervalMs: 5_000,
56
+ wake: {
57
+ async getToken() {
58
+ const response = await fetch(`/api/sync/${namespace}/wake-token`, {
59
+ method: 'POST',
60
+ })
61
+ if (!response.ok) throw new Error('wake token mint failed')
62
+ return (await response.json()).token
63
+ },
64
+ },
65
+ })
66
+ ```
67
+
68
+ The transport appends that value as `wakeToken` because browser WebSockets
69
+ cannot set an authorization header. Verify its signature, expiry, and namespace
70
+ inside the consumer's `authorizeWake` callback. `sync-cf-host` deliberately does
71
+ not prescribe a token format or hold the signing key:
72
+
73
+ ```ts
74
+ authorizeWake(request, env) {
75
+ const url = new URL(request.url)
76
+ return verifyWakeToken(url.searchParams.get('wakeToken'), {
77
+ namespace: namespaceFrom(url),
78
+ secret: env.WAKE_SECRET,
79
+ })
80
+ }
81
+ ```
82
+
83
+ Treat the URL token as a narrowly scoped capability and avoid logging it. If
84
+ minting or wake authorization is unavailable, the wake channel retries in the
85
+ background while HTTP pulls and the safety poll continue to provide
86
+ convergence.
87
+
30
88
  `/admin/status` reports a boot ID, hibernation simulation count, connected wake
31
89
  sockets, durable database size, engine watermark/floor, and aggregate counters.
32
90
  After the configured idle gap (5 seconds in the harness deployment), the local
@@ -42,6 +100,11 @@ callback succeeds or the request presents the deployment's `ADMIN_KEY` through
42
100
  includes the persisted writer-enabled state and wasm linear-memory byte length
43
101
  in addition to the engine/counter fields above.
44
102
 
103
+ `POST /<namespace>/notify` is rejected unless `authorizeNotify` accepts a
104
+ service or operator capability. Both `authorizeWake` and `authorizeNotify` run
105
+ before `idFromName`, so rejected requests cannot instantiate namespace Durable
106
+ Objects.
107
+
45
108
  `POST /admin/writer` with `{ "enabled": false }` durably stops pushes for that
46
109
  namespace. A stopped writer consumes and discards the request body, returns 503,
47
110
  and performs no engine or application write. Canary rollback drills must stop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/src/config.ts CHANGED
@@ -10,6 +10,12 @@ export function validatePullCaps(caps: PullCaps): PullCaps {
10
10
  export function validateSyncHostConfig<Env extends SyncHostEnv>(
11
11
  config: SyncHostConfig<Env>
12
12
  ): SyncHostConfig<Env> {
13
+ if (typeof config.authorizeWake !== 'function') {
14
+ throw new TypeError('sync host config authorizeWake is required')
15
+ }
16
+ if (typeof config.authorizeNotify !== 'function') {
17
+ throw new TypeError('sync host config authorizeNotify is required')
18
+ }
13
19
  const hasMutators = config.mutators !== undefined
14
20
  const hasDelegate = config.mutateUrl !== undefined
15
21
  if (hasMutators === hasDelegate) {
@@ -21,6 +27,9 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
21
27
  if (config.mutateBinding !== undefined && !hasDelegate) {
22
28
  throw new TypeError('sync host config mutateBinding requires mutateUrl')
23
29
  }
30
+ if (config.mutateOrigin !== undefined && !hasDelegate) {
31
+ throw new TypeError('sync host config mutateOrigin requires mutateUrl')
32
+ }
24
33
  if (config.delegatedPushRetry !== undefined && !hasDelegate) {
25
34
  throw new TypeError('sync host config delegatedPushRetry requires mutateUrl')
26
35
  }
@@ -35,6 +44,20 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
35
44
  if (config.mutateUrl && !config.mutateUrl.startsWith('/')) {
36
45
  throw new TypeError('mutateUrl must be an absolute path')
37
46
  }
47
+ if (config.mutateOrigin !== undefined) {
48
+ let origin: URL
49
+ try {
50
+ origin = new URL(config.mutateOrigin)
51
+ } catch {
52
+ throw new TypeError('mutateOrigin must be an absolute http(s) origin')
53
+ }
54
+ if (
55
+ (origin.protocol !== 'http:' && origin.protocol !== 'https:') ||
56
+ origin.origin !== config.mutateOrigin
57
+ ) {
58
+ throw new TypeError('mutateOrigin must be an absolute http(s) origin')
59
+ }
60
+ }
38
61
  if (config.upstream) {
39
62
  if (!config.upstream.binding) throw new TypeError('upstream.binding is required')
40
63
  const limit = config.upstream.changeLimit ?? 1_000
@@ -1,6 +1,18 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
3
 
4
+ /**
5
+ * Apply one ordered catch-up page to staging without touching the live table
6
+ * namespace or live upstream watermark.
7
+ */
8
+ export function engine_apply_snapshot_changes(db: any, schema: any, generation: string, batch: any): any;
9
+
10
+ /**
11
+ * Apply one bounded source page to staging and commit its opaque cursor with
12
+ * the rows. The host owns one transaction per call.
13
+ */
14
+ export function engine_apply_snapshot_page(db: any, schema: any, generation: string, table: string, rows: any, next_cursor?: string | null): any;
15
+
4
16
  /**
5
17
  * Apply one ordered page from the upstream ZeroSqlDO change feed. The host
6
18
  * owns one transaction around this call; application triggers append the
@@ -15,6 +27,12 @@ export function engine_apply_upstream_snapshot(db: any, schema: any, snapshot: a
15
27
 
16
28
  export function engine_assemble_push_response(results: any): any;
17
29
 
30
+ /**
31
+ * Create a new staged generation. `start_watermark` is captured by the host
32
+ * from the first source snapshot-page response. The host owns the transaction.
33
+ */
34
+ export function engine_begin_snapshot_generation(db: any, schema: any, start_watermark: string): any;
35
+
18
36
  /**
19
37
  * Compile a validated Zero query AST for a consumer mutator's transactional
20
38
  * `tx.run(...)`. Execution remains in the host-owned application transaction.
@@ -23,6 +41,12 @@ export function engine_compile_query(schema: any, ast: any): any;
23
41
 
24
42
  export function engine_finalize(db: any, client_group_id: string, client_id: string, mutation_id: string): void;
25
43
 
44
+ /**
45
+ * Atomically rename a drained staged generation into the live namespace,
46
+ * invalidate pre-cutover client cookies, and advance the live source cursor.
47
+ */
48
+ export function engine_finalize_snapshot_generation(db: any, schema: any, generation: string, watermark: string): any;
49
+
26
50
  /**
27
51
  * Production pull entry. The TypeScript host owns `transactionSync`.
28
52
  */
@@ -63,6 +87,12 @@ export function engine_prune(db: any, retain_changes: string): void;
63
87
  */
64
88
  export function engine_push_validate(body: any): any;
65
89
 
90
+ /**
91
+ * Strictly read resumable snapshot progress. Storage and row-shape failures
92
+ * are errors; only a successful query with no active row returns null.
93
+ */
94
+ export function engine_read_snapshot_progress(db: any): any;
95
+
66
96
  export function engine_record_app_error(db: any, client_group_id: string, client_id: string, mutation_id: string, user_id: string): void;
67
97
 
68
98
  export function engine_state(db: any): any;
@@ -73,11 +103,15 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
73
103
 
74
104
  export interface InitOutput {
75
105
  readonly memory: WebAssembly.Memory;
106
+ readonly engine_apply_snapshot_changes: (a: any, b: any, c: number, d: number, e: any) => [number, number, number];
107
+ readonly engine_apply_snapshot_page: (a: any, b: any, c: number, d: number, e: number, f: number, g: any, h: number, i: number) => [number, number, number];
76
108
  readonly engine_apply_upstream: (a: any, b: any, c: any) => [number, number, number];
77
109
  readonly engine_apply_upstream_snapshot: (a: any, b: any, c: any) => [number, number, number];
78
110
  readonly engine_assemble_push_response: (a: any) => [number, number, number];
111
+ readonly engine_begin_snapshot_generation: (a: any, b: any, c: number, d: number) => [number, number, number];
79
112
  readonly engine_compile_query: (a: any, b: any) => [number, number, number];
80
113
  readonly engine_finalize: (a: any, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
114
+ readonly engine_finalize_snapshot_generation: (a: any, b: any, c: number, d: number, e: number, f: number) => [number, number, number];
81
115
  readonly engine_handle_pull: (a: any, b: any, c: any, d: any, e: number, f: number, g: any, h: number, i: number) => [number, number, number];
82
116
  readonly engine_handle_query_pull: (a: any, b: any, c: number, d: number, e: any, f: number, g: number) => [number, number, number];
83
117
  readonly engine_init_query_schema: (a: any) => [number, number];
@@ -87,6 +121,7 @@ export interface InitOutput {
87
121
  readonly engine_preflight: (a: any, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number, number];
88
122
  readonly engine_prune: (a: any, b: number, c: number) => [number, number];
89
123
  readonly engine_push_validate: (a: any) => [number, number, number];
124
+ readonly engine_read_snapshot_progress: (a: any) => [number, number, number];
90
125
  readonly engine_record_app_error: (a: any, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number];
91
126
  readonly engine_state: (a: any) => [number, number, number];
92
127
  readonly engine_version: () => [number, number];
@@ -1,5 +1,49 @@
1
1
  /* @ts-self-types="./sync_wasm.d.ts" */
2
2
 
3
+ /**
4
+ * Apply one ordered catch-up page to staging without touching the live table
5
+ * namespace or live upstream watermark.
6
+ * @param {any} db
7
+ * @param {any} schema
8
+ * @param {string} generation
9
+ * @param {any} batch
10
+ * @returns {any}
11
+ */
12
+ export function engine_apply_snapshot_changes(db, schema, generation, batch) {
13
+ const ptr0 = passStringToWasm0(generation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
14
+ const len0 = WASM_VECTOR_LEN;
15
+ const ret = wasm.engine_apply_snapshot_changes(db, schema, ptr0, len0, batch);
16
+ if (ret[2]) {
17
+ throw takeFromExternrefTable0(ret[1]);
18
+ }
19
+ return takeFromExternrefTable0(ret[0]);
20
+ }
21
+
22
+ /**
23
+ * Apply one bounded source page to staging and commit its opaque cursor with
24
+ * the rows. The host owns one transaction per call.
25
+ * @param {any} db
26
+ * @param {any} schema
27
+ * @param {string} generation
28
+ * @param {string} table
29
+ * @param {any} rows
30
+ * @param {string | null} [next_cursor]
31
+ * @returns {any}
32
+ */
33
+ export function engine_apply_snapshot_page(db, schema, generation, table, rows, next_cursor) {
34
+ const ptr0 = passStringToWasm0(generation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
35
+ const len0 = WASM_VECTOR_LEN;
36
+ const ptr1 = passStringToWasm0(table, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
37
+ const len1 = WASM_VECTOR_LEN;
38
+ var ptr2 = isLikeNone(next_cursor) ? 0 : passStringToWasm0(next_cursor, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
39
+ var len2 = WASM_VECTOR_LEN;
40
+ const ret = wasm.engine_apply_snapshot_page(db, schema, ptr0, len0, ptr1, len1, rows, ptr2, len2);
41
+ if (ret[2]) {
42
+ throw takeFromExternrefTable0(ret[1]);
43
+ }
44
+ return takeFromExternrefTable0(ret[0]);
45
+ }
46
+
3
47
  /**
4
48
  * Apply one ordered page from the upstream ZeroSqlDO change feed. The host
5
49
  * owns one transaction around this call; application triggers append the
@@ -44,6 +88,24 @@ export function engine_assemble_push_response(results) {
44
88
  return takeFromExternrefTable0(ret[0]);
45
89
  }
46
90
 
91
+ /**
92
+ * Create a new staged generation. `start_watermark` is captured by the host
93
+ * from the first source snapshot-page response. The host owns the transaction.
94
+ * @param {any} db
95
+ * @param {any} schema
96
+ * @param {string} start_watermark
97
+ * @returns {any}
98
+ */
99
+ export function engine_begin_snapshot_generation(db, schema, start_watermark) {
100
+ const ptr0 = passStringToWasm0(start_watermark, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
101
+ const len0 = WASM_VECTOR_LEN;
102
+ const ret = wasm.engine_begin_snapshot_generation(db, schema, ptr0, len0);
103
+ if (ret[2]) {
104
+ throw takeFromExternrefTable0(ret[1]);
105
+ }
106
+ return takeFromExternrefTable0(ret[0]);
107
+ }
108
+
47
109
  /**
48
110
  * Compile a validated Zero query AST for a consumer mutator's transactional
49
111
  * `tx.run(...)`. Execution remains in the host-owned application transaction.
@@ -78,6 +140,27 @@ export function engine_finalize(db, client_group_id, client_id, mutation_id) {
78
140
  }
79
141
  }
80
142
 
143
+ /**
144
+ * Atomically rename a drained staged generation into the live namespace,
145
+ * invalidate pre-cutover client cookies, and advance the live source cursor.
146
+ * @param {any} db
147
+ * @param {any} schema
148
+ * @param {string} generation
149
+ * @param {string} watermark
150
+ * @returns {any}
151
+ */
152
+ export function engine_finalize_snapshot_generation(db, schema, generation, watermark) {
153
+ const ptr0 = passStringToWasm0(generation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
154
+ const len0 = WASM_VECTOR_LEN;
155
+ const ptr1 = passStringToWasm0(watermark, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
156
+ const len1 = WASM_VECTOR_LEN;
157
+ const ret = wasm.engine_finalize_snapshot_generation(db, schema, ptr0, len0, ptr1, len1);
158
+ if (ret[2]) {
159
+ throw takeFromExternrefTable0(ret[1]);
160
+ }
161
+ return takeFromExternrefTable0(ret[0]);
162
+ }
163
+
81
164
  /**
82
165
  * Production pull entry. The TypeScript host owns `transactionSync`.
83
166
  * @param {any} db
@@ -221,6 +304,20 @@ export function engine_push_validate(body) {
221
304
  return takeFromExternrefTable0(ret[0]);
222
305
  }
223
306
 
307
+ /**
308
+ * Strictly read resumable snapshot progress. Storage and row-shape failures
309
+ * are errors; only a successful query with no active row returns null.
310
+ * @param {any} db
311
+ * @returns {any}
312
+ */
313
+ export function engine_read_snapshot_progress(db) {
314
+ const ret = wasm.engine_read_snapshot_progress(db);
315
+ if (ret[2]) {
316
+ throw takeFromExternrefTable0(ret[1]);
317
+ }
318
+ return takeFromExternrefTable0(ret[0]);
319
+ }
320
+
224
321
  /**
225
322
  * @param {any} db
226
323
  * @param {string} client_group_id
Binary file
@@ -329,6 +329,93 @@ const harnessMutators = registerMutators({
329
329
  },
330
330
  })
331
331
 
332
+ const WAKE_TOKEN_TTL_MS = 60_000
333
+
334
+ type HarnessWakeTokenPayload = {
335
+ namespace: string
336
+ userID: string
337
+ expiresAt: number
338
+ }
339
+
340
+ export async function mintHarnessWakeToken(
341
+ namespace: string,
342
+ userID: string,
343
+ secret: string
344
+ ): Promise<{ token: string; expiresAt: number }> {
345
+ const expiresAt = Date.now() + WAKE_TOKEN_TTL_MS
346
+ const payload = encodeBase64URL(
347
+ new TextEncoder().encode(JSON.stringify({ namespace, userID, expiresAt }))
348
+ )
349
+ return {
350
+ token: `${payload}.${await signWakeToken(payload, secret)}`,
351
+ expiresAt,
352
+ }
353
+ }
354
+
355
+ async function verifyHarnessWakeToken(
356
+ token: string,
357
+ namespace: string,
358
+ secret: string
359
+ ): Promise<boolean> {
360
+ try {
361
+ const [payload, signature, extra] = token.split('.')
362
+ if (!payload || !signature || extra) return false
363
+ const key = await wakeTokenKey(secret, ['verify'])
364
+ const valid = await crypto.subtle.verify(
365
+ 'HMAC',
366
+ key,
367
+ decodeBase64URL(signature),
368
+ new TextEncoder().encode(payload)
369
+ )
370
+ if (!valid) return false
371
+ const claims = JSON.parse(
372
+ new TextDecoder().decode(decodeBase64URL(payload))
373
+ ) as HarnessWakeTokenPayload
374
+ return (
375
+ claims.namespace === namespace &&
376
+ typeof claims.userID === 'string' &&
377
+ claims.userID.length > 0 &&
378
+ Number.isFinite(claims.expiresAt) &&
379
+ claims.expiresAt > Date.now()
380
+ )
381
+ } catch {
382
+ return false
383
+ }
384
+ }
385
+
386
+ async function signWakeToken(payload: string, secret: string): Promise<string> {
387
+ const key = await wakeTokenKey(secret, ['sign'])
388
+ const signature = await crypto.subtle.sign(
389
+ 'HMAC',
390
+ key,
391
+ new TextEncoder().encode(payload)
392
+ )
393
+ return encodeBase64URL(new Uint8Array(signature))
394
+ }
395
+
396
+ function wakeTokenKey(secret: string, usages: KeyUsage[]): Promise<CryptoKey> {
397
+ return crypto.subtle.importKey(
398
+ 'raw',
399
+ new TextEncoder().encode(secret),
400
+ { name: 'HMAC', hash: 'SHA-256' },
401
+ false,
402
+ usages
403
+ )
404
+ }
405
+
406
+ function encodeBase64URL(bytes: Uint8Array): string {
407
+ let binary = ''
408
+ for (const byte of bytes) binary += String.fromCharCode(byte)
409
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
410
+ }
411
+
412
+ function decodeBase64URL(value: string): ArrayBuffer {
413
+ const base64 = value.replaceAll('-', '+').replaceAll('_', '/')
414
+ const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='))
415
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0))
416
+ .buffer as ArrayBuffer
417
+ }
418
+
332
419
  export function harnessConfig<Env extends SyncHostEnv>(): SyncHostConfig<Env> {
333
420
  return {
334
421
  hostVersion: '0.1.0',
@@ -336,7 +423,9 @@ export function harnessConfig<Env extends SyncHostEnv>(): SyncHostConfig<Env> {
336
423
  mutators: harnessMutators,
337
424
  queryAware: false,
338
425
  queryTransformVersion: 1,
339
- resolveQuery(name, args) {
426
+ async resolveQuery(name, args) {
427
+ const delayMs = Number((args[0] as { delayMs?: unknown } | undefined)?.delayMs ?? 0)
428
+ if (delayMs > 0) await scheduler.wait(delayMs)
340
429
  return queryNameToAst(name, args) as never
341
430
  },
342
431
  initialize: initializeHarness,
@@ -349,6 +438,21 @@ export function harnessConfig<Env extends SyncHostEnv>(): SyncHostConfig<Env> {
349
438
  ?.match(/^Bearer token-(.+)$/)?.[1]
350
439
  return userID ? { userID } : null
351
440
  },
441
+ authorizeWake(request, env) {
442
+ const url = new URL(request.url)
443
+ const namespace = url.pathname.split('/')[1]
444
+ if (!namespace || !env.ADMIN_KEY) return false
445
+ return verifyHarnessWakeToken(
446
+ url.searchParams.get('wakeToken') ?? '',
447
+ namespace,
448
+ env.ADMIN_KEY
449
+ )
450
+ },
451
+ authorizeNotify(request, env) {
452
+ return (
453
+ Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
454
+ )
455
+ },
352
456
  visibility: {
353
457
  rowLocal: false,
354
458
  filter(table, claims) {
@@ -1,4 +1,4 @@
1
- import { harnessConfig } from './harness-config.js'
1
+ import { harnessConfig, mintHarnessWakeToken } from './harness-config.js'
2
2
  import { createSyncDurableObject, createSyncWorker } from './index.js'
3
3
 
4
4
  import type { SyncHostEnv } from './index.js'
@@ -8,6 +8,26 @@ interface Env extends SyncHostEnv {
8
8
  }
9
9
 
10
10
  const config = harnessConfig<Env>()
11
+ const syncWorker = createSyncWorker(config)
11
12
 
12
13
  export const SyncDurableObject = createSyncDurableObject(config)
13
- export default createSyncWorker(config)
14
+ export default {
15
+ async fetch(request, env, ctx): Promise<Response> {
16
+ const url = new URL(request.url)
17
+ const namespace = config.namespace(request)
18
+ const route = url.pathname.split('/').slice(2).join('/')
19
+ if (namespace && route === 'auth/wake-token') {
20
+ const claims = await config.authenticate(request, env)
21
+ if (!claims) {
22
+ return Response.json({ error: 'missing authentication' }, { status: 401 })
23
+ }
24
+ if (!env.ADMIN_KEY) {
25
+ return Response.json({ error: 'wake token minting unavailable' }, { status: 503 })
26
+ }
27
+ return Response.json(
28
+ await mintHarnessWakeToken(namespace, claims.userID, env.ADMIN_KEY)
29
+ )
30
+ }
31
+ return syncWorker.fetch!(request, env, ctx)
32
+ },
33
+ } satisfies ExportedHandler<Env>