@topy-ai/maggie 0.7.47 → 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 (58) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +49 -15
  3. package/bin/maggie.js +6 -2
  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/provider-adapter-contract.md +33 -0
  30. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  31. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  32. package/bundled-skills/maggie-deployment/SKILL.md +53 -1
  33. package/bundled-skills/maggie-deployment/references/vps.md +41 -0
  34. package/bundled-skills/maggie-design/SKILL.md +99 -0
  35. package/bundled-skills/maggie-feedback/SKILL.md +38 -1
  36. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  38. package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
  39. package/bundled-skills/maggie-social-share/SKILL.md +37 -5
  40. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
  41. package/bundled-skills/maggie-template/SKILL.md +57 -3
  42. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  43. package/bundled-tools/clis/maggie_booking.py +199 -26
  44. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  45. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  46. package/bundled-tools/clis/maggie_deployment.py +249 -1
  47. package/bundled-tools/clis/maggie_design.py +9 -0
  48. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  49. package/bundled-tools/clis/maggie_feedback.py +106 -3
  50. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  51. package/bundled-tools/clis/maggie_palette.py +280 -0
  52. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  53. package/bundled-tools/clis/maggie_social_queue.py +338 -0
  54. package/bundled-tools/clis/maggie_social_share.py +42 -9
  55. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  56. package/package.json +1 -1
  57. package/references/browser-inspection.md +25 -0
  58. package/references/provider-adapter-contract.md +33 -0
@@ -0,0 +1,101 @@
1
+ # Social Share queue, campaigns, and connections
2
+
3
+ All commands read one redacted JSON document, make no provider or model calls,
4
+ and print a JSON report. Validation commands exit `1` when the contract fails.
5
+
6
+ ## Adapter scope contract — `adapters`
7
+
8
+ ```json
9
+ {
10
+ "adapters": [{
11
+ "provider": "linkedin",
12
+ "canonical_scopes": ["openid", "profile", "email", "w_member_social"],
13
+ "client_id_ref": "linkedin-app-main",
14
+ "connect_paths": [
15
+ {"id": "dashboard", "scopes": ["openid", "profile", "email", "w_member_social"]},
16
+ {"id": "mobile", "scopes": ["openid", "profile", "email", "w_member_social"]}
17
+ ],
18
+ "redirect_uris": ["https://app.example.com/oauth/linkedin/callback"],
19
+ "client_shared_with": []
20
+ }]
21
+ }
22
+ ```
23
+
24
+ - Fails when any connect path requests a scope set different from
25
+ `canonical_scopes` (order is ignored). LinkedIn revokes a member's earlier
26
+ tokens when a sign-in requests a different scope set.
27
+ - Fails when secret fields (`client_secret`, tokens) appear in the manifest.
28
+ `client_id_ref` is a label, not the credential.
29
+ - Warns when the OAuth client is shared with other products, or redirect URIs
30
+ span several hosts without a `client_shared_with` record.
31
+
32
+ ## Connection health — `connections`
33
+
34
+ ```json
35
+ {"connections": [{
36
+ "id": "conn-1", "provider": "linkedin", "status": "active",
37
+ "token_decryptable": true,
38
+ "provider_check": {"status": "valid", "checked_at": "2026-10-10T08:00:00Z"},
39
+ "avatar_url": "https://media.licdn.com/dms/image/...?...&e=1790000000",
40
+ "avatar_cached": false, "avatar_refresh_on_read": false,
41
+ "scheduled_items": 4
42
+ }]}
43
+ ```
44
+
45
+ - `status` is derived: `needs_reconnect` when the token cannot be decrypted on
46
+ the serving host or the provider reports it `revoked`/`invalid`/`expired`.
47
+ A stored `active` that should be `needs_reconnect` fails, and the report
48
+ counts scheduled items that would otherwise fail at publish time.
49
+ - Run the decrypt check on every host that serves social routes; a missing
50
+ encryption key on a failover host looks exactly like an undecryptable token.
51
+ - A signed, expiring avatar URL (LinkedIn `e=`, Meta `oe=`, S3/GCS signed
52
+ URLs) fails unless `avatar_cached` (bytes in owned storage) or
53
+ `avatar_refresh_on_read` is true. Use `--now` for deterministic checks.
54
+
55
+ ## Campaign idea stage — `campaigns`
56
+
57
+ ```text
58
+ idea (planned, no copy) --write (1 credit)--> written (draft_id) --all written + start_day + posts_per_day--> Start
59
+ ```
60
+
61
+ - Ideas are visible and manageable on every client (dashboard and mobile), not
62
+ only where they were planned. Written drafts keep their `campaign_id`.
63
+ - A campaign can start only when every idea is `written` with a `draft_id`,
64
+ orders are unique integers, `start_day` is a date, and `posts_per_day >= 1`.
65
+ The report lists blockers; a `started` campaign with blockers fails.
66
+
67
+ ## Slot-based posting queue — `queue`
68
+
69
+ Posts do not carry a fixed publish time. The person's posting hours are slots:
70
+
71
+ ```json
72
+ {
73
+ "timezone": "Europe/London",
74
+ "posting_hours": {"mon": ["09:00", "17:00"], "wed": ["12:30"]},
75
+ "queue": [{"id": "post-7", "queued_at": "2026-10-09T10:00:00Z", "not_before": null}],
76
+ "campaigns": [{"id": "autumn", "status": "started", "start_day": "2026-10-12", "posts_per_day": 1,
77
+ "ideas": [{"id": "i1", "order": 1, "status": "written", "draft_id": "post-31"}],
78
+ "posted_idea_ids": []}]
79
+ }
80
+ ```
81
+
82
+ ```bash
83
+ maggie tool maggie_social_share.py queue queue.json --from 2026-10-12T00:00:00Z --slots 14
84
+ ```
85
+
86
+ Rules the planner applies, in order:
87
+
88
+ 1. Each slot (local wall time in `timezone`) posts at most one item.
89
+ 2. The standalone queue is newest-first: a new post goes to the front.
90
+ 3. A stored time is `not_before` only. An overdue item is simply eligible; it
91
+ is never stranded behind a past `scheduled_at`.
92
+ 4. A started campaign publishes written ideas in idea order from `start_day`,
93
+ at most `posts_per_day` per local day.
94
+ 5. The standalone queue and each started campaign take turns (round-robin);
95
+ a source with nothing eligible is skipped for that slot.
96
+ 6. Wall times keep their local meaning across DST. A time that does not exist
97
+ (spring-forward gap) is skipped and reported; an ambiguous time uses its
98
+ first occurrence.
99
+
100
+ The plan is advisory output for the host scheduler, which re-plans after every
101
+ publish, approval, or queue edit instead of persisting stale per-post times.
@@ -28,9 +28,10 @@ maggie tool maggie_marketplace.py apply \
28
28
  --id medical-spa-landing-001 --project /path/to/astro-project
29
29
  ```
30
30
 
31
- `list` shows ID, lifecycle status, industries, and style tags. `suggest`
32
- matches the user's brief against those fields and ranks published entries
33
- first. `inspect` returns metadata and available artifacts. `apply` validates
31
+ `list` shows ID, the neutral `displayName`, lifecycle status, industries, and
32
+ style tags. Show customers the `displayName` and `displayDescription`, never the
33
+ source title in `sourceMetadata`. `suggest` matches the user's brief against
34
+ those fields and ranks published entries first. `inspect` returns metadata and available artifacts. `apply` validates
34
35
  the template, writes a namespaced reference to `.maggie/marketplace/<id>/`,
35
36
  copies the homepage, assets, screenshots, and `DESIGN.md`, and records
36
37
  `.maggie/template-selection.json`. It does not silently overwrite application
@@ -39,6 +40,59 @@ routes.
39
40
  Draft entries are for review only. Use `--allow-draft` only when the user
40
41
  explicitly wants to preview an unpublished template.
41
42
 
43
+ ## Preview studio
44
+
45
+ Compare a template across viewports and palettes before applying it:
46
+
47
+ ```bash
48
+ maggie tool maggie_marketplace.py measure-design <id> --palette-roles /tmp/<id>-palettes.json
49
+ maggie tool maggie_design.py palette generate --roles /tmp/<id>-palettes.json \
50
+ --template <id> --output /tmp/<id>-palettes.json # optional: contrast-safe variants
51
+ maggie tool maggie_marketplace.py studio <id> --palettes /tmp/<id>-palettes.json
52
+ maggie tool maggie_marketplace.py studio <id> --palettes /tmp/<id>-palettes.json --check-overflow [--palette <variant-id>]
53
+ ```
54
+
55
+ `studio` writes `.maggie/template-studio/<id>/index.html` under the
56
+ marketplace root: the homepage in scaled frames at 390, 768, 1024, and
57
+ 1440 px, a palette dropdown fed by a `maggie-palette-roles-v1` file (the
58
+ measured signature when none is given), and labels on sample copy. Each label
59
+ links to `content-fields.html`, which names the `maggie-template-content-v1`
60
+ field and where the owner replaces it. Elements marked
61
+ `data-maggie-sample="hero.headline"` are labelled exactly; otherwise headings,
62
+ contact links, and section headings are matched heuristically. A palette is
63
+ applied by swapping the signature role colours for the variant's and by
64
+ setting `--maggie-<role>` CSS variables. Open `#<palette-id>` to link a variant.
65
+
66
+ Serve the marketplace root over HTTP (`python3 -m http.server`) to view it.
67
+ `--check-overflow` serves it locally, renders it with headless Chrome, writes
68
+ `overflow-report.json`, and exits non-zero when any viewport has horizontal
69
+ overflow, naming up to five offending elements. Templates that load Tailwind or
70
+ fonts from a CDN need network access for a faithful render.
71
+
72
+ ## Data-driven component export
73
+
74
+ A template reused across many businesses must render data, not hand-edited
75
+ copy. The neutral content contract is
76
+ [`contracts/maggie-marketplace/template-content-v1.schema.json`](../../bundled-contracts/maggie-marketplace/template-content-v1.schema.json)
77
+ (`brand`, `contact`, `hero`, `categories` with `services`, `about`, `gallery`,
78
+ `reviews`, `hours`, `faqs`, plus `samples` and an optional palette). An example
79
+ is in [`references/template-content-v1.example.json`](references/template-content-v1.example.json).
80
+
81
+ ```bash
82
+ maggie tool maggie_marketplace.py content-validate --input content.json
83
+ maggie tool maggie_marketplace.py content-css --input content.json # :root { --maggie-<role>: … }
84
+ ```
85
+
86
+ `content-validate` enforces the business rules the schema cannot: service
87
+ prices and durations, `HH:MM` opening hours, reviews that carry a `source` or
88
+ are explicitly `sample: true` (never invent reviews), and `samples` paths that
89
+ resolve. Export path: build one Astro component per template that takes the
90
+ content object as props, maps each `[data-maggie-sample]` region to its
91
+ content field, and reads colours from the `--maggie-<role>` variables emitted
92
+ by `content-css`. Converting the existing marketplace templates into such
93
+ components is not automated yet; do it per template and keep the static
94
+ `homepage.html` as the visual reference.
95
+
42
96
  ## Selection workflow
43
97
 
44
98
  Use the deterministic marketplace CLI for listing, recommendation, inspection,
@@ -0,0 +1,34 @@
1
+ {
2
+ "schemaVersion": "maggie-template-content-v1",
3
+ "brand": { "name": "Maggie Studio", "tagline": "Considered care in London", "logo": { "src": "assets/maggie-mark.png", "alt": "Maggie Studio" } },
4
+ "contact": { "phone": "020 0000 0000", "email": "hello@example.co.uk", "address": "1 Example Street, London", "bookingUrl": "/book" },
5
+ "hero": {
6
+ "headline": "Calm, personalised treatments",
7
+ "subheadline": "Book facials, massage and body treatments online.",
8
+ "image": { "src": "assets/maggie-hero.png", "alt": "Treatment room" },
9
+ "cta": { "label": "Book now", "href": "/book" }
10
+ },
11
+ "categories": [
12
+ {
13
+ "name": "Facials",
14
+ "services": [
15
+ { "name": "Signature facial", "price": 85, "currency": "GBP", "durationMinutes": 60, "bookingRef": null },
16
+ { "name": "Express facial", "price": 50, "currency": "GBP", "durationMinutes": 30, "bookingRef": null }
17
+ ]
18
+ }
19
+ ],
20
+ "about": { "heading": "About us", "body": "A small team offering unhurried appointments.", "image": null },
21
+ "gallery": [{ "src": "assets/maggie-care-team.png", "alt": "Care team" }],
22
+ "reviews": [{ "quote": "Thoughtful and relaxing.", "author": "A. Client", "rating": 5, "sample": true }],
23
+ "hours": [
24
+ { "day": "mon", "opens": "09:00", "closes": "18:00" },
25
+ { "day": "sun", "closed": true }
26
+ ],
27
+ "faqs": [{ "question": "Do you take walk-ins?", "answer": "Please book online so we can prepare your room." }],
28
+ "samples": ["brand.name", "contact.phone", "contact.email", "contact.address", "hero.headline", "reviews.0"],
29
+ "palette": {
30
+ "id": "signature",
31
+ "name": "Signature",
32
+ "roles": { "canvas": "#FDFBF7", "panel": "#FFFFFF", "soft": "#F2EFE9", "text": "#2A241E", "accent": "#2D5A27", "accentText": "#2D5A27", "onAccent": "#FFFFFF", "dark": "#2D5A27", "deep": "#000000" }
33
+ }
34
+ }
@@ -7,6 +7,8 @@ import argparse
7
7
  import json
8
8
  import os
9
9
  import re
10
+ import struct
11
+ import zlib
10
12
  import secrets
11
13
  import shlex
12
14
  import shutil
@@ -17,6 +19,7 @@ from pathlib import Path
17
19
 
18
20
 
19
21
  REPO_ROOT = Path(__file__).resolve().parents[2]
22
+ STRIPE_SECRET_ENV_KEYS = ("STRIPE_SECRET_KEY", "ST_SECRECT_KEY")
20
23
  # Source checkouts keep contracts and the delivery board at repository root;
21
24
  # npm packages keep the same artifacts under bundled-contracts. Resolve both
22
25
  # layouts so the published CLI uses the exact same Booking gates as the repo.
@@ -26,7 +29,9 @@ DEFAULT_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-host-adapter-v1.json
26
29
  DEFAULT_ACCESS_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-access-v1.json"
27
30
  DEFAULT_EMAIL_TEMPLATES_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-email-templates-v1.json"
28
31
  DEFAULT_OPS_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-ops-evidence-v1.json"
32
+ DEFAULT_BROWSER_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-browser-evidence-v2.json"
29
33
  DEFAULT_STRIPE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "stripe-booking-capabilities-v1.json"
34
+ DEFAULT_VOUCHER_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-voucher-lifecycle-v1.json"
30
35
  EXECUTION_BOARD = (
31
36
  CONTRACTS_ROOT / "maggiedash" / "execution-board.json"
32
37
  if PACKAGED_CONTRACTS_ROOT.is_dir()
@@ -149,7 +154,7 @@ def stripe_contract_checks(contract: dict, max_age_days: int = 120) -> list[str]
149
154
  errors.extend(f"missing API matrix entry: {item}" for item in sorted(required_apis - api_ids))
150
155
  events = contract.get("x-webhook-events") or contract.get("webhookEvents") or []
151
156
  event_ids = {item.get("eventType") for item in events if isinstance(item, dict)}
152
- required_events = {"checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "payment_intent.processing", "payment_intent.payment_failed", "payment_intent.succeeded", "payment_intent.requires_action", "payment_intent.canceled", "refund.created", "refund.updated", "refund.failed"}
157
+ required_events = {"checkout.session.completed", "checkout.session.expired", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "payment_intent.processing", "payment_intent.payment_failed", "payment_intent.succeeded", "payment_intent.requires_action", "payment_intent.canceled", "refund.created", "refund.updated", "refund.failed"}
153
158
  errors.extend(f"missing webhook event: {item}" for item in sorted(required_events - event_ids))
154
159
  native = contract.get("x-native-capabilities") or contract.get("nativeCapabilities") or {}
155
160
  if native.get("giftCards") != "not-native": errors.append("giftCards must remain not-native")
@@ -284,12 +289,16 @@ def email_templates_contract_checks(contract: dict) -> list[str]:
284
289
  if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
285
290
  if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
286
291
  events = contract.get("events")
287
- if not isinstance(events, list) or len(events) != 9: errors.append("exactly nine Booking email events are required")
292
+ if not isinstance(events, list) or len(events) != 10: errors.append("exactly ten Booking email events are required")
288
293
  event_ids = [item.get("id") for item in events if isinstance(item, dict)] if isinstance(events, list) else []
289
- expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
294
+ expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "payment.no_show_fee", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
290
295
  if event_ids != expected_events: errors.append("email event IDs are incomplete or out of order")
291
296
  if len(event_ids) != len(set(event_ids)): errors.append("email event IDs must be unique")
292
- allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName"}
297
+ allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName", "arrivalNote"}
298
+ declared_variables = contract.get("variables")
299
+ event_variables = {variable for event in events if isinstance(event, dict) for variable in event.get("variables", []) if isinstance(variable, str)} if isinstance(events, list) else set()
300
+ if declared_variables is not None and (not isinstance(declared_variables, list) or set(declared_variables) != event_variables or len(declared_variables) != len(set(declared_variables))):
301
+ errors.append("email template global variables must exactly match the event allowlists")
293
302
  for event in events if isinstance(events, list) else []:
294
303
  variables = event.get("variables") if isinstance(event, dict) else None
295
304
  if not isinstance(variables, list) or not variables: errors.append(f"{event.get('id', 'unknown')}: variables must be a non-empty list")
@@ -384,6 +393,23 @@ def command_email_templates_contract(args: argparse.Namespace) -> int:
384
393
  return 0 if not errors else 1
385
394
 
386
395
 
396
+ def command_voucher_contract(args: argparse.Namespace) -> int:
397
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
398
+ from maggie_booking_vouchers import voucher_contract_checks, voucher_ledger_checks
399
+ path = Path(args.contract).expanduser().resolve()
400
+ contract = load_json(path)
401
+ errors = voucher_contract_checks(contract)
402
+ result = {"status": "passed", "contract": str(path), "schemaVersion": contract.get("schemaVersion"), "runtimeOwner": (contract.get("runtime") or {}).get("owner"), "errors": errors}
403
+ if args.ledger:
404
+ ledger_path = Path(args.ledger).expanduser().resolve()
405
+ vouchers, ledger_errors = voucher_ledger_checks(load_json(ledger_path))
406
+ result.update({"ledger": str(ledger_path), "vouchers": vouchers})
407
+ errors.extend(ledger_errors)
408
+ result["status"] = "passed" if not errors else "failed"
409
+ print(json.dumps(result, indent=2, ensure_ascii=False))
410
+ return 0 if not errors else 1
411
+
412
+
387
413
  def project_path(args: argparse.Namespace) -> Path:
388
414
  return Path(args.project).expanduser().resolve()
389
415
 
@@ -625,10 +651,21 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
625
651
  has_sender = has("EMAIL_FROM") or has("RESEND_FROM_EMAIL")
626
652
  token_present = has("BOOKING_TOKEN_SECRET")
627
653
  token_valid = token_present and len(values.get("BOOKING_TOKEN_SECRET", "")) >= 32
628
- stripe_secret = has("STRIPE_SECRET_KEY")
654
+ stripe_secret = any(has(key) for key in STRIPE_SECRET_ENV_KEYS)
655
+ stripe_legacy_alias = has("ST_SECRECT_KEY") and not has("STRIPE_SECRET_KEY")
629
656
  stripe_webhook = has("STRIPE_WEBHOOK_SECRET")
630
657
  stripe_complete = stripe_secret and stripe_webhook
631
658
  stripe_partial = stripe_secret != stripe_webhook
659
+ stripe_key_help = "STRIPE_SECRET_KEY (or existing ORA alias ST_SECRECT_KEY)"
660
+ stripe_detail = (
661
+ "Recognizes ORA's existing ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
662
+ if stripe_legacy_alias and stripe_complete
663
+ else f"Both {stripe_key_help} and STRIPE_WEBHOOK_SECRET are required for online payments."
664
+ if stripe_partial
665
+ else "Required only for deposit/full online payment flows; webhook URL is /api/maggie/booking/webhooks/stripe."
666
+ if stripe_secret or stripe_webhook
667
+ else "Optional at install: an owner or manager can connect Stripe from Booking Overview, or use the deployment secret store."
668
+ )
632
669
  workspace = (root / "_maggie" / "booking" / "booking.tsx").is_file()
633
670
  middleware = root / "src" / "middleware.ts"
634
671
  middleware_text = middleware.read_text(encoding="utf-8") if middleware.is_file() else ""
@@ -641,7 +678,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
641
678
  setup_check("site", "Public site URL", ["PUBLIC_SITE_URL"], has("PUBLIC_SITE_URL"), detail="Used for login links, booking links, and email URLs."),
642
679
  setup_check("booking-token", "Public booking token secret", ["BOOKING_TOKEN_SECRET"], token_valid, detail="Must be at least 32 characters; used to sign public manage-booking links." if token_valid or not token_present else "The configured secret is too short; use at least 32 characters.", status_override="passed" if token_valid else "invalid" if token_present else None),
643
680
  setup_check("resend", "Resend email delivery", ["RESEND_API_KEY", "EMAIL_FROM|RESEND_FROM_EMAIL"], has("RESEND_API_KEY") and has_sender, detail="Required for password setup/reset, booking notifications, and reminders."),
644
- setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail="Required only for deposit/full online payment flows; webhook URL is /api/maggie/booking/webhooks/stripe." if not stripe_partial and (stripe_secret or stripe_webhook) else "Both Stripe secret and webhook secret are required for online payments." if stripe_partial else "Optional at install: an owner or manager can connect Stripe from Booking Overview, or use the deployment secret store.", status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
681
+ setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY|ST_SECRECT_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail=stripe_detail, status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
645
682
  {"id": "schema", "label": "Booking database schema", "status": "not-verified", "required": True, "detail": "The CLI does not connect to PostgreSQL. Apply the schema after backup, then verify with the host runtime checks."},
646
683
  {"id": "owner", "label": "First owner account", "status": "not-verified", "required": True, "detail": "Open /_maggie/register once the schema is applied. Registration closes after the first owner."},
647
684
  {"id": "worker", "label": "Resend worker schedule", "status": "not-verified", "required": True, "detail": "Run the installed worker from a single-flight scheduler; setup does not create a cron or systemd job."},
@@ -656,7 +693,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
656
693
  if "booking-token" in missing_config:
657
694
  next_steps.append("Regenerate BOOKING_TOKEN_SECRET with at least 32 characters before enabling public customer manage links.")
658
695
  if "stripe" in degraded_config:
659
- next_steps.append("Configure both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before accepting online payments.")
696
+ next_steps.append(f"Configure both {stripe_key_help} and STRIPE_WEBHOOK_SECRET before accepting online payments.")
660
697
  if missing_dependencies:
661
698
  next_steps.append("For a new host, install missing packages and apply the schema together: maggie booking install --project . --confirm --ready")
662
699
  if not missing_dependencies and any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
@@ -676,7 +713,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
676
713
  "status": status,
677
714
  "project": str(root),
678
715
  "checks": checks,
679
- "configuredKeys": sorted(key for key in sources if key in {"DATABASE_URL", "PUBLIC_SITE_URL", "BOOKING_TOKEN_SECRET", "RESEND_API_KEY", "EMAIL_FROM", "RESEND_FROM_EMAIL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"}),
716
+ "configuredKeys": sorted(key for key in sources if key in {"DATABASE_URL", "PUBLIC_SITE_URL", "BOOKING_TOKEN_SECRET", "RESEND_API_KEY", "EMAIL_FROM", "RESEND_FROM_EMAIL", "STRIPE_SECRET_KEY", "ST_SECRECT_KEY", "STRIPE_WEBHOOK_SECRET"}),
680
717
  "verificationPending": verification_pending,
681
718
  "paymentConfiguration": "online-payments-pending-verification" if stripe_complete else "incomplete-deployment-stripe" if stripe_partial else "dashboard-or-host-secret-pending",
682
719
  "next": next_steps,
@@ -946,7 +983,7 @@ def command_inspect(args: argparse.Namespace) -> int:
946
983
  "stripeContract": stripe_contract.is_file(),
947
984
  "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file(),
948
985
  "runtimeContract": (root / "maggiedash" / "contracts" / "booking-runtime-v1.json").is_file(),
949
- "browserEvidenceContract": (root / "maggiedash" / "contracts" / "booking-browser-evidence-v1.json").is_file(),
986
+ "browserEvidenceContract": (root / "maggiedash" / "contracts" / "booking-browser-evidence-v2.json").is_file(),
950
987
  "runtimePlanValidator": (root / "maggiedash" / "scripts" / "validate-booking-runtime-plan.mjs").is_file(),
951
988
  "customerFlowValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-flow.mjs").is_file(),
952
989
  "customerUIValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-ui.mjs").is_file(),
@@ -1014,36 +1051,147 @@ def runtime_result(evidence: dict, contract: dict) -> dict:
1014
1051
  return {"status": "passed" if not errors and bool(observations) else "failed", "checked": len(observations), "required": len(required), "missing": missing, "errors": errors}
1015
1052
 
1016
1053
 
1017
- def browser_result(evidence: dict, artifact_root: Path) -> dict:
1054
+ def browser_result(evidence: dict, artifact_root: Path, contract: dict | None = None) -> dict:
1018
1055
  """Validate sanitized browser evidence without importing a browser runtime."""
1019
1056
  errors: list[str] = []
1020
- if evidence.get("schemaVersion") != "maggiedash-booking-browser-evidence.v1": errors.append("browser schemaVersion is unsupported")
1057
+ contract = contract or load_json(DEFAULT_BROWSER_EVIDENCE_CONTRACT)
1058
+ if contract.get("properties", {}).get("schemaVersion", {}).get("const") != "maggiedash-booking-browser-evidence.v2": errors.append("browser evidence contract is unsupported")
1059
+ if evidence.get("schemaVersion") != "maggiedash-booking-browser-evidence.v2": errors.append("browser schemaVersion is unsupported")
1021
1060
  if not str(evidence.get("baseUrl", "")).startswith("https://"): errors.append("browser baseUrl must use HTTPS")
1022
1061
  generated_at = evidence.get("generatedAt")
1023
1062
  try:
1024
1063
  datetime.fromisoformat(str(generated_at).replace("Z", "+00:00"))
1025
1064
  except (TypeError, ValueError):
1026
1065
  errors.append("browser generatedAt must be an ISO timestamp")
1027
- viewport_map = {("mobile", 390, 844), ("tablet", 768, 1024), ("desktop", 1440, 1000)}
1066
+ required_viewports = contract.get("x-required-viewports") if isinstance(contract.get("x-required-viewports"), list) else []
1067
+ viewport_map = {(item.get("name"), item.get("width"), item.get("height")) for item in required_viewports if isinstance(item, dict)}
1028
1068
  actual_viewports = evidence.get("viewports") if isinstance(evidence.get("viewports"), list) else []
1029
1069
  observed_viewports = {(item.get("name"), item.get("width"), item.get("height")) for item in actual_viewports if isinstance(item, dict)}
1030
1070
  for viewport in sorted(viewport_map - observed_viewports):
1031
1071
  errors.append(f"missing viewport {viewport[0]} {viewport[1]}x{viewport[2]}")
1032
- routes = ["/_maggie/booking/", "/_maggie/booking/calendar", "/_maggie/booking/bookings", "/_maggie/booking/customers", "/_maggie/booking/waitlist", "/_maggie/booking/locations", "/_maggie/booking/catalog", "/_maggie/booking/staff", "/_maggie/booking/resources", "/_maggie/booking/schedule", "/_maggie/booking/payments", "/_maggie/booking/notifications", "/_maggie/booking/reports", "/_maggie/booking/settings", "/_maggie/booking/audit"]
1072
+ routes = contract.get("x-required-routes") if isinstance(contract.get("x-required-routes"), list) else []
1073
+ expected_headings = contract.get("x-required-headings") if isinstance(contract.get("x-required-headings"), dict) else {}
1074
+ if not routes or len(set(routes)) != len(routes): errors.append("browser evidence contract must define unique required routes")
1075
+ if len(expected_headings) != len(routes) or any(not isinstance(expected_headings.get(route), str) or not expected_headings[route].strip() for route in routes): errors.append("browser evidence contract must define one heading for every required route")
1033
1076
  screens = evidence.get("screens") if isinstance(evidence.get("screens"), list) else []
1077
+ expected_count = len(routes) * len(viewport_map)
1078
+ if len(screens) != expected_count: errors.append(f"browser screen count must be exactly {expected_count}")
1079
+ expected_pairs = {(route, viewport[0]) for route in routes for viewport in viewport_map}
1080
+ seen_pairs: set[tuple[str, str]] = set()
1081
+ for item in screens:
1082
+ if not isinstance(item, dict):
1083
+ errors.append("browser screen must be an object")
1084
+ continue
1085
+ pair = (item.get("route"), item.get("viewport"))
1086
+ if pair not in expected_pairs: errors.append(f"unexpected browser screen {pair[1]}:{pair[0]}")
1087
+ if pair in seen_pairs: errors.append(f"duplicate browser screen {pair[1]}:{pair[0]}")
1088
+ seen_pairs.add(pair)
1034
1089
  for route in routes:
1035
- for viewport in ("mobile", "tablet", "desktop"):
1090
+ for viewport in sorted({item[0] for item in viewport_map}):
1036
1091
  item = next((row for row in screens if isinstance(row, dict) and row.get("route") == route and row.get("viewport") == viewport), None)
1037
1092
  if not item: errors.append(f"missing browser screen {viewport}:{route}"); continue
1038
1093
  if not isinstance(item.get("httpStatus"), int) or not 200 <= item["httpStatus"] < 400: errors.append(f"browser screen is not successful {viewport}:{route}")
1094
+ final_path = item.get("finalPath")
1095
+ normalized_final_path = final_path.rstrip("/") if isinstance(final_path, str) and final_path != "/" else final_path
1096
+ normalized_route = route.rstrip("/") if route != "/" else route
1097
+ if not isinstance(final_path, str) or not final_path.startswith("/") or final_path.startswith("//") or "?" in final_path or "#" in final_path or normalized_final_path != normalized_route: errors.append(f"browser screen final path does not match route {viewport}:{route}")
1098
+ if not isinstance(item.get("heading"), str) or item["heading"].strip() != expected_headings.get(route): errors.append(f"browser screen heading does not match route {viewport}:{route}")
1039
1099
  if item.get("ready") is not True: errors.append(f"browser screen is not ready {viewport}:{route}")
1040
1100
  screenshot = item.get("screenshot")
1041
1101
  if not isinstance(screenshot, str) or Path(screenshot).is_absolute() or ".." in Path(screenshot).parts: errors.append(f"unsafe browser screenshot path {viewport}:{route}")
1042
- elif not (artifact_root / screenshot).is_file(): errors.append(f"missing browser screenshot {screenshot}")
1102
+ else:
1103
+ path = artifact_root / screenshot
1104
+ if not path.is_file():
1105
+ errors.append(f"missing browser screenshot {screenshot}")
1106
+ else:
1107
+ expected = next((width, height) for name, width, height in viewport_map if name == viewport)
1108
+ try:
1109
+ image_format, width, height = browser_image_dimensions(path)
1110
+ suffix = path.suffix.lower()
1111
+ extensions = {"png": {".png"}, "jpeg": {".jpg", ".jpeg"}}
1112
+ if suffix not in extensions.get(image_format, set()):
1113
+ errors.append(f"browser screenshot format does not match extension {viewport}:{route}")
1114
+ if width != expected[0] or height < expected[1]:
1115
+ errors.append(f"browser screenshot dimensions do not match viewport {viewport}:{route}")
1116
+ except (OSError, ValueError, struct.error):
1117
+ errors.append(f"browser screenshot is not a supported image {viewport}:{route}")
1043
1118
  if evidence.get("consoleErrors") != []: errors.append("browser consoleErrors is not empty")
1044
1119
  if evidence.get("failedRequests") != []: errors.append("browser failedRequests is not empty")
1045
1120
  if evidence.get("layoutErrors") != []: errors.append("browser layoutErrors is not empty")
1046
- return {"status": "passed" if not errors else "failed", "screens": len(screens), "required": len(routes) * 3, "errors": errors}
1121
+ return {"status": "passed" if not errors else "failed", "screens": len(screens), "required": expected_count, "errors": errors}
1122
+
1123
+
1124
+ def browser_image_dimensions(path: Path) -> tuple[str, int, int]:
1125
+ """Read PNG/JPEG dimensions while bounding the evidence artifact size."""
1126
+ if path.stat().st_size > 25 * 1024 * 1024:
1127
+ raise ValueError("screenshot exceeds 25 MiB")
1128
+ data = path.read_bytes()
1129
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
1130
+ offset = 8
1131
+ width = height = 0
1132
+ found_header = found_data = found_end = False
1133
+ while offset + 12 <= len(data):
1134
+ chunk_length = struct.unpack(">I", data[offset:offset + 4])[0]
1135
+ chunk_end = offset + 12 + chunk_length
1136
+ if chunk_end > len(data):
1137
+ break
1138
+ kind = data[offset + 4:offset + 8]
1139
+ payload = data[offset + 8:offset + 8 + chunk_length]
1140
+ expected_crc = struct.unpack(">I", data[offset + 8 + chunk_length:chunk_end])[0]
1141
+ if zlib.crc32(kind + payload) & 0xFFFFFFFF != expected_crc:
1142
+ raise ValueError("invalid PNG chunk checksum")
1143
+ if not found_header:
1144
+ if kind != b"IHDR" or chunk_length != 13:
1145
+ raise ValueError("invalid PNG header")
1146
+ width, height = struct.unpack(">II", payload[:8])
1147
+ found_header = True
1148
+ elif kind == b"IHDR":
1149
+ raise ValueError("duplicate PNG header")
1150
+ if kind == b"IDAT" and chunk_length:
1151
+ found_data = True
1152
+ if kind == b"IEND":
1153
+ if chunk_length != 0 or chunk_end != len(data):
1154
+ raise ValueError("invalid PNG end chunk")
1155
+ found_end = True
1156
+ break
1157
+ offset = chunk_end
1158
+ if not found_header or not found_data or not found_end or not width or not height:
1159
+ raise ValueError("incomplete PNG image")
1160
+ return "png", width, height
1161
+ if not data.startswith(b"\xff\xd8"):
1162
+ raise ValueError("unsupported image format")
1163
+ offset = 2
1164
+ frame_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
1165
+ dimensions = None
1166
+ while offset < len(data):
1167
+ while offset < len(data) and data[offset] != 0xFF:
1168
+ offset += 1
1169
+ while offset < len(data) and data[offset] == 0xFF:
1170
+ offset += 1
1171
+ if offset >= len(data):
1172
+ break
1173
+ marker = data[offset]
1174
+ offset += 1
1175
+ if marker in {0xD8, 0xD9} or 0xD0 <= marker <= 0xD7:
1176
+ continue
1177
+ if offset + 2 > len(data):
1178
+ break
1179
+ segment_length = int.from_bytes(data[offset:offset + 2], "big")
1180
+ if segment_length < 2 or offset + segment_length > len(data):
1181
+ break
1182
+ if marker in frame_markers:
1183
+ if segment_length < 7:
1184
+ break
1185
+ height, width = struct.unpack(">HH", data[offset + 3:offset + 7])
1186
+ if not width or not height:
1187
+ break
1188
+ dimensions = (width, height)
1189
+ if marker == 0xDA:
1190
+ if dimensions and data.endswith(b"\xff\xd9"):
1191
+ return "jpeg", *dimensions
1192
+ break
1193
+ offset += segment_length
1194
+ raise ValueError("JPEG dimensions were not found")
1047
1195
 
1048
1196
 
1049
1197
  def command_runtime_validate(args: argparse.Namespace) -> int:
@@ -1055,14 +1203,29 @@ def command_runtime_validate(args: argparse.Namespace) -> int:
1055
1203
 
1056
1204
 
1057
1205
  def ops_evidence_result(evidence: dict, contract: dict, require_host: bool = False) -> dict:
1206
+ if not isinstance(evidence, dict):
1207
+ return {"status": "failed", "checks": [], "requiredAlertCodes": 0, "errors": ["ops evidence must be a JSON object"]}
1208
+ if not isinstance(contract, dict):
1209
+ return {"status": "failed", "checks": [], "requiredAlertCodes": 0, "errors": ["ops evidence contract must be a JSON object"]}
1058
1210
  errors: list[str] = []
1211
+ def parse_iso_utc_timestamp(value: object) -> datetime | None:
1212
+ if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z", value):
1213
+ return None
1214
+ try:
1215
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
1216
+ except ValueError:
1217
+ return None
1218
+
1219
+ def is_iso_utc_timestamp(value: object) -> bool:
1220
+ return parse_iso_utc_timestamp(value) is not None
1221
+
1059
1222
  if evidence.get("schemaVersion") != contract.get("schemaVersion"): errors.append("ops evidence schemaVersion is unsupported")
1060
- if not evidence.get("generatedAt") or not isinstance(evidence.get("environment"), str) or not isinstance(evidence.get("tenant"), str): errors.append("generatedAt, environment, and tenant are required")
1061
- try:
1062
- datetime.fromisoformat(str(evidence.get("generatedAt")).replace("Z", "+00:00"))
1063
- except (TypeError, ValueError):
1223
+ environment = evidence.get("environment")
1224
+ tenant = evidence.get("tenant")
1225
+ if not evidence.get("generatedAt") or not isinstance(environment, str) or not environment.strip() or not isinstance(tenant, str) or not tenant.strip(): errors.append("generatedAt, environment, and tenant are required")
1226
+ if not is_iso_utc_timestamp(evidence.get("generatedAt")):
1064
1227
  errors.append("ops generatedAt must be an ISO timestamp")
1065
- if require_host and evidence.get("environment") in {"fixture", "test", "local"}: errors.append("host evidence cannot use a fixture/local environment")
1228
+ if require_host and isinstance(environment, str) and environment.strip().lower() in {"fixture", "test", "local"}: errors.append("host evidence cannot use a fixture/local environment")
1066
1229
  checks = evidence.get("checks") if isinstance(evidence.get("checks"), dict) else {}
1067
1230
  def check_object(name: str) -> dict:
1068
1231
  value = checks.get(name)
@@ -1080,10 +1243,14 @@ def ops_evidence_result(evidence: dict, contract: dict, require_host: bool = Fal
1080
1243
  routed = check_object("alert_routing").get("alertCodes")
1081
1244
  if not isinstance(routed, list) or not set(contract.get("requiredAlertCodes", [])).issubset(set(routed)): errors.append("alert routing does not cover every stable alert code")
1082
1245
  for field in ("backupVerifiedAt", "restoreVerifiedAt"):
1083
- try:
1084
- datetime.fromisoformat(str(check_object("backup_restore").get(field)).replace("Z", "+00:00"))
1085
- except (TypeError, ValueError):
1246
+ if not is_iso_utc_timestamp(check_object("backup_restore").get(field)):
1086
1247
  errors.append(f"backup_restore.{field} must be an ISO timestamp")
1248
+ generated_at = parse_iso_utc_timestamp(evidence.get("generatedAt"))
1249
+ backup_verified_at = parse_iso_utc_timestamp(check_object("backup_restore").get("backupVerifiedAt"))
1250
+ restore_verified_at = parse_iso_utc_timestamp(check_object("backup_restore").get("restoreVerifiedAt"))
1251
+ if generated_at is not None and backup_verified_at is not None and restore_verified_at is not None:
1252
+ if restore_verified_at < backup_verified_at: errors.append("backup_restore.restoreVerifiedAt must not precede backupVerifiedAt")
1253
+ if generated_at < restore_verified_at: errors.append("generatedAt must not precede restoreVerifiedAt")
1087
1254
  if check_object("backup_restore").get("rowCountVerified") is not True: errors.append("backup_restore.rowCountVerified must be true")
1088
1255
  if check_object("alert_routing").get("criticalDeliveryVerified") is not True: errors.append("alert_routing.criticalDeliveryVerified must be true")
1089
1256
  for field in ("rollbackWriteGuardVerified", "workerRestartVerified", "outboxReplayVerified"):
@@ -1116,9 +1283,11 @@ def command_release_gate(args: argparse.Namespace) -> int:
1116
1283
  access_result = access_contract_checks(load_json(access_path)) if access_path.is_file() else ["installed Booking access contract is missing"]
1117
1284
  email_templates_path = root / "maggiedash" / "contracts" / "booking-email-templates-v1.json"
1118
1285
  email_templates_result = email_templates_contract_checks(load_json(email_templates_path)) if email_templates_path.is_file() else ["installed Booking email templates contract is missing"]
1286
+ browser_contract_path = root / "maggiedash" / "contracts" / "booking-browser-evidence-v2.json"
1287
+ browser_contract = load_json(browser_contract_path) if browser_contract_path.is_file() else load_json(DEFAULT_BROWSER_EVIDENCE_CONTRACT)
1119
1288
  stripe_path = root / "maggiedash" / "contracts" / "stripe-booking-capabilities-v1.json"
1120
1289
  stripe_errors = stripe_contract_checks(load_json(stripe_path)) if stripe_path.is_file() else ["installed Stripe capability contract is missing"]
1121
- checks = {"contract": not contract_result, "emailTemplatesContract": not email_templates_result, "stripeContract": not stripe_errors, "serviceAdapterTypes": (root / "maggiedash" / "backend" / "booking-adapter-contract.d.ts").is_file(), "serviceContractValidator": (root / "maggiedash" / "scripts" / "validate-booking-service-contract.mjs").is_file(), "postgresManagerMethodsValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-manager-methods.mjs").is_file(), "postgresPaymentAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-payment-adapter.mjs").is_file(), "postgresPaymentValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-payment-adapter.mjs").is_file(), "postgresCustomerAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-customer-adapter.mjs").is_file(), "postgresCustomerValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-customer-adapter.mjs").is_file(), "postgresBackupRestoreValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-backup-restore.integration.mjs").is_file(), "postgresCoreRepository": (root / "maggiedash" / "backend" / "booking" / "postgres-core-repository.mjs").is_file(), "workspace": (root / "_maggie" / "booking" / "booking.tsx").is_file(), "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file()}
1290
+ checks = {"contract": not contract_result, "emailTemplatesContract": not email_templates_result, "browserEvidenceContract": browser_contract_path.is_file(), "stripeContract": not stripe_errors, "serviceAdapterTypes": (root / "maggiedash" / "backend" / "booking-adapter-contract.d.ts").is_file(), "serviceContractValidator": (root / "maggiedash" / "scripts" / "validate-booking-service-contract.mjs").is_file(), "postgresManagerMethodsValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-manager-methods.mjs").is_file(), "postgresPaymentAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-payment-adapter.mjs").is_file(), "postgresPaymentValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-payment-adapter.mjs").is_file(), "postgresCustomerAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-customer-adapter.mjs").is_file(), "postgresCustomerValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-customer-adapter.mjs").is_file(), "postgresBackupRestoreValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-backup-restore.integration.mjs").is_file(), "postgresCoreRepository": (root / "maggiedash" / "backend" / "booking" / "postgres-core-repository.mjs").is_file(), "workspace": (root / "_maggie" / "booking" / "booking.tsx").is_file(), "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file()}
1122
1291
  errors = list(contract_result)
1123
1292
  errors.extend(f"access:{item}" for item in access_result)
1124
1293
  errors.extend(f"emailTemplates:{item}" for item in email_templates_result)
@@ -1143,7 +1312,7 @@ def command_release_gate(args: argparse.Namespace) -> int:
1143
1312
  browser_status = "failed"
1144
1313
  errors.append(browser_error)
1145
1314
  else:
1146
- browser_status = browser_result(browser_evidence, browser_path.parent).get("status", "failed")
1315
+ browser_status = browser_result(browser_evidence, browser_path.parent, browser_contract).get("status", "failed")
1147
1316
  if browser_status != "passed": errors.append("browser evidence failed")
1148
1317
  elif args.require_browser:
1149
1318
  errors.append("browser evidence is required")
@@ -1219,6 +1388,10 @@ def parser() -> argparse.ArgumentParser:
1219
1388
  email_templates_contract = sub.add_parser("email-templates-contract", help="validate the tenant-scoped Booking email template contract")
1220
1389
  email_templates_contract.add_argument("--contract", default=str(DEFAULT_EMAIL_TEMPLATES_CONTRACT))
1221
1390
  email_templates_contract.set_defaults(func=command_email_templates_contract)
1391
+ voucher_contract = sub.add_parser("voucher-contract", help="validate the gift voucher lifecycle contract and an optional sanitized host ledger fixture")
1392
+ voucher_contract.add_argument("--contract", default=str(DEFAULT_VOUCHER_CONTRACT))
1393
+ voucher_contract.add_argument("--ledger", help="sanitized maggiedash-booking-voucher-ledger.v1 fixture to replay")
1394
+ voucher_contract.set_defaults(func=command_voucher_contract)
1222
1395
  inspect = sub.add_parser("inspect", help="inspect Booking install artifacts and contracts")
1223
1396
  inspect.add_argument("--project", default=".")
1224
1397
  inspect.add_argument("--contract", default=str(DEFAULT_CONTRACT))