@syncular/server-hono 0.0.1-60
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/api-key-auth.d.ts +49 -0
- package/dist/api-key-auth.d.ts.map +1 -0
- package/dist/api-key-auth.js +110 -0
- package/dist/api-key-auth.js.map +1 -0
- package/dist/blobs.d.ts +69 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +383 -0
- package/dist/blobs.js.map +1 -0
- package/dist/console/index.d.ts +8 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +7 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/routes.d.ts +106 -0
- package/dist/console/routes.d.ts.map +1 -0
- package/dist/console/routes.js +1612 -0
- package/dist/console/routes.js.map +1 -0
- package/dist/console/schemas.d.ts +308 -0
- package/dist/console/schemas.d.ts.map +1 -0
- package/dist/console/schemas.js +201 -0
- package/dist/console/schemas.js.map +1 -0
- package/dist/create-server.d.ts +78 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +99 -0
- package/dist/create-server.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +59 -0
- package/dist/openapi.js.map +1 -0
- package/dist/proxy/connection-manager.d.ts +78 -0
- package/dist/proxy/connection-manager.d.ts.map +1 -0
- package/dist/proxy/connection-manager.js +251 -0
- package/dist/proxy/connection-manager.js.map +1 -0
- package/dist/proxy/index.d.ts +8 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +8 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/routes.d.ts +74 -0
- package/dist/proxy/routes.d.ts.map +1 -0
- package/dist/proxy/routes.js +147 -0
- package/dist/proxy/routes.js.map +1 -0
- package/dist/rate-limit.d.ts +101 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +186 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes.d.ts +126 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +788 -0
- package/dist/routes.js.map +1 -0
- package/dist/ws.d.ts +230 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +601 -0
- package/dist/ws.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/create-server.test.ts +187 -0
- package/src/__tests__/pull-chunk-storage.test.ts +189 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -0
- package/src/__tests__/ws-connection-manager.test.ts +176 -0
- package/src/api-key-auth.ts +179 -0
- package/src/blobs.ts +534 -0
- package/src/console/index.ts +17 -0
- package/src/console/routes.ts +2155 -0
- package/src/console/schemas.ts +299 -0
- package/src/create-server.ts +180 -0
- package/src/index.ts +42 -0
- package/src/openapi.ts +74 -0
- package/src/proxy/connection-manager.ts +340 -0
- package/src/proxy/index.ts +8 -0
- package/src/proxy/routes.ts +223 -0
- package/src/rate-limit.ts +321 -0
- package/src/routes.ts +1186 -0
- package/src/ws.ts +789 -0
package/src/blobs.ts
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server-hono - Blob routes for media/binary handling
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - POST /blobs/upload - Initiate a blob upload (get presigned URL)
|
|
6
|
+
* - POST /blobs/:hash/complete - Complete a blob upload
|
|
7
|
+
* - GET /blobs/:hash/url - Get a presigned download URL
|
|
8
|
+
* - PUT /blobs/:hash/upload - Direct upload (for database adapter)
|
|
9
|
+
* - GET /blobs/:hash/download - Direct download (for database adapter)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
BlobUploadCompleteResponseSchema,
|
|
14
|
+
BlobUploadInitRequestSchema,
|
|
15
|
+
BlobUploadInitResponseSchema,
|
|
16
|
+
ErrorResponseSchema,
|
|
17
|
+
parseBlobHash,
|
|
18
|
+
} from '@syncular/core';
|
|
19
|
+
import type {
|
|
20
|
+
BlobManager,
|
|
21
|
+
BlobNotFoundError,
|
|
22
|
+
BlobValidationError,
|
|
23
|
+
} from '@syncular/server';
|
|
24
|
+
import {
|
|
25
|
+
type BlobTokenSigner,
|
|
26
|
+
readBlobFromDatabase,
|
|
27
|
+
type SyncBlobsDb,
|
|
28
|
+
storeBlobInDatabase,
|
|
29
|
+
} from '@syncular/server';
|
|
30
|
+
import type { Context } from 'hono';
|
|
31
|
+
import { Hono } from 'hono';
|
|
32
|
+
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
|
|
33
|
+
import type { Kysely } from 'kysely';
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
|
|
36
|
+
interface BlobAuthResult {
|
|
37
|
+
actorId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CreateBlobRoutesOptions<DB extends SyncBlobsDb = SyncBlobsDb> {
|
|
41
|
+
/** Blob manager instance */
|
|
42
|
+
blobManager: BlobManager;
|
|
43
|
+
/** Authentication function */
|
|
44
|
+
authenticate: (c: Context) => Promise<BlobAuthResult | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Token signer for database adapter direct uploads/downloads.
|
|
47
|
+
* Required if using the database blob storage adapter.
|
|
48
|
+
*/
|
|
49
|
+
tokenSigner?: BlobTokenSigner;
|
|
50
|
+
/**
|
|
51
|
+
* Database instance for direct blob storage.
|
|
52
|
+
* Required if using the database blob storage adapter.
|
|
53
|
+
*/
|
|
54
|
+
db?: Kysely<DB>;
|
|
55
|
+
/**
|
|
56
|
+
* Optional: Check if actor can access a blob.
|
|
57
|
+
* By default, any authenticated actor can access any completed blob.
|
|
58
|
+
* Provide this to implement scope-based access control.
|
|
59
|
+
*/
|
|
60
|
+
canAccessBlob?: (args: { actorId: string; hash: string }) => Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Maximum upload size in bytes.
|
|
63
|
+
* Default: 100MB (104857600)
|
|
64
|
+
*/
|
|
65
|
+
maxUploadSize?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hashParamsSchema = z.object({
|
|
69
|
+
hash: z.string().min(1),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const tokenQuerySchema = z.object({
|
|
73
|
+
token: z.string().min(1),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create blob routes for Hono.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* const blobRoutes = createBlobRoutes({
|
|
82
|
+
* blobManager,
|
|
83
|
+
* authenticate: async (c) => {
|
|
84
|
+
* const token = c.req.header('Authorization')?.replace('Bearer ', '');
|
|
85
|
+
* if (!token) return null;
|
|
86
|
+
* const user = await verifyToken(token);
|
|
87
|
+
* return user ? { actorId: user.id } : null;
|
|
88
|
+
* },
|
|
89
|
+
* });
|
|
90
|
+
*
|
|
91
|
+
* app.route('/api/sync', blobRoutes);
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function createBlobRoutes<DB extends SyncBlobsDb>(
|
|
95
|
+
options: CreateBlobRoutesOptions<DB>
|
|
96
|
+
): Hono {
|
|
97
|
+
const {
|
|
98
|
+
blobManager,
|
|
99
|
+
authenticate,
|
|
100
|
+
tokenSigner,
|
|
101
|
+
db,
|
|
102
|
+
canAccessBlob,
|
|
103
|
+
maxUploadSize = 100 * 1024 * 1024, // 100MB
|
|
104
|
+
} = options;
|
|
105
|
+
|
|
106
|
+
const routes = new Hono();
|
|
107
|
+
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
// POST /blobs/upload - Initiate upload
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
routes.post(
|
|
113
|
+
'/blobs/upload',
|
|
114
|
+
describeRoute({
|
|
115
|
+
tags: ['blobs'],
|
|
116
|
+
summary: 'Initiate blob upload',
|
|
117
|
+
description:
|
|
118
|
+
'Initiates a blob upload and returns a presigned URL for uploading',
|
|
119
|
+
responses: {
|
|
120
|
+
200: {
|
|
121
|
+
description: 'Upload initiated (or blob already exists)',
|
|
122
|
+
content: {
|
|
123
|
+
'application/json': {
|
|
124
|
+
schema: resolver(BlobUploadInitResponseSchema),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
400: {
|
|
129
|
+
description: 'Invalid request',
|
|
130
|
+
content: {
|
|
131
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
401: {
|
|
135
|
+
description: 'Unauthenticated',
|
|
136
|
+
content: {
|
|
137
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
zValidator('json', BlobUploadInitRequestSchema),
|
|
143
|
+
async (c) => {
|
|
144
|
+
const auth = await authenticate(c);
|
|
145
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
146
|
+
|
|
147
|
+
const body = c.req.valid('json');
|
|
148
|
+
|
|
149
|
+
// Validate size
|
|
150
|
+
if (body.size > maxUploadSize) {
|
|
151
|
+
return c.json(
|
|
152
|
+
{
|
|
153
|
+
error: 'BLOB_TOO_LARGE',
|
|
154
|
+
message: `Maximum upload size is ${maxUploadSize} bytes`,
|
|
155
|
+
},
|
|
156
|
+
400
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const result = await blobManager.initiateUpload({
|
|
162
|
+
hash: body.hash,
|
|
163
|
+
size: body.size,
|
|
164
|
+
mimeType: body.mimeType,
|
|
165
|
+
actorId: auth.actorId,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return c.json(result, 200);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (isBlobValidationError(err)) {
|
|
171
|
+
return c.json(
|
|
172
|
+
{ error: 'INVALID_REQUEST', message: err.message },
|
|
173
|
+
400
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
// POST /blobs/:hash/complete - Complete upload
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
routes.post(
|
|
186
|
+
'/blobs/:hash/complete',
|
|
187
|
+
describeRoute({
|
|
188
|
+
tags: ['blobs'],
|
|
189
|
+
summary: 'Complete blob upload',
|
|
190
|
+
description:
|
|
191
|
+
'Marks a blob upload as complete after the client has uploaded to the presigned URL',
|
|
192
|
+
responses: {
|
|
193
|
+
200: {
|
|
194
|
+
description: 'Upload completed',
|
|
195
|
+
content: {
|
|
196
|
+
'application/json': {
|
|
197
|
+
schema: resolver(BlobUploadCompleteResponseSchema),
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
400: {
|
|
202
|
+
description: 'Invalid request or upload failed',
|
|
203
|
+
content: {
|
|
204
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
401: {
|
|
208
|
+
description: 'Unauthenticated',
|
|
209
|
+
content: {
|
|
210
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
zValidator('param', hashParamsSchema),
|
|
216
|
+
async (c) => {
|
|
217
|
+
const auth = await authenticate(c);
|
|
218
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
219
|
+
|
|
220
|
+
const { hash } = c.req.valid('param');
|
|
221
|
+
|
|
222
|
+
// Validate hash format
|
|
223
|
+
if (!parseBlobHash(hash)) {
|
|
224
|
+
return c.json(
|
|
225
|
+
{ error: 'INVALID_REQUEST', message: 'Invalid blob hash format' },
|
|
226
|
+
400
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result = await blobManager.completeUpload(hash);
|
|
231
|
+
|
|
232
|
+
if (!result.ok) {
|
|
233
|
+
return c.json({ error: 'UPLOAD_FAILED', message: result.error }, 400);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return c.json(result, 200);
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
// GET /blobs/:hash/url - Get download URL
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
routes.get(
|
|
245
|
+
'/blobs/:hash/url',
|
|
246
|
+
describeRoute({
|
|
247
|
+
tags: ['blobs'],
|
|
248
|
+
summary: 'Get blob download URL',
|
|
249
|
+
description: 'Returns a presigned URL for downloading a blob',
|
|
250
|
+
responses: {
|
|
251
|
+
200: {
|
|
252
|
+
description: 'Download URL',
|
|
253
|
+
content: {
|
|
254
|
+
'application/json': {
|
|
255
|
+
schema: resolver(
|
|
256
|
+
z.object({
|
|
257
|
+
url: z.string().url(),
|
|
258
|
+
expiresAt: z.string(),
|
|
259
|
+
metadata: z.object({
|
|
260
|
+
hash: z.string(),
|
|
261
|
+
size: z.number(),
|
|
262
|
+
mimeType: z.string(),
|
|
263
|
+
createdAt: z.string(),
|
|
264
|
+
uploadComplete: z.boolean(),
|
|
265
|
+
}),
|
|
266
|
+
})
|
|
267
|
+
),
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
401: {
|
|
272
|
+
description: 'Unauthenticated',
|
|
273
|
+
content: {
|
|
274
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
403: {
|
|
278
|
+
description: 'Forbidden',
|
|
279
|
+
content: {
|
|
280
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
404: {
|
|
284
|
+
description: 'Not found',
|
|
285
|
+
content: {
|
|
286
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
zValidator('param', hashParamsSchema),
|
|
292
|
+
async (c) => {
|
|
293
|
+
const auth = await authenticate(c);
|
|
294
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
295
|
+
|
|
296
|
+
const { hash } = c.req.valid('param');
|
|
297
|
+
|
|
298
|
+
// Validate hash format
|
|
299
|
+
if (!parseBlobHash(hash)) {
|
|
300
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check access if canAccessBlob is provided
|
|
304
|
+
if (canAccessBlob) {
|
|
305
|
+
const canAccess = await canAccessBlob({ actorId: auth.actorId, hash });
|
|
306
|
+
if (!canAccess) {
|
|
307
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const result = await blobManager.getDownloadUrl({
|
|
313
|
+
hash,
|
|
314
|
+
actorId: auth.actorId,
|
|
315
|
+
});
|
|
316
|
+
return c.json(result, 200);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (isBlobNotFoundError(err)) {
|
|
319
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
// PUT /blobs/:hash/upload - Direct upload (database adapter)
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
if (tokenSigner && db) {
|
|
331
|
+
routes.put(
|
|
332
|
+
'/blobs/:hash/upload',
|
|
333
|
+
describeRoute({
|
|
334
|
+
tags: ['blobs'],
|
|
335
|
+
summary: 'Direct blob upload',
|
|
336
|
+
description:
|
|
337
|
+
'Direct upload endpoint for database storage adapter. Requires a signed token.',
|
|
338
|
+
responses: {
|
|
339
|
+
200: {
|
|
340
|
+
description: 'Upload successful',
|
|
341
|
+
},
|
|
342
|
+
400: {
|
|
343
|
+
description: 'Invalid request',
|
|
344
|
+
content: {
|
|
345
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
401: {
|
|
349
|
+
description: 'Invalid or expired token',
|
|
350
|
+
content: {
|
|
351
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
zValidator('param', hashParamsSchema),
|
|
357
|
+
zValidator('query', tokenQuerySchema),
|
|
358
|
+
async (c) => {
|
|
359
|
+
const { hash } = c.req.valid('param');
|
|
360
|
+
const { token } = c.req.valid('query');
|
|
361
|
+
|
|
362
|
+
// Verify token
|
|
363
|
+
const payload = await tokenSigner.verify(token);
|
|
364
|
+
if (!payload || payload.action !== 'upload' || payload.hash !== hash) {
|
|
365
|
+
return c.json({ error: 'INVALID_TOKEN' }, 401);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Get upload metadata
|
|
369
|
+
const metadata = await blobManager.getMetadata(hash);
|
|
370
|
+
|
|
371
|
+
// Read body
|
|
372
|
+
const body = await c.req.arrayBuffer();
|
|
373
|
+
const bodyBytes = new Uint8Array(body);
|
|
374
|
+
|
|
375
|
+
// Verify size
|
|
376
|
+
const expectedSize = metadata?.size;
|
|
377
|
+
if (expectedSize !== undefined && bodyBytes.length !== expectedSize) {
|
|
378
|
+
return c.json(
|
|
379
|
+
{
|
|
380
|
+
error: 'SIZE_MISMATCH',
|
|
381
|
+
message: `Expected ${expectedSize} bytes, got ${bodyBytes.length}`,
|
|
382
|
+
},
|
|
383
|
+
400
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Verify hash
|
|
388
|
+
const computedHash = await computeSha256Hash(bodyBytes);
|
|
389
|
+
const expectedHex = parseBlobHash(hash);
|
|
390
|
+
if (computedHash !== expectedHex) {
|
|
391
|
+
return c.json(
|
|
392
|
+
{
|
|
393
|
+
error: 'HASH_MISMATCH',
|
|
394
|
+
message: 'Content hash does not match',
|
|
395
|
+
},
|
|
396
|
+
400
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Store via the blob adapter (R2, database, etc.)
|
|
401
|
+
const mimeType =
|
|
402
|
+
c.req.header('Content-Type') ??
|
|
403
|
+
metadata?.mimeType ??
|
|
404
|
+
'application/octet-stream';
|
|
405
|
+
|
|
406
|
+
if (blobManager.adapter.put) {
|
|
407
|
+
await blobManager.adapter.put(hash, bodyBytes, { mimeType });
|
|
408
|
+
} else {
|
|
409
|
+
await storeBlobInDatabase(db, {
|
|
410
|
+
hash,
|
|
411
|
+
size: bodyBytes.length,
|
|
412
|
+
mimeType,
|
|
413
|
+
body: bodyBytes,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return c.text('OK', 200);
|
|
418
|
+
}
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// -------------------------------------------------------------------------
|
|
422
|
+
// GET /blobs/:hash/download - Direct download (database adapter)
|
|
423
|
+
// -------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
routes.get(
|
|
426
|
+
'/blobs/:hash/download',
|
|
427
|
+
describeRoute({
|
|
428
|
+
tags: ['blobs'],
|
|
429
|
+
summary: 'Direct blob download',
|
|
430
|
+
description:
|
|
431
|
+
'Direct download endpoint for database storage adapter. Requires a signed token.',
|
|
432
|
+
responses: {
|
|
433
|
+
200: {
|
|
434
|
+
description: 'Blob content',
|
|
435
|
+
},
|
|
436
|
+
401: {
|
|
437
|
+
description: 'Invalid or expired token',
|
|
438
|
+
content: {
|
|
439
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
404: {
|
|
443
|
+
description: 'Not found',
|
|
444
|
+
content: {
|
|
445
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
zValidator('param', hashParamsSchema),
|
|
451
|
+
zValidator('query', tokenQuerySchema),
|
|
452
|
+
async (c) => {
|
|
453
|
+
const { hash } = c.req.valid('param');
|
|
454
|
+
const { token } = c.req.valid('query');
|
|
455
|
+
|
|
456
|
+
// Verify token
|
|
457
|
+
const payload = await tokenSigner.verify(token);
|
|
458
|
+
if (
|
|
459
|
+
!payload ||
|
|
460
|
+
payload.action !== 'download' ||
|
|
461
|
+
payload.hash !== hash
|
|
462
|
+
) {
|
|
463
|
+
return c.json({ error: 'INVALID_TOKEN' }, 401);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Read via the blob adapter (R2, database, etc.)
|
|
467
|
+
if (blobManager.adapter.get) {
|
|
468
|
+
const data = await blobManager.adapter.get(hash);
|
|
469
|
+
if (!data) {
|
|
470
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
471
|
+
}
|
|
472
|
+
const meta = blobManager.adapter.getMetadata
|
|
473
|
+
? await blobManager.adapter.getMetadata(hash)
|
|
474
|
+
: null;
|
|
475
|
+
return new Response(data as BodyInit, {
|
|
476
|
+
status: 200,
|
|
477
|
+
headers: {
|
|
478
|
+
'Content-Type': meta?.mimeType ?? 'application/octet-stream',
|
|
479
|
+
'Content-Length': String(data.length),
|
|
480
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Fallback: read from database directly
|
|
486
|
+
const blob = await readBlobFromDatabase(db, hash);
|
|
487
|
+
if (!blob) {
|
|
488
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return new Response(blob.body as BodyInit, {
|
|
492
|
+
status: 200,
|
|
493
|
+
headers: {
|
|
494
|
+
'Content-Type': blob.mimeType,
|
|
495
|
+
'Content-Length': String(blob.size),
|
|
496
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return routes;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ============================================================================
|
|
507
|
+
// Helpers
|
|
508
|
+
// ============================================================================
|
|
509
|
+
|
|
510
|
+
function isBlobValidationError(err: unknown): err is BlobValidationError {
|
|
511
|
+
return (
|
|
512
|
+
typeof err === 'object' &&
|
|
513
|
+
err !== null &&
|
|
514
|
+
(err as { name?: string }).name === 'BlobValidationError'
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isBlobNotFoundError(err: unknown): err is BlobNotFoundError {
|
|
519
|
+
return (
|
|
520
|
+
typeof err === 'object' &&
|
|
521
|
+
err !== null &&
|
|
522
|
+
(err as { name?: string }).name === 'BlobNotFoundError'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function computeSha256Hash(data: Uint8Array): Promise<string> {
|
|
527
|
+
// Create a new ArrayBuffer copy to satisfy TypeScript's strict typing
|
|
528
|
+
const buffer = new Uint8Array(data).buffer as ArrayBuffer;
|
|
529
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
530
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
531
|
+
return Array.from(hashArray)
|
|
532
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
533
|
+
.join('');
|
|
534
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server-hono - Console API
|
|
3
|
+
*
|
|
4
|
+
* Provides monitoring and operations endpoints for the @syncular dashboard.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Re-export types from routes (which exports from schemas)
|
|
8
|
+
export type {
|
|
9
|
+
ConsoleAuthResult,
|
|
10
|
+
ConsoleEventEmitter,
|
|
11
|
+
CreateConsoleRoutesOptions,
|
|
12
|
+
} from './routes';
|
|
13
|
+
export {
|
|
14
|
+
createConsoleEventEmitter,
|
|
15
|
+
createConsoleRoutes,
|
|
16
|
+
createTokenAuthenticator,
|
|
17
|
+
} from './routes';
|