astrocade-creator-mcp 0.1.0__tar.gz

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 (33) hide show
  1. astrocade_creator_mcp-0.1.0/PKG-INFO +58 -0
  2. astrocade_creator_mcp-0.1.0/README.md +38 -0
  3. astrocade_creator_mcp-0.1.0/pyproject.toml +41 -0
  4. astrocade_creator_mcp-0.1.0/setup.cfg +4 -0
  5. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/__init__.py +4 -0
  6. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/astrolib/lib-api-reference-animation.md +38 -0
  7. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/astrolib/lib-api-reference-base.md +170 -0
  8. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/astrolib/lib-api-reference-llm.md +52 -0
  9. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/astrolib_ref.py +19 -0
  10. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/auth/__init__.py +1 -0
  11. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/auth/cognito.py +71 -0
  12. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/auth/login_helper.py +236 -0
  13. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/backend_client.py +274 -0
  14. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/config.py +107 -0
  15. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/context.py +26 -0
  16. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/eslint.config.js +232 -0
  17. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/lib_docs.py +137 -0
  18. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/models.py +35 -0
  19. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/server.py +151 -0
  20. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/stdio_main.py +71 -0
  21. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/tools.py +422 -0
  22. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp/validation.py +526 -0
  23. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp.egg-info/PKG-INFO +58 -0
  24. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp.egg-info/SOURCES.txt +31 -0
  25. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp.egg-info/dependency_links.txt +1 -0
  26. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp.egg-info/entry_points.txt +2 -0
  27. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp.egg-info/requires.txt +14 -0
  28. astrocade_creator_mcp-0.1.0/src/astrocade_creator_mcp.egg-info/top_level.txt +1 -0
  29. astrocade_creator_mcp-0.1.0/tests/test_backend_client.py +102 -0
  30. astrocade_creator_mcp-0.1.0/tests/test_cognito.py +96 -0
  31. astrocade_creator_mcp-0.1.0/tests/test_config.py +16 -0
  32. astrocade_creator_mcp-0.1.0/tests/test_login_helper.py +115 -0
  33. astrocade_creator_mcp-0.1.0/tests/test_tools.py +393 -0
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: astrocade-creator-mcp
3
+ Version: 0.1.0
4
+ Summary: Astrocade Creator MCP — pull and edit your Astrocade games locally over MCP (stdio).
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: mcp>=1.11.0
8
+ Requires-Dist: pydantic>=2
9
+ Requires-Dist: PyJWT[crypto]>=2.10
10
+ Requires-Dist: requests
11
+ Requires-Dist: aiohttp
12
+ Requires-Dist: html5lib
13
+ Requires-Dist: beautifulsoup4
14
+ Requires-Dist: esprima
15
+ Requires-Dist: tinycss2
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Requires-Dist: pytest-asyncio; extra == "dev"
19
+ Requires-Dist: mypy; extra == "dev"
20
+
21
+ # Astrocade Creator MCP
22
+
23
+ A local (stdio) [MCP](https://modelcontextprotocol.io) server that lets a creator pull, edit,
24
+ push, and publish the games already on their Astrocade account — as ordinary local files.
25
+
26
+ ## Install & run
27
+
28
+ ```bash
29
+ uvx astrocade-creator-mcp
30
+ # or
31
+ pip install astrocade-creator-mcp && astrocade-creator-mcp
32
+ ```
33
+
34
+ Targets **prod** by default — a creator sets nothing. To point at stage instead, set
35
+ `ASTROCADE_CREATOR_MCP_ENV=stage`.
36
+
37
+ ### Sign-in
38
+
39
+ Nothing to configure. The **first tool call** opens a browser for Cognito (Google) login, caches
40
+ the tokens under `~/.astrocade`, and silently refreshes them afterward. Login is lazy (not at
41
+ startup) so the browser flow never blocks the MCP stdio handshake.
42
+
43
+ Optional — sign in ahead of time so the first call has no delay:
44
+ ```bash
45
+ astrocade-creator-mcp login
46
+ ```
47
+ Token cache dir: `~/.astrocade` (override with `ASTROCADE_TOKEN_CACHE_DIR`).
48
+
49
+ ## Tools
50
+
51
+ - `list_my_games` — your games (drafts included).
52
+ - `pull_game` / `push_game` — check a game out (files + metadata, as JSON), save edits back as a draft. `push_game` can also update metadata (`title`, `description`, `thumbnail_url`).
53
+ - `preview_game` — publish the draft and get a playable URL.
54
+ - `check_wish_status` — poll an AI "wish" that's in flight (wishes are submitted on astrocade.com; `push_game`/`pull_game` surface the request id when one is running).
55
+ - `list_lib_apis` / `get_lib_api` — browse the Astrocade `lib` API reference.
56
+
57
+ Optional JavaScript validation via ESLint runs on push/preview when `eslint` is on `PATH`;
58
+ it's skipped (non-fatal) otherwise.
@@ -0,0 +1,38 @@
1
+ # Astrocade Creator MCP
2
+
3
+ A local (stdio) [MCP](https://modelcontextprotocol.io) server that lets a creator pull, edit,
4
+ push, and publish the games already on their Astrocade account — as ordinary local files.
5
+
6
+ ## Install & run
7
+
8
+ ```bash
9
+ uvx astrocade-creator-mcp
10
+ # or
11
+ pip install astrocade-creator-mcp && astrocade-creator-mcp
12
+ ```
13
+
14
+ Targets **prod** by default — a creator sets nothing. To point at stage instead, set
15
+ `ASTROCADE_CREATOR_MCP_ENV=stage`.
16
+
17
+ ### Sign-in
18
+
19
+ Nothing to configure. The **first tool call** opens a browser for Cognito (Google) login, caches
20
+ the tokens under `~/.astrocade`, and silently refreshes them afterward. Login is lazy (not at
21
+ startup) so the browser flow never blocks the MCP stdio handshake.
22
+
23
+ Optional — sign in ahead of time so the first call has no delay:
24
+ ```bash
25
+ astrocade-creator-mcp login
26
+ ```
27
+ Token cache dir: `~/.astrocade` (override with `ASTROCADE_TOKEN_CACHE_DIR`).
28
+
29
+ ## Tools
30
+
31
+ - `list_my_games` — your games (drafts included).
32
+ - `pull_game` / `push_game` — check a game out (files + metadata, as JSON), save edits back as a draft. `push_game` can also update metadata (`title`, `description`, `thumbnail_url`).
33
+ - `preview_game` — publish the draft and get a playable URL.
34
+ - `check_wish_status` — poll an AI "wish" that's in flight (wishes are submitted on astrocade.com; `push_game`/`pull_game` surface the request id when one is running).
35
+ - `list_lib_apis` / `get_lib_api` — browse the Astrocade `lib` API reference.
36
+
37
+ Optional JavaScript validation via ESLint runs on push/preview when `eslint` is on `PATH`;
38
+ it's skipped (non-fatal) otherwise.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "astrocade-creator-mcp"
7
+ version = "0.1.0"
8
+ description = "Astrocade Creator MCP — pull and edit your Astrocade games locally over MCP (stdio)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "mcp>=1.11.0",
13
+ "pydantic>=2",
14
+ "PyJWT[crypto]>=2.10",
15
+ "requests",
16
+ "aiohttp",
17
+ "html5lib",
18
+ "beautifulsoup4",
19
+ "esprima",
20
+ "tinycss2",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest", "pytest-asyncio", "mypy"]
25
+
26
+ [project.scripts]
27
+ astrocade-creator-mcp = "astrocade_creator_mcp.stdio_main:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ astrocade_creator_mcp = ["eslint.config.js", "astrolib/*.md"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ pythonpath = ["src"]
38
+
39
+ [tool.mypy]
40
+ python_version = "3.12"
41
+ ignore_missing_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Astrocade Creator MCP — let creators pull and edit their existing games via MCP.
2
+
3
+ A slim, self-contained package serving the Creator tools over the local stdio transport.
4
+ """
@@ -0,0 +1,38 @@
1
+ ## ANIMATION
2
+
3
+ ### `AnimationPlayer` (Object)
4
+
5
+ - **update** `function(number): void` — Advances animation based on timestamp
6
+ - **draw** `function(CanvasRenderingContext2D, number, number, number, number): void` — Draws current frame
7
+ - **getCurrentFrame** `function(): {index: number, frame: {x: number, y: number, w: number, h: number}|undefined, total: number}` — Gets current frame info
8
+ - **reset** `function(): void` — Resets animation to first frame
9
+
10
+ Creates an animation player for sprite sheet animations.
11
+ The assetId should reference an asset with animation data.
12
+ - **assetId** (`string`): The animation asset ID
13
+
14
+ **Returns:** `AnimationPlayer|null` — The animation player, or null if not found
15
+
16
+ **Example:**
17
+ ```js
18
+ const anim = lib.getAnimationPlayer('player_run');
19
+ function gameLoop(timestamp) {
20
+ anim.update(timestamp);
21
+ anim.draw(ctx, x, y, 64, 64);
22
+ requestAnimationFrame(gameLoop);
23
+ }
24
+ ```
25
+
26
+ ```js
27
+ lib.getAnimationPlayer = function(assetId) {};
28
+ ```
29
+
30
+ Preloads an animation's sprite sheet for smoother playback.
31
+ Call during initialization to avoid loading delays.
32
+ - **assetId** (`string`): The animation asset ID
33
+
34
+ **Returns:** `Promise<void>`
35
+
36
+ ```js
37
+ lib.preloadAnimation = async function(assetId) {};
38
+ ```
@@ -0,0 +1,170 @@
1
+ ============================================================================
2
+ ASTROCADE LIBRARY (lib) - API REFERENCE
3
+ ============================================================================
4
+
5
+ The lib object is automatically available in your game code via "lib"
6
+ This is READ-ONLY reference documentation - lib code is owned by Astrocade.
7
+
8
+ Your game's entry point:
9
+
10
+ ```js
11
+ function run(mode) {
12
+ // mode is 'edit' or 'play'
13
+ // window.gameConfig is already set up before run() is called
14
+ }
15
+ ```
16
+
17
+ If you remove this function the game will not run.
18
+
19
+ ## TYPE DEFINITIONS
20
+
21
+ ### `LeaderboardEntry` (Object)
22
+
23
+ - **username** `string` — The player's username
24
+ - **profilePicture** `string|null` — URL to profile picture, or null
25
+ - **score** `number` — The player's score
26
+
27
+ ### `LeaderboardResponse` (Object)
28
+
29
+ - **entries** `LeaderboardEntry[]` — Array of leaderboard entries
30
+ - **userRank** `number|null` — Current user's rank, or null if not ranked
31
+ - **[success]** `boolean` — Whether the operation succeeded
32
+
33
+ ### `UserGameStateResponse` (Object)
34
+
35
+ - **userId** `string` — The user's ID
36
+ - **gameId** `string` — The game's ID
37
+ - **state** `Object|null` — The saved state, or null if none exists
38
+ - **[success]** `boolean` — Whether the operation succeeded
39
+
40
+ ## GLOBALS
41
+
42
+ The game's configuration state.
43
+ Automatically available before run() is called.
44
+ In edit mode, changes are tracked for undo/redo.
45
+
46
+ *Type:* `Object`
47
+
48
+ ```js
49
+ var gameConfig = window.gameConfig;
50
+ ```
51
+
52
+ Current game mode. Use to conditionally enable editor features.
53
+
54
+ *Type:* `'edit' | 'play'`
55
+
56
+ ```js
57
+ var mode = window.mode;
58
+ ```
59
+
60
+ ## CORE FUNCTIONS
61
+
62
+ Retrieves an asset by its ID from the asset map.
63
+ - **id** (`string`): The asset ID
64
+
65
+ **Returns:** `Object|undefined` — The asset object with these properties, or undefined if not found:
66
+ - url: CDN URL for the asset
67
+ - type: "image", "audio", or "animation"
68
+ - aspect_ratio: [width, height] ratio (e.g., [1536, 1024]) - images only
69
+ - size_hint: suggested display size - images only
70
+ - view: perspective hint (e.g., "top-down", "side-view", "front-view") - images only
71
+ - outline: collision/selection shape - images only
72
+ - box: { type: "box", center: [x, y], size: [w, h] }
73
+ - circle: { type: "circle", center: [x, y], radius: r }
74
+ - polygon: { type: "polygon", points: [[x, y], ...] }
75
+ - loop: "true" or "false" - whether audio should repeat - audio only
76
+ - duration_secs: duration in seconds - audio only
77
+
78
+ ```js
79
+ lib.getAsset = function(id) {};
80
+ ```
81
+
82
+ Debug logging. Messages are forwarded to the host environment console.
83
+ - **message** (`string`): The message to log
84
+
85
+ ```js
86
+ lib.log = function(message) {};
87
+ ```
88
+
89
+ Displays a game-wide settings panel in the host UI.
90
+ Call once in run() to register persistent game parameters.
91
+ - **options.name** (`string`): Display name for the settings panel
92
+ - **options.params** (`Object`): Map of display names to parameter configs
93
+ Each param config has:
94
+ - key: path into gameConfig (e.g., 'settings.difficulty')
95
+ - type: 'slider' | 'dropdown' | 'color' | 'checkbox' | 'string' | 'number'
96
+ - min, max, step: for 'slider' type
97
+ - options: [{label, value}] for 'dropdown' type
98
+ - onChange(value): optional callback; if omitted, auto-updates gameConfig at key
99
+
100
+ ```js
101
+ lib.showGameParameters = function(options) {};
102
+ ```
103
+
104
+ ## LEADERBOARD
105
+
106
+ Submits the current player's score to the game's leaderboard.
107
+ Throws if game is not saved or user is not authenticated.
108
+ - **score** (`number`): The player's score
109
+ - **numEntries** (`number`, optional): Optional: number of top entries to return
110
+
111
+ **Returns:** `Promise<LeaderboardResponse>`
112
+
113
+ ```js
114
+ lib.addPlayerScoreToLeaderboard = async function(score, numEntries) {};
115
+ ```
116
+
117
+ Retrieves the top N entries from the game's leaderboard.
118
+ - **numEntries** (`number`, optional): Optional: number of entries to retrieve
119
+
120
+ **Returns:** `Promise<LeaderboardResponse>`
121
+
122
+ ```js
123
+ lib.getTopNEntriesFromLeaderboard = async function(numEntries) {};
124
+ ```
125
+
126
+ ## USER GAME STATE (Save/Load)
127
+
128
+ Persists the user's game progress to the server.
129
+ State must be JSON-serializable (no functions, circular refs, etc).
130
+ Each user has one save slot per game; always pass the full latest state
131
+ (the slot is overwritten, not merged).
132
+ Call whenever meaningful state changes — level complete, item earned,
133
+ settings changed, or a short settle after the player stops interacting.
134
+ Keep state compact and bounded — store what's needed to resume, and never let
135
+ a saved array (inventory, history, gallery) grow without a cap.
136
+ - **state** (`Object`): The full game state to save
137
+
138
+ **Returns:** `Promise<UserGameStateResponse>`
139
+
140
+ ```js
141
+ lib.saveUserGameState = async function(state) {};
142
+ ```
143
+
144
+ Retrieves the user's previously saved game state.
145
+
146
+ **Returns:** `Promise<UserGameStateResponse>`
147
+
148
+ ```js
149
+ lib.getUserGameState = async function() {};
150
+ ```
151
+
152
+ Deletes the user's saved game state.
153
+ Use for "New Game" or reset functionality.
154
+
155
+ **Returns:** `Promise<{success: boolean}>`
156
+
157
+ ```js
158
+ lib.deleteUserGameState = async function() {};
159
+ ```
160
+
161
+ ## DEVICE DETECTION
162
+
163
+ Detects whether touch controls should be used.
164
+ Returns true on mobile/tablet devices (coarse pointer or no hover).
165
+
166
+ **Returns:** `boolean` — true if touch controls are appropriate
167
+
168
+ ```js
169
+ lib.shouldUseTouchControls = function() {};
170
+ ```
@@ -0,0 +1,52 @@
1
+ ## LLM (Chat Completions)
2
+
3
+ ### `LlmMessage` (Object)
4
+
5
+ - **role** `'user' | 'assistant'` — Speaker for this turn
6
+ - **content** `string` — Message text
7
+
8
+ ### `LlmConfig` (Object)
9
+
10
+ - **[temperature]** `number` — 0..2
11
+ - **[maxOutputTokens]** `number` — 1..4,096
12
+
13
+ Calls a chat-completion LLM with the given conversation. Only use when output
14
+ depends on runtime state that couldn't be authored upfront (free-form player
15
+ input, accumulated in-session choices). Fixed dialogue, flavor text, and
16
+ canned hints belong in code or gameConfig — don't fetch what you could author.
17
+ Don't use for game logic, scoring, physics, per-frame work, or anything
18
+ requiring determinism.
19
+ Async; can throw on rate limit, network failure, timeout, or all-empty
20
+ messages. Wrap in try/catch with a non-LLM fallback so the game keeps working
21
+ when the call fails. Empty/whitespace-only messages are silently dropped
22
+ before sending — safe to feed a running chat history without sanitizing.
23
+ Rate-limited server-side. Debounce user-triggered calls, cache repeated
24
+ prompts, never call per frame.
25
+ result.text is a string. For structured output, instruct the model in
26
+ systemPrompt to reply with JSON and parse defensively (JSON.parse inside
27
+ try/catch with a non-LLM fallback).
28
+ Caps: 100 messages, 50,000 chars/message, 200,000 chars aggregate, 10,000
29
+ chars systemPrompt.
30
+ - **options.messages** (`LlmMessage[]`): Conversation so far
31
+ - **options.systemPrompt** (`string`, optional): Persona / instructions
32
+ - **options.config** (`LlmConfig`, optional): Sampling controls
33
+
34
+ **Returns:** `Promise<{text: string}>`
35
+
36
+ **Example:**
37
+ ```js
38
+ try {
39
+ const reply = await lib.llm({
40
+ messages: [{ role: 'user', content: 'Greet the player as a grumpy wizard.' }],
41
+ systemPrompt: 'You are a wizard NPC. Reply in one short sentence.',
42
+ config: { temperature: 0.8, maxOutputTokens: 60 },
43
+ });
44
+ showDialogue(reply.text);
45
+ } catch (err) {
46
+ showDialogue('The wizard grumbles incoherently.');
47
+ }
48
+ ```
49
+
50
+ ```js
51
+ lib.llm = async function(options) {};
52
+ ```
@@ -0,0 +1,19 @@
1
+ """Loads the bundled astrolib API reference markdown for the lib-docs tools."""
2
+
3
+ from pathlib import Path
4
+
5
+ _DIST_DIR = Path(__file__).parent / "astrolib"
6
+
7
+ ASTROLIB_API_REFERENCE_BASE: str = (_DIST_DIR / "lib-api-reference-base.md").read_text()
8
+ ASTROLIB_API_REFERENCE_ANIMATION: str = (_DIST_DIR / "lib-api-reference-animation.md").read_text()
9
+ ASTROLIB_API_REFERENCE_LLM: str = (_DIST_DIR / "lib-api-reference-llm.md").read_text()
10
+
11
+
12
+ def get_astrolib_api_reference(animation_enabled: bool = True, lib_llm_enabled: bool = True) -> str:
13
+ """Get the astrolib API reference, optionally including animation and lib.llm docs."""
14
+ parts = [ASTROLIB_API_REFERENCE_BASE]
15
+ if animation_enabled:
16
+ parts.append(ASTROLIB_API_REFERENCE_ANIMATION)
17
+ if lib_llm_enabled:
18
+ parts.append(ASTROLIB_API_REFERENCE_LLM)
19
+ return "\n".join(parts)
@@ -0,0 +1 @@
1
+ """Cognito auth for the Creator MCP: shared JWT helpers and the stdio login flow."""
@@ -0,0 +1,71 @@
1
+ """Cognito JWT helpers used by the stdio login flow.
2
+
3
+ Verification mirrors the AstroBackend custom authorizer: RS256 against the pool's JWKS, issuer
4
+ match, token_use == "access", client_id match. Cognito access tokens carry no `aud`, so audience
5
+ verification is disabled — the client_id check is the equivalent binding.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ import jwt
11
+ from jwt import PyJWKClient
12
+
13
+ from astrocade_creator_mcp.config import CognitoConfig
14
+
15
+ # One JWKS client per JWKS URL; PyJWKClient caches signing keys and refetches on a key miss.
16
+ _jwks_clients: dict[str, PyJWKClient] = {}
17
+
18
+
19
+ def _jwks_client(jwks_url: str) -> PyJWKClient:
20
+ client = _jwks_clients.get(jwks_url)
21
+ if client is None:
22
+ client = PyJWKClient(jwks_url)
23
+ _jwks_clients[jwks_url] = client
24
+ return client
25
+
26
+
27
+ def verify_access_token(cfg: CognitoConfig, token: str) -> dict[str, Any] | None:
28
+ """Verify a Cognito ACCESS token's signature and claims.
29
+
30
+ Returns the decoded claims on success, or None if the token is invalid, expired, from the
31
+ wrong pool/issuer, not an access token, or issued for a different client.
32
+ """
33
+ try:
34
+ signing_key = _jwks_client(cfg.jwks_url).get_signing_key_from_jwt(token)
35
+ claims: dict[str, Any] = jwt.decode(
36
+ token,
37
+ signing_key.key,
38
+ algorithms=["RS256"],
39
+ issuer=cfg.issuer_url,
40
+ options={"verify_aud": False},
41
+ )
42
+ except Exception:
43
+ return None
44
+
45
+ if claims.get("token_use") != "access":
46
+ return None
47
+ if claims.get("client_id") != cfg.client_id:
48
+ return None
49
+ return claims
50
+
51
+
52
+ def decode_unverified_claims(token: str) -> dict[str, Any]:
53
+ """Decode a JWT's claims WITHOUT signature verification.
54
+
55
+ Used to read the user id out of a token whose signature was already established: one we just
56
+ obtained from Cognito ourselves. The backend re-verifies the signature on every call regardless.
57
+ """
58
+ claims: dict[str, Any] = jwt.decode(token, options={"verify_signature": False})
59
+ return claims
60
+
61
+
62
+ def user_id_from_claims(claims: dict[str, Any]) -> str:
63
+ """Resolve the Astrocade user id from token claims.
64
+
65
+ We only ever see Cognito ACCESS tokens (that's what the frontend forwards as the
66
+ Authorization header), which carry `username` but not `cognito:username`. The backend
67
+ resolves the same access token to the same id, so they match. `cognito:username` (present
68
+ on id tokens) and `sub` are fallbacks for robustness, not the expected path.
69
+ """
70
+ user_id = claims.get("username") or claims.get("cognito:username") or claims.get("sub", "")
71
+ return str(user_id)