@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
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env python3
2
+ """Booking rule contracts with shared test vectors (``maggie booking rules-vectors``).
3
+
4
+ Hosts built on the MaggieDash booking model kept rediscovering the same rules
5
+ by hand: client manage links that cancelled bookings already in progress, a
6
+ paid client left without a booking when payment arrived after the hold
7
+ expired, time off converted with the browser's time zone, add-ons counted
8
+ twice at the till, reminders that only had one offset, a PIN switch that
9
+ changed the API role but not the pages. Each rule here is a pure function
10
+ with test vectors in ``contracts/maggiedash/booking-rules-v1.json``.
11
+
12
+ ``rules-vectors`` runs every vector against this reference implementation and,
13
+ with ``--host-command``, against the host's own implementation: the command
14
+ receives ``{"rule": NAME, "input": {...}}`` as one JSON line on stdin per
15
+ vector and must print the result JSON. Results are compared field by field
16
+ (only the fields the vector expects).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import shlex
25
+ import subprocess
26
+ import sys
27
+ from datetime import date, datetime, time, timedelta, timezone
28
+ from pathlib import Path
29
+ from zoneinfo import ZoneInfo
30
+
31
+ CONTRACT = Path(__file__).resolve().parents[2] / "contracts" / "maggiedash" / "booking-rules-v1.json"
32
+ SCHEMA = "maggiedash-booking-rules.v1"
33
+
34
+
35
+ def _instant(value: str) -> datetime:
36
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
37
+
38
+
39
+ def _iso(value: datetime) -> str:
40
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
41
+
42
+
43
+ # --- lifecycle --------------------------------------------------------------------
44
+
45
+ OPEN = {"confirmed"}
46
+ NOT_CONFIRMED = {"held", "pending_payment"}
47
+ IN_PROGRESS = {"arrived", "checked_in", "in_service", "in_progress"}
48
+ CLOSED = {"completed", "cancelled", "no_show", "expired"}
49
+
50
+
51
+ def manage_action(i: dict) -> dict:
52
+ """May this actor cancel or reschedule this booking now? Clients only before start, only confirmed bookings."""
53
+ status, action, actor = i["status"], i["action"], i.get("actor", "client")
54
+ if action not in {"cancel", "reschedule"}:
55
+ raise ValueError("action must be cancel or reschedule")
56
+ if status in CLOSED:
57
+ return {"allowed": False, "reason": "closed"}
58
+ if actor == "client":
59
+ if status in NOT_CONFIRMED:
60
+ return {"allowed": False, "reason": "not_confirmed"}
61
+ if status in IN_PROGRESS:
62
+ return {"allowed": False, "reason": "in_progress"}
63
+ minutes = (_instant(i["startAt"]) - _instant(i["now"])).total_seconds() / 60
64
+ if minutes <= 0:
65
+ return {"allowed": False, "reason": "started"}
66
+ policy = i.get("policy") or {}
67
+ if policy.get("mode") == "window" and minutes < float(policy.get("windowHours", 0)) * 60:
68
+ return {"allowed": False, "reason": "inside_window"}
69
+ return {"allowed": True, "reason": "ok"}
70
+ if status in IN_PROGRESS and action == "reschedule":
71
+ return {"allowed": False, "reason": "in_progress"}
72
+ return {"allowed": True, "reason": "ok"}
73
+
74
+
75
+ def late_payment(i: dict) -> dict:
76
+ """Checkout completed: confirm a pending booking; after the hold expired reinstate if free, else refund in full."""
77
+ status = i["bookingStatus"]
78
+ paid = int(i["paidMinor"])
79
+ if status in {"held", "pending_payment"}:
80
+ return {"outcome": "confirm", "newStatus": "confirmed", "refundMinor": 0, "notify": ["client.booking_confirmed", "business.booking_received"]}
81
+ if status == "confirmed":
82
+ return {"outcome": "duplicate_event", "newStatus": "confirmed", "refundMinor": 0, "notify": []}
83
+ if status in {"expired", "cancelled"} and i.get("cancelledBy", "system") == "system":
84
+ if i.get("slotFree"):
85
+ return {"outcome": "reinstate", "newStatus": "confirmed", "refundMinor": 0, "notify": ["client.booking_confirmed", "business.booking_received"]}
86
+ return {"outcome": "refund", "newStatus": status, "refundMinor": paid, "notify": ["client.payment_refunded_slot_taken", "business.late_payment_refunded"]}
87
+ return {"outcome": "refund", "newStatus": status, "refundMinor": paid, "notify": ["client.payment_refunded", "business.late_payment_refunded"]}
88
+
89
+
90
+ # --- time and money ----------------------------------------------------------------------
91
+
92
+ def schedule_to_utc(i: dict) -> dict:
93
+ """Interpret a schedule input (time off, closure, shift) in the venue's zone; the browser's zone never matters."""
94
+ zone = ZoneInfo(i["venueTz"])
95
+ local = datetime.fromisoformat(i["local"])
96
+ first = local.replace(tzinfo=zone, fold=0)
97
+ round_trip = first.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None)
98
+ if round_trip != local:
99
+ return {"error": "nonexistent_local_time"}
100
+ second = local.replace(tzinfo=zone, fold=1)
101
+ ambiguous = first.utcoffset() != second.utcoffset()
102
+ return {"utc": _iso(first), "ambiguous": ambiguous}
103
+
104
+
105
+ def money_minor(i: dict) -> dict:
106
+ """Money is integer minor units; node-postgres returns bigint as a string, so parse before any arithmetic."""
107
+ value = i["value"]
108
+ if isinstance(value, bool):
109
+ return {"error": "not_minor_units"}
110
+ if isinstance(value, int):
111
+ return {"minor": value}
112
+ if isinstance(value, str) and re.fullmatch(r"-?\d{1,15}", value.strip()):
113
+ return {"minor": int(value.strip())}
114
+ return {"error": "not_minor_units"}
115
+
116
+
117
+ def _round_half_up(numerator: int, denominator: int) -> int:
118
+ return (2 * numerator + denominator) // (2 * denominator)
119
+
120
+
121
+ def sale_total(i: dict) -> dict:
122
+ """Till maths: items + add-ons not already in the item price, discount, service charge, then payments and tips."""
123
+ subtotal = 0
124
+ for item in i["items"]:
125
+ qty = int(item.get("qty", 1))
126
+ subtotal += int(item["priceMinor"]) * qty
127
+ for addon in item.get("addOns", []):
128
+ if not addon.get("includedInItemPrice"):
129
+ subtotal += int(addon["priceMinor"]) * qty
130
+ discount = min(int(i.get("discountMinor", 0)), subtotal)
131
+ after_discount = subtotal - discount
132
+ percent = i.get("serviceChargePercent", 0)
133
+ service_charge = _round_half_up(after_discount * int(round(float(percent) * 100)), 10000) if percent else 0
134
+ total = after_discount + service_charge
135
+ voucher = min(int(i.get("voucherMinor", 0)), total)
136
+ package = min(int(i.get("packageCoveredMinor", 0)), total - voucher)
137
+ tip = int(i.get("tipMinor", 0))
138
+ split = i.get("tipSplit") or []
139
+ tips: dict[str, int] = {}
140
+ if tip and split:
141
+ weights = [(entry["staffId"], float(entry.get("share", 1))) for entry in split]
142
+ total_weight = sum(weight for _, weight in weights)
143
+ raw = [(staff, tip * weight / total_weight) for staff, weight in weights]
144
+ tips = {staff: int(amount) for staff, amount in raw}
145
+ remainder = tip - sum(tips.values())
146
+ for staff, _ in sorted(raw, key=lambda pair: (-(pair[1] - int(pair[1])), pair[0]))[:remainder]:
147
+ tips[staff] += 1
148
+ elif tip:
149
+ tips = {"unassigned": tip}
150
+ return {"subtotalMinor": subtotal, "discountMinor": discount, "serviceChargeMinor": service_charge, "totalMinor": total,
151
+ "voucherMinor": voucher, "packageMinor": package, "tipMinor": tip, "amountDueMinor": total - voucher - package + tip, "tipsByStaff": tips}
152
+
153
+
154
+ def payment_policy_setup(i: dict) -> dict:
155
+ """Deposit or full prepayment needs a connected payments account that can take charges."""
156
+ policy = i["policy"]
157
+ if policy == "venue":
158
+ return {"allowed": True, "reason": "ok"}
159
+ if policy not in {"deposit", "full"}:
160
+ raise ValueError("policy must be venue, deposit or full")
161
+ if not i.get("paymentsConnected"):
162
+ return {"allowed": False, "reason": "payments_not_connected"}
163
+ if not i.get("chargesEnabled"):
164
+ return {"allowed": False, "reason": "charges_not_enabled"}
165
+ return {"allowed": True, "reason": "ok"}
166
+
167
+
168
+ # --- notifications and calendar -------------------------------------------------------------
169
+
170
+ def reminders_due(i: dict) -> dict:
171
+ """Which reminder stages to send now: each offset once per booking, never for one created inside its window."""
172
+ if i.get("status", "confirmed") != "confirmed":
173
+ return {"due": [], "skipped": []}
174
+ start, now, created = _instant(i["startAt"]), _instant(i["now"]), _instant(i["createdAt"])
175
+ sent = set(i.get("sent", []))
176
+ due, skipped = [], []
177
+ for hours in sorted(i.get("offsetsHours", [24, 2]), reverse=True):
178
+ stage = f"{hours:g}h"
179
+ send_at = start - timedelta(hours=hours)
180
+ if stage in sent:
181
+ continue
182
+ if created > send_at:
183
+ skipped.append(stage)
184
+ elif send_at <= now < start:
185
+ due.append(stage)
186
+ # Only the latest due stage goes out; earlier ones a worker missed are skipped, not sent late.
187
+ if len(due) > 1:
188
+ skipped += due[:-1]
189
+ due = due[-1:]
190
+ return {"due": due, "skipped": skipped}
191
+
192
+
193
+ def notification_plan(i: dict) -> dict:
194
+ """Templates per recipient for a cancel or reschedule; the wording names who acted."""
195
+ event, actor = i["event"], i["actor"]
196
+ if actor not in {"client", "business", "system"} or event not in {"cancelled", "rescheduled"}:
197
+ raise ValueError("event must be cancelled|rescheduled and actor client|business|system")
198
+ plan = {"client": f"booking.{event}.by_{actor}"}
199
+ if actor != "business":
200
+ plan["business"] = f"business.booking_{event}.by_{actor}"
201
+ return {"templates": plan}
202
+
203
+
204
+ def ics_text(i: dict) -> dict:
205
+ """RFC 5545 TEXT escaping and 75-octet line folding."""
206
+ text = str(i["text"]).replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\r\n", "\\n").replace("\n", "\\n")
207
+ line = f"{i.get('property', 'SUMMARY')}:{text}"
208
+ folded, current = [], b""
209
+ for char in line:
210
+ encoded = char.encode("utf-8")
211
+ limit = 75 if not folded else 74
212
+ if len(current) + len(encoded) > limit:
213
+ folded.append(current.decode("utf-8"))
214
+ current = b""
215
+ current += encoded
216
+ folded.append(current.decode("utf-8"))
217
+ return {"escaped": text, "lines": [folded[0]] + [" " + part for part in folded[1:]]}
218
+
219
+
220
+ # --- availability ------------------------------------------------------------------------------
221
+
222
+ def _ranges(day: date, zone: ZoneInfo, ranges: list[list[str]]) -> list[tuple[datetime, datetime]]:
223
+ out = []
224
+ for start, end in ranges:
225
+ a = datetime.combine(day, time.fromisoformat(start)).replace(tzinfo=zone)
226
+ b = datetime.combine(day, time.fromisoformat(end)).replace(tzinfo=zone)
227
+ out.append((a.astimezone(timezone.utc), b.astimezone(timezone.utc)))
228
+ return out
229
+
230
+
231
+ def rota_week(i: dict) -> dict:
232
+ """Which pattern of an N-week rota applies on a date (weeks counted from the anchor's Monday)."""
233
+ anchor, day = date.fromisoformat(i["anchor"]), date.fromisoformat(i["date"])
234
+ monday = anchor - timedelta(days=anchor.weekday())
235
+ weeks = (day - monday).days // 7
236
+ return {"week": weeks % int(i.get("weeks", 2))}
237
+
238
+
239
+ def _staff_ranges(staff: dict, day: date, zone: ZoneInfo, default: list[list[str]]) -> list[tuple[datetime, datetime]]:
240
+ rota = staff.get("rota")
241
+ if rota:
242
+ pattern = rota["patterns"][rota_week({"anchor": rota["anchor"], "date": day.isoformat(), "weeks": len(rota["patterns"])})["week"]]
243
+ ranges = pattern.get(str(day.weekday()), [])
244
+ else:
245
+ ranges = staff.get("hours", default)
246
+ return _ranges(day, zone, ranges)
247
+
248
+
249
+ def plan_slots(i: dict) -> dict:
250
+ """Reference slot planner: split hours, rotas, per-staff durations, buffers, gap rule, any-staff choice, groups, DST."""
251
+ zone = ZoneInfo(i["timezone"])
252
+ day = date.fromisoformat(i["date"])
253
+ step = timedelta(minutes=int(i.get("slotMinutes", 15)))
254
+ service = i["service"]
255
+ before = timedelta(minutes=int(service.get("bufferBefore", 0)))
256
+ after = timedelta(minutes=int(service.get("bufferAfter", 0)))
257
+ min_gap = timedelta(minutes=int(i.get("minGapMinutes", 0)))
258
+ group = int(i.get("groupSize", 1))
259
+ if not 1 <= group <= 6:
260
+ raise ValueError("groupSize must be 1-6")
261
+ now = _instant(i["now"]) if i.get("now") else None
262
+ lead = timedelta(minutes=int(i.get("leadMinutes", 0)))
263
+ business = _ranges(day, zone, i["hours"])
264
+ closures = [(_instant(a), _instant(b)) for a, b in i.get("closures", [])]
265
+ staff = sorted(i["staff"], key=lambda s: (int(s.get("bookingsToday", 0)), int(s.get("sortOrder", 0)), s["id"]))
266
+ slots = []
267
+ for open_start, open_end in business:
268
+ cursor = open_start
269
+ while cursor < open_end:
270
+ free = []
271
+ for person in staff:
272
+ if service["id"] not in person.get("services", [service["id"]]):
273
+ continue
274
+ minutes = int(person.get("durations", {}).get(service["id"], service["durationMinutes"]))
275
+ service_end = cursor + timedelta(minutes=minutes)
276
+ # As in MaggieDash planSlots: the service time sits inside opening hours and outside closures;
277
+ # buffers only keep other bookings away.
278
+ window_start, window_end = cursor - before, service_end + after
279
+ if service_end > open_end or (now and cursor < now + lead):
280
+ continue
281
+ if any(a < service_end and cursor < b for a, b in closures):
282
+ continue
283
+ if not any(a <= cursor and service_end <= b for a, b in _staff_ranges(person, day, zone, i["hours"])):
284
+ continue
285
+ busy = [(_instant(a), _instant(b)) for a, b in person.get("busy", [])]
286
+ if any(a < window_end and window_start < b for a, b in busy):
287
+ continue
288
+ if min_gap:
289
+ gaps = [window_start - b for a, b in busy if b <= window_start] + [a - window_end for a, b in busy if a >= window_end]
290
+ if any(timedelta(0) < gap < min_gap for gap in gaps):
291
+ continue
292
+ free.append(person["id"])
293
+ if len(free) >= group:
294
+ local = cursor.astimezone(zone)
295
+ slots.append({"start": local.strftime("%H:%M"), "utc": _iso(cursor), "staff": free[:group]})
296
+ cursor += step
297
+ return {"slots": slots, "starts": [slot["start"] for slot in slots]}
298
+
299
+
300
+ def price_for(i: dict) -> dict:
301
+ """Peak/off-peak price rules: the first matching rule (weekday + local time window) adjusts the base price."""
302
+ zone = ZoneInfo(i["timezone"])
303
+ local = _instant(i["startUtc"]).astimezone(zone)
304
+ price = int(i["basePriceMinor"])
305
+ for rule in i.get("rules", []):
306
+ if local.weekday() in rule.get("weekdays", range(7)) and time.fromisoformat(rule["from"]) <= local.time() < time.fromisoformat(rule["to"]):
307
+ if "percent" in rule:
308
+ return {"priceMinor": price + _round_half_up(price * int(rule["percent"] * 100), 10000), "rule": rule["name"]}
309
+ return {"priceMinor": int(rule["priceMinor"]), "rule": rule["name"]}
310
+ return {"priceMinor": price, "rule": None}
311
+
312
+
313
+ # --- team, clients, money owed ----------------------------------------------------------------------
314
+
315
+ ROLES = {"owner": 4, "manager": 3, "reception": 2, "therapist": 1}
316
+ DEFAULT_PERMISSIONS = {
317
+ "owner": {"*"},
318
+ "manager": {"bookings.view_all", "bookings.edit", "clients.view", "clients.edit", "checkout.take_payment", "checkout.refund", "reports.view", "schedule.edit", "timeclock.self", "timeclock.manage"},
319
+ "reception": {"bookings.view_all", "bookings.edit", "clients.view", "clients.edit", "checkout.take_payment", "timeclock.self"},
320
+ "therapist": {"bookings.edit", "clients.view", "timeclock.self"},
321
+ }
322
+ OWNER_ONLY = {"team.manage", "billing.manage"}
323
+
324
+
325
+ def permission_check(i: dict) -> dict:
326
+ """Named permissions per role, owner overrides, owner-only permissions, own-bookings scope, PIN switch that only lowers."""
327
+ session = i["role"]
328
+ pin = i.get("pinRole")
329
+ if pin and ROLES[pin] > ROLES[session]:
330
+ return {"allowed": False, "effectiveRole": session, "reason": "pin_cannot_raise_role"}
331
+ role = pin or session
332
+ permission = i["permission"]
333
+ if permission in OWNER_ONLY:
334
+ return {"allowed": role == "owner", "effectiveRole": role, "reason": "ok" if role == "owner" else "owner_only"}
335
+ granted = set(DEFAULT_PERMISSIONS[role])
336
+ override = (i.get("overrides") or {}).get(role, {})
337
+ granted |= set(override.get("grant", [])) - OWNER_ONLY
338
+ granted -= set(override.get("revoke", []))
339
+ allowed = "*" in granted or permission in granted
340
+ if allowed and permission.startswith("bookings.") and i.get("bookingStaffId") and "*" not in granted and "bookings.view_all" not in granted:
341
+ if i.get("bookingStaffId") != i.get("actorStaffId"):
342
+ return {"allowed": False, "effectiveRole": role, "reason": "own_bookings_only"}
343
+ return {"allowed": allowed, "effectiveRole": role, "reason": "ok" if allowed else "missing_permission"}
344
+
345
+
346
+ def online_booking_gate(i: dict) -> dict:
347
+ """Client safety before online booking: blocked clients, patch tests per treatment category."""
348
+ client, service = i.get("client") or {}, i["service"]
349
+ if client.get("blocked"):
350
+ return {"allowed": False, "reason": "contact_business"}
351
+ category = service.get("patchTestCategory")
352
+ if not category:
353
+ return {"allowed": True, "reason": "ok"}
354
+ taken = (client.get("patchTests") or {}).get(category)
355
+ start = _instant(i["startAt"])
356
+ if not taken:
357
+ return {"allowed": False, "reason": "patch_test_required"}
358
+ taken_at = _instant(taken)
359
+ if start - taken_at > timedelta(days=int(service.get("patchTestValidDays", 180))):
360
+ return {"allowed": False, "reason": "patch_test_expired"}
361
+ if start - taken_at < timedelta(hours=int(service.get("patchTestMinHoursBefore", 48))):
362
+ return {"allowed": False, "reason": "patch_test_too_recent"}
363
+ return {"allowed": True, "reason": "ok"}
364
+
365
+
366
+ def merge_clients(i: dict) -> dict:
367
+ """Merging duplicate clients keeps both point balances and histories and recomputes the tier."""
368
+ primary, duplicate = i["primary"], i["duplicate"]
369
+ points = int(primary.get("points", 0)) + int(duplicate.get("points", 0))
370
+ lifetime = int(primary.get("lifetimePoints", primary.get("points", 0))) + int(duplicate.get("lifetimePoints", duplicate.get("points", 0)))
371
+ tier = None
372
+ for entry in sorted(i.get("tiers", []), key=lambda t: t["minLifetimePoints"]):
373
+ if lifetime >= entry["minLifetimePoints"]:
374
+ tier = entry["name"]
375
+ return {"points": points, "lifetimePoints": lifetime, "tier": tier, "historyEntries": len(primary.get("history", [])) + len(duplicate.get("history", [])),
376
+ "memberships": sorted(set(primary.get("memberships", [])) | set(duplicate.get("memberships", [])))}
377
+
378
+
379
+ def membership_credits(i: dict) -> dict:
380
+ """Monthly credits accrue only in billed months; a paused month neither bills nor adds credits."""
381
+ billed = [month for month in i["months"] if month["state"] == "active"]
382
+ return {"billedMonths": len(billed), "creditsAdded": len(billed) * int(i["creditsPerMonth"]), "amountBilledMinor": len(billed) * int(i["priceMinor"])}
383
+
384
+
385
+ def commission(i: dict) -> dict:
386
+ """Commission on net sales after discounts (never on tips or service charge), per staff member and kind."""
387
+ totals: dict[str, int] = {}
388
+ for line in i["lines"]:
389
+ rate = (i["rates"].get(line["staffId"]) or {}).get(line["kind"], 0)
390
+ totals[line["staffId"]] = totals.get(line["staffId"], 0) + _round_half_up(int(line["netMinor"]) * int(round(rate * 10000)), 10000) # rate is a fraction (0.4 = 40%)
391
+ return {"commissionMinor": totals}
392
+
393
+
394
+ def hourly_pay(i: dict) -> dict:
395
+ """Clock-in/out shifts minus unpaid breaks, paid per minute at the hourly rate."""
396
+ minutes = 0
397
+ for shift in i["shifts"]:
398
+ worked = int((_instant(shift["out"]) - _instant(shift["in"])).total_seconds() // 60) - int(shift.get("breakMinutes", 0))
399
+ if worked < 0:
400
+ raise ValueError("break longer than the shift")
401
+ minutes += worked
402
+ return {"minutes": minutes, "payMinor": _round_half_up(minutes * int(i["rateMinor"]), 60)}
403
+
404
+
405
+ def link_booking_to_account(i: dict) -> dict:
406
+ """A guest (token) booking joins a signed-in account only when the account e-mail is verified and equal."""
407
+ same = str(i["bookingEmail"]).strip().lower() == str(i["accountEmail"]).strip().lower()
408
+ return {"link": bool(same and i.get("accountEmailVerified")), "reason": "ok" if same and i.get("accountEmailVerified") else ("email_not_verified" if same else "different_email")}
409
+
410
+
411
+ RULES = {
412
+ "manage_action": manage_action, "late_payment": late_payment, "schedule_to_utc": schedule_to_utc, "money_minor": money_minor,
413
+ "sale_total": sale_total, "payment_policy_setup": payment_policy_setup, "reminders_due": reminders_due, "notification_plan": notification_plan,
414
+ "ics_text": ics_text, "rota_week": rota_week, "plan_slots": plan_slots, "price_for": price_for, "permission_check": permission_check,
415
+ "online_booking_gate": online_booking_gate, "merge_clients": merge_clients, "membership_credits": membership_credits, "commission": commission,
416
+ "hourly_pay": hourly_pay, "link_booking_to_account": link_booking_to_account,
417
+ }
418
+
419
+
420
+ def _subset(expected: object, actual: object) -> bool:
421
+ if isinstance(expected, dict):
422
+ return isinstance(actual, dict) and all(key in actual and _subset(value, actual[key]) for key, value in expected.items())
423
+ return expected == actual
424
+
425
+
426
+ def run_vectors(contract: dict, only: str | None = None, host_command: str | None = None) -> dict:
427
+ if contract.get("schemaVersion") != SCHEMA:
428
+ raise ValueError(f"contract schemaVersion must be {SCHEMA}")
429
+ results = []
430
+ for rule in contract.get("rules", []):
431
+ name = rule["id"]
432
+ if only and name != only:
433
+ continue
434
+ if name not in RULES:
435
+ results.append({"rule": name, "vector": "-", "passed": False, "error": "no reference implementation"})
436
+ continue
437
+ for vector in rule.get("vectors", []):
438
+ if host_command:
439
+ completed = subprocess.run(shlex.split(host_command), input=json.dumps({"rule": name, "input": vector["input"]}) + "\n", capture_output=True, text=True, timeout=60, check=False)
440
+ try:
441
+ actual = json.loads(completed.stdout.strip().splitlines()[-1]) if completed.stdout.strip() else {"error": completed.stderr.strip()[-200:]}
442
+ except json.JSONDecodeError:
443
+ actual = {"error": "host printed non-JSON output"}
444
+ else:
445
+ try:
446
+ actual = RULES[name](vector["input"])
447
+ except (ValueError, KeyError) as error:
448
+ actual = {"error": str(error)}
449
+ ok = _subset(vector["expect"], actual)
450
+ results.append({"rule": name, "vector": vector["name"], "passed": ok, **({} if ok else {"expected": vector["expect"], "actual": actual})})
451
+ failed = [r for r in results if not r["passed"]]
452
+ return {"schemaVersion": "maggiedash-booking-rules-run.v1", "implementation": "host" if host_command else "reference", "vectors": len(results), "failed": len(failed), "failures": failed, "passed": not failed and bool(results)}
453
+
454
+
455
+ def main(argv: list[str] | None = None) -> int:
456
+ parser = argparse.ArgumentParser(prog="maggie booking rules-vectors", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
457
+ parser.add_argument("--contract", type=Path, default=CONTRACT)
458
+ parser.add_argument("--rule", help="run one rule's vectors")
459
+ parser.add_argument("--host-command", help="command that answers one {rule, input} JSON line with the result JSON (e.g. 'node scripts/booking-rules.mjs')")
460
+ parser.add_argument("--list", action="store_true", help="list rules and vector counts")
461
+ args = parser.parse_args(argv)
462
+ contract = json.loads(args.contract.read_text(encoding="utf-8"))
463
+ if args.list:
464
+ print(json.dumps([{"rule": r["id"], "feedback": r.get("why", ""), "vectors": len(r.get("vectors", []))} for r in contract.get("rules", [])], indent=2, ensure_ascii=False))
465
+ return 0
466
+ try:
467
+ report = run_vectors(contract, args.rule, args.host_command)
468
+ except (ValueError, OSError, subprocess.SubprocessError) as error:
469
+ print(f"BLOCKED: maggie booking rules-vectors: {error}", file=sys.stderr)
470
+ return 2
471
+ print(json.dumps(report, indent=2, ensure_ascii=False))
472
+ return 0 if report["passed"] else 1
473
+
474
+
475
+ if __name__ == "__main__":
476
+ raise SystemExit(main())
@@ -41,6 +41,32 @@ def _mode(value: object) -> int | None:
41
41
  return int(text, 8) if re.fullmatch(r"0?[0-7]{3}", text) else None
42
42
 
43
43
 
44
+ MB = 1024 * 1024
45
+
46
+
47
+ def parse_size(value: object) -> int:
48
+ """nginx size ("512k", "25m", "1g", "1048576") in bytes; 0 when unknown."""
49
+ match = re.fullmatch(r"\s*(\d+)\s*([kKmMgG]?)\s*", str(value or ""))
50
+ if not match:
51
+ return 0
52
+ return int(match.group(1)) * {"": 1, "k": 1024, "m": MB, "g": 1024 * MB}[match.group(2).lower()]
53
+
54
+
55
+ def parse_seconds(value: object) -> int:
56
+ """nginx time ("300s", "5m", "300") in seconds; 0 when unknown."""
57
+ match = re.fullmatch(r"\s*(\d+)\s*(ms|s|m|h)?\s*", str(value or ""))
58
+ if not match:
59
+ return 0
60
+ return {"ms": 0, None: 1, "s": 1, "m": 60, "h": 3600}[match.group(2)] * int(match.group(1)) if match.group(2) != "ms" else int(match.group(1)) // 1000
61
+
62
+
63
+ def proxy_needs(workloads: set[str]) -> tuple[int, int]:
64
+ """(min body bytes, min read timeout s): photos need several MB, image generation up to ~5 minutes."""
65
+ body = 25 * MB if workloads & {"images", "image-generation", "photos"} else 2 * MB
66
+ read = 360 if workloads & {"image-generation", "images"} else 120
67
+ return body, read
68
+
69
+
44
70
  def validate_plan(plan: dict, today: date | None = None) -> dict:
45
71
  today = today or datetime.now(timezone.utc).date()
46
72
  errors: list[str] = []
@@ -78,6 +104,17 @@ def validate_plan(plan: dict, today: date | None = None) -> dict:
78
104
  errors.append("network.publicBind must be loopback when a reverse proxy fronts the host")
79
105
  if net.get("adminBind") and net.get("adminBind") == net.get("publicBind"):
80
106
  errors.append("admin and public APIs must listen on different ports")
107
+ limits = net.get("proxyLimits") if isinstance(net.get("proxyLimits"), dict) else None
108
+ workloads = set(plan.get("workloads", []) or [])
109
+ need_body, need_read = proxy_needs(workloads)
110
+ if net.get("proxy") and limits is None:
111
+ (errors if workloads & {"images", "image-generation"} else warnings).append(
112
+ f"network.proxyLimits is missing: declare clientMaxBodySize (at least {need_body // MB} MB) and readTimeoutSeconds (at least {need_read}) for the bridge routes")
113
+ elif limits is not None:
114
+ if parse_size(limits.get("clientMaxBodySize")) < need_body:
115
+ errors.append(f"network.proxyLimits.clientMaxBodySize must be at least {need_body // MB}m for {', '.join(sorted(workloads)) or 'chat'}")
116
+ if not isinstance(limits.get("readTimeoutSeconds"), int) or limits["readTimeoutSeconds"] < need_read:
117
+ errors.append(f"network.proxyLimits.readTimeoutSeconds must be at least {need_read}: image generation can take 5 minutes")
81
118
  cap = plan.get("capacity", {}) if isinstance(plan.get("capacity"), dict) else {}
82
119
  if not isinstance(cap.get("maxSessions"), int) or cap["maxSessions"] < 1:
83
120
  errors.append("capacity.maxSessions must be a positive integer")
@@ -123,11 +160,34 @@ def validate_plan(plan: dict, today: date | None = None) -> dict:
123
160
  return {"schemaVersion": "maggie-bridge-host-plan-check.v1", "passed": not errors, "errors": errors, "warnings": warnings}
124
161
 
125
162
 
163
+ # Effective nginx limits of the server block that proxies to the public port:
164
+ # server/location values override the http level; nginx defaults are 1m / 60s.
165
+ PROXY_AWK = r'''
166
+ function val(line) { sub(/^[^ \t]*[ \t]+/, "", line); sub(/;.*/, "", line); return line }
167
+ /^[ \t]*#/ { next }
168
+ { line = $0; sub(/^[ \t]+/, "", line) }
169
+ line ~ /^server[ \t]*\{/ && !ins { ins = 1; sd = depth; sb = ""; sr = ""; hit = 0 }
170
+ line ~ /^client_max_body_size/ { if (ins) sb = val(line); else hb = val(line) }
171
+ line ~ /^proxy_read_timeout/ { if (ins) sr = val(line); else hr = val(line) }
172
+ ins && line ~ /^proxy_pass/ && index(line, ":" port) { hit = 1 }
173
+ {
174
+ copy = line; opened = gsub(/\{/, "", copy); copy = line; closed = gsub(/\}/, "", copy); depth += opened - closed
175
+ if (ins && depth <= sd) { if (hit) { fb = (sb != "" ? sb : hb); fr = (sr != "" ? sr : hr); found = 1 } ins = 0 }
176
+ }
177
+ END {
178
+ if (found) printf "\"proxy\":{\"clientMaxBodySize\":\"%s\",\"readTimeout\":\"%s\"}}\n", (fb != "" ? fb : "1m"), (fr != "" ? fr : "60s")
179
+ else printf "\"proxy\":null}\n"
180
+ }
181
+ '''
182
+
183
+
126
184
  def collect_script(plan: dict) -> str:
127
185
  """A POSIX sh collector; values are quoted once here and the script is sent over ssh stdin."""
128
186
  root = shlex.quote(str(plan.get("tenantsRoot", "/var/lib/bridge/tenants")))
129
187
  label = shlex.quote(str(plan.get("image", {}).get("label", "")))
130
188
  ports = " ".join(shlex.quote(str(plan.get("network", {}).get(key, "")).rsplit(":", 1)[-1]) for key in ("publicBind", "adminBind"))
189
+ public_port = shlex.quote(str(plan.get("network", {}).get("publicBind", "")).rsplit(":", 1)[-1])
190
+ awk = shlex.quote(PROXY_AWK)
131
191
  return f"""#!/bin/sh
132
192
  # maggie bridge-host collector: prints JSON facts only (modes, uids, listeners, image count).
133
193
  # Run: ssh <host> 'sh -s' < collect.sh > observed.json
@@ -150,7 +210,9 @@ printf '],"listeners":['
150
210
  listeners=$(for port in {ports}; do ss -ltnH "sport = :$port" 2>/dev/null | awk '{{print $4}}'; done)
151
211
  sep=''
152
212
  for local in $listeners; do printf '%s"%s"' "$sep" "$local"; sep=','; done
153
- printf '],"images":%s}}\\n' "$(docker images --filter label={label} --format '{{{{.ID}}}}' 2>/dev/null | sort -u | wc -l)"
213
+ printf '],"images":%s,' "$(docker images --filter label={label} --format '{{{{.ID}}}}' 2>/dev/null | sort -u | wc -l)"
214
+ conf=$( (sudo -n nginx -T 2>/dev/null || nginx -T 2>/dev/null) || true)
215
+ printf '%s\\n' "$conf" | awk -v port={public_port} {awk}
154
216
  """
155
217
 
156
218
 
@@ -181,6 +243,20 @@ def check_observed(plan: dict, observed: dict) -> dict:
181
243
  host, _, port = str(listener).rpartition(":")
182
244
  if port == admin_port and host not in {"127.0.0.1", "[::1]", "::1"}:
183
245
  errors.append(f"admin port {port} listens on {host or '*'}, not loopback")
246
+ proxy = observed.get("proxy")
247
+ if plan.get("network", {}).get("proxy") == "nginx":
248
+ need_body, need_read = proxy_needs(set(plan.get("workloads", []) or []))
249
+ limits = plan.get("network", {}).get("proxyLimits") or {}
250
+ need_body = max(need_body, parse_size(limits.get("clientMaxBodySize")))
251
+ need_read = max(need_read, int(limits.get("readTimeoutSeconds") or 0))
252
+ if not isinstance(proxy, dict):
253
+ errors.append("no nginx server block proxies to the public port (or nginx -T was not readable); check the bridge routes by hand")
254
+ else:
255
+ body, read = parse_size(proxy.get("clientMaxBodySize")), parse_seconds(proxy.get("readTimeout"))
256
+ if body and body < need_body:
257
+ errors.append(f"nginx client_max_body_size is {proxy.get('clientMaxBodySize')} on the bridge routes; photos need at least {need_body // MB}m")
258
+ if read < need_read:
259
+ errors.append(f"nginx proxy_read_timeout is {proxy.get('readTimeout')} on the bridge routes; image jobs need at least {need_read}s")
184
260
  retain = plan.get("image", {}).get("retain", 2)
185
261
  if isinstance(observed.get("images"), int) and observed["images"] > retain:
186
262
  errors.append(f"{observed['images']} labelled images on the host; keep {retain} (current + previous)")