orez-sync-cf-host 0.12.4 → 0.12.5

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
@@ -90,11 +90,21 @@ must not run the Vite loader.
90
90
 
91
91
  ## Wake channel and eviction
92
92
 
93
- `GET /<namespace>/wake?clientID=<id>&wakeToken=<capability>` upgrades to a
94
- Durable Object hibernating WebSocket after `authorizeWake` accepts the
95
- capability. Browser consumers should mint a short-lived, namespace-scoped token
96
- at their authenticated edge because the native WebSocket constructor cannot set
97
- an authorization header. Socket attachments carry only the client ID. A
93
+ `createZeroClientTransport()` opens `GET /<namespace>/wake?clientID=<id>` by
94
+ default and keeps a five-minute pull as a safety check. It carries the auth token
95
+ Zero already supplied in a WebSocket subprotocol. The sync worker converts that
96
+ credential to the ordinary `Authorization` header before calling
97
+ `authorizeWake`, so the standard setup reuses the application's existing
98
+ authentication:
99
+
100
+ ```ts
101
+ authorizeWake: async (request, env) => {
102
+ const claims = await authenticate(request, env)
103
+ return claims ? { userID: claims.userID } : false
104
+ }
105
+ ```
106
+
107
+ Socket attachments carry only the client ID and normalized user identity. A
98
108
  committed push sends a text `wake` frame to all connected clients except the
99
109
  pusher; a scheduler window
100
110
  coalesces a burst into one frame per socket. `ping` receives `pong`. The message
@@ -102,12 +112,10 @@ contains no state and carries no correctness weight: clients pull after a wake
102
112
  and retain their safety poll. `ctx.getWebSockets()` plus serialized attachments
103
113
  means sockets remain discoverable after hibernation/re-instantiation.
104
114
 
105
- ### Consumer-minted wake capabilities
115
+ ### Custom wake capabilities
106
116
 
107
- The consumer Worker owns both token minting and verification. Add an
108
- authenticated edge route that signs the namespace and a short expiry, typically
109
- 30 to 60 seconds, with a secret that never reaches the browser. Return only the
110
- signed token:
117
+ Deployments that cannot reuse Zero's bearer token can provide a custom
118
+ short-lived capability. The consumer Worker owns both minting and verification:
111
119
 
112
120
  ```ts
113
121
  // consumer edge route, after normal session authentication
@@ -116,7 +124,7 @@ const token = await signWakeToken({ namespace, userID, expiresAt }, env.WAKE_SEC
116
124
  return Response.json({ token, expiresAt })
117
125
  ```
118
126
 
119
- Pass a mint callback to the canonical HTTP transport. It calls `getToken()` for
127
+ Pass a mint callback to the low-level HTTP transport. It calls `getToken()` for
120
128
  every socket attempt, including reconnects, so short-lived tokens are never
121
129
  reused after the wake connection drops:
122
130
 
@@ -125,7 +133,7 @@ import { ensureHttpPullTransport } from 'orez-lite/client'
125
133
 
126
134
  ensureHttpPullTransport({
127
135
  origin: syncOrigin,
128
- pullIntervalMs: 5_000,
136
+ pullIntervalMs: 300_000,
129
137
  wake: {
130
138
  async getToken() {
131
139
  const response = await fetch(`/api/sync/${namespace}/wake-token`, {
package/dist/host.js CHANGED
@@ -179,7 +179,29 @@ export function createSyncWorker(config) {
179
179
  const isAdmin = route.startsWith('/admin/');
180
180
  let wakeUserID = null;
181
181
  if (route === '/wake') {
182
- const wake = await config.authorizeWake(request, env);
182
+ let wakeRequest = request;
183
+ const protocol = request.headers.get('sec-websocket-protocol')?.trim();
184
+ const encodedAuth = protocol?.startsWith('orez-auth.') === true
185
+ ? protocol.slice('orez-auth.'.length)
186
+ : null;
187
+ if (!request.headers.has('authorization') &&
188
+ encodedAuth &&
189
+ /^[A-Za-z0-9_-]+$/.test(encodedAuth)) {
190
+ try {
191
+ const base64 = encodedAuth.replaceAll('-', '+').replaceAll('_', '/');
192
+ const binary = globalThis.atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
193
+ const authToken = new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
194
+ if (authToken) {
195
+ const wakeHeaders = new Headers(request.headers);
196
+ wakeHeaders.set('authorization', `Bearer ${authToken}`);
197
+ wakeRequest = new Request(request, { headers: wakeHeaders });
198
+ }
199
+ }
200
+ catch {
201
+ // malformed subprotocol credentials remain unauthenticated
202
+ }
203
+ }
204
+ const wake = await config.authorizeWake(wakeRequest, env);
183
205
  if (!wake)
184
206
  return json({ error: 'missing wake capability' }, 401);
185
207
  if (typeof wake === 'object')
@@ -209,10 +231,10 @@ export function createSyncWorker(config) {
209
231
  headers.delete(UPSTREAM_PATH_HEADER);
210
232
  headers.delete(IDENTITY_HEADER);
211
233
  let forwardedBody = null;
212
- // /wake and /realtime/produce are both websocket upgrades, which cannot
213
- // carry an Authorization header from a browser and have no body to put
214
- // claims in. Each has its own capability check above and in the DO, so
215
- // neither passes through the bearer-token gate below.
234
+ // /wake and /realtime/produce are both websocket upgrades and have no
235
+ // body to put claims in. wake authentication is normalized from its
236
+ // WebSocket subprotocol above; each route has its own authorization check
237
+ // before either reaches the Durable Object.
216
238
  if (!isAdmin &&
217
239
  route !== '/wake' &&
218
240
  route !== '/notify' &&
@@ -1384,7 +1406,12 @@ export function createSyncDurableObject(config) {
1384
1406
  // namespace poll upstream forever after its first request, even across
1385
1407
  // DO eviction, producing a permanent rows-written floor at zero traffic.
1386
1408
  this.#armUpstreamAlarm();
1387
- return new Response(null, { status: 101, webSocket: client });
1409
+ const protocol = request.headers.get('sec-websocket-protocol');
1410
+ return new Response(null, {
1411
+ status: 101,
1412
+ headers: protocol ? { 'Sec-WebSocket-Protocol': protocol } : undefined,
1413
+ webSocket: client,
1414
+ });
1388
1415
  }
1389
1416
  #admin(route, request, upstreamPath) {
1390
1417
  if (route === '/admin/health')
package/dist/types.d.ts CHANGED
@@ -73,16 +73,16 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv, S extends Sche
73
73
  /** Authorize authenticated application access before selecting a namespace DO. */
74
74
  authorize(request: Request, claims: NormalizedClaims, namespace: string, env: Env): boolean | Promise<boolean>;
75
75
  /** Authorize the advisory wake socket before selecting a namespace DO.
76
- * Browser clients should present a short-lived, namespace-scoped capability
77
- * in the query string because WebSocket cannot set request headers.
76
+ * The standard client carries its existing Zero auth token in a WebSocket
77
+ * subprotocol. The host normalizes that to the ordinary Authorization header
78
+ * before this callback runs.
78
79
  *
79
80
  * Return `{ userID }` instead of `true` to also identify the socket, which is
80
81
  * what a namespace serving `streamingManifest` must do: field subscriptions
81
82
  * ride this socket and are authorized against that userID. Returning bare
82
83
  * `true` there is refused rather than quietly downgraded to a wake-only
83
84
  * socket, because the failure would otherwise look like streaming that just
84
- * never arrives. The capability is the only credential available here, so the
85
- * userID belongs inside it. */
85
+ * never arrives. */
86
86
  authorizeWake(request: Request, env: Env): boolean | {
87
87
  userID: string;
88
88
  } | Promise<boolean | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
4
4
  "description": "Internal orez Cloudflare sync host. Do not import directly: consumers use orez-lite/cloudflare/sync.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,7 +58,7 @@
58
58
  "test:large-data": "bun run build:dist && bun large-data-test.mjs"
59
59
  },
60
60
  "dependencies": {
61
- "orez-sync-executor": "0.12.4"
61
+ "orez-sync-executor": "0.12.5"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@cloudflare/workers-types": "4.20260617.1",