@topy-ai/maggie 0.7.46 → 0.7.48
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/README-zh-TW.md +12 -4
- package/README.md +55 -11
- package/bin/maggie.js +26 -4
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/memory-hook.md +18 -5
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-deployment/SKILL.md +59 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +41 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +295 -0
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +107 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/memory-hook.md +18 -5
- package/references/provider-adapter-contract.md +33 -0
|
@@ -16,7 +16,9 @@ import re
|
|
|
16
16
|
import sys
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
|
+
from urllib.error import HTTPError, URLError
|
|
19
20
|
from urllib.parse import urlsplit, urlunsplit
|
|
21
|
+
from urllib.request import Request, urlopen
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
|
|
@@ -29,6 +31,9 @@ SECRET_RE = re.compile(
|
|
|
29
31
|
r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
|
|
30
32
|
)
|
|
31
33
|
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
|
|
34
|
+
CORS_SAFELISTED_METHODS = {"GET", "HEAD", "POST"}
|
|
35
|
+
DEFAULT_CORS_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
36
|
+
MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
32
37
|
QUERY_SECRET_RE = re.compile(r"(?i)([?&](?:token|key|secret|password|code|id_token|access_token)=)[^&\s]+")
|
|
33
38
|
|
|
34
39
|
|
|
@@ -149,6 +154,11 @@ def start(args: argparse.Namespace) -> int:
|
|
|
149
154
|
path = run_directory(project) / f"{run_id}.json"
|
|
150
155
|
if path.exists():
|
|
151
156
|
raise ValueError(f"QA run already exists: {path}")
|
|
157
|
+
api_base = safe_base_url(args.api_base) if getattr(args, "api_base", "") else ""
|
|
158
|
+
production_hosts = {host for host in (api_host(value) for value in getattr(args, "production_api", []) or []) if host}
|
|
159
|
+
targets_production = bool(api_base) and api_host(api_base) in production_hosts
|
|
160
|
+
allow_writes = bool(getattr(args, "allow_production_writes", False))
|
|
161
|
+
write_policy = "allowed" if not targets_production else ("allowed-by-consent" if allow_writes else "read-only")
|
|
152
162
|
scenarios = {
|
|
153
163
|
item["id"]: {
|
|
154
164
|
"id": item["id"],
|
|
@@ -164,6 +174,20 @@ def start(args: argparse.Namespace) -> int:
|
|
|
164
174
|
}
|
|
165
175
|
for item in records
|
|
166
176
|
}
|
|
177
|
+
blocked_writes = []
|
|
178
|
+
if write_policy == "read-only":
|
|
179
|
+
for item in records:
|
|
180
|
+
if item.get("mutates") is True:
|
|
181
|
+
scenario = scenarios[item["id"]]
|
|
182
|
+
scenario["status"] = "blocked"
|
|
183
|
+
scenario["mutates"] = True
|
|
184
|
+
scenario["history"].append({
|
|
185
|
+
"at": now(),
|
|
186
|
+
"phase": "guard",
|
|
187
|
+
"status": "blocked",
|
|
188
|
+
"summary": "Mutating scenario blocked: the API target is production and --allow-production-writes was not given",
|
|
189
|
+
})
|
|
190
|
+
blocked_writes.append(item["id"])
|
|
167
191
|
try:
|
|
168
192
|
manifest_label = str(manifest_path.relative_to(project))
|
|
169
193
|
except ValueError:
|
|
@@ -183,11 +207,22 @@ def start(args: argparse.Namespace) -> int:
|
|
|
183
207
|
"commit": safe_text(args.commit),
|
|
184
208
|
"release": safe_text(args.release),
|
|
185
209
|
"scenarioManifest": manifest_label,
|
|
210
|
+
"apiTarget": {
|
|
211
|
+
"baseUrl": api_base,
|
|
212
|
+
"production": targets_production,
|
|
213
|
+
"writePolicy": write_policy,
|
|
214
|
+
"interception": "network-layer required; page-level window.fetch stubs do not intercept bundled clients",
|
|
215
|
+
},
|
|
186
216
|
"scenarios": scenarios,
|
|
187
217
|
}
|
|
188
218
|
run["summary"] = calculate(run)
|
|
189
219
|
write_json(path, run)
|
|
190
|
-
|
|
220
|
+
report = {"runId": run_id, "path": str(path), "summary": run["summary"], "writePolicy": write_policy}
|
|
221
|
+
if blocked_writes:
|
|
222
|
+
report["blockedMutatingScenarios"] = blocked_writes
|
|
223
|
+
if not api_base:
|
|
224
|
+
report["warnings"] = ["--api-base was not given; declare the API the build talks to so writes against production can be blocked"]
|
|
225
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
191
226
|
return 0
|
|
192
227
|
|
|
193
228
|
|
|
@@ -217,6 +252,16 @@ def record(args: argparse.Namespace) -> int:
|
|
|
217
252
|
raise ValueError(f"phase must be one of: {', '.join(sorted(VALID_PHASES))}")
|
|
218
253
|
if phase in {"test", "retest"} and args.status not in VALID_STATUSES - {"pending"}:
|
|
219
254
|
raise ValueError("test and retest require status pass, fail, blocked or inconclusive")
|
|
255
|
+
if (
|
|
256
|
+
phase in {"test", "retest"}
|
|
257
|
+
and item.get("mutates") is True
|
|
258
|
+
and (run.get("apiTarget") or {}).get("writePolicy") == "read-only"
|
|
259
|
+
and args.status in {"pass", "fail"}
|
|
260
|
+
):
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"this mutating scenario targets a production API without consent; "
|
|
263
|
+
"rerun QA against a non-production API or start a new run with --allow-production-writes"
|
|
264
|
+
)
|
|
220
265
|
if phase == "fix" and not args.resolution:
|
|
221
266
|
raise ValueError("fix requires --resolution")
|
|
222
267
|
if phase == "fix" and item.get("status") != "fail":
|
|
@@ -417,6 +462,104 @@ def assertion_audit(args: argparse.Namespace) -> int:
|
|
|
417
462
|
return 0 if passed else 1
|
|
418
463
|
|
|
419
464
|
|
|
465
|
+
def api_host(value: object) -> str:
|
|
466
|
+
try:
|
|
467
|
+
parsed = urlsplit(str(value or "").strip())
|
|
468
|
+
except ValueError:
|
|
469
|
+
return ""
|
|
470
|
+
if not parsed.hostname:
|
|
471
|
+
return ""
|
|
472
|
+
return f"{parsed.hostname.lower()}:{parsed.port}" if parsed.port else parsed.hostname.lower()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def header_tokens(value: str | None) -> set[str]:
|
|
476
|
+
return {item.strip().lower() for item in (value or "").split(",") if item.strip()}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def preflight_method(url: str, origin: str, method: str, request_headers: list[str], timeout: float) -> dict:
|
|
480
|
+
"""Send one CORS preflight; never sends the method itself, so it cannot mutate data."""
|
|
481
|
+
headers = {"Origin": origin, "Access-Control-Request-Method": method, "User-Agent": "MaggieQA/1.0"}
|
|
482
|
+
if request_headers:
|
|
483
|
+
headers["Access-Control-Request-Headers"] = ",".join(request_headers)
|
|
484
|
+
status = 0
|
|
485
|
+
response_headers: dict[str, str] = {}
|
|
486
|
+
error = ""
|
|
487
|
+
try:
|
|
488
|
+
with urlopen(Request(url, headers=headers, method="OPTIONS"), timeout=timeout) as response:
|
|
489
|
+
status = response.status
|
|
490
|
+
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
|
491
|
+
except HTTPError as failure:
|
|
492
|
+
status = failure.code
|
|
493
|
+
response_headers = {key.lower(): value for key, value in failure.headers.items()}
|
|
494
|
+
except (URLError, TimeoutError, OSError) as failure:
|
|
495
|
+
error = safe_text(getattr(failure, "reason", failure), 200)
|
|
496
|
+
allow_origin = response_headers.get("access-control-allow-origin", "")
|
|
497
|
+
allow_methods = {item.upper() for item in header_tokens(response_headers.get("access-control-allow-methods"))}
|
|
498
|
+
allow_headers = header_tokens(response_headers.get("access-control-allow-headers"))
|
|
499
|
+
reasons = []
|
|
500
|
+
if error:
|
|
501
|
+
reasons.append(f"preflight request failed: {error}")
|
|
502
|
+
elif not 200 <= status < 300:
|
|
503
|
+
reasons.append(f"preflight returned HTTP {status}")
|
|
504
|
+
if not error and allow_origin not in {origin, "*"}:
|
|
505
|
+
reasons.append("Access-Control-Allow-Origin does not allow this origin")
|
|
506
|
+
if not error and method not in CORS_SAFELISTED_METHODS and method not in allow_methods and "*" not in allow_methods:
|
|
507
|
+
reasons.append(f"{method} is not in Access-Control-Allow-Methods")
|
|
508
|
+
missing_headers = sorted(header.lower() for header in request_headers if header.lower() not in allow_headers and "*" not in allow_headers)
|
|
509
|
+
if not error and missing_headers:
|
|
510
|
+
reasons.append("request headers not allowed: " + ", ".join(missing_headers))
|
|
511
|
+
return {
|
|
512
|
+
"method": method,
|
|
513
|
+
"status": status,
|
|
514
|
+
"allowed": not reasons,
|
|
515
|
+
"allowOrigin": allow_origin if allow_origin in {origin, "*"} else ("other" if allow_origin else ""),
|
|
516
|
+
"allowMethods": sorted(allow_methods),
|
|
517
|
+
"reasons": reasons,
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cors_preflight(args: argparse.Namespace) -> int:
|
|
522
|
+
url = str(args.url).strip()
|
|
523
|
+
parsed = urlsplit(url)
|
|
524
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
525
|
+
raise ValueError("--url must be an absolute http(s) URL")
|
|
526
|
+
origin = safe_base_url(args.origin)
|
|
527
|
+
if not urlsplit(origin).hostname:
|
|
528
|
+
raise ValueError("--origin must be an absolute origin such as https://app.example.com")
|
|
529
|
+
methods = []
|
|
530
|
+
for value in args.method or list(DEFAULT_CORS_METHODS):
|
|
531
|
+
for item in str(value).split(","):
|
|
532
|
+
item = item.strip().upper()
|
|
533
|
+
if item and item not in methods:
|
|
534
|
+
if not re.fullmatch(r"[A-Z]{3,10}", item):
|
|
535
|
+
raise ValueError(f"invalid HTTP method: {item}")
|
|
536
|
+
methods.append(item)
|
|
537
|
+
request_headers = []
|
|
538
|
+
for value in args.request_header:
|
|
539
|
+
request_headers.extend(item.strip() for item in str(value).split(",") if item.strip())
|
|
540
|
+
results = [preflight_method(url, origin, method, request_headers, args.timeout) for method in methods]
|
|
541
|
+
blocked = [item["method"] for item in results if not item["allowed"]]
|
|
542
|
+
report = {
|
|
543
|
+
"schemaVersion": "maggie.qa-cors-preflight.v1",
|
|
544
|
+
"checkedAt": now(),
|
|
545
|
+
"url": safe_base_url(url) + (parsed.path or "/"),
|
|
546
|
+
"origin": origin,
|
|
547
|
+
"requestHeaders": [header.lower() for header in request_headers],
|
|
548
|
+
"methods": results,
|
|
549
|
+
"blocked": blocked,
|
|
550
|
+
"passed": not blocked,
|
|
551
|
+
"mutation": "not executed; only OPTIONS preflights were sent",
|
|
552
|
+
}
|
|
553
|
+
if args.output:
|
|
554
|
+
project = project_root(args.project)
|
|
555
|
+
output = Path(args.output)
|
|
556
|
+
if not output.is_absolute():
|
|
557
|
+
output = project / output
|
|
558
|
+
write_json(output, report)
|
|
559
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
560
|
+
return 0 if report["passed"] else 1
|
|
561
|
+
|
|
562
|
+
|
|
420
563
|
def parser() -> argparse.ArgumentParser:
|
|
421
564
|
root = argparse.ArgumentParser(description=__doc__)
|
|
422
565
|
root.add_argument("--project", default=".")
|
|
@@ -431,6 +574,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
431
574
|
start_parser.add_argument("--browser", default="chrome")
|
|
432
575
|
start_parser.add_argument("--commit", default="")
|
|
433
576
|
start_parser.add_argument("--release", default="")
|
|
577
|
+
start_parser.add_argument("--api-base", default="", help="API origin the build under test talks to")
|
|
578
|
+
start_parser.add_argument("--production-api", action="append", default=[], help="production API origin; repeat")
|
|
579
|
+
start_parser.add_argument("--allow-production-writes", action="store_true", help="explicit consent to run mutating scenarios against a production API")
|
|
434
580
|
|
|
435
581
|
record_parser = commands.add_parser("record")
|
|
436
582
|
record_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
@@ -453,6 +599,14 @@ def parser() -> argparse.ArgumentParser:
|
|
|
453
599
|
command.add_argument("--run", required=True)
|
|
454
600
|
if name == "summary": command.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
455
601
|
else: command.add_argument("--output", required=True)
|
|
602
|
+
cors_parser = commands.add_parser("cors-preflight", help="preflight every HTTP method a client uses through the public edge")
|
|
603
|
+
cors_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
604
|
+
cors_parser.add_argument("--url", required=True, help="public API URL as the browser reaches it")
|
|
605
|
+
cors_parser.add_argument("--origin", required=True, help="app origin that sends the requests")
|
|
606
|
+
cors_parser.add_argument("--method", action="append", default=[], help="method or comma list; default GET,POST,PUT,PATCH,DELETE")
|
|
607
|
+
cors_parser.add_argument("--request-header", action="append", default=[], help="non-safelisted request header the client sends, e.g. authorization,content-type")
|
|
608
|
+
cors_parser.add_argument("--timeout", type=float, default=10.0)
|
|
609
|
+
cors_parser.add_argument("--output")
|
|
456
610
|
assertion_parser = commands.add_parser("assertion-audit")
|
|
457
611
|
assertion_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
458
612
|
assertion_parser.add_argument("--assertions", required=True, help="runtime assertion manifest JSON")
|
|
@@ -467,6 +621,7 @@ def main() -> int:
|
|
|
467
621
|
if args.command == "record": return record(args)
|
|
468
622
|
if args.command == "summary": return summary(args)
|
|
469
623
|
if args.command == "assertion-audit": return assertion_audit(args)
|
|
624
|
+
if args.command == "cors-preflight": return cors_preflight(args)
|
|
470
625
|
return export_run(args)
|
|
471
626
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
472
627
|
print(f"QA workflow error: {safe_text(error)}", file=sys.stderr)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure, deterministic checks for Maggie Social Share adapters, connections, and the slot queue.
|
|
3
|
+
|
|
4
|
+
No function in this module calls a provider, a model, or the network. Every
|
|
5
|
+
input is a reviewable JSON document and every output is a JSON-serialisable
|
|
6
|
+
report, so the same contract can be enforced in CI, in the CLI, and in a host
|
|
7
|
+
application's own tests.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
12
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
13
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
17
|
+
SECRET_KEYS = {"client_secret", "access_token", "refresh_token", "token", "secret"}
|
|
18
|
+
REVOKED_PROVIDER_STATES = {"revoked", "invalid", "expired", "unauthorized"}
|
|
19
|
+
# Hosts whose avatar/media URLs are known to be signed with an expiry.
|
|
20
|
+
SIGNED_MEDIA_HOSTS = ("media.licdn.com", "fbcdn.net", "cdninstagram.com", "pbs.twimg.com")
|
|
21
|
+
# Query parameters that carry an absolute expiry (unix seconds, or hex for Meta's `oe`).
|
|
22
|
+
EXPIRY_PARAMS = ("e", "oe", "expires", "Expires")
|
|
23
|
+
RELATIVE_EXPIRY_PARAMS = ("X-Amz-Expires", "X-Goog-Expires")
|
|
24
|
+
CAMPAIGN_STATUSES = {"planned", "started", "paused", "completed"}
|
|
25
|
+
IDEA_STATUSES = {"planned", "written"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_instant(value: str, label: str) -> datetime:
|
|
29
|
+
try:
|
|
30
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
31
|
+
except ValueError as error:
|
|
32
|
+
raise ValueError(f"{label} must be an ISO 8601 date-time") from error
|
|
33
|
+
if parsed.tzinfo is None:
|
|
34
|
+
raise ValueError(f"{label} must include a UTC offset")
|
|
35
|
+
return parsed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def iso(value: datetime) -> str:
|
|
39
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- T01: canonical OAuth scope sets -----------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_adapters(manifest: dict) -> dict:
|
|
46
|
+
"""Fail when connect paths disagree on scopes; warn about OAuth clients shared across products.
|
|
47
|
+
|
|
48
|
+
LinkedIn (and other providers) invalidate a member's earlier tokens when a
|
|
49
|
+
later sign-in requests a different scope set, so every connect path for one
|
|
50
|
+
provider must request exactly the adapter's canonical set.
|
|
51
|
+
"""
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
warnings: list[str] = []
|
|
54
|
+
adapters = manifest.get("adapters")
|
|
55
|
+
if not isinstance(adapters, list) or not adapters:
|
|
56
|
+
return {"valid": False, "errors": ["adapters must be a non-empty list"], "warnings": []}
|
|
57
|
+
seen_clients: dict[str, str] = {}
|
|
58
|
+
for index, adapter in enumerate(adapters):
|
|
59
|
+
label = f"adapters[{index}]"
|
|
60
|
+
if not isinstance(adapter, dict):
|
|
61
|
+
errors.append(f"{label} must be an object")
|
|
62
|
+
continue
|
|
63
|
+
provider = adapter.get("provider") or label
|
|
64
|
+
label = f"adapter {provider}"
|
|
65
|
+
leaked = sorted(SECRET_KEYS & set(adapter))
|
|
66
|
+
if leaked:
|
|
67
|
+
errors.append(f"{label} must not contain secret fields: {', '.join(leaked)}")
|
|
68
|
+
canonical = adapter.get("canonical_scopes")
|
|
69
|
+
if not isinstance(canonical, list) or not canonical or not all(isinstance(scope, str) and scope for scope in canonical):
|
|
70
|
+
errors.append(f"{label} must declare canonical_scopes as a non-empty list of strings")
|
|
71
|
+
continue
|
|
72
|
+
canonical_set = set(canonical)
|
|
73
|
+
paths = adapter.get("connect_paths")
|
|
74
|
+
if not isinstance(paths, list) or not paths:
|
|
75
|
+
errors.append(f"{label} must list connect_paths (every client that starts the OAuth flow)")
|
|
76
|
+
paths = []
|
|
77
|
+
for path in paths:
|
|
78
|
+
path_id = path.get("id") if isinstance(path, dict) else None
|
|
79
|
+
scopes = path.get("scopes") if isinstance(path, dict) else None
|
|
80
|
+
if not path_id or not isinstance(scopes, list):
|
|
81
|
+
errors.append(f"{label} connect path needs id and scopes")
|
|
82
|
+
continue
|
|
83
|
+
missing = sorted(canonical_set - set(scopes))
|
|
84
|
+
extra = sorted(set(scopes) - canonical_set)
|
|
85
|
+
if missing or extra:
|
|
86
|
+
detail = "; ".join(part for part in (
|
|
87
|
+
f"missing {', '.join(missing)}" if missing else "",
|
|
88
|
+
f"extra {', '.join(extra)}" if extra else "",
|
|
89
|
+
) if part)
|
|
90
|
+
errors.append(f"{label} connect path {path_id} requests a different scope set ({detail}); "
|
|
91
|
+
"a sign-in with a different scope set can revoke tokens issued to the other paths")
|
|
92
|
+
client = str(adapter.get("client_id_ref") or "")
|
|
93
|
+
if client:
|
|
94
|
+
if client in seen_clients and seen_clients[client] != provider:
|
|
95
|
+
warnings.append(f"{label} reuses OAuth client {client} from adapter {seen_clients[client]}")
|
|
96
|
+
seen_clients.setdefault(client, provider)
|
|
97
|
+
shared = adapter.get("client_shared_with") or []
|
|
98
|
+
if shared:
|
|
99
|
+
warnings.append(f"{label} OAuth client is shared with {len(shared)} other product(s); their sign-ins can "
|
|
100
|
+
"revoke this product's tokens unless they request the identical scope set — prefer a dedicated client")
|
|
101
|
+
hosts = sorted({urlsplit(uri).hostname or "" for uri in adapter.get("redirect_uris") or [] if isinstance(uri, str)} - {""})
|
|
102
|
+
if len(hosts) > 1 and not shared:
|
|
103
|
+
warnings.append(f"{label} redirect URIs span {len(hosts)} hosts ({', '.join(hosts)}); confirm they belong to "
|
|
104
|
+
"one product or record them in client_shared_with")
|
|
105
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- T02: connection health --------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def avatar_expiry(url: str) -> tuple[bool, datetime | None]:
|
|
112
|
+
"""Return (is_signed, absolute_expiry) for a provider media URL."""
|
|
113
|
+
parsed = urlsplit(url)
|
|
114
|
+
host = (parsed.hostname or "").lower()
|
|
115
|
+
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
116
|
+
for key in EXPIRY_PARAMS:
|
|
117
|
+
raw = query.get(key)
|
|
118
|
+
if not raw:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
seconds = int(raw, 16) if key == "oe" else int(raw)
|
|
122
|
+
except ValueError:
|
|
123
|
+
continue
|
|
124
|
+
if seconds > 10**12:
|
|
125
|
+
seconds //= 1000
|
|
126
|
+
return True, datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
127
|
+
if any(key in query for key in RELATIVE_EXPIRY_PARAMS) or "Signature" in query or "X-Amz-Signature" in query:
|
|
128
|
+
return True, None
|
|
129
|
+
return any(host == known or host.endswith("." + known) for known in SIGNED_MEDIA_HOSTS), None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def connection_health(document: dict, now: datetime) -> dict:
|
|
133
|
+
"""Derive each connection's real status before anything is scheduled on it."""
|
|
134
|
+
connections = document.get("connections")
|
|
135
|
+
if not isinstance(connections, list):
|
|
136
|
+
return {"valid": False, "errors": ["connections must be a list"], "warnings": [], "connections": []}
|
|
137
|
+
errors: list[str] = []
|
|
138
|
+
warnings: list[str] = []
|
|
139
|
+
report = []
|
|
140
|
+
for connection in connections:
|
|
141
|
+
if not isinstance(connection, dict):
|
|
142
|
+
errors.append("each connection must be an object")
|
|
143
|
+
continue
|
|
144
|
+
cid = connection.get("id") or "?"
|
|
145
|
+
stored = connection.get("status")
|
|
146
|
+
reasons: list[str] = []
|
|
147
|
+
if connection.get("token_decryptable") is False:
|
|
148
|
+
reasons.append("token cannot be decrypted on this host (check encryption-key parity)")
|
|
149
|
+
provider_state = (connection.get("provider_check") or {}).get("status")
|
|
150
|
+
if provider_state in REVOKED_PROVIDER_STATES:
|
|
151
|
+
reasons.append(f"provider reports token {provider_state}")
|
|
152
|
+
elif provider_state in (None, "unknown"):
|
|
153
|
+
warnings.append(f"connection {cid} has no provider token check; verify before scheduling")
|
|
154
|
+
derived = "needs_reconnect" if reasons else (stored or "unknown")
|
|
155
|
+
if reasons and stored == "active":
|
|
156
|
+
scheduled = int(connection.get("scheduled_items") or 0)
|
|
157
|
+
errors.append(f"connection {cid} is stored as active but must be needs_reconnect: {'; '.join(reasons)}"
|
|
158
|
+
+ (f" ({scheduled} scheduled item(s) would fail at publish time)" if scheduled else ""))
|
|
159
|
+
avatar = connection.get("avatar_url")
|
|
160
|
+
avatar_state = "none"
|
|
161
|
+
if isinstance(avatar, str) and avatar:
|
|
162
|
+
signed, expires = avatar_expiry(avatar)
|
|
163
|
+
avatar_state = "stable"
|
|
164
|
+
if signed:
|
|
165
|
+
avatar_state = "signed"
|
|
166
|
+
if expires and expires <= now:
|
|
167
|
+
avatar_state = "expired"
|
|
168
|
+
if not connection.get("avatar_cached") and not connection.get("avatar_refresh_on_read"):
|
|
169
|
+
when = f" (expires {iso(expires)})" if expires else ""
|
|
170
|
+
errors.append(f"connection {cid} stores a signed, expiring provider avatar URL{when}; cache the image "
|
|
171
|
+
"bytes in owned storage or refresh the URL on readback")
|
|
172
|
+
report.append({"id": cid, "provider": connection.get("provider"), "stored_status": stored,
|
|
173
|
+
"status": derived, "reasons": reasons, "avatar": avatar_state})
|
|
174
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings, "connections": report}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- T03: campaign idea stage ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def campaign_readiness(campaign: dict) -> list[str]:
|
|
181
|
+
"""Return the blockers that prevent a campaign from starting (empty list = can start)."""
|
|
182
|
+
blockers: list[str] = []
|
|
183
|
+
ideas = campaign.get("ideas")
|
|
184
|
+
if not isinstance(ideas, list) or not ideas:
|
|
185
|
+
return ["campaign has no ideas"]
|
|
186
|
+
for idea in ideas:
|
|
187
|
+
status = idea.get("status") if isinstance(idea, dict) else None
|
|
188
|
+
if status not in IDEA_STATUSES:
|
|
189
|
+
blockers.append(f"idea {idea.get('id') if isinstance(idea, dict) else '?'} has invalid status")
|
|
190
|
+
elif status == "planned":
|
|
191
|
+
blockers.append(f"idea {idea.get('id')} is not written yet")
|
|
192
|
+
elif not idea.get("draft_id"):
|
|
193
|
+
blockers.append(f"idea {idea.get('id')} is written but has no draft_id")
|
|
194
|
+
orders = [idea.get("order") for idea in ideas if isinstance(idea, dict)]
|
|
195
|
+
if any(not isinstance(order, int) for order in orders) or len(set(orders)) != len(orders):
|
|
196
|
+
blockers.append("ideas need unique integer order values")
|
|
197
|
+
try:
|
|
198
|
+
date.fromisoformat(str(campaign.get("start_day")))
|
|
199
|
+
except ValueError:
|
|
200
|
+
blockers.append("start_day (YYYY-MM-DD) is required")
|
|
201
|
+
per_day = campaign.get("posts_per_day")
|
|
202
|
+
if not isinstance(per_day, int) or per_day < 1:
|
|
203
|
+
blockers.append("posts_per_day must be a positive integer")
|
|
204
|
+
return blockers
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_campaigns(document: dict) -> dict:
|
|
208
|
+
errors: list[str] = []
|
|
209
|
+
report = []
|
|
210
|
+
for campaign in document.get("campaigns") or []:
|
|
211
|
+
cid = campaign.get("id") or "?"
|
|
212
|
+
status = campaign.get("status", "planned")
|
|
213
|
+
if status not in CAMPAIGN_STATUSES:
|
|
214
|
+
errors.append(f"campaign {cid} has invalid status {status}")
|
|
215
|
+
continue
|
|
216
|
+
blockers = campaign_readiness(campaign)
|
|
217
|
+
if status == "started" and blockers:
|
|
218
|
+
errors.append(f"campaign {cid} is started but not startable: {'; '.join(blockers)}")
|
|
219
|
+
report.append({"id": cid, "status": status, "can_start": not blockers, "blockers": blockers,
|
|
220
|
+
"ideas_planned": sum(1 for idea in campaign.get("ideas") or [] if idea.get("status") == "planned"),
|
|
221
|
+
"ideas_written": sum(1 for idea in campaign.get("ideas") or [] if idea.get("status") == "written")})
|
|
222
|
+
return {"valid": not errors, "errors": errors, "campaigns": report}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- T03: slot-based queue planner ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_posting_hours(hours: dict) -> dict[int, list[time]]:
|
|
229
|
+
if not isinstance(hours, dict) or not hours:
|
|
230
|
+
raise ValueError("posting_hours must map weekdays (mon..sun) to HH:MM lists")
|
|
231
|
+
result: dict[int, list[time]] = {}
|
|
232
|
+
for key, values in hours.items():
|
|
233
|
+
if key not in WEEKDAYS:
|
|
234
|
+
raise ValueError(f"posting_hours has unknown weekday {key}")
|
|
235
|
+
slots = []
|
|
236
|
+
for value in values or []:
|
|
237
|
+
try:
|
|
238
|
+
slots.append(time.fromisoformat(value))
|
|
239
|
+
except (TypeError, ValueError) as error:
|
|
240
|
+
raise ValueError(f"posting_hours.{key} has invalid time {value!r}") from error
|
|
241
|
+
result[WEEKDAYS.index(key)] = sorted(set(slots))
|
|
242
|
+
if not any(result.values()):
|
|
243
|
+
raise ValueError("posting_hours must contain at least one time")
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def iter_slots(start: datetime, zone: ZoneInfo, hours: dict[int, list[time]], skipped: list[str]):
|
|
248
|
+
"""Yield aware slot instants >= start, in order. Non-existent DST wall times are skipped and reported."""
|
|
249
|
+
day = start.astimezone(zone).date()
|
|
250
|
+
while True:
|
|
251
|
+
for wall in hours.get(day.weekday(), []):
|
|
252
|
+
local = datetime.combine(day, wall, tzinfo=zone) # fold=0: first occurrence of an ambiguous time
|
|
253
|
+
if local.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None) != local.replace(tzinfo=None):
|
|
254
|
+
skipped.append(f"{day.isoformat()}T{wall.isoformat('minutes')} does not exist in {zone.key} (DST gap)")
|
|
255
|
+
continue
|
|
256
|
+
if local >= start:
|
|
257
|
+
yield local
|
|
258
|
+
day += timedelta(days=1)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def plan_queue(document: dict, start: datetime, slot_count: int) -> dict:
|
|
262
|
+
"""Assign queued and campaign posts to the next `slot_count` posting slots.
|
|
263
|
+
|
|
264
|
+
Rules: each slot posts one item; the standalone queue is newest-first (a new
|
|
265
|
+
post goes to the front); a stored time is only a not-before bound, so an
|
|
266
|
+
overdue item is simply eligible; each started campaign publishes its written
|
|
267
|
+
ideas in idea order from its start day, at most posts_per_day per local day;
|
|
268
|
+
the standalone queue and started campaigns take turns (round-robin), and a
|
|
269
|
+
source with nothing eligible is skipped for that slot.
|
|
270
|
+
"""
|
|
271
|
+
if slot_count < 1 or slot_count > 1000:
|
|
272
|
+
raise ValueError("slot count must be between 1 and 1000")
|
|
273
|
+
try:
|
|
274
|
+
zone = ZoneInfo(str(document.get("timezone")))
|
|
275
|
+
except (ZoneInfoNotFoundError, ValueError) as error:
|
|
276
|
+
raise ValueError("timezone must be an IANA time zone such as Europe/London") from error
|
|
277
|
+
hours = parse_posting_hours(document.get("posting_hours"))
|
|
278
|
+
warnings: list[str] = []
|
|
279
|
+
skipped: list[str] = []
|
|
280
|
+
|
|
281
|
+
queue = []
|
|
282
|
+
for item in document.get("queue") or []:
|
|
283
|
+
if not item.get("id") or not item.get("queued_at"):
|
|
284
|
+
raise ValueError("queue items need id and queued_at")
|
|
285
|
+
queue.append({"id": item["id"], "queued_at": parse_instant(item["queued_at"], f"queue {item['id']} queued_at"),
|
|
286
|
+
"not_before": parse_instant(item["not_before"], f"queue {item['id']} not_before") if item.get("not_before") else None})
|
|
287
|
+
# Newest first; id breaks ties so the plan is deterministic.
|
|
288
|
+
queue.sort(key=lambda item: (-item["queued_at"].timestamp(), item["id"]))
|
|
289
|
+
|
|
290
|
+
campaigns = []
|
|
291
|
+
for campaign in document.get("campaigns") or []:
|
|
292
|
+
cid = campaign.get("id") or "?"
|
|
293
|
+
if campaign.get("status") != "started":
|
|
294
|
+
warnings.append(f"campaign {cid} is {campaign.get('status', 'planned')}; its posts are not scheduled until it starts")
|
|
295
|
+
continue
|
|
296
|
+
blockers = campaign_readiness(campaign)
|
|
297
|
+
if blockers:
|
|
298
|
+
warnings.append(f"campaign {cid} is started but not startable and was skipped: {'; '.join(blockers)}")
|
|
299
|
+
continue
|
|
300
|
+
posted = set(campaign.get("posted_idea_ids") or [])
|
|
301
|
+
ideas = [idea for idea in sorted(campaign["ideas"], key=lambda idea: idea["order"]) if idea["id"] not in posted]
|
|
302
|
+
campaigns.append({"id": cid, "start_day": date.fromisoformat(campaign["start_day"]),
|
|
303
|
+
"per_day": campaign["posts_per_day"], "ideas": ideas, "used": {}})
|
|
304
|
+
|
|
305
|
+
sources = ["queue"] + [campaign["id"] for campaign in campaigns]
|
|
306
|
+
by_id = {campaign["id"]: campaign for campaign in campaigns}
|
|
307
|
+
pointer = 0
|
|
308
|
+
assignments = []
|
|
309
|
+
empty_slots = 0
|
|
310
|
+
slots = iter_slots(start, zone, hours, skipped)
|
|
311
|
+
for _ in range(slot_count):
|
|
312
|
+
slot = next(slots)
|
|
313
|
+
chosen = None
|
|
314
|
+
for offset in range(len(sources)):
|
|
315
|
+
source = sources[(pointer + offset) % len(sources)]
|
|
316
|
+
if source == "queue":
|
|
317
|
+
index = next((i for i, item in enumerate(queue) if item["not_before"] is None or item["not_before"] <= slot), None)
|
|
318
|
+
if index is not None:
|
|
319
|
+
item = queue.pop(index)
|
|
320
|
+
chosen = {"post_id": item["id"], "source": "queue",
|
|
321
|
+
"overdue": bool(item["not_before"] and item["not_before"] < start)}
|
|
322
|
+
else:
|
|
323
|
+
campaign = by_id[source]
|
|
324
|
+
local_day = slot.astimezone(zone).date()
|
|
325
|
+
if campaign["ideas"] and local_day >= campaign["start_day"] and campaign["used"].get(local_day, 0) < campaign["per_day"]:
|
|
326
|
+
idea = campaign["ideas"].pop(0)
|
|
327
|
+
campaign["used"][local_day] = campaign["used"].get(local_day, 0) + 1
|
|
328
|
+
chosen = {"post_id": idea["draft_id"], "idea_id": idea["id"], "source": f"campaign:{source}"}
|
|
329
|
+
if chosen:
|
|
330
|
+
pointer = (pointer + offset + 1) % len(sources)
|
|
331
|
+
break
|
|
332
|
+
if chosen is None:
|
|
333
|
+
empty_slots += 1
|
|
334
|
+
assignments.append({"slot": slot.isoformat(), "slot_utc": iso(slot.astimezone(timezone.utc)), **(chosen or {"post_id": None})})
|
|
335
|
+
remaining = [item["id"] for item in queue] + [idea["draft_id"] for campaign in campaigns for idea in campaign["ideas"]]
|
|
336
|
+
return {"schema_version": "maggie-social-queue-plan.v1", "timezone": zone.key, "from": iso(start),
|
|
337
|
+
"assignments": assignments, "empty_slots": empty_slots, "remaining": remaining,
|
|
338
|
+
"skipped_wall_times": skipped, "warnings": warnings}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Create and validate
|
|
2
|
+
"""Create and validate deterministic Maggie Social Share drafts, adapters, connections, campaigns, and the slot queue."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
9
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, validate_adapters # noqa: E402
|
|
14
|
+
|
|
10
15
|
|
|
11
16
|
LIMITS = {"linkedin": 3000, "facebook": 63206, "x": 280}
|
|
12
17
|
REQUIRED = ("source_content_id", "title", "canonical_url")
|
|
@@ -64,23 +69,51 @@ def validate(value: dict) -> list[str]:
|
|
|
64
69
|
def main() -> int:
|
|
65
70
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
66
71
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
67
|
-
create = sub.add_parser("plan")
|
|
72
|
+
create = sub.add_parser("plan", help="create a draft channel contract from one content object")
|
|
68
73
|
create.add_argument("input")
|
|
69
74
|
create.add_argument("--channels", default="linkedin,facebook")
|
|
70
75
|
create.add_argument("--output")
|
|
71
|
-
check = sub.add_parser("validate")
|
|
76
|
+
check = sub.add_parser("validate", help="validate a draft channel contract")
|
|
72
77
|
check.add_argument("input")
|
|
78
|
+
adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
|
|
79
|
+
adapters.add_argument("input")
|
|
80
|
+
health = sub.add_parser("connections", help="derive connection status (needs_reconnect) and expiring avatar URLs")
|
|
81
|
+
health.add_argument("input")
|
|
82
|
+
health.add_argument("--now", help="ISO 8601 instant used for expiry checks (default: current time)")
|
|
83
|
+
campaigns = sub.add_parser("campaigns", help="check the campaign idea stage and Start gate")
|
|
84
|
+
campaigns.add_argument("input")
|
|
85
|
+
queue = sub.add_parser("queue", help="assign queued and campaign posts to the next posting slots")
|
|
86
|
+
queue.add_argument("input")
|
|
87
|
+
queue.add_argument("--from", dest="start", help="ISO 8601 instant to plan from (default: current time)")
|
|
88
|
+
queue.add_argument("--slots", type=int, default=14)
|
|
89
|
+
queue.add_argument("--output")
|
|
73
90
|
args = parser.parse_args()
|
|
74
91
|
try:
|
|
75
92
|
value = read(Path(args.input))
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
now = datetime.now(timezone.utc)
|
|
94
|
+
if args.command == "adapters":
|
|
95
|
+
result = validate_adapters(value)
|
|
96
|
+
elif args.command == "connections":
|
|
97
|
+
result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
|
|
98
|
+
elif args.command == "campaigns":
|
|
99
|
+
result = check_campaigns(value)
|
|
100
|
+
elif args.command == "queue":
|
|
101
|
+
result = plan_queue(value, parse_instant(args.start, "--from") if args.start else now, args.slots)
|
|
78
102
|
if args.output:
|
|
79
103
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
Path(args.output).write_text(json.dumps(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
105
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
106
|
+
return 0
|
|
107
|
+
else:
|
|
108
|
+
if args.command == "plan":
|
|
109
|
+
value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()])
|
|
110
|
+
if args.output:
|
|
111
|
+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
Path(args.output).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
113
|
+
errors = validate(value) if args.command == "validate" else []
|
|
114
|
+
result = {"valid": not errors, "errors": errors, "draft": value}
|
|
115
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
116
|
+
return 0 if result["valid"] else 1
|
|
84
117
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
85
118
|
parser.error(str(error))
|
|
86
119
|
return 2
|