@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.
- package/README-zh-TW.md +2 -2
- package/README.md +49 -15
- package/bin/maggie.js +6 -2
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-deployment/SKILL.md +53 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +38 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +249 -1
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +106 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- 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
|
|
32
|
-
|
|
33
|
-
|
|
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) !=
|
|
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(
|
|
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=
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
|
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
|
-
|
|
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))
|