delimit-cli 4.6.0 → 4.6.1

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.
@@ -48,6 +48,55 @@ QUEUE_FILE = Path.home() / ".delimit" / "social_scan_queue.jsonl"
48
48
  DEFAULT_DEDUPE_HOURS = 24 * 7 # don't re-queue a fingerprint within 7 days
49
49
  DEFAULT_EXPIRE_HOURS = 24 * 7 # entries older than 7 days roll to expired
50
50
 
51
+ # Per-platform freshness cap at claim_pending time. Reddit posts decay
52
+ # in comment-visibility VERY fast (Boris-Cherny LED-1335: <6h high-yield,
53
+ # <12h marginal, ~zero after 24h), so drafting on a 3-day-old post wastes
54
+ # a brand-account engagement. Other platforms (github, devto) have longer
55
+ # half-lives — issue threads can be relevant weeks later — so we don't
56
+ # apply the freshness cap there.
57
+ #
58
+ # Founder regression report 2026-05-18: drafts were being generated on
59
+ # posts queued 3+ days earlier because the queue is FIFO and the drafter
60
+ # falls behind the scanner. This filter ensures claim_pending() never
61
+ # returns reddit entries whose `queued_at` is more than CLAIM_FRESHNESS_HOURS
62
+ # old, regardless of queue position.
63
+ CLAIM_FRESHNESS_HOURS_BY_PLATFORM: Dict[str, int] = {
64
+ "reddit": 24,
65
+ # Phase C (2026-05-18): github targets fail ~96% of the time
66
+ # (historical 3256/3389 marked drafted_failed) and the queue grew
67
+ # to 1122 pending dominating FIFO order. 24h cap drains stale crud
68
+ # while preserving fresh github targets the drafter would actually
69
+ # process. Without this, github starves reddit/x/hn even with the
70
+ # round-robin claim (see CLAIM_MAX_PER_PLATFORM).
71
+ "github": 24,
72
+ }
73
+
74
+ # Phase C (2026-05-18): round-robin claim_pending across platforms so a
75
+ # noisy platform doesn't starve quieter ones. Drafter calls claim_pending
76
+ # with limit=10; pre-Phase-C this returned 10 oldest entries regardless
77
+ # of platform, which with github=1122 pending meant 10/10 github and
78
+ # reddit drafts never fired. With this cap, drafter sees a balanced mix.
79
+ # Within-platform order is FIFO (oldest pending first) EXCEPT for
80
+ # platforms in CLAIM_LIFO_PLATFORMS — see below.
81
+ CLAIM_MAX_PER_PLATFORM: int = 3
82
+
83
+ # Phase D (2026-05-18 founder request): "we need first-poster advantage."
84
+ # For time-critical engagement platforms, the drafter should pick the
85
+ # FRESHEST pending entry, not the oldest. Reddit comment visibility decays
86
+ # sharply after the first 15-30 minutes of a thread (the first 5-10 visible
87
+ # comments capture the bulk of upvotes + clickthrough). Pre-Phase-D's
88
+ # within-reddit FIFO meant the drafter pulled 22-24h-old entries (near
89
+ # the freshness cap) instead of brand-new ones.
90
+ #
91
+ # LIFO-within-platform reverses that for the listed platforms: within the
92
+ # eligible bucket, sort newest queued_at first. Across platforms, round-
93
+ # robin still applies. Entries that get displaced by newer ones are
94
+ # naturally cleaned up by the freshness cap groomer.
95
+ #
96
+ # Other platforms (github, devto, etc.) keep FIFO — their content has a
97
+ # longer half-life and oldest-first is the right discipline there.
98
+ CLAIM_LIFO_PLATFORMS: set = {"reddit"}
99
+
51
100
  PENDING = "pending"
52
101
  DRAFTED = "drafted"
53
102
  DRAFTED_FAILED = "drafted_failed"
@@ -184,23 +233,130 @@ def enqueue(target: Dict[str, Any], dedupe_hours: int = DEFAULT_DEDUPE_HOURS) ->
184
233
 
185
234
 
186
235
  def claim_pending(platform: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
187
- """Return up to ``limit`` pending entries, optionally filtered by platform.
236
+ """Return up to ``limit`` pending entries, with round-robin balancing
237
+ across platforms when ``platform`` is None.
188
238
 
189
239
  Read-only — does NOT mutate state. The caller must call ``mark_drafted``
190
- or ``mark_failed`` once it processes the entry. Returns oldest-first
191
- (FIFO) so the queue drains in scan order.
240
+ or ``mark_failed`` once it processes the entry.
241
+
242
+ Round-robin (Phase C, 2026-05-18): without a platform filter, returns
243
+ at most CLAIM_MAX_PER_PLATFORM entries from any single platform per
244
+ call. Within each platform, oldest-first (FIFO). Across platforms,
245
+ interleaved so the drafter sees a balanced mix instead of saturating
246
+ on whichever platform has the deepest backlog.
247
+
248
+ With an explicit ``platform`` filter, behaves as FIFO over that single
249
+ platform's pending entries (no per-platform cap, since the caller is
250
+ already targeting one platform).
251
+
252
+ Freshness cap (Phase A, 2026-05-18): entries whose platform has a
253
+ CLAIM_FRESHNESS_HOURS_BY_PLATFORM cap and whose ``queued_at`` is
254
+ older than that cap are skipped silently. Those stale entries stay
255
+ in the file with status=pending; the separate ``expire_stale_for_
256
+ freshness_caps`` pass flips them so they don't pile up forever.
192
257
  """
193
- out: List[Dict[str, Any]] = []
194
- # Build a list because we want oldest-first; JSONL append order = FIFO.
195
- for entry in _iter_entries():
258
+ now = datetime.now(timezone.utc)
259
+
260
+ def _is_eligible(entry: Dict[str, Any]) -> bool:
196
261
  if entry.get("status") != PENDING:
197
- continue
262
+ return False
198
263
  if platform and entry.get("platform") != platform:
264
+ return False
265
+ cap = CLAIM_FRESHNESS_HOURS_BY_PLATFORM.get(entry.get("platform"))
266
+ if cap is not None:
267
+ qts = _parse_iso(entry.get("queued_at"))
268
+ if qts is not None and (now - qts) > timedelta(hours=cap):
269
+ return False
270
+ return True
271
+
272
+ # Single-platform filter path: keep legacy strict-FIFO behavior.
273
+ if platform:
274
+ out: List[Dict[str, Any]] = []
275
+ for entry in _iter_entries():
276
+ if not _is_eligible(entry):
277
+ continue
278
+ out.append(entry)
279
+ if len(out) >= limit:
280
+ break
281
+ return out
282
+
283
+ # Round-robin path: group by platform first (preserving within-platform
284
+ # FIFO via iteration order), then cap per-platform and interleave.
285
+ # CRITICAL Phase D change: for CLAIM_LIFO_PLATFORMS, collect the full
286
+ # eligible set per platform first, then sort newest-first BEFORE
287
+ # truncation — otherwise the early-break truncation in the FIFO path
288
+ # would keep the oldest entries even when we want the newest.
289
+ by_platform: Dict[str, List[Dict[str, Any]]] = {}
290
+ for entry in _iter_entries():
291
+ if not _is_eligible(entry):
199
292
  continue
200
- out.append(entry)
201
- if len(out) >= limit:
293
+ plat = entry.get("platform") or "unknown"
294
+ by_platform.setdefault(plat, []).append(entry)
295
+
296
+ # Sort + truncate each bucket.
297
+ for plat in list(by_platform.keys()):
298
+ if plat in CLAIM_LIFO_PLATFORMS:
299
+ # Newest queued_at first. Parse-failures sort last so a
300
+ # corrupted-timestamp entry doesn't block legitimate fresh
301
+ # entries from being claimed.
302
+ by_platform[plat].sort(
303
+ key=lambda e: _parse_iso(e.get("queued_at")) or datetime.min.replace(tzinfo=timezone.utc),
304
+ reverse=True,
305
+ )
306
+ # FIFO platforms keep insertion order (which is JSONL-append =
307
+ # oldest first); no sort needed.
308
+ by_platform[plat] = by_platform[plat][:CLAIM_MAX_PER_PLATFORM]
309
+
310
+ # Interleave: round-robin across platforms in alphabetical order for
311
+ # determinism. Stop when limit is reached or all buckets are drained.
312
+ out2: List[Dict[str, Any]] = []
313
+ plat_order = sorted(by_platform.keys())
314
+ idx = {p: 0 for p in plat_order}
315
+ while len(out2) < limit:
316
+ added = False
317
+ for p in plat_order:
318
+ if idx[p] < len(by_platform[p]):
319
+ out2.append(by_platform[p][idx[p]])
320
+ idx[p] += 1
321
+ added = True
322
+ if len(out2) >= limit:
323
+ break
324
+ if not added:
202
325
  break
203
- return out
326
+ return out2
327
+
328
+
329
+ def expire_stale_for_freshness_caps() -> Dict[str, int]:
330
+ """Roll pending entries past their platform's claim freshness cap to expired.
331
+
332
+ Companion to ``claim_pending``'s in-flight skip: without this, the
333
+ queue file fills up with pending-but-permanently-skipped entries
334
+ that we still re-scan on every claim. Returns a dict
335
+ ``{platform: count_expired}`` for observability.
336
+ """
337
+ now = datetime.now(timezone.utc)
338
+ entries = list(_iter_entries())
339
+ if not entries:
340
+ return {}
341
+ flipped: Dict[str, int] = {}
342
+ changed = False
343
+ for entry in entries:
344
+ if entry.get("status") != PENDING:
345
+ continue
346
+ plat = entry.get("platform")
347
+ cap = CLAIM_FRESHNESS_HOURS_BY_PLATFORM.get(plat)
348
+ if cap is None:
349
+ continue
350
+ qts = _parse_iso(entry.get("queued_at"))
351
+ if qts is None or (now - qts) <= timedelta(hours=cap):
352
+ continue
353
+ entry["status"] = EXPIRED
354
+ entry["error"] = f"expired_freshness_cap_{cap}h"
355
+ flipped[plat] = flipped.get(plat, 0) + 1
356
+ changed = True
357
+ if changed:
358
+ _atomic_rewrite(entries)
359
+ return flipped
204
360
 
205
361
 
206
362
  def _update_entry(fingerprint: str, mutator) -> bool:
@@ -0,0 +1,329 @@
1
+ """LED-2268 P0 Phase 0.1 — gateway-side tenant API key validator.
2
+
3
+ The dashboard at app.delimit.ai (`/dashboard/api-keys`) issues per-user
4
+ keys with the `dlmt_<43-char-base64url>` shape. Only the sha256 of the
5
+ plaintext is stored — see supabase migration 034 + lib/user-api-keys.ts.
6
+
7
+ This module owns the gateway side of that contract:
8
+ - parse `Authorization: ApiKey dlmt_xxx` from an HTTP header
9
+ - sha256-hash the plaintext
10
+ - look up the hash in `user_api_keys` via service-role Supabase REST
11
+ - return `{user_id, scope, key_id}` for a live (non-revoked) match
12
+ - return None for anything else (bad shape, no match, revoked, etc.)
13
+
14
+ Phase 0.1 stays minimal on purpose:
15
+ - no `last_used_at` write (deferred — adds a write per call; Phase 0.2)
16
+ - no cache (every call hits Supabase; fine at current volume)
17
+ - no JWT, no rotation grace period — soft-delete is hard once set
18
+
19
+ Phase 0.2 will add tenant-scoped data routing (per-user data root under
20
+ ~/.delimit/tenants/<user_id>/); this module only resolves identity.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import logging
27
+ import os
28
+ import threading
29
+ import urllib.error
30
+ import urllib.parse
31
+ import urllib.request
32
+ from datetime import datetime, timezone
33
+ from typing import Optional, TypedDict
34
+
35
+ logger = logging.getLogger("delimit.tenant_auth")
36
+
37
+ # Process-local counter for failed last_used_at PATCH writes. Lets
38
+ # operators (and future /heartbeats-style health surfaces) see whether
39
+ # the audit-write fire-and-forget is silently dropping a sustained
40
+ # burst — debug log on every error is too quiet to notice in journalctl
41
+ # during a Supabase outage. Reset only on process restart by design.
42
+ _last_used_dropped_count = 0
43
+ _last_used_dropped_lock = threading.Lock()
44
+ # Log at INFO every Nth drop so a sustained outage surfaces without
45
+ # flooding the journal on transient blips. First drop is also INFO so
46
+ # the first sign of trouble is visible.
47
+ _LAST_USED_DROP_LOG_EVERY = 10
48
+
49
+
50
+ def get_last_used_dropped_count() -> int:
51
+ """How many last_used_at PATCH writes have been dropped since process start.
52
+
53
+ Read-only; intended for /heartbeats, future metrics endpoints, and
54
+ operational tooling. NOT a security signal — dropped writes don't
55
+ affect auth correctness, only audit completeness.
56
+ """
57
+ with _last_used_dropped_lock:
58
+ return _last_used_dropped_count
59
+
60
+
61
+ class TenantIdentity(TypedDict):
62
+ """Resolved tenant identity for a presented API key."""
63
+ user_id: str
64
+ scope: str
65
+ key_id: str
66
+
67
+
68
+ # The plaintext shape issued by lib/user-api-keys.ts is `dlmt_` + 43
69
+ # base64url chars (32 random bytes encoded). Reject anything that doesn't
70
+ # fit before hashing — saves a Supabase round-trip on malformed input.
71
+ _KEY_PREFIX = "dlmt_"
72
+ _KEY_PLAINTEXT_LEN_MIN = len(_KEY_PREFIX) + 32 # be lenient on lower bound
73
+ _KEY_PLAINTEXT_LEN_MAX = len(_KEY_PREFIX) + 128 # cap to defeat absurd inputs
74
+
75
+
76
+ def parse_auth_header(header: str) -> Optional[tuple[str, str]]:
77
+ """Parse `Authorization` into (scheme, token).
78
+
79
+ Recognizes two schemes:
80
+ - `Bearer <token>` — existing shared-bearer pattern (founder/system)
81
+ - `ApiKey <plaintext>` — per-user tenant key (this module's domain)
82
+
83
+ Returns (scheme_lowercase, token) on match, None on anything else.
84
+ Caller decides which scheme is acceptable for which endpoint.
85
+ """
86
+ if not header:
87
+ return None
88
+ parts = header.split(None, 1)
89
+ if len(parts) != 2:
90
+ return None
91
+ scheme, token = parts[0].strip().lower(), parts[1].strip()
92
+ if scheme in ("bearer", "apikey") and token:
93
+ return (scheme, token)
94
+ return None
95
+
96
+
97
+ def _hash_key(plaintext: str) -> str:
98
+ """sha256(plaintext) as lowercase hex — matches lib/user-api-keys.ts."""
99
+ return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
100
+
101
+
102
+ def _looks_like_tenant_key(plaintext: str) -> bool:
103
+ """Cheap shape check before we bother Supabase."""
104
+ if not plaintext.startswith(_KEY_PREFIX):
105
+ return False
106
+ n = len(plaintext)
107
+ return _KEY_PLAINTEXT_LEN_MIN <= n <= _KEY_PLAINTEXT_LEN_MAX
108
+
109
+
110
+ def validate_api_key(plaintext: str) -> Optional[TenantIdentity]:
111
+ """Resolve `dlmt_xxx` plaintext to a tenant identity, or None.
112
+
113
+ Returns None for: malformed input, no Supabase config, network
114
+ failure, no row matched, row marked revoked. Caller treats None as
115
+ "unauthorized" — never leak why specifically.
116
+
117
+ This function is intentionally synchronous + fire-and-forget on
118
+ errors. Logs them at debug level. Production audit comes from the
119
+ request-log layer (each endpoint logs the resolved user_id, not
120
+ the validator).
121
+ """
122
+ if not _looks_like_tenant_key(plaintext):
123
+ return None
124
+
125
+ supabase_url = os.environ.get("SUPABASE_URL", "").rstrip("/")
126
+ service_key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
127
+ if not supabase_url or not service_key:
128
+ # If the gateway host hasn't been configured for Supabase, tenant
129
+ # auth simply doesn't work — the shared-bearer path stays intact.
130
+ logger.debug("validate_api_key: supabase env not configured")
131
+ return None
132
+
133
+ key_hash = _hash_key(plaintext)
134
+ # Active-only lookup: the partial index `idx_user_api_keys_active_hash`
135
+ # makes this O(log n) and gauarantees revoked keys never match.
136
+ url = (
137
+ f"{supabase_url}/rest/v1/user_api_keys"
138
+ f"?select=id,user_id,scope"
139
+ f"&key_hash=eq.{urllib.parse.quote(key_hash, safe='')}"
140
+ f"&revoked_at=is.null"
141
+ f"&limit=1"
142
+ )
143
+ req = urllib.request.Request(
144
+ url,
145
+ headers={
146
+ "apikey": service_key,
147
+ "Authorization": f"Bearer {service_key}",
148
+ "Accept": "application/json",
149
+ },
150
+ )
151
+ try:
152
+ with urllib.request.urlopen(req, timeout=5) as resp:
153
+ body = resp.read()
154
+ except urllib.error.HTTPError as e:
155
+ logger.debug("validate_api_key supabase HTTP %s", getattr(e, "code", "?"))
156
+ return None
157
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
158
+ logger.debug("validate_api_key supabase net err: %s", e)
159
+ return None
160
+
161
+ try:
162
+ rows = json.loads(body)
163
+ except json.JSONDecodeError:
164
+ logger.debug("validate_api_key non-json response")
165
+ return None
166
+ if not isinstance(rows, list) or not rows:
167
+ return None
168
+ row = rows[0]
169
+ if not isinstance(row, dict):
170
+ return None
171
+ user_id = row.get("user_id") or ""
172
+ if not user_id:
173
+ return None
174
+ key_id = str(row.get("id") or "")
175
+ # Phase 0.2: fire-and-forget last_used_at write. Lets operators see
176
+ # "this key was actually used in the last N hours" in the dashboard
177
+ # API-keys list, which is important for rotation hygiene (you can
178
+ # tell which keys are dead before deciding what to revoke).
179
+ # Backgrounded so the validate path stays as fast as it was in 0.1.
180
+ if key_id:
181
+ _fire_last_used_update(supabase_url, service_key, key_id)
182
+ return TenantIdentity(
183
+ user_id=str(user_id),
184
+ scope=str(row.get("scope") or ""),
185
+ key_id=key_id,
186
+ )
187
+
188
+
189
+ def _fire_last_used_update(supabase_url: str, service_key: str, key_id: str) -> None:
190
+ """Background-thread PATCH to bump last_used_at on a successful validate.
191
+
192
+ Errors are swallowed; the validate path NEVER blocks on this and the
193
+ foreground response is unaffected. The point is best-effort audit
194
+ signal, not authorization.
195
+
196
+ The thread is daemonised so a hung Supabase call can't keep the
197
+ process alive past shutdown.
198
+ """
199
+ def _patch():
200
+ try:
201
+ url = (
202
+ f"{supabase_url.rstrip('/')}/rest/v1/user_api_keys"
203
+ f"?id=eq.{urllib.parse.quote(key_id, safe='')}"
204
+ )
205
+ body = json.dumps({
206
+ "last_used_at": datetime.now(timezone.utc).isoformat(),
207
+ }).encode("utf-8")
208
+ req = urllib.request.Request(
209
+ url,
210
+ data=body,
211
+ method="PATCH",
212
+ headers={
213
+ "apikey": service_key,
214
+ "Authorization": f"Bearer {service_key}",
215
+ "Content-Type": "application/json",
216
+ # Prefer: return=minimal — we don't need the row back.
217
+ "Prefer": "return=minimal",
218
+ },
219
+ )
220
+ with urllib.request.urlopen(req, timeout=5):
221
+ pass
222
+ except Exception as e: # noqa: BLE001 — fire-and-forget; never raise
223
+ # Bump the process-local dropped-write counter and log at
224
+ # INFO every Nth drop (plus the first). Lets a sustained
225
+ # outage surface in journalctl without spam on blips.
226
+ global _last_used_dropped_count
227
+ with _last_used_dropped_lock:
228
+ _last_used_dropped_count += 1
229
+ count = _last_used_dropped_count
230
+ if count == 1 or count % _LAST_USED_DROP_LOG_EVERY == 0:
231
+ logger.info(
232
+ "last_used_at update dropped (cum_dropped=%d): %s",
233
+ count, e,
234
+ )
235
+ else:
236
+ logger.debug(
237
+ "last_used_at update dropped (cum_dropped=%d): %s",
238
+ count, e,
239
+ )
240
+
241
+ t = threading.Thread(target=_patch, daemon=True, name="delimit-last-used-update")
242
+ t.start()
243
+
244
+
245
+ def authenticate(
246
+ header: str,
247
+ shared_bearer: str = "",
248
+ impersonation_header: str = "",
249
+ ) -> Optional[dict]:
250
+ """End-to-end auth resolver for an HTTP request.
251
+
252
+ Returns a dict describing the resolved identity, or None if the
253
+ request should be rejected. Three accepted-request outcomes:
254
+
255
+ - `{"auth_mode": "bearer", "is_tenant_scoped": False}` — shared-
256
+ bearer match WITHOUT impersonation. Founder/system access to
257
+ the shared `~/.delimit/` view. No user_id field present.
258
+ - `{"auth_mode": "bearer", "is_tenant_scoped": True, "user_id":
259
+ ..., "scope": "", "key_id": "bearer-impersonation"}` — shared
260
+ bearer match WITH a valid impersonation header. The trusted
261
+ BFF/system is acting on behalf of a specific tenant (LED-2268
262
+ Phase 0.5a, lets the Vercel dashboard read/write tenant data
263
+ on behalf of a NextAuth-authenticated user without the user
264
+ ever exposing their plaintext API key to the BFF).
265
+ - `{"auth_mode": "apikey", "is_tenant_scoped": True, "user_id":
266
+ ..., "scope": ..., "key_id": ...}` — tenant key match.
267
+
268
+ Trust model: the shared bearer is held only by a SMALL set of
269
+ trusted clients (Vercel BFF + the gateway host). If it leaks, the
270
+ blast radius is already total (founder-class access to everything
271
+ the gateway serves). The impersonation header just lets that
272
+ bearer be more granular per-request; it does NOT grant access the
273
+ bearer didn't already have.
274
+
275
+ Order: Bearer first (cheap string compare), then ApiKey (Supabase
276
+ round-trip). A request can only present one Authorization header,
277
+ so the order is which-scheme-wins-when-the-shape-fits.
278
+ """
279
+ parsed = parse_auth_header(header)
280
+ if not parsed:
281
+ return None
282
+ scheme, token = parsed
283
+ if scheme == "bearer":
284
+ if not shared_bearer or token != shared_bearer:
285
+ return None
286
+ # Phase 0.5a — optional tenant impersonation. If the BFF/system
287
+ # presented a tenant header AND it sanitises to a valid segment,
288
+ # treat as tenant-scoped under that user_id. Validate via the
289
+ # SAME sanitiser tenant_paths uses for filesystem routing so the
290
+ # downstream code sees a consistent identity.
291
+ if impersonation_header:
292
+ # Lazy import to avoid circular: tenant_paths only needed when
293
+ # impersonation is actually requested.
294
+ from . import tenant_paths
295
+ seg = tenant_paths.safe_user_segment(impersonation_header)
296
+ if seg is None:
297
+ # Header was present but garbage. Reject the request
298
+ # entirely rather than silently falling back to shared
299
+ # scope — a confused BFF surfacing here is exactly the
300
+ # class of bug that header validation should catch.
301
+ logger.info(
302
+ "authenticate: bearer + invalid impersonation header rejected: %r",
303
+ impersonation_header[:64],
304
+ )
305
+ return None
306
+ # We pass the RAW header value (not the sanitised segment)
307
+ # downstream so callers see the same user_id shape as the
308
+ # ApiKey path. tenant_paths.safe_user_segment runs again
309
+ # inside tenant_data_root for actual fs routing.
310
+ return {
311
+ "auth_mode": "bearer",
312
+ "is_tenant_scoped": True,
313
+ "user_id": impersonation_header,
314
+ "scope": "",
315
+ "key_id": "bearer-impersonation",
316
+ }
317
+ return {"auth_mode": "bearer", "is_tenant_scoped": False}
318
+ if scheme == "apikey":
319
+ identity = validate_api_key(token)
320
+ if identity is None:
321
+ return None
322
+ return {
323
+ "auth_mode": "apikey",
324
+ "is_tenant_scoped": True,
325
+ "user_id": identity["user_id"],
326
+ "scope": identity["scope"],
327
+ "key_id": identity["key_id"],
328
+ }
329
+ return None