orez-sync-cf-host 0.12.13 → 0.12.14

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/dist/host.js CHANGED
@@ -170,114 +170,154 @@ function socketCloseQuietly(socket, code, reason) {
170
170
  */
171
171
  export function createSyncWorker(config) {
172
172
  validateSyncHostConfig(config);
173
+ const allowedOrigins = new Set(config.allowedOrigins ?? []);
174
+ const corsHeaders = (origin) => ({
175
+ 'access-control-allow-origin': origin,
176
+ vary: 'origin',
177
+ });
173
178
  return {
174
179
  async fetch(request, env) {
175
- const namespace = config.namespace(request);
176
- if (!namespace)
177
- return new Response('orez sync-cf-host', { status: 200 });
178
- const route = routeAfterNamespace(new URL(request.url).pathname);
179
- const isAdmin = route.startsWith('/admin/');
180
- let wakeUserID = null;
181
- if (route === '/wake') {
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 });
180
+ const requestOrigin = request.headers.get('origin');
181
+ const corsOrigin = requestOrigin && allowedOrigins.has(requestOrigin) ? requestOrigin : null;
182
+ // answer allowed preflights before any routing or authentication: a
183
+ // preflight carries no Authorization header, so the auth wall below 401s
184
+ // it and the browser never sends the real request.
185
+ if (corsOrigin &&
186
+ request.method === 'OPTIONS' &&
187
+ request.headers.has('access-control-request-method')) {
188
+ return new Response(null, {
189
+ status: 204,
190
+ headers: {
191
+ ...corsHeaders(corsOrigin),
192
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
193
+ 'access-control-allow-headers': 'authorization, content-type',
194
+ 'access-control-max-age': '86400',
195
+ },
196
+ });
197
+ }
198
+ const withCors = (response) => {
199
+ // a websocket upgrade has an immutable response; the socket handshake
200
+ // is not CORS-gated anyway.
201
+ if (!corsOrigin || response.webSocket)
202
+ return response;
203
+ const wrapped = new Response(response.body, response);
204
+ for (const [name, value] of Object.entries(corsHeaders(corsOrigin))) {
205
+ wrapped.headers.set(name, value);
206
+ }
207
+ return wrapped;
208
+ };
209
+ const handle = async () => {
210
+ const namespace = config.namespace(request);
211
+ if (!namespace)
212
+ return new Response('orez sync-cf-host', { status: 200 });
213
+ const route = routeAfterNamespace(new URL(request.url).pathname);
214
+ const isAdmin = route.startsWith('/admin/');
215
+ let wakeUserID = null;
216
+ if (route === '/wake') {
217
+ let wakeRequest = request;
218
+ const protocol = request.headers.get('sec-websocket-protocol')?.trim();
219
+ const encodedAuth = protocol?.startsWith('orez-auth.') === true
220
+ ? protocol.slice('orez-auth.'.length)
221
+ : null;
222
+ if (!request.headers.has('authorization') &&
223
+ encodedAuth &&
224
+ /^[A-Za-z0-9_-]+$/.test(encodedAuth)) {
225
+ try {
226
+ const base64 = encodedAuth.replaceAll('-', '+').replaceAll('_', '/');
227
+ const binary = globalThis.atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
228
+ const authToken = new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
229
+ if (authToken) {
230
+ const wakeHeaders = new Headers(request.headers);
231
+ wakeHeaders.set('authorization', `Bearer ${authToken}`);
232
+ wakeRequest = new Request(request, { headers: wakeHeaders });
233
+ }
234
+ }
235
+ catch {
236
+ // malformed subprotocol credentials remain unauthenticated
198
237
  }
199
238
  }
200
- catch {
201
- // malformed subprotocol credentials remain unauthenticated
239
+ const wake = await config.authorizeWake(wakeRequest, env);
240
+ if (!wake)
241
+ return json({ error: 'missing wake capability' }, 401);
242
+ if (typeof wake === 'object')
243
+ wakeUserID = wake.userID;
244
+ // A namespace that streams fields authorizes every subscription against
245
+ // this userID, so a capability that does not carry one cannot open the
246
+ // socket. Failing here names the cause; accepting it would produce a
247
+ // wake-only socket whose subscriptions silently never deliver.
248
+ if (config.streamingManifest && !wakeUserID) {
249
+ return json({ error: 'wake capability must identify a user' }, 401);
202
250
  }
203
251
  }
204
- const wake = await config.authorizeWake(wakeRequest, env);
205
- if (!wake)
206
- return json({ error: 'missing wake capability' }, 401);
207
- if (typeof wake === 'object')
208
- wakeUserID = wake.userID;
209
- // A namespace that streams fields authorizes every subscription against
210
- // this userID, so a capability that does not carry one cannot open the
211
- // socket. Failing here names the cause; accepting it would produce a
212
- // wake-only socket whose subscriptions silently never deliver.
213
- if (config.streamingManifest && !wakeUserID) {
214
- return json({ error: 'wake capability must identify a user' }, 401);
215
- }
216
- }
217
- else if (route === '/notify') {
218
- if (!(await config.authorizeNotify(request, env))) {
219
- return json({ error: 'forbidden' }, 403);
220
- }
221
- }
222
- else if (isAdmin) {
223
- const authorized = config.authorizeAdmin
224
- ? await config.authorizeAdmin(request, env)
225
- : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY;
226
- if (!authorized)
227
- return json({ error: 'forbidden' }, 403);
228
- }
229
- const headers = new Headers(request.headers);
230
- headers.delete(NAMESPACE_HEADER);
231
- headers.delete(UPSTREAM_PATH_HEADER);
232
- headers.delete(IDENTITY_HEADER);
233
- let forwardedBody = null;
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.
238
- if (!isAdmin &&
239
- route !== '/wake' &&
240
- route !== '/notify' &&
241
- route !== '/realtime/produce') {
242
- const claims = await config.authenticate(request, env);
243
- if (!claims || typeof claims.userID !== 'string' || claims.userID.length === 0) {
244
- return json({ error: 'missing authentication' }, 401);
252
+ else if (route === '/notify') {
253
+ if (!(await config.authorizeNotify(request, env))) {
254
+ return json({ error: 'forbidden' }, 403);
255
+ }
245
256
  }
246
- if (!(await config.authorize(request, claims, namespace, env))) {
247
- return json({ error: 'forbidden' }, 403);
257
+ else if (isAdmin) {
258
+ const authorized = config.authorizeAdmin
259
+ ? await config.authorizeAdmin(request, env)
260
+ : Boolean(env.ADMIN_KEY) &&
261
+ request.headers.get('x-admin-key') === env.ADMIN_KEY;
262
+ if (!authorized)
263
+ return json({ error: 'forbidden' }, 403);
248
264
  }
249
- if ((route === '/pull' || route === '/push') && request.method === 'POST') {
250
- try {
251
- const body = await requestObject(request);
252
- forwardedBody = { claims, body };
265
+ const headers = new Headers(request.headers);
266
+ headers.delete(NAMESPACE_HEADER);
267
+ headers.delete(UPSTREAM_PATH_HEADER);
268
+ headers.delete(IDENTITY_HEADER);
269
+ let forwardedBody = null;
270
+ // /wake and /realtime/produce are both websocket upgrades and have no
271
+ // body to put claims in. wake authentication is normalized from its
272
+ // WebSocket subprotocol above; each route has its own authorization check
273
+ // before either reaches the Durable Object.
274
+ if (!isAdmin &&
275
+ route !== '/wake' &&
276
+ route !== '/notify' &&
277
+ route !== '/realtime/produce') {
278
+ const claims = await config.authenticate(request, env);
279
+ if (!claims ||
280
+ typeof claims.userID !== 'string' ||
281
+ claims.userID.length === 0) {
282
+ return json({ error: 'missing authentication' }, 401);
253
283
  }
254
- catch (error) {
255
- return errorResponse(error);
284
+ if (!(await config.authorize(request, claims, namespace, env))) {
285
+ return json({ error: 'forbidden' }, 403);
286
+ }
287
+ if ((route === '/pull' || route === '/push') && request.method === 'POST') {
288
+ try {
289
+ const body = await requestObject(request);
290
+ forwardedBody = { claims, body };
291
+ }
292
+ catch (error) {
293
+ return errorResponse(error);
294
+ }
256
295
  }
257
296
  }
258
- }
259
- // Only the userID travels, because it is the only part the DO must be
260
- // unable to doubt. The socket also carries a clientID and clientGroupID,
261
- // but those are the client's own assertion and the engine checks the
262
- // group against this userID before it will read a single row, so there is
263
- // nothing gained by moving them here.
264
- if (wakeUserID)
265
- headers.set(IDENTITY_HEADER, encodeURIComponent(wakeUserID));
266
- headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
267
- if (config.upstream) {
268
- const namespacePath = typeof config.upstream.namespacePath === 'function'
269
- ? config.upstream.namespacePath(namespace)
270
- : config.upstream.namespacePath;
271
- if (!namespacePath.startsWith('/')) {
272
- return json({ error: 'upstream namespacePath must be an absolute path' }, 500);
297
+ // Only the userID travels, because it is the only part the DO must be
298
+ // unable to doubt. The socket also carries a clientID and clientGroupID,
299
+ // but those are the client's own assertion and the engine checks the
300
+ // group against this userID before it will read a single row, so there is
301
+ // nothing gained by moving them here.
302
+ if (wakeUserID)
303
+ headers.set(IDENTITY_HEADER, encodeURIComponent(wakeUserID));
304
+ headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
305
+ if (config.upstream) {
306
+ const namespacePath = typeof config.upstream.namespacePath === 'function'
307
+ ? config.upstream.namespacePath(namespace)
308
+ : config.upstream.namespacePath;
309
+ if (!namespacePath.startsWith('/')) {
310
+ return json({ error: 'upstream namespacePath must be an absolute path' }, 500);
311
+ }
312
+ headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''));
273
313
  }
274
- headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''));
275
- }
276
- const forwarded = forwardedBody
277
- ? jsonBodyRequest(request, headers, forwardedBody)
278
- : new Request(request, { headers });
279
- const id = env.SYNC_DO.idFromName(namespace);
280
- return env.SYNC_DO.get(id).fetch(forwarded);
314
+ const forwarded = forwardedBody
315
+ ? jsonBodyRequest(request, headers, forwardedBody)
316
+ : new Request(request, { headers });
317
+ const id = env.SYNC_DO.idFromName(namespace);
318
+ return env.SYNC_DO.get(id).fetch(forwarded);
319
+ };
320
+ return withCors(await handle());
281
321
  },
282
322
  };
283
323
  }
@@ -1314,7 +1354,10 @@ export function createSyncDurableObject(config) {
1314
1354
  // pull that records it.
1315
1355
  if (result.ownsGroup)
1316
1356
  return { status: 'pending' };
1317
- return { status: 'denied', reason: 'row is not in this client group' };
1357
+ return {
1358
+ status: 'denied',
1359
+ reason: 'row is not in this client group',
1360
+ };
1318
1361
  },
1319
1362
  });
1320
1363
  return this.#realtime;
@@ -1392,7 +1435,10 @@ export function createSyncDurableObject(config) {
1392
1435
  clientGroupID,
1393
1436
  }
1394
1437
  : undefined;
1395
- server.serializeAttachment({ clientID, identity });
1438
+ server.serializeAttachment({
1439
+ clientID,
1440
+ identity,
1441
+ });
1396
1442
  this.ctx.acceptWebSocket(server, [`client:${clientID}`]);
1397
1443
  if (identity) {
1398
1444
  const host = this.#realtimeHost();
@@ -1447,7 +1493,9 @@ export function createSyncDurableObject(config) {
1447
1493
  if (typeof query !== 'string')
1448
1494
  return json({ error: 'query is required' }, 400);
1449
1495
  try {
1450
- return json({ rows: this.#directSql.query(query, decodeSqlParams(params)) });
1496
+ return json({
1497
+ rows: this.#directSql.query(query, decodeSqlParams(params)),
1498
+ });
1451
1499
  }
1452
1500
  catch (error) {
1453
1501
  if (error instanceof TypeError && error.message.startsWith('params')) {
package/dist/types.d.ts CHANGED
@@ -129,4 +129,14 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv, S extends Sche
129
129
  /** per-query guard for recursive transaction query materialization. */
130
130
  transactionQueryBudget?: Partial<TransactionQueryBudget>;
131
131
  authorizeAdmin?: (request: Request, env: Env) => boolean | Promise<boolean>;
132
+ /**
133
+ * Browser origins (exact match, e.g. `https://ios-myapp.example.com`) allowed
134
+ * to call this host cross-origin. The host answers their CORS preflights
135
+ * before authentication (a preflight carries no credentials, so an auth-first
136
+ * router 401s it and the browser never sends the real request) and stamps
137
+ * allow-origin on their responses. Clients authenticate with a bearer
138
+ * Authorization header, so no cookie credentials are allowed or needed.
139
+ * Absent means same-origin consumers only.
140
+ */
141
+ allowedOrigins?: readonly string[];
132
142
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.12.13",
3
+ "version": "0.12.14",
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.13"
61
+ "orez-sync-executor": "0.12.14"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@cloudflare/workers-types": "4.20260617.1",