delimit-cli 4.6.0 → 4.6.2
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.
- package/CHANGELOG.md +71 -8
- package/bin/delimit-cli.js +59 -9
- package/bin/delimit-setup.js +7 -3
- package/gateway/ai/agent_dispatch.py +5 -0
- package/gateway/ai/backends/gateway_core.py +6 -0
- package/gateway/ai/backends/git_health.py +175 -0
- package/gateway/ai/backends/memory_bridge.py +210 -53
- package/gateway/ai/backends/tools_infra.py +93 -0
- package/gateway/ai/backends/tools_real.py +53 -7
- package/gateway/ai/cli_contract.py +185 -0
- package/gateway/ai/governance.py +181 -0
- package/gateway/ai/heartbeat.py +290 -0
- package/gateway/ai/ledger_manager.py +81 -4
- package/gateway/ai/ledger_proof.py +127 -0
- package/gateway/ai/license.py +132 -47
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/license_core.pyi +1 -1
- package/gateway/ai/outreach_loop_daemon.py +349 -0
- package/gateway/ai/outreach_substantive.py +768 -7
- package/gateway/ai/pro_tools.yaml +167 -0
- package/gateway/ai/reddit_scanner.py +7 -1
- package/gateway/ai/server.py +295 -116
- package/gateway/ai/session_phoenix.py +121 -0
- package/gateway/ai/social_queue.py +166 -10
- package/gateway/ai/tenant_auth.py +329 -0
- package/gateway/ai/tenant_data.py +339 -0
- package/gateway/ai/tenant_paths.py +150 -0
- package/gateway/core/diff_engine_v2.py +517 -54
- package/gateway/core/semver_classifier.py +52 -6
- package/package.json +4 -1
- package/scripts/build-license-core.sh +0 -85
- package/scripts/security-check.sh +0 -66
- package/scripts/test-license-core-so.sh +0 -107
|
@@ -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,
|
|
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.
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
258
|
+
now = datetime.now(timezone.utc)
|
|
259
|
+
|
|
260
|
+
def _is_eligible(entry: Dict[str, Any]) -> bool:
|
|
196
261
|
if entry.get("status") != PENDING:
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|