@topy-ai/maggie 0.7.47 → 0.7.49

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 (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. package/references/provider-adapter-contract.md +33 -0
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """Generate and validate a semantic palette role contract for a template.
3
+
4
+ A template exposes colour roles instead of raw hex values in markup. The
5
+ signature palette is the template's own; variants are derived from it by
6
+ moving hue while keeping lightness, capping accent saturation, and forcing
7
+ the contrast rules that make the roles safe to swap without editing markup.
8
+ The command is deterministic and makes no network or model calls.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import colorsys
15
+ import json
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ SCHEMA_VERSION = "maggie-palette-roles-v1"
21
+ ROLES = ("canvas", "panel", "soft", "text", "accent", "accentText", "onAccent", "dark", "deep")
22
+ HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")
23
+ MIN_TEXT_CONTRAST = 4.5
24
+ DEFAULT_MAX_SATURATION = 0.65
25
+ DEFAULT_VARIANT_COUNT = 17
26
+ MIN_HUE_DISTANCE = 15.0
27
+
28
+ # Named hue anchors used for variants. Order is the output order.
29
+ HUE_ANCHORS = (
30
+ ("rose", "Rose", 350), ("coral", "Coral", 10), ("terracotta", "Terracotta", 20),
31
+ ("amber", "Amber", 36), ("ochre", "Ochre", 45), ("olive", "Olive", 65),
32
+ ("moss", "Moss", 90), ("sage", "Sage", 130), ("jade", "Jade", 155),
33
+ ("teal", "Teal", 178), ("lagoon", "Lagoon", 190), ("ocean", "Ocean", 205),
34
+ ("cobalt", "Cobalt", 220), ("indigo", "Indigo", 240), ("iris", "Iris", 258),
35
+ ("violet", "Violet", 275), ("plum", "Plum", 300), ("orchid", "Orchid", 322),
36
+ )
37
+
38
+
39
+ def parse_hex(value: str) -> tuple[float, float, float]:
40
+ if not isinstance(value, str) or not HEX.match(value):
41
+ raise ValueError(f"colour must be #RRGGBB: {value!r}")
42
+ return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) # type: ignore[return-value]
43
+
44
+
45
+ def to_hex(rgb: tuple[float, float, float]) -> str:
46
+ return "#" + "".join(f"{round(max(0.0, min(1.0, channel)) * 255):02X}" for channel in rgb)
47
+
48
+
49
+ def to_hsl(value: str) -> tuple[float, float, float]:
50
+ hue, lightness, saturation = colorsys.rgb_to_hls(*parse_hex(value))
51
+ return hue * 360, saturation, lightness
52
+
53
+
54
+ def from_hsl(hue: float, saturation: float, lightness: float) -> str:
55
+ return to_hex(colorsys.hls_to_rgb((hue % 360) / 360, max(0.0, min(1.0, lightness)), max(0.0, min(1.0, saturation))))
56
+
57
+
58
+ def luminance(value: str) -> float:
59
+ def linear(channel: float) -> float:
60
+ return channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4
61
+ red, green, blue = (linear(channel) for channel in parse_hex(value))
62
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue
63
+
64
+
65
+ def contrast(first: str, second: str) -> float:
66
+ high, low = sorted((luminance(first), luminance(second)), reverse=True)
67
+ return (high + 0.05) / (low + 0.05)
68
+
69
+
70
+ def hue_distance(first: float, second: float) -> float:
71
+ delta = abs(first - second) % 360
72
+ return min(delta, 360 - delta)
73
+
74
+
75
+ def mix(first: str, second: str, amount: float) -> str:
76
+ a, b = parse_hex(first), parse_hex(second)
77
+ return to_hex(tuple(x + (y - x) * amount for x, y in zip(a, b))) # type: ignore[arg-type]
78
+
79
+
80
+ def ensure_contrast(colour: str, background: str, minimum: float = MIN_TEXT_CONTRAST) -> str:
81
+ """Move only lightness, away from the background, until contrast passes."""
82
+ if contrast(colour, background) >= minimum:
83
+ return colour
84
+ hue, saturation, lightness = to_hsl(colour)
85
+ step = -0.01 if luminance(background) > 0.18 else 0.01
86
+ candidate = colour
87
+ for _ in range(101):
88
+ lightness = max(0.0, min(1.0, lightness + step))
89
+ candidate = from_hsl(hue, saturation, lightness)
90
+ if contrast(candidate, background) >= minimum:
91
+ return candidate
92
+ return candidate
93
+
94
+
95
+ def pick_on_accent(accent: str, candidates: list[str]) -> str:
96
+ return max(candidates, key=lambda candidate: (round(contrast(candidate, accent), 4), candidate))
97
+
98
+
99
+ def complete_roles(roles: dict[str, str]) -> dict[str, str]:
100
+ """Fill derived roles for a signature; supplied roles are never changed."""
101
+ missing = [name for name in ("canvas", "text", "accent") if not roles.get(name)]
102
+ if missing:
103
+ raise ValueError("signature needs canvas, text and accent; missing " + ", ".join(missing))
104
+ for name, value in roles.items():
105
+ if name not in ROLES:
106
+ raise ValueError(f"unknown palette role: {name}")
107
+ parse_hex(value)
108
+ filled = {name: roles[name].upper() for name in roles}
109
+ canvas, text, accent = filled["canvas"], filled["text"], filled["accent"]
110
+ filled.setdefault("panel", mix(canvas, text, 0.04))
111
+ filled.setdefault("soft", mix(canvas, accent, 0.12))
112
+ # The dark band is the text colour on light canvases and a deeper canvas on dark ones.
113
+ if luminance(canvas) > 0.18:
114
+ filled.setdefault("dark", text)
115
+ else:
116
+ canvas_hue, canvas_saturation, canvas_lightness = to_hsl(canvas)
117
+ filled.setdefault("dark", from_hsl(canvas_hue, canvas_saturation, canvas_lightness * 0.8))
118
+ deep_hue, deep_saturation, deep_lightness = to_hsl(filled["dark"])
119
+ filled.setdefault("deep", from_hsl(deep_hue, deep_saturation, deep_lightness * 0.7))
120
+ filled.setdefault("accentText", ensure_contrast(accent, canvas))
121
+ filled.setdefault("onAccent", pick_on_accent(accent, ["#FFFFFF", filled["deep"], text, canvas]))
122
+ return {name: filled[name] for name in ROLES}
123
+
124
+
125
+ def derive_variant(signature: dict[str, str], anchor: tuple[str, str, int], max_saturation: float) -> dict[str, object]:
126
+ identifier, name, target = anchor
127
+ accent_hue, accent_saturation, accent_lightness = to_hsl(signature["accent"])
128
+ shift = target - accent_hue
129
+ roles: dict[str, str] = {}
130
+ for role in ("canvas", "panel", "soft", "text", "dark", "deep"):
131
+ hue, saturation, lightness = to_hsl(signature[role])
132
+ # Neutrals follow the hue move but never gain chroma.
133
+ roles[role] = from_hsl(hue + shift, min(saturation, max_saturation), lightness)
134
+ roles["text"] = ensure_contrast(roles["text"], roles["canvas"])
135
+ roles["text"] = ensure_contrast(roles["text"], roles["panel"])
136
+ accent = from_hsl(target, min(accent_saturation, max_saturation), accent_lightness)
137
+ candidates = ["#FFFFFF", roles["deep"], roles["text"], roles["canvas"]]
138
+ on_accent = pick_on_accent(accent, candidates)
139
+ if contrast(on_accent, accent) < MIN_TEXT_CONTRAST:
140
+ accent = ensure_contrast(accent, on_accent)
141
+ on_accent = pick_on_accent(accent, candidates)
142
+ roles["accent"] = accent
143
+ roles["onAccent"] = on_accent
144
+ roles["accentText"] = ensure_contrast(accent, roles["canvas"])
145
+ return {"id": identifier, "name": name, "roles": {role: roles[role] for role in ROLES}}
146
+
147
+
148
+ def generate(roles: dict[str, str], *, template: str | None = None, name: str = "Signature",
149
+ count: int = DEFAULT_VARIANT_COUNT, max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
150
+ if not 0 < max_saturation <= 1:
151
+ raise ValueError("max saturation must be in (0, 1]")
152
+ if count < 0:
153
+ raise ValueError("count must not be negative")
154
+ signature = complete_roles(roles)
155
+ accent_hue = to_hsl(signature["accent"])[0]
156
+ anchors = [anchor for anchor in HUE_ANCHORS if hue_distance(anchor[2], accent_hue) >= MIN_HUE_DISTANCE]
157
+ variants = [derive_variant(signature, anchor, max_saturation) for anchor in anchors[:count]]
158
+ return {
159
+ "schemaVersion": SCHEMA_VERSION,
160
+ "template": template,
161
+ "signature": {"id": "signature", "name": name, "roles": signature},
162
+ "variants": variants,
163
+ }
164
+
165
+
166
+ def validate(palette: dict[str, object], max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
167
+ errors: list[str] = []
168
+ warnings: list[str] = []
169
+ if palette.get("schemaVersion") != SCHEMA_VERSION:
170
+ errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
171
+ template = palette.get("template")
172
+ if template is not None and not isinstance(template, str):
173
+ errors.append("template must be a string or null")
174
+ entries: list[tuple[dict, bool]] = []
175
+ if isinstance(palette.get("signature"), dict):
176
+ entries.append((palette["signature"], False)) # type: ignore[arg-type]
177
+ else:
178
+ errors.append("signature must be an object")
179
+ if isinstance(palette.get("variants"), list):
180
+ entries.extend((variant, True) for variant in palette["variants"]) # type: ignore[union-attr]
181
+ else:
182
+ errors.append("variants must be a list")
183
+ seen: set[str] = set()
184
+ checked = 0
185
+ for entry, is_variant in entries:
186
+ if not isinstance(entry, dict):
187
+ errors.append("each palette entry must be an object")
188
+ continue
189
+ identifier = str(entry.get("id") or "")
190
+ label = identifier or "<missing id>"
191
+ if not identifier or not str(entry.get("name") or "").strip():
192
+ errors.append(f"{label}: id and name are required")
193
+ if identifier in seen:
194
+ errors.append(f"{label}: duplicate palette id")
195
+ seen.add(identifier)
196
+ roles = entry.get("roles")
197
+ if not isinstance(roles, dict):
198
+ errors.append(f"{label}: roles must be an object")
199
+ continue
200
+ missing = [role for role in ROLES if role not in roles]
201
+ invalid = [role for role in ROLES if role in roles and not (isinstance(roles[role], str) and HEX.match(roles[role]))]
202
+ if missing:
203
+ errors.append(f"{label}: missing roles " + ", ".join(missing))
204
+ if invalid:
205
+ errors.append(f"{label}: roles must be #RRGGBB: " + ", ".join(invalid))
206
+ if missing or invalid:
207
+ continue
208
+ checked += 1
209
+ for foreground, background in (("text", "canvas"), ("text", "panel"), ("accentText", "canvas"), ("onAccent", "accent")):
210
+ ratio = contrast(roles[foreground], roles[background])
211
+ if ratio < MIN_TEXT_CONTRAST:
212
+ errors.append(f"{label}: {foreground} on {background} is {ratio:.2f}:1; needs {MIN_TEXT_CONTRAST}:1")
213
+ saturation = to_hsl(roles["accent"])[1]
214
+ if is_variant and saturation > max_saturation + 0.01:
215
+ errors.append(f"{label}: accent saturation {saturation:.2f} exceeds cap {max_saturation:.2f}")
216
+ if contrast(roles["text"], roles["soft"]) < MIN_TEXT_CONTRAST:
217
+ warnings.append(f"{label}: text on soft is below {MIN_TEXT_CONTRAST}:1; avoid body copy on soft surfaces")
218
+ return {"schemaVersion": "maggie-palette-validation.v1", "passed": not errors, "checkedPalettes": checked, "errors": errors, "warnings": warnings}
219
+
220
+
221
+ def _load_roles(args: argparse.Namespace) -> dict[str, str]:
222
+ roles: dict[str, str] = {}
223
+ if args.roles:
224
+ data = json.loads(args.roles.read_text(encoding="utf-8"))
225
+ if isinstance(data.get("signature"), dict):
226
+ data = data["signature"].get("roles") or {}
227
+ elif isinstance(data.get("roles"), dict):
228
+ data = data["roles"]
229
+ roles.update({key: value for key, value in data.items() if isinstance(value, str)})
230
+ for role in ROLES:
231
+ value = getattr(args, role, None)
232
+ if value:
233
+ roles[role] = value
234
+ return roles
235
+
236
+
237
+ def _emit(payload: dict[str, object], output: Path | None) -> None:
238
+ encoded = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
239
+ if output:
240
+ output.parent.mkdir(parents=True, exist_ok=True)
241
+ output.write_text(encoded, encoding="utf-8")
242
+ print(encoded, end="")
243
+
244
+
245
+ def main(argv: list[str] | None = None) -> int:
246
+ parser = argparse.ArgumentParser(prog="maggie design palette", description=__doc__.splitlines()[0])
247
+ sub = parser.add_subparsers(dest="command", required=True)
248
+ generate_parser = sub.add_parser("generate", help="derive contrast-safe variants from a signature palette")
249
+ generate_parser.add_argument("--roles", type=Path, help="JSON roles object, or an existing palette file")
250
+ for role in ROLES:
251
+ generate_parser.add_argument(f"--{role}", dest=role, help=f"{role} colour as #RRGGBB")
252
+ generate_parser.add_argument("--template", help="template id recorded in the contract")
253
+ generate_parser.add_argument("--name", default="Signature", help="signature palette display name")
254
+ generate_parser.add_argument("--count", type=int, default=DEFAULT_VARIANT_COUNT)
255
+ generate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
256
+ generate_parser.add_argument("--output", type=Path)
257
+ validate_parser = sub.add_parser("validate", help="check roles, hex values, contrast and saturation cap")
258
+ validate_parser.add_argument("--palette", type=Path, required=True)
259
+ validate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
260
+ args = parser.parse_args(argv)
261
+ try:
262
+ if args.command == "generate":
263
+ palette = generate(_load_roles(args), template=args.template, name=args.name, count=args.count, max_saturation=args.max_saturation)
264
+ report = validate(palette, args.max_saturation)
265
+ if not report["passed"]:
266
+ print(json.dumps(report, indent=2), file=sys.stderr)
267
+ print("BLOCKED: maggie-design palette generate: the signature palette fails the contrast contract", file=sys.stderr)
268
+ return 1
269
+ _emit(palette, args.output)
270
+ return 0
271
+ report = validate(json.loads(args.palette.read_text(encoding="utf-8")), args.max_saturation)
272
+ _emit(report, None)
273
+ return 0 if report["passed"] else 1
274
+ except (OSError, ValueError, json.JSONDecodeError) as error:
275
+ print(f"BLOCKED: maggie-design palette {args.command}: {error}", file=sys.stderr)
276
+ return 1
277
+
278
+
279
+ if __name__ == "__main__":
280
+ raise SystemExit(main())
@@ -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)
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """Validate and materialise safe Maggie scheduled-job definitions."""
2
+ """Validate and materialise safe Maggie scheduled-job definitions.
3
+
4
+ These jobs are the `infrastructure` schedule layer: when a worker wakes up.
5
+ They never carry a content-calendar publish time, a campaign flight, or a
6
+ measurement window; those layers are declared in a marketing run and checked
7
+ by `maggie_marketing_run.py` (see `maggie_marketing_run.py layers`).
8
+ """
3
9
 
4
10
  from __future__ import annotations
5
11
 
@@ -68,11 +74,15 @@ def validate(manifest: object, project: Path | None = None) -> dict:
68
74
  errors.append(f"{prefix}.maxRetries must be between 0 and 5")
69
75
  if job.get("overlapPolicy") != "skip-if-running":
70
76
  errors.append(f"{prefix}.overlapPolicy must be skip-if-running")
77
+ if job.get("layer", "infrastructure") != "infrastructure":
78
+ errors.append(f"{prefix}.layer must be infrastructure; content calendars, campaign flights and measurement windows belong in a marketing run")
79
+ if any(key in job for key in ("scheduled_at", "scheduledAt", "publishAt", "publish_at")):
80
+ errors.append(f"{prefix} must not carry a publish time; a timer only wakes the worker")
71
81
  if not job.get("lockKey"):
72
82
  errors.append(f"{prefix}.lockKey is required")
73
83
  if re.search(r"(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=|https?://[^ $]+:[^ $]+@", str(job.get("command", "")), re.I):
74
84
  errors.append(f"{prefix}.command must not contain credentials")
75
- return {"passed": not errors, "errors": errors, "jobs": len(jobs), "sourceChecks": source_checks, "mutation": "not executed"}
85
+ return {"passed": not errors, "errors": errors, "jobs": len(jobs), "sourceChecks": source_checks, "layer": "infrastructure", "mutation": "not executed"}
76
86
 
77
87
 
78
88
  def write_units(output_dir: Path, manifest: dict) -> None: