@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.
Files changed (63) hide show
  1. package/README-zh-TW.md +12 -4
  2. package/README.md +55 -11
  3. package/bin/maggie.js +26 -4
  4. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  5. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  6. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  7. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  8. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  9. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  10. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  11. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  12. package/bundled-marketplace/README.md +17 -0
  13. package/bundled-marketplace/catalog.json +56 -7
  14. package/bundled-references/browser-inspection.md +25 -0
  15. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  16. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  17. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  18. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  19. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  20. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  21. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  22. package/bundled-references/maggiedash-booking/README.md +23 -6
  23. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  24. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  25. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  26. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  27. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  28. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  29. package/bundled-references/memory-hook.md +18 -5
  30. package/bundled-references/provider-adapter-contract.md +33 -0
  31. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  32. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  33. package/bundled-skills/maggie-deployment/SKILL.md +59 -1
  34. package/bundled-skills/maggie-deployment/references/vps.md +41 -0
  35. package/bundled-skills/maggie-design/SKILL.md +99 -0
  36. package/bundled-skills/maggie-feedback/SKILL.md +41 -2
  37. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  38. package/bundled-skills/maggie-memory/SKILL.md +10 -1
  39. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  40. package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
  41. package/bundled-skills/maggie-social-share/SKILL.md +37 -5
  42. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
  43. package/bundled-skills/maggie-template/SKILL.md +57 -3
  44. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  45. package/bundled-tools/clis/maggie_booking.py +199 -26
  46. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  47. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  48. package/bundled-tools/clis/maggie_deployment.py +295 -0
  49. package/bundled-tools/clis/maggie_design.py +9 -0
  50. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  51. package/bundled-tools/clis/maggie_feedback.py +107 -3
  52. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  53. package/bundled-tools/clis/maggie_memory.py +10 -3
  54. package/bundled-tools/clis/maggie_palette.py +280 -0
  55. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  56. package/bundled-tools/clis/maggie_social_queue.py +338 -0
  57. package/bundled-tools/clis/maggie_social_share.py +42 -9
  58. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  59. package/bundled-tools/runtime/maggie_memory.py +17 -2
  60. package/package.json +1 -1
  61. package/references/browser-inspection.md +25 -0
  62. package/references/memory-hook.md +18 -5
  63. 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
- print(json.dumps({"runId": run_id, "path": str(path), "summary": run["summary"]}, ensure_ascii=False, indent=2))
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 a deterministic Maggie Social Share draft contract."""
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
- if args.command == "plan":
77
- value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()])
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(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
81
- errors = validate(value) if args.command == "validate" else []
82
- print(json.dumps({"valid": not errors, "errors": errors, "draft": value}, indent=2, ensure_ascii=False))
83
- return 0 if not errors else 1
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