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
@@ -0,0 +1,349 @@
1
+ impl JacScaleUserManager.postinit -> None {
2
+ super.postinit();
3
+ # Create SSO accounts table for tracking linked external identities
4
+ self._ensure_connection();
5
+ self._conn.execute(
6
+ """
7
+ CREATE TABLE IF NOT EXISTS sso_accounts (
8
+ user_id TEXT NOT NULL,
9
+ platform TEXT NOT NULL,
10
+ external_id TEXT NOT NULL,
11
+ email TEXT,
12
+ linked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
13
+ PRIMARY KEY (platform, external_id),
14
+ FOREIGN KEY (user_id) REFERENCES users(username) ON DELETE CASCADE
15
+ )
16
+ """
17
+ );
18
+ # Create index for faster lookups by user_id
19
+ self._conn.execute(
20
+ "CREATE INDEX IF NOT EXISTS idx_sso_accounts_user_id ON sso_accounts(user_id)"
21
+ );
22
+ self._conn.commit();
23
+ # Load SSO config
24
+ sso_config = get_scale_config().get_sso_config();
25
+ for platform in Platforms {
26
+ key = platform.lower();
27
+ platform_config = sso_config.get(key, {});
28
+
29
+ client_id = platform_config.get('client_id', '');
30
+ client_secret = platform_config.get('client_secret', '');
31
+
32
+ if not client_id or not client_secret {
33
+ continue;
34
+ }
35
+
36
+ self.SUPPORTED_PLATFORMS[platform.value] = {
37
+ "client_id": client_id,
38
+ "client_secret": client_secret
39
+ };
40
+ }
41
+ }
42
+
43
+ impl JacScaleUserManager.create_jwt_token(username: str) -> str {
44
+ now = datetime.now(UTC);
45
+ payload: dict[(str, Any)] = {
46
+ 'username': username,
47
+ 'exp': (now + timedelta(days=JWT_EXP_DELTA_DAYS)),
48
+ 'iat': now.timestamp()
49
+ };
50
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM);
51
+ }
52
+
53
+ impl JacScaleUserManager.validate_jwt_token(token: str) -> (str | None) {
54
+ try {
55
+ decoded = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]);
56
+ return decoded['username'];
57
+ } except Exception {
58
+ return None;
59
+ }
60
+ }
61
+
62
+ impl JacScaleUserManager.refresh_jwt_token(token: str) -> (str | None) {
63
+ try {
64
+ decoded = jwt.decode(
65
+ token, JWT_SECRET, algorithms=[JWT_ALGORITHM], options={"verify_exp": True}
66
+ );
67
+ username = decoded.get('username');
68
+
69
+ if not username {
70
+ return None;
71
+ }
72
+
73
+ return self.create_jwt_token(username);
74
+ } except Exception {
75
+ return None;
76
+ }
77
+ }
78
+
79
+ impl JacScaleUserManager.validate_token(token: str) -> (str | None) {
80
+ return self.validate_jwt_token(token);
81
+ }
82
+
83
+ impl JacScaleUserManager.get_sso(platform: str, operation: str) -> (SSOProvider | None) {
84
+ if (platform not in self.SUPPORTED_PLATFORMS) {
85
+ return None;
86
+ }
87
+ credentials = self.SUPPORTED_PLATFORMS[platform];
88
+ redirect_uri = f"{SSO_HOST}/{platform}/{operation}/callback";
89
+ if (platform == Platforms.GOOGLE.value) {
90
+ import from jac_scale.google_sso_provider { GoogleSSOProvider }
91
+ return GoogleSSOProvider(
92
+ client_id=credentials['client_id'],
93
+ client_secret=credentials['client_secret'],
94
+ redirect_uri=redirect_uri,
95
+ allow_insecure_http=True
96
+ );
97
+ }
98
+ return None;
99
+ }
100
+
101
+ impl JacScaleUserManager.sso_initiate(
102
+ platform: str, operation: str
103
+ ) -> (Response | TransportResponse) {
104
+ import from jaclang.runtimelib.server { JsonValue }
105
+ if (platform not in [p.value for p in Platforms]) {
106
+ return TransportResponse.fail(
107
+ code='INVALID_PLATFORM',
108
+ message=f"Invalid platform '{platform}'. Supported platforms: {', '.join(
109
+ [p.value for p in Platforms]
110
+ )}",
111
+ meta=Meta(extra={'http_status': 400})
112
+ );
113
+ }
114
+ if (platform not in self.SUPPORTED_PLATFORMS) {
115
+ return TransportResponse.fail(
116
+ code='SSO_NOT_CONFIGURED',
117
+ message=f"SSO for platform '{platform}' is not configured. Please set SSO_{platform.upper()}_CLIENT_ID and SSO_{platform.upper()}_CLIENT_SECRET environment variables.",
118
+ meta=Meta(extra={'http_status': 501})
119
+ );
120
+ }
121
+ if (operation not in [o.value for o in Operations]) {
122
+ return TransportResponse.fail(
123
+ code='INVALID_OPERATION',
124
+ message=f"Invalid operation '{operation}'. Must be 'login' or 'register'",
125
+ meta=Meta(extra={'http_status': 400})
126
+ );
127
+ }
128
+ sso = self.get_sso(platform, operation);
129
+ if not sso {
130
+ return TransportResponse.fail(
131
+ code='SSO_INIT_FAILED',
132
+ message=f"Failed to initialize SSO for platform '{platform}'",
133
+ meta=Meta(extra={'http_status': 500})
134
+ );
135
+ }
136
+ return await sso.initiate_auth(operation);
137
+ }
138
+
139
+ impl JacScaleUserManager.sso_callback(
140
+ request: Request, platform: str, operation: str
141
+ ) -> TransportResponse {
142
+ import from jaclang.runtimelib.server { JsonValue }
143
+ if (platform not in [p.value for p in Platforms]) {
144
+ return TransportResponse.fail(
145
+ code='INVALID_PLATFORM',
146
+ message=f"Invalid platform '{platform}'. Supported platforms: {', '.join(
147
+ [p.value for p in Platforms]
148
+ )}",
149
+ meta=Meta(extra={'http_status': 400})
150
+ );
151
+ }
152
+ if (platform not in self.SUPPORTED_PLATFORMS) {
153
+ return TransportResponse.fail(
154
+ code='SSO_NOT_CONFIGURED',
155
+ message=f"SSO for platform '{platform}' is not configured. Please set SSO_{platform.upper()}_CLIENT_ID and SSO_{platform.upper()}_CLIENT_SECRET environment variables.",
156
+ meta=Meta(extra={'http_status': 501})
157
+ );
158
+ }
159
+ if (operation not in [o.value for o in Operations]) {
160
+ return TransportResponse.fail(
161
+ code='INVALID_OPERATION',
162
+ message=f"Invalid operation '{operation}'. Must be 'login' or 'register'",
163
+ meta=Meta(extra={'http_status': 400})
164
+ );
165
+ }
166
+ sso = self.get_sso(platform, operation);
167
+ if not sso {
168
+ return TransportResponse.fail(
169
+ code='SSO_INIT_FAILED',
170
+ message=f"Failed to initialize SSO for platform '{platform}'",
171
+ meta=Meta(extra={'http_status': 500})
172
+ );
173
+ }
174
+ try {
175
+ user_info = await sso.handle_callback(request);
176
+ email = user_info.email;
177
+ if not email {
178
+ return TransportResponse.fail(
179
+ code='EMAIL_MISSING',
180
+ message=f"Email not provided by {platform}",
181
+ meta=Meta(extra={'http_status': 400})
182
+ );
183
+ }
184
+ if (operation == Operations.LOGIN.value) {
185
+ user = self.get_user(email);
186
+ if not user {
187
+ return TransportResponse.fail(
188
+ code='USER_NOT_FOUND',
189
+ message='User not found. Please register first.',
190
+ meta=Meta(extra={'http_status': 404})
191
+ );
192
+ }
193
+ token = self.create_jwt_token(email);
194
+ return TransportResponse.success(
195
+ data={
196
+ 'message': 'Login successful',
197
+ 'email': email,
198
+ 'token': token,
199
+ 'platform': platform,
200
+ 'user': dict[(str, JsonValue)](user)
201
+ },
202
+ meta=Meta(extra={'http_status': 200})
203
+ );
204
+ } elif (operation == Operations.REGISTER.value) {
205
+ existing_user = self.get_user(email);
206
+ if existing_user {
207
+ return TransportResponse.fail(
208
+ code='USER_EXISTS',
209
+ message='User already exists. Please login instead.',
210
+ meta=Meta(extra={'http_status': 400})
211
+ );
212
+ }
213
+ random_password = generate_random_password();
214
+ result = self.create_user(email, random_password);
215
+ if ('error' in result) {
216
+ return TransportResponse.fail(
217
+ code='USER_CREATION_FAILED',
218
+ message=result.get('error', 'User creation failed'),
219
+ meta=Meta(extra={'http_status': 400})
220
+ );
221
+ }
222
+ token = self.create_jwt_token(email);
223
+ result['token'] = token;
224
+ result['platform'] = platform;
225
+ return TransportResponse.success(
226
+ data=result, meta=Meta(extra={'http_status': 201})
227
+ );
228
+ }
229
+ } except Exception as e {
230
+ return TransportResponse.fail(
231
+ code='AUTHENTICATION_FAILED',
232
+ message=f"Authentication failed: {str(e)}",
233
+ meta=Meta(extra={'http_status': 500})
234
+ );
235
+ }
236
+ return TransportResponse.fail(
237
+ code='UNKNOWN_ERROR',
238
+ message='An unknown error occurred',
239
+ meta=Meta(extra={'http_status': 500})
240
+ );
241
+ }
242
+
243
+ # SSO Account Linking Methods
244
+ """Link an SSO account to a user.
245
+
246
+ This allows users to have multiple SSO providers linked to their account.
247
+ """
248
+ impl JacScaleUserManager.link_sso_account(
249
+ user_id: str, platform: str, external_id: str, email: str
250
+ ) -> dict[str, str] {
251
+ self._ensure_connection();
252
+ # Check if this SSO account is already linked to another user
253
+ cursor = self._conn.execute(
254
+ "SELECT user_id FROM sso_accounts WHERE platform = ? AND external_id = ?",
255
+ (platform, external_id)
256
+ );
257
+ existing = cursor.fetchone();
258
+ if existing {
259
+ if existing[0] == user_id {
260
+ return {'message': 'SSO account already linked to this user'};
261
+ }
262
+ return {'error': 'This SSO account is already linked to another user'};
263
+ }
264
+ # Link the SSO account
265
+ self._conn.execute(
266
+ """
267
+ INSERT INTO sso_accounts (user_id, platform, external_id, email)
268
+ VALUES (?, ?, ?, ?)
269
+ """,
270
+ (user_id, platform, external_id, email)
271
+ );
272
+ self._conn.commit();
273
+ return {
274
+ 'message': 'SSO account linked successfully',
275
+ 'user_id': user_id,
276
+ 'platform': platform
277
+ };
278
+ }
279
+
280
+ """Unlink an SSO account from a user."""
281
+ impl JacScaleUserManager.unlink_sso_account(
282
+ user_id: str, platform: str
283
+ ) -> dict[str, str] {
284
+ self._ensure_connection();
285
+ cursor = self._conn.execute(
286
+ "DELETE FROM sso_accounts WHERE user_id = ? AND platform = ?",
287
+ (user_id, platform)
288
+ );
289
+ self._conn.commit();
290
+ if cursor.rowcount == 0 {
291
+ return {'error': 'SSO account not found'};
292
+ }
293
+ return {
294
+ 'message': 'SSO account unlinked successfully',
295
+ 'user_id': user_id,
296
+ 'platform': platform
297
+ };
298
+ }
299
+
300
+ """Get all SSO accounts linked to a user."""
301
+ impl JacScaleUserManager.get_sso_accounts(user_id: str) -> list[dict[str, str]] {
302
+ self._ensure_connection();
303
+ cursor = self._conn.execute(
304
+ """
305
+ SELECT platform, external_id, email, linked_at
306
+ FROM sso_accounts
307
+ WHERE user_id = ?
308
+ ORDER BY linked_at DESC
309
+ """,
310
+ (user_id, )
311
+ );
312
+ accounts = [];
313
+ for row in cursor.fetchall() {
314
+ accounts.append(
315
+ {
316
+ 'platform': row[0],
317
+ 'external_id': row[1],
318
+ 'email': row[2],
319
+ 'linked_at': row[3]
320
+ }
321
+ );
322
+ }
323
+ return accounts;
324
+ }
325
+
326
+ """Find a user by their SSO account credentials.
327
+
328
+ This is used during SSO login to find the user associated with
329
+ an external SSO identity.
330
+ """
331
+ impl JacScaleUserManager.get_user_by_sso(
332
+ platform: str, external_id: str
333
+ ) -> (dict[str, str] | None) {
334
+ self._ensure_connection();
335
+ cursor = self._conn.execute(
336
+ """
337
+ SELECT sa.user_id, u.token, u.root_id
338
+ FROM sso_accounts sa
339
+ JOIN users u ON sa.user_id = u.username
340
+ WHERE sa.platform = ? AND sa.external_id = ?
341
+ """,
342
+ (platform, external_id)
343
+ );
344
+ row = cursor.fetchone();
345
+ if not row {
346
+ return None;
347
+ }
348
+ return {'email': row[0], 'token': row[1], 'root_id': row[2]};
349
+ }
@@ -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';
@@ -74,7 +74,9 @@ MongoDB persistence backend - implements PersistentMemory for durable L3 storage
74
74
  Replaces SqliteMemory when MongoDB is available.
75
75
  """
76
76
  obj MongoBackend(PersistentMemory) {
77
- has client: (MongoClient | None) = None,
77
+ has client: MongoClient | None = None,
78
+ db: Any by postinit,
79
+ collection: Any by postinit,
78
80
  db_name: str = 'jac_db',
79
81
  collection_name: str = 'anchors',
80
82
  mongo_url: str = _db_config['mongodb_uri'];
jac_scale/plugin.jac CHANGED
@@ -4,6 +4,7 @@ import pathlib;
4
4
  import from dotenv { load_dotenv }
5
5
  import from jaclang.cli.registry { get_registry }
6
6
  import from jaclang.cli.command { Arg, ArgKind, CommandPriority, HookContext }
7
+ import from jaclang.cli.console { console }
7
8
  import from jaclang.pycore.runtime { hookimpl, plugin_manager }
8
9
  import from jaclang.runtimelib.context { ExecutionContext }
9
10
  import from jaclang.runtimelib.server { JacAPIServer as JacServer }
@@ -15,6 +16,9 @@ import from .factories.deployment_factory { DeploymentTargetFactory }
15
16
  import from .factories.registry_factory { ImageRegistryFactory }
16
17
  import from .factories.utility_factory { UtilityFactory }
17
18
  import from .abstractions.config.app_config { AppConfig }
19
+ import from .user_manager { JacScaleUserManager }
20
+ import from jaclang.runtimelib.server { UserManager }
21
+ import from jaclang.runtimelib.storage { Storage }
18
22
 
19
23
  """Pre-hook for jac start command to handle --scale flag."""
20
24
  def _scale_pre_hook(context: HookContext) -> None {
@@ -23,6 +27,7 @@ def _scale_pre_hook(context: HookContext) -> None {
23
27
  # Handle deployment instead of local server
24
28
  filename = context.get_arg("filename");
25
29
  build = context.get_arg("build", False);
30
+ experimental = context.get_arg("experimental", False);
26
31
  target = context.get_arg("target", "kubernetes");
27
32
  registry = context.get_arg("registry", "dockerhub");
28
33
  if not os.path.exists(filename) {
@@ -57,15 +62,27 @@ def _scale_pre_hook(context: HookContext) -> None {
57
62
  }
58
63
  # Create app config
59
64
  app_config = AppConfig(
60
- code_folder=code_folder, file_name=base_file_path, build=build
65
+ code_folder=code_folder,
66
+ file_name=base_file_path,
67
+ build=build,
68
+ experimental=experimental
61
69
  );
70
+ if experimental {
71
+ console.print(
72
+ "Installing Jaseci packages from repository (experimental mode)..."
73
+ );
74
+ } else {
75
+ console.print("Installing Jaseci packages from PyPI...");
76
+ }
62
77
  # Deploy
63
78
  result = deployment_target.deploy(app_config);
64
79
  if not result.success {
65
80
  raise RuntimeError(result.message or "Deployment failed") ;
66
81
  }
67
82
  if result.service_url {
68
- print(f"Deployment complete! Service available at: {result.service_url}");
83
+ console.print(
84
+ f"Deployment complete! Service available at: {result.service_url}"
85
+ );
69
86
  }
70
87
  # Cancel normal start execution since we handled it
71
88
  context.set_data("cancel_execution", True);
@@ -99,6 +116,13 @@ class JacCmd {
99
116
  help="Build and push Docker image (with --scale)",
100
117
  short="b"
101
118
  ),
119
+ Arg.create(
120
+ "experimental",
121
+ typ=bool,
122
+ default=False,
123
+ help="Use experimental mode (install from repo instead of PyPI)",
124
+ short="e"
125
+ ),
102
126
  Arg.create(
103
127
  "target",
104
128
  typ=str,
@@ -166,7 +190,9 @@ class JacCmd {
166
190
  app_name = os.getenv('APP_NAME') or target_config.get('app_name', 'jaseci');
167
191
  deployment_target.destroy(app_name);
168
192
 
169
- print(f"Successfully destroyed deployment '{app_name}' from {target}");
193
+ console.print(
194
+ f"Successfully destroyed deployment '{app_name}' from {target}"
195
+ );
170
196
  return 0;
171
197
  }
172
198
  }
@@ -197,6 +223,23 @@ class JacScalePlugin {
197
223
  static def get_api_server_class -> type {
198
224
  return JacAPIServer;
199
225
  }
226
+
227
+ """Provide jac-scale's UserManager."""
228
+ @hookimpl
229
+ static def get_user_manager(base_path: str) -> UserManager {
230
+ return JacScaleUserManager(base_path=base_path);
231
+ }
232
+
233
+ """Provide jac-scale's storage backend.
234
+
235
+ This overrides the core store() to use jac-scale's StorageFactory,
236
+ which supports cloud backends (S3, GCS, Azure) via configuration.
237
+ """
238
+ @hookimpl
239
+ static def store(base_path: str = "./storage", create_dirs: bool = True) -> Storage {
240
+ import from .factories.storage_factory { StorageFactory }
241
+ return StorageFactory.get_default(base_path, create_dirs);
242
+ }
200
243
  }
201
244
 
202
245
  # Pluggy's varnames() puts parameters with defaults into kwargnames, not argnames.