@syncular/server-hono 0.0.6-86 → 0.0.6-89
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server-hono",
|
|
3
|
-
"version": "0.0.6-
|
|
3
|
+
"version": "0.0.6-89",
|
|
4
4
|
"description": "Hono adapter for the Syncular server with OpenAPI support",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -62,17 +62,17 @@
|
|
|
62
62
|
"@hono/standard-validator": "^0.2.2",
|
|
63
63
|
"@standard-community/standard-json": "^0.3.5",
|
|
64
64
|
"@standard-community/standard-openapi": "^0.2.9",
|
|
65
|
-
"@syncular/console": "0.0.6-
|
|
66
|
-
"@syncular/core": "0.0.6-
|
|
67
|
-
"@syncular/server": "0.0.6-
|
|
65
|
+
"@syncular/console": "0.0.6-89",
|
|
66
|
+
"@syncular/core": "0.0.6-89",
|
|
67
|
+
"@syncular/server": "0.0.6-89",
|
|
68
68
|
"@types/json-schema": "^7.0.15",
|
|
69
69
|
"hono-openapi": "^1.2.0",
|
|
70
70
|
"openapi-types": "^12.1.3"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@syncular/config": "0.0.0",
|
|
74
|
-
"@syncular/dialect-pglite": "0.0.6-
|
|
75
|
-
"@syncular/server-dialect-postgres": "0.0.6-
|
|
74
|
+
"@syncular/dialect-pglite": "0.0.6-89",
|
|
75
|
+
"@syncular/server-dialect-postgres": "0.0.6-89",
|
|
76
76
|
"kysely": "*",
|
|
77
77
|
"zod": "*"
|
|
78
78
|
},
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { BlobStorageAdapter } from '@syncular/core';
|
|
3
|
+
import { createDatabase } from '@syncular/core';
|
|
4
|
+
import {
|
|
5
|
+
type BlobTokenSigner,
|
|
6
|
+
createBlobManager,
|
|
7
|
+
createDatabaseBlobStorageAdapter,
|
|
8
|
+
createHmacTokenSigner,
|
|
9
|
+
ensureBlobStorageSchemaSqlite,
|
|
10
|
+
type SyncBlobDb,
|
|
11
|
+
} from '@syncular/server';
|
|
12
|
+
import { Hono } from 'hono';
|
|
13
|
+
import type { Kysely } from 'kysely';
|
|
14
|
+
import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
|
|
15
|
+
import { createBlobRoutes } from '../blobs';
|
|
16
|
+
|
|
17
|
+
interface UploadInitResponse {
|
|
18
|
+
exists: boolean;
|
|
19
|
+
uploadUrl?: string;
|
|
20
|
+
uploadMethod?: 'PUT' | 'POST';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UrlResponse {
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CompleteResponse {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ACTOR_HEADER = 'x-user-id';
|
|
33
|
+
const ACTOR_ID = 'user-1';
|
|
34
|
+
const INVALID_HASH = 'invalid-hash';
|
|
35
|
+
|
|
36
|
+
function toHex(bytes: Uint8Array): string {
|
|
37
|
+
return Array.from(bytes)
|
|
38
|
+
.map((value) => value.toString(16).padStart(2, '0'))
|
|
39
|
+
.join('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createHash(bytes: Uint8Array): Promise<string> {
|
|
43
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
44
|
+
return `sha256:${toHex(new Uint8Array(digest))}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function signBlobToken(args: {
|
|
48
|
+
signer: BlobTokenSigner;
|
|
49
|
+
hash: string;
|
|
50
|
+
action: 'upload' | 'download';
|
|
51
|
+
}): Promise<string> {
|
|
52
|
+
return args.signer.sign(
|
|
53
|
+
{
|
|
54
|
+
hash: args.hash,
|
|
55
|
+
action: args.action,
|
|
56
|
+
expiresAt: Date.now() + 60_000,
|
|
57
|
+
},
|
|
58
|
+
60
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createDefaultAdapter(
|
|
63
|
+
db: Kysely<SyncBlobDb>,
|
|
64
|
+
tokenSigner: BlobTokenSigner
|
|
65
|
+
): BlobStorageAdapter {
|
|
66
|
+
return createDatabaseBlobStorageAdapter({
|
|
67
|
+
db,
|
|
68
|
+
baseUrl: 'http://localhost/sync',
|
|
69
|
+
tokenSigner,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createFallbackAdapter(
|
|
74
|
+
db: Kysely<SyncBlobDb>,
|
|
75
|
+
tokenSigner: BlobTokenSigner
|
|
76
|
+
): BlobStorageAdapter {
|
|
77
|
+
const adapter = createDefaultAdapter(db, tokenSigner);
|
|
78
|
+
return {
|
|
79
|
+
name: 'database-fallback',
|
|
80
|
+
signUpload: adapter.signUpload,
|
|
81
|
+
signDownload: adapter.signDownload,
|
|
82
|
+
exists: adapter.exists,
|
|
83
|
+
delete: adapter.delete,
|
|
84
|
+
getMetadata: adapter.getMetadata,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildApp(args: {
|
|
89
|
+
db: Kysely<SyncBlobDb>;
|
|
90
|
+
tokenSigner: BlobTokenSigner;
|
|
91
|
+
adapter: BlobStorageAdapter;
|
|
92
|
+
authenticate?: (
|
|
93
|
+
c: Parameters<typeof createBlobRoutes>[0]['authenticate']
|
|
94
|
+
) => ReturnType<Parameters<typeof createBlobRoutes>[0]['authenticate']>;
|
|
95
|
+
canAccessBlob?: Parameters<typeof createBlobRoutes>[0]['canAccessBlob'];
|
|
96
|
+
}): Hono {
|
|
97
|
+
const blobManager = createBlobManager({
|
|
98
|
+
db: args.db,
|
|
99
|
+
adapter: args.adapter,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const app = new Hono();
|
|
103
|
+
app.route(
|
|
104
|
+
'/sync',
|
|
105
|
+
createBlobRoutes({
|
|
106
|
+
blobManager,
|
|
107
|
+
authenticate: async (c) => {
|
|
108
|
+
if (args.authenticate) {
|
|
109
|
+
return args.authenticate(c);
|
|
110
|
+
}
|
|
111
|
+
const actorId = c.req.header(ACTOR_HEADER);
|
|
112
|
+
return actorId ? { actorId } : null;
|
|
113
|
+
},
|
|
114
|
+
tokenSigner: args.tokenSigner,
|
|
115
|
+
db: args.db,
|
|
116
|
+
canAccessBlob: args.canAccessBlob,
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
return app;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function initiateUpload(args: {
|
|
123
|
+
app: Hono;
|
|
124
|
+
hash: string;
|
|
125
|
+
size: number;
|
|
126
|
+
mimeType?: string;
|
|
127
|
+
}): Promise<UploadInitResponse> {
|
|
128
|
+
const response = await args.app.request(
|
|
129
|
+
'http://localhost/sync/blobs/upload',
|
|
130
|
+
{
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
'content-type': 'application/json',
|
|
134
|
+
[ACTOR_HEADER]: ACTOR_ID,
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
hash: args.hash,
|
|
138
|
+
size: args.size,
|
|
139
|
+
mimeType: args.mimeType ?? 'application/octet-stream',
|
|
140
|
+
}),
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(response.status).toBe(200);
|
|
145
|
+
return (await response.json()) as UploadInitResponse;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe('createBlobRoutes', () => {
|
|
149
|
+
let db: Kysely<SyncBlobDb>;
|
|
150
|
+
let tokenSigner: BlobTokenSigner;
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
db = createDatabase<SyncBlobDb>({
|
|
154
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
155
|
+
family: 'sqlite',
|
|
156
|
+
});
|
|
157
|
+
await ensureBlobStorageSchemaSqlite(db);
|
|
158
|
+
tokenSigner = createHmacTokenSigner('blob-route-test-secret');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(async () => {
|
|
162
|
+
await db.destroy();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rejects unauthenticated upload initiation', async () => {
|
|
166
|
+
const app = buildApp({
|
|
167
|
+
db,
|
|
168
|
+
tokenSigner,
|
|
169
|
+
adapter: createDefaultAdapter(db, tokenSigner),
|
|
170
|
+
authenticate: async () => null,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const hash = await createHash(new Uint8Array([1, 2, 3]));
|
|
174
|
+
const response = await app.request('http://localhost/sync/blobs/upload', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'content-type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
hash,
|
|
179
|
+
size: 3,
|
|
180
|
+
mimeType: 'application/octet-stream',
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(response.status).toBe(401);
|
|
185
|
+
expect(await response.json()).toEqual({ error: 'UNAUTHENTICATED' });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('rejects invalid direct-upload tokens', async () => {
|
|
189
|
+
const app = buildApp({
|
|
190
|
+
db,
|
|
191
|
+
tokenSigner,
|
|
192
|
+
adapter: createDefaultAdapter(db, tokenSigner),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const response = await app.request(
|
|
196
|
+
`http://localhost/sync/blobs/${encodeURIComponent(`sha256:${'a'.repeat(64)}`)}/upload?token=invalid-token`,
|
|
197
|
+
{
|
|
198
|
+
method: 'PUT',
|
|
199
|
+
body: new Uint8Array([1, 2, 3]),
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(response.status).toBe(401);
|
|
204
|
+
expect(await response.json()).toEqual({ error: 'INVALID_TOKEN' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rejects direct upload when body size does not match metadata', async () => {
|
|
208
|
+
const app = buildApp({
|
|
209
|
+
db,
|
|
210
|
+
tokenSigner,
|
|
211
|
+
adapter: createDefaultAdapter(db, tokenSigner),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const content = new Uint8Array([1, 2, 3, 4]);
|
|
215
|
+
const hash = await createHash(content);
|
|
216
|
+
const init = await initiateUpload({
|
|
217
|
+
app,
|
|
218
|
+
hash,
|
|
219
|
+
size: content.length,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const firstUpload = await app.request(init.uploadUrl!, {
|
|
223
|
+
method: init.uploadMethod ?? 'PUT',
|
|
224
|
+
body: content,
|
|
225
|
+
});
|
|
226
|
+
expect(firstUpload.status).toBe(200);
|
|
227
|
+
|
|
228
|
+
const complete = await app.request(
|
|
229
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
|
|
230
|
+
{
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
expect(complete.status).toBe(200);
|
|
236
|
+
|
|
237
|
+
const token = await signBlobToken({
|
|
238
|
+
signer: tokenSigner,
|
|
239
|
+
hash,
|
|
240
|
+
action: 'upload',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const response = await app.request(
|
|
244
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/upload?token=${encodeURIComponent(token)}`,
|
|
245
|
+
{
|
|
246
|
+
method: 'PUT',
|
|
247
|
+
body: new Uint8Array([1, 2, 3]),
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(response.status).toBe(400);
|
|
252
|
+
const payload = (await response.json()) as { error: string };
|
|
253
|
+
expect(payload.error).toBe('SIZE_MISMATCH');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('rejects direct upload when body hash does not match route hash', async () => {
|
|
257
|
+
const app = buildApp({
|
|
258
|
+
db,
|
|
259
|
+
tokenSigner,
|
|
260
|
+
adapter: createDefaultAdapter(db, tokenSigner),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const expected = new Uint8Array([1, 2, 3, 4]);
|
|
264
|
+
const hash = await createHash(expected);
|
|
265
|
+
await initiateUpload({
|
|
266
|
+
app,
|
|
267
|
+
hash,
|
|
268
|
+
size: expected.length,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const token = await signBlobToken({
|
|
272
|
+
signer: tokenSigner,
|
|
273
|
+
hash,
|
|
274
|
+
action: 'upload',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const response = await app.request(
|
|
278
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/upload?token=${encodeURIComponent(token)}`,
|
|
279
|
+
{
|
|
280
|
+
method: 'PUT',
|
|
281
|
+
body: new Uint8Array([9, 9, 9, 9]),
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(response.status).toBe(400);
|
|
286
|
+
const payload = (await response.json()) as { error: string };
|
|
287
|
+
expect(payload.error).toBe('HASH_MISMATCH');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns 404 for invalid hash format and 403 for forbidden actor access', async () => {
|
|
291
|
+
const app = buildApp({
|
|
292
|
+
db,
|
|
293
|
+
tokenSigner,
|
|
294
|
+
adapter: createDefaultAdapter(db, tokenSigner),
|
|
295
|
+
canAccessBlob: async () => false,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const invalidHashResponse = await app.request(
|
|
299
|
+
`http://localhost/sync/blobs/${INVALID_HASH}/url`,
|
|
300
|
+
{
|
|
301
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
expect(invalidHashResponse.status).toBe(404);
|
|
305
|
+
expect(await invalidHashResponse.json()).toEqual({ error: 'NOT_FOUND' });
|
|
306
|
+
|
|
307
|
+
const validHash = `sha256:${'b'.repeat(64)}`;
|
|
308
|
+
const forbiddenResponse = await app.request(
|
|
309
|
+
`http://localhost/sync/blobs/${encodeURIComponent(validHash)}/url`,
|
|
310
|
+
{
|
|
311
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
expect(forbiddenResponse.status).toBe(403);
|
|
315
|
+
expect(await forbiddenResponse.json()).toEqual({ error: 'FORBIDDEN' });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('uploads and downloads blobs through adapter put/get branches', async () => {
|
|
319
|
+
const app = buildApp({
|
|
320
|
+
db,
|
|
321
|
+
tokenSigner,
|
|
322
|
+
adapter: createDefaultAdapter(db, tokenSigner),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const content = new TextEncoder().encode('adapter-route-content');
|
|
326
|
+
const hash = await createHash(content);
|
|
327
|
+
const init = await initiateUpload({
|
|
328
|
+
app,
|
|
329
|
+
hash,
|
|
330
|
+
size: content.length,
|
|
331
|
+
mimeType: 'text/plain',
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(init.exists).toBe(false);
|
|
335
|
+
expect(typeof init.uploadUrl).toBe('string');
|
|
336
|
+
|
|
337
|
+
const uploadResponse = await app.request(init.uploadUrl!, {
|
|
338
|
+
method: init.uploadMethod ?? 'PUT',
|
|
339
|
+
headers: { 'content-type': 'text/plain' },
|
|
340
|
+
body: content,
|
|
341
|
+
});
|
|
342
|
+
expect(uploadResponse.status).toBe(200);
|
|
343
|
+
|
|
344
|
+
const completeResponse = await app.request(
|
|
345
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
|
|
346
|
+
{
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
349
|
+
}
|
|
350
|
+
);
|
|
351
|
+
expect(completeResponse.status).toBe(200);
|
|
352
|
+
expect((await completeResponse.json()) as CompleteResponse).toEqual({
|
|
353
|
+
ok: true,
|
|
354
|
+
metadata: expect.anything(),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const downloadUrlResponse = await app.request(
|
|
358
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/url`,
|
|
359
|
+
{
|
|
360
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
expect(downloadUrlResponse.status).toBe(200);
|
|
364
|
+
const { url } = (await downloadUrlResponse.json()) as UrlResponse;
|
|
365
|
+
|
|
366
|
+
const downloadResponse = await app.request(url);
|
|
367
|
+
expect(downloadResponse.status).toBe(200);
|
|
368
|
+
expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(
|
|
369
|
+
content
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('uploads and downloads blobs through DB fallback branches when adapter lacks put/get', async () => {
|
|
374
|
+
const app = buildApp({
|
|
375
|
+
db,
|
|
376
|
+
tokenSigner,
|
|
377
|
+
adapter: createFallbackAdapter(db, tokenSigner),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const content = new TextEncoder().encode('database-fallback-content');
|
|
381
|
+
const hash = await createHash(content);
|
|
382
|
+
const init = await initiateUpload({
|
|
383
|
+
app,
|
|
384
|
+
hash,
|
|
385
|
+
size: content.length,
|
|
386
|
+
mimeType: 'text/plain',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const uploadResponse = await app.request(init.uploadUrl!, {
|
|
390
|
+
method: init.uploadMethod ?? 'PUT',
|
|
391
|
+
headers: { 'content-type': 'text/plain' },
|
|
392
|
+
body: content,
|
|
393
|
+
});
|
|
394
|
+
expect(uploadResponse.status).toBe(200);
|
|
395
|
+
|
|
396
|
+
const completeResponse = await app.request(
|
|
397
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
|
|
398
|
+
{
|
|
399
|
+
method: 'POST',
|
|
400
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
expect(completeResponse.status).toBe(200);
|
|
404
|
+
expect((await completeResponse.json()) as CompleteResponse).toEqual({
|
|
405
|
+
ok: true,
|
|
406
|
+
metadata: expect.anything(),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const urlResponse = await app.request(
|
|
410
|
+
`http://localhost/sync/blobs/${encodeURIComponent(hash)}/url`,
|
|
411
|
+
{
|
|
412
|
+
headers: { [ACTOR_HEADER]: ACTOR_ID },
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
expect(urlResponse.status).toBe(200);
|
|
416
|
+
const payload = (await urlResponse.json()) as UrlResponse;
|
|
417
|
+
|
|
418
|
+
const downloadResponse = await app.request(payload.url);
|
|
419
|
+
expect(downloadResponse.status).toBe(200);
|
|
420
|
+
expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(
|
|
421
|
+
content
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
});
|