create-prisma-php-app 5.0.0-alpha.8 → 5.0.1

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 (60) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +191 -0
  3. package/dist/AGENTS.md +588 -193
  4. package/dist/bootstrap.php +426 -213
  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/prisma-php.js +2 -2
  9. package/dist/public/.htaccess +1 -1
  10. package/dist/public/js/main.js +13 -1
  11. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  12. package/dist/settings/auto-swagger-docs.ts +10 -10
  13. package/dist/settings/bs-config.ts +47 -27
  14. package/dist/settings/build.ts +2 -32
  15. package/dist/settings/component-map.ts +476 -0
  16. package/dist/settings/files-list.ts +2 -2
  17. package/dist/settings/project-name.ts +3 -6
  18. package/dist/settings/restart-mcp.ts +2 -2
  19. package/dist/settings/restart-websocket.ts +2 -2
  20. package/dist/settings/run-postcss.ts +205 -0
  21. package/dist/settings/run-tests.ts +35 -0
  22. package/dist/settings/swagger-config.ts +3 -3
  23. package/dist/settings/utils.ts +3 -3
  24. package/dist/src/Lib/Auth/Auth.php +62 -34
  25. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  26. package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
  27. package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
  28. package/dist/src/Lib/Websocket/Socket.php +170 -0
  29. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  30. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  31. package/dist/src/Lib/Websocket/sockets.php +50 -0
  32. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  33. package/dist/src/app/globals.css +3 -1
  34. package/dist/src/app/layout.php +1 -1
  35. package/dist/tests/AuthTest.php +59 -0
  36. package/dist/tests/ConnectionManagerTest.php +277 -0
  37. package/dist/tests/CsrfTest.php +119 -0
  38. package/dist/tests/DeferComponentRootsTest.php +147 -0
  39. package/dist/tests/FeaturesTest.php +41 -0
  40. package/dist/tests/README.md +119 -0
  41. package/dist/tests/RpcWireContractTest.php +101 -0
  42. package/dist/tests/SocketPoolTest.php +69 -0
  43. package/dist/tests/SocketRegistryTest.php +77 -0
  44. package/dist/tests/SocketTest.php +124 -0
  45. package/dist/tests/SocketsRegistrationTest.php +40 -0
  46. package/dist/tests/Support/FakeConnection.php +62 -0
  47. package/dist/tests/Support/Features.php +38 -0
  48. package/dist/tests/Support/RequiresFeature.php +26 -0
  49. package/dist/tests/bootstrap.php +44 -0
  50. package/dist/ts/main.ts +21 -3
  51. package/dist/ts/tailwind-merge.ts +13 -0
  52. package/package.json +4 -4
  53. package/dist/README.md +0 -213
  54. package/dist/public/js/pp-reactive-v2.js +0 -1
  55. package/dist/settings/bs-config.json +0 -6
  56. package/dist/settings/class-imports.ts +0 -165
  57. package/dist/settings/class-log.ts +0 -244
  58. package/dist/settings/component-import-checker.ts +0 -90
  59. package/dist/settings/files-list.json +0 -1
  60. package/dist/settings/prisma-schema-config.json +0 -16
@@ -31,8 +31,21 @@ use PP\Attributes\Exposed;
31
31
  use PP\Attributes\ExposedRegistry;
32
32
  use PP\Streaming\SSE;
33
33
  use PP\Security\RateLimiter;
34
+ use PP\Security\Csrf;
34
35
  use PP\Env;
35
36
 
37
+ /**
38
+ * An RPC failure meant for the wire: message plus HTTP status, serialized as
39
+ * `{"error": "..."}` — the shape the PulsePoint runtime reads.
40
+ */
41
+ final class RpcError extends RuntimeException
42
+ {
43
+ public function __construct(string $message, public readonly int $status)
44
+ {
45
+ parent::__construct($message);
46
+ }
47
+ }
48
+
36
49
  final class Bootstrap extends RuntimeException
37
50
  {
38
51
  public static string $contentToInclude = '';
@@ -43,7 +56,6 @@ final class Bootstrap extends RuntimeException
43
56
  public static bool $isContentIncluded = false;
44
57
  public static bool $isChildContentIncluded = false;
45
58
  public static bool $isContentVariableIncluded = false;
46
- public static bool $secondRequestC69CD = false;
47
59
  public static array $requestFilesData = [];
48
60
 
49
61
  private string $context;
@@ -60,6 +72,19 @@ final class Bootstrap extends RuntimeException
60
72
  );
61
73
  }
62
74
 
75
+ public static function isPathWithinApp(string $path): bool
76
+ {
77
+ $resolvedPath = realpath($path);
78
+ if ($resolvedPath === false) {
79
+ return false;
80
+ }
81
+
82
+ $appPath = rtrim(str_replace('\\', '/', APP_PATH), '/');
83
+ $normalizedResolvedPath = str_replace('\\', '/', $resolvedPath);
84
+
85
+ return str_starts_with($normalizedResolvedPath, $appPath . '/');
86
+ }
87
+
63
88
  public function __construct(string $message, string $context = '', int $code = 0, ?Throwable $previous = null)
64
89
  {
65
90
  $this->context = $context;
@@ -81,22 +106,7 @@ final class Bootstrap extends RuntimeException
81
106
  MainLayout::init();
82
107
  ErrorHandler::registerHandlers();
83
108
 
84
- setcookie("pp_local_store_key", PrismaPHPSettings::$localStoreKey, [
85
- 'expires' => time() + 3600,
86
- 'path' => '/',
87
- 'domain' => '',
88
- 'secure' => self::isHttpsRequest(),
89
- 'httponly' => false,
90
- 'samesite' => 'Lax',
91
- ]);
92
-
93
- self::setCsrfCookie();
94
-
95
- self::$secondRequestC69CD = Request::$data['secondRequestC69CD'] ?? false;
96
-
97
- if (Request::$isWire && !self::$secondRequestC69CD) {
98
- self::isLocalStoreCallback();
99
- }
109
+ Csrf::ensureCookie();
100
110
 
101
111
  $contentInfo = self::determineContentToInclude();
102
112
  self::$contentToInclude = $contentInfo['path'] ?? '';
@@ -137,79 +147,94 @@ final class Bootstrap extends RuntimeException
137
147
  ErrorHandler::checkFatalError();
138
148
  }
139
149
 
140
- private static function isLocalStoreCallback(): void
150
+ /**
151
+ * Anti-CSRF origin check for RPC calls. NOT authentication: a browser
152
+ * cannot forge the Origin header, so this blocks cross-site script-driven
153
+ * calls; raw clients are handled by the CSRF token and auth gates.
154
+ *
155
+ * @return string|null An error message, or null when the origin is fine.
156
+ */
157
+ private static function validateRpcOrigin(): ?string
141
158
  {
142
- if (empty($_SERVER['HTTP_X_PP_FUNCTION'])) {
143
- return;
159
+ $origin = rtrim(trim($_SERVER['HTTP_ORIGIN'] ?? ''), '/');
160
+ if ($origin === '') {
161
+ return null;
162
+ }
163
+
164
+ if (Env::string('APP_ENV', 'production') !== 'production') {
165
+ if (preg_match('#^http://(localhost|127\.0\.0\.1)(:\d+)?$#i', $origin)) {
166
+ return null;
167
+ }
144
168
  }
145
169
 
146
- $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'];
170
+ $allowedOrigins = [rtrim(Request::$protocol . Request::$domainName, '/')];
171
+
172
+ $baseUrl = rtrim(Env::string('APP_BASE_URL', ''), '/');
173
+ if ($baseUrl !== '') {
174
+ $allowedOrigins[] = $baseUrl;
175
+ }
147
176
 
148
- if ($callbackName === PrismaPHPSettings::$localStoreKey) {
149
- self::validateCsrfToken();
150
- self::jsonExit(['success' => true, 'response' => 'localStorage updated']);
177
+ foreach (self::parseOriginList(Env::string('CORS_ALLOWED_ORIGINS', '')) as $allowed) {
178
+ $allowedOrigins[] = $allowed;
151
179
  }
180
+
181
+ return in_array($origin, $allowedOrigins, true) ? null : 'Invalid origin';
152
182
  }
153
183
 
154
- private static function setCsrfCookie(): void
184
+ /**
185
+ * Parse an origin list env value: CSV or a JSON array, the same formats
186
+ * CorsMiddleware accepts.
187
+ *
188
+ * @return string[]
189
+ */
190
+ private static function parseOriginList(string $raw): array
155
191
  {
156
- $secret = Env::string('FUNCTION_CALL_SECRET', 'pp_default_insecure_secret');
157
- $shouldRegenerate = true;
158
-
159
- if (isset($_COOKIE['prisma_php_csrf'])) {
160
- $parts = explode('.', $_COOKIE['prisma_php_csrf']);
161
- if (count($parts) === 2) {
162
- [$nonce, $signature] = $parts;
163
- $expectedSignature = hash_hmac('sha256', $nonce, $secret);
192
+ $raw = trim($raw);
193
+ if ($raw === '' || $raw === '[]') {
194
+ return [];
195
+ }
164
196
 
165
- if (hash_equals($expectedSignature, $signature)) {
166
- $shouldRegenerate = false;
167
- }
197
+ $values = null;
198
+ if ($raw[0] === '[') {
199
+ $decoded = json_decode($raw, true);
200
+ if (is_array($decoded)) {
201
+ $values = array_map('strval', $decoded);
168
202
  }
169
203
  }
170
204
 
171
- if ($shouldRegenerate) {
172
- $nonce = bin2hex(random_bytes(16));
173
- $signature = hash_hmac('sha256', $nonce, $secret);
174
- $token = $nonce . '.' . $signature;
205
+ $values ??= explode(',', $raw);
175
206
 
176
- setcookie('prisma_php_csrf', $token, [
177
- 'expires' => time() + 3600,
178
- 'path' => '/',
179
- 'secure' => self::isHttpsRequest(),
180
- 'httponly' => false,
181
- 'samesite' => 'Lax',
182
- ]);
183
-
184
- $_COOKIE['prisma_php_csrf'] = $token;
207
+ $origins = [];
208
+ foreach ($values as $value) {
209
+ $value = rtrim(trim($value), '/');
210
+ if ($value !== '') {
211
+ $origins[] = $value;
212
+ }
185
213
  }
214
+
215
+ return $origins;
186
216
  }
187
217
 
188
- private static function validateCsrfToken(): void
218
+ /**
219
+ * RPC bodies are JSON or multipart uploads; any other content type with a
220
+ * body is refused with 415, matching the runtime's error handling.
221
+ */
222
+ private static function isRpcContentTypeAllowed(): bool
189
223
  {
190
- $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
191
- $cookieToken = $_COOKIE['prisma_php_csrf'] ?? '';
192
- $secret = Env::string('FUNCTION_CALL_SECRET', '');
193
-
194
- if (empty($headerToken) || empty($cookieToken)) {
195
- self::jsonExit(['success' => false, 'error' => 'CSRF token missing']);
196
- }
197
-
198
- if (!hash_equals($cookieToken, $headerToken)) {
199
- self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
224
+ $contentType = strtolower(Request::$contentType);
225
+ if (
226
+ str_starts_with($contentType, 'application/json')
227
+ || str_starts_with($contentType, 'multipart/form-data')
228
+ ) {
229
+ return true;
200
230
  }
201
231
 
202
- $parts = explode('.', $cookieToken);
203
- if (count($parts) !== 2) {
204
- self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token format']);
232
+ $contentLength = $_SERVER['CONTENT_LENGTH'] ?? $_SERVER['HTTP_CONTENT_LENGTH'] ?? null;
233
+ if ($contentLength !== null) {
234
+ return (int) $contentLength <= 0;
205
235
  }
206
236
 
207
- [$nonce, $signature] = $parts;
208
- $expectedSignature = hash_hmac('sha256', $nonce, $secret);
209
-
210
- if (!hash_equals($expectedSignature, $signature)) {
211
- self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token signature']);
212
- }
237
+ return strtolower((string) ($_SERVER['HTTP_TRANSFER_ENCODING'] ?? '')) === '';
213
238
  }
214
239
 
215
240
  private static function fileExistsCached(string $path): bool
@@ -269,6 +294,11 @@ final class Bootstrap extends RuntimeException
269
294
  }
270
295
  }
271
296
 
297
+ $exactContentInfo = self::getExactContentInfo($requestUri, $pathname);
298
+ if ($exactContentInfo !== null) {
299
+ return $exactContentInfo;
300
+ }
301
+
272
302
  if ($pathname) {
273
303
  $groupFolder = self::findGroupFolder($pathname);
274
304
  if ($groupFolder) {
@@ -303,6 +333,139 @@ final class Bootstrap extends RuntimeException
303
333
  ];
304
334
  }
305
335
 
336
+ private static function getExactContentInfo(string $requestUri, string $pathname): ?array
337
+ {
338
+ $requestData = self::findExactRequestData($requestUri, $pathname);
339
+ if ($requestData === null) {
340
+ return null;
341
+ }
342
+
343
+ $includedFiles = $requestData['includedFiles'] ?? null;
344
+ if (!is_array($includedFiles) || empty($includedFiles)) {
345
+ return null;
346
+ }
347
+
348
+ $includePath = '';
349
+ $layoutsToInclude = [];
350
+
351
+ foreach ($includedFiles as $file) {
352
+ if (!is_string($file) || $file === '') {
353
+ continue;
354
+ }
355
+
356
+ $normalizedFile = str_replace('\\', '/', $file);
357
+
358
+ if (str_ends_with($normalizedFile, '/layout.php')) {
359
+ if (self::fileExistsCached($file) && !in_array($file, $layoutsToInclude, true)) {
360
+ $layoutsToInclude[] = $file;
361
+ }
362
+
363
+ continue;
364
+ }
365
+
366
+ if ($includePath === '' && self::fileExistsCached($file)) {
367
+ $includePath = $file;
368
+ }
369
+ }
370
+
371
+ if ($includePath === '') {
372
+ return null;
373
+ }
374
+
375
+ self::hydrateDynamicParamsFromIncludePath($pathname, $includePath);
376
+
377
+ usort($layoutsToInclude, static function (string $left, string $right): int {
378
+ return substr_count(str_replace('\\', '/', $left), '/')
379
+ <=> substr_count(str_replace('\\', '/', $right), '/');
380
+ });
381
+
382
+ return [
383
+ 'path' => $includePath,
384
+ 'layouts' => $layoutsToInclude,
385
+ 'pathname' => $pathname,
386
+ 'uri' => $requestUri,
387
+ ];
388
+ }
389
+
390
+ private static function findExactRequestData(string $requestUri, string $pathname): ?array
391
+ {
392
+ $candidates = [];
393
+ $decodedRequestUri = Request::getDecodedUrl($requestUri);
394
+
395
+ if ($decodedRequestUri !== '') {
396
+ $candidates[] = $decodedRequestUri;
397
+
398
+ $trimmedDecodedRequestUri = ltrim($decodedRequestUri, '/');
399
+ if ($trimmedDecodedRequestUri !== '') {
400
+ $candidates[] = $trimmedDecodedRequestUri;
401
+ }
402
+ }
403
+
404
+ if ($pathname === '') {
405
+ $candidates[] = '/';
406
+ } else {
407
+ $candidates[] = $pathname;
408
+ $candidates[] = '/' . $pathname;
409
+ }
410
+
411
+ foreach (array_values(array_unique($candidates)) as $candidate) {
412
+ $requestData = PrismaPHPSettings::$includeFiles[$candidate] ?? null;
413
+
414
+ if (is_array($requestData)) {
415
+ return $requestData;
416
+ }
417
+ }
418
+
419
+ return null;
420
+ }
421
+
422
+ private static function hydrateDynamicParamsFromIncludePath(string $pathname, string $includePath): void
423
+ {
424
+ $normalizedIncludePath = str_replace('\\', '/', $includePath);
425
+ $normalizedAppPath = str_replace('\\', '/', APP_PATH);
426
+
427
+ if (!str_starts_with($normalizedIncludePath, $normalizedAppPath . '/')) {
428
+ return;
429
+ }
430
+
431
+ $relativeRoutePath = ltrim(substr($normalizedIncludePath, strlen($normalizedAppPath)), '/');
432
+ $routeSegments = array_values(array_filter(
433
+ explode('/', $relativeRoutePath),
434
+ static function (string $segment): bool {
435
+ return $segment !== '' && !preg_match('/^\([^)]+\)$/', $segment);
436
+ }
437
+ ));
438
+
439
+ if (!empty($routeSegments) && in_array(end($routeSegments), ['index.php', 'route.php'], true)) {
440
+ array_pop($routeSegments);
441
+ }
442
+
443
+ $pathnameSegments = $pathname === '' ? [] : explode('/', $pathname);
444
+ $dynamicParams = [];
445
+ $pathnameIndex = 0;
446
+
447
+ foreach ($routeSegments as $routeSegment) {
448
+ if (preg_match('/^\[\.\.\.(.+)\]$/', $routeSegment, $matches)) {
449
+ $dynamicParams[$matches[1]] = array_slice($pathnameSegments, $pathnameIndex);
450
+ break;
451
+ }
452
+
453
+ if (!array_key_exists($pathnameIndex, $pathnameSegments)) {
454
+ break;
455
+ }
456
+
457
+ if (preg_match('/^\[(.+)\]$/', $routeSegment, $matches)) {
458
+ $dynamicParams[$matches[1]] = $pathnameSegments[$pathnameIndex];
459
+ }
460
+
461
+ $pathnameIndex++;
462
+ }
463
+
464
+ if (!empty($dynamicParams)) {
465
+ Request::$dynamicParams = new ArrayObject($dynamicParams, ArrayObject::ARRAY_AS_PROPS);
466
+ }
467
+ }
468
+
306
469
  private static function collectLayouts(string $pathname, ?string $groupFolder, ?string $dynamicRoute): array
307
470
  {
308
471
  $layoutsToInclude = [];
@@ -818,21 +981,35 @@ final class Bootstrap extends RuntimeException
818
981
  $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
819
982
 
820
983
  if (empty($callbackName)) {
821
- self::jsonExit(['success' => false, 'error' => 'Callback header not provided', 'response' => null]);
984
+ self::jsonExit(['error' => 'Missing function name'], 400);
822
985
  }
823
986
 
824
- self::validateCsrfToken();
825
-
826
987
  if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
827
- self::jsonExit(['success' => false, 'error' => 'Invalid callback format']);
988
+ self::jsonExit(['error' => 'Invalid function name'], 400);
989
+ }
990
+
991
+ if (($error = self::validateRpcOrigin()) !== null) {
992
+ self::jsonExit(['error' => $error], 403);
993
+ }
994
+
995
+ if (!self::isRpcContentTypeAllowed()) {
996
+ self::jsonExit(['error' => 'Invalid content type'], 415);
997
+ }
998
+
999
+ if (($error = Csrf::validateHeaderToken($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '')) !== null) {
1000
+ self::jsonExit(['error' => $error], 403);
828
1001
  }
829
1002
 
830
1003
  $data = self::getRequestData();
831
1004
  $args = self::convertToArrayObject($data);
832
1005
 
833
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
834
- ? self::dispatchMethod($callbackName, $args)
835
- : self::dispatchFunction($callbackName, $args);
1006
+ try {
1007
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
1008
+ ? self::dispatchMethod($callbackName, $args)
1009
+ : self::dispatchFunction($callbackName, $args);
1010
+ } catch (RpcError $e) {
1011
+ self::jsonExit(['error' => $e->getMessage()], $e->status);
1012
+ }
836
1013
 
837
1014
  if ($out instanceof SSE) {
838
1015
  $out->send();
@@ -844,14 +1021,18 @@ final class Bootstrap extends RuntimeException
844
1021
  exit;
845
1022
  }
846
1023
 
847
- if ($out !== null) {
848
- self::jsonExit($out);
849
- }
850
- exit;
1024
+ // Always emit valid JSON — the runtime parses every non-stream RPC
1025
+ // response, so a null result must arrive as the JSON literal `null`.
1026
+ self::jsonExit($out);
851
1027
  }
852
1028
 
853
- private static function jsonExit(mixed $payload): void
1029
+ private static function jsonExit(mixed $payload, int $status = 200): void
854
1030
  {
1031
+ if (!headers_sent()) {
1032
+ http_response_code($status);
1033
+ header('Content-Type: application/json; charset=UTF-8');
1034
+ }
1035
+
855
1036
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
856
1037
  exit;
857
1038
  }
@@ -859,7 +1040,18 @@ final class Bootstrap extends RuntimeException
859
1040
  private static function getRequestData(): array
860
1041
  {
861
1042
  if (!empty($_FILES)) {
862
- $data = $_POST;
1043
+ // Non-file values in an RPC multipart body are JSON-encoded by
1044
+ // the runtime (objects/arrays) or stringified scalars; decode
1045
+ // what parses so `{count: 2}` arrives as a number, not "2".
1046
+ $data = [];
1047
+ foreach ($_POST as $key => $value) {
1048
+ if (is_string($value)) {
1049
+ $decoded = json_decode($value, true);
1050
+ $data[$key] = json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
1051
+ } else {
1052
+ $data[$key] = $value;
1053
+ }
1054
+ }
863
1055
  foreach ($_FILES as $key => $file) {
864
1056
  $data[$key] = is_array($file['name'])
865
1057
  ? array_map(
@@ -883,69 +1075,41 @@ final class Bootstrap extends RuntimeException
883
1075
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
884
1076
  }
885
1077
 
886
- private static function validateAccess(Exposed $attribute): bool
1078
+ /**
1079
+ * Authorize the caller for one exposed function, mirroring the reference
1080
+ * server: 401 when auth is required and missing, 403 on a role mismatch.
1081
+ */
1082
+ private static function authorizeAccess(Exposed $attribute): void
887
1083
  {
888
- if ($attribute->requiresAuth || !empty($attribute->allowedRoles)) {
889
- $auth = Auth::getInstance();
890
-
891
- if (!$auth->isAuthenticated()) {
892
- return false;
893
- }
894
-
895
- if (!empty($attribute->allowedRoles)) {
896
- $payload = $auth->getPayload();
897
- $currentRole = null;
898
-
899
- if (is_scalar($payload)) {
900
- $currentRole = $payload;
901
- } else {
902
- $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
1084
+ if (!$attribute->requiresAuth && empty($attribute->allowedRoles)) {
1085
+ return;
1086
+ }
903
1087
 
904
- if (is_object($payload)) {
905
- $currentRole = $payload->$roleKey ?? null;
906
- } elseif (is_array($payload)) {
907
- $currentRole = $payload[$roleKey] ?? null;
908
- }
909
- }
1088
+ $auth = Auth::getInstance();
910
1089
 
911
- if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
912
- return false;
913
- }
914
- }
1090
+ if (!$auth->isAuthenticated()) {
1091
+ throw new RpcError('Authentication required', 401);
915
1092
  }
916
1093
 
917
- return true;
918
- }
1094
+ if (!empty($attribute->allowedRoles)) {
1095
+ $payload = $auth->getPayload();
1096
+ $currentRole = null;
919
1097
 
920
- private static function isFunctionAllowed(string $fn): bool
921
- {
922
- try {
923
- $ref = new ReflectionFunction($fn);
924
- $attrs = $ref->getAttributes(Exposed::class);
1098
+ if (is_scalar($payload)) {
1099
+ $currentRole = $payload;
1100
+ } else {
1101
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
925
1102
 
926
- if (empty($attrs)) {
927
- return false;
1103
+ if (is_object($payload)) {
1104
+ $currentRole = $payload->$roleKey ?? null;
1105
+ } elseif (is_array($payload)) {
1106
+ $currentRole = $payload[$roleKey] ?? null;
1107
+ }
928
1108
  }
929
1109
 
930
- return self::validateAccess($attrs[0]->newInstance());
931
- } catch (Throwable) {
932
- return false;
933
- }
934
- }
935
-
936
- private static function isMethodAllowed(string $class, string $method): bool
937
- {
938
- try {
939
- $ref = new ReflectionMethod($class, $method);
940
- $attrs = $ref->getAttributes(Exposed::class);
941
-
942
- if (empty($attrs)) {
943
- return false;
1110
+ if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
1111
+ throw new RpcError('Permission denied', 403);
944
1112
  }
945
-
946
- return self::validateAccess($attrs[0]->newInstance());
947
- } catch (Throwable) {
948
- return false;
949
1113
  }
950
1114
  }
951
1115
 
@@ -978,7 +1142,33 @@ final class Bootstrap extends RuntimeException
978
1142
  }
979
1143
 
980
1144
  if ($limits) {
981
- RateLimiter::verify($identifier, $limits);
1145
+ try {
1146
+ RateLimiter::verify($identifier, $limits);
1147
+ } catch (Throwable $e) {
1148
+ throw new RpcError('Rate limit exceeded. Try again later.', 429);
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Run one exposed callable and translate its failures onto the wire:
1155
+ * `InvalidArgumentException` is a message meant for the caller (400, the
1156
+ * validation convention), anything else is a 500.
1157
+ */
1158
+ private static function invokeExposed(callable $callable, mixed $args): mixed
1159
+ {
1160
+ try {
1161
+ return call_user_func($callable, $args);
1162
+ } catch (RpcError $e) {
1163
+ throw $e;
1164
+ } catch (InvalidArgumentException $e) {
1165
+ throw new RpcError($e->getMessage(), 400);
1166
+ } catch (Throwable $e) {
1167
+ if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1168
+ throw new RpcError('Internal server error', 500);
1169
+ }
1170
+
1171
+ throw new RpcError("Function error: {$e->getMessage()}", 500);
982
1172
  }
983
1173
  }
984
1174
 
@@ -991,39 +1181,21 @@ final class Bootstrap extends RuntimeException
991
1181
  }
992
1182
  }
993
1183
 
994
- if (!self::isFunctionAllowed($fn)) {
995
- return ['success' => false, 'error' => 'Function not callable from client'];
1184
+ if (!function_exists($fn) || !is_callable($fn)) {
1185
+ throw new RpcError('Function not found', 404);
996
1186
  }
997
1187
 
998
1188
  $attribute = self::getExposedAttribute($fn);
999
- if (!self::validateAccess($attribute)) {
1000
- return ['success' => false, 'error' => 'Permission denied'];
1189
+ if ($attribute === null) {
1190
+ // Existing but not #[Exposed]: indistinguishable from absent, on
1191
+ // purpose — the wire must not reveal which functions exist.
1192
+ throw new RpcError('Function not found', 404);
1001
1193
  }
1002
1194
 
1003
- if (function_exists($fn) && is_callable($fn)) {
1004
- try {
1005
- self::enforceRateLimit($attribute, "fn:$fn");
1006
-
1007
- $res = call_user_func($fn, $args);
1008
-
1009
- if ($res instanceof Generator || $res instanceof SSE) {
1010
- return $res;
1011
- }
1012
-
1013
- return $res;
1014
- } catch (Throwable $e) {
1015
- if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1016
- return ['success' => false, 'error' => $e->getMessage()];
1017
- }
1195
+ self::authorizeAccess($attribute);
1196
+ self::enforceRateLimit($attribute, "fn:$fn");
1018
1197
 
1019
- if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1020
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1021
- } else {
1022
- return ['success' => false, 'error' => "Function error: {$e->getMessage()}"];
1023
- }
1024
- }
1025
- }
1026
- return ['success' => false, 'error' => 'Invalid callback'];
1198
+ return self::invokeExposed($fn, $args);
1027
1199
  }
1028
1200
 
1029
1201
  private static function dispatchMethod(string $call, mixed $args)
@@ -1046,56 +1218,36 @@ final class Bootstrap extends RuntimeException
1046
1218
  }
1047
1219
 
1048
1220
  if (!class_exists($class)) {
1049
- return ['success' => false, 'error' => "Class '$requested' not found"];
1050
- }
1051
-
1052
- if (!self::isMethodAllowed($class, $method)) {
1053
- return ['success' => false, 'error' => 'Method not callable from client'];
1221
+ throw new RpcError('Function not found', 404);
1054
1222
  }
1055
1223
 
1056
1224
  $attribute = self::getExposedAttribute($class, $method);
1057
- if (!$attribute) {
1058
- return ['success' => false, 'error' => 'Method not callable from client'];
1225
+ if ($attribute === null) {
1226
+ throw new RpcError('Function not found', 404);
1059
1227
  }
1060
1228
 
1061
- if (!self::validateAccess($attribute)) {
1062
- return ['success' => false, 'error' => 'Permission denied'];
1063
- }
1064
-
1065
- try {
1066
- self::enforceRateLimit($attribute, "method:$class::$method");
1067
-
1068
- $res = null;
1069
- if (!$isStatic) {
1070
- $instance = new $class();
1071
- if (!is_callable([$instance, $method])) throw new Exception("Method not callable");
1072
- $res = call_user_func([$instance, $method], $args);
1073
- } else {
1074
- if (!is_callable([$class, $method])) throw new Exception("Static method invalid");
1075
- $res = call_user_func([$class, $method], $args);
1076
- }
1229
+ self::authorizeAccess($attribute);
1230
+ self::enforceRateLimit($attribute, "method:$class::$method");
1077
1231
 
1078
- if ($res instanceof Generator || $res instanceof SSE) {
1079
- return $res;
1232
+ if ($isStatic) {
1233
+ if (!is_callable([$class, $method])) {
1234
+ throw new RpcError('Function not found', 404);
1080
1235
  }
1081
1236
 
1082
- return $res;
1083
- } catch (Throwable $e) {
1084
- if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1085
- return ['success' => false, 'error' => $e->getMessage()];
1086
- }
1237
+ return self::invokeExposed([$class, $method], $args);
1238
+ }
1087
1239
 
1088
- if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1089
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1090
- } else {
1091
- return ['success' => false, 'error' => "Call error: {$e->getMessage()}"];
1092
- }
1240
+ $instance = new $class();
1241
+ if (!is_callable([$instance, $method])) {
1242
+ throw new RpcError('Function not found', 404);
1093
1243
  }
1244
+
1245
+ return self::invokeExposed([$instance, $method], $args);
1094
1246
  }
1095
1247
 
1096
1248
  private static function resolveClassImport(string $simpleClassKey): ?array
1097
1249
  {
1098
- $logs = PrismaPHPSettings::$classLogFiles[$simpleClassKey] ?? [];
1250
+ $logs = PrismaPHPSettings::getClassLogFiles()[$simpleClassKey] ?? [];
1099
1251
  if (!is_array($logs) || empty($logs)) {
1100
1252
  return null;
1101
1253
  }
@@ -1178,19 +1330,35 @@ final class Bootstrap extends RuntimeException
1178
1330
  }
1179
1331
  }
1180
1332
 
1181
- $currentUrl = Request::getDecodedUrl(Request::$uri);
1333
+ $currentUrl = self::normalizeRequestDataKey(Request::getDecodedUrl(Request::$uri));
1334
+ $legacyUrl = $currentUrl === '/' ? null : '/' . $currentUrl;
1335
+
1336
+ if ($legacyUrl !== null && isset($currentData[$legacyUrl])) {
1337
+ $legacyEntry = $currentData[$legacyUrl];
1338
+ unset($currentData[$legacyUrl]);
1339
+
1340
+ if (!isset($currentData[$currentUrl])) {
1341
+ $currentData[$currentUrl] = $legacyEntry;
1342
+ } elseif (isset($legacyEntry['includedFiles']) && is_array($legacyEntry['includedFiles'])) {
1343
+ $currentData[$currentUrl]['includedFiles'] = array_values(array_unique(array_merge(
1344
+ $currentData[$currentUrl]['includedFiles'] ?? [],
1345
+ $legacyEntry['includedFiles']
1346
+ )));
1347
+ }
1348
+ }
1182
1349
 
1183
1350
  if (isset($currentData[$currentUrl])) {
1351
+ $currentData[$currentUrl]['url'] = $currentUrl;
1184
1352
  $currentData[$currentUrl]['includedFiles'] = array_values(array_unique(
1185
1353
  array_merge($currentData[$currentUrl]['includedFiles'], $srcAppFiles)
1186
1354
  ));
1187
1355
 
1188
- if (!Request::$isWire && !self::$secondRequestC69CD) {
1356
+ if (!Request::$isRpc) {
1189
1357
  $currentData[$currentUrl]['isCacheable'] = CacheHandler::$isCacheable;
1190
1358
  }
1191
1359
  } else {
1192
1360
  $currentData[$currentUrl] = [
1193
- 'url' => Request::$uri,
1361
+ 'url' => $currentUrl,
1194
1362
  'fileName' => self::convertUrlToFileName($currentUrl),
1195
1363
  'isCacheable' => CacheHandler::$isCacheable,
1196
1364
  'cacheTtl' => CacheHandler::$ttl,
@@ -1213,6 +1381,17 @@ final class Bootstrap extends RuntimeException
1213
1381
  return $fileName ? mb_strtolower($fileName, 'UTF-8') : 'index';
1214
1382
  }
1215
1383
 
1384
+ private static function normalizeRequestDataKey(string $url): string
1385
+ {
1386
+ $trimmedUrl = trim($url, '/');
1387
+
1388
+ if ($trimmedUrl === '') {
1389
+ return '/';
1390
+ }
1391
+
1392
+ return $trimmedUrl;
1393
+ }
1394
+
1216
1395
  private static function authenticateUserToken(): void
1217
1396
  {
1218
1397
  $token = Request::getBearerToken();
@@ -1237,7 +1416,17 @@ final class Bootstrap extends RuntimeException
1237
1416
  public static function applyRootLayoutId(string $html): string
1238
1417
  {
1239
1418
  $rootLayoutPath = self::$layoutsToInclude[0] ?? self::$parentLayoutPath;
1240
- $rootLayoutId = !empty($rootLayoutPath) ? md5($rootLayoutPath) : 'default-root';
1419
+
1420
+ if (!empty($rootLayoutPath)) {
1421
+ $resolvedRootLayoutPath = realpath($rootLayoutPath);
1422
+ $canonicalRootLayoutPath = $resolvedRootLayoutPath !== false
1423
+ ? $resolvedRootLayoutPath
1424
+ : $rootLayoutPath;
1425
+
1426
+ $rootLayoutId = md5(str_replace('\\', '/', $canonicalRootLayoutPath));
1427
+ } else {
1428
+ $rootLayoutId = 'default-root';
1429
+ }
1241
1430
 
1242
1431
  header('X-PP-Root-Layout: ' . $rootLayoutId);
1243
1432
 
@@ -1267,11 +1456,18 @@ try {
1267
1456
 
1268
1457
  if (is_file(Bootstrap::$requestFilePath)) {
1269
1458
  if (file_exists(Bootstrap::$requestFilePath) && Request::$isXFileRequest) {
1459
+ if (!Bootstrap::isPathWithinApp(Bootstrap::$requestFilePath)) {
1460
+ http_response_code(403);
1461
+ exit;
1462
+ }
1463
+
1464
+ $resolvedRequestFilePath = realpath(Bootstrap::$requestFilePath);
1465
+
1270
1466
  if (pathinfo(Bootstrap::$requestFilePath, PATHINFO_EXTENSION) === 'php') {
1271
- include Bootstrap::$requestFilePath;
1467
+ include $resolvedRequestFilePath ?: Bootstrap::$requestFilePath;
1272
1468
  } else {
1273
- header('Content-Type: ' . mime_content_type(Bootstrap::$requestFilePath));
1274
- readfile(Bootstrap::$requestFilePath);
1469
+ header('Content-Type: ' . mime_content_type($resolvedRequestFilePath ?: Bootstrap::$requestFilePath));
1470
+ readfile($resolvedRequestFilePath ?: Bootstrap::$requestFilePath);
1275
1471
  }
1276
1472
  exit;
1277
1473
  }
@@ -1293,6 +1489,13 @@ try {
1293
1489
  require_once Bootstrap::$contentToInclude;
1294
1490
  MainLayout::$children = ob_get_clean();
1295
1491
 
1492
+ if (Request::$fileToInclude === 'index.php') {
1493
+ MainLayout::$children = TemplateCompiler::scopeRouteRoot(
1494
+ MainLayout::$children,
1495
+ Bootstrap::$contentToInclude
1496
+ );
1497
+ }
1498
+
1296
1499
  if (count(Bootstrap::$layoutsToInclude) > 1) {
1297
1500
  $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1298
1501
 
@@ -1304,6 +1507,7 @@ try {
1304
1507
  ob_start();
1305
1508
  require_once $layoutPath;
1306
1509
  MainLayout::$children = ob_get_clean();
1510
+ MainLayout::$children = TemplateCompiler::scopeRouteRoot(MainLayout::$children, $layoutPath);
1307
1511
  }
1308
1512
  }
1309
1513
  } else {
@@ -1316,7 +1520,7 @@ try {
1316
1520
  }
1317
1521
 
1318
1522
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1319
- if (Request::$isWire && !Bootstrap::$secondRequestC69CD) {
1523
+ if (Request::$isRpc) {
1320
1524
  if (isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1321
1525
  foreach (Bootstrap::$requestFilesData[Request::$decodedUri]['includedFiles'] as $file) {
1322
1526
  if (file_exists($file)) {
@@ -1328,14 +1532,14 @@ try {
1328
1532
  }
1329
1533
  }
1330
1534
 
1331
- if (Request::$isWire && !Bootstrap::$secondRequestC69CD) {
1535
+ if (Request::$isRpc) {
1332
1536
  while (ob_get_level() > 0) {
1333
1537
  ob_end_clean();
1334
1538
  }
1335
1539
  Bootstrap::wireCallback();
1336
1540
  }
1337
1541
 
1338
- if ((!Request::$isWire && !Bootstrap::$secondRequestC69CD) && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1542
+ if (!Request::$isRpc && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1339
1543
  $cacheEnabled = (Env::string('CACHE_ENABLED', 'false') === 'true');
1340
1544
 
1341
1545
  $shouldCache = CacheHandler::$isCacheable === true
@@ -1356,25 +1560,34 @@ try {
1356
1560
  }
1357
1561
 
1358
1562
  MainLayout::$html = ob_get_clean();
1563
+
1564
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1565
+ TemplateCompiler::validateSingleRootHtml(
1566
+ MainLayout::$html,
1567
+ Bootstrap::$parentLayoutPath,
1568
+ 'Layout file'
1569
+ );
1570
+ }
1571
+
1359
1572
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1360
1573
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1361
1574
  MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
1575
+ MainLayout::$html = TemplateCompiler::deferComponentRoots(MainLayout::$html);
1362
1576
 
1363
1577
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1364
1578
 
1365
- if (!Bootstrap::$secondRequestC69CD) {
1366
- Bootstrap::createUpdateRequestData();
1367
- }
1579
+ Bootstrap::createUpdateRequestData();
1368
1580
 
1369
1581
  if (
1370
1582
  http_response_code() === 200
1371
1583
  && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1372
1584
  && $shouldCache
1373
- && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1585
+ && !Request::$isRpc
1374
1586
  ) {
1375
1587
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1376
1588
  }
1377
1589
 
1590
+ header('Content-Type: text/html; charset=UTF-8');
1378
1591
  echo MainLayout::$html;
1379
1592
  } else {
1380
1593
  $layoutPath = Bootstrap::$isContentIncluded