jac-scale 0.1.1__py3-none-any.whl → 0.1.4__py3-none-any.whl

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 (36) hide show
  1. jac_scale/abstractions/config/app_config.jac +5 -2
  2. jac_scale/config_loader.jac +2 -1
  3. jac_scale/context.jac +2 -1
  4. jac_scale/factories/storage_factory.jac +75 -0
  5. jac_scale/google_sso_provider.jac +85 -0
  6. jac_scale/impl/config_loader.impl.jac +28 -3
  7. jac_scale/impl/context.impl.jac +1 -0
  8. jac_scale/impl/serve.impl.jac +749 -266
  9. jac_scale/impl/user_manager.impl.jac +349 -0
  10. jac_scale/impl/webhook.impl.jac +212 -0
  11. jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
  12. jac_scale/memory_hierarchy.jac +3 -1
  13. jac_scale/plugin.jac +46 -3
  14. jac_scale/plugin_config.jac +28 -1
  15. jac_scale/serve.jac +33 -16
  16. jac_scale/sso_provider.jac +72 -0
  17. jac_scale/targets/kubernetes/kubernetes_config.jac +9 -15
  18. jac_scale/targets/kubernetes/kubernetes_target.jac +174 -15
  19. jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac +32 -0
  20. jac_scale/tests/fixtures/scale-feats/main.jac +147 -0
  21. jac_scale/tests/fixtures/test_api.jac +89 -0
  22. jac_scale/tests/fixtures/test_restspec.jac +88 -0
  23. jac_scale/tests/test_deploy_k8s.py +2 -1
  24. jac_scale/tests/test_examples.py +180 -5
  25. jac_scale/tests/test_hooks.py +39 -0
  26. jac_scale/tests/test_restspec.py +289 -0
  27. jac_scale/tests/test_serve.py +411 -4
  28. jac_scale/tests/test_sso.py +273 -284
  29. jac_scale/tests/test_storage.py +274 -0
  30. jac_scale/user_manager.jac +49 -0
  31. jac_scale/webhook.jac +93 -0
  32. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/METADATA +11 -4
  33. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/RECORD +36 -23
  34. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +1 -1
  35. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
  36. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
@@ -57,10 +57,10 @@ def _transport_response_to_json_response(
57
57
  );
58
58
  }
59
59
 
60
- impl JacAPIServer.start(dev: bool = False) -> None {
60
+ impl JacAPIServer.start(dev: bool = False, no_client: bool = False) -> None {
61
61
  self.introspector.load();
62
- # Eagerly build client bundle if there are client exports (skip in dev mode)
63
- if not dev {
62
+ # Eagerly build client bundle if there are client exports (skip in dev or no_client mode)
63
+ if not dev and not no_client {
64
64
  client_exports = self.introspector._client_manifest.get('exports', []);
65
65
  if client_exports {
66
66
  import time;
@@ -96,14 +96,17 @@ impl JacAPIServer.start(dev: bool = False) -> None {
96
96
  self.register_static_file_endpoint();
97
97
  self.register_update_username_endpoint();
98
98
  self.register_update_password_endpoint();
99
+ self.register_api_key_endpoints();
99
100
  # Use dynamic routing for HMR support, static routing for production
100
101
  if dev {
101
102
  self.register_dynamic_walker_endpoint();
102
103
  self.register_dynamic_function_endpoint();
103
104
  self.register_dynamic_introspection_endpoints();
105
+ self.register_dynamic_webhook_endpoint();
104
106
  } else {
105
107
  self.register_walkers_endpoints();
106
108
  self.register_functions_endpoints();
109
+ self.register_webhook_endpoints();
107
110
  }
108
111
  self.register_root_asset_endpoint();
109
112
  self._configure_openapi_security();
@@ -177,19 +180,35 @@ impl JacAPIServer._configure_openapi_security -> None {
177
180
  self.server.app.openapi = custom_openapi;
178
181
  }
179
182
 
180
- """Serve root-level assets like /img.png, /icons/logo.svg, etc."""
183
+ """Serve root-level assets like /img.png, /icons/logo.svg, etc.
184
+ Falls back to SPA HTML for extensionless paths when base_route_app is configured."""
181
185
  impl JacAPIServer.serve_root_asset(file_path: str) -> Response {
182
186
  allowed_extensions = {'.png','.jpg','.jpeg','.gif','.webp','.svg','.ico','.woff','.woff2','.ttf','.otf','.eot','.mp4','.webm','.mp3','.wav','.css','.js','.json','.pdf','.txt','.xml'};
183
187
  file_ext = Path(file_path).suffix.lower();
184
- if (not file_ext or (file_ext not in allowed_extensions)) {
185
- return Response(status_code=404, content='Not found', media_type='text/plain');
186
- }
187
188
  import from jaclang.project.config { get_config }
188
189
  config = get_config();
189
190
  cl_route_prefix = config.serve.cl_route_prefix if config else "cl";
190
- if file_path.startswith(
191
- (f'{cl_route_prefix}/', 'walker/', 'function/', 'user/', 'static/')
192
- ) {
191
+ api_prefixes = (f'{cl_route_prefix}/', 'walker/', 'function/', 'user/', 'static/');
192
+ if (not file_ext or (file_ext not in allowed_extensions)) {
193
+ # SPA catch-all: serve base_route_app for extensionless paths
194
+ base_route_app = config.serve.base_route_app if config else "";
195
+ if (not file_ext and base_route_app and not file_path.startswith(api_prefixes)) {
196
+ try {
197
+ render_payload = self.introspector.render_page(
198
+ base_route_app, {}, '__guest__'
199
+ );
200
+ return HTMLResponse(content=render_payload['html']);
201
+ } except ValueError {
202
+ return HTMLResponse(content="<h1>404 Not Found</h1>", status_code=404);
203
+ } except RuntimeError {
204
+ return HTMLResponse(
205
+ content="<h1>503 Service Unavailable</h1>", status_code=503
206
+ );
207
+ }
208
+ }
209
+ return Response(status_code=404, content='Not found', media_type='text/plain');
210
+ }
211
+ if file_path.startswith(api_prefixes) {
193
212
  return Response(status_code=404, content='Not found', media_type='text/plain');
194
213
  }
195
214
  # Find project root (where jac.toml is) instead of using base_path_dir
@@ -486,12 +505,21 @@ impl JacAPIServer.render_base_route_callback(
486
505
 
487
506
  impl JacAPIServer.register_functions_endpoints -> None {
488
507
  for func_name in self.get_functions() {
508
+ func_obj = self.get_functions()[func_name];
509
+ restspec = func_obj.restspec if func_obj?.restspec else None;
510
+ spec_method = restspec.method if restspec else HTTPMethod.POST;
511
+ spec_path = restspec.path if restspec else None;
512
+
513
+ final_path = spec_path or f"/function/{func_name}";
514
+
489
515
  self.server.add_endpoint(
490
516
  JEndPoint(
491
- method=HTTPMethod.POST,
492
- path=f"/function/{func_name}",
517
+ method=spec_method,
518
+ path=final_path,
493
519
  callback=self.create_function_callback(func_name),
494
- parameters=self.create_function_parameters(func_name),
520
+ parameters=self.create_function_parameters(
521
+ func_name, method=spec_method
522
+ ),
495
523
  response_model=None,
496
524
  tags=['Functions'],
497
525
  summary='This is a summary',
@@ -501,7 +529,9 @@ impl JacAPIServer.register_functions_endpoints -> None {
501
529
  }
502
530
  }
503
531
 
504
- impl JacAPIServer.create_function_parameters(func_name: str) -> list[APIParameter] {
532
+ impl JacAPIServer.create_function_parameters(
533
+ func_name: str, method: HTTPMethod = HTTPMethod.POST
534
+ ) -> list[APIParameter] {
505
535
  parameters: list[APIParameter] = [];
506
536
  if self.introspector.is_auth_required_for_function(func_name) {
507
537
  parameters.append(
@@ -520,8 +550,13 @@ impl JacAPIServer.create_function_parameters(func_name: str) -> list[APIParamete
520
550
  )['parameters'];
521
551
  for field_name in func_fields {
522
552
  field_type = func_fields[field_name]['type'];
523
- # Determine parameter type based on field type
524
- if ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
553
+ # Determine parameter type based on field type and method
554
+ if (
555
+ method == HTTPMethod.GET
556
+ and not ('UploadFile' in field_type or 'uploadfile' in field_type.lower())
557
+ ) {
558
+ param_type = ParameterType.QUERY;
559
+ } elif ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
525
560
  # Support UploadFile type for file uploads
526
561
  param_type = ParameterType.FILE;
527
562
  } else {
@@ -546,7 +581,7 @@ impl JacAPIServer.create_function_callback(
546
581
  ) -> Callable[..., TransportResponse] {
547
582
  import from jaclang.runtimelib.transport { TransportResponse, Meta }
548
583
  requires_auth = self.introspector.is_auth_required_for_function(func_name);
549
- def callback(**kwargs: JsonValue) -> TransportResponse {
584
+ async def callback(**kwargs: JsonValue) -> TransportResponse {
550
585
  username: (str | None) = None;
551
586
  if requires_auth {
552
587
  authorization = kwargs.pop('Authorization', None);
@@ -558,7 +593,7 @@ impl JacAPIServer.create_function_callback(
558
593
  ) {
559
594
  token = authorization[7:];
560
595
  }
561
- username = self.validate_jwt_token(token) if token else None;
596
+ username = self.user_manager.validate_jwt_token(token) if token else None;
562
597
  if not username {
563
598
  return TransportResponse.fail(
564
599
  code='UNAUTHORIZED',
@@ -568,9 +603,21 @@ impl JacAPIServer.create_function_callback(
568
603
  }
569
604
  }
570
605
  print(f"Executing function '{func_name}' with params: {kwargs}");
571
- result = self.execution_manager.execute_function(
606
+ result = await self.execution_manager.execute_function(
572
607
  self.get_functions()[func_name], kwargs, (username or '__guest__')
573
608
  );
609
+ # Handle streaming responses (generators/async generators)
610
+ if (isgenerator(result) or isinstance(result, AsyncGenerator)) {
611
+ return StreamingResponse(
612
+ result,
613
+ media_type='text/event-stream',
614
+ headers={
615
+ 'Cache-Control': 'no-cache',
616
+ 'Connection': 'close',
617
+ 'X-Accel-Buffering': 'no'
618
+ }
619
+ );
620
+ }
574
621
  if 'error' in result {
575
622
  return TransportResponse.fail(
576
623
  code='EXECUTION_ERROR',
@@ -588,13 +635,23 @@ impl JacAPIServer.create_function_callback(
588
635
 
589
636
  impl JacAPIServer.register_walkers_endpoints -> None {
590
637
  for walker_name in self.get_walkers() {
638
+ # Skip walkers configured for webhook transport - they use /webhook/{walker_name} instead
639
+ transport_type = self.get_transport_type_for_walker(walker_name);
640
+ if transport_type == TransportType.WEBHOOK {
641
+ continue;
642
+ }
643
+ walker_cls = self.get_walkers()[walker_name];
644
+ restspec = walker_cls.restspec if walker_cls?.restspec else None;
645
+ spec_method = restspec.method if restspec?.method else HTTPMethod.POST;
646
+ spec_path = restspec.path if restspec?.path else f"/walker/{walker_name}";
647
+
591
648
  self.server.add_endpoint(
592
649
  JEndPoint(
593
- method=HTTPMethod.POST,
594
- path=f"/walker/{walker_name}/{{node}}",
650
+ method=spec_method,
651
+ path=f"{spec_path}/{{node}}",
595
652
  callback=self.create_walker_callback(walker_name, has_node_param=True),
596
653
  parameters=self.create_walker_parameters(
597
- walker_name, invoke_on_root=False
654
+ walker_name, invoke_on_root=False, method=spec_method
598
655
  ),
599
656
  response_model=None,
600
657
  tags=['Walkers'],
@@ -604,11 +661,11 @@ impl JacAPIServer.register_walkers_endpoints -> None {
604
661
  );
605
662
  self.server.add_endpoint(
606
663
  JEndPoint(
607
- method=HTTPMethod.POST,
608
- path=f"/walker/{walker_name}",
664
+ method=spec_method,
665
+ path=spec_path,
609
666
  callback=self.create_walker_callback(walker_name, has_node_param=False),
610
667
  parameters=self.create_walker_parameters(
611
- walker_name, invoke_on_root=True
668
+ walker_name, invoke_on_root=True, method=spec_method
612
669
  ),
613
670
  response_model=None,
614
671
  tags=['Walkers'],
@@ -620,7 +677,7 @@ impl JacAPIServer.register_walkers_endpoints -> None {
620
677
  }
621
678
 
622
679
  impl JacAPIServer.create_walker_parameters(
623
- walker_name: str, invoke_on_root: bool
680
+ walker_name: str, invoke_on_root: bool, method: HTTPMethod = HTTPMethod.POST
624
681
  ) -> list[APIParameter] {
625
682
  parameters: list[APIParameter] = [];
626
683
  if self.introspector.is_auth_required_for_walker(walker_name) {
@@ -643,9 +700,14 @@ impl JacAPIServer.create_walker_parameters(
643
700
  continue;
644
701
  }
645
702
  field_type = walker_fields[field_name]['type'];
646
- # Determine parameter type based on field type
703
+ # Determine parameter type based on field type and method
647
704
  if (field_name == '_jac_spawn_node') {
648
705
  param_type = ParameterType.PATH;
706
+ } elif (
707
+ method == HTTPMethod.GET
708
+ and not ('UploadFile' in field_type or 'uploadfile' in field_type.lower())
709
+ ) {
710
+ param_type = ParameterType.QUERY;
649
711
  } elif ('UploadFile' in field_type or 'uploadfile' in field_type.lower()) {
650
712
  # Support UploadFile type for file uploads
651
713
  param_type = ParameterType.FILE;
@@ -685,7 +747,7 @@ impl JacAPIServer.create_walker_callback(
685
747
  ) {
686
748
  token = authorization[7:];
687
749
  }
688
- username = self.validate_jwt_token(token) if token else None;
750
+ username = self.user_manager.validate_jwt_token(token) if token else None;
689
751
  if not username {
690
752
  return TransportResponse.fail(
691
753
  code='UNAUTHORIZED',
@@ -700,6 +762,17 @@ impl JacAPIServer.create_walker_callback(
700
762
  result = await self.execution_manager.spawn_walker(
701
763
  self.get_walkers()[walker_name], kwargs, (username or '__guest__')
702
764
  );
765
+ if (isgenerator(result) or isinstance(result, AsyncGenerator)) {
766
+ return StreamingResponse(
767
+ result,
768
+ media_type='text/event-stream',
769
+ headers={
770
+ 'Cache-Control': 'no-cache',
771
+ 'Connection': 'close',
772
+ 'X-Accel-Buffering': 'no'
773
+ }
774
+ );
775
+ }
703
776
  if 'error' in result {
704
777
  return TransportResponse.fail(
705
778
  code='EXECUTION_ERROR',
@@ -783,7 +856,7 @@ impl JacAPIServer.refresh_token(token: (str | None) = None) -> TransportResponse
783
856
  if token.startswith('Bearer ') {
784
857
  token = token[7:];
785
858
  }
786
- new_token = self.refresh_jwt_token(token);
859
+ new_token = self.user_manager.refresh_jwt_token(token);
787
860
  if (not new_token) {
788
861
  return TransportResponse.fail(
789
862
  code='UNAUTHORIZED',
@@ -809,7 +882,7 @@ impl JacAPIServer.create_user(username: str, password: str) -> TransportResponse
809
882
  meta=Meta(extra={'http_status': 400})
810
883
  );
811
884
  }
812
- res['token'] = self.create_jwt_token(username);
885
+ res['token'] = self.user_manager.create_jwt_token(username);
813
886
  return TransportResponse.success(
814
887
  data=res, meta=Meta(extra={'http_status': 201})
815
888
  );
@@ -837,7 +910,7 @@ impl JacAPIServer.update_username(
837
910
  ) {
838
911
  token = Authorization[7:];
839
912
  }
840
- token_username = self.validate_jwt_token(token) if token else None;
913
+ token_username = self.user_manager.validate_jwt_token(token) if token else None;
841
914
  if not token_username {
842
915
  return TransportResponse.fail(
843
916
  code='UNAUTHORIZED',
@@ -869,7 +942,7 @@ impl JacAPIServer.update_username(
869
942
  );
870
943
  }
871
944
  # Generate new JWT token with updated username
872
- result['token'] = self.create_jwt_token(new_username);
945
+ result['token'] = self.user_manager.create_jwt_token(new_username);
873
946
  return TransportResponse.success(
874
947
  data=result, meta=Meta(extra={'http_status': 200})
875
948
  );
@@ -891,7 +964,7 @@ impl JacAPIServer.update_password(
891
964
  ) {
892
965
  token = Authorization[7:];
893
966
  }
894
- token_username = self.validate_jwt_token(token) if token else None;
967
+ token_username = self.user_manager.validate_jwt_token(token) if token else None;
895
968
  if not token_username {
896
969
  return TransportResponse.fail(
897
970
  code='UNAUTHORIZED',
@@ -1066,7 +1139,7 @@ impl JacAPIServer.login(username: str, password: str) -> TransportResponse {
1066
1139
  meta=Meta(extra={'http_status': 401})
1067
1140
  );
1068
1141
  }
1069
- result['token'] = self.create_jwt_token(username);
1142
+ result['token'] = self.user_manager.create_jwt_token(username);
1070
1143
  return TransportResponse.success(
1071
1144
  data=dict[(str, JsonValue)](result), meta=Meta(extra={'http_status': 200})
1072
1145
  );
@@ -1074,6 +1147,7 @@ impl JacAPIServer.login(username: str, password: str) -> TransportResponse {
1074
1147
 
1075
1148
  impl JacAPIServer.postinit -> None {
1076
1149
  super.postinit();
1150
+ self._api_key_manager = ApiKeyManager();
1077
1151
  self.server.app.add_middleware(
1078
1152
  CORSMiddleware,
1079
1153
  allow_origins=['*'],
@@ -1109,219 +1183,6 @@ impl JacAPIServer.postinit -> None {
1109
1183
  }
1110
1184
  self.server.app.add_middleware(CustomHeadersMiddleware);
1111
1185
  }
1112
- self.SUPPORTED_PLATFORMS: dict = {};
1113
- # Load SSO config fresh (not from cached global) to support env var overrides at runtime
1114
- sso_config = get_scale_config().get_sso_config();
1115
- for platform in Platforms {
1116
- key = platform.lower();
1117
- platform_config = sso_config.get(key, {});
1118
-
1119
- client_id = platform_config.get('client_id', '');
1120
- client_secret = platform_config.get('client_secret', '');
1121
-
1122
- if not client_id or not client_secret {
1123
- continue;
1124
- }
1125
-
1126
- self.SUPPORTED_PLATFORMS[platform.value] = {
1127
- "client_id": client_id,
1128
- "client_secret": client_secret
1129
- };
1130
- }
1131
- }
1132
-
1133
- impl JacAPIServer.refresh_jwt_token(token: str) -> (str | None) {
1134
- try {
1135
- decoded = jwt.decode(
1136
- token, JWT_SECRET, algorithms=[JWT_ALGORITHM], options={"verify_exp": True}
1137
- );
1138
- username = decoded.get('username');
1139
-
1140
- if not username {
1141
- return None;
1142
- }
1143
-
1144
- return JacAPIServer.create_jwt_token(username);
1145
- } except Exception {
1146
- return None;
1147
- }
1148
- }
1149
-
1150
- impl JacAPIServer.validate_jwt_token(token: str) -> (str | None) {
1151
- try {
1152
- decoded = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]);
1153
- return decoded['username'];
1154
- } except Exception {
1155
- return None;
1156
- }
1157
- }
1158
-
1159
- impl JacAPIServer.create_jwt_token(username: str) -> str {
1160
- now = datetime.now(UTC);
1161
- payload: dict[(str, Any)] = {
1162
- 'username': username,
1163
- 'exp': (now + timedelta(days=JWT_EXP_DELTA_DAYS)),
1164
- 'iat': now.timestamp()
1165
- };
1166
- return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM);
1167
- }
1168
-
1169
- impl JacAPIServer.get_sso(platform: str, operation: str) -> (GoogleSSO | None) {
1170
- if (platform not in self.SUPPORTED_PLATFORMS) {
1171
- return None;
1172
- }
1173
- credentials = self.SUPPORTED_PLATFORMS[platform];
1174
- redirect_uri = f"{SSO_HOST}/{platform}/{operation}/callback";
1175
- if (platform == Platforms.GOOGLE.value) {
1176
- return GoogleSSO(
1177
- client_id=credentials['client_id'],
1178
- client_secret=credentials['client_secret'],
1179
- redirect_uri=redirect_uri,
1180
- allow_insecure_http=True
1181
- );
1182
- }
1183
- return None;
1184
- }
1185
-
1186
- impl JacAPIServer.sso_initiate(
1187
- platform: str, operation: str
1188
- ) -> (Response | TransportResponse) {
1189
- import from jaclang.runtimelib.transport { TransportResponse, Meta }
1190
- if (platform not in [p.value for p in Platforms]) {
1191
- return TransportResponse.fail(
1192
- code='INVALID_PLATFORM',
1193
- message=f"Invalid platform '{platform}'. Supported platforms: {', '.join(
1194
- [p.value for p in Platforms]
1195
- )}",
1196
- meta=Meta(extra={'http_status': 400})
1197
- );
1198
- }
1199
- if (platform not in self.SUPPORTED_PLATFORMS) {
1200
- return TransportResponse.fail(
1201
- code='SSO_NOT_CONFIGURED',
1202
- message=f"SSO for platform '{platform}' is not configured. Please set SSO_{platform.upper()}_CLIENT_ID and SSO_{platform.upper()}_CLIENT_SECRET environment variables.",
1203
- meta=Meta(extra={'http_status': 501})
1204
- );
1205
- }
1206
- if (operation not in [o.value for o in Operations]) {
1207
- return TransportResponse.fail(
1208
- code='INVALID_OPERATION',
1209
- message=f"Invalid operation '{operation}'. Must be 'login' or 'register'",
1210
- meta=Meta(extra={'http_status': 400})
1211
- );
1212
- }
1213
- sso = self.get_sso(platform, operation);
1214
- if not sso {
1215
- return TransportResponse.fail(
1216
- code='SSO_INIT_FAILED',
1217
- message=f"Failed to initialize SSO for platform '{platform}'",
1218
- meta=Meta(extra={'http_status': 500})
1219
- );
1220
- }
1221
- with sso {
1222
- return await sso.get_login_redirect();
1223
- }
1224
- }
1225
-
1226
- impl JacAPIServer.sso_callback(
1227
- request: Request, platform: str, operation: str
1228
- ) -> TransportResponse {
1229
- import from jaclang.runtimelib.transport { TransportResponse, Meta }
1230
- if (platform not in [p.value for p in Platforms]) {
1231
- return TransportResponse.fail(
1232
- code='INVALID_PLATFORM',
1233
- message=f"Invalid platform '{platform}'. Supported platforms: {', '.join(
1234
- [p.value for p in Platforms]
1235
- )}",
1236
- meta=Meta(extra={'http_status': 400})
1237
- );
1238
- }
1239
- if (platform not in self.SUPPORTED_PLATFORMS) {
1240
- return TransportResponse.fail(
1241
- code='SSO_NOT_CONFIGURED',
1242
- message=f"SSO for platform '{platform}' is not configured. Please set SSO_{platform.upper()}_CLIENT_ID and SSO_{platform.upper()}_CLIENT_SECRET environment variables.",
1243
- meta=Meta(extra={'http_status': 501})
1244
- );
1245
- }
1246
- if (operation not in [o.value for o in Operations]) {
1247
- return TransportResponse.fail(
1248
- code='INVALID_OPERATION',
1249
- message=f"Invalid operation '{operation}'. Must be 'login' or 'register'",
1250
- meta=Meta(extra={'http_status': 400})
1251
- );
1252
- }
1253
- sso = self.get_sso(platform, operation);
1254
- if not sso {
1255
- return TransportResponse.fail(
1256
- code='SSO_INIT_FAILED',
1257
- message=f"Failed to initialize SSO for platform '{platform}'",
1258
- meta=Meta(extra={'http_status': 500})
1259
- );
1260
- }
1261
- try {
1262
- with sso {
1263
- user_info = await sso.verify_and_process(request);
1264
- email = user_info.email;
1265
- if not email {
1266
- return TransportResponse.fail(
1267
- code='EMAIL_MISSING',
1268
- message=f"Email not provided by {platform}",
1269
- meta=Meta(extra={'http_status': 400})
1270
- );
1271
- }
1272
- if (operation == Operations.LOGIN.value) {
1273
- user = self.user_manager.get_user(email);
1274
- if not user {
1275
- return TransportResponse.fail(
1276
- code='USER_NOT_FOUND',
1277
- message='User not found. Please register first.',
1278
- meta=Meta(extra={'http_status': 404})
1279
- );
1280
- }
1281
- token = self.create_jwt_token(email);
1282
- return TransportResponse.success(
1283
- data={
1284
- 'message': 'Login successful',
1285
- 'email': email,
1286
- 'token': token,
1287
- 'platform': platform,
1288
- 'user': dict[(str, JsonValue)](user)
1289
- },
1290
- meta=Meta(extra={'http_status': 200})
1291
- );
1292
- } elif (operation == Operations.REGISTER.value) {
1293
- existing_user = self.user_manager.get_user(email);
1294
- if existing_user {
1295
- return TransportResponse.fail(
1296
- code='USER_EXISTS',
1297
- message='User already exists. Please login instead.',
1298
- meta=Meta(extra={'http_status': 400})
1299
- );
1300
- }
1301
- random_password = generate_random_password();
1302
- result = self.user_manager.create_user(email, random_password);
1303
- if ('error' in result) {
1304
- return TransportResponse.fail(
1305
- code='USER_CREATION_FAILED',
1306
- message=result.get('error', 'User creation failed'),
1307
- meta=Meta(extra={'http_status': 400})
1308
- );
1309
- }
1310
- token = self.create_jwt_token(email);
1311
- result['token'] = token;
1312
- result['platform'] = platform;
1313
- return TransportResponse.success(
1314
- data=result, meta=Meta(extra={'http_status': 201})
1315
- );
1316
- }
1317
- }
1318
- } except Exception as e {
1319
- return TransportResponse.fail(
1320
- code='AUTHENTICATION_FAILED',
1321
- message=f"Authentication failed: {str(e)}",
1322
- meta=Meta(extra={'http_status': 500})
1323
- );
1324
- }
1325
1186
  }
1326
1187
 
1327
1188
  impl JacAPIServer.register_sso_endpoints -> None {
@@ -1329,7 +1190,7 @@ impl JacAPIServer.register_sso_endpoints -> None {
1329
1190
  JEndPoint(
1330
1191
  method=HTTPMethod.GET,
1331
1192
  path='/sso/{platform}/{operation}',
1332
- callback=self.sso_initiate,
1193
+ callback=self.user_manager.sso_initiate,
1333
1194
  parameters=[
1334
1195
  APIParameter(
1335
1196
  name='platform',
@@ -1358,7 +1219,7 @@ impl JacAPIServer.register_sso_endpoints -> None {
1358
1219
  JEndPoint(
1359
1220
  method=HTTPMethod.GET,
1360
1221
  path='/sso/{platform}/{operation}/callback',
1361
- callback=self.sso_callback,
1222
+ callback=self.user_manager.sso_callback,
1362
1223
  parameters=[
1363
1224
  APIParameter(
1364
1225
  name='platform',
@@ -1417,13 +1278,17 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1417
1278
  node: str | None = None,
1418
1279
  Authorization: str | None = None
1419
1280
  ) -> TransportResponse {
1420
- # Parse request body to get walker fields
1421
- try {
1422
- body = await request.json();
1423
- } except Exception {
1424
- body = {};
1281
+ # Parse request body or query params to get walker fields
1282
+ if request.method == 'GET' {
1283
+ kwargs: dict[str, Any] = dict(request.query_params);
1284
+ } else {
1285
+ try {
1286
+ body = await request.json();
1287
+ } except Exception {
1288
+ body = {};
1289
+ }
1290
+ kwargs = dict(body) if body else {};
1425
1291
  }
1426
- kwargs: dict[str, Any] = dict(body) if body else {};
1427
1292
 
1428
1293
  # Reload introspector if files changed (HMR)
1429
1294
  if self._hmr_pending {
@@ -1443,6 +1308,16 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1443
1308
  );
1444
1309
  }
1445
1310
 
1311
+ # Reject walkers configured for webhook transport - they use /webhook/{walker_name} instead
1312
+ transport_type = self.get_transport_type_for_walker(walker_name);
1313
+ if transport_type == TransportType.WEBHOOK {
1314
+ return TransportResponse.fail(
1315
+ code='BAD_REQUEST',
1316
+ message=f"Walker '{walker_name}' is configured as a webhook. Use /webhook/{walker_name} instead.",
1317
+ meta=Meta(extra={'http_status': 400})
1318
+ );
1319
+ }
1320
+
1446
1321
  # Handle authentication
1447
1322
  username: str | None = None;
1448
1323
  authorization = kwargs.pop('Authorization', None);
@@ -1455,7 +1330,7 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1455
1330
  ) {
1456
1331
  token = Authorization[7:];
1457
1332
  }
1458
- username = self.validate_jwt_token(token) if token else None;
1333
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1459
1334
  if not username {
1460
1335
  return TransportResponse.fail(
1461
1336
  code='UNAUTHORIZED',
@@ -1473,6 +1348,20 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1473
1348
  result = await self.execution_manager.spawn_walker(
1474
1349
  walkers[walker_name], kwargs, (username or '__guest__')
1475
1350
  );
1351
+
1352
+ # Handle streaming responses (generators/async generators)
1353
+ if (isgenerator(result) or isinstance(result, AsyncGenerator)) {
1354
+ return StreamingResponse(
1355
+ result,
1356
+ media_type='text/event-stream',
1357
+ headers={
1358
+ 'Cache-Control': 'no-cache',
1359
+ 'Connection': 'close',
1360
+ 'X-Accel-Buffering': 'no'
1361
+ }
1362
+ );
1363
+ }
1364
+
1476
1365
  if 'error' in result {
1477
1366
  return TransportResponse.fail(
1478
1367
  code='EXECUTION_ERROR',
@@ -1485,7 +1374,7 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1485
1374
  data=result, meta=Meta(extra={'http_status': 200})
1486
1375
  );
1487
1376
  }
1488
- # Register catch-all route for walkers with node parameter
1377
+ # Register catch-all route for walkers with node parameter (POST)
1489
1378
  self.server.add_endpoint(
1490
1379
  JEndPoint(
1491
1380
  method=HTTPMethod.POST,
@@ -1523,7 +1412,45 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1523
1412
  description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1524
1413
  )
1525
1414
  );
1526
- # Register catch-all route for walkers without node parameter (root)
1415
+ # Register catch-all route for walkers with node parameter (GET)
1416
+ self.server.add_endpoint(
1417
+ JEndPoint(
1418
+ method=HTTPMethod.GET,
1419
+ path='/walker/{walker_name}/{node}',
1420
+ callback=dynamic_walker_handler,
1421
+ parameters=[
1422
+ APIParameter(
1423
+ name='walker_name',
1424
+ data_type='string',
1425
+ required=True,
1426
+ default=None,
1427
+ description='Name of the walker to execute',
1428
+ type=ParameterType.PATH
1429
+ ),
1430
+ APIParameter(
1431
+ name='node',
1432
+ data_type='string',
1433
+ required=True,
1434
+ default=None,
1435
+ description='Node ID to spawn walker on',
1436
+ type=ParameterType.PATH
1437
+ ),
1438
+ APIParameter(
1439
+ name='Authorization',
1440
+ data_type='string',
1441
+ required=False,
1442
+ default=None,
1443
+ description='Bearer token for authentication',
1444
+ type=ParameterType.HEADER
1445
+ )
1446
+ ],
1447
+ response_model=None,
1448
+ tags=['Walkers (Dynamic)'],
1449
+ summary='Execute walker on node (dynamic HMR)',
1450
+ description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1451
+ )
1452
+ );
1453
+ # Register catch-all route for walkers without node parameter (root) (POST)
1527
1454
  self.server.add_endpoint(
1528
1455
  JEndPoint(
1529
1456
  method=HTTPMethod.POST,
@@ -1553,6 +1480,36 @@ impl JacAPIServer.register_dynamic_walker_endpoint -> None {
1553
1480
  description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1554
1481
  )
1555
1482
  );
1483
+ # Register catch-all route for walkers without node parameter (root) (GET)
1484
+ self.server.add_endpoint(
1485
+ JEndPoint(
1486
+ method=HTTPMethod.GET,
1487
+ path='/walker/{walker_name}',
1488
+ callback=dynamic_walker_handler,
1489
+ parameters=[
1490
+ APIParameter(
1491
+ name='walker_name',
1492
+ data_type='string',
1493
+ required=True,
1494
+ default=None,
1495
+ description='Name of the walker to execute',
1496
+ type=ParameterType.PATH
1497
+ ),
1498
+ APIParameter(
1499
+ name='Authorization',
1500
+ data_type='string',
1501
+ required=False,
1502
+ default=None,
1503
+ description='Bearer token for authentication',
1504
+ type=ParameterType.HEADER
1505
+ )
1506
+ ],
1507
+ response_model=None,
1508
+ tags=['Walkers (Dynamic)'],
1509
+ summary='Execute walker on root (dynamic HMR)',
1510
+ description='Dynamically routes to any registered walker. Supports HMR - walker changes are reflected immediately.'
1511
+ )
1512
+ );
1556
1513
  }
1557
1514
 
1558
1515
  """Register a single dynamic endpoint for all functions.
@@ -1566,13 +1523,17 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1566
1523
  async def dynamic_function_handler(
1567
1524
  request: Request, function_name: str, Authorization: str | None = None
1568
1525
  ) -> TransportResponse {
1569
- # Parse request body to get function arguments
1570
- try {
1571
- body = await request.json();
1572
- } except Exception {
1573
- body = {};
1526
+ # Parse request body or query params to get function arguments
1527
+ if request.method == 'GET' {
1528
+ kwargs: dict[str, Any] = dict(request.query_params);
1529
+ } else {
1530
+ try {
1531
+ body = await request.json();
1532
+ } except Exception {
1533
+ body = {};
1534
+ }
1535
+ kwargs = dict(body) if body else {};
1574
1536
  }
1575
- kwargs: dict[str, Any] = dict(body) if body else {};
1576
1537
 
1577
1538
  # Reload introspector if files changed (HMR)
1578
1539
  if self._hmr_pending {
@@ -1604,7 +1565,7 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1604
1565
  ) {
1605
1566
  token = Authorization[7:];
1606
1567
  }
1607
- username = self.validate_jwt_token(token) if token else None;
1568
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1608
1569
  if not username {
1609
1570
  return TransportResponse.fail(
1610
1571
  code='UNAUTHORIZED',
@@ -1614,9 +1575,21 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1614
1575
  }
1615
1576
  }
1616
1577
 
1617
- result = self.execution_manager.execute_function(
1578
+ result = await self.execution_manager.execute_function(
1618
1579
  functions[function_name], kwargs, (username or '__guest__')
1619
1580
  );
1581
+ # Handle streaming responses (generators/async generators)
1582
+ if (isgenerator(result) or isinstance(result, AsyncGenerator)) {
1583
+ return StreamingResponse(
1584
+ result,
1585
+ media_type='text/event-stream',
1586
+ headers={
1587
+ 'Cache-Control': 'no-cache',
1588
+ 'Connection': 'close',
1589
+ 'X-Accel-Buffering': 'no'
1590
+ }
1591
+ );
1592
+ }
1620
1593
  if 'error' in result {
1621
1594
  return TransportResponse.fail(
1622
1595
  code='EXECUTION_ERROR',
@@ -1629,6 +1602,7 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1629
1602
  data=result, meta=Meta(extra={'http_status': 200})
1630
1603
  );
1631
1604
  }
1605
+ # Register POST endpoint
1632
1606
  self.server.add_endpoint(
1633
1607
  JEndPoint(
1634
1608
  method=HTTPMethod.POST,
@@ -1658,6 +1632,36 @@ impl JacAPIServer.register_dynamic_function_endpoint -> None {
1658
1632
  description='Dynamically routes to any registered function. Supports HMR - function changes are reflected immediately.'
1659
1633
  )
1660
1634
  );
1635
+ # Register GET endpoint
1636
+ self.server.add_endpoint(
1637
+ JEndPoint(
1638
+ method=HTTPMethod.GET,
1639
+ path='/function/{function_name}',
1640
+ callback=dynamic_function_handler,
1641
+ parameters=[
1642
+ APIParameter(
1643
+ name='function_name',
1644
+ data_type='string',
1645
+ required=True,
1646
+ default=None,
1647
+ description='Name of the function to call',
1648
+ type=ParameterType.PATH
1649
+ ),
1650
+ APIParameter(
1651
+ name='Authorization',
1652
+ data_type='string',
1653
+ required=False,
1654
+ default=None,
1655
+ description='Bearer token for authentication',
1656
+ type=ParameterType.HEADER
1657
+ )
1658
+ ],
1659
+ response_model=None,
1660
+ tags=['Functions (Dynamic)'],
1661
+ summary='Call function (dynamic HMR)',
1662
+ description='Dynamically routes to any registered function. Supports HMR - function changes are reflected immediately.'
1663
+ )
1664
+ );
1661
1665
  }
1662
1666
 
1663
1667
  """Register endpoints for runtime introspection of available walkers/functions.
@@ -1783,3 +1787,482 @@ impl JacAPIServer.register_dynamic_introspection_endpoints -> None {
1783
1787
  )
1784
1788
  );
1785
1789
  }
1790
+
1791
+ """Get or create the API key manager instance."""
1792
+ impl JacAPIServer.get_api_key_manager -> ApiKeyManager {
1793
+ if self._api_key_manager is None {
1794
+ self._api_key_manager = ApiKeyManager();
1795
+ }
1796
+ return self._api_key_manager;
1797
+ }
1798
+
1799
+ """Create a new API key for the authenticated user."""
1800
+ impl JacAPIServer.create_api_key(
1801
+ name: str, expiry_days: int | None = None, Authorization: str | None = None
1802
+ ) -> TransportResponse {
1803
+ # Validate JWT token to get username
1804
+ token: str | None = None;
1805
+ if Authorization and Authorization.startswith('Bearer ') {
1806
+ token = Authorization[7:];
1807
+ }
1808
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1809
+ if not username {
1810
+ return TransportResponse.fail(
1811
+ code='UNAUTHORIZED',
1812
+ message='Valid authentication required to create API keys',
1813
+ meta=Meta(extra={'http_status': 401})
1814
+ );
1815
+ }
1816
+ return self.get_api_key_manager().create_api_key(
1817
+ username=username, name=name, expiry_days=expiry_days
1818
+ );
1819
+ }
1820
+
1821
+ """List all API keys for the authenticated user."""
1822
+ impl JacAPIServer.list_api_keys(Authorization: str | None = None) -> TransportResponse {
1823
+ # Validate JWT token to get username
1824
+ token: str | None = None;
1825
+ if Authorization and Authorization.startswith('Bearer ') {
1826
+ token = Authorization[7:];
1827
+ }
1828
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1829
+ if not username {
1830
+ return TransportResponse.fail(
1831
+ code='UNAUTHORIZED',
1832
+ message='Valid authentication required to list API keys',
1833
+ meta=Meta(extra={'http_status': 401})
1834
+ );
1835
+ }
1836
+ return self.get_api_key_manager().list_api_keys(username);
1837
+ }
1838
+
1839
+ """Revoke an API key for the authenticated user."""
1840
+ impl JacAPIServer.revoke_api_key(
1841
+ api_key_id: str, Authorization: str | None = None
1842
+ ) -> TransportResponse {
1843
+ # Validate JWT token to get username
1844
+ token: str | None = None;
1845
+ if Authorization and Authorization.startswith('Bearer ') {
1846
+ token = Authorization[7:];
1847
+ }
1848
+ username = self.user_manager.validate_jwt_token(token) if token else None;
1849
+ if not username {
1850
+ return TransportResponse.fail(
1851
+ code='UNAUTHORIZED',
1852
+ message='Valid authentication required to revoke API keys',
1853
+ meta=Meta(extra={'http_status': 401})
1854
+ );
1855
+ }
1856
+ return self.get_api_key_manager().revoke_api_key(username, api_key_id);
1857
+ }
1858
+
1859
+ """Register API key management endpoints."""
1860
+ impl JacAPIServer.register_api_key_endpoints -> None {
1861
+ # Create API key
1862
+ self.server.add_endpoint(
1863
+ JEndPoint(
1864
+ method=HTTPMethod.POST,
1865
+ path='/api-key/create',
1866
+ callback=self.create_api_key,
1867
+ parameters=[
1868
+ APIParameter(
1869
+ name='name',
1870
+ data_type='string',
1871
+ required=True,
1872
+ default=None,
1873
+ description='A friendly name for the API key',
1874
+ type=ParameterType.BODY
1875
+ ),
1876
+ APIParameter(
1877
+ name='expiry_days',
1878
+ data_type='integer',
1879
+ required=False,
1880
+ default=None,
1881
+ description='Number of days until expiry (default from config)',
1882
+ type=ParameterType.BODY
1883
+ ),
1884
+ APIParameter(
1885
+ name='Authorization',
1886
+ data_type='string',
1887
+ required=True,
1888
+ default=None,
1889
+ description='Bearer token for authentication',
1890
+ type=ParameterType.HEADER
1891
+ )
1892
+ ],
1893
+ response_model=None,
1894
+ tags=['API Keys'],
1895
+ summary='Create a new API key',
1896
+ description='Creates a new API key for webhook authentication. The API key is wrapped in a JWT and can be used with HMAC-SHA256 signature verification.'
1897
+ )
1898
+ );
1899
+ # List API keys
1900
+ self.server.add_endpoint(
1901
+ JEndPoint(
1902
+ method=HTTPMethod.GET,
1903
+ path='/api-key/list',
1904
+ callback=self.list_api_keys,
1905
+ parameters=[
1906
+ APIParameter(
1907
+ name='Authorization',
1908
+ data_type='string',
1909
+ required=True,
1910
+ default=None,
1911
+ description='Bearer token for authentication',
1912
+ type=ParameterType.HEADER
1913
+ )
1914
+ ],
1915
+ response_model=None,
1916
+ tags=['API Keys'],
1917
+ summary='List all API keys',
1918
+ description='Lists all API keys for the authenticated user (metadata only, not the actual keys).'
1919
+ )
1920
+ );
1921
+ # Revoke API key
1922
+ self.server.add_endpoint(
1923
+ JEndPoint(
1924
+ method=HTTPMethod.DELETE,
1925
+ path='/api-key/{api_key_id}',
1926
+ callback=self.revoke_api_key,
1927
+ parameters=[
1928
+ APIParameter(
1929
+ name='api_key_id',
1930
+ data_type='string',
1931
+ required=True,
1932
+ default=None,
1933
+ description='The ID of the API key to revoke',
1934
+ type=ParameterType.PATH
1935
+ ),
1936
+ APIParameter(
1937
+ name='Authorization',
1938
+ data_type='string',
1939
+ required=True,
1940
+ default=None,
1941
+ description='Bearer token for authentication',
1942
+ type=ParameterType.HEADER
1943
+ )
1944
+ ],
1945
+ response_model=None,
1946
+ tags=['API Keys'],
1947
+ summary='Revoke an API key',
1948
+ description='Revokes an API key, making it invalid for future webhook requests.'
1949
+ )
1950
+ );
1951
+ }
1952
+
1953
+ """Get the transport type for a walker by checking its restspec."""
1954
+ impl JacAPIServer.get_transport_type_for_walker(walker_name: str) -> str {
1955
+ walkers = self.get_walkers();
1956
+ if walker_name not in walkers {
1957
+ return TransportType.HTTP;
1958
+ }
1959
+ walker_cls = walkers[walker_name];
1960
+ restspec = walker_cls.restspec if walker_cls?.restspec else None;
1961
+ if restspec?.webhook {
1962
+ return TransportType.WEBHOOK;
1963
+ }
1964
+ return TransportType.HTTP;
1965
+ }
1966
+
1967
+ """Create webhook callback for a walker with HMAC-SHA256 signature verification."""
1968
+ impl JacAPIServer.create_webhook_callback(
1969
+ walker_name: str
1970
+ ) -> Callable[..., TransportResponse] {
1971
+ async def callback(request: Request, **kwargs: JsonValue) -> TransportResponse {
1972
+ webhook_config = get_scale_config().get_webhook_config();
1973
+ signature_header = webhook_config.get(
1974
+ 'signature_header', 'X-Webhook-Signature'
1975
+ );
1976
+ verify_signature = webhook_config.get('verify_signature', True);
1977
+
1978
+ # Get API key from header
1979
+ api_key = request.headers.get('X-API-Key');
1980
+ if not api_key {
1981
+ return TransportResponse.fail(
1982
+ code='UNAUTHORIZED',
1983
+ message='Missing X-API-Key header',
1984
+ meta=Meta(extra={'http_status': 401})
1985
+ );
1986
+ }
1987
+
1988
+ # Validate API key and get username
1989
+ username = self.get_api_key_manager().validate_api_key(api_key);
1990
+ if not username {
1991
+ return TransportResponse.fail(
1992
+ code='UNAUTHORIZED',
1993
+ message='Invalid or expired API key',
1994
+ meta=Meta(extra={'http_status': 401})
1995
+ );
1996
+ }
1997
+
1998
+ # Verify HMAC-SHA256 signature if enabled
1999
+ if verify_signature {
2000
+ signature = request.headers.get(signature_header);
2001
+ if not signature {
2002
+ return TransportResponse.fail(
2003
+ code='UNAUTHORIZED',
2004
+ message=f"Missing {signature_header} header",
2005
+ meta=Meta(extra={'http_status': 401})
2006
+ );
2007
+ }
2008
+ # Get raw body for signature verification
2009
+ body = await request.body();
2010
+ extracted_signature = WebhookUtils.extract_signature(signature);
2011
+ # Use the API key as the secret for HMAC verification
2012
+ if not WebhookUtils.verify_signature(body, extracted_signature, api_key) {
2013
+ return TransportResponse.fail(
2014
+ code='UNAUTHORIZED',
2015
+ message='Invalid webhook signature',
2016
+ meta=Meta(extra={'http_status': 401})
2017
+ );
2018
+ }
2019
+ }
2020
+
2021
+ # Parse body for walker fields
2022
+ try {
2023
+ body_bytes = await request.body();
2024
+ body_data = json.loads(body_bytes) if body_bytes else {};
2025
+ } except Exception {
2026
+ body_data = {};
2027
+ }
2028
+
2029
+ walker_kwargs: dict[str, Any] = dict(body_data) if body_data else {};
2030
+
2031
+ # Execute the walker
2032
+ result = await self.execution_manager.spawn_walker(
2033
+ self.get_walkers()[walker_name], walker_kwargs, username
2034
+ );
2035
+
2036
+ if 'error' in result {
2037
+ return TransportResponse.fail(
2038
+ code='EXECUTION_ERROR',
2039
+ message=result.get('error', 'Walker execution failed'),
2040
+ details=result.get('traceback') if 'traceback' in result else None,
2041
+ meta=Meta(extra={'http_status': 500})
2042
+ );
2043
+ }
2044
+
2045
+ return TransportResponse.success(
2046
+ data=result, meta=Meta(extra={'http_status': 200})
2047
+ );
2048
+ }
2049
+ return callback;
2050
+ }
2051
+
2052
+ """Create parameters for webhook endpoint."""
2053
+ impl JacAPIServer.create_webhook_parameters(walker_name: str) -> list[APIParameter] {
2054
+ parameters: list[APIParameter] = [];
2055
+ # API key header (required for webhooks)
2056
+ # Use underscore naming for valid Python identifiers - FastAPI auto-converts to hyphenated headers
2057
+ parameters.append(
2058
+ APIParameter(
2059
+ name='x_api_key',
2060
+ data_type='string',
2061
+ required=True,
2062
+ default=None,
2063
+ description='API key for webhook authentication (X-API-Key header)',
2064
+ type=ParameterType.HEADER
2065
+ )
2066
+ );
2067
+ # Signature header (for HMAC verification)
2068
+ # Use underscore naming for valid Python identifiers
2069
+ parameters.append(
2070
+ APIParameter(
2071
+ name='x_webhook_signature',
2072
+ data_type='string',
2073
+ required=False,
2074
+ default=None,
2075
+ description='HMAC-SHA256 signature of the request body (X-Webhook-Signature header)',
2076
+ type=ParameterType.HEADER
2077
+ )
2078
+ );
2079
+ # Add walker fields as body parameters (excluding transport_type)
2080
+ walker_fields = self.introspector.introspect_walker(
2081
+ self.get_walkers()[walker_name]
2082
+ )['fields'];
2083
+ for field_name in walker_fields {
2084
+ if field_name in ('_jac_spawn_node', ) {
2085
+ continue;
2086
+ }
2087
+ field_type = walker_fields[field_name]['type'];
2088
+ parameters.append(
2089
+ APIParameter(
2090
+ name=field_name,
2091
+ data_type=field_type,
2092
+ required=walker_fields[field_name]['required'],
2093
+ default=walker_fields[field_name]['default'],
2094
+ description=f"Field {field_name} for webhook walker {walker_name}",
2095
+ type=ParameterType.BODY
2096
+ )
2097
+ );
2098
+ }
2099
+ return parameters;
2100
+ }
2101
+
2102
+ """Register webhook endpoints for walkers with transport_type=WEBHOOK."""
2103
+ impl JacAPIServer.register_webhook_endpoints -> None {
2104
+ for walker_name in self.get_walkers() {
2105
+ transport_type = self.get_transport_type_for_walker(walker_name);
2106
+
2107
+ if transport_type == TransportType.WEBHOOK {
2108
+ self.server.add_endpoint(
2109
+ JEndPoint(
2110
+ method=HTTPMethod.POST,
2111
+ path=f"/webhook/{walker_name}",
2112
+ callback=self.create_webhook_callback(walker_name),
2113
+ parameters=self.create_webhook_parameters(walker_name),
2114
+ response_model=None,
2115
+ tags=['Webhooks'],
2116
+ summary=f'Webhook endpoint for {walker_name}',
2117
+ description=f'Webhook endpoint for {walker_name}. Requires API key authentication and HMAC-SHA256 signature verification.'
2118
+ )
2119
+ );
2120
+ }
2121
+ }
2122
+ }
2123
+
2124
+ """Register dynamic webhook endpoint for HMR support."""
2125
+ impl JacAPIServer.register_dynamic_webhook_endpoint -> None {
2126
+ async def dynamic_webhook_handler(
2127
+ request: Request, walker_name: str
2128
+ ) -> TransportResponse {
2129
+ # Reload introspector if files changed (HMR)
2130
+ if self._hmr_pending {
2131
+ self.introspector.load(force_reload=True);
2132
+ self._hmr_pending = False;
2133
+ }
2134
+
2135
+ walkers = self.get_walkers();
2136
+
2137
+ if walker_name not in walkers {
2138
+ return TransportResponse.fail(
2139
+ code='NOT_FOUND',
2140
+ message=f"Webhook walker '{walker_name}' not found",
2141
+ meta=Meta(extra={'http_status': 404})
2142
+ );
2143
+ }
2144
+
2145
+ # Verify this walker is configured for webhook transport
2146
+ transport_type = self.get_transport_type_for_walker(walker_name);
2147
+ if transport_type != TransportType.WEBHOOK {
2148
+ return TransportResponse.fail(
2149
+ code='BAD_REQUEST',
2150
+ message=f"Walker '{walker_name}' is not configured as a webhook. Use /walker/{walker_name} instead.",
2151
+ meta=Meta(extra={'http_status': 400})
2152
+ );
2153
+ }
2154
+
2155
+ webhook_config = get_scale_config().get_webhook_config();
2156
+ signature_header = webhook_config.get(
2157
+ 'signature_header', 'X-Webhook-Signature'
2158
+ );
2159
+ verify_signature = webhook_config.get('verify_signature', True);
2160
+
2161
+ # Get API key from header
2162
+ api_key = request.headers.get('X-API-Key');
2163
+ if not api_key {
2164
+ return TransportResponse.fail(
2165
+ code='UNAUTHORIZED',
2166
+ message='Missing X-API-Key header',
2167
+ meta=Meta(extra={'http_status': 401})
2168
+ );
2169
+ }
2170
+
2171
+ # Validate API key and get username
2172
+ username = self.get_api_key_manager().validate_api_key(api_key);
2173
+ if not username {
2174
+ return TransportResponse.fail(
2175
+ code='UNAUTHORIZED',
2176
+ message='Invalid or expired API key',
2177
+ meta=Meta(extra={'http_status': 401})
2178
+ );
2179
+ }
2180
+
2181
+ # Verify HMAC-SHA256 signature if enabled
2182
+ if verify_signature {
2183
+ signature = request.headers.get(signature_header);
2184
+ if not signature {
2185
+ return TransportResponse.fail(
2186
+ code='UNAUTHORIZED',
2187
+ message=f"Missing {signature_header} header",
2188
+ meta=Meta(extra={'http_status': 401})
2189
+ );
2190
+ }
2191
+ body = await request.body();
2192
+ extracted_signature = WebhookUtils.extract_signature(signature);
2193
+ if not WebhookUtils.verify_signature(body, extracted_signature, api_key) {
2194
+ return TransportResponse.fail(
2195
+ code='UNAUTHORIZED',
2196
+ message='Invalid webhook signature',
2197
+ meta=Meta(extra={'http_status': 401})
2198
+ );
2199
+ }
2200
+ }
2201
+
2202
+ # Parse body for walker fields
2203
+ try {
2204
+ body_bytes = await request.body();
2205
+ body_data = json.loads(body_bytes) if body_bytes else {};
2206
+ } except Exception {
2207
+ body_data = {};
2208
+ }
2209
+
2210
+ walker_kwargs: dict[str, Any] = dict(body_data) if body_data else {};
2211
+
2212
+ # Execute the walker
2213
+ result = await self.execution_manager.spawn_walker(
2214
+ walkers[walker_name], walker_kwargs, username
2215
+ );
2216
+
2217
+ if 'error' in result {
2218
+ return TransportResponse.fail(
2219
+ code='EXECUTION_ERROR',
2220
+ message=result.get('error', 'Walker execution failed'),
2221
+ details=result.get('traceback') if 'traceback' in result else None,
2222
+ meta=Meta(extra={'http_status': 500})
2223
+ );
2224
+ }
2225
+
2226
+ return TransportResponse.success(
2227
+ data=result, meta=Meta(extra={'http_status': 200})
2228
+ );
2229
+ }
2230
+ # Register dynamic webhook route
2231
+ self.server.add_endpoint(
2232
+ JEndPoint(
2233
+ method=HTTPMethod.POST,
2234
+ path='/webhook/{walker_name}',
2235
+ callback=dynamic_webhook_handler,
2236
+ parameters=[
2237
+ APIParameter(
2238
+ name='walker_name',
2239
+ data_type='string',
2240
+ required=True,
2241
+ default=None,
2242
+ description='Name of the webhook walker to execute',
2243
+ type=ParameterType.PATH
2244
+ ),
2245
+ APIParameter(
2246
+ name='x_api_key',
2247
+ data_type='string',
2248
+ required=True,
2249
+ default=None,
2250
+ description='API key for webhook authentication (X-API-Key header)',
2251
+ type=ParameterType.HEADER
2252
+ ),
2253
+ APIParameter(
2254
+ name='x_webhook_signature',
2255
+ data_type='string',
2256
+ required=False,
2257
+ default=None,
2258
+ description='HMAC-SHA256 signature of the request body (X-Webhook-Signature header)',
2259
+ type=ParameterType.HEADER
2260
+ )
2261
+ ],
2262
+ response_model=None,
2263
+ tags=['Webhooks (Dynamic)'],
2264
+ summary='Execute webhook walker (dynamic HMR)',
2265
+ description='Dynamically routes to webhook walkers. Supports HMR - walker changes are reflected immediately.'
2266
+ )
2267
+ );
2268
+ }