@edge-base/server 0.2.5 → 0.2.7
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/admin-build/_app/immutable/chunks/{DILS_-VJ.js → B3CvhH3c.js} +1 -1
- package/admin-build/_app/immutable/chunks/BDYewzou.js +1 -0
- package/admin-build/_app/immutable/chunks/{Cdm5zBRA.js → BEM1BeVF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dt4vL4Df.js → BYL_uBga.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B94PilAN.js → BYyykAbh.js} +1 -1
- package/admin-build/_app/immutable/chunks/BaUG2TJ-.js +1 -0
- package/admin-build/_app/immutable/chunks/{C72lTcG0.js → Bcs4KYNp.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D2j3I1VQ.js → BfpUQYr3.js} +1 -1
- package/admin-build/_app/immutable/chunks/BhCO1Fpt.js +1 -0
- package/admin-build/_app/immutable/chunks/{B8s_s9QY.js → BkZCgsc3.js} +1 -1
- package/admin-build/_app/immutable/chunks/CIOC1v_q.js +128 -0
- package/admin-build/_app/immutable/chunks/CjcrXziO.js +2 -0
- package/admin-build/_app/immutable/chunks/CvczjTXx.js +1 -0
- package/admin-build/_app/immutable/chunks/D1u3u7xu.js +1 -0
- package/admin-build/_app/immutable/chunks/{B0HRJ657.js → DOOPbWwG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BqTb6Mxk.js → DaXO-sFP.js} +1 -1
- package/admin-build/_app/immutable/chunks/DnpbvAPi.js +1 -0
- package/admin-build/_app/immutable/chunks/{B6MschND.js → Dz9cUCuv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CaVKAiCe.js → Tea2dBJ8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Z41NK6i6.js → bguI1TeA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{J2Gw0SMu.js → ejoEf2I5.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B2TnDKF7.js → iEyeblJR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{_teD5ji5.js → nlAMTi52.js} +1 -1
- package/admin-build/_app/immutable/chunks/qKdzaeX3.js +1 -0
- package/admin-build/_app/immutable/entry/{app.D3flihMw.js → app.DoUaxnew.js} +2 -2
- package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CdczqZLK.js → 0.Dsxi8s7i.js} +1 -1
- package/admin-build/_app/immutable/nodes/1.Cp2l-hol.js +1 -0
- package/admin-build/_app/immutable/nodes/10.4oY6m8Nz.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DfcozD4J.js +1 -0
- package/admin-build/_app/immutable/nodes/12.uJgZdCIA.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CaN1kRev.js +110 -0
- package/admin-build/_app/immutable/nodes/14.DQ5xIi3s.js +3 -0
- package/admin-build/_app/immutable/nodes/15.B_EkebTJ.js +1 -0
- package/admin-build/_app/immutable/nodes/{16.BR7WwQrS.js → 16.Tko1ZX8-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.Cm57KKXV.js → 17.BCmWMJX9.js} +1 -1
- package/admin-build/_app/immutable/nodes/18.hmGhl1O2.js +1 -0
- package/admin-build/_app/immutable/nodes/19.D-1infOo.js +2 -0
- package/admin-build/_app/immutable/nodes/{20.DnHeFlTv.js → 20.CY4KKcBL.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
- package/admin-build/_app/immutable/nodes/22.14Vd7bnt.js +1 -0
- package/admin-build/_app/immutable/nodes/{23.CWSGMcKJ.js → 23.Be6jK77o.js} +2 -2
- package/admin-build/_app/immutable/nodes/24.CSTFkr6R.js +2 -0
- package/admin-build/_app/immutable/nodes/25.DRTg8fHc.js +2 -0
- package/admin-build/_app/immutable/nodes/26.DKt-9lwQ.js +1 -0
- package/admin-build/_app/immutable/nodes/27.D5caPu0F.js +1 -0
- package/admin-build/_app/immutable/nodes/28.hJhlnlyY.js +1 -0
- package/admin-build/_app/immutable/nodes/29.CDYBzFyT.js +1 -0
- package/admin-build/_app/immutable/nodes/{3.B6q-7qr8.js → 3.DMyKwkGn.js} +1 -1
- package/admin-build/_app/immutable/nodes/30.BaHNeEmc.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C6PV5L-2.js +1 -0
- package/admin-build/_app/immutable/nodes/4.9E118Ftm.js +1 -0
- package/admin-build/_app/immutable/nodes/5.D8guAl3v.js +1 -0
- package/admin-build/_app/immutable/nodes/6.D1u__DtT.js +1 -0
- package/admin-build/_app/immutable/nodes/7.DWXHnRFf.js +1 -0
- package/admin-build/_app/immutable/nodes/8.Dojd8krc.js +1 -0
- package/admin-build/_app/immutable/nodes/9.CLtrr0K_.js +1 -0
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +6 -1941
- package/package.json +3 -3
- package/src/__tests__/openapi-coverage.test.ts +0 -6
- package/src/__tests__/push-handlers.test.ts +1 -1
- package/src/__tests__/room-auth-state-loss.test.ts +6 -0
- package/src/__tests__/room-handler-context.test.ts +0 -31
- package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
- package/src/__tests__/room-runtime-routing.test.ts +24 -111
- package/src/__tests__/route-parser.test.ts +6 -0
- package/src/__tests__/schema.test.ts +15 -6
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/database-do.ts +7 -1
- package/src/durable-objects/room-runtime-base.ts +290 -57
- package/src/durable-objects/rooms-do.ts +212 -1336
- package/src/index.ts +23 -9
- package/src/lib/d1-handler.ts +32 -17
- package/src/lib/openapi.ts +1 -4
- package/src/lib/postgres-handler.ts +24 -12
- package/src/lib/route-parser.ts +3 -0
- package/src/lib/schemas.ts +12 -2
- package/src/middleware/captcha-verify.ts +16 -3
- package/src/middleware/error-handler.ts +1 -1
- package/src/middleware/rules.ts +28 -9
- package/src/routes/admin-auth.ts +3 -3
- package/src/routes/admin.ts +13 -8
- package/src/routes/analytics-api.ts +3 -3
- package/src/routes/auth.ts +1 -1
- package/src/routes/backup.ts +1 -1
- package/src/routes/d1.ts +14 -7
- package/src/routes/database-live.ts +13 -6
- package/src/routes/kv.ts +21 -10
- package/src/routes/oauth.ts +1 -1
- package/src/routes/push.ts +119 -77
- package/src/routes/room.ts +203 -280
- package/src/routes/schema-endpoint.ts +2 -2
- package/src/routes/sql.ts +10 -6
- package/src/routes/storage.ts +4 -2
- package/src/routes/vectorize.ts +16 -4
- package/src/types.ts +1 -14
- package/admin-build/_app/immutable/chunks/6oMK_164.js +0 -1
- package/admin-build/_app/immutable/chunks/BEW7Ez_g.js +0 -1
- package/admin-build/_app/immutable/chunks/BoOooyH6.js +0 -1
- package/admin-build/_app/immutable/chunks/BvHnF5tV.js +0 -1
- package/admin-build/_app/immutable/chunks/CoI6jjbg.js +0 -2
- package/admin-build/_app/immutable/chunks/CrOZMmdF.js +0 -1
- package/admin-build/_app/immutable/chunks/Cw6OYcq-.js +0 -1
- package/admin-build/_app/immutable/chunks/DPdQ7z0T.js +0 -128
- package/admin-build/_app/immutable/chunks/pUxw8jfq.js +0 -1
- package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +0 -1
- package/admin-build/_app/immutable/nodes/1.DxcSsEqS.js +0 -1
- package/admin-build/_app/immutable/nodes/10.DuAd4aIm.js +0 -1
- package/admin-build/_app/immutable/nodes/11.0jgHQL92.js +0 -1
- package/admin-build/_app/immutable/nodes/12.CKNPqmyy.js +0 -1
- package/admin-build/_app/immutable/nodes/13.B1p2POXS.js +0 -110
- package/admin-build/_app/immutable/nodes/14.Bb-REBND.js +0 -3
- package/admin-build/_app/immutable/nodes/15.1uBFCX0X.js +0 -1
- package/admin-build/_app/immutable/nodes/18.CoiwfAuQ.js +0 -1
- package/admin-build/_app/immutable/nodes/19.B8ZdLlXj.js +0 -2
- package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +0 -1
- package/admin-build/_app/immutable/nodes/22.CItETFzy.js +0 -1
- package/admin-build/_app/immutable/nodes/24.CWbEqNMB.js +0 -2
- package/admin-build/_app/immutable/nodes/25.DRkLEhKi.js +0 -2
- package/admin-build/_app/immutable/nodes/26.BRxO8AYH.js +0 -1
- package/admin-build/_app/immutable/nodes/27.BLs-nVHz.js +0 -1
- package/admin-build/_app/immutable/nodes/28.G79qkdBK.js +0 -1
- package/admin-build/_app/immutable/nodes/29.BOcI6g0N.js +0 -1
- package/admin-build/_app/immutable/nodes/30.DAIC7dKd.js +0 -1
- package/admin-build/_app/immutable/nodes/31.pl0XXjXF.js +0 -1
- package/admin-build/_app/immutable/nodes/4.DOdvVlZj.js +0 -1
- package/admin-build/_app/immutable/nodes/5.BW_zlgye.js +0 -1
- package/admin-build/_app/immutable/nodes/6.Dxy1CAI2.js +0 -1
- package/admin-build/_app/immutable/nodes/7.BG98w_o7.js +0 -1
- package/admin-build/_app/immutable/nodes/8.DoG5R2rG.js +0 -1
- package/admin-build/_app/immutable/nodes/9.Dmxf6zAC.js +0 -1
- package/src/__tests__/cloudflare-realtime.test.ts +0 -113
- package/src/lib/cloudflare-realtime.ts +0 -251
package/src/routes/room.ts
CHANGED
|
@@ -45,109 +45,26 @@ const roomConnectDiagnosticSchema = z.object({
|
|
|
45
45
|
pendingCount: z.number().optional(),
|
|
46
46
|
maxPending: z.number().optional(),
|
|
47
47
|
});
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const roomSummarySchema = z.object({
|
|
49
|
+
namespace: z.string(),
|
|
50
|
+
roomId: z.string(),
|
|
51
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
52
|
+
occupancy: z.object({
|
|
53
|
+
activeMembers: z.number(),
|
|
54
|
+
activeConnections: z.number(),
|
|
55
|
+
}),
|
|
56
|
+
updatedAt: z.string(),
|
|
51
57
|
});
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
sessionId: z.string().optional().openapi({ description: 'Provider session ID associated with this track' }),
|
|
56
|
-
trackName: z.string().optional().openapi({ description: 'Track name used by the provider' }),
|
|
57
|
-
bidirectionalMediaStream: z.boolean().optional().openapi({ description: 'Whether the track should be bidirectional' }),
|
|
58
|
-
kind: z.string().optional().openapi({ description: 'Track kind reported by the provider' }),
|
|
59
|
-
simulcast: z.object({
|
|
60
|
-
preferredRid: z.string().optional(),
|
|
61
|
-
priorityOrdering: z.enum(['none', 'asciibetical']).optional(),
|
|
62
|
-
ridNotAvailable: z.enum(['none', 'asciibetical']).optional(),
|
|
63
|
-
}).optional().openapi({ description: 'Optional simulcast preferences' }),
|
|
64
|
-
errorCode: z.string().optional().openapi({ description: 'Provider-level error code for this track' }),
|
|
65
|
-
errorDescription: z.string().optional().openapi({ description: 'Provider-level error description for this track' }),
|
|
58
|
+
const roomSummaryBatchBodySchema = z.object({
|
|
59
|
+
namespace: z.string().openapi({ description: 'Room namespace shared by the requested room IDs' }),
|
|
60
|
+
ids: z.array(z.string()).min(1).max(100).openapi({ description: 'Room IDs to summarize' }),
|
|
66
61
|
});
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
const roomSummaryCollectionSchema = z.object({
|
|
63
|
+
namespace: z.string(),
|
|
64
|
+
items: z.array(roomSummarySchema),
|
|
65
|
+
deniedIds: z.array(z.string()),
|
|
66
|
+
updatedAt: z.string(),
|
|
72
67
|
});
|
|
73
|
-
const roomCloudflareRealtimeKitCreateSessionBodySchema = z.object({
|
|
74
|
-
connectionId: z.string().optional().openapi({ description: 'Specific room connection ID to bind the Cloudflare RealtimeKit participant to' }),
|
|
75
|
-
customParticipantId: z.string().optional().openapi({ description: 'Optional custom participant identifier for the provisioned RealtimeKit participant' }),
|
|
76
|
-
name: z.string().optional().openapi({ description: 'Optional display name for the provisioned RealtimeKit participant' }),
|
|
77
|
-
picture: z.string().optional().openapi({ description: 'Optional avatar URL for the provisioned RealtimeKit participant' }),
|
|
78
|
-
});
|
|
79
|
-
const roomRealtimeCreateSessionResponseSchema = z.object({
|
|
80
|
-
sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
|
|
81
|
-
sessionDescription: roomRealtimeSessionDescriptionSchema.optional(),
|
|
82
|
-
errorCode: z.string().optional(),
|
|
83
|
-
errorDescription: z.string().optional(),
|
|
84
|
-
connectionId: z.string().optional().openapi({ description: 'Room connection ID associated with the session' }),
|
|
85
|
-
reused: z.boolean().optional().openapi({ description: 'Whether an existing provider session was reused' }),
|
|
86
|
-
});
|
|
87
|
-
const roomCloudflareRealtimeKitCreateSessionResponseSchema = z.object({
|
|
88
|
-
sessionId: z.string().openapi({ description: 'Cloudflare RealtimeKit participant ID' }),
|
|
89
|
-
meetingId: z.string().openapi({ description: 'Cloudflare RealtimeKit meeting ID backing the room session' }),
|
|
90
|
-
participantId: z.string().openapi({ description: 'Cloudflare RealtimeKit participant ID' }),
|
|
91
|
-
authToken: z.string().openapi({ description: 'RealtimeKit auth token for the provisioned participant' }),
|
|
92
|
-
presetName: z.string().optional().openapi({ description: 'RealtimeKit preset used for the provisioned participant' }),
|
|
93
|
-
connectionId: z.string().optional().openapi({ description: 'Room connection ID associated with the session' }),
|
|
94
|
-
reused: z.boolean().optional().openapi({ description: 'Whether an existing provider participant was reused' }),
|
|
95
|
-
});
|
|
96
|
-
const roomRealtimeSessionStateSchema = z.object({
|
|
97
|
-
sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
|
|
98
|
-
connectionId: z.string().optional().openapi({ description: 'Room connection ID associated with the session' }),
|
|
99
|
-
createdAt: z.number().openapi({ description: 'Unix epoch milliseconds when the session was created' }),
|
|
100
|
-
updatedAt: z.number().openapi({ description: 'Unix epoch milliseconds when the session was last updated' }),
|
|
101
|
-
});
|
|
102
|
-
const roomRealtimeIceServerSchema = z.object({
|
|
103
|
-
urls: z.union([z.array(z.string()), z.string()]).openapi({ description: 'ICE server URL or URL list' }),
|
|
104
|
-
username: z.string().optional(),
|
|
105
|
-
credential: z.string().optional(),
|
|
106
|
-
});
|
|
107
|
-
const roomRealtimeIceServersBodySchema = z.object({
|
|
108
|
-
ttl: z.number().optional().openapi({ description: 'Requested TURN credential TTL in seconds' }),
|
|
109
|
-
});
|
|
110
|
-
const roomRealtimeIceServersResponseSchema = z.object({
|
|
111
|
-
iceServers: z.array(roomRealtimeIceServerSchema).openapi({ description: 'ICE servers returned by Cloudflare TURN' }),
|
|
112
|
-
});
|
|
113
|
-
const roomRealtimeTracksResponseSchema = z.object({
|
|
114
|
-
errorCode: z.string().optional(),
|
|
115
|
-
errorDescription: z.string().optional(),
|
|
116
|
-
requiresImmediateRenegotiation: z.boolean().optional(),
|
|
117
|
-
sessionDescription: roomRealtimeSessionDescriptionSchema.optional(),
|
|
118
|
-
tracks: z.array(roomRealtimeTrackSchema).optional(),
|
|
119
|
-
});
|
|
120
|
-
const roomRealtimeTracksBodySchema = z.object({
|
|
121
|
-
sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
|
|
122
|
-
connectionId: z.string().optional().openapi({ description: 'Specific room connection ID to bind the track operation to' }),
|
|
123
|
-
sessionDescription: roomRealtimeSessionDescriptionSchema.optional(),
|
|
124
|
-
tracks: z.array(roomRealtimeTrackSchema).min(1).openapi({ description: 'Tracks to create or subscribe to' }),
|
|
125
|
-
autoDiscover: z.boolean().optional().openapi({ description: 'Ask the provider to auto-discover remote tracks' }),
|
|
126
|
-
publish: z.object({
|
|
127
|
-
kind: z.enum(['audio', 'video', 'screen']).optional(),
|
|
128
|
-
trackId: z.string().optional(),
|
|
129
|
-
deviceId: z.string().optional(),
|
|
130
|
-
muted: z.boolean().optional(),
|
|
131
|
-
}).optional().openapi({ description: 'Optional room media state updates to apply after track creation' }),
|
|
132
|
-
});
|
|
133
|
-
const roomRealtimeRenegotiateBodySchema = z.object({
|
|
134
|
-
sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
|
|
135
|
-
connectionId: z.string().optional().openapi({ description: 'Specific room connection ID to bind the renegotiation to' }),
|
|
136
|
-
sessionDescription: roomRealtimeSessionDescriptionSchema,
|
|
137
|
-
});
|
|
138
|
-
const roomRealtimeCloseTracksBodySchema = z.object({
|
|
139
|
-
sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
|
|
140
|
-
connectionId: z.string().optional().openapi({ description: 'Specific room connection ID to bind the close operation to' }),
|
|
141
|
-
sessionDescription: roomRealtimeSessionDescriptionSchema.optional(),
|
|
142
|
-
tracks: z.array(z.object({
|
|
143
|
-
mid: z.string().openapi({ description: 'Track MID to close' }),
|
|
144
|
-
})).min(1).openapi({ description: 'Tracks to close' }),
|
|
145
|
-
force: z.boolean().optional().openapi({ description: 'Force close even if the provider reports the track as active' }),
|
|
146
|
-
unpublish: z.object({
|
|
147
|
-
kind: z.enum(['audio', 'video', 'screen']).optional(),
|
|
148
|
-
}).optional().openapi({ description: 'Optional room media state cleanup after closing tracks' }),
|
|
149
|
-
});
|
|
150
|
-
|
|
151
68
|
function isRoomOperationPublic(
|
|
152
69
|
namespaceConfig: RoomNamespaceConfig | null | undefined,
|
|
153
70
|
operation: 'metadata' | 'join' | 'action',
|
|
@@ -157,6 +74,22 @@ function isRoomOperationPublic(
|
|
|
157
74
|
return !!namespaceConfig.public[operation];
|
|
158
75
|
}
|
|
159
76
|
|
|
77
|
+
function warnRoomDevelopmentFallback(
|
|
78
|
+
namespace: string,
|
|
79
|
+
operation: 'metadata' | 'join' | 'action',
|
|
80
|
+
): void {
|
|
81
|
+
const warningKey = `${namespace}:${operation}`;
|
|
82
|
+
if (roomFallbackWarnings.has(warningKey)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
roomFallbackWarnings.add(warningKey);
|
|
86
|
+
console.warn(
|
|
87
|
+
`[Room] ${warningKey} is allowed because release=false and no explicit room rule was found. `
|
|
88
|
+
+ `This fallback is local-dev only. Add rooms.${namespace}.access.${operation} or set `
|
|
89
|
+
+ `rooms.${namespace}.public.${operation}=true to make the behavior explicit.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
160
93
|
function getRoomAuthContext(
|
|
161
94
|
auth: {
|
|
162
95
|
id?: string;
|
|
@@ -188,11 +121,11 @@ export async function proxyRoomDoRequest(
|
|
|
188
121
|
const roomId = c.req.query('id');
|
|
189
122
|
|
|
190
123
|
if (!namespace || !roomId) {
|
|
191
|
-
return c.json({ code: 400, message: 'namespace and id
|
|
124
|
+
return c.json({ code: 400, message: "Missing required query parameters 'namespace' and 'id' for room requests." }, 400);
|
|
192
125
|
}
|
|
193
126
|
|
|
194
127
|
if (options?.requireAuth && !c.get('auth')) {
|
|
195
|
-
return c.json({ code: 401, message: 'Authentication required' }, 401);
|
|
128
|
+
return c.json({ code: 401, message: 'Authentication required. Sign in before trying to access this room.' }, 401);
|
|
196
129
|
}
|
|
197
130
|
|
|
198
131
|
const config = parseConfig(c.env);
|
|
@@ -433,6 +366,42 @@ const getRoomMetadata = createRoute({
|
|
|
433
366
|
},
|
|
434
367
|
});
|
|
435
368
|
|
|
369
|
+
const getRoomSummary = createRoute({
|
|
370
|
+
operationId: 'getRoomSummary',
|
|
371
|
+
method: 'get',
|
|
372
|
+
path: '/summary',
|
|
373
|
+
tags: ['client'],
|
|
374
|
+
summary: 'Get room summary',
|
|
375
|
+
description: 'Returns lobby-safe room metadata plus current occupancy without joining the room.',
|
|
376
|
+
request: {
|
|
377
|
+
query: roomQuerySchema,
|
|
378
|
+
},
|
|
379
|
+
responses: {
|
|
380
|
+
200: { description: 'Room summary', content: { 'application/json': { schema: roomSummarySchema } } },
|
|
381
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
382
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
383
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const getRoomSummaries = createRoute({
|
|
388
|
+
operationId: 'getRoomSummaries',
|
|
389
|
+
method: 'post',
|
|
390
|
+
path: '/summaries',
|
|
391
|
+
tags: ['client'],
|
|
392
|
+
summary: 'Get summaries for multiple rooms',
|
|
393
|
+
description: 'Returns lobby-safe room metadata plus current occupancy for multiple room IDs in the same namespace.',
|
|
394
|
+
request: {
|
|
395
|
+
body: { content: { 'application/json': { schema: roomSummaryBatchBodySchema } }, required: true },
|
|
396
|
+
},
|
|
397
|
+
responses: {
|
|
398
|
+
200: { description: 'Room summaries', content: { 'application/json': { schema: roomSummaryCollectionSchema } } },
|
|
399
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
400
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
401
|
+
404: { description: 'Room feature not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
436
405
|
roomRoute.openapi(getRoomMetadata, async (c) => {
|
|
437
406
|
const namespace = c.req.query('namespace');
|
|
438
407
|
const roomId = c.req.query('id');
|
|
@@ -459,11 +428,7 @@ roomRoute.openapi(getRoomMetadata, async (c) => {
|
|
|
459
428
|
return c.json({ code: 403, message: 'Room metadata requires access.metadata or public.metadata in release mode' }, 403);
|
|
460
429
|
}
|
|
461
430
|
if (!config.release) {
|
|
462
|
-
|
|
463
|
-
if (!roomFallbackWarnings.has(warningKey)) {
|
|
464
|
-
roomFallbackWarnings.add(warningKey);
|
|
465
|
-
console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${namespace}.access.metadata or public.metadata to make this explicit.`);
|
|
466
|
-
}
|
|
431
|
+
warnRoomDevelopmentFallback(namespace, 'metadata');
|
|
467
432
|
}
|
|
468
433
|
}
|
|
469
434
|
if (metadataAccess) {
|
|
@@ -498,184 +463,142 @@ roomRoute.openapi(getRoomMetadata, async (c) => {
|
|
|
498
463
|
return doStub.fetch(doRequest);
|
|
499
464
|
});
|
|
500
465
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
path: '/media/realtime/session',
|
|
505
|
-
tags: ['client'],
|
|
506
|
-
summary: 'Get the active room realtime media session',
|
|
507
|
-
description: 'Returns the provider session currently bound to the authenticated room member.',
|
|
508
|
-
request: {
|
|
509
|
-
query: roomQuerySchema.extend({
|
|
510
|
-
connectionId: z.string().optional().openapi({ description: 'Optional room connection ID override' }),
|
|
511
|
-
}),
|
|
512
|
-
},
|
|
513
|
-
responses: {
|
|
514
|
-
200: { description: 'Active room realtime session', content: { 'application/json': { schema: roomRealtimeSessionStateSchema } } },
|
|
515
|
-
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
516
|
-
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
517
|
-
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
518
|
-
404: { description: 'No active session or runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
519
|
-
},
|
|
520
|
-
});
|
|
466
|
+
roomRoute.openapi(getRoomSummary, async (c) => {
|
|
467
|
+
const namespace = c.req.query('namespace');
|
|
468
|
+
const roomId = c.req.query('id');
|
|
521
469
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
path: '/media/realtime/session',
|
|
526
|
-
tags: ['client'],
|
|
527
|
-
summary: 'Create a room realtime media session',
|
|
528
|
-
description: 'Creates a Cloudflare Realtime session for the authenticated room member.',
|
|
529
|
-
request: {
|
|
530
|
-
query: roomQuerySchema,
|
|
531
|
-
body: { content: { 'application/json': { schema: roomRealtimeCreateSessionBodySchema } }, required: false },
|
|
532
|
-
},
|
|
533
|
-
responses: {
|
|
534
|
-
200: { description: 'Realtime session created', content: { 'application/json': { schema: roomRealtimeCreateSessionResponseSchema } } },
|
|
535
|
-
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
536
|
-
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
537
|
-
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
538
|
-
404: { description: 'Room runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
539
|
-
409: { description: 'Conflicting existing published media', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
540
|
-
},
|
|
541
|
-
});
|
|
470
|
+
if (!namespace || !roomId) {
|
|
471
|
+
return c.json({ code: 400, message: 'namespace and id query parameters required' }, 400);
|
|
472
|
+
}
|
|
542
473
|
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
},
|
|
554
|
-
responses: {
|
|
555
|
-
200: { description: 'Cloudflare RealtimeKit session created', content: { 'application/json': { schema: roomCloudflareRealtimeKitCreateSessionResponseSchema } } },
|
|
556
|
-
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
557
|
-
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
558
|
-
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
559
|
-
404: { description: 'Room runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
560
|
-
409: { description: 'Conflicting existing published media', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
561
|
-
},
|
|
562
|
-
});
|
|
474
|
+
const config = parseConfig(c.env);
|
|
475
|
+
const namespaceConfig = config.rooms?.[namespace];
|
|
476
|
+
if (config.release) {
|
|
477
|
+
if (!config.rooms?.[namespace]) {
|
|
478
|
+
return c.json({
|
|
479
|
+
code: 403,
|
|
480
|
+
message: `Room namespace '${namespace}' not configured`,
|
|
481
|
+
}, 403);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
563
484
|
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
485
|
+
const metadataAccess = namespaceConfig?.access?.metadata;
|
|
486
|
+
if (!metadataAccess) {
|
|
487
|
+
if (config.release && !isRoomOperationPublic(namespaceConfig, 'metadata')) {
|
|
488
|
+
return c.json({ code: 403, message: 'Room summary requires access.metadata or public.metadata in release mode' }, 403);
|
|
489
|
+
}
|
|
490
|
+
if (!config.release) {
|
|
491
|
+
warnRoomDevelopmentFallback(namespace, 'metadata');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (metadataAccess) {
|
|
495
|
+
const auth = (c.get('auth') as { id?: string; role?: string; email?: string | null; custom?: Record<string, unknown> | null; isAnonymous?: boolean; meta?: Record<string, unknown> } | null | undefined) ?? null;
|
|
496
|
+
const allowed = await Promise.resolve(metadataAccess(
|
|
497
|
+
getRoomAuthContext(auth),
|
|
498
|
+
roomId,
|
|
499
|
+
)).catch(() => false);
|
|
500
|
+
if (!allowed) {
|
|
501
|
+
return c.json({ code: 403, message: 'Denied by room metadata access rule' }, 403);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
583
504
|
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
tags: ['client'],
|
|
589
|
-
summary: 'Add realtime media tracks to a room session',
|
|
590
|
-
description: 'Creates or subscribes realtime tracks for the authenticated room member.',
|
|
591
|
-
request: {
|
|
592
|
-
query: roomQuerySchema,
|
|
593
|
-
body: { content: { 'application/json': { schema: roomRealtimeTracksBodySchema } }, required: true },
|
|
594
|
-
},
|
|
595
|
-
responses: {
|
|
596
|
-
200: { description: 'Realtime tracks updated', content: { 'application/json': { schema: roomRealtimeTracksResponseSchema } } },
|
|
597
|
-
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
598
|
-
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
599
|
-
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
600
|
-
404: { description: 'Room runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
601
|
-
},
|
|
602
|
-
});
|
|
505
|
+
const runtime = resolveRoomRuntime(c.env);
|
|
506
|
+
if (!runtime.binding) {
|
|
507
|
+
return c.json({ code: 404, message: `Room runtime '${runtime.target}' not configured` }, 404);
|
|
508
|
+
}
|
|
603
509
|
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
path: '/media/realtime/renegotiate',
|
|
608
|
-
tags: ['client'],
|
|
609
|
-
summary: 'Renegotiate a room realtime media session',
|
|
610
|
-
description: 'Submits a new session description for an existing room realtime media session.',
|
|
611
|
-
request: {
|
|
612
|
-
query: roomQuerySchema,
|
|
613
|
-
body: { content: { 'application/json': { schema: roomRealtimeRenegotiateBodySchema } }, required: true },
|
|
614
|
-
},
|
|
615
|
-
responses: {
|
|
616
|
-
200: { description: 'Realtime session renegotiated', content: { 'application/json': { schema: roomRealtimeTracksResponseSchema } } },
|
|
617
|
-
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
618
|
-
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
619
|
-
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
620
|
-
404: { description: 'Room runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
621
|
-
},
|
|
622
|
-
});
|
|
510
|
+
const doName = `${namespace}::${roomId}`;
|
|
511
|
+
const doId = runtime.binding.idFromName(doName);
|
|
512
|
+
const doStub = runtime.binding.get(doId);
|
|
623
513
|
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
body: { content: { 'application/json': { schema: roomRealtimeCloseTracksBodySchema } }, required: true },
|
|
634
|
-
},
|
|
635
|
-
responses: {
|
|
636
|
-
200: { description: 'Realtime tracks closed', content: { 'application/json': { schema: roomRealtimeTracksResponseSchema } } },
|
|
637
|
-
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
638
|
-
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
639
|
-
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
640
|
-
404: { description: 'Room runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
641
|
-
},
|
|
514
|
+
const url = new URL(c.req.url);
|
|
515
|
+
url.pathname = '/summary';
|
|
516
|
+
url.searchParams.set('room', doName);
|
|
517
|
+
const doRequest = new Request(url.toString(), {
|
|
518
|
+
method: 'GET',
|
|
519
|
+
headers: c.req.raw.headers,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
return doStub.fetch(doRequest);
|
|
642
523
|
});
|
|
643
524
|
|
|
644
|
-
roomRoute.openapi(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
525
|
+
roomRoute.openapi(getRoomSummaries, async (c) => {
|
|
526
|
+
const body = c.req.valid('json') as z.infer<typeof roomSummaryBatchBodySchema>;
|
|
527
|
+
const namespace = body.namespace;
|
|
528
|
+
const roomIds = [...new Set(body.ids)];
|
|
529
|
+
|
|
530
|
+
const config = parseConfig(c.env);
|
|
531
|
+
const namespaceConfig = config.rooms?.[namespace];
|
|
532
|
+
if (config.release && !namespaceConfig) {
|
|
533
|
+
return c.json({
|
|
534
|
+
code: 403,
|
|
535
|
+
message: `Room namespace '${namespace}' not configured`,
|
|
536
|
+
}, 403);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const metadataAccess = namespaceConfig?.access?.metadata;
|
|
540
|
+
if (!metadataAccess) {
|
|
541
|
+
if (config.release && !isRoomOperationPublic(namespaceConfig, 'metadata')) {
|
|
542
|
+
return c.json({ code: 403, message: 'Room summaries require access.metadata or public.metadata in release mode' }, 403);
|
|
543
|
+
}
|
|
544
|
+
if (!config.release) {
|
|
545
|
+
warnRoomDevelopmentFallback(namespace, 'metadata');
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const runtime = resolveRoomRuntime(c.env);
|
|
550
|
+
if (!runtime.binding) {
|
|
551
|
+
return c.json({ code: 404, message: `Room runtime '${runtime.target}' not configured` }, 404);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const auth = (c.get('auth') as { id?: string; role?: string; email?: string | null; custom?: Record<string, unknown> | null; isAnonymous?: boolean; meta?: Record<string, unknown> } | null | undefined) ?? null;
|
|
555
|
+
const authContext = getRoomAuthContext(auth);
|
|
556
|
+
const allowedRoomIds: string[] = [];
|
|
557
|
+
const deniedIds: string[] = [];
|
|
558
|
+
|
|
559
|
+
if (metadataAccess) {
|
|
560
|
+
for (const roomId of roomIds) {
|
|
561
|
+
const allowed = await Promise.resolve(metadataAccess(authContext, roomId)).catch(() => false);
|
|
562
|
+
if (allowed) {
|
|
563
|
+
allowedRoomIds.push(roomId);
|
|
564
|
+
} else {
|
|
565
|
+
deniedIds.push(roomId);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
allowedRoomIds.push(...roomIds);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const items = await Promise.all(
|
|
573
|
+
allowedRoomIds.map(async (roomId) => {
|
|
574
|
+
const doName = `${namespace}::${roomId}`;
|
|
575
|
+
const doId = runtime.binding!.idFromName(doName);
|
|
576
|
+
const doStub = runtime.binding!.get(doId);
|
|
577
|
+
|
|
578
|
+
const url = new URL(c.req.url);
|
|
579
|
+
url.pathname = '/summary';
|
|
580
|
+
url.searchParams.set('room', doName);
|
|
581
|
+
const doResponse = await doStub.fetch(new Request(url.toString(), {
|
|
582
|
+
method: 'GET',
|
|
583
|
+
headers: c.req.raw.headers,
|
|
584
|
+
}));
|
|
585
|
+
|
|
586
|
+
if (!doResponse.ok) {
|
|
587
|
+
const errorPayload = await doResponse.json().catch(() => null) as { message?: string } | null;
|
|
588
|
+
throw new Error(
|
|
589
|
+
errorPayload?.message
|
|
590
|
+
?? `Failed to load room summary for '${roomId}' in namespace '${namespace}'.`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return doResponse.json() as Promise<z.infer<typeof roomSummarySchema>>;
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
return c.json({
|
|
599
|
+
namespace,
|
|
600
|
+
items,
|
|
601
|
+
deniedIds,
|
|
602
|
+
updatedAt: new Date().toISOString(),
|
|
603
|
+
});
|
|
604
|
+
});
|
|
@@ -45,14 +45,14 @@ schemaRoute.openapi(getSchema, async (c) => {
|
|
|
45
45
|
buildConstraintCtx(c.env, c.req),
|
|
46
46
|
);
|
|
47
47
|
if (skResult === 'invalid') {
|
|
48
|
-
throw new EdgeBaseError(401, '
|
|
48
|
+
throw new EdgeBaseError(401, 'Invalid X-EdgeBase-Service-Key for schema reads.');
|
|
49
49
|
}
|
|
50
50
|
const serviceKeyBypass = skResult === 'valid';
|
|
51
51
|
|
|
52
52
|
if (!serviceKeyBypass) {
|
|
53
53
|
const auth = c.get('auth');
|
|
54
54
|
if (!auth) {
|
|
55
|
-
throw new EdgeBaseError(401, 'Authentication required.');
|
|
55
|
+
throw new EdgeBaseError(401, 'Authentication required to read the schema endpoint.');
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/routes/sql.ts
CHANGED
|
@@ -39,6 +39,10 @@ import { executeProviderAwareSql } from '../lib/provider-aware-sql.js';
|
|
|
39
39
|
|
|
40
40
|
export const sqlRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
|
|
41
41
|
|
|
42
|
+
function invalidSqlJsonMessage(): string {
|
|
43
|
+
return 'Invalid JSON body for SQL execution. Send application/json with { namespace, sql, params? }.';
|
|
44
|
+
}
|
|
45
|
+
|
|
42
46
|
/**
|
|
43
47
|
* POST /api/sql
|
|
44
48
|
* Body: { namespace: string, id?: string, sql: string, params?: unknown[] }
|
|
@@ -81,19 +85,19 @@ sqlRoute.openapi(executeSql, async (c) => {
|
|
|
81
85
|
try {
|
|
82
86
|
body = await c.req.json();
|
|
83
87
|
} catch {
|
|
84
|
-
return c.json({ code: 400, message:
|
|
88
|
+
return c.json({ code: 400, message: invalidSqlJsonMessage() }, 400);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
const { namespace, id, sql, params } = body;
|
|
88
92
|
|
|
89
93
|
if (!namespace || typeof namespace !== 'string') {
|
|
90
|
-
return c.json({ code: 400, message: 'namespace
|
|
94
|
+
return c.json({ code: 400, message: "Missing required field 'namespace' for SQL execution." }, 400);
|
|
91
95
|
}
|
|
92
96
|
if (id !== undefined && id !== null && typeof id !== 'string') {
|
|
93
|
-
return c.json({ code: 400, message: 'id must be a string
|
|
97
|
+
return c.json({ code: 400, message: "Field 'id' must be a string when provided for SQL execution." }, 400);
|
|
94
98
|
}
|
|
95
99
|
if (!sql || typeof sql !== 'string') {
|
|
96
|
-
return c.json({ code: 400, message: 'sql
|
|
100
|
+
return c.json({ code: 400, message: "Missing required field 'sql' for SQL execution." }, 400);
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
// Validate namespace is declared in databases config (§1)
|
|
@@ -124,10 +128,10 @@ sqlRoute.openapi(executeSql, async (c) => {
|
|
|
124
128
|
buildConstraintCtx(c.env, c.req),
|
|
125
129
|
);
|
|
126
130
|
if (skResult === 'missing') {
|
|
127
|
-
return c.json({ code: 403, message:
|
|
131
|
+
return c.json({ code: 403, message: `X-EdgeBase-Service-Key is required to execute SQL for database namespace '${namespace}'.` }, 403);
|
|
128
132
|
}
|
|
129
133
|
if (skResult === 'invalid') {
|
|
130
|
-
return c.json({ code: 401, message:
|
|
134
|
+
return c.json({ code: 401, message: `Invalid X-EdgeBase-Service-Key for SQL execution in database namespace '${namespace}'.` }, 401);
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
try {
|
package/src/routes/storage.ts
CHANGED
|
@@ -297,7 +297,7 @@ function checkServiceKey(env: Env, header: string | undefined, scope: string, re
|
|
|
297
297
|
const { result } = validateKey(header, scope, config, env, undefined, constraintCtx);
|
|
298
298
|
if (result === 'valid') return true;
|
|
299
299
|
if (result === 'invalid') {
|
|
300
|
-
throw new EdgeBaseError(401,
|
|
300
|
+
throw new EdgeBaseError(401, `Invalid X-EdgeBase-Service-Key for storage scope '${scope}'.`, undefined, 'unauthenticated');
|
|
301
301
|
}
|
|
302
302
|
return false; // 'missing' → continue to normal rules
|
|
303
303
|
}
|
|
@@ -1236,7 +1236,9 @@ storage.openapi(deleteBatch, async (c) => {
|
|
|
1236
1236
|
// afterDelete — plugin-registered storage hooks (per-file, non-blocking)
|
|
1237
1237
|
executeStorageHooks('afterDelete', { ...fileMeta, bucket: bucketName }, auth, c.executionCtx, c.env, getWorkerUrl(c.req.url, c.env));
|
|
1238
1238
|
} catch (e) {
|
|
1239
|
-
const msg = e instanceof EdgeBaseError
|
|
1239
|
+
const msg = e instanceof EdgeBaseError
|
|
1240
|
+
? e.message
|
|
1241
|
+
: 'Delete failed with an unexpected storage error. Check worker logs for details.';
|
|
1240
1242
|
failed.push({ key, error: msg });
|
|
1241
1243
|
}
|
|
1242
1244
|
}
|