@topy-ai/maggie 0.7.52 → 0.7.54

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 (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  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/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -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
 
@@ -32,11 +32,9 @@ DEFAULT_OPS_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-ops-evi
32
32
  DEFAULT_BROWSER_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-browser-evidence-v2.json"
33
33
  DEFAULT_STRIPE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "stripe-booking-capabilities-v1.json"
34
34
  DEFAULT_VOUCHER_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-voucher-lifecycle-v1.json"
35
- EXECUTION_BOARD = (
36
- CONTRACTS_ROOT / "maggiedash" / "execution-board.json"
37
- if PACKAGED_CONTRACTS_ROOT.is_dir()
38
- else REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
39
- )
35
+ # The Booking delivery board is a maintainer document of the Maggie source
36
+ # repository; the npm package does not ship it (config/release-privacy.json).
37
+ EXECUTION_BOARD = REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
40
38
 
41
39
 
42
40
  def load_json(path: Path) -> dict:
@@ -57,6 +55,8 @@ def load_gate_json(path: Path, label: str) -> tuple[dict | None, str | None]:
57
55
 
58
56
 
59
57
  def load_execution_board(path: Path = EXECUTION_BOARD) -> dict:
58
+ if not path.is_file():
59
+ raise RuntimeError("the Booking delivery board lives in the Maggie source repository (docs/maggiedash-booking/EXECUTION-BOARD.json); pass --board PATH")
60
60
  board = load_json(path)
61
61
  if board.get("schemaVersion") != "maggiedash-booking-execution-board.v1":
62
62
  raise RuntimeError("unsupported Booking execution board schema")
@@ -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))):
@@ -656,9 +661,9 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
656
661
  stripe_webhook = has("STRIPE_WEBHOOK_SECRET")
657
662
  stripe_complete = stripe_secret and stripe_webhook
658
663
  stripe_partial = stripe_secret != stripe_webhook
659
- stripe_key_help = "STRIPE_SECRET_KEY (or existing ORA alias ST_SECRECT_KEY)"
664
+ stripe_key_help = "STRIPE_SECRET_KEY (or the legacy alias ST_SECRECT_KEY)"
660
665
  stripe_detail = (
661
- "Recognizes ORA's existing ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
666
+ "Recognizes the legacy ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
662
667
  if stripe_legacy_alias and stripe_complete
663
668
  else f"Both {stripe_key_help} and STRIPE_WEBHOOK_SECRET are required for online payments."
664
669
  if stripe_partial
@@ -867,7 +872,8 @@ def command_install(args: argparse.Namespace) -> int:
867
872
  return 2
868
873
  root = project_path(args)
869
874
  dash = Path(__file__).with_name("maggie_dash.py")
870
- command = [sys.executable, str(dash), "install", "--project", str(root), "--host", args.host]
875
+ # Booking is an optional MaggieDash module: install the core and Booking.
876
+ command = [sys.executable, str(dash), "install", "--project", str(root), "--host", args.host, "--with", "booking"]
871
877
  if args.source:
872
878
  command.extend(["--source", args.source])
873
879
  if args.ref:
@@ -1445,6 +1451,12 @@ def parser() -> argparse.ArgumentParser:
1445
1451
 
1446
1452
 
1447
1453
  if __name__ == "__main__":
1454
+ if sys.argv[1:2] == ["rules-vectors"]:
1455
+ from maggie_booking_rules import main as rules_main
1456
+ raise SystemExit(rules_main(sys.argv[2:]))
1457
+ if sys.argv[1:2] == ["connect-qa"]:
1458
+ from maggie_booking_connect_qa import main as connect_qa_main
1459
+ raise SystemExit(connect_qa_main(sys.argv[2:]))
1448
1460
  try:
1449
1461
  arguments = parser().parse_args()
1450
1462
  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())