create-prisma-php-app 5.1.0-alpha.3 → 5.1.0-alpha.30

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.
Files changed (41) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +80 -33
  3. package/dist/AGENTS.md +59 -25
  4. package/dist/bootstrap.php +207 -187
  5. package/dist/index.js +2 -2
  6. package/dist/phpunit.xml +25 -0
  7. package/dist/postcss.config.js +4 -2
  8. package/dist/public/.htaccess +1 -1
  9. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  10. package/dist/settings/bs-config.ts +44 -1
  11. package/dist/settings/run-postcss.ts +205 -0
  12. package/dist/settings/run-tests.ts +35 -0
  13. package/dist/src/Lib/Auth/Auth.php +12 -25
  14. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  15. package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
  16. package/dist/src/Lib/Websocket/Socket.php +170 -0
  17. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  18. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  19. package/dist/src/Lib/Websocket/sockets.php +50 -0
  20. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  21. package/dist/src/app/globals.css +3 -1
  22. package/dist/src/app/layout.php +1 -1
  23. package/dist/tests/AuthTest.php +59 -0
  24. package/dist/tests/ConnectionManagerTest.php +277 -0
  25. package/dist/tests/CsrfTest.php +119 -0
  26. package/dist/tests/DeferComponentRootsTest.php +147 -0
  27. package/dist/tests/FeaturesTest.php +41 -0
  28. package/dist/tests/README.md +119 -0
  29. package/dist/tests/RpcWireContractTest.php +101 -0
  30. package/dist/tests/SocketPoolTest.php +69 -0
  31. package/dist/tests/SocketRegistryTest.php +77 -0
  32. package/dist/tests/SocketTest.php +124 -0
  33. package/dist/tests/SocketsRegistrationTest.php +40 -0
  34. package/dist/tests/Support/FakeConnection.php +62 -0
  35. package/dist/tests/Support/Features.php +38 -0
  36. package/dist/tests/Support/RequiresFeature.php +26 -0
  37. package/dist/tests/bootstrap.php +44 -0
  38. package/dist/ts/main.ts +5 -8
  39. package/dist/ts/tailwind-merge.ts +13 -0
  40. package/package.json +4 -4
  41. package/dist/public/js/pp-reactive-v2.js +0 -1
@@ -1,47 +1,500 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Lib\Websocket;
6
-
7
- use Ratchet\MessageComponentInterface;
8
- use Ratchet\ConnectionInterface;
9
- use Exception;
10
- use SplObjectStorage;
11
-
12
- class ConnectionManager implements MessageComponentInterface
13
- {
14
- protected SplObjectStorage $clients;
15
-
16
- public function __construct()
17
- {
18
- $this->clients = new SplObjectStorage();
19
- }
20
-
21
- public function onOpen(ConnectionInterface $conn): void
22
- {
23
- $this->clients->offsetSet($conn, true);
24
- echo "New connection! ({$conn->resourceId})";
25
- }
26
-
27
- public function onMessage(ConnectionInterface $from, $msg): void
28
- {
29
- foreach ($this->clients as $client) {
30
- if ($from !== $client) {
31
- $client->send($msg);
32
- }
33
- }
34
- }
35
-
36
- public function onClose(ConnectionInterface $conn): void
37
- {
38
- $this->clients->offsetUnset($conn);
39
- echo "Connection {$conn->resourceId} has disconnected";
40
- }
41
-
42
- public function onError(ConnectionInterface $conn, Exception $e): void
43
- {
44
- echo "An error has occurred: {$e->getMessage()}";
45
- $conn->close();
46
- }
47
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Lib\Websocket;
6
+
7
+ use Ratchet\MessageComponentInterface;
8
+ use Ratchet\ConnectionInterface;
9
+ use React\EventLoop\LoopInterface;
10
+ use React\EventLoop\TimerInterface;
11
+ use Lib\Auth\Auth;
12
+ use PP\Env;
13
+ use Exception;
14
+ use SplObjectStorage;
15
+ use Throwable;
16
+
17
+ /**
18
+ * The server half of `pp.socket(...)`: the PulsePoint named-socket wire.
19
+ *
20
+ * Every socket connects to one endpoint (`/__pulsepoint/ws` through the dev
21
+ * proxy, this Ratchet server directly otherwise), naming its function in the
22
+ * `name` query parameter. The arguments do not travel in the URL — a URL is
23
+ * logged by every proxy on the way — but as the first text frame, one JSON
24
+ * object, exactly the payload `pp.rpc` would have posted. Every frame after
25
+ * that is one JSON value, in either direction.
26
+ *
27
+ * There is no status line inside an open connection, so failure is a frame:
28
+ * `{"error": "..."}` — that key alone — followed by a close. The client
29
+ * runtime routes it to `onError` rather than `onMessage`.
30
+ *
31
+ * Handshake security mirrors the reference server: an anti-CSWSH origin
32
+ * check and a connection ceiling before anything else, then per-socket auth
33
+ * (`requireAuth` / `allowedRoles` on the registry entry, verified against
34
+ * the JWT auth cookie), then the shared message-size, message-rate, and
35
+ * idle-timeout limits for the life of the connection.
36
+ */
37
+ class ConnectionManager implements MessageComponentInterface
38
+ {
39
+ private const ARGS_TIMEOUT_SECONDS = 10;
40
+
41
+ /** @var SplObjectStorage<ConnectionInterface, object> */
42
+ protected SplObjectStorage $clients;
43
+
44
+ private ?LoopInterface $loop = null;
45
+ private ?TimerInterface $idleSweepTimer = null;
46
+ private int $openConnections = 0;
47
+
48
+ public function __construct()
49
+ {
50
+ $this->clients = new SplObjectStorage();
51
+ }
52
+
53
+ /**
54
+ * Give the manager the event loop so it can run the first-frame timeout
55
+ * and the idle sweep. Without a loop the wire still works; only the
56
+ * time-based limits are disabled.
57
+ */
58
+ public function attachLoop(LoopInterface $loop): void
59
+ {
60
+ $this->loop = $loop;
61
+
62
+ $this->idleSweepTimer ??= $loop->addPeriodicTimer(15, function (): void {
63
+ $idleTimeout = self::idleTimeoutSeconds();
64
+ $now = microtime(true);
65
+
66
+ foreach ($this->clients as $conn) {
67
+ $state = $this->clients[$conn];
68
+ if ($now - $state->lastActivity >= $idleTimeout) {
69
+ $conn->close(1000);
70
+ }
71
+ }
72
+ });
73
+ }
74
+
75
+ public function openConnectionCount(): int
76
+ {
77
+ return $this->openConnections;
78
+ }
79
+
80
+ public function onOpen(ConnectionInterface $conn): void
81
+ {
82
+ if (!$this->isOriginAllowed($conn)) {
83
+ $conn->close(1008);
84
+ return;
85
+ }
86
+
87
+ if ($this->openConnections >= self::maxConnections()) {
88
+ $conn->close(1013);
89
+ return;
90
+ }
91
+
92
+ $entry = $this->resolveEntry($conn);
93
+ if (is_string($entry)) {
94
+ $this->refuse($conn, $entry);
95
+ return;
96
+ }
97
+
98
+ $payload = $this->verifiedPayload($conn);
99
+
100
+ $refusal = $this->authorize($entry, $payload);
101
+ if ($refusal !== null) {
102
+ $this->refuse($conn, $refusal);
103
+ return;
104
+ }
105
+
106
+ $state = new \stdClass();
107
+ $state->entry = $entry;
108
+ $state->payload = $payload;
109
+ $state->socket = null; // Set once the argument frame arrives.
110
+ $state->lastActivity = microtime(true);
111
+ $state->messageTimestamps = [];
112
+ $state->argsTimer = $this->loop?->addTimer(
113
+ self::ARGS_TIMEOUT_SECONDS,
114
+ function () use ($conn): void {
115
+ $this->refuse(
116
+ $conn,
117
+ 'This socket opened and sent no arguments. The first frame '
118
+ . 'is the payload — one JSON object, {} when the function '
119
+ . 'takes nothing. pp.socket sends it on open.'
120
+ );
121
+ }
122
+ );
123
+
124
+ $this->clients->offsetSet($conn, $state);
125
+ $this->openConnections++;
126
+ }
127
+
128
+ public function onMessage(ConnectionInterface $from, $msg): void
129
+ {
130
+ if (!$this->clients->offsetExists($from)) {
131
+ return;
132
+ }
133
+
134
+ $state = $this->clients[$from];
135
+ $state->lastActivity = microtime(true);
136
+
137
+ if (strlen($msg) > self::maxMessageBytes()) {
138
+ $state->socket?->handleClose();
139
+ $from->close(1009);
140
+ return;
141
+ }
142
+
143
+ // The argument frame: one JSON object, before anything else.
144
+ if ($state->socket === null) {
145
+ $this->cancelArgsTimer($state);
146
+
147
+ $args = json_decode($msg, true);
148
+ if (!is_array($args) || array_is_list($args) && $args !== []) {
149
+ $this->refuse(
150
+ $from,
151
+ 'The first frame of a socket is not a JSON object. '
152
+ . 'Arguments are named, so they arrive as { "room": ... } — '
153
+ . 'pp.socket("name", { room }) is what sends them.'
154
+ );
155
+ return;
156
+ }
157
+
158
+ $socket = new Socket($from, $state->entry['name'], $state->payload);
159
+ $state->socket = $socket;
160
+
161
+ try {
162
+ ($state->entry['handler'])($socket, $args);
163
+ } catch (Throwable $e) {
164
+ $this->reportHandlerFailure($state->entry['name'], $socket, $e);
165
+ }
166
+ return;
167
+ }
168
+
169
+ if (!$this->allowMessage($state)) {
170
+ $state->socket->error('Too many messages. Slow down.');
171
+ return;
172
+ }
173
+
174
+ $value = json_decode($msg, true);
175
+ if ($value === null && json_last_error() !== JSON_ERROR_NONE) {
176
+ // A frame the handler cannot read is a client bug worth
177
+ // surfacing, and it travels back as the error frame.
178
+ $state->socket->error(
179
+ '`' . $state->entry['name'] . '` could not read a frame. '
180
+ . 'Each frame is one JSON value.'
181
+ );
182
+ return;
183
+ }
184
+
185
+ try {
186
+ $state->socket->handleMessage($value);
187
+ } catch (Throwable $e) {
188
+ $this->reportHandlerFailure($state->entry['name'], $state->socket, $e);
189
+ }
190
+ }
191
+
192
+ public function onClose(ConnectionInterface $conn): void
193
+ {
194
+ if (!$this->clients->offsetExists($conn)) {
195
+ return;
196
+ }
197
+
198
+ $state = $this->clients[$conn];
199
+ $this->cancelArgsTimer($state);
200
+ $this->clients->offsetUnset($conn);
201
+ $this->openConnections--;
202
+
203
+ $state->socket?->handleClose();
204
+ }
205
+
206
+ public function onError(ConnectionInterface $conn, Exception $e): void
207
+ {
208
+ echo "Socket connection error: {$e->getMessage()}" . PHP_EOL;
209
+ $conn->close();
210
+ }
211
+
212
+ // ==== Handshake ====
213
+
214
+ /**
215
+ * @return array{name: string, handler: callable, requireAuth: bool, allowedRoles: string[]}|string
216
+ */
217
+ private function resolveEntry(ConnectionInterface $conn): array|string
218
+ {
219
+ $query = [];
220
+ $request = $conn->httpRequest ?? null;
221
+ if ($request !== null) {
222
+ parse_str($request->getUri()->getQuery(), $query);
223
+ }
224
+
225
+ $name = trim((string) ($query['name'] ?? ''));
226
+ if ($name === '') {
227
+ return 'This connection named no socket. Open it as '
228
+ . 'pp.socket("name", { ... }) — the client runtime sends the '
229
+ . 'name in the `name` query parameter.';
230
+ }
231
+
232
+ $entry = SocketRegistry::get($name);
233
+ if ($entry === null) {
234
+ return "No socket named `$name`. Register it in "
235
+ . 'src/Lib/Websocket/sockets.php with SocketRegistry::register() — '
236
+ . 'the name the client connects with is the registered one.';
237
+ }
238
+
239
+ return $entry;
240
+ }
241
+
242
+ /**
243
+ * The verified auth payload from the handshake's auth cookie, or null.
244
+ * Verification is pure JWT (`Auth::verifyToken`), so it works in this
245
+ * long-running process without a PHP session.
246
+ */
247
+ private function verifiedPayload(ConnectionInterface $conn): mixed
248
+ {
249
+ try {
250
+ $request = $conn->httpRequest ?? null;
251
+ if ($request === null) {
252
+ return null;
253
+ }
254
+
255
+ $cookies = self::parseCookies($request->getHeaderLine('Cookie'));
256
+
257
+ $authCookieName = strtolower(preg_replace(
258
+ '/\s+/',
259
+ '_',
260
+ trim(Env::string('AUTH_COOKIE_NAME', 'auth_cookie_name_d36e5'))
261
+ ));
262
+
263
+ $jwt = $cookies[$authCookieName] ?? null;
264
+ if ($jwt === null || $jwt === '') {
265
+ return null;
266
+ }
267
+
268
+ return Auth::getInstance()->verifyToken($jwt);
269
+ } catch (Throwable) {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * @param array{name: string, requireAuth: bool, allowedRoles: string[]} $entry
276
+ */
277
+ private function authorize(array $entry, mixed $payload): ?string
278
+ {
279
+ if ($payload !== null) {
280
+ if (!empty($entry['allowedRoles'])) {
281
+ $currentRole = null;
282
+
283
+ if (is_scalar($payload)) {
284
+ $currentRole = $payload;
285
+ } else {
286
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
287
+
288
+ if (is_object($payload)) {
289
+ $currentRole = $payload->$roleKey ?? null;
290
+ } elseif (is_array($payload)) {
291
+ $currentRole = $payload[$roleKey] ?? null;
292
+ }
293
+ }
294
+
295
+ if ($currentRole === null || !in_array($currentRole, $entry['allowedRoles'], true)) {
296
+ return "The socket `{$entry['name']}` is not available to this account.";
297
+ }
298
+ }
299
+
300
+ return null;
301
+ }
302
+
303
+ if ($entry['requireAuth'] || !empty($entry['allowedRoles'])) {
304
+ return "The socket `{$entry['name']}` needs a signed-in session. "
305
+ . 'It is registered with requireAuth, so it answers only while '
306
+ . 'the browser carries one.';
307
+ }
308
+
309
+ return null;
310
+ }
311
+
312
+ /**
313
+ * Anti-CSWSH origin check. NOT authentication: a browser cannot forge
314
+ * the Origin header, so this blocks cross-site script-driven handshakes;
315
+ * a raw client can send any origin, which is exactly why auth is a
316
+ * separate gate.
317
+ */
318
+ private function isOriginAllowed(ConnectionInterface $conn): bool
319
+ {
320
+ $request = $conn->httpRequest ?? null;
321
+ $origin = rtrim(trim((string) ($request?->getHeaderLine('Origin') ?? '')), '/');
322
+
323
+ if ($origin === '') {
324
+ // No Origin header: tolerate local tooling in dev, reject in production.
325
+ return !self::isProduction();
326
+ }
327
+
328
+ $parts = parse_url($origin);
329
+ if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
330
+ return false;
331
+ }
332
+
333
+ $host = strtolower($parts['host']);
334
+ if (!self::isProduction() && in_array($host, ['localhost', '127.0.0.1'], true)) {
335
+ return strtolower($parts['scheme']) === 'http';
336
+ }
337
+
338
+ $allowedOrigins = [];
339
+ foreach (['WEBSOCKET_ALLOWED_ORIGINS', 'CORS_ALLOWED_ORIGINS', 'APP_BASE_URL'] as $envName) {
340
+ foreach (self::parseOriginList(Env::string($envName, '')) as $allowed) {
341
+ $allowedOrigins[] = $allowed;
342
+ }
343
+ }
344
+
345
+ // Same-origin fallback from the Host header is a development
346
+ // convenience only — production must name its origins explicitly.
347
+ if (!self::isProduction() && $request !== null) {
348
+ $hostHeader = trim($request->getHeaderLine('Host'));
349
+ if ($hostHeader !== '') {
350
+ $allowedOrigins[] = 'http://' . $hostHeader;
351
+ $allowedOrigins[] = 'https://' . $hostHeader;
352
+ }
353
+ }
354
+
355
+ return in_array($origin, $allowedOrigins, true);
356
+ }
357
+
358
+ /**
359
+ * Refuse after the handshake: the error frame, then the close, so the
360
+ * browser gets a readable message instead of a bare close code.
361
+ */
362
+ private function refuse(ConnectionInterface $conn, string $message): void
363
+ {
364
+ if ($this->clients->offsetExists($conn)) {
365
+ $state = $this->clients[$conn];
366
+ $this->cancelArgsTimer($state);
367
+ }
368
+
369
+ try {
370
+ $conn->send(json_encode(['error' => $message], JSON_UNESCAPED_UNICODE));
371
+ } catch (Throwable) {
372
+ // The browser is already gone; the close below is all that is left.
373
+ }
374
+
375
+ $conn->close(1008);
376
+ }
377
+
378
+ private function reportHandlerFailure(string $name, Socket $socket, Throwable $e): void
379
+ {
380
+ echo "[Socket Error] $name: {$e->getMessage()}" . PHP_EOL;
381
+
382
+ $message = self::isProduction()
383
+ ? 'Internal server error'
384
+ : "$name: {$e->getMessage()}";
385
+
386
+ $socket->error($message);
387
+ }
388
+
389
+ // ==== Limits ====
390
+
391
+ private function allowMessage(object $state): bool
392
+ {
393
+ $now = microtime(true);
394
+ $cutoff = $now - self::rateWindowSeconds();
395
+
396
+ $state->messageTimestamps = array_values(array_filter(
397
+ $state->messageTimestamps,
398
+ static fn (float $timestamp): bool => $timestamp > $cutoff,
399
+ ));
400
+
401
+ if (count($state->messageTimestamps) >= self::messagesPerWindow()) {
402
+ return false;
403
+ }
404
+
405
+ $state->messageTimestamps[] = $now;
406
+
407
+ return true;
408
+ }
409
+
410
+ private function cancelArgsTimer(object $state): void
411
+ {
412
+ if ($state->argsTimer !== null && $this->loop !== null) {
413
+ $this->loop->cancelTimer($state->argsTimer);
414
+ $state->argsTimer = null;
415
+ }
416
+ }
417
+
418
+ // ==== Settings (same env names and defaults as the reference server) ====
419
+
420
+ private static function isProduction(): bool
421
+ {
422
+ return Env::string('APP_ENV', 'production') === 'production';
423
+ }
424
+
425
+ private static function idleTimeoutSeconds(): int
426
+ {
427
+ return max(10, Env::int('WEBSOCKET_IDLE_TIMEOUT_SECONDS', 120));
428
+ }
429
+
430
+ private static function maxMessageBytes(): int
431
+ {
432
+ return max(256, Env::int('MAX_WEBSOCKET_MESSAGE_BYTES', 4096));
433
+ }
434
+
435
+ private static function messagesPerWindow(): int
436
+ {
437
+ return max(1, Env::int('MAX_WEBSOCKET_MESSAGES_PER_WINDOW', 20));
438
+ }
439
+
440
+ private static function rateWindowSeconds(): int
441
+ {
442
+ return max(1, Env::int('WEBSOCKET_RATE_WINDOW_SECONDS', 10));
443
+ }
444
+
445
+ private static function maxConnections(): int
446
+ {
447
+ return max(1, Env::int('MAX_WEBSOCKET_CONNECTIONS', 200));
448
+ }
449
+
450
+ /**
451
+ * Parse an origin list env value: CSV or a JSON array, the same formats
452
+ * CorsMiddleware accepts.
453
+ *
454
+ * @return string[]
455
+ */
456
+ private static function parseOriginList(string $raw): array
457
+ {
458
+ $raw = trim($raw);
459
+ if ($raw === '' || $raw === '[]') {
460
+ return [];
461
+ }
462
+
463
+ $values = null;
464
+ if ($raw[0] === '[') {
465
+ $decoded = json_decode($raw, true);
466
+ if (is_array($decoded)) {
467
+ $values = array_map('strval', $decoded);
468
+ }
469
+ }
470
+
471
+ $values ??= explode(',', $raw);
472
+
473
+ $origins = [];
474
+ foreach ($values as $value) {
475
+ $value = rtrim(trim($value), '/');
476
+ if ($value !== '') {
477
+ $origins[] = $value;
478
+ }
479
+ }
480
+
481
+ return $origins;
482
+ }
483
+
484
+ /**
485
+ * @return array<string, string>
486
+ */
487
+ private static function parseCookies(string $header): array
488
+ {
489
+ $cookies = [];
490
+
491
+ foreach (explode(';', $header) as $pair) {
492
+ $parts = explode('=', trim($pair), 2);
493
+ if (count($parts) === 2 && $parts[0] !== '') {
494
+ $cookies[strtolower($parts[0])] = urldecode($parts[1]);
495
+ }
496
+ }
497
+
498
+ return $cookies;
499
+ }
500
+ }