lcp-reference-platform 1.0.0__tar.gz → 1.0.2__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.
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/.gitignore +6 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/PKG-INFO +1 -1
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/README.md +73 -2
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/__init__.py +1 -1
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/admin.py +21 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/matching.py +55 -4
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/messages.py +4 -1
- lcp_reference_platform-1.0.2/lcp_platform/observability.py +23 -0
- lcp_reference_platform-1.0.2/lcp_platform/offer_extensions.py +151 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/postgres.py +30 -8
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/router.py +318 -41
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/service.py +73 -29
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/storage.py +1117 -28
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/validation.py +70 -22
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/wsgi.py +16 -3
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/pyproject.toml +1 -1
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/tests/test_new_flows.py +251 -1
- lcp_reference_platform-1.0.2/tests/test_offer_extensions.py +95 -0
- lcp_reference_platform-1.0.2/tests/test_platform.py +1035 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/tests/test_postgres_integration.py +59 -1
- lcp_reference_platform-1.0.2/tests/test_wsgi.py +183 -0
- lcp_reference_platform-1.0.0/tests/test_platform.py +0 -409
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/Dockerfile +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/docker-compose.yml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/README.md +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/configmap.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/deployment.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/hpa.example.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/network-policy.example.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/pod-disruption-budget.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/pvc.example.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/secret.example.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/service.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/README.md +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/kyverno-test.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/provenance-policy.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/resources.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/sbom-policy.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/signature-policy.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/verify-images-kyverno.example.yaml +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/api_keys.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/attachments.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/auth.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/config.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/crypto.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/database.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/mapping.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/secrets.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/security.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/server.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/worker.py +0 -0
- {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/tests/test_api_keys.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# Local tooling artifacts — never published
|
|
5
5
|
.gitnexus/
|
|
6
|
+
.freebuff/
|
|
6
7
|
.claude/
|
|
7
8
|
graphify-out/
|
|
8
9
|
CLAUDE.md
|
|
@@ -14,3 +15,8 @@ __pycache__/
|
|
|
14
15
|
.venv/
|
|
15
16
|
data/
|
|
16
17
|
*.sqlite3
|
|
18
|
+
|
|
19
|
+
# SDK build artifacts (Maven/Gradle) — never published
|
|
20
|
+
implementations/sdk/java/target/
|
|
21
|
+
implementations/sdk/kotlin/.gradle/
|
|
22
|
+
implementations/sdk/kotlin/build/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcp-reference-platform
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Production-oriented reference platform and router for LCP Lead Context Protocol deployments.
|
|
5
5
|
Project-URL: Documentation, https://github.com/SpearSystems/LCP
|
|
6
6
|
Project-URL: Source, https://github.com/SpearSystems/LCP
|
|
@@ -18,7 +18,8 @@ hardening.
|
|
|
18
18
|
- Structured call payable evaluation and per-offer monthly quota reporting.
|
|
19
19
|
- JSON Schema and strict ping-safe validation.
|
|
20
20
|
- Bearer API-key and HMAC authentication.
|
|
21
|
-
- Deterministic offer matching and auction selection
|
|
21
|
+
- Deterministic offer matching and auction selection, including versioned
|
|
22
|
+
`lcp.platform.requirements` and `lcp.platform.service_area` extensions.
|
|
22
23
|
- At-least-once buyer webhook delivery.
|
|
23
24
|
- Standalone delivery worker for multi-process deployments.
|
|
24
25
|
- WSGI entry point for a production HTTP process manager.
|
|
@@ -126,7 +127,10 @@ lcp-platform-admin privacy erase-lead --lead-id lead_abc123 --actor-id privacy_o
|
|
|
126
127
|
```
|
|
127
128
|
|
|
128
129
|
Offer management is intentionally an operator/admin concern. The public LCP
|
|
129
|
-
surface exposes active offers through `GET /v1/lcp/offers`.
|
|
130
|
+
surface exposes active offers through `GET /v1/lcp/offers`. For fast-changing
|
|
131
|
+
buyer requirements, use the versioned [requirement profiles and service areas](../../docs/REQUIREMENT-PROFILES.md)
|
|
132
|
+
without expanding the universal core or creating a new vertical for each
|
|
133
|
+
category.
|
|
130
134
|
|
|
131
135
|
## Run locally
|
|
132
136
|
|
|
@@ -167,6 +171,38 @@ gunicorn --bind 0.0.0.0:8080 --workers 3 --threads 4 \
|
|
|
167
171
|
--access-logfile - lcp_platform.wsgi:application
|
|
168
172
|
```
|
|
169
173
|
|
|
174
|
+
The WSGI path handles binary attachments with the same behavior as the local
|
|
175
|
+
server: the attachment body limit (`LCP_MAX_ATTACHMENT_BYTES`) is selected
|
|
176
|
+
before `wsgi.input` is read, downloads stream raw encrypted bytes (never
|
|
177
|
+
`json.dumps`), and binary headers (`Content-Type`, `Content-Disposition`,
|
|
178
|
+
`Content-Length`) are forwarded. Upload and download with curl:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
export LCP_ATTACHMENT_FILE=scanned-evidence.pdf
|
|
182
|
+
export LCP_ATTACHMENT_ID=att_evidence_001
|
|
183
|
+
export LCP_ATTACHMENT_SHA256="$(sha256sum "$LCP_ATTACHMENT_FILE" | cut -d' ' -f1)"
|
|
184
|
+
|
|
185
|
+
# Upload: Bearer-authenticated raw bytes. The route's body limit applies before
|
|
186
|
+
# wsgi.input is read; replace the lead and sender IDs with your own values.
|
|
187
|
+
curl -sS -X POST "$LCP_ENDPOINT/v1/lcp/attachments" \
|
|
188
|
+
-H "Authorization: Bearer $LCP_API_KEY" \
|
|
189
|
+
-H "X-LCP-Sender-Id: publisher_001" \
|
|
190
|
+
-H "X-LCP-Lead-Id: <lead_id>" \
|
|
191
|
+
-H "X-LCP-Attachment-Id: $LCP_ATTACHMENT_ID" \
|
|
192
|
+
-H "X-LCP-Attachment-Purpose: supporting_document" \
|
|
193
|
+
-H "X-LCP-Filename: $LCP_ATTACHMENT_FILE" \
|
|
194
|
+
-H "X-LCP-Content-SHA256: $LCP_ATTACHMENT_SHA256" \
|
|
195
|
+
-H "X-LCP-Idempotency-Key: publisher-attachment-001" \
|
|
196
|
+
-H "Content-Type: application/pdf" \
|
|
197
|
+
--data-binary @"$LCP_ATTACHMENT_FILE"
|
|
198
|
+
|
|
199
|
+
# Download: bytes are returned unchanged with the original content headers.
|
|
200
|
+
curl -sS "$LCP_ENDPOINT/v1/lcp/attachments/$LCP_ATTACHMENT_ID" \
|
|
201
|
+
-H "Authorization: Bearer $LCP_API_KEY" \
|
|
202
|
+
-H "X-LCP-Sender-Id: publisher_001" \
|
|
203
|
+
-o evidence.bin
|
|
204
|
+
```
|
|
205
|
+
|
|
170
206
|
Run `lcp-platform-worker` as a separate supervised process. Place the
|
|
171
207
|
application behind TLS; the included threaded server is intended for local
|
|
172
208
|
operation and smoke tests.
|
|
@@ -189,12 +225,47 @@ multiple nodes or high throughput.
|
|
|
189
225
|
| `GET` | `/v1/lcp/leads/{lead_id}` | Lead status and lifecycle |
|
|
190
226
|
| `GET` | `/health/live` | Unauthenticated process liveness |
|
|
191
227
|
| `GET` | `/health/ready` | Database-backed readiness |
|
|
228
|
+
| `GET` | `/v1/lcp/metrics` | Admin-scoped aggregate worker and retention metrics |
|
|
192
229
|
| `GET` | `/v1/lcp/capabilities` | Capability discovery |
|
|
193
230
|
| `GET` | `/v1/lcp/offers` | Active offer discovery |
|
|
194
231
|
| `GET` | `/v1/lcp/schemas/{name}` | Schema discovery |
|
|
195
232
|
|
|
196
233
|
The complete transport contract is [api/lcp-openapi.yaml](../../api/lcp-openapi.yaml).
|
|
197
234
|
|
|
235
|
+
`/v1/lcp/metrics` requires the `platform:admin` scope in an authenticated
|
|
236
|
+
production deployment. It returns queue age, retry depth, lease expiry,
|
|
237
|
+
attachment scanner/deletion backlog, and dead-letter counts only; it never
|
|
238
|
+
returns lead IDs, buyer IDs, tenant IDs, URLs, or consumer payloads.
|
|
239
|
+
|
|
240
|
+
Exhausted delivery and routing jobs are durable dead letters. Operators can
|
|
241
|
+
inspect and recover them without reading payloads:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
lcp-platform-admin dead-letter list --status OPEN
|
|
245
|
+
lcp-platform-admin dead-letter quarantine --job-id <job-id>
|
|
246
|
+
lcp-platform-admin dead-letter replay --job-id <job-id>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Lifecycle events are role-scoped and idempotent. A delivered buyer reports
|
|
250
|
+
`ACCEPTED`/`REJECTED`/`DISPUTED`; the publisher reports `CONSENT_WITHDRAWN`
|
|
251
|
+
and `ERASURE_REQUEST`; every applied transition writes an audit record with
|
|
252
|
+
actor, event, and previous status. Status reads are projected by role — a
|
|
253
|
+
buyer sees only its own match decisions, payables, and events:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
# Buyer confirms the delivered lead (event -> audit row lead.accepted).
|
|
257
|
+
curl -sS -X POST "$LCP_ENDPOINT/v1/lcp/events" \
|
|
258
|
+
-H "Authorization: Bearer $LCP_API_KEY" \
|
|
259
|
+
-H "X-LCP-Sender-Id: buyer_001" \
|
|
260
|
+
-H "X-LCP-Idempotency-Key: buyer-accept-001" \
|
|
261
|
+
-H "Content-Type: application/json" \
|
|
262
|
+
-d '{"lcp":{"version":"1.0.0","message":{"type":"event","id":"<uuid>","timestamp":"2026-08-18T00:00:00Z","sender_id":"buyer_001","receiver_id":"platform_001","idempotency_key":"buyer-accept-001"},"payload":{"lead_id":"<lead_id>","event":"ACCEPTED","timestamp":"2026-08-18T00:00:00Z"}}}'
|
|
263
|
+
|
|
264
|
+
# Publisher reads status; the response contains only publisher-scoped data.
|
|
265
|
+
curl -sS "$LCP_ENDPOINT/v1/lcp/leads/<lead_id>" \
|
|
266
|
+
-H "Authorization: Bearer $LCP_API_KEY"
|
|
267
|
+
```
|
|
268
|
+
|
|
198
269
|
## Tenant and credential model
|
|
199
270
|
|
|
200
271
|
Every sender has a tenant ID and scopes. Examples include `lead:submit`,
|
|
@@ -35,6 +35,16 @@ def main() -> None:
|
|
|
35
35
|
erase_lead.add_argument("--lead-id", required=True)
|
|
36
36
|
erase_lead.add_argument("--actor-id", default="operator")
|
|
37
37
|
|
|
38
|
+
dead_letter = commands.add_parser("dead-letter", help="Inspect and recover exhausted worker jobs")
|
|
39
|
+
dead_letter_sub = dead_letter.add_subparsers(dest="dead_letter_command", required=True)
|
|
40
|
+
list_dead_letter = dead_letter_sub.add_parser("list")
|
|
41
|
+
list_dead_letter.add_argument(
|
|
42
|
+
"--status", choices=["OPEN", "QUARANTINED", "REPLAYED"], default=None
|
|
43
|
+
)
|
|
44
|
+
for command_name in ("quarantine", "replay"):
|
|
45
|
+
command = dead_letter_sub.add_parser(command_name)
|
|
46
|
+
command.add_argument("--job-id", required=True)
|
|
47
|
+
|
|
38
48
|
mapping = commands.add_parser("mapping", help="Manage publisher form mappings")
|
|
39
49
|
mapping_sub = mapping.add_subparsers(dest="mapping_command", required=True)
|
|
40
50
|
upsert_mapping = mapping_sub.add_parser("upsert")
|
|
@@ -70,6 +80,17 @@ def main() -> None:
|
|
|
70
80
|
if args.privacy_command == "erase-lead":
|
|
71
81
|
platform.erase_lead(args.lead_id, actor_id=args.actor_id)
|
|
72
82
|
print(f"Erased lead {args.lead_id}")
|
|
83
|
+
elif args.command == "dead-letter":
|
|
84
|
+
if args.dead_letter_command == "list":
|
|
85
|
+
print(json.dumps(platform.store.list_dead_letters(args.status), indent=2))
|
|
86
|
+
elif args.dead_letter_command == "quarantine":
|
|
87
|
+
if not platform.store.quarantine_dead_letter(args.job_id):
|
|
88
|
+
raise SystemExit(f"Dead-letter job not found or already closed: {args.job_id}")
|
|
89
|
+
print(f"Quarantined dead-letter job {args.job_id}")
|
|
90
|
+
elif args.dead_letter_command == "replay":
|
|
91
|
+
if not platform.store.replay_dead_letter(args.job_id):
|
|
92
|
+
raise SystemExit(f"Dead-letter job cannot be replayed: {args.job_id}")
|
|
93
|
+
print(f"Replayed dead-letter job {args.job_id}")
|
|
73
94
|
elif args.command == "mapping":
|
|
74
95
|
with args.file.open(encoding="utf-8") as handle:
|
|
75
96
|
mapping_data = json.load(handle)
|
|
@@ -7,6 +7,9 @@ from datetime import datetime, timezone
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
from zoneinfo import ZoneInfo
|
|
9
9
|
|
|
10
|
+
from .offer_extensions import evaluate_offer_extensions
|
|
11
|
+
from .storage import parse_iso_datetime
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
_COMPLETENESS = {"minimal": 1, "standard": 2, "rich": 3}
|
|
12
15
|
|
|
@@ -48,15 +51,37 @@ def _in_window(window: dict[str, Any], now: datetime) -> bool:
|
|
|
48
51
|
return current >= start or current <= end
|
|
49
52
|
|
|
50
53
|
|
|
51
|
-
def offer_is_available(
|
|
52
|
-
|
|
54
|
+
def offer_is_available(
|
|
55
|
+
offer: dict[str, Any],
|
|
56
|
+
now: datetime | None = None,
|
|
57
|
+
*,
|
|
58
|
+
vertical: str | None = None,
|
|
59
|
+
channel: str | None = None,
|
|
60
|
+
) -> MatchResult:
|
|
61
|
+
"""Check active status and configured delivery windows.
|
|
62
|
+
|
|
63
|
+
A delivery window may narrow an offer to particular verticals or channels.
|
|
64
|
+
Missing lead criteria cannot satisfy a restricted window; this keeps
|
|
65
|
+
matching fail-closed when a publisher omits a field that the buyer used to
|
|
66
|
+
constrain availability.
|
|
67
|
+
"""
|
|
53
68
|
reasons: list[str] = []
|
|
54
69
|
if offer.get("active", True) is not True:
|
|
55
70
|
reasons.append("offer_inactive")
|
|
56
71
|
windows = offer.get("delivery_windows", [])
|
|
57
72
|
if windows:
|
|
58
73
|
current = now or datetime.now(timezone.utc)
|
|
59
|
-
|
|
74
|
+
matching_windows = []
|
|
75
|
+
for window in windows:
|
|
76
|
+
allowed_verticals = window.get("verticals")
|
|
77
|
+
allowed_channels = window.get("channels")
|
|
78
|
+
criteria_match = (
|
|
79
|
+
(not allowed_verticals or vertical in allowed_verticals)
|
|
80
|
+
and (not allowed_channels or channel in allowed_channels)
|
|
81
|
+
)
|
|
82
|
+
if criteria_match and _in_window(window, current):
|
|
83
|
+
matching_windows.append(window)
|
|
84
|
+
if not matching_windows:
|
|
60
85
|
reasons.append("outside_delivery_window")
|
|
61
86
|
return MatchResult(not reasons, tuple(reasons))
|
|
62
87
|
|
|
@@ -76,9 +101,33 @@ def match_offer(
|
|
|
76
101
|
compliance = payload.get("compliance", {})
|
|
77
102
|
quality = payload.get("lead_quality", {})
|
|
78
103
|
|
|
79
|
-
|
|
104
|
+
current = now or datetime.now(timezone.utc)
|
|
105
|
+
available = offer_is_available(
|
|
106
|
+
offer,
|
|
107
|
+
current,
|
|
108
|
+
vertical=attributes.get("vertical"),
|
|
109
|
+
channel=payload.get("channel"),
|
|
110
|
+
)
|
|
80
111
|
reasons.extend(available.reasons)
|
|
81
112
|
|
|
113
|
+
consent_expires_at = parse_iso_datetime(compliance.get("consent_expires_at"))
|
|
114
|
+
if consent_expires_at is not None and consent_expires_at <= current:
|
|
115
|
+
reasons.append("consent_expired")
|
|
116
|
+
extensions = offer.get("extensions", {})
|
|
117
|
+
if isinstance(extensions, dict):
|
|
118
|
+
required_purposes = extensions.get("lcp.platform.required_consent_purposes", [])
|
|
119
|
+
contact_purpose = extensions.get("lcp.platform.contact_purpose")
|
|
120
|
+
if contact_purpose and contact_purpose not in required_purposes:
|
|
121
|
+
required_purposes = [*required_purposes, contact_purpose] if isinstance(required_purposes, list) else [contact_purpose]
|
|
122
|
+
if required_purposes:
|
|
123
|
+
purposes = compliance.get("consent_purposes", [])
|
|
124
|
+
if not isinstance(required_purposes, list) or not isinstance(purposes, list):
|
|
125
|
+
reasons.append("consent_purpose_policy_invalid")
|
|
126
|
+
else:
|
|
127
|
+
missing = sorted({str(purpose) for purpose in required_purposes} - set(purposes))
|
|
128
|
+
if missing:
|
|
129
|
+
reasons.append("consent_purpose_missing:" + ",".join(missing))
|
|
130
|
+
|
|
82
131
|
if attributes.get("vertical") != offer.get("vertical"):
|
|
83
132
|
reasons.append("vertical_mismatch")
|
|
84
133
|
allowed_publishers = offer.get("allowed_publisher_ids", [])
|
|
@@ -137,6 +186,8 @@ def match_offer(
|
|
|
137
186
|
if offer.get("reject_blacklist_flagged") and _scrub_status(payload, "blacklist") != "clean":
|
|
138
187
|
reasons.append("blacklist_not_clean")
|
|
139
188
|
|
|
189
|
+
reasons.extend(evaluate_offer_extensions(offer, payload))
|
|
190
|
+
|
|
140
191
|
# Claim-language evidence is intentionally fail-closed. A deployment may
|
|
141
192
|
# provide a namespaced evidence block without putting copy/PII in a ping.
|
|
142
193
|
if offer.get("excluded_claim_language") and not payload.get("extensions", {}).get(
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
from uuid import uuid4
|
|
12
12
|
|
|
13
13
|
from .auth import signature_for
|
|
14
|
-
from .storage import now_iso
|
|
14
|
+
from .storage import is_expired, now_iso, parse_iso_datetime
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def _envelope(
|
|
@@ -135,6 +135,9 @@ def build_ping(
|
|
|
135
135
|
"consent": bool(lead.get("compliance", {}).get("consent_evidence")),
|
|
136
136
|
"otp_verified": bool(lead.get("compliance", {}).get("otp_verified")),
|
|
137
137
|
"dnc_checked": bool(lead.get("compliance", {}).get("dnc_checked")),
|
|
138
|
+
"consent_valid": not is_expired(
|
|
139
|
+
parse_iso_datetime(lead.get("compliance", {}).get("consent_expires_at"))
|
|
140
|
+
),
|
|
138
141
|
},
|
|
139
142
|
"quality_flags": {
|
|
140
143
|
"verified_phone": lead.get("lead_quality", {}).get("verified_phone", False),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Request-scoped observability primitives with no consumer-data logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_request_id: ContextVar[str | None] = ContextVar("lcp_request_id", default=None)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_request_id(value: str | None):
|
|
12
|
+
"""Set the request ID for the current execution context."""
|
|
13
|
+
return _request_id.set(value)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reset_request_id(token) -> None:
|
|
17
|
+
"""Restore the previous request ID after a request finishes."""
|
|
18
|
+
_request_id.reset(token)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_request_id() -> str | None:
|
|
22
|
+
"""Return the current request ID, if the call is inside an HTTP request."""
|
|
23
|
+
return _request_id.get()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Safe, deployment-scoped offer extension matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
REQUIREMENTS_NAMESPACE = "lcp.platform.requirements"
|
|
10
|
+
SERVICE_AREA_NAMESPACE = "lcp.platform.service_area"
|
|
11
|
+
_ALLOWED_PATH_ROOTS = {"attributes", "location", "provenance"}
|
|
12
|
+
_ALLOWED_OPERATORS = {"equals", "in", "exists", "between", "prefix"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _path_value(payload: Mapping[str, Any], path: str) -> tuple[bool, Any]:
|
|
16
|
+
"""Read one allowlisted top-level path without evaluating expressions."""
|
|
17
|
+
|
|
18
|
+
if path == "channel":
|
|
19
|
+
return "channel" in payload, payload.get("channel")
|
|
20
|
+
|
|
21
|
+
parts = path.split(".")
|
|
22
|
+
if len(parts) != 2 or parts[0] not in _ALLOWED_PATH_ROOTS or not parts[1]:
|
|
23
|
+
raise ValueError("path is not an allowed two-level LCP path")
|
|
24
|
+
container = payload.get(parts[0])
|
|
25
|
+
if not isinstance(container, Mapping):
|
|
26
|
+
return False, None
|
|
27
|
+
return parts[1] in container, container.get(parts[1])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _predicate_match(predicate: Mapping[str, Any], payload: Mapping[str, Any]) -> bool:
|
|
31
|
+
path = predicate.get("path")
|
|
32
|
+
operator = predicate.get("operator")
|
|
33
|
+
if not isinstance(path, str) or not isinstance(operator, str):
|
|
34
|
+
raise ValueError("predicate requires string path and operator")
|
|
35
|
+
if operator not in _ALLOWED_OPERATORS:
|
|
36
|
+
raise ValueError(f"unsupported predicate operator: {operator}")
|
|
37
|
+
|
|
38
|
+
present, actual = _path_value(payload, path)
|
|
39
|
+
if operator == "exists":
|
|
40
|
+
expected = predicate.get("value")
|
|
41
|
+
if not isinstance(expected, bool):
|
|
42
|
+
raise ValueError("exists predicate requires a boolean value")
|
|
43
|
+
return present is expected
|
|
44
|
+
if not present:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
if operator == "equals":
|
|
48
|
+
if "value" not in predicate:
|
|
49
|
+
raise ValueError("equals predicate requires value")
|
|
50
|
+
return actual == predicate["value"]
|
|
51
|
+
if operator == "in":
|
|
52
|
+
values = predicate.get("values")
|
|
53
|
+
if not isinstance(values, list) or not values:
|
|
54
|
+
raise ValueError("in predicate requires a non-empty values array")
|
|
55
|
+
return actual in values
|
|
56
|
+
if operator == "between":
|
|
57
|
+
minimum = predicate.get("min")
|
|
58
|
+
maximum = predicate.get("max")
|
|
59
|
+
if (
|
|
60
|
+
isinstance(actual, bool)
|
|
61
|
+
or not isinstance(actual, (int, float))
|
|
62
|
+
or isinstance(minimum, bool)
|
|
63
|
+
or not isinstance(minimum, (int, float))
|
|
64
|
+
or isinstance(maximum, bool)
|
|
65
|
+
or not isinstance(maximum, (int, float))
|
|
66
|
+
or minimum > maximum
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("between predicate requires ordered numeric min and max")
|
|
69
|
+
return minimum <= actual <= maximum
|
|
70
|
+
if operator == "prefix":
|
|
71
|
+
prefix = predicate.get("value")
|
|
72
|
+
if not isinstance(prefix, str):
|
|
73
|
+
raise ValueError("prefix predicate requires a string value")
|
|
74
|
+
return isinstance(actual, str) and actual.startswith(prefix)
|
|
75
|
+
raise ValueError(f"unsupported predicate operator: {operator}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _require_profile_identity(profile: Mapping[str, Any], label: str) -> None:
|
|
79
|
+
if not isinstance(profile.get("profile_id"), str) or not profile["profile_id"]:
|
|
80
|
+
raise ValueError(f"{label} requires profile_id")
|
|
81
|
+
if not isinstance(profile.get("version"), str) or not profile["version"]:
|
|
82
|
+
raise ValueError(f"{label} requires version")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _match_requirements(profile: Any, payload: Mapping[str, Any]) -> list[str]:
|
|
86
|
+
if not isinstance(profile, Mapping):
|
|
87
|
+
return ["requirements_profile_invalid"]
|
|
88
|
+
try:
|
|
89
|
+
_require_profile_identity(profile, "requirements profile")
|
|
90
|
+
predicates = profile.get("predicates")
|
|
91
|
+
if not isinstance(predicates, list) or not predicates:
|
|
92
|
+
raise ValueError("requirements profile requires predicates")
|
|
93
|
+
reasons: list[str] = []
|
|
94
|
+
for predicate in predicates:
|
|
95
|
+
if not isinstance(predicate, Mapping):
|
|
96
|
+
raise ValueError("requirements predicate must be an object")
|
|
97
|
+
if not _predicate_match(predicate, payload):
|
|
98
|
+
reasons.append("requirements_predicate_mismatch")
|
|
99
|
+
return reasons
|
|
100
|
+
except ValueError:
|
|
101
|
+
return ["requirements_profile_invalid"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _match_service_area(profile: Any, payload: Mapping[str, Any]) -> list[str]:
|
|
105
|
+
if not isinstance(profile, Mapping):
|
|
106
|
+
return ["service_area_profile_invalid"]
|
|
107
|
+
try:
|
|
108
|
+
_require_profile_identity(profile, "service-area profile")
|
|
109
|
+
location = payload.get("location")
|
|
110
|
+
if not isinstance(location, Mapping):
|
|
111
|
+
return ["service_area_location_missing"]
|
|
112
|
+
reasons: list[str] = []
|
|
113
|
+
for field, reason in (
|
|
114
|
+
("countries", "service_area_country_not_supported"),
|
|
115
|
+
("state_regions", "service_area_state_not_supported"),
|
|
116
|
+
("postal_codes", "service_area_postal_code_not_supported"),
|
|
117
|
+
):
|
|
118
|
+
allowed = profile.get(field)
|
|
119
|
+
if allowed is None:
|
|
120
|
+
continue
|
|
121
|
+
if not isinstance(allowed, list) or not allowed or not all(
|
|
122
|
+
isinstance(value, str) and value for value in allowed
|
|
123
|
+
):
|
|
124
|
+
raise ValueError(f"{field} must be a non-empty string array")
|
|
125
|
+
location_field = "state_region" if field == "state_regions" else (
|
|
126
|
+
"postal_code" if field == "postal_codes" else "country_code"
|
|
127
|
+
)
|
|
128
|
+
actual = location.get(location_field)
|
|
129
|
+
if actual not in allowed:
|
|
130
|
+
reasons.append(reason)
|
|
131
|
+
return reasons
|
|
132
|
+
except ValueError:
|
|
133
|
+
return ["service_area_profile_invalid"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def evaluate_offer_extensions(
|
|
137
|
+
offer: Mapping[str, Any],
|
|
138
|
+
payload: Mapping[str, Any],
|
|
139
|
+
) -> tuple[str, ...]:
|
|
140
|
+
"""Evaluate known platform extensions and return explainable failure reasons."""
|
|
141
|
+
|
|
142
|
+
extensions = offer.get("extensions", {})
|
|
143
|
+
if not isinstance(extensions, Mapping):
|
|
144
|
+
return ("offer_extensions_invalid",)
|
|
145
|
+
|
|
146
|
+
reasons: list[str] = []
|
|
147
|
+
if REQUIREMENTS_NAMESPACE in extensions:
|
|
148
|
+
reasons.extend(_match_requirements(extensions[REQUIREMENTS_NAMESPACE], payload))
|
|
149
|
+
if SERVICE_AREA_NAMESPACE in extensions:
|
|
150
|
+
reasons.extend(_match_service_area(extensions[SERVICE_AREA_NAMESPACE], payload))
|
|
151
|
+
return tuple(reasons)
|
|
@@ -130,6 +130,22 @@ class PostgresStore(Store):
|
|
|
130
130
|
db.execute(
|
|
131
131
|
"ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'"
|
|
132
132
|
)
|
|
133
|
+
db.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS expires_at TEXT")
|
|
134
|
+
db.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS consent_expires_at TEXT")
|
|
135
|
+
db.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS suppressed INTEGER NOT NULL DEFAULT 0")
|
|
136
|
+
db.execute("ALTER TABLE offers ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'")
|
|
137
|
+
db.execute("ALTER TABLE offers ADD COLUMN IF NOT EXISTS vertical TEXT")
|
|
138
|
+
for row in db.execute("SELECT offer_id, offer_json FROM offers").fetchall():
|
|
139
|
+
stored_offer = json.loads(_decode_text(row["offer_json"]))
|
|
140
|
+
db.execute(
|
|
141
|
+
"UPDATE offers SET tenant_id = ?, vertical = ? WHERE offer_id = ?",
|
|
142
|
+
(
|
|
143
|
+
str(stored_offer.get("tenant_id", "default")),
|
|
144
|
+
stored_offer.get("vertical"),
|
|
145
|
+
row["offer_id"],
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_offers_discovery ON offers(active, tenant_id, vertical, offer_id)")
|
|
133
149
|
db.execute("ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS lease_owner TEXT")
|
|
134
150
|
db.execute("ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS lease_until TEXT")
|
|
135
151
|
db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS residency TEXT NOT NULL DEFAULT 'TEST'")
|
|
@@ -137,6 +153,7 @@ class PostgresStore(Store):
|
|
|
137
153
|
db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS scan_engine TEXT NOT NULL DEFAULT 'unknown'")
|
|
138
154
|
db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS scanned_at TEXT NOT NULL DEFAULT ''")
|
|
139
155
|
db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS encryption TEXT NOT NULL DEFAULT 'application_encrypted'")
|
|
156
|
+
db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS expires_at TEXT")
|
|
140
157
|
|
|
141
158
|
def close(self) -> None:
|
|
142
159
|
with self._lock:
|
|
@@ -150,17 +167,22 @@ class PostgresStore(Store):
|
|
|
150
167
|
active_only: bool = True,
|
|
151
168
|
) -> list[dict[str, Any]]:
|
|
152
169
|
query = "SELECT offer_json FROM offers"
|
|
170
|
+
clauses: list[str] = []
|
|
171
|
+
values: list[Any] = []
|
|
153
172
|
if active_only:
|
|
154
|
-
|
|
155
|
-
query += " ORDER BY offer_id"
|
|
156
|
-
with self._lock:
|
|
157
|
-
rows = self._connection.execute(query, ()).fetchall()
|
|
158
|
-
offers = [json.loads(row["offer_json"]) for row in rows]
|
|
173
|
+
clauses.append("active = 1")
|
|
159
174
|
if vertical:
|
|
160
|
-
|
|
175
|
+
clauses.append("vertical = ?")
|
|
176
|
+
values.append(vertical)
|
|
161
177
|
if tenant_id:
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
clauses.append("tenant_id = ?")
|
|
179
|
+
values.append(tenant_id)
|
|
180
|
+
if clauses:
|
|
181
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
182
|
+
query += " ORDER BY offer_id"
|
|
183
|
+
with self._lock:
|
|
184
|
+
rows = self._connection.execute(query, values).fetchall()
|
|
185
|
+
return [json.loads(_decode_text(row["offer_json"])) for row in rows]
|
|
164
186
|
|
|
165
187
|
def claim_routing_jobs(
|
|
166
188
|
self,
|