jac-scale 0.1.3__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.
@@ -0,0 +1,212 @@
1
+ """Implementation of Webhook utilities for Jac Scale.
2
+
3
+ This module implements HMAC-SHA256 signature generation/verification
4
+ and API key management for webhook authentication.
5
+ """
6
+ import hmac;
7
+ import hashlib;
8
+ import secrets;
9
+ import jwt;
10
+ import from datetime { UTC, datetime, timedelta }
11
+ import from typing { Any }
12
+ import from jaclang.runtimelib.transport { TransportResponse, Meta }
13
+ import from jac_scale.config_loader { get_scale_config }
14
+
15
+ # Load JWT config for signing API keys
16
+ glob _jwt_config = get_scale_config().get_jwt_config(),
17
+ JWT_SECRET = _jwt_config['secret'],
18
+ JWT_ALGORITHM = _jwt_config['algorithm'];
19
+
20
+ """Generate HMAC-SHA256 signature for webhook payload."""
21
+ impl WebhookUtils.generate_signature(payload: bytes, secret: str) -> str {
22
+ return hmac.new(secret.encode('utf-8'), payload, hashlib.sha256).hexdigest();
23
+ }
24
+
25
+ """Verify HMAC-SHA256 signature for webhook payload."""
26
+ impl WebhookUtils.verify_signature(payload: bytes, signature: str, secret: str) -> bool {
27
+ expected_signature = WebhookUtils.generate_signature(payload, secret);
28
+ # Use constant-time comparison to prevent timing attacks
29
+ return hmac.compare_digest(signature.lower(), expected_signature.lower());
30
+ }
31
+
32
+ """Generate a cryptographically secure API key."""
33
+ impl WebhookUtils.generate_api_key -> str {
34
+ return secrets.token_hex(32);
35
+ }
36
+
37
+ """Create a JWT-wrapped API key token."""
38
+ impl WebhookUtils.create_api_key_token(
39
+ api_key_id: str, username: str, name: str, expiry_days: int | None = None
40
+ ) -> str {
41
+ _webhook_config = get_scale_config().get_webhook_config();
42
+ default_expiry = _webhook_config.get('api_key_expiry_days', 365);
43
+ payload: dict[str, Any] = {
44
+ 'type': 'api_key',
45
+ 'api_key_id': api_key_id,
46
+ 'sub': username,
47
+ 'name': name,
48
+ 'iat': datetime.now(UTC)
49
+ };
50
+ # Add expiry if specified
51
+ if expiry_days is not None {
52
+ payload['exp'] = datetime.now(UTC) + timedelta(days=expiry_days);
53
+ } elif default_expiry > 0 {
54
+ payload['exp'] = datetime.now(UTC) + timedelta(days=default_expiry);
55
+ }
56
+ # If expiry_days is 0 or negative, no expiry is set (permanent key)
57
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM);
58
+ }
59
+
60
+ """Validate an API key token and extract user information."""
61
+ impl WebhookUtils.validate_api_key(api_key: str) -> dict[str, str] | None {
62
+ try {
63
+ payload = jwt.decode(api_key, JWT_SECRET, algorithms=[JWT_ALGORITHM]);
64
+
65
+ # Verify this is an API key token
66
+ if payload.get('type') != 'api_key' {
67
+ return None;
68
+ }
69
+
70
+ return {
71
+ 'username': payload.get('sub', ''),
72
+ 'api_key_id': payload.get('api_key_id', ''),
73
+ 'name': payload.get('name', '')
74
+ };
75
+ } except jwt.ExpiredSignatureError {
76
+ return None;
77
+ } except jwt.InvalidTokenError {
78
+ return None;
79
+ }
80
+ }
81
+
82
+ """Extract signature from request header value."""
83
+ impl WebhookUtils.extract_signature(header_value: str) -> str {
84
+ # Handle prefixed signatures like "sha256=abc123..."
85
+ if '=' in header_value {
86
+ parts = header_value.split('=', 1);
87
+ if len(parts) == 2 {
88
+ return parts[1];
89
+ }
90
+ }
91
+ return header_value;
92
+ }
93
+
94
+ """Create a new API key for a user."""
95
+ impl ApiKeyManager.create_api_key(
96
+ username: str, name: str, expiry_days: int | None = None
97
+ ) -> TransportResponse {
98
+ # Generate unique API key ID
99
+ api_key_id = secrets.token_hex(16);
100
+ # Create the JWT-wrapped API key
101
+ api_key = WebhookUtils.create_api_key_token(
102
+ api_key_id=api_key_id, username=username, name=name, expiry_days=expiry_days
103
+ );
104
+ created_at = datetime.now(UTC);
105
+ expires_at: datetime | None = None;
106
+ _webhook_config = get_scale_config().get_webhook_config();
107
+ default_expiry = _webhook_config.get('api_key_expiry_days', 365);
108
+ if expiry_days is not None and expiry_days > 0 {
109
+ expires_at = created_at + timedelta(days=expiry_days);
110
+ } elif default_expiry > 0 {
111
+ expires_at = created_at + timedelta(days=default_expiry);
112
+ }
113
+ # Store key metadata (not the key itself for security)
114
+ self._api_keys[api_key_id] = {
115
+ 'username': username,
116
+ 'name': name,
117
+ 'created_at': created_at.isoformat(),
118
+ 'expires_at': expires_at.isoformat() if expires_at else None,
119
+ 'revoked': False
120
+ };
121
+ # Track keys by user
122
+ if username not in self._user_keys {
123
+ self._user_keys[username] = [];
124
+ }
125
+ self._user_keys[username].append(api_key_id);
126
+ return TransportResponse.success(
127
+ data={
128
+ 'api_key': api_key,
129
+ 'api_key_id': api_key_id,
130
+ 'name': name,
131
+ 'created_at': created_at.isoformat(),
132
+ 'expires_at': expires_at.isoformat() if expires_at else None
133
+ },
134
+ meta=Meta(extra={'http_status': 201})
135
+ );
136
+ }
137
+
138
+ """List all API keys for a user (metadata only, not the keys)."""
139
+ impl ApiKeyManager.list_api_keys(username: str) -> TransportResponse {
140
+ user_key_ids = self._user_keys.get(username, []);
141
+ keys: list[dict[str, Any]] = [];
142
+ for key_id in user_key_ids {
143
+ key_info = self._api_keys.get(key_id);
144
+ if key_info and not key_info.get('revoked', False) {
145
+ keys.append(
146
+ {
147
+ 'api_key_id': key_id,
148
+ 'name': key_info.get('name', ''),
149
+ 'created_at': key_info.get('created_at'),
150
+ 'expires_at': key_info.get('expires_at'),
151
+ 'active': True
152
+ }
153
+ );
154
+ }
155
+ }
156
+ return TransportResponse.success(
157
+ data={'api_keys': keys}, meta=Meta(extra={'http_status': 200})
158
+ );
159
+ }
160
+
161
+ """Revoke an API key."""
162
+ impl ApiKeyManager.revoke_api_key(username: str, api_key_id: str) -> TransportResponse {
163
+ # Check if key exists
164
+ if api_key_id not in self._api_keys {
165
+ return TransportResponse.fail(
166
+ code='NOT_FOUND',
167
+ message=f"API key '{api_key_id}' not found",
168
+ meta=Meta(extra={'http_status': 404})
169
+ );
170
+ }
171
+ # Check if key belongs to user
172
+ key_info = self._api_keys[api_key_id];
173
+ if key_info.get('username') != username {
174
+ return TransportResponse.fail(
175
+ code='FORBIDDEN',
176
+ message='Cannot revoke API key owned by another user',
177
+ meta=Meta(extra={'http_status': 403})
178
+ );
179
+ }
180
+ # Mark as revoked
181
+ self._api_keys[api_key_id]['revoked'] = True;
182
+ return TransportResponse.success(
183
+ data={'message': f"API key '{api_key_id}' has been revoked"},
184
+ meta=Meta(extra={'http_status': 200})
185
+ );
186
+ }
187
+
188
+ """Validate an API key and return the associated username."""
189
+ impl ApiKeyManager.validate_api_key(api_key: str) -> str | None {
190
+ # Decode the JWT to get key info
191
+ key_info = WebhookUtils.validate_api_key(api_key);
192
+ if not key_info {
193
+ return None;
194
+ }
195
+ api_key_id = key_info.get('api_key_id', '');
196
+ # Check if key is still active (not revoked)
197
+ if not self.is_key_active(api_key_id) {
198
+ return None;
199
+ }
200
+ return key_info.get('username');
201
+ }
202
+
203
+ """Check if an API key ID exists and is not revoked."""
204
+ impl ApiKeyManager.is_key_active(api_key_id: str) -> bool {
205
+ key_info = self._api_keys.get(api_key_id);
206
+ if not key_info {
207
+ # Key not in our store - could be from before server restart
208
+ # Allow it if JWT is valid (handled by validate_api_key)
209
+ return True;
210
+ }
211
+ return not key_info.get('revoked', False);
212
+ }
@@ -379,6 +379,10 @@ impl JFastApiServer._create_endpoint_function(
379
379
  if accepts_kwargs {
380
380
  param_strs.append('request: Request');
381
381
  param_mapping['__request__'] = 'request';
382
+ # If the callback also explicitly accepts a request parameter, map it
383
+ if needs_request {
384
+ param_mapping['request'] = 'request';
385
+ }
382
386
  } elif needs_request {
383
387
  param_strs.append('request: Request');
384
388
  param_mapping['request'] = 'request';
@@ -34,7 +34,7 @@ class JacScalePluginConfig {
34
34
  "nested": {
35
35
  "secret": {
36
36
  "type": "string",
37
- "default": "supersecretkey",
37
+ "default": "supersecretkey_for_testing_only!",
38
38
  "description": "Secret key for JWT signing"
39
39
  },
40
40
  "algorithm": {
jac_scale/serve.jac CHANGED
@@ -6,7 +6,9 @@ import from pathlib { Path }
6
6
  import from pydantic { BaseModel, Field }
7
7
  import from typing { Any }
8
8
  import jwt;
9
+ import json;
9
10
  import from os { getenv }
11
+ import from fastapi { Request }
10
12
  import from fastapi.middleware.cors { CORSMiddleware }
11
13
  import from fastapi.responses { HTMLResponse, JSONResponse, Response, RedirectResponse }
12
14
  import from jac_scale.jserver.jfast_api { JFastApiServer }
@@ -24,6 +26,7 @@ import from enum { StrEnum }
24
26
  import from fastapi_sso.sso.google { GoogleSSO }
25
27
  import from jac_scale.utils { generate_random_password }
26
28
  import from jac_scale.config_loader { get_scale_config }
29
+ import from jac_scale.webhook { ApiKeyManager, WebhookUtils }
27
30
  import from typing { AsyncGenerator }
28
31
  import from inspect { isgenerator }
29
32
  import from fastapi.responses { StreamingResponse }
@@ -41,10 +44,13 @@ enum Platforms ( StrEnum ) { GOOGLE = 'google' }
41
44
 
42
45
  enum Operations ( StrEnum ) { LOGIN = 'login', REGISTER = 'register' }
43
46
 
47
+ enum TransportType ( StrEnum ) { HTTP = 'http', WEBHOOK = 'webhook' }
48
+
44
49
  obj JacAPIServer(JServer) {
45
50
  # HMR (Hot Module Replacement) support fields
46
51
  has _hmr_pending: bool = False,
47
- _hot_reloader: Any | None = None;
52
+ _hot_reloader: Any | None = None,
53
+ _api_key_manager: ApiKeyManager | None = None;
48
54
 
49
55
  def postinit -> None;
50
56
  def login(username: str, password: str) -> TransportResponse;
@@ -72,12 +78,15 @@ obj JacAPIServer(JServer) {
72
78
  ) -> Callable[..., TransportResponse];
73
79
 
74
80
  def create_walker_parameters(
75
- walker_name: str, invoke_on_root: bool
81
+ walker_name: str, invoke_on_root: bool, method: HTTPMethod = HTTPMethod.POST
76
82
  ) -> list[APIParameter];
77
83
 
78
84
  def register_walkers_endpoints -> None;
79
85
  def create_function_callback(func_name: str) -> Callable[..., TransportResponse];
80
- def create_function_parameters(func_name: str) -> list[APIParameter];
86
+ def create_function_parameters(
87
+ func_name: str, method: HTTPMethod = HTTPMethod.POST
88
+ ) -> list[APIParameter];
89
+
81
90
  def register_functions_endpoints -> None;
82
91
  def render_page_callback -> Callable[..., HTMLResponse];
83
92
  def render_base_route_callback(app_name: str) -> Callable[..., HTMLResponse];
@@ -89,12 +98,29 @@ obj JacAPIServer(JServer) {
89
98
  def register_root_asset_endpoint -> None;
90
99
  def serve_root_asset(file_path: str) -> Response;
91
100
  def _configure_openapi_security -> None;
92
- def start(dev: bool = False) -> None;
101
+ def start(dev: bool = False, no_client: bool = False) -> None;
93
102
  # HMR (Hot Module Replacement) dynamic routing methods
94
103
  def enable_hmr(hot_reloader: Any) -> None;
95
104
  def register_dynamic_walker_endpoint -> None;
96
105
  def register_dynamic_function_endpoint -> None;
97
106
  def register_dynamic_introspection_endpoints -> None;
107
+ # Webhook support methods
108
+ def get_api_key_manager -> ApiKeyManager;
109
+ def create_api_key(
110
+ name: str, expiry_days: int | None = None, Authorization: str | None = None
111
+ ) -> TransportResponse;
112
+
113
+ def list_api_keys(Authorization: str | None = None) -> TransportResponse;
114
+ def revoke_api_key(
115
+ api_key_id: str, Authorization: str | None = None
116
+ ) -> TransportResponse;
117
+
118
+ def register_api_key_endpoints -> None;
119
+ def register_webhook_endpoints -> None;
120
+ def create_webhook_callback(walker_name: str) -> Callable[..., TransportResponse];
121
+ def create_webhook_parameters(walker_name: str) -> list[APIParameter];
122
+ def register_dynamic_webhook_endpoint -> None;
123
+ def get_transport_type_for_walker(walker_name: str) -> str;
98
124
  }
99
125
 
100
126
  class UpdateUsernameRequest(BaseModel) {
@@ -186,3 +186,63 @@ walker : pub PublicFileUpload {
186
186
  };
187
187
  }
188
188
  }
189
+
190
+ # ============================================================================
191
+ # Webhook Walkers
192
+ # ============================================================================
193
+ """Test 1: Webhook walker with multiple fields using @restspec(webhook=True).
194
+ This walker should be accessible ONLY via /webhook/PaymentReceived endpoint."""
195
+ @restspec(webhook=True)
196
+ walker PaymentReceived {
197
+ has payment_id: str,
198
+ order_id: str,
199
+ amount: float,
200
+ currency: str = 'USD';
201
+
202
+ can process with `root entry {
203
+ response = {
204
+ "status": "success",
205
+ "message": f"Payment {self.payment_id} processed successfully.",
206
+ "payment_id": self.payment_id,
207
+ "order_id": self.order_id,
208
+ "amount": self.amount,
209
+ "currency": self.currency
210
+ };
211
+ report response;
212
+ }
213
+ }
214
+
215
+ """Test 2: Normal walker without @restspec(webhook=True) (defaults to HTTP).
216
+ This walker should be accessible via /walker/NormalPayment endpoint, NOT /webhook/."""
217
+ walker NormalPayment {
218
+ has payment_id: str,
219
+ order_id: str,
220
+ amount: float,
221
+ currency: str = 'USD';
222
+
223
+ can process with `root entry {
224
+ response = {
225
+ "status": "success",
226
+ "message": f"Normal payment {self.payment_id} processed.",
227
+ "payment_id": self.payment_id,
228
+ "order_id": self.order_id,
229
+ "amount": self.amount,
230
+ "currency": self.currency,
231
+ "transport": "http"
232
+ };
233
+ report response;
234
+ }
235
+ }
236
+
237
+ """Test 3: Minimal webhook walker with ONLY @restspec(webhook=True).
238
+ This walker should be accessible ONLY via /webhook/MinimalWebhook endpoint."""
239
+ @restspec(webhook=True)
240
+ walker MinimalWebhook {
241
+ can process with `root entry {
242
+ report {
243
+ "status": "received",
244
+ "message": "Minimal webhook executed successfully",
245
+ "transport": "webhook"
246
+ };
247
+ }
248
+ }
@@ -35,3 +35,54 @@ def get_func() -> dict {
35
35
  def custom_path_func() -> dict {
36
36
  return {"message": "custom_path_func executed", "path": "/custom/func"};
37
37
  }
38
+
39
+ """Walker with POST method via restspec decorator."""
40
+ @restspec(method=HTTPMethod.POST)
41
+ walker : pub PostWalker {
42
+ can post_data with `root entry {
43
+ report {"message": "PostWalker executed", "method": "POST"};
44
+ }
45
+ }
46
+
47
+ """Walker with default method (POST)."""
48
+ walker : pub DefaultWalker {
49
+ can post_data with `root entry {
50
+ report {"message": "DefaultWalker executed", "method": "DEFAULT"};
51
+ }
52
+ }
53
+
54
+ """Function with POST method via restspec decorator."""
55
+ @restspec(method=HTTPMethod.POST)
56
+ def post_func() -> dict {
57
+ return {"message": "post_func executed", "method": "POST"};
58
+ }
59
+
60
+ """Function with default method (POST)."""
61
+ def default_func() -> dict {
62
+ return {"message": "default_func executed", "method": "DEFAULT"};
63
+ }
64
+
65
+ """Walker with GET method and parameters."""
66
+ @restspec(method=HTTPMethod.GET)
67
+ walker : pub GetWalkerWithParams {
68
+ has name: str;
69
+ has age: int;
70
+
71
+ can get_data with `root entry {
72
+ report {
73
+ "message": "GetWalkerWithParams executed",
74
+ "name": self.name,
75
+ "age": self.age
76
+ };
77
+ }
78
+ }
79
+
80
+ """Function with GET method and parameters."""
81
+ @restspec(method=HTTPMethod.GET)
82
+ def get_func_with_params(name: str, age: int) -> dict {
83
+ return {
84
+ "message": "get_func_with_params executed",
85
+ "name": name,
86
+ "age": age
87
+ };
88
+ }
@@ -139,6 +139,22 @@ class TestRestSpec:
139
139
  assert data["reports"][0]["message"] == "CustomPathWalker executed"
140
140
  assert data["reports"][0]["path"] == "/custom/walker"
141
141
 
142
+ def test_post_method_walker(self) -> None:
143
+ """Test walker with explicit POST method."""
144
+ response = requests.post(f"{self.base_url}/walker/PostWalker", timeout=5)
145
+ assert response.status_code == 200
146
+ data = self._extract_data(response.json())
147
+ assert data["reports"][0]["message"] == "PostWalker executed"
148
+ assert data["reports"][0]["method"] == "POST"
149
+
150
+ def test_default_method_walker(self) -> None:
151
+ """Test walker with default method (POST)."""
152
+ response = requests.post(f"{self.base_url}/walker/DefaultWalker", timeout=5)
153
+ assert response.status_code == 200
154
+ data = self._extract_data(response.json())
155
+ assert data["reports"][0]["message"] == "DefaultWalker executed"
156
+ assert data["reports"][0]["method"] == "DEFAULT"
157
+
142
158
  def test_custom_method_func(self) -> None:
143
159
  """Test function with custom GET method."""
144
160
  requests.post(
@@ -176,6 +192,79 @@ class TestRestSpec:
176
192
  assert data["result"]["message"] == "custom_path_func executed"
177
193
  assert data["result"]["path"] == "/custom/func"
178
194
 
195
+ def test_post_method_func(self) -> None:
196
+ """Test function with explicit POST method."""
197
+ # Use existing user token if possible, but simplest is fresh login
198
+ login = requests.post(
199
+ f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
200
+ )
201
+ token = self._extract_data(login.json())["token"]
202
+
203
+ response = requests.post(
204
+ f"{self.base_url}/function/post_func",
205
+ headers={"Authorization": f"Bearer {token}"},
206
+ timeout=5,
207
+ )
208
+ assert response.status_code == 200
209
+ data = self._extract_data(response.json())
210
+ assert data["result"]["message"] == "post_func executed"
211
+ assert data["result"]["method"] == "POST"
212
+
213
+ def test_default_method_func(self) -> None:
214
+ """Test function with default method (POST)."""
215
+ # Use existing user token if possible, but simplest is fresh login
216
+ login = requests.post(
217
+ f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
218
+ )
219
+ token = self._extract_data(login.json())["token"]
220
+
221
+ response = requests.post(
222
+ f"{self.base_url}/function/default_func",
223
+ headers={"Authorization": f"Bearer {token}"},
224
+ timeout=5,
225
+ )
226
+ assert response.status_code == 200
227
+ data = self._extract_data(response.json())
228
+ assert data["result"]["message"] == "default_func executed"
229
+ assert data["result"]["method"] == "DEFAULT"
230
+
231
+ def test_get_walker_with_params(self) -> None:
232
+ """Test walker with GET method and query parameters."""
233
+ # Parameters should be passed as query string
234
+ params: dict[str, str | int] = {"name": "Alice", "age": 30}
235
+ response = requests.get(
236
+ f"{self.base_url}/walker/GetWalkerWithParams",
237
+ params=params,
238
+ timeout=5,
239
+ )
240
+ assert response.status_code == 200
241
+ data = self._extract_data(response.json())
242
+ assert data["reports"][0]["message"] == "GetWalkerWithParams executed"
243
+ assert data["reports"][0]["name"] == "Alice"
244
+ assert data["reports"][0]["age"] == 30
245
+
246
+ def test_get_func_with_params(self) -> None:
247
+ """Test function with GET method and query parameters."""
248
+ # Use existing user token if possible, but simplest is fresh login
249
+ login = requests.post(
250
+ f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
251
+ )
252
+ token = self._extract_data(login.json())["token"]
253
+
254
+ # Parameters should be passed as query string
255
+ params: dict[str, str | int] = {"name": "Bob", "age": 40}
256
+ response = requests.get(
257
+ f"{self.base_url}/function/get_func_with_params",
258
+ headers={"Authorization": f"Bearer {token}"},
259
+ params=params,
260
+ timeout=5,
261
+ )
262
+ assert response.status_code == 200
263
+ data = self._extract_data(response.json())
264
+ assert data["result"]["message"] == "get_func_with_params executed"
265
+ assert data["result"]["name"] == "Bob"
266
+ assert data["result"]["age"] == 40
267
+
179
268
  def test_openapi_specs(self) -> None:
180
269
  """Verify OpenAPI documentation reflects custom paths and methods."""
181
270
  spec = requests.get(f"{self.base_url}/openapi.json").json()
@@ -190,3 +279,11 @@ class TestRestSpec:
190
279
  assert "/walker/GetWalker" in paths
191
280
  assert "get" in paths["/walker/GetWalker"]
192
281
  assert "post" not in paths["/walker/GetWalker"]
282
+
283
+ assert "/walker/PostWalker" in paths
284
+ assert "post" in paths["/walker/PostWalker"]
285
+ assert "get" not in paths["/walker/PostWalker"]
286
+
287
+ assert "/walker/DefaultWalker" in paths
288
+ assert "post" in paths["/walker/DefaultWalker"]
289
+ assert "get" not in paths["/walker/DefaultWalker"]