aaa-server 0.1.0__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.
aaa_server/auth.py ADDED
@@ -0,0 +1,255 @@
1
+ """Authentication: who is calling, and which tenant they belong to.
2
+
3
+ The tenant, user and scopes of a request come ONLY from the `Principal` an `Authenticator` returns. Nothing in
4
+ a request body, query string or header other than the credential is ever read as an identity."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import hmac
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+ import httpx
15
+
16
+ from aaa.runtime.tenancy import valid_tenant
17
+
18
+ SCOPE_RUN = "run" # start runs, read events, cancel
19
+ SCOPE_APPROVE = "approve" # resolve tool approvals (a separate, rarer privilege)
20
+ SCOPE_SESSION = "session" # sessions and their messages
21
+ SCOPE_ADMIN = "admin" # read the tenant's usage and quota
22
+ ALL_SCOPES = frozenset({SCOPE_RUN, SCOPE_APPROVE, SCOPE_SESSION, SCOPE_ADMIN})
23
+
24
+
25
+ class AuthError(Exception):
26
+ """The credential is missing, malformed, unknown, expired or otherwise not acceptable.
27
+
28
+ The message is for server-side logs; the HTTP response is always the same generic 401."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Principal:
33
+ tenant: str
34
+ subject: str
35
+ scopes: frozenset[str] = frozenset({SCOPE_RUN, SCOPE_SESSION})
36
+
37
+ def __post_init__(self) -> None:
38
+ valid_tenant(self.tenant)
39
+ if not self.subject:
40
+ raise ValueError("a principal needs a subject")
41
+
42
+ def can(self, scope: str) -> bool:
43
+ return scope in self.scopes
44
+
45
+
46
+ @runtime_checkable
47
+ class Authenticator(Protocol):
48
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
49
+ """Return the caller or raise `AuthError`. `headers` keys are lower-case."""
50
+ ...
51
+
52
+
53
+ def bearer_token(headers: Mapping[str, str]) -> str:
54
+ raw = headers.get("authorization", "")
55
+ scheme, _, token = raw.partition(" ")
56
+ if scheme.lower() != "bearer" or not token.strip():
57
+ raise AuthError("missing bearer token")
58
+ return token.strip()
59
+
60
+
61
+ def hash_api_key(key: str) -> str:
62
+ """SHA-256 hex of an API key. Keys are long random strings, so a fast hash is the right tool (there is
63
+ nothing to brute-force); store this, never the key."""
64
+ return hashlib.sha256(key.encode()).hexdigest()
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ApiKey:
69
+ key_hash: str
70
+ tenant: str
71
+ subject: str
72
+ scopes: frozenset[str] = frozenset({SCOPE_RUN, SCOPE_SESSION})
73
+
74
+
75
+ class ApiKeyAuthenticator:
76
+ """Static API keys, held as hashes. Every candidate is compared in constant time, so the response time does
77
+ not reveal how much of a key was right or which keys exist."""
78
+
79
+ def __init__(self, keys: Sequence[ApiKey]) -> None:
80
+ self._keys = tuple(keys)
81
+
82
+ @classmethod
83
+ def from_keys(
84
+ cls,
85
+ mapping: Mapping[str, tuple[str, str] | tuple[str, str, Sequence[str]]],
86
+ ) -> ApiKeyAuthenticator:
87
+ """`{"<the key>": ("tenant", "subject"[, scopes])}` for tests and small deployments. Prefer
88
+ `ApiKey(key_hash=...)` entries loaded from a secret store."""
89
+ out = []
90
+ for key, spec in mapping.items():
91
+ tenant, subject = spec[0], spec[1]
92
+ scopes = frozenset(spec[2]) if len(spec) > 2 else frozenset({SCOPE_RUN, SCOPE_SESSION}) # type: ignore[misc]
93
+ out.append(ApiKey(hash_api_key(key), tenant, subject, scopes))
94
+ return cls(out)
95
+
96
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
97
+ digest = hash_api_key(bearer_token(headers))
98
+ found: ApiKey | None = None
99
+ for k in self._keys: # no early exit: every entry is compared
100
+ if hmac.compare_digest(k.key_hash, digest):
101
+ found = k
102
+ if found is None:
103
+ raise AuthError("unknown api key")
104
+ return Principal(found.tenant, found.subject, found.scopes)
105
+
106
+
107
+ class JWKSKeys:
108
+ """Signing keys fetched from a JWKS URL and cached; an unknown `kid` triggers one refetch (rotation),
109
+ rate-limited so a stream of bogus tokens cannot make the server hammer the identity provider."""
110
+
111
+ def __init__(
112
+ self,
113
+ url: str,
114
+ *,
115
+ client: httpx.AsyncClient | None = None,
116
+ ttl_s: float = 600.0,
117
+ min_refetch_s: float = 30.0,
118
+ clock: Callable[[], float] | None = None,
119
+ ) -> None:
120
+ import time
121
+
122
+ self.url, self._client, self.ttl_s, self.min_refetch_s = url, client, ttl_s, min_refetch_s
123
+ self._clock = clock or time.monotonic
124
+ self._keys: dict[str, Any] = {}
125
+ self._fetched = float("-inf")
126
+
127
+ async def _fetch(self) -> None:
128
+ from jwt import PyJWK
129
+
130
+ client = self._client or httpx.AsyncClient(timeout=10.0, trust_env=False)
131
+ try:
132
+ r = await client.get(self.url)
133
+ r.raise_for_status()
134
+ doc = r.json()
135
+ except (httpx.HTTPError, ValueError) as e:
136
+ raise AuthError(f"jwks fetch failed: {type(e).__name__}") from None
137
+ finally:
138
+ if self._client is None:
139
+ await client.aclose()
140
+ keys: dict[str, Any] = {}
141
+ for jwk in doc.get("keys", []):
142
+ if jwk.get("use") not in (None, "sig") or "kid" not in jwk:
143
+ continue
144
+ try:
145
+ keys[jwk["kid"]] = PyJWK(jwk).key
146
+ except Exception: # noqa: BLE001, S112 - an unusable key in the set must not break the others
147
+ continue
148
+ self._keys, self._fetched = keys, self._clock()
149
+
150
+ async def key(self, kid: str) -> Any:
151
+ now = self._clock()
152
+ if (kid not in self._keys and now - self._fetched >= self.min_refetch_s) or (
153
+ now - self._fetched >= self.ttl_s
154
+ ):
155
+ await self._fetch()
156
+ try:
157
+ return self._keys[kid]
158
+ except KeyError:
159
+ raise AuthError("unknown signing key") from None
160
+
161
+
162
+ @dataclass
163
+ class JWTAuthenticator:
164
+ """Verifies a bearer JWT. Safe by construction:
165
+
166
+ - the algorithm allow-list is fixed by YOU (asymmetric by default); the token's own `alg` header is never trusted,
167
+ and `none` and the HS* family are refused unless you configure a shared `secret` (and then only HS*);
168
+ - `exp`, `iss` and `aud` are required and checked;
169
+ - the tenant, subject and scopes come from claims you name, and a token without a tenant claim is rejected."""
170
+
171
+ issuer: str
172
+ audience: str
173
+ keys: JWKSKeys | None = None
174
+ secret: str | None = None
175
+ algorithms: tuple[str, ...] = ("RS256", "ES256", "EdDSA")
176
+ tenant_claim: str = "tenant"
177
+ scope_claim: str = "scope"
178
+ leeway_s: float = 30.0
179
+ default_scopes: frozenset[str] = field(
180
+ default_factory=lambda: frozenset({SCOPE_RUN, SCOPE_SESSION})
181
+ )
182
+
183
+ def __post_init__(self) -> None:
184
+ if (self.keys is None) == (self.secret is None):
185
+ raise ValueError("give exactly one of `keys` (JWKS) or `secret` (HS256)")
186
+ if self.secret is not None:
187
+ self.algorithms = ("HS256",)
188
+ if len(self.secret.encode()) < 32:
189
+ raise ValueError("a shared JWT secret must be at least 32 bytes")
190
+ elif any(a.lower() == "none" or a.upper().startswith("HS") for a in self.algorithms):
191
+ raise ValueError("a JWKS-verified token must use an asymmetric algorithm")
192
+
193
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
194
+ import jwt
195
+
196
+ token = bearer_token(headers)
197
+ try:
198
+ header = jwt.get_unverified_header(token)
199
+ if header.get("alg") not in self.algorithms:
200
+ raise AuthError("algorithm not allowed")
201
+ if self.keys is not None:
202
+ kid = header.get("kid")
203
+ if not isinstance(kid, str):
204
+ raise AuthError("token has no kid")
205
+ key: Any = await self.keys.key(kid)
206
+ else:
207
+ key = self.secret
208
+ claims = jwt.decode(
209
+ token,
210
+ key,
211
+ algorithms=list(self.algorithms),
212
+ audience=self.audience,
213
+ issuer=self.issuer,
214
+ leeway=self.leeway_s,
215
+ options={"require": ["exp", "iss", "aud", "sub"]},
216
+ )
217
+ except AuthError:
218
+ raise
219
+ except jwt.PyJWTError as e:
220
+ raise AuthError(f"invalid token: {type(e).__name__}") from None
221
+ tenant = claims.get(self.tenant_claim)
222
+ if not isinstance(tenant, str):
223
+ raise AuthError("token has no tenant")
224
+ try:
225
+ valid_tenant(tenant)
226
+ except ValueError:
227
+ raise AuthError("token tenant is invalid") from None
228
+ raw = claims.get(self.scope_claim)
229
+ if raw is None:
230
+ scopes = self.default_scopes
231
+ elif isinstance(raw, str):
232
+ scopes = frozenset(raw.split()) & ALL_SCOPES
233
+ elif isinstance(raw, list):
234
+ scopes = frozenset(str(s) for s in raw) & ALL_SCOPES
235
+ else:
236
+ raise AuthError("token scope is malformed")
237
+ return Principal(tenant, str(claims["sub"]), scopes)
238
+
239
+
240
+ __all__ = [
241
+ "ALL_SCOPES",
242
+ "SCOPE_ADMIN",
243
+ "SCOPE_APPROVE",
244
+ "SCOPE_RUN",
245
+ "SCOPE_SESSION",
246
+ "ApiKey",
247
+ "ApiKeyAuthenticator",
248
+ "AuthError",
249
+ "Authenticator",
250
+ "JWKSKeys",
251
+ "JWTAuthenticator",
252
+ "Principal",
253
+ "bearer_token",
254
+ "hash_api_key",
255
+ ]
aaa_server/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """`aaa-server serve module:factory` - run the server for the configuration your factory builds.
2
+
3
+ The factory is a function (sync or async) returning a `ServerConfig`; it is where you choose the store, the LLM client, the
4
+ algorithms, the authenticator and the limits. The command only adds the network listener, and listens on loopback unless you
5
+ pass `--host`."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import importlib
12
+ import inspect
13
+ import sys
14
+ from collections.abc import Callable, Sequence
15
+ from typing import Any
16
+
17
+ from .app import ServerConfig, create_app
18
+
19
+
20
+ def load_factory(spec: str) -> Callable[[], Any]:
21
+ module, _, attr = spec.partition(":")
22
+ if not module or not attr:
23
+ raise SystemExit("expected module:factory, e.g. myapp.server:build")
24
+ sys.path.insert(0, "")
25
+ fn = getattr(importlib.import_module(module), attr)
26
+ return fn if callable(fn) else (lambda: fn)
27
+
28
+
29
+ async def _build(factory: Callable[[], Any]) -> ServerConfig:
30
+ cfg = factory()
31
+ if inspect.isawaitable(cfg):
32
+ cfg = await cfg
33
+ if not isinstance(cfg, ServerConfig):
34
+ raise SystemExit(f"the factory must return a ServerConfig, got {type(cfg).__name__}")
35
+ return cfg
36
+
37
+
38
+ def load_config(spec: str) -> ServerConfig:
39
+ """Build the configuration now (for tests and sync code). The server itself builds it inside its own event loop."""
40
+ return asyncio.run(_build(load_factory(spec)))
41
+
42
+
43
+ class LazyApp:
44
+ """An ASGI app that builds the configuration INSIDE the server's event loop, at startup. A factory that opens connection
45
+ pools (asyncpg, redis) must run in the loop that will use them, which `asyncio.run(factory())` before `uvicorn.run` is not."""
46
+
47
+ def __init__(self, factory: Callable[[], Any]) -> None:
48
+ self.factory, self.app = factory, None
49
+
50
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
51
+ if scope["type"] == "lifespan":
52
+ while True:
53
+ msg = await receive()
54
+ if msg["type"] == "lifespan.startup":
55
+ try:
56
+ cfg = await _build(self.factory)
57
+ stack = cfg.engine
58
+ await stack.__aenter__()
59
+ self.app = create_app(cfg)
60
+ except BaseException as e: # noqa: BLE001 - report the failure to the server, which exits
61
+ await send(
62
+ {
63
+ "type": "lifespan.startup.failed",
64
+ "message": f"{type(e).__name__}: {e}",
65
+ }
66
+ )
67
+ return
68
+ await send({"type": "lifespan.startup.complete"})
69
+ elif msg["type"] == "lifespan.shutdown":
70
+ await stack.__aexit__(None, None, None)
71
+ await send({"type": "lifespan.shutdown.complete"})
72
+ return
73
+ elif self.app is None:
74
+ raise RuntimeError("the server has not finished starting")
75
+ else:
76
+ await self.app(scope, receive, send)
77
+
78
+
79
+ def main(argv: Sequence[str] | None = None) -> int:
80
+ ap = argparse.ArgumentParser(prog="aaa-server")
81
+ sub = ap.add_subparsers(dest="cmd", required=True)
82
+ sv = sub.add_parser("serve", help="run the HTTP server")
83
+ sv.add_argument("factory", help="module:function returning a ServerConfig")
84
+ sv.add_argument("--host", default="127.0.0.1", help="default: loopback only")
85
+ sv.add_argument("--port", type=int, default=8000)
86
+ a = ap.parse_args(argv)
87
+ import uvicorn
88
+
89
+ app = LazyApp(load_factory(a.factory))
90
+ if a.host not in ("127.0.0.1", "localhost", "::1"):
91
+ print(
92
+ f"warning: listening on {a.host}. Put TLS and a reverse proxy (body/header timeouts) in front; see the server guide.",
93
+ file=sys.stderr,
94
+ )
95
+ uvicorn.run(app, host=a.host, port=a.port, log_level="info", server_header=False)
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__":
100
+ raise SystemExit(main())
aaa_server/mcp_http.py ADDED
@@ -0,0 +1,64 @@
1
+ """MCP over Streamable HTTP, served by `aaa-server` so the same authentication, tenant and rate-limit rules apply.
2
+
3
+ This is the stateless subset of the transport: every POST carries one JSON-RPC message and gets one JSON answer (no sessions, no
4
+ server-initiated messages, so `GET` is `405`). That is enough for `initialize`, `ping`, `tools/list` and `tools/call`, which is
5
+ all a tool server needs, and it is what lets the tenant of every call come from the authenticated principal instead of from
6
+ anything the MCP client says."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from aaa.mcp.server import MCPToolServer
13
+
14
+ SUPPORTED = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
15
+ PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602
16
+
17
+
18
+ def _error(id_: Any, code: int, message: str) -> dict[str, Any]:
19
+ return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
20
+
21
+
22
+ async def handle(
23
+ core: MCPToolServer, message: Any, *, tenant: str, user: str
24
+ ) -> dict[str, Any] | None:
25
+ """Answer one JSON-RPC message, or `None` for a notification / client response (which get `202`)."""
26
+ if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
27
+ return _error(None, INVALID_REQUEST, "expected a JSON-RPC 2.0 object")
28
+ method, id_, params = message.get("method"), message.get("id"), message.get("params") or {}
29
+ if method is None: # a response to something we never sent
30
+ return None
31
+ if id_ is None: # a notification
32
+ return None
33
+ if not isinstance(method, str) or not isinstance(params, dict):
34
+ return _error(id_, INVALID_REQUEST, "malformed request")
35
+ if method == "initialize":
36
+ asked = params.get("protocolVersion")
37
+ return {
38
+ "jsonrpc": "2.0",
39
+ "id": id_,
40
+ "result": {
41
+ "protocolVersion": asked if asked in SUPPORTED else SUPPORTED[0],
42
+ "capabilities": {"tools": {"listChanged": False}},
43
+ "serverInfo": {"name": core.name, "version": "0.1"},
44
+ },
45
+ }
46
+ if method == "ping":
47
+ return {"jsonrpc": "2.0", "id": id_, "result": {}}
48
+ if method == "tools/list":
49
+ tools = [
50
+ {"name": t.name, "description": t.description, "inputSchema": t.input_schema}
51
+ for t in core.list_tools()
52
+ ]
53
+ return {"jsonrpc": "2.0", "id": id_, "result": {"tools": tools}}
54
+ if method == "tools/call":
55
+ name, args = params.get("name"), params.get("arguments") or {}
56
+ if not isinstance(name, str) or not isinstance(args, dict):
57
+ return _error(id_, INVALID_PARAMS, "tools/call needs a name and an arguments object")
58
+ r = await core.call(name, args, tenant=tenant, user=user)
59
+ return {
60
+ "jsonrpc": "2.0",
61
+ "id": id_,
62
+ "result": {"content": [{"type": "text", "text": r.text}], "isError": r.is_error},
63
+ }
64
+ return _error(id_, METHOD_NOT_FOUND, f"method {method!r} is not supported")
aaa_server/py.typed ADDED
File without changes
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: aaa-server
3
+ Version: 0.1.0
4
+ Summary: HTTP/SSE server for AAA agents: authentication, tenant isolation, rate limits, quotas, approvals.
5
+ Project-URL: Homepage, https://github.com/innerkorehq/AutonomousAgentArchitecture
6
+ Project-URL: Documentation, https://innerkorehq.github.io/AutonomousAgentArchitecture/server/
7
+ Project-URL: Source, https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server
8
+ Project-URL: Library, https://pypi.org/project/autonomous-agent-architecture/
9
+ Project-URL: Changelog, https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/
10
+ Project-URL: Threat model, https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/
11
+ Project-URL: Issues, https://github.com/innerkorehq/AutonomousAgentArchitecture/issues
12
+ Author: Innerkore Technologies
13
+ License-Expression: MIT
14
+ License-File: LICENSE
15
+ Keywords: agents,asgi,llm,multi-tenant,server,sse
16
+ Classifier: Development Status :: 3 - Alpha
17
+ Classifier: Framework :: AsyncIO
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: autonomous-agent-architecture>=0.3.1
26
+ Requires-Dist: starlette>=0.37
27
+ Requires-Dist: uvicorn>=0.30
28
+ Provides-Extra: jwt
29
+ Requires-Dist: pyjwt[crypto]>=2.8; extra == 'jwt'
30
+ Provides-Extra: redis
31
+ Requires-Dist: redis>=5.0; extra == 'redis'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # aaa-server
35
+
36
+ HTTP/SSE server for [AAA](https://github.com/innerkorehq/AutonomousAgentArchitecture) agents ([`autonomous-agent-architecture`](https://pypi.org/project/autonomous-agent-architecture/)): bearer authentication, tenants taken only from the credential, rate limits and quotas, server-side budgets, human approvals over HTTP, resumable event streams, plus MCP and (experimental) A2A endpoints.
37
+
38
+ **Documentation:** [overview](https://innerkorehq.github.io/AutonomousAgentArchitecture/server/) · [server guide](https://innerkorehq.github.io/AutonomousAgentArchitecture/guides/server/) · [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) · [API reference](https://innerkorehq.github.io/AutonomousAgentArchitecture/reference/api/server/) · [library docs](https://innerkorehq.github.io/AutonomousAgentArchitecture/) · [changelog](https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/)
39
+
40
+ ```bash
41
+ pip install aaa-server # needs autonomous-agent-architecture>=0.3.0 (installed automatically)
42
+ pip install "aaa-server[jwt,redis]" # + JWT/JWKS auth, + the shared Redis rate limiter
43
+ ```
44
+
45
+ ```python
46
+ # myapp/server.py
47
+ from aaa.algos import ToolLoop
48
+ from aaa.runtime import Budget, Engine
49
+ from aaa.tools import ToolRegistry
50
+ from aaa_server import ApiKeyAuthenticator, ServerConfig
51
+
52
+ async def build() -> ServerConfig:
53
+ engine = Engine() # + attach your LLM and tool handlers; use Postgres in production
54
+ return ServerConfig(
55
+ engine=engine,
56
+ authenticator=ApiKeyAuthenticator.from_keys({"s3cret-key": ("acme", "ann")}),
57
+ algorithms={"agent": ToolLoop(ToolRegistry([]))},
58
+ budget=Budget(max_steps=20, deadline_s=60),
59
+ )
60
+ ```
61
+
62
+ ```bash
63
+ aaa-server serve myapp.server:build # 127.0.0.1:8000 by default
64
+ ```
65
+
66
+ Read the [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) before exposing it: put a TLS-terminating reverse proxy in front.
67
+ Status: **alpha (0.1)**. MIT licensed. Source: <https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server>.
@@ -0,0 +1,13 @@
1
+ aaa_server/__init__.py,sha256=5Xg3Da6oOw6ySZ-EmZkPs1ki1DU7Qq2fHEhofOM51oA,978
2
+ aaa_server/a2a.py,sha256=pFUgvXeL-2BdZhkvfGYM4mMfUbEdL6PTXSEVZ9ScSO0,14380
3
+ aaa_server/admission.py,sha256=5zTN3zO5mKjT2eGzzQnVmweO7jvNS8bVvK-ufvAv4Kk,9086
4
+ aaa_server/app.py,sha256=Fh0e0PIhl0nX1wp2U-sFm4aNaixgwmIp9Eg5Ws5eJ9A,30738
5
+ aaa_server/auth.py,sha256=YsQ-B5sH5r9wDa3VlnjevEzAcU3zGaNb8YRisKkIghg,9303
6
+ aaa_server/cli.py,sha256=LKT_4vyIPY9VOWqXib5TXxlYh5uoaipFEsxqxy0eEow,4048
7
+ aaa_server/mcp_http.py,sha256=1QeVAdkv3MbVwAoN9-kpUdjYZzkLM_gIu5hD-l4DNDk,3039
8
+ aaa_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ aaa_server-0.1.0.dist-info/METADATA,sha256=78g8jFvhSWBPTFJakOe9LPZMFm8hM8nVpRy09X4wqAs,3759
10
+ aaa_server-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ aaa_server-0.1.0.dist-info/entry_points.txt,sha256=_pmXG6lotsoMixXcqcywxlfUqwybjCx61M6WyL75G3E,51
12
+ aaa_server-0.1.0.dist-info/licenses/LICENSE,sha256=inG0UPjirLkSwEpASZlLycVzvrsbuJmvStPTEUzEcys,1079
13
+ aaa_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aaa-server = aaa_server.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Innerkore Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.