@pluv/platform-pluv 0.32.4 → 0.32.6

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.
@@ -1,18 +1,18 @@
1
1
 
2
- > @pluv/platform-pluv@0.32.4 build /home/runner/work/pluv/pluv/packages/platform-pluv
2
+ > @pluv/platform-pluv@0.32.6 build /home/runner/work/pluv/pluv/packages/platform-pluv
3
3
  > tsup src/index.ts --format esm,cjs --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
6
6
  CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.3.0
7
+ CLI tsup v8.3.5
8
8
  CLI Target: es6
9
9
  ESM Build start
10
10
  CJS Build start
11
- ESM dist/index.mjs 4.82 KB
12
- ESM ⚡️ Build success in 77ms
13
- CJS dist/index.js 5.91 KB
14
- CJS ⚡️ Build success in 84ms
11
+ ESM dist/index.mjs 8.83 KB
12
+ ESM ⚡️ Build success in 80ms
13
+ CJS dist/index.js 9.70 KB
14
+ CJS ⚡️ Build success in 81ms
15
15
  DTS Build start
16
- DTS ⚡️ Build success in 6492ms
17
- DTS dist/index.d.mts 2.89 KB
18
- DTS dist/index.d.ts 2.89 KB
16
+ DTS ⚡️ Build success in 6208ms
17
+ DTS dist/index.d.mts 3.54 KB
18
+ DTS dist/index.d.ts 3.54 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # @pluv/platform-pluv
2
2
 
3
+ ## 0.32.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 25292d4: Fix event payload types to only contain serializable fields.
8
+ - c0956e7: Add `onUserConnected` and `onUserDisconnected` events on `PluvServer`.
9
+
10
+ ```ts
11
+ import { createIO } from "@pluv/io";
12
+
13
+ const io = createIO({
14
+ /* ... */
15
+ });
16
+
17
+ const ioServer = io.server({
18
+ // ...
19
+ onUserConnected: (event) => {
20
+ // ...
21
+ },
22
+ onUserDisconnected: (event) => {
23
+ // ...
24
+ },
25
+ });
26
+ ```
27
+
28
+ - fc83a44: Enable `onUserConnected` and `onUserDisconnected` event listeners.
29
+ - be1488f: Validate webhook signatures via webhook secrets.
30
+
31
+ ```ts
32
+ import { createIO } from "@pluv/platform-pluv";
33
+
34
+ const io = createIO({
35
+ // ...
36
+ // If you provide a webhookSecret
37
+ webhookSecret: "whsec_...",
38
+
39
+ // The following properties will be made available to configure
40
+ getInitialStorage: (event) => {
41
+ /* ... */
42
+ },
43
+ onRoomDeleted: (event) => {
44
+ /* ... */
45
+ },
46
+ onUserConnected: (event) => {
47
+ /* ... */
48
+ },
49
+ onUserDisconnected: (event) => {
50
+ /* ... */
51
+ },
52
+ });
53
+ ```
54
+
55
+ - Updated dependencies [c0956e7]
56
+ - @pluv/io@0.32.6
57
+ - @pluv/crdt@0.32.6
58
+ - @pluv/types@0.32.6
59
+
60
+ ## 0.32.5
61
+
62
+ ### Patch Changes
63
+
64
+ - @pluv/crdt@0.32.5
65
+ - @pluv/io@0.32.5
66
+ - @pluv/types@0.32.5
67
+
3
68
  ## 0.32.4
4
69
 
5
70
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,5 +1,4 @@
1
- import { AbstractPlatform, WebSocketRegistrationMode, AbstractWebSocket, ConvertWebSocketConfig, WebSocketSerializedState, AbstractPlatformConfig, PluvIOListeners, GetInitialStorageFn, JWTEncodeParams, BaseUser as BaseUser$1 } from '@pluv/io';
2
- import * as hono_types from 'hono/types';
1
+ import { AbstractPlatform, WebSocketRegistrationMode, AbstractWebSocket, ConvertWebSocketConfig, WebSocketSerializedState, AbstractPlatformConfig, JWTEncodeParams, GetInitialStorageFn, BaseUser as BaseUser$1 } from '@pluv/io';
3
2
  import * as hono from 'hono';
4
3
  import { BaseUser, InputZodLike, IOLike } from '@pluv/types';
5
4
 
@@ -25,7 +24,34 @@ interface PluvAuthorize<TUser extends BaseUser> {
25
24
  interface PluvIOEndpoints {
26
25
  createToken: string;
27
26
  }
28
- type PluvIOConfig<TUser extends BaseUser> = Partial<Pick<PluvIOListeners<PluvPlatform, PluvAuthorize<TUser>, {}, {}>, "onRoomDeleted">> & {
27
+ type RoomDeletedMessageEventData = {
28
+ encodedState: string | null;
29
+ room: string;
30
+ };
31
+ type UserConnectedEventData<TUser extends BaseUser> = {
32
+ encodedState: string | null;
33
+ room: string;
34
+ user: TUser;
35
+ };
36
+ type UserDisconnectedEventData<TUser extends BaseUser> = {
37
+ encodedState: string | null;
38
+ room: string;
39
+ user: TUser;
40
+ };
41
+ type PluvIOListeners<TUser extends BaseUser> = {
42
+ getInitialStorage?: GetInitialStorageFn;
43
+ onRoomDeleted: (event: RoomDeletedMessageEventData) => void;
44
+ onUserConnected: (event: UserConnectedEventData<TUser>) => void;
45
+ onUserDisconnected: (event: UserDisconnectedEventData<TUser>) => void;
46
+ };
47
+ type WebhooksConfig<TUser extends BaseUser> = ({
48
+ webhookSecret?: undefined;
49
+ } & {
50
+ [P in keyof PluvIOListeners<TUser>]?: undefined;
51
+ }) | ({
52
+ webhookSecret: string;
53
+ } & Partial<PluvIOListeners<TUser>>);
54
+ type PluvIOConfig<TUser extends BaseUser> = WebhooksConfig<TUser> & {
29
55
  /**
30
56
  * @ignore
31
57
  * @readonly
@@ -38,7 +64,6 @@ type PluvIOConfig<TUser extends BaseUser> = Partial<Pick<PluvIOListeners<PluvPla
38
64
  user: InputZodLike<TUser>;
39
65
  };
40
66
  basePath: string;
41
- getInitialStorage?: GetInitialStorageFn<PluvPlatform>;
42
67
  publicKey: string;
43
68
  secretKey: string;
44
69
  };
@@ -50,6 +75,7 @@ declare class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUs
50
75
  private readonly _listeners;
51
76
  private readonly _publicKey;
52
77
  private readonly _secretKey;
78
+ private readonly _webhookSecret?;
53
79
  /**
54
80
  * @ignore
55
81
  * @readonly
@@ -62,7 +88,7 @@ declare class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUs
62
88
  readonly platform: void;
63
89
  };
64
90
  get fetch(): (request: Request, Env?: unknown, executionCtx?: hono.ExecutionContext) => Response | Promise<Response>;
65
- get handler(): (req: Request, requestContext: hono_types.FetchEventLike) => Response | Promise<Response>;
91
+ get handler(): (req: Request) => Response | Promise<Response>;
66
92
  private _webhooks;
67
93
  constructor(options: PluvIOConfig<TUser>);
68
94
  createToken(params: JWTEncodeParams<TUser, PluvPlatform>): Promise<string>;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { AbstractPlatform, WebSocketRegistrationMode, AbstractWebSocket, ConvertWebSocketConfig, WebSocketSerializedState, AbstractPlatformConfig, PluvIOListeners, GetInitialStorageFn, JWTEncodeParams, BaseUser as BaseUser$1 } from '@pluv/io';
2
- import * as hono_types from 'hono/types';
1
+ import { AbstractPlatform, WebSocketRegistrationMode, AbstractWebSocket, ConvertWebSocketConfig, WebSocketSerializedState, AbstractPlatformConfig, JWTEncodeParams, GetInitialStorageFn, BaseUser as BaseUser$1 } from '@pluv/io';
3
2
  import * as hono from 'hono';
4
3
  import { BaseUser, InputZodLike, IOLike } from '@pluv/types';
5
4
 
@@ -25,7 +24,34 @@ interface PluvAuthorize<TUser extends BaseUser> {
25
24
  interface PluvIOEndpoints {
26
25
  createToken: string;
27
26
  }
28
- type PluvIOConfig<TUser extends BaseUser> = Partial<Pick<PluvIOListeners<PluvPlatform, PluvAuthorize<TUser>, {}, {}>, "onRoomDeleted">> & {
27
+ type RoomDeletedMessageEventData = {
28
+ encodedState: string | null;
29
+ room: string;
30
+ };
31
+ type UserConnectedEventData<TUser extends BaseUser> = {
32
+ encodedState: string | null;
33
+ room: string;
34
+ user: TUser;
35
+ };
36
+ type UserDisconnectedEventData<TUser extends BaseUser> = {
37
+ encodedState: string | null;
38
+ room: string;
39
+ user: TUser;
40
+ };
41
+ type PluvIOListeners<TUser extends BaseUser> = {
42
+ getInitialStorage?: GetInitialStorageFn;
43
+ onRoomDeleted: (event: RoomDeletedMessageEventData) => void;
44
+ onUserConnected: (event: UserConnectedEventData<TUser>) => void;
45
+ onUserDisconnected: (event: UserDisconnectedEventData<TUser>) => void;
46
+ };
47
+ type WebhooksConfig<TUser extends BaseUser> = ({
48
+ webhookSecret?: undefined;
49
+ } & {
50
+ [P in keyof PluvIOListeners<TUser>]?: undefined;
51
+ }) | ({
52
+ webhookSecret: string;
53
+ } & Partial<PluvIOListeners<TUser>>);
54
+ type PluvIOConfig<TUser extends BaseUser> = WebhooksConfig<TUser> & {
29
55
  /**
30
56
  * @ignore
31
57
  * @readonly
@@ -38,7 +64,6 @@ type PluvIOConfig<TUser extends BaseUser> = Partial<Pick<PluvIOListeners<PluvPla
38
64
  user: InputZodLike<TUser>;
39
65
  };
40
66
  basePath: string;
41
- getInitialStorage?: GetInitialStorageFn<PluvPlatform>;
42
67
  publicKey: string;
43
68
  secretKey: string;
44
69
  };
@@ -50,6 +75,7 @@ declare class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUs
50
75
  private readonly _listeners;
51
76
  private readonly _publicKey;
52
77
  private readonly _secretKey;
78
+ private readonly _webhookSecret?;
53
79
  /**
54
80
  * @ignore
55
81
  * @readonly
@@ -62,7 +88,7 @@ declare class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUs
62
88
  readonly platform: void;
63
89
  };
64
90
  get fetch(): (request: Request, Env?: unknown, executionCtx?: hono.ExecutionContext) => Response | Promise<Response>;
65
- get handler(): (req: Request, requestContext: hono_types.FetchEventLike) => Response | Promise<Response>;
91
+ get handler(): (req: Request) => Response | Promise<Response>;
66
92
  private _webhooks;
67
93
  constructor(options: PluvIOConfig<TUser>);
68
94
  createToken(params: JWTEncodeParams<TUser, PluvPlatform>): Promise<string>;
package/dist/index.js CHANGED
@@ -62,6 +62,9 @@ module.exports = __toCommonJS(src_exports);
62
62
  var import_hono = require("hono");
63
63
  var import_vercel = require("hono/vercel");
64
64
 
65
+ // src/constants.ts
66
+ var SIGNATURE_HEADER = "x-pluv-signature-256";
67
+
65
68
  // src/schemas.ts
66
69
  var import_zod = require("zod");
67
70
  var ZodEventInitialStorage = import_zod.z.object({
@@ -77,15 +80,100 @@ var ZodEventRoomDeleted = import_zod.z.object({
77
80
  storage: import_zod.z.string().nullable()
78
81
  })
79
82
  });
80
- var ZodEvent = import_zod.z.discriminatedUnion("event", [ZodEventInitialStorage, ZodEventRoomDeleted]);
83
+ var ZodEventUserConnected = import_zod.z.object({
84
+ event: import_zod.z.literal("user-connected"),
85
+ data: import_zod.z.object({
86
+ room: import_zod.z.string(),
87
+ storage: import_zod.z.string().nullable(),
88
+ user: import_zod.z.object({
89
+ id: import_zod.z.string()
90
+ }).passthrough()
91
+ })
92
+ });
93
+ var ZodEventUserDisconnected = import_zod.z.object({
94
+ event: import_zod.z.literal("user-disconnected"),
95
+ data: import_zod.z.object({
96
+ room: import_zod.z.string(),
97
+ storage: import_zod.z.string().nullable(),
98
+ user: import_zod.z.object({
99
+ id: import_zod.z.string()
100
+ }).passthrough()
101
+ })
102
+ });
103
+ var ZodEvent = import_zod.z.discriminatedUnion("event", [
104
+ ZodEventInitialStorage,
105
+ ZodEventRoomDeleted,
106
+ ZodEventUserConnected,
107
+ ZodEventUserDisconnected
108
+ ]);
109
+
110
+ // src/shared/getCrypto.ts
111
+ var getCrypto = () => {
112
+ if (typeof crypto !== "undefined") {
113
+ return crypto;
114
+ }
115
+ if (typeof require === "function") {
116
+ return require("crypto").webcrypto;
117
+ }
118
+ throw new Error("Missing crypto module");
119
+ };
120
+
121
+ // src/shared/createHmac.ts
122
+ var createHmac = (params) => __async(void 0, null, function* () {
123
+ const { payload, secret } = params;
124
+ if (!payload || !secret) throw new Error("Secret and payload are required to sign payload");
125
+ const encoder = new TextEncoder();
126
+ const keyBytes = encoder.encode(secret);
127
+ const crypto2 = getCrypto();
128
+ const algorithm = { name: "HMAC", hash: { name: "SHA-256" } };
129
+ const extractable = false;
130
+ const key = yield crypto2.subtle.importKey("raw", keyBytes, algorithm, extractable, ["sign", "verify"]);
131
+ const payloadBytes = encoder.encode(payload);
132
+ const signature = yield crypto2.subtle.sign("HMAC", key, payloadBytes);
133
+ const hmac = Array.from(new Uint8Array(signature)).map((b) => ("0" + b.toString(16)).slice(-2)).join("");
134
+ return { algorithm: "sha256", hmac };
135
+ });
136
+
137
+ // src/shared/timingSafeEqual.ts
138
+ var timingSafeEqual = (a, b) => {
139
+ if (a.length !== b.length) return false;
140
+ let result = 0;
141
+ for (let i = 0; i < a.length; i++) {
142
+ result |= a[i] ^ b[i];
143
+ }
144
+ return result === 0;
145
+ };
146
+
147
+ // src/shared/verifyWebhook.ts
148
+ var verifyWebhook = (params) => __async(void 0, null, function* () {
149
+ const { payload, secret, signature } = params;
150
+ if (!secret || !payload || !signature) {
151
+ throw new Error("Secret, payload and signature are required to verify payload");
152
+ }
153
+ const { hmac } = yield createHmac({ payload, secret });
154
+ if (hmac.length !== signature.length) return false;
155
+ const encoder = new TextEncoder();
156
+ const verificationBytes = encoder.encode(hmac);
157
+ const signatureBytes = encoder.encode(signature);
158
+ if (verificationBytes.length !== signatureBytes.length) return false;
159
+ return timingSafeEqual(verificationBytes, signatureBytes);
160
+ });
81
161
 
82
162
  // src/PluvIO.ts
83
163
  var PluvIO = class {
84
164
  constructor(options) {
85
165
  this._webhooks = new import_hono.Hono().basePath("/").post("/event", (c) => __async(this, null, function* () {
86
166
  var _a, _b;
87
- const body = yield c.req.json();
88
- const parsed = ZodEvent.safeParse(body);
167
+ const signature = c.req.header(SIGNATURE_HEADER);
168
+ if (!this._webhookSecret || !signature) return c.json({ error: "Unauthorized" }, 401);
169
+ const payload = yield c.req.json();
170
+ const verified = yield verifyWebhook({
171
+ payload,
172
+ signature,
173
+ secret: this._webhookSecret
174
+ });
175
+ if (!verified) return c.json({ error: "Unauthorized" }, 401);
176
+ const parsed = ZodEvent.safeParse(payload);
89
177
  if (!parsed.success) return c.json({ data: { ok: true } }, 200);
90
178
  const { event, data } = parsed.data;
91
179
  switch (event) {
@@ -97,14 +185,37 @@ var PluvIO = class {
97
185
  case "room-deleted": {
98
186
  const room = data.room;
99
187
  const encodedState = data.storage;
100
- yield Promise.resolve(this._listeners.onRoomDeleted({ context: {}, encodedState, room }));
188
+ yield Promise.resolve(this._listeners.onRoomDeleted({ encodedState, room }));
101
189
  return c.json({ data: { room } }, 200);
102
190
  }
191
+ case "user-connected": {
192
+ const room = data.room;
193
+ const encodedState = data.storage;
194
+ const user = data.user;
195
+ yield Promise.resolve(this._listeners.onUserConnected({ encodedState, room, user }));
196
+ }
197
+ case "user-disconnected": {
198
+ const room = data.room;
199
+ const encodedState = data.storage;
200
+ const user = data.user;
201
+ yield Promise.resolve(this._listeners.onUserDisconnected({ encodedState, room, user }));
202
+ }
103
203
  default:
104
204
  return c.json({ data: { ok: true } }, 200);
105
205
  }
106
206
  }));
107
- const { _defs, authorize, basePath, onRoomDeleted, getInitialStorage, publicKey, secretKey } = options;
207
+ const {
208
+ _defs,
209
+ authorize,
210
+ basePath,
211
+ getInitialStorage,
212
+ onRoomDeleted,
213
+ onUserConnected,
214
+ onUserDisconnected,
215
+ publicKey,
216
+ secretKey,
217
+ webhookSecret
218
+ } = options;
108
219
  this._authorize = {
109
220
  required: true,
110
221
  secret: "",
@@ -115,9 +226,14 @@ var PluvIO = class {
115
226
  createToken: "https://pluv.io/api/room/token"
116
227
  }, _defs == null ? void 0 : _defs.endpoints);
117
228
  this._getInitialStorage = getInitialStorage;
118
- this._listeners = { onRoomDeleted: (event) => onRoomDeleted == null ? void 0 : onRoomDeleted(event) };
229
+ this._listeners = {
230
+ onRoomDeleted: (event) => onRoomDeleted == null ? void 0 : onRoomDeleted(event),
231
+ onUserConnected: (event) => onUserConnected == null ? void 0 : onUserConnected(event),
232
+ onUserDisconnected: (event) => onUserDisconnected == null ? void 0 : onUserDisconnected(event)
233
+ };
119
234
  this._publicKey = publicKey;
120
235
  this._secretKey = secretKey;
236
+ this._webhookSecret = webhookSecret;
121
237
  }
122
238
  /**
123
239
  * @ignore
@@ -161,9 +277,7 @@ var PluvIO = class {
161
277
  throw new Error("Authorization failed");
162
278
  }
163
279
  const token = yield res.text().catch(() => null);
164
- if (typeof token !== "string") {
165
- throw new Error("Authorization failed");
166
- }
280
+ if (typeof token !== "string") throw new Error("Authorization failed");
167
281
  return token;
168
282
  });
169
283
  }
package/dist/index.mjs CHANGED
@@ -14,6 +14,12 @@ var __spreadValues = (a, b) => {
14
14
  }
15
15
  return a;
16
16
  };
17
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
18
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
19
+ }) : x)(function(x) {
20
+ if (typeof require !== "undefined") return require.apply(this, arguments);
21
+ throw Error('Dynamic require of "' + x + '" is not supported');
22
+ });
17
23
  var __async = (__this, __arguments, generator) => {
18
24
  return new Promise((resolve, reject) => {
19
25
  var fulfilled = (value) => {
@@ -39,6 +45,9 @@ var __async = (__this, __arguments, generator) => {
39
45
  import { Hono } from "hono";
40
46
  import { handle } from "hono/vercel";
41
47
 
48
+ // src/constants.ts
49
+ var SIGNATURE_HEADER = "x-pluv-signature-256";
50
+
42
51
  // src/schemas.ts
43
52
  import { z } from "zod";
44
53
  var ZodEventInitialStorage = z.object({
@@ -54,15 +63,100 @@ var ZodEventRoomDeleted = z.object({
54
63
  storage: z.string().nullable()
55
64
  })
56
65
  });
57
- var ZodEvent = z.discriminatedUnion("event", [ZodEventInitialStorage, ZodEventRoomDeleted]);
66
+ var ZodEventUserConnected = z.object({
67
+ event: z.literal("user-connected"),
68
+ data: z.object({
69
+ room: z.string(),
70
+ storage: z.string().nullable(),
71
+ user: z.object({
72
+ id: z.string()
73
+ }).passthrough()
74
+ })
75
+ });
76
+ var ZodEventUserDisconnected = z.object({
77
+ event: z.literal("user-disconnected"),
78
+ data: z.object({
79
+ room: z.string(),
80
+ storage: z.string().nullable(),
81
+ user: z.object({
82
+ id: z.string()
83
+ }).passthrough()
84
+ })
85
+ });
86
+ var ZodEvent = z.discriminatedUnion("event", [
87
+ ZodEventInitialStorage,
88
+ ZodEventRoomDeleted,
89
+ ZodEventUserConnected,
90
+ ZodEventUserDisconnected
91
+ ]);
92
+
93
+ // src/shared/getCrypto.ts
94
+ var getCrypto = () => {
95
+ if (typeof crypto !== "undefined") {
96
+ return crypto;
97
+ }
98
+ if (typeof __require === "function") {
99
+ return __require("node:crypto").webcrypto;
100
+ }
101
+ throw new Error("Missing crypto module");
102
+ };
103
+
104
+ // src/shared/createHmac.ts
105
+ var createHmac = (params) => __async(void 0, null, function* () {
106
+ const { payload, secret } = params;
107
+ if (!payload || !secret) throw new Error("Secret and payload are required to sign payload");
108
+ const encoder = new TextEncoder();
109
+ const keyBytes = encoder.encode(secret);
110
+ const crypto2 = getCrypto();
111
+ const algorithm = { name: "HMAC", hash: { name: "SHA-256" } };
112
+ const extractable = false;
113
+ const key = yield crypto2.subtle.importKey("raw", keyBytes, algorithm, extractable, ["sign", "verify"]);
114
+ const payloadBytes = encoder.encode(payload);
115
+ const signature = yield crypto2.subtle.sign("HMAC", key, payloadBytes);
116
+ const hmac = Array.from(new Uint8Array(signature)).map((b) => ("0" + b.toString(16)).slice(-2)).join("");
117
+ return { algorithm: "sha256", hmac };
118
+ });
119
+
120
+ // src/shared/timingSafeEqual.ts
121
+ var timingSafeEqual = (a, b) => {
122
+ if (a.length !== b.length) return false;
123
+ let result = 0;
124
+ for (let i = 0; i < a.length; i++) {
125
+ result |= a[i] ^ b[i];
126
+ }
127
+ return result === 0;
128
+ };
129
+
130
+ // src/shared/verifyWebhook.ts
131
+ var verifyWebhook = (params) => __async(void 0, null, function* () {
132
+ const { payload, secret, signature } = params;
133
+ if (!secret || !payload || !signature) {
134
+ throw new Error("Secret, payload and signature are required to verify payload");
135
+ }
136
+ const { hmac } = yield createHmac({ payload, secret });
137
+ if (hmac.length !== signature.length) return false;
138
+ const encoder = new TextEncoder();
139
+ const verificationBytes = encoder.encode(hmac);
140
+ const signatureBytes = encoder.encode(signature);
141
+ if (verificationBytes.length !== signatureBytes.length) return false;
142
+ return timingSafeEqual(verificationBytes, signatureBytes);
143
+ });
58
144
 
59
145
  // src/PluvIO.ts
60
146
  var PluvIO = class {
61
147
  constructor(options) {
62
148
  this._webhooks = new Hono().basePath("/").post("/event", (c) => __async(this, null, function* () {
63
149
  var _a, _b;
64
- const body = yield c.req.json();
65
- const parsed = ZodEvent.safeParse(body);
150
+ const signature = c.req.header(SIGNATURE_HEADER);
151
+ if (!this._webhookSecret || !signature) return c.json({ error: "Unauthorized" }, 401);
152
+ const payload = yield c.req.json();
153
+ const verified = yield verifyWebhook({
154
+ payload,
155
+ signature,
156
+ secret: this._webhookSecret
157
+ });
158
+ if (!verified) return c.json({ error: "Unauthorized" }, 401);
159
+ const parsed = ZodEvent.safeParse(payload);
66
160
  if (!parsed.success) return c.json({ data: { ok: true } }, 200);
67
161
  const { event, data } = parsed.data;
68
162
  switch (event) {
@@ -74,14 +168,37 @@ var PluvIO = class {
74
168
  case "room-deleted": {
75
169
  const room = data.room;
76
170
  const encodedState = data.storage;
77
- yield Promise.resolve(this._listeners.onRoomDeleted({ context: {}, encodedState, room }));
171
+ yield Promise.resolve(this._listeners.onRoomDeleted({ encodedState, room }));
78
172
  return c.json({ data: { room } }, 200);
79
173
  }
174
+ case "user-connected": {
175
+ const room = data.room;
176
+ const encodedState = data.storage;
177
+ const user = data.user;
178
+ yield Promise.resolve(this._listeners.onUserConnected({ encodedState, room, user }));
179
+ }
180
+ case "user-disconnected": {
181
+ const room = data.room;
182
+ const encodedState = data.storage;
183
+ const user = data.user;
184
+ yield Promise.resolve(this._listeners.onUserDisconnected({ encodedState, room, user }));
185
+ }
80
186
  default:
81
187
  return c.json({ data: { ok: true } }, 200);
82
188
  }
83
189
  }));
84
- const { _defs, authorize, basePath, onRoomDeleted, getInitialStorage, publicKey, secretKey } = options;
190
+ const {
191
+ _defs,
192
+ authorize,
193
+ basePath,
194
+ getInitialStorage,
195
+ onRoomDeleted,
196
+ onUserConnected,
197
+ onUserDisconnected,
198
+ publicKey,
199
+ secretKey,
200
+ webhookSecret
201
+ } = options;
85
202
  this._authorize = {
86
203
  required: true,
87
204
  secret: "",
@@ -92,9 +209,14 @@ var PluvIO = class {
92
209
  createToken: "https://pluv.io/api/room/token"
93
210
  }, _defs == null ? void 0 : _defs.endpoints);
94
211
  this._getInitialStorage = getInitialStorage;
95
- this._listeners = { onRoomDeleted: (event) => onRoomDeleted == null ? void 0 : onRoomDeleted(event) };
212
+ this._listeners = {
213
+ onRoomDeleted: (event) => onRoomDeleted == null ? void 0 : onRoomDeleted(event),
214
+ onUserConnected: (event) => onUserConnected == null ? void 0 : onUserConnected(event),
215
+ onUserDisconnected: (event) => onUserDisconnected == null ? void 0 : onUserDisconnected(event)
216
+ };
96
217
  this._publicKey = publicKey;
97
218
  this._secretKey = secretKey;
219
+ this._webhookSecret = webhookSecret;
98
220
  }
99
221
  /**
100
222
  * @ignore
@@ -138,9 +260,7 @@ var PluvIO = class {
138
260
  throw new Error("Authorization failed");
139
261
  }
140
262
  const token = yield res.text().catch(() => null);
141
- if (typeof token !== "string") {
142
- throw new Error("Authorization failed");
143
- }
263
+ if (typeof token !== "string") throw new Error("Authorization failed");
144
264
  return token;
145
265
  });
146
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pluv/platform-pluv",
3
- "version": "0.32.4",
3
+ "version": "0.32.6",
4
4
  "description": "@pluv/io adapter for pluv.io",
5
5
  "author": "leedavidcs",
6
6
  "license": "MIT",
@@ -17,19 +17,19 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@types/node": "^22.7.8",
21
- "hono": "^4.6.6",
20
+ "@types/node": "^22.8.1",
21
+ "hono": "^4.6.7",
22
22
  "zod": "^3.23.8",
23
- "@pluv/crdt": "^0.32.4",
24
- "@pluv/io": "^0.32.4",
25
- "@pluv/types": "^0.32.4"
23
+ "@pluv/crdt": "^0.32.6",
24
+ "@pluv/io": "^0.32.6",
25
+ "@pluv/types": "^0.32.6"
26
26
  },
27
27
  "devDependencies": {
28
28
  "eslint": "^8.57.0",
29
- "tsup": "^8.3.0",
29
+ "tsup": "^8.3.5",
30
30
  "typescript": "^5.6.3",
31
- "@pluv/tsconfig": "^0.32.4",
32
- "eslint-config-pluv": "^0.32.4"
31
+ "@pluv/tsconfig": "^0.32.6",
32
+ "eslint-config-pluv": "^0.32.6"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsup src/index.ts --format esm,cjs --dts",
package/src/PluvIO.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { GetInitialStorageFn, JWTEncodeParams, PluvIOListeners } from "@pluv/io";
1
+ import type { GetInitialStorageFn, JWTEncodeParams } from "@pluv/io";
2
2
  import type { BaseUser, IOLike, InputZodLike } from "@pluv/types";
3
3
  import { Hono } from "hono";
4
4
  import { handle } from "hono/vercel";
5
+ import { SIGNATURE_HEADER } from "./constants";
5
6
  import type { PluvPlatform } from "./PluvPlatform";
6
7
  import { ZodEvent } from "./schemas";
8
+ import { verifyWebhook } from "./shared";
7
9
 
8
10
  interface PluvAuthorize<TUser extends BaseUser> {
9
11
  required: true;
@@ -15,9 +17,35 @@ interface PluvIOEndpoints {
15
17
  createToken: string;
16
18
  }
17
19
 
18
- export type PluvIOConfig<TUser extends BaseUser> = Partial<
19
- Pick<PluvIOListeners<PluvPlatform, PluvAuthorize<TUser>, {}, {}>, "onRoomDeleted">
20
- > & {
20
+ type RoomDeletedMessageEventData = {
21
+ encodedState: string | null;
22
+ room: string;
23
+ };
24
+
25
+ type UserConnectedEventData<TUser extends BaseUser> = {
26
+ encodedState: string | null;
27
+ room: string;
28
+ user: TUser;
29
+ };
30
+
31
+ type UserDisconnectedEventData<TUser extends BaseUser> = {
32
+ encodedState: string | null;
33
+ room: string;
34
+ user: TUser;
35
+ };
36
+
37
+ type PluvIOListeners<TUser extends BaseUser> = {
38
+ getInitialStorage?: GetInitialStorageFn;
39
+ onRoomDeleted: (event: RoomDeletedMessageEventData) => void;
40
+ onUserConnected: (event: UserConnectedEventData<TUser>) => void;
41
+ onUserDisconnected: (event: UserDisconnectedEventData<TUser>) => void;
42
+ };
43
+
44
+ type WebhooksConfig<TUser extends BaseUser> =
45
+ | ({ webhookSecret?: undefined } & { [P in keyof PluvIOListeners<TUser>]?: undefined })
46
+ | ({ webhookSecret: string } & Partial<PluvIOListeners<TUser>>);
47
+
48
+ export type PluvIOConfig<TUser extends BaseUser> = WebhooksConfig<TUser> & {
21
49
  /**
22
50
  * @ignore
23
51
  * @readonly
@@ -30,7 +58,6 @@ export type PluvIOConfig<TUser extends BaseUser> = Partial<
30
58
  user: InputZodLike<TUser>;
31
59
  };
32
60
  basePath: string;
33
- getInitialStorage?: GetInitialStorageFn<PluvPlatform>;
34
61
  publicKey: string;
35
62
  secretKey: string;
36
63
  };
@@ -39,10 +66,11 @@ export class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUse
39
66
  private readonly _authorize: PluvAuthorize<TUser>;
40
67
  private readonly _basePath: string;
41
68
  private readonly _endpoints: PluvIOEndpoints;
42
- private readonly _getInitialStorage?: GetInitialStorageFn<PluvPlatform>;
43
- private readonly _listeners: Pick<PluvIOListeners<PluvPlatform, PluvAuthorize<TUser>, {}, {}>, "onRoomDeleted">;
69
+ private readonly _getInitialStorage?: GetInitialStorageFn;
70
+ private readonly _listeners: PluvIOListeners<TUser>;
44
71
  private readonly _publicKey: string;
45
72
  private readonly _secretKey: string;
73
+ private readonly _webhookSecret?: string;
46
74
 
47
75
  /**
48
76
  * @ignore
@@ -73,8 +101,21 @@ export class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUse
73
101
  }
74
102
 
75
103
  private _webhooks = new Hono().basePath("/").post("/event", async (c) => {
76
- const body = await c.req.json();
77
- const parsed = ZodEvent.safeParse(body);
104
+ const signature = c.req.header(SIGNATURE_HEADER);
105
+
106
+ if (!this._webhookSecret || !signature) return c.json({ error: "Unauthorized" }, 401);
107
+
108
+ const payload = await c.req.json();
109
+
110
+ const verified = await verifyWebhook({
111
+ payload,
112
+ signature,
113
+ secret: this._webhookSecret,
114
+ });
115
+
116
+ if (!verified) return c.json({ error: "Unauthorized" }, 401);
117
+
118
+ const parsed = ZodEvent.safeParse(payload);
78
119
 
79
120
  if (!parsed.success) return c.json({ data: { ok: true } }, 200);
80
121
 
@@ -91,17 +132,42 @@ export class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUse
91
132
  const room = data.room;
92
133
  const encodedState = data.storage;
93
134
 
94
- await Promise.resolve(this._listeners.onRoomDeleted({ context: {}, encodedState, room }));
135
+ await Promise.resolve(this._listeners.onRoomDeleted({ encodedState, room }));
95
136
 
96
137
  return c.json({ data: { room } }, 200);
97
138
  }
139
+ case "user-connected": {
140
+ const room = data.room;
141
+ const encodedState = data.storage;
142
+ const user = data.user as TUser;
143
+
144
+ await Promise.resolve(this._listeners.onUserConnected({ encodedState, room, user }));
145
+ }
146
+ case "user-disconnected": {
147
+ const room = data.room;
148
+ const encodedState = data.storage;
149
+ const user = data.user as TUser;
150
+
151
+ await Promise.resolve(this._listeners.onUserDisconnected({ encodedState, room, user }));
152
+ }
98
153
  default:
99
154
  return c.json({ data: { ok: true } }, 200);
100
155
  }
101
156
  });
102
157
 
103
158
  constructor(options: PluvIOConfig<TUser>) {
104
- const { _defs, authorize, basePath, onRoomDeleted, getInitialStorage, publicKey, secretKey } = options;
159
+ const {
160
+ _defs,
161
+ authorize,
162
+ basePath,
163
+ getInitialStorage,
164
+ onRoomDeleted,
165
+ onUserConnected,
166
+ onUserDisconnected,
167
+ publicKey,
168
+ secretKey,
169
+ webhookSecret,
170
+ } = options;
105
171
 
106
172
  this._authorize = {
107
173
  required: true,
@@ -114,9 +180,14 @@ export class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUse
114
180
  ..._defs?.endpoints,
115
181
  };
116
182
  this._getInitialStorage = getInitialStorage;
117
- this._listeners = { onRoomDeleted: (event) => onRoomDeleted?.(event) };
183
+ this._listeners = {
184
+ onRoomDeleted: (event) => onRoomDeleted?.(event),
185
+ onUserConnected: (event) => onUserConnected?.(event),
186
+ onUserDisconnected: (event) => onUserDisconnected?.(event),
187
+ };
118
188
  this._publicKey = publicKey;
119
189
  this._secretKey = secretKey;
190
+ this._webhookSecret = webhookSecret;
120
191
  }
121
192
 
122
193
  public async createToken(params: JWTEncodeParams<TUser, PluvPlatform>): Promise<string> {
@@ -140,9 +211,7 @@ export class PluvIO<TUser extends BaseUser> implements IOLike<PluvAuthorize<TUse
140
211
 
141
212
  const token = await res.text().catch(() => null);
142
213
 
143
- if (typeof token !== "string") {
144
- throw new Error("Authorization failed");
145
- }
214
+ if (typeof token !== "string") throw new Error("Authorization failed");
146
215
 
147
216
  return token;
148
217
  }
@@ -0,0 +1 @@
1
+ export const SIGNATURE_HEADER = "x-pluv-signature-256";
package/src/schemas.ts CHANGED
@@ -15,4 +15,35 @@ const ZodEventRoomDeleted = z.object({
15
15
  }),
16
16
  });
17
17
 
18
- export const ZodEvent = z.discriminatedUnion("event", [ZodEventInitialStorage, ZodEventRoomDeleted]);
18
+ const ZodEventUserConnected = z.object({
19
+ event: z.literal("user-connected"),
20
+ data: z.object({
21
+ room: z.string(),
22
+ storage: z.string().nullable(),
23
+ user: z
24
+ .object({
25
+ id: z.string(),
26
+ })
27
+ .passthrough(),
28
+ }),
29
+ });
30
+
31
+ const ZodEventUserDisconnected = z.object({
32
+ event: z.literal("user-disconnected"),
33
+ data: z.object({
34
+ room: z.string(),
35
+ storage: z.string().nullable(),
36
+ user: z
37
+ .object({
38
+ id: z.string(),
39
+ })
40
+ .passthrough(),
41
+ }),
42
+ });
43
+
44
+ export const ZodEvent = z.discriminatedUnion("event", [
45
+ ZodEventInitialStorage,
46
+ ZodEventRoomDeleted,
47
+ ZodEventUserConnected,
48
+ ZodEventUserDisconnected,
49
+ ]);
@@ -0,0 +1,38 @@
1
+ import type { webcrypto } from "crypto";
2
+ import { getCrypto } from "./getCrypto";
3
+
4
+ export interface CreateHmacParams {
5
+ payload: string;
6
+ secret: string;
7
+ }
8
+
9
+ export type CreateHmacResult = {
10
+ algorithm: "sha256";
11
+ hmac: string;
12
+ };
13
+
14
+ export const createHmac = async (params: CreateHmacParams): Promise<CreateHmacResult> => {
15
+ const { payload, secret } = params;
16
+
17
+ if (!payload || !secret) throw new Error("Secret and payload are required to sign payload");
18
+
19
+ const encoder = new TextEncoder();
20
+ const keyBytes = encoder.encode(secret);
21
+
22
+ const crypto = getCrypto();
23
+
24
+ const algorithm: webcrypto.HmacImportParams = { name: "HMAC", hash: { name: "SHA-256" } };
25
+ const extractable = false;
26
+
27
+ const key = await crypto.subtle.importKey("raw", keyBytes, algorithm, extractable, ["sign", "verify"]);
28
+ const payloadBytes = encoder.encode(payload);
29
+
30
+ const signature = await crypto.subtle.sign("HMAC", key, payloadBytes);
31
+
32
+ // Convert the signature to a hex string
33
+ const hmac = Array.from(new Uint8Array(signature))
34
+ .map((b) => ("0" + b.toString(16)).slice(-2))
35
+ .join("");
36
+
37
+ return { algorithm: "sha256", hmac };
38
+ };
@@ -0,0 +1,16 @@
1
+ import type { webcrypto } from "crypto";
2
+
3
+ export const getCrypto = (): webcrypto.Crypto => {
4
+ if (typeof crypto !== "undefined") {
5
+ // In a browser or Web Worker (including Cloudflare Workers)
6
+ return crypto;
7
+ }
8
+
9
+ if (typeof require === "function") {
10
+ // In Node.js
11
+ // Node 15+ supports `crypto.webcrypto`
12
+ return require("node:crypto").webcrypto as webcrypto.Crypto;
13
+ }
14
+
15
+ throw new Error("Missing crypto module");
16
+ };
@@ -0,0 +1,8 @@
1
+ export { createHmac } from "./createHmac";
2
+ export type { CreateHmacParams } from "./createHmac";
3
+ export { getCrypto } from "./getCrypto";
4
+ export { signWebhook } from "./signWebhook";
5
+ export type { SignWebhookParams } from "./signWebhook";
6
+ export { timingSafeEqual } from "./timingSafeEqual";
7
+ export { verifyWebhook } from "./verifyWebhook";
8
+ export type { VerifyWebhookParams } from "./verifyWebhook";
@@ -0,0 +1,10 @@
1
+ import type { CreateHmacParams } from "./createHmac";
2
+ import { createHmac } from "./createHmac";
3
+
4
+ export type SignWebhookParams = CreateHmacParams;
5
+
6
+ export const signWebhook = async (params: SignWebhookParams): Promise<string> => {
7
+ const { algorithm, hmac } = await createHmac(params);
8
+
9
+ return `${algorithm}=${hmac}`;
10
+ };
@@ -0,0 +1,10 @@
1
+ export const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => {
2
+ if (a.length !== b.length) return false; // Lengths are different
3
+
4
+ let result = 0;
5
+ for (let i = 0; i < a.length; i++) {
6
+ result |= a[i] ^ b[i]; // XOR each byte
7
+ }
8
+
9
+ return result === 0; // If all bytes are equal, result will be 0
10
+ };
@@ -0,0 +1,28 @@
1
+ import { createHmac } from "./createHmac";
2
+ import { timingSafeEqual } from "./timingSafeEqual";
3
+
4
+ export interface VerifyWebhookParams {
5
+ payload: string;
6
+ secret: string;
7
+ signature: string;
8
+ }
9
+
10
+ export const verifyWebhook = async (params: VerifyWebhookParams): Promise<boolean> => {
11
+ const { payload, secret, signature } = params;
12
+
13
+ if (!secret || !payload || !signature) {
14
+ throw new Error("Secret, payload and signature are required to verify payload");
15
+ }
16
+
17
+ const { hmac } = await createHmac({ payload, secret });
18
+
19
+ if (hmac.length !== signature.length) return false;
20
+
21
+ const encoder = new TextEncoder();
22
+ const verificationBytes = encoder.encode(hmac);
23
+ const signatureBytes = encoder.encode(signature);
24
+
25
+ if (verificationBytes.length !== signatureBytes.length) return false;
26
+
27
+ return timingSafeEqual(verificationBytes, signatureBytes);
28
+ };