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 +147 -99
- package/dist/types.d.ts +10 -0
- package/package.json +2 -2
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 (
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
return
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
headers.set(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return
|
|
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 {
|
|
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({
|
|
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({
|
|
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.
|
|
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.
|
|
61
|
+
"orez-sync-executor": "0.12.14"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@cloudflare/workers-types": "4.20260617.1",
|