orez-sync-cf-host 0.5.17 → 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 +66 -3
- package/package.json +1 -1
- package/src/config.ts +23 -0
- package/src/generated/sync_wasm.d.ts +35 -0
- package/src/generated/sync_wasm.js +97 -0
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/harness-config.ts +105 -1
- package/src/harness-worker.ts +22 -2
- package/src/host.ts +446 -137
- package/src/ingest-harness-worker.ts +75 -4
- package/src/types.ts +9 -1
- package/src/write-safeguards.ts +14 -15
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
|
|
23
|
-
|
|
24
|
-
|
|
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
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
|
package/src/harness-config.ts
CHANGED
|
@@ -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) {
|
package/src/harness-worker.ts
CHANGED
|
@@ -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
|
|
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>
|