@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.
- package/README-zh-TW.md +28 -4
- package/README.md +93 -60
- package/bin/maggie.js +67 -12
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +66 -14
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -2
- package/bundled-skills/maggie-deployment/SKILL.md +48 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +98 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +27 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +56 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +24 -12
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
- package/bundled-tools/clis/maggie_design.py +15 -2
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +205 -3
- package/bundled-tools/clis/maggie_feedback.py +138 -28
- package/bundled-tools/clis/maggie_live_urls.py +138 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +219 -4
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +174 -7
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- 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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
664
|
+
stripe_key_help = "STRIPE_SECRET_KEY (or the legacy alias ST_SECRECT_KEY)"
|
|
660
665
|
stripe_detail = (
|
|
661
|
-
"Recognizes
|
|
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
|
-
|
|
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())
|