@topy-ai/maggie 0.7.52 → 0.7.53

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 (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -54
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +17 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +36 -6
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +9 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +75 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +136 -4
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -124,6 +124,66 @@ It warns, with environment variable names and host classes only, when:
124
124
 
125
125
  Use `--rotating-refresh-tokens yes|no` when the code scan cannot tell.
126
126
 
127
+ After an edit, prove the browser is running it before judging the change:
128
+ `--base-url http://localhost:8081 --expect-symbol ComposerAutoGrow` fetches the
129
+ page's same-origin scripts and fails when the served bundle lacks the name (a
130
+ stale watcher, the wrong folder, or another session's server). For a local
131
+ `--base-url` the report also names the process listening on the port and its
132
+ start time; a server that was killed (exit 137) and replaced by another
133
+ session fails with "port … is held by pid …".
134
+
135
+ ## Driving the browser during QA
136
+
137
+ These rules come from sessions that rediscovered each of them:
138
+
139
+ - **Find controls by role and accessible name, not by text.** Icon fonts put
140
+ private-use glyphs into `innerText` (a React Native Web tab reads
141
+ `"\nImage"` or `"Home"`), so exact-text matching fails. Match
142
+ `[role=tab]`, `[role=button]` with `aria-label`, or strip characters in
143
+ U+E000–U+F8FF before comparing.
144
+ - **Click through the DOM when the automated window is in the background.**
145
+ Synthetic mouse clicks wait for the window to be visible and time out;
146
+ `element.click()` (or dispatching the event) works in a background window.
147
+ Keep real input for checks that are about input itself (hover, drag).
148
+ - **Reuse the app tab.** Opening a new tab per check clutters the user's
149
+ browser and, with rotating refresh tokens, adds another app instance.
150
+ Navigate the existing tab, and close any tab you opened.
151
+ - **Phone width when the window cannot be resized.** Some browser extensions
152
+ ignore window resizing. A same-origin `<iframe>` 390 px wide reuses the
153
+ signed-in session and is fine for layout checks **one at a time**: create
154
+ it, measure, remove it, then the next. The "no parallel iframes" rule is
155
+ about several app instances refreshing a rotating token at once, not about
156
+ a single frame. `maggie design app-collect` and `maggie qa fit-check` set
157
+ the size through the DevTools protocol and need no iframe.
158
+ - **Long measurements run in the page, not in one call.** A loop over 30
159
+ routes inside one script call times out the browser tool. Start the loop in
160
+ the page (`window.__qa = {done: false, results: []}`; an async function that
161
+ pushes results), return immediately, then poll `window.__qa` with short
162
+ calls. Give each call a time budget of a few seconds.
163
+
164
+ ## Check that dashboards fit the screen
165
+
166
+ When a dashboard must not scroll the page, check every route, tab and drawer
167
+ at a short laptop and a phone viewport:
168
+
169
+ ```bash
170
+ maggie qa fit-check --base-url http://localhost:4321 \
171
+ --route /merchant/bookings --route /merchant/settings \
172
+ --profile-dir .maggie/qa-profile --drawer-selector "[data-drawer-trigger]" \
173
+ --sidebar-selector "nav.sidebar" --output .maggie/qa-runs/fit.json
174
+ ```
175
+
176
+ Default viewports are 1536x687 (a laptop with browser chrome) and 390x844.
177
+ For each route it measures page overflow on both axes, then clicks every
178
+ visible `[role=tab]` (`--tab-selector`) and measures again, then opens each
179
+ drawer trigger on a fresh page and checks the dialog stays inside the
180
+ viewport. Hidden panels are measured only after their tab is active — a
181
+ panel measured while hidden reports zero, which also breaks screen-sized
182
+ pagers that do not re-measure on tab change. Labels ignore icon glyphs.
183
+ Writes are blocked unless `--allow-write <refresh path>` or `--allow-writes`;
184
+ use `--cdp-url` to attach to a signed-in Chrome. Exit 1 lists each failing
185
+ route, viewport and state, with the first element that is too wide.
186
+
127
187
  ## Check CORS for every method the client uses
128
188
 
129
189
  Before a release that adds or changes API calls from a browser or webview,
@@ -205,6 +205,39 @@ does not claim keyboard accessibility, network asset correctness or semantic
205
205
  content quality unless those checks run separately. Use the installed browser
206
206
  path explicitly when Bun is not in PATH.
207
207
 
208
+ ## Route checks: name the origin
209
+
210
+ Release checks that crawl routes (dynamic SEO, localization routes, sitemap
211
+ size) must take an explicit base URL — `--base-url` or a required
212
+ `SITE_ORIGIN` that fails when unset — and print the origin they checked. A
213
+ default such as `http://127.0.0.1:4321` silently hits another local project
214
+ on that port and produces false "foreign URL" and sitemap-size failures.
215
+ Prove which site answers first:
216
+
217
+ ```bash
218
+ maggie seo origin-check --base-url http://127.0.0.1:4321 --project .
219
+ ```
220
+
221
+ It compares the page's canonical / `og:url` and the robots sitemap host with
222
+ `site` in `astro.config.*` (or `--expect-host`), fails when another site
223
+ answers, and prints `checked origin: …`.
224
+
225
+ ## Personalised HTML fragments
226
+
227
+ Per-person partials (recently viewed, recommendations, "your bookings",
228
+ signed-in header fragments) are not public pages. Each must answer
229
+ `Cache-Control: private, no-store` and `X-Robots-Tag: noindex`, with no
230
+ `public`, `s-maxage` or CDN cache header. Global middleware that sets public
231
+ HTML caching for every `text/html` response must leave a route's private
232
+ header alone (check for an existing `Cache-Control` before setting one);
233
+ otherwise a CDN caches one person's fragment and serves it to everyone. The
234
+ public-session cache contract covers full pages; fragments follow this rule:
235
+
236
+ ```bash
237
+ maggie seo fragment-check --url http://127.0.0.1:4321/fragments/recently-viewed \
238
+ --url http://127.0.0.1:4321/fragments/recommendations --cookie-env QA_SESSION_COOKIE
239
+ ```
240
+
208
241
  ## Automatic memory hook
209
242
 
210
243
  Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
@@ -486,6 +486,29 @@ FAQ helper from the first category implementation; it is a known gap and must
486
486
  remain marked incomplete until each FAQ record is independently AI-authored and
487
487
  reviewed.
488
488
 
489
+ ## Moving a business: clients, appointments, gift cards
490
+
491
+ Importing the provider catalogue is not enough to switch a real business.
492
+ Dry-run the exports first (nothing is written):
493
+
494
+ ```bash
495
+ maggie service migrate-check --project . --timezone Europe/London --market uk \
496
+ --clients clients.csv --appointments appointments.csv --gift-cards gift-cards.csv \
497
+ --service "Lash lift" --service "Massage" --staff Jo --staff Sam
498
+ ```
499
+
500
+ It recognises Fresha's (and other systems') column names by synonyms and
501
+ lists unmapped columns; de-duplicates clients by e-mail, then by phone in
502
+ E.164; keeps marketing consent only when the export says opted in; keeps
503
+ only future, not-cancelled appointments, converted with the venue's zone,
504
+ and reports clashes and unmatched services or staff instead of forcing them;
505
+ and keeps gift-card codes with their balance in an `IMPORT:` namespace. The
506
+ report holds counts, column names and row numbers only — no client values —
507
+ plus the undo key and plan. Every real import writes an import record first
508
+ so it can be undone (bookings and unused clients/staff deleted, untouched
509
+ imported codes voided), and an optional "we've moved" e-mail goes once, only
510
+ to imported clients with marketing consent, while online booking is live.
511
+
489
512
  ## Payments: Stripe live setup and gift vouchers
490
513
 
491
514
  Service pages often lead to first-party payments. When the host takes money
@@ -494,9 +517,11 @@ read-only live setup runbook in `docs/maggiedash-booking/STRIPE-INTEGRATION.md`:
494
517
 
495
518
  - verify live state with list/retrieve (`GET`) calls only; never use a create
496
519
  call as a probe — a probe expected to fail can create a real live object;
497
- - the owner installs webhook and Connect event-destination signing secrets
498
- with a no-echo command (`read -rs … | <secret store>`); the agent never sees
499
- them;
520
+ - the owner installs webhook signing secrets — and, on a platform that hosts
521
+ many businesses (Stripe Connect direct charges, see "Platform hosts" in
522
+ `STRIPE-INTEGRATION.md`), the Connect event-destination secret — with a
523
+ no-echo command (`read -rs … | <secret store>`); the agent never sees them.
524
+ A single business needs no Connect;
500
525
  - thin-event destination signing secrets cannot be retrieved via the API;
501
526
  roll them in the Dashboard if they were not captured;
502
527
  - the Stripe Dashboard may be unusable under browser automation, so hand
@@ -22,7 +22,7 @@ and GEO. It is not a second content source of truth.
22
22
  Create the reviewable channel contract before generating copy:
23
23
 
24
24
  ```bash
25
- maggie tool maggie_social_share.py plan content.json \
25
+ maggie tool maggie_social_share.py plan content.json --workspace-domain example.com \
26
26
  --channels linkedin,facebook --output .maggie/social-share-plan.json
27
27
  maggie tool maggie_social_share.py validate .maggie/social-share-plan.json
28
28
  ```
@@ -30,6 +30,20 @@ maggie tool maggie_social_share.py validate .maggie/social-share-plan.json
30
30
  The CLI does not generate copy or publish. It validates identity, canonical
31
31
  URL, channel limits, UTM links, approval state, and provider post state.
32
32
 
33
+ - **Own domain only.** A workspace shares articles published on its own
34
+ site. `plan` refuses a source whose canonical URL is not on
35
+ `--workspace-domain` (or a subdomain), and `validate` refuses an approved
36
+ draft without `workspace_domain`. Apply the same rule in source selection:
37
+ an account that manages several sites must not pick another site's
38
+ articles for this workspace's plan.
39
+ - **Image posts by default.** When a post has its own image (`image_url`),
40
+ each channel gets `publish_mode: "image"` with the link in the text;
41
+ `link_preview` is the default only without an image. A person may switch a
42
+ post to link preview (record `publish_mode_source: "user"`), and the
43
+ preview shows the card LinkedIn will show. `validate` fails an image post
44
+ whose copy lacks the link and an image silently dropped by link-preview
45
+ mode.
46
+
33
47
  Before connecting providers or scheduling, check the adapter, connection,
34
48
  campaign, and queue contracts (shapes and rules in
35
49
  [queue and connections](references/queue-and-connections.md)):
@@ -68,7 +82,10 @@ maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
68
82
  optional `not_before`, and local/remote status. Posting hours are slots;
69
83
  do not persist a fixed `scheduled_at` per post (it goes stale and strands
70
84
  overdue posts). Campaign ideas stay in the idea stage until written, and a
71
- campaign starts only through the Start gate.
85
+ campaign starts only through the Start gate. A series or campaign created
86
+ only so it can be planned is deleted (or never committed) when the AI plan
87
+ fails, so a retry does not leave an empty draft per attempt; `campaigns`
88
+ fails a campaign with `plan_status: failed` and no ideas.
72
89
  6. Publish only after the required approval and entitlement checks. Record the
73
90
  publication attempt and provider ID; reconcile unknown outcomes against
74
91
  readback and retry only with the original idempotency key.
@@ -101,8 +118,13 @@ idempotency, and delete/republish behavior.
101
118
  decrypt check on every host that serves social routes.
102
119
  - Provider avatar URLs are signed and expire; cache the bytes or refresh on
103
120
  readback.
104
- - Keep model structured-output enums small and validate large vocabularies
105
- server-side; see [provider adapter contract](../../references/provider-adapter-contract.md#structured-model-output).
121
+ - Structured-output schemas fail on **complexity**, not only on large enums:
122
+ no enum inside repeated array items, small `maxItems`, and validate the
123
+ vocabulary server-side. Run `maggie tool maggie_social_share.py
124
+ schema-check plan-schema.json` before a release; see [provider adapter
125
+ contract](../../references/provider-adapter-contract.md#structured-model-output).
126
+ - Posted posts have a lifecycle (Post now, delete, edit and republish); see
127
+ [queue and connections](references/queue-and-connections.md#posted-post-lifecycle).
106
128
 
107
129
  ## Safety and commercial boundary
108
130
 
@@ -115,6 +115,34 @@ An adapter that publishes must declare, in addition to its scope set:
115
115
 
116
116
  Adapters without `publishes` keep the scope-only shape.
117
117
 
118
+ ## Posted-post lifecycle
119
+
120
+ **Post now.** Claim the item atomically before calling the provider
121
+ (`UPDATE ... SET status = 'publishing' WHERE id = $1 AND status IN
122
+ ('queued','approved') RETURNING ...`); a second click or another worker gets
123
+ no row and does nothing. On a provider failure restore the previous status
124
+ (and its slot); on an unknown outcome (timeout) leave it `publishing` for
125
+ `reconcile`.
126
+
127
+ **Delete a published post.** Delete on the provider first, then mark the
128
+ item `deleted` with the time and who did it. A provider 404 means it is
129
+ already gone: mark it deleted. Never delete the local record while the
130
+ provider post still exists.
131
+
132
+ **Edit and republish.** Most providers cannot edit a post, so an edit is a
133
+ new post replacing the old one. Order matters — never take the old post down
134
+ when the new one cannot go out:
135
+
136
+ 1. Pre-check everything: the source article is still published on the
137
+ workspace's own domain, the copy with the link fits the limit, the image
138
+ (if any) is present and accepted, the connection is healthy.
139
+ 2. Publish the new post (same idempotency rules as any publish).
140
+ 3. Only after the new post is confirmed, delete the old one; if that delete
141
+ fails, keep both recorded and retry the delete — do not roll back the new
142
+ post.
143
+ 4. If step 2 fails, nothing changed: the old post stays and the item returns
144
+ to its previous state with the error.
145
+
118
146
  ## Publish reconciliation — `reconcile`
119
147
 
120
148
  `reconcile` reads the publish-attempt log and a provider readback and returns
@@ -18,7 +18,7 @@ code or call a model.
18
18
  a readable summary (never a bare UUID) and the right scope (account-level
19
19
  writes are not filed under the open site).
20
20
  - ``approval-selftest``: exercises the reference signed one-time approval
21
- token (``tools/runtime/assistant_approval.py``).
21
+ token and standing workflow approvals (``tools/runtime/assistant_approval.py``).
22
22
  """
23
23
 
24
24
  from __future__ import annotations
@@ -34,8 +34,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
34
34
  SCHEMA = "maggie-assistant-tool-registry.v1"
35
35
  RISKS = ("read", "write-reversible", "write-spends", "write-public", "never")
36
36
  RISK_ALIASES = {"R0": "read", "W1": "write-reversible", "W2": "write-spends", "W3": "write-public", "W4": "never"}
37
- CHANNELS = ("in-app", "mcp", "chatgpt", "claude", "cli")
37
+ CHANNELS = ("in-app", "mcp", "chatgpt", "claude", "cli", "web-widget")
38
38
  SCOPES = ("account", "workspace")
39
+ CARDS = ("place", "slot-picker", "booking", "voucher", "change-preview", "numbers", "draft", "list")
39
40
  NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$|^[a-z][a-z0-9]+$")
40
41
  SNAKE_RE = re.compile(r"\b[a-z][a-z0-9]*_[a-z0-9_]+\b")
41
42
  THE_USER_RE = re.compile(r"\bthe user('s)?\b", re.I)
@@ -74,6 +75,31 @@ def validate_registry(registry: dict) -> dict:
74
75
  for duplicate in sorted({name for name in names if names.count(name) > 1}):
75
76
  errors.append(f"duplicate tool name {duplicate}")
76
77
  counts = {risk: 0 for risk in RISKS}
78
+ servers = {}
79
+ for server in registry.get("mcpServers", []) or []:
80
+ if not isinstance(server, dict) or not server.get("name"):
81
+ errors.append("mcpServers entries need a name"); continue
82
+ name = str(server["name"])
83
+ servers[name] = server
84
+ if server.get("auth") not in {"none", "oauth"}:
85
+ errors.append(f"MCP server {name}: auth must be none (public, read-only) or oauth")
86
+ elif server["auth"] == "none":
87
+ limit = server.get("rateLimit") if isinstance(server.get("rateLimit"), dict) else {}
88
+ if not limit.get("perIpPerMinute") and not limit.get("perIpPerDay"):
89
+ errors.append(f"MCP server {name}: a public server needs rateLimit (perIpPerMinute and/or perIpPerDay)")
90
+ else:
91
+ if not str(server.get("resource", "")).startswith("https://"):
92
+ errors.append(f"MCP server {name}: resource must be the server's canonical https URL (the token audience)")
93
+ if server.get("audienceBinding", True) is not True and not str(server.get("audienceException", "")).strip():
94
+ errors.append(f"MCP server {name}: audience binding is on by default (RFC 8707); turning it off needs audienceException with the reason")
95
+ threads = registry.get("threads") if isinstance(registry.get("threads"), dict) else None
96
+ if threads and threads.get("storage") == "server":
97
+ if not isinstance(threads.get("retentionDays"), int) or threads["retentionDays"] < 1:
98
+ errors.append("threads.retentionDays is required for server-stored conversations")
99
+ if threads.get("deleteOnAccountDeletion") is not True:
100
+ errors.append("threads.deleteOnAccountDeletion must be true for server-stored conversations")
101
+ if threads.get("supportAccess") not in {"none", "with-consent"}:
102
+ errors.append("threads.supportAccess must be none or with-consent")
77
103
  for tool in tools:
78
104
  if not isinstance(tool, dict):
79
105
  errors.append("each tool must be an object"); continue
@@ -122,13 +148,47 @@ def validate_registry(registry: dict) -> dict:
122
148
  warnings.append(f"{where}: the name reads as destructive; app reviewers expect destructiveHint, so consider write-public")
123
149
  if tool.get("audit", {}).get("scope") not in (None, tool.get("scope")):
124
150
  errors.append(f"{where}: audit.scope must equal the tool scope (an account-level write is not audited under the open site)")
151
+ card = tool.get("card")
152
+ if card is not None and card not in CARDS:
153
+ errors.append(f"{where}: card must be one of {', '.join(CARDS)}")
154
+ if card == "change-preview" and risk == "read":
155
+ warnings.append(f"{where}: a change-preview card belongs to a write (it shows what will change before approval)")
156
+ for server in tool.get("mcpServers", []) or []:
157
+ if server not in servers:
158
+ errors.append(f"{where}: mcpServers lists {server!r}, which is not declared in registry.mcpServers")
159
+ elif servers[server].get("auth") == "none" and risk != "read":
160
+ errors.append(f"{where}: a public (unauthenticated) MCP server may only expose read tools")
161
+ elif servers[server].get("auth") == "none" and tool.get("scope") == "account":
162
+ errors.append(f"{where}: account-scoped tools need a signed-in caller; keep them off the public MCP server")
125
163
  for item in registry.get("cantDo", []) or []:
126
164
  if not isinstance(item, dict) or not str(item.get("text", "")).strip():
127
165
  errors.append("cantDo entries need text (and ideally 'instead')")
128
166
  continue
129
167
  for problem in user_copy_problems(str(item.get("text", "")) + " " + str(item.get("instead", "")), name_set):
130
168
  errors.append(f"cantDo '{str(item['text'])[:40]}': {problem}")
131
- return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts}}
169
+ widget_tools = [t for t in tools if isinstance(t, dict) and "web-widget" in (t.get("channels") or [])]
170
+ if widget_tools:
171
+ # A visitor-facing assistant (an AI receptionist) answers strangers: no approvals are possible,
172
+ # so it only reads business facts, is off until the business turns it on, is capped and logged.
173
+ for tool in widget_tools:
174
+ if risk_of(tool) != "read":
175
+ errors.append(f"{tool.get('name')}: web-widget tools answer visitors and must be read-only; booking and payment stay in the normal booking sheet")
176
+ if tool.get("scope") == "account":
177
+ errors.append(f"{tool.get('name')}: web-widget tools act for one business (scope workspace), never on an account")
178
+ policy = registry.get("visitorPolicy") if isinstance(registry.get("visitorPolicy"), dict) else {}
179
+ if policy.get("enabledByDefault") is not False:
180
+ errors.append("visitorPolicy.enabledByDefault must be false: the business turns the receptionist on")
181
+ caps = policy.get("dailyCaps") if isinstance(policy.get("dailyCaps"), dict) else {}
182
+ if not caps.get("perVisitor") or not caps.get("perBusiness"):
183
+ errors.append("visitorPolicy.dailyCaps needs perVisitor and perBusiness (model spend and abuse)")
184
+ if policy.get("logAnswers") is not True:
185
+ errors.append("visitorPolicy.logAnswers must be true: the business can read every answer given in its name")
186
+ if policy.get("factsOnly") is not True:
187
+ errors.append("visitorPolicy.factsOnly must be true: answers come from the business's own facts, never general knowledge about prices or health")
188
+ if policy.get("cardDetails") != "never":
189
+ errors.append("visitorPolicy.cardDetails must be never: the chat never asks for or accepts card numbers")
190
+ public_tools = [t.get("name") for t in tools if isinstance(t, dict) and any(servers.get(s, {}).get("auth") == "none" for s in t.get("mcpServers", []) or [])]
191
+ return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts, "cards": sum(1 for t in tools if isinstance(t, dict) and t.get("card")), "publicMcpTools": len(public_tools)}}
132
192
 
133
193
 
134
194
  def annotations_for(tool: dict) -> dict:
@@ -224,6 +284,8 @@ def audit_check(registry: dict, session: dict) -> dict:
224
284
  expected_scope = "account" if tool.get("scope") == "account" else call.get("workspace")
225
285
  if expected_scope and action.get("scope") != expected_scope:
226
286
  errors.append(f"{call.get('tool')}: audited under {action.get('scope')!r}, expected {expected_scope!r}")
287
+ if action.get("channel") == "workflow" and not action.get("standingApprovalId"):
288
+ errors.append(f"{call.get('tool')}: a workflow run must record the standing approval id it ran under")
227
289
  if call.get("ok") is False and not action.get("error"):
228
290
  errors.append(f"{call.get('tool')}: failed call has no audit error")
229
291
  return {"schemaVersion": "maggie-assistant-audit-check.v1", "passed": not errors, "errors": errors, "writes": sum(1 for c in calls if risk_of(tools.get(c.get("tool"), {})) not in {"read", ""})}
@@ -249,6 +311,25 @@ def approval_selftest() -> dict:
249
311
  results["scopeBound"] = raises(lambda: approval.verify(secret, token, "key-1", "site-b", calls, now=1001))
250
312
  results["tamperRejected"] = raises(lambda: approval.verify(secret, token[:-2] + ("AA" if not token.endswith("AA") else "BB"), "key-1", "site-a", calls, now=1001))
251
313
  results["idempotencyKeyStable"] = approval.idempotency_key(issued["id"], calls[0]) == approval.idempotency_key(issued["id"], dict(calls[0]))
314
+ # Standing approval: a waitlist workflow may send at most 2 offer e-mails a day with one template.
315
+ standing, grant = approval.issue_standing(secret, "owner-1", "site-a", "waitlist-fill", [{"name": "waitlist_offer_send", "maxPerDay": 2, "fixedArgs": {"template": "waitlist_offer"}}], days=30, now=1000)
316
+ store = approval.StandingStore()
317
+ offer = {"name": "waitlist_offer_send", "args": {"template": "waitlist_offer", "clientId": "c1"}}
318
+ first = store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-13", now=1001)
319
+ results["standingAllowsInsideLimits"] = first["auditChannel"] == "workflow" and first["approvalId"] == grant["id"]
320
+ store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-13", now=1001)
321
+ def falls_back(fn) -> bool:
322
+ try:
323
+ fn(); return False
324
+ except approval.OutsideStandingApproval:
325
+ return True
326
+ results["standingDailyLimit"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-13", now=1001))
327
+ results["standingArgsBound"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", {"name": "waitlist_offer_send", "args": {"template": "promo"}}, "2026-10-14", now=1001))
328
+ results["standingOtherTool"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", {"name": "booking_cancel", "args": {}}, "2026-10-14", now=1001))
329
+ results["standingExpires"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-12-01", now=1000 + 31 * 86400))
330
+ store.revoke(grant["id"])
331
+ results["standingRevocable"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-15", now=1001))
332
+ results["standingNotAOneTimeToken"] = raises(lambda: approval.verify(secret, standing, "owner-1", "site-a", [offer], now=1001))
252
333
  return {"schemaVersion": "maggie-assistant-approval-selftest.v1", "passed": all(results.values()), "checks": results}
253
334
 
254
335
 
@@ -260,6 +260,10 @@ def access_contract_checks(contract: dict) -> list[str]:
260
260
  return errors
261
261
 
262
262
 
263
+ EMAIL_TEMPLATE_EVENTS_LEGACY = ["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"]
264
+ EMAIL_TEMPLATE_EVENTS = EMAIL_TEMPLATE_EVENTS_LEGACY[:-1] + ["payment.late_refund_pending", "business.booking_cancelled", "business.booking_rescheduled", "business.late_payment_refund_required", "booking.waitlist.available"]
265
+
266
+
263
267
  def email_templates_contract_checks(contract: dict) -> list[str]:
264
268
  errors: list[str] = []
265
269
  properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
@@ -289,12 +293,13 @@ def email_templates_contract_checks(contract: dict) -> list[str]:
289
293
  if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
290
294
  if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
291
295
  events = contract.get("events")
292
- if not isinstance(events, list) or len(events) != 10: errors.append("exactly ten Booking email events are required")
293
296
  event_ids = [item.get("id") for item in events if isinstance(item, dict)] if isinstance(events, list) else []
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"]
295
- if event_ids != expected_events: errors.append("email event IDs are incomplete or out of order")
297
+ # MaggieDash <= 0.2.12 installs ship the ten-event contract; 0.2.13 adds the
298
+ # late-payment and business notices. Both are valid installed contracts.
299
+ if event_ids not in (EMAIL_TEMPLATE_EVENTS, EMAIL_TEMPLATE_EVENTS_LEGACY):
300
+ errors.append(f"email template events must be the {len(EMAIL_TEMPLATE_EVENTS)}-event list (or the {len(EMAIL_TEMPLATE_EVENTS_LEGACY)}-event list of MaggieDash 0.2.12 and earlier), in order")
296
301
  if len(event_ids) != len(set(event_ids)): errors.append("email event IDs must be unique")
297
- allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName", "arrivalNote"}
302
+ allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName", "arrivalNote", "cancelledBy", "previousWhen"}
298
303
  declared_variables = contract.get("variables")
299
304
  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
305
  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))):
@@ -1445,6 +1450,12 @@ def parser() -> argparse.ArgumentParser:
1445
1450
 
1446
1451
 
1447
1452
  if __name__ == "__main__":
1453
+ if sys.argv[1:2] == ["rules-vectors"]:
1454
+ from maggie_booking_rules import main as rules_main
1455
+ raise SystemExit(rules_main(sys.argv[2:]))
1456
+ if sys.argv[1:2] == ["connect-qa"]:
1457
+ from maggie_booking_connect_qa import main as connect_qa_main
1458
+ raise SystemExit(connect_qa_main(sys.argv[2:]))
1448
1459
  try:
1449
1460
  arguments = parser().parse_args()
1450
1461
  raise SystemExit(arguments.func(arguments))
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Test-mode Stripe Connect QA for a booking platform (``maggie booking connect-qa``).
3
+
4
+ Without a test-mode connected account every Connect path (deposits on the
5
+ business's account, saved cards, no-show fees, packages, memberships,
6
+ refunds, card readers) can only be tested with simulated signed webhooks.
7
+ This command prepares a real end-to-end test run without changing anything:
8
+
9
+ - refuses live keys (``sk_live_``/``rk_live_``) and reports which variable
10
+ names are set (never values);
11
+ - with ``--detect``, lists the platform's **test-mode** connected accounts
12
+ (read-only ``GET /v1/accounts``) and names one that can take charges, or
13
+ explains how to create one in test mode;
14
+ - prints the ``stripe listen`` command that forwards both platform and Connect
15
+ events to the local webhook routes;
16
+ - lists the payment cases to run, with Stripe's test cards and the reader
17
+ simulator, so a QA run records each outcome.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import base64
24
+ import json
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+ from urllib.error import HTTPError, URLError
29
+ from urllib.request import Request, urlopen
30
+
31
+ KEY_NAMES = ("STRIPE_SECRET_KEY", "STRIPE_API_KEY")
32
+ WEBHOOK_SECRET_NAMES = ("STRIPE_CONNECT_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET")
33
+ EVENTS = [
34
+ "checkout.session.completed", "checkout.session.expired", "payment_intent.succeeded", "payment_intent.payment_failed",
35
+ "setup_intent.succeeded", "charge.refunded", "charge.dispute.created", "customer.subscription.created",
36
+ "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "invoice.payment_failed",
37
+ "terminal.reader.action_succeeded", "terminal.reader.action_failed", "account.updated",
38
+ ]
39
+ CASES = [
40
+ {"id": "deposit", "do": "Book a deposit service and pay with 4242 4242 4242 4242", "expect": "booking confirmed only after the Connect webhook; payment row on the connected account; amount, currency and account checked"},
41
+ {"id": "strong-authentication", "do": "Pay with 4000 0025 0000 3155 and complete 3-D Secure", "expect": "confirmed after authentication; the return page polls instead of confirming"},
42
+ {"id": "declined", "do": "Pay with 4000 0000 0000 0002", "expect": "booking stays pending_payment, hold expires, slot freed"},
43
+ {"id": "late-payment", "do": "Let the hold expire, then complete the Checkout session", "expect": "reinstated if the time is free, otherwise refunded in full with a client e-mail (rule late_payment)"},
44
+ {"id": "saved-card-no-show", "do": "Save a card (SetupIntent), mark the booking no-show, charge the no-show fee off-session", "expect": "fee charged on the connected account with the saved payment method; 4000 0027 6000 3184 asks for authentication and must notify the client"},
45
+ {"id": "package", "do": "Buy a treatment package", "expect": "package credits issued once per payment; redeeming one reduces the balance"},
46
+ {"id": "membership", "do": "Start a monthly membership, pause it, resume it", "expect": "subscription on the connected account; credits only in billed months (rule membership_credits)"},
47
+ {"id": "refund", "do": "Refund in full and partly from the dashboard", "expect": "refund on the connected account; charge.refunded reconciles the booking payment"},
48
+ {"id": "card-reader", "do": "Register the simulated reader (registration code simulated-wpe), send a sale to it, present a test card", "expect": "payment recorded only when the PaymentIntent succeeds; reader cancel leaves the sale open"},
49
+ {"id": "dispute", "do": "Pay with 4000 0000 0000 0259", "expect": "dispute shown to the business; no automatic refund"},
50
+ ]
51
+
52
+
53
+ def _env(env_file: Path | None) -> dict[str, str]:
54
+ values = dict(os.environ)
55
+ if env_file and env_file.is_file():
56
+ for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines():
57
+ line = line.strip()
58
+ if line and not line.startswith("#") and "=" in line:
59
+ key, value = line.removeprefix("export ").split("=", 1)
60
+ values.setdefault(key.strip(), value.strip().strip("\"'"))
61
+ return values
62
+
63
+
64
+ def list_accounts(key: str) -> dict:
65
+ token = base64.b64encode(f"{key}:".encode()).decode()
66
+ request = Request("https://api.stripe.com/v1/accounts?limit=20", headers={"Authorization": f"Basic {token}"})
67
+ try:
68
+ with urlopen(request, timeout=20) as response: # noqa: S310 - fixed Stripe API host, GET only
69
+ return json.loads(response.read())
70
+ except HTTPError as error:
71
+ return {"error": f"Stripe answered {error.code}"}
72
+ except (URLError, OSError, ValueError) as error:
73
+ return {"error": str(error)[:120]}
74
+
75
+
76
+ def connect_qa(env_file: Path | None, connect_url: str, platform_url: str | None, detect: bool, lister=list_accounts) -> dict:
77
+ values = _env(env_file)
78
+ errors: list[str] = []
79
+ warnings: list[str] = []
80
+ key_name = next((name for name in KEY_NAMES if values.get(name)), None)
81
+ key = values.get(key_name, "") if key_name else ""
82
+ if not key:
83
+ errors.append("no Stripe secret key found (STRIPE_SECRET_KEY); use the platform's test-mode key")
84
+ elif key.startswith(("sk_live_", "rk_live_")):
85
+ errors.append(f"{key_name} is a live key; Connect QA runs only in test mode — switch to the sk_test_ key")
86
+ key = ""
87
+ elif not key.startswith(("sk_test_", "rk_test_")):
88
+ warnings.append(f"{key_name} does not look like a Stripe test key")
89
+ secrets = {name: bool(values.get(name)) for name in WEBHOOK_SECRET_NAMES}
90
+ if not any(secrets.values()):
91
+ warnings.append("no webhook signing secret is set; copy the whsec_ value printed by `stripe listen` into the Connect webhook secret variable")
92
+ accounts = None
93
+ ready = None
94
+ if detect and key:
95
+ listing = lister(key)
96
+ if "error" in listing:
97
+ errors.append(f"could not list connected accounts: {listing['error']}")
98
+ else:
99
+ accounts = [{"id": a.get("id"), "chargesEnabled": bool(a.get("charges_enabled")), "detailsSubmitted": bool(a.get("details_submitted")), "country": a.get("country")} for a in listing.get("data", [])]
100
+ ready = next((a["id"] for a in accounts if a["chargesEnabled"]), None)
101
+ if not accounts:
102
+ warnings.append("no test-mode connected account yet: in the Stripe Dashboard (test mode) open Connect → Accounts → Create, complete onboarding with test data, then re-run with --detect")
103
+ elif not ready:
104
+ warnings.append("test connected accounts exist but none can take charges yet; finish onboarding with Stripe's test data")
105
+ listen = "stripe listen --forward-connect-to " + connect_url + (f" --forward-to {platform_url}" if platform_url else "") + " --events " + ",".join(EVENTS)
106
+ return {
107
+ "schemaVersion": "maggiedash-booking-connect-qa.v1",
108
+ "mode": "test",
109
+ "keyVariable": key_name,
110
+ "webhookSecrets": secrets,
111
+ "connectedAccounts": accounts,
112
+ "testAccount": ready,
113
+ "commands": [listen, "stripe trigger checkout.session.completed --stripe-account <acct_test_id> # smoke the Connect route"],
114
+ "cases": CASES,
115
+ "status": "failed" if errors else ("warning" if warnings else "ready"),
116
+ "errors": errors,
117
+ "warnings": warnings,
118
+ "note": "read-only: nothing was created or charged; record each case's outcome with maggie qa record",
119
+ }
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ parser = argparse.ArgumentParser(prog="maggie booking connect-qa", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
124
+ parser.add_argument("--project", type=Path, default=Path("."))
125
+ parser.add_argument("--env-file", type=Path, help="default: <project>/.env")
126
+ parser.add_argument("--connect-webhook-url", default="http://localhost:4321/api/stripe/connect-events")
127
+ parser.add_argument("--platform-webhook-url", help="platform (non-Connect) webhook route, if separate")
128
+ parser.add_argument("--detect", action="store_true", help="read-only: list test-mode connected accounts")
129
+ args = parser.parse_args(argv)
130
+ report = connect_qa(args.env_file or args.project / ".env", args.connect_webhook_url, args.platform_webhook_url, args.detect)
131
+ print(json.dumps(report, indent=2, ensure_ascii=False))
132
+ return 1 if report["status"] == "failed" else 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())