@topy-ai/maggie 0.7.38 → 0.7.40

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 (66) hide show
  1. package/README-zh-TW.md +55 -4
  2. package/README.md +65 -7
  3. package/bin/maggie.js +10 -2
  4. package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
  5. package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
  6. package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
  7. package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
  8. package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
  9. package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
  10. package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
  11. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
  12. package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +14 -2
  13. package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
  14. package/bundled-contracts/maggiedash/execution-board.json +526 -26
  15. package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
  16. package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
  17. package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
  18. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
  19. package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
  20. package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
  21. package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
  22. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
  23. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
  24. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
  25. package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
  26. package/bundled-references/maggiedash-booking/PRD.md +228 -0
  27. package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
  28. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
  29. package/bundled-references/maggiedash-booking/README.md +271 -0
  30. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
  31. package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
  32. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
  33. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
  34. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
  35. package/bundled-references/maggiedash-booking/TASKS.md +137 -0
  36. package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
  37. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
  38. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
  39. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
  40. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
  41. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
  42. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
  43. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
  44. package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
  45. package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
  46. package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
  47. package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
  48. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
  49. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
  50. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
  51. package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
  52. package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
  53. package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
  54. package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
  55. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +16 -0
  56. package/bundled-skills/maggie-booking/SKILL.md +97 -18
  57. package/bundled-skills/maggie-design/SKILL.md +35 -0
  58. package/bundled-skills/maggie-seo-geo/SKILL.md +12 -0
  59. package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
  60. package/bundled-tools/clis/maggie_booking.py +161 -26
  61. package/bundled-tools/clis/maggie_contracts.py +282 -0
  62. package/bundled-tools/clis/maggie_dash.py +22 -6
  63. package/bundled-tools/clis/maggie_design.py +29 -12
  64. package/bundled-tools/clis/maggie_service_booking.py +50 -1
  65. package/bundled-tools/clis/site_audit.py +49 -0
  66. package/package.json +1 -1
@@ -11,22 +11,30 @@ for importing a provider's service catalogue. The existing
11
11
  right choice for provider-backed services, prices, durations, booking links,
12
12
  and factual provider validation.
13
13
 
14
+ ## Project memory
15
+
16
+ Before changing a host project, follow the shared
17
+ [`memory-hook.md`](../../references/memory-hook.md) instructions for reading
18
+ relevant project preferences and known pitfalls. After a verified fix or a
19
+ durable host decision, record only the generalizable learning; never promote a
20
+ temporary project detail, secret, or unverified guess into active memory.
21
+
14
22
  ## Contract and safety boundary
15
23
 
16
- Read [`../../docs/maggiedash-booking/README.md`](../../docs/maggiedash-booking/README.md),
17
- [`TASKS.md`](../../docs/maggiedash-booking/TASKS.md), and the task's linked
24
+ Read [`../../bundled-references/maggiedash-booking/README.md`](../../bundled-references/maggiedash-booking/README.md),
25
+ [`TASKS.md`](../../bundled-references/maggiedash-booking/TASKS.md), and the task's linked
18
26
  reference before changing code. MaggieDash owns the manager UI and versioned
19
27
  contracts. The host owns routes, traditional email/password sessions,
20
28
  PostgreSQL connection/transaction lifetime, tenant authorization, provider
21
29
  credentials, payment webhooks, notifications, and deployment. MaggieDash also
22
- ships a tenant-scoped PostgreSQL manager repository for catalogue, availability,
30
+ ships a tenant-scoped PostgreSQL manager repository for catalogue, treatment packages, availability,
23
31
  booking, customer, configuration, schedule, health, audit, hold, and lifecycle
24
32
  persistence; it does not replace host connection ownership or payment/public-
25
33
  checkout adapters.
26
34
 
27
35
  For implementation work, use the leaf board and runbook:
28
- [`EXECUTION-BOARD.json`](../../docs/maggiedash-booking/EXECUTION-BOARD.json)
29
- and [`TASK-RUNBOOK.md`](../../docs/maggiedash-booking/TASK-RUNBOOK.md). Do not
36
+ [`EXECUTION-BOARD.json`](../../bundled-references/maggiedash-booking/EXECUTION-BOARD.json)
37
+ and [`TASK-RUNBOOK.md`](../../bundled-references/maggiedash-booking/TASK-RUNBOOK.md). Do not
30
38
  mark a task passed until its implementation gate, docs update, and evidence
31
39
  record are complete.
32
40
 
@@ -69,7 +77,7 @@ It applies the host-only evidence rule; fixture, test, and local environments
69
77
  cannot satisfy a required operational release artifact.
70
78
 
71
79
  For local CLI regression, the sanitized no-customer fixture is
72
- [`fixtures/maggiedash/booking-runtime.v1.json`](../../fixtures/maggiedash/booking-runtime.v1.json).
80
+ [`fixtures/maggiedash/booking-runtime.v1.json`](../../bundled-contracts/maggiedash/booking-runtime.v1.json).
73
81
 
74
82
  Install the first-party workspace and the host integration through the stable
75
83
  Booking installer:
@@ -93,9 +101,23 @@ Astro host it also adds the compatible `@astrojs/node` SSR adapter and the
93
101
  portable server config; an existing `astro.config.*` and deployment adapter
94
102
  remain host-owned.
95
103
 
104
+ For a new host with an approved database migration, `--ready` combines the two
105
+ explicit setup mutations into one command:
106
+
107
+ ```bash
108
+ maggie booking install --project . --confirm --ready
109
+ ```
110
+
111
+ It installs only missing host packages, generates `BOOKING_TOKEN_SECRET` when
112
+ needed, and applies the installed idempotent Booking schema. It still does not
113
+ create the owner, configure Stripe, send email, enable a scheduler, or deploy.
114
+ Use `--schedule systemd` or `--schedule cron` with `--ready` only to generate a
115
+ reviewable scheduler artifact; enabling it remains an operator action after
116
+ backup, migration, and release evidence checks.
117
+
96
118
  The default installer source is the public MaggieDash `0.2.8` distribution. It
97
- contains the released Astro `hostBootstrap` manifest and 40-route Booking
98
- source. A fresh public-source install, `maggie booking inspect`, and
119
+ contains the released Astro `hostBootstrap` manifest and 47-route Booking
120
+ source, including the tenant-scoped treatment package catalogue. A fresh public-source install, `maggie booking inspect`, and
99
121
  `astro build` are the release evidence; a local-source result alone is not.
100
122
 
101
123
  For a new host with a reviewed `DATABASE_URL`, the explicit one-command
@@ -114,6 +136,21 @@ characters fails closed. External secret stores must provision the same key
114
136
  separately. Bootstrap still does not create the owner, configure Stripe, send
115
137
  email, or deploy.
116
138
 
139
+ Generate the reviewed worker scheduler artifact as part of the same install,
140
+ or after an existing install:
141
+
142
+ ```bash
143
+ maggie booking install --project . --confirm --bootstrap --schedule systemd
144
+ # or, for a host without systemd:
145
+ maggie booking worker-schedule --project . --scheduler cron --confirm
146
+ ```
147
+
148
+ This writes `deploy/maggie-booking-worker.service` plus `.timer`, or a
149
+ reviewable `deploy/maggie-booking-worker.cron`. It does not edit the crontab,
150
+ enable systemd, start a process, or expose secrets. Review the generated
151
+ artifact, set an unprivileged service user, then enable it only after the
152
+ schema, backup/restore, and release evidence gates pass.
153
+
117
154
  Before changing host configuration, run the safe first-run checklist:
118
155
 
119
156
  ```bash
@@ -149,16 +186,19 @@ Validate the tenant-scoped Resend template boundary independently:
149
186
  maggie booking email-templates-contract
150
187
  ```
151
188
 
152
- This freezes the seven supported Booking events, approved `{{variable}}`
189
+ This freezes the ten supported Booking events, approved `{{variable}}`
153
190
  placeholders, owner/admin/manager access, audit and idempotency requirements,
154
191
  and the server-only provider/recipient privacy boundary. It does not connect to
155
192
  Resend, send a message, or expose customer data.
156
193
 
157
194
  After the first owner signs in, the Booking Overview includes the same
158
195
  secret-safe readiness path: Stripe connection status, test/live mode, and a
159
- copyable `/api/maggie/booking/webhooks/stripe` URL. It gives the operator the
160
- next secret-store action without accepting or storing Stripe credentials in the
161
- browser or Booking database.
196
+ copyable `/api/maggie/booking/webhooks/stripe` URL. The preferred path is still
197
+ the deployment secret store. For a single-business host, an owner or manager
198
+ may submit both Stripe values over HTTPS from this screen; the server verifies
199
+ them first, stores only authenticated AES-256-GCM ciphertext derived from
200
+ `BOOKING_TOKEN_SECRET`, and never returns the raw values. Deployment
201
+ `STRIPE_SECRET_KEY` takes precedence over dashboard-managed credentials.
162
202
 
163
203
  For an Astro host, framework detection installs missing email/password login,
164
204
  first-owner registration, manager/public Booking routes, PostgreSQL/Stripe
@@ -171,9 +211,11 @@ the UI. Use `--host astro` to make detection explicit;
171
211
  use `--host none` when only the source distribution is wanted. The
172
212
  distribution keeps the content admin at `./_maggie/admin` and Booking source at
173
213
  `./_maggie/booking`. Existing files, including an existing `src/middleware.ts`,
174
- are preserved unless `--force` is explicitly supplied. A conventional Astro
175
- middleware receives the rewrite block automatically; unsupported middleware is
176
- preserved and reported by `maggie booking inspect` until integrated manually.
214
+ are preserved unless `--force` is explicitly supplied. Conventional Astro
215
+ middleware using either `defineMiddleware((context, next) => {})` or a
216
+ destructured context such as `defineMiddleware(({ url, request }, next) => {})`
217
+ receives the rewrite block automatically; unsupported middleware is preserved
218
+ and reported by `maggie booking inspect` until integrated manually.
177
219
  Installation does not apply SQL, create an owner, or enable a payment provider.
178
220
  The standard Booking migration must create
179
221
  `maggiedash_booking_user_roles`; do not move that table into a project-only
@@ -233,18 +275,48 @@ allocation by the same time delta, checks each staff/resource interval, and
233
275
  preserves the original total duration. Single-treatment requests and legacy
234
276
  rows remain compatible. Public DTOs expose only safe segment fields.
235
277
 
278
+ When a tenant has multiple online-bookable locations, the public catalogue must
279
+ return a safe `locations` list and the customer page must let the customer pick
280
+ one. Include the selected `locationId` in availability, waitlist, hold,
281
+ checkout, and reschedule requests. Availability must validate that the
282
+ location belongs to the tenant, is active and online-bookable, use its
283
+ timezone, and filter conflicts by that location. Keep the legacy first
284
+ `locationId` field during migration so older hosts remain compatible; never
285
+ fall back to `location-1` or another fixture ID.
286
+
287
+ After a confirmed package purchase, the public page may carry the opaque
288
+ `packageEntitlementId` into the hold and checkout requests. The server resolves
289
+ the customer from the submitted email, validates tenant ownership, active
290
+ status, expiry, currency, and every selected treatment balance, then locks the
291
+ entitlement and item rows while creating the booking and redemption ledger.
292
+ Public package redemption is all-or-nothing for a multi-treatment selection;
293
+ it confirms a fully covered booking at zero price and does not trust the
294
+ browser to decide the balance.
295
+
236
296
  Every `staff` account must also be linked to a Booking staff profile. The host
237
297
  passes that `staff_id` into the server context, and the PostgreSQL repository
238
298
  filters bookings, calendar, availability, customer summaries, staff/resources,
239
299
  schedule, and lifecycle mutations by that assignment. An unlinked staff
240
300
  session fails closed. This is a data-security rule, not a UI convention.
301
+ The administrator access registry must stay identical to the route-handler
302
+ capabilities: calendar, bookings, customers, waitlist, catalogue, packages,
303
+ staff, resources, schedule, locations, payments, refunds, notifications,
304
+ settings, reports, and audit. If `permissions_json` is absent on a legacy role,
305
+ apply the safe role defaults server-side; never treat a missing object as full
306
+ read access. Explicitly granted staff capabilities must be honored by the
307
+ route handler, while staff mutations remain limited to approved lifecycle
308
+ transitions. Keep the access contract, Astro host, reference host, ORA mirror,
309
+ and CLI validator synchronized whenever a route capability changes.
241
310
  The bootstrap now uses the host's server-side Resend configuration to send
242
311
  single-use invitation and password-reset links; raw tokens are never stored.
243
312
  The Resend worker separately delivers booking notifications. The default
244
313
  worker cycle includes `match-waitlist` and `enqueue-reminders`. The waitlist
245
314
  matcher orders eligible entries by priority/FIFO, requires explicit service and
246
315
  location scope, persists the offered slot, and queues one
247
- `booking.waitlist.available` notification. It never auto-creates or reserves a
316
+ `booking.waitlist.available` notification. Package purchase fulfillment and
317
+ provider-confirmed package refunds also queue the allowlisted
318
+ `package.purchase.confirmed`/`package.purchase.refunded` Resend events linked to
319
+ the package purchase, without appointment manage links. It never auto-creates or reserves a
248
320
  booking. The reminder job queues one `booking.reminder`
249
321
  email for each confirmed booking in the next 24 hours; the existing Resend
250
322
  dispatcher resolves the customer address at delivery time. Reminder queueing
@@ -280,7 +352,9 @@ installed host is:
280
352
  node scripts/maggie-booking-schema.mjs --dry-run
281
353
  node scripts/maggie-booking-schema.mjs --apply --confirm
282
354
  # open /_maggie/register and create the first owner
283
- # add Stripe and Resend secrets through the deployment secret store
355
+ # preferred: add Stripe and Resend secrets through the deployment secret store
356
+ # alternative: owner/manager can enter the Stripe key pair in Booking Overview;
357
+ # the host verifies it and stores only encrypted ciphertext
284
358
  node scripts/maggie-booking-worker.mjs --write --confirm
285
359
  ```
286
360
 
@@ -379,7 +453,7 @@ Follow the task board rather than building a disconnected calendar first:
379
453
  signed duplicate/out-of-order webhooks before enabling capture.
380
454
  7. Run the UI source gate, contract gate, host runtime conformance, browser
381
455
  journeys, and release gate. Record each command in
382
- [`PROGRESS.md`](../../docs/maggiedash-booking/PROGRESS.md).
456
+ [`PROGRESS.md`](../../bundled-references/maggiedash-booking/PROGRESS.md).
383
457
 
384
458
  8. Verify the scheduled worker queues and delivers a reminder once for a
385
459
  confirmed booking inside the 24-hour window. Check the notification
@@ -442,6 +516,11 @@ short-lived while customer-manage tokens are 30-day links. A redirect is never
442
516
  payment proof.
443
517
  Run `npm run booking:customer-flow:test` in MaggieDash before wiring the
444
518
  adapter, and retain real host/runtime/browser evidence as a separate gate.
519
+ If the host already has a legacy `/book` request form, keep it as a fallback
520
+ until the Booking schema is ready; expose the new customer route only after a
521
+ schema-aware readiness check, and preserve any existing treatment hint when
522
+ linking into the new flow. Do not replace a working host booking form with a
523
+ route that can only show an unconfigured Booking error.
445
524
  The manager Booking UI also exposes the tenant-scoped waitlist queue. Managers
446
525
  can filter entries, notify/cancel/reprioritize them, or select a concrete slot
447
526
  and staff/resource allocation to create a real booking transaction linked by
@@ -182,6 +182,20 @@ unconfirmed writes. Generated copy and media must be marked authored rather
182
182
  than source-observed. Implementation must still capture responsive screenshots
183
183
  and run accessibility/build validation before publication.
184
184
 
185
+ Author jobs are resumable design jobs even though their plan is stored beside
186
+ the author brief at `.maggie/design/author-<id>.json`. Use the same progress
187
+ commands as an in-place job; `status` and `step` resolve both supported job
188
+ locations:
189
+
190
+ ```bash
191
+ maggie design status author-<id> --project .
192
+ maggie design step author-<id> --project . \
193
+ --step inspect-shell --evidence .maggie/evidence/inspect-shell.json
194
+ ```
195
+
196
+ An author plan is not complete until every `requiredSteps` entry has matching
197
+ evidence and the explicit approval step is recorded.
198
+
185
199
  ## Content UI initialization
186
200
 
187
201
  Use content UI initialization when `maggie-blog` or
@@ -496,3 +510,24 @@ Follow the shared [Maggie Decision Loop](../../references/decision-loop.md) for
496
510
  6. Require explicit final confirmation before any mutation or external write.
497
511
 
498
512
  If a decision is not relevant, record it as `skipped` with a reason. Do not silently assume a missing choice, and do not treat an existing output as permission to skip required work.
513
+
514
+ ## Contract checks for dashboards, samples, and progressive enhancement
515
+
516
+ Run these stable evidence checks after implementation:
517
+
518
+ ```bash
519
+ maggie design progressive-check --evidence .maggie/progressive.json
520
+ maggie design css-check --html dist/index.html --css dist/assets/app.css
521
+ maggie design css-cascade-check --evidence .maggie/css-cascade.json
522
+ maggie design dash-init --project . --route /_maggie/admin --confirm
523
+ maggie design dash-validate --plan .maggie/design/dashboard/*/plan.json --evidence .maggie/dashboard-evidence.json
524
+ maggie design sample-init --project . --route /layout-sample --purpose "layout exploration" --confirm
525
+ maggie design sample-validate --plan .maggie/design/sample/*/plan.json --evidence .maggie/sample-evidence.json
526
+ ```
527
+
528
+ The checks cover smallest-region swaps, stable DOM identities, third-party
529
+ requests, stale responsive Tailwind utilities, and unlayered CSS winning over
530
+ utilities. Dashboard plans require operator journeys and desktop/tablet/mobile,
531
+ collapsed-rail, mobile-drawer, and modal evidence. Sample plans require
532
+ noindex, sitemap exclusion, a visible sample banner, placeholders, and a
533
+ finish checklist.
@@ -381,3 +381,15 @@ backed by a content-change/source-revision event; operational sync, pull,
381
381
  deploy, or `updated_at` timestamps are rejected. The agent-file command emits
382
382
  locale-aware `llms.txt`, `sitemap.md`, and `insights.md` from the same route
383
383
  inventory; non-indexable routes are omitted.
384
+
385
+ For every release, audit third-party privacy origins:
386
+
387
+ ```bash
388
+ maggie seo privacy-check --evidence .maggie/privacy-origin-evidence.json
389
+ ```
390
+
391
+ The site audit also records `primary_navigation_indexability` and flags a
392
+ same-origin page linked from primary navigation when it is `noindex`,
393
+ `nofollow`, or `none`. The privacy check fails in both directions: an embedded
394
+ origin missing from the policy, or a policy origin never observed in rendered
395
+ pages, requires review.
@@ -502,3 +502,17 @@ later sync are marked `archived`, retained for reconciliation, and excluded
502
502
  from active category context and public fact gates. After every sync, run
503
503
  `fact-audit`, `category-context`, the category hash/render/audit sequence, and
504
504
  the aggregate `maggie_release.py` gate.
505
+
506
+ ### Treatment groups
507
+
508
+ Keep one treatment catalogue entry and put length/price choices in its
509
+ `variants` array. Imports emit `treatmentGroup` with variant count, duration
510
+ range, and a from-price summary. Run the gate before rendering service cards:
511
+
512
+ ```bash
513
+ maggie service treatment-group-audit --project .
514
+ ```
515
+
516
+ This prevents a 30/60/90/120-minute treatment from becoming four unrelated
517
+ products and gives catalogue, detail, booking, and pricing surfaces one stable
518
+ identity.
@@ -8,6 +8,7 @@ import json
8
8
  import os
9
9
  import re
10
10
  import secrets
11
+ import shlex
11
12
  import shutil
12
13
  import subprocess
13
14
  import sys
@@ -200,7 +201,7 @@ def contract_checks(contract: dict) -> list[str]:
200
201
  if not isinstance(service_methods, dict) or set(service_methods) != set(endpoint_ids or []): errors.append("serviceMethods must map every endpoint exactly once")
201
202
  elif not all(isinstance(value, str) and value for value in service_methods.values()): errors.append("serviceMethods values must be non-empty strings")
202
203
  customer_methods = contract.get("customerServiceMethods")
203
- if customer_methods != ["readPublicCatalog", "readPublicAvailability", "createCustomerHold", "startCustomerCheckout", "readCustomerPaymentReturn", "readCustomerManage", "cancelCustomerBooking", "rescheduleCustomerBooking", "joinCustomerWaitlist"]: errors.append("customerServiceMethods must declare the nine public customer methods")
204
+ if customer_methods != ["readPublicCatalog", "readPublicAvailability", "createCustomerHold", "startCustomerCheckout", "startCustomerPackageCheckout", "readCustomerPaymentReturn", "readCustomerPackagePaymentReturn", "readCustomerManage", "cancelCustomerBooking", "rescheduleCustomerBooking", "joinCustomerWaitlist"]: errors.append("customerServiceMethods must declare the eleven public customer methods")
204
205
  return errors
205
206
 
206
207
 
@@ -235,7 +236,17 @@ def access_contract_checks(contract: dict) -> list[str]:
235
236
  if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
236
237
  if auth.get("resendInvitation") is not True: errors.append("resendInvitation must be enabled")
237
238
  if roles != ["manager", "staff", "finance"]: errors.append("roles must be manager, staff, finance")
238
- for permission in ("calendar.read", "bookings.manage", "payments.manage", "refunds.manage", "audit.read"):
239
+ expected_permissions = (
240
+ "calendar.read", "bookings.read", "bookings.manage", "customers.read",
241
+ "waitlist.read", "waitlist.manage", "catalog.read", "catalog.manage",
242
+ "packages.read", "packages.manage", "staff.read", "staff.manage",
243
+ "resources.read", "resources.manage", "schedule.read", "schedule.manage",
244
+ "locations.read", "locations.manage", "payments.read", "payments.manage",
245
+ "refunds.manage", "notifications.read", "notifications.manage",
246
+ "settings.read", "settings.manage", "reports.read", "audit.read",
247
+ )
248
+ if permissions != list(expected_permissions): errors.append("access permissions do not match the complete route capability registry")
249
+ for permission in expected_permissions:
239
250
  if permission not in permissions: errors.append(f"missing permission: {permission}")
240
251
  if endpoint_ids != ["booking.access.read", "booking.access.create", "booking.access.update", "booking.access.revoke"]: errors.append("access endpoint IDs are incomplete or out of order")
241
252
  expected = {"booking.access.read": "GET /access.json", "booking.access.create": "POST /access.json", "booking.access.update": "PATCH /access.json", "booking.access.revoke": "DELETE /access.json"}
@@ -273,12 +284,12 @@ def email_templates_contract_checks(contract: dict) -> list[str]:
273
284
  if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
274
285
  if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
275
286
  events = contract.get("events")
276
- if not isinstance(events, list) or len(events) != 7: errors.append("exactly seven Booking email events are required")
287
+ if not isinstance(events, list) or len(events) != 9: errors.append("exactly nine Booking email events are required")
277
288
  event_ids = [item.get("id") for item in events if isinstance(item, dict)] if isinstance(events, list) else []
278
- expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "booking.waitlist.available"]
289
+ expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
279
290
  if event_ids != expected_events: errors.append("email event IDs are incomplete or out of order")
280
291
  if len(event_ids) != len(set(event_ids)): errors.append("email event IDs must be unique")
281
- allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "siteName"}
292
+ allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName"}
282
293
  for event in events if isinstance(events, list) else []:
283
294
  variables = event.get("variables") if isinstance(event, dict) else None
284
295
  if not isinstance(variables, list) or not variables: errors.append(f"{event.get('id', 'unknown')}: variables must be a non-empty list")
@@ -331,7 +342,7 @@ def customer_contract_checks(contract: dict) -> list[str]:
331
342
  if noindex is not True: errors.append("customer surface must be noindex")
332
343
  if properties == {} and response_policy.get("mutationPolicy") != "all-public-mutations-require-idempotency-key-and-replay-original-result": errors.append("customer mutationPolicy must require idempotent replay")
333
344
  if properties == {} and set(response_policy.get("holdInputPolicy", [])) != {"valid-email", "booking-update-consent"}: errors.append("customer holdInputPolicy must require valid email and booking-update-consent")
334
- if len(endpoints) != 9: errors.append("customer surface must declare nine endpoints")
345
+ if len(endpoints) != 11: errors.append("customer surface must declare eleven endpoints")
335
346
  if len(ids) != len(set(ids)): errors.append("customer endpoint IDs must be unique")
336
347
  if not all(str(item).startswith("booking.public.") for item in ids): errors.append("customer endpoint IDs must use the booking.public namespace")
337
348
  for endpoint in endpoints:
@@ -630,7 +641,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
630
641
  setup_check("site", "Public site URL", ["PUBLIC_SITE_URL"], has("PUBLIC_SITE_URL"), detail="Used for login links, booking links, and email URLs."),
631
642
  setup_check("booking-token", "Public booking token secret", ["BOOKING_TOKEN_SECRET"], token_valid, detail="Must be at least 32 characters; used to sign public manage-booking links." if token_valid or not token_present else "The configured secret is too short; use at least 32 characters.", status_override="passed" if token_valid else "invalid" if token_present else None),
632
643
  setup_check("resend", "Resend email delivery", ["RESEND_API_KEY", "EMAIL_FROM|RESEND_FROM_EMAIL"], has("RESEND_API_KEY") and has_sender, detail="Required for password setup/reset, booking notifications, and reminders."),
633
- setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail="Required only for deposit/full online payment flows; webhook URL is /api/maggie/booking/webhooks/stripe." if not stripe_partial else "Both Stripe secret and webhook secret are required for online payments.", status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
644
+ setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail="Required only for deposit/full online payment flows; webhook URL is /api/maggie/booking/webhooks/stripe." if not stripe_partial and (stripe_secret or stripe_webhook) else "Both Stripe secret and webhook secret are required for online payments." if stripe_partial else "Optional at install: an owner or manager can connect Stripe from Booking Overview, or use the deployment secret store.", status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
634
645
  {"id": "schema", "label": "Booking database schema", "status": "not-verified", "required": True, "detail": "The CLI does not connect to PostgreSQL. Apply the schema after backup, then verify with the host runtime checks."},
635
646
  {"id": "owner", "label": "First owner account", "status": "not-verified", "required": True, "detail": "Open /_maggie/register once the schema is applied. Registration closes after the first owner."},
636
647
  {"id": "worker", "label": "Resend worker schedule", "status": "not-verified", "required": True, "detail": "Run the installed worker from a single-flight scheduler; setup does not create a cron or systemd job."},
@@ -647,15 +658,19 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
647
658
  if "stripe" in degraded_config:
648
659
  next_steps.append("Configure both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before accepting online payments.")
649
660
  if missing_dependencies:
650
- next_steps.append("Install missing host packages with the project package manager: maggie booking install --project . --confirm --install-dependencies")
651
- if any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
661
+ next_steps.append("For a new host, install missing packages and apply the schema together: maggie booking install --project . --confirm --ready")
662
+ if not missing_dependencies and any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
652
663
  next_steps.append("After backup, run: maggie booking install --project . --confirm --bootstrap")
653
664
  if any(item["id"] == "owner" and item["status"] == "not-verified" for item in checks):
654
665
  next_steps.append("Open /_maggie/register and create the first owner, then sign in at /_maggie/login")
655
666
  if any(item["id"] == "worker" and item["status"] == "not-verified" for item in checks):
656
667
  next_steps.append("Configure a single-flight scheduler for: node scripts/maggie-booking-worker.mjs --write --confirm")
657
- if not has("STRIPE_SECRET_KEY") or not has("STRIPE_WEBHOOK_SECRET"):
658
- next_steps.append("If taking online payments, configure Stripe test keys and POST /api/maggie/booking/webhooks/stripe before live mode.")
668
+ if stripe_partial:
669
+ next_steps.append("Complete both Stripe deployment keys before accepting online payments; then POST /api/maggie/booking/webhooks/stripe and verify the connection.")
670
+ elif not stripe_complete:
671
+ next_steps.append("After signing in, an owner or manager can connect Stripe from Booking Overview; alternatively add both deployment keys, configure the webhook URL, and verify the connection.")
672
+ else:
673
+ next_steps.append("After signing in, open Booking Overview and run Verify Stripe connection; env-key presence alone is not connected state.")
659
674
  status = "needs-configuration" if missing_required else "configured-pending-verification"
660
675
  return {
661
676
  "status": status,
@@ -663,7 +678,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
663
678
  "checks": checks,
664
679
  "configuredKeys": sorted(key for key in sources if key in {"DATABASE_URL", "PUBLIC_SITE_URL", "BOOKING_TOKEN_SECRET", "RESEND_API_KEY", "EMAIL_FROM", "RESEND_FROM_EMAIL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"}),
665
680
  "verificationPending": verification_pending,
666
- "paymentConfiguration": "online-payments" if has("STRIPE_SECRET_KEY") and has("STRIPE_WEBHOOK_SECRET") else "no-payment-or-incomplete-stripe",
681
+ "paymentConfiguration": "online-payments-pending-verification" if stripe_complete else "incomplete-deployment-stripe" if stripe_partial else "dashboard-or-host-secret-pending",
667
682
  "next": next_steps,
668
683
  "note": "Read-only checklist. It does not print secrets, connect to PostgreSQL, apply migrations, create an owner, configure Stripe, send email, or deploy.",
669
684
  }
@@ -714,10 +729,101 @@ def ensure_bootstrap_token_secret(root: Path) -> bool:
714
729
  return True
715
730
 
716
731
 
732
+ def write_worker_schedule(root: Path, scheduler: str, *, force: bool = False) -> dict:
733
+ """Generate a host-owned single-flight schedule for the installed worker.
734
+
735
+ The CLI writes deployment artifacts only. It never enables a systemd unit,
736
+ edits the host crontab, starts a process, or prints environment values.
737
+ The host operator remains responsible for reviewing the unit, selecting an
738
+ unprivileged service user, and enabling it after migration/evidence gates.
739
+ """
740
+ if scheduler not in {"systemd", "cron"}:
741
+ raise RuntimeError("worker scheduler must be systemd or cron")
742
+ script = root / "scripts" / "maggie-booking-worker.mjs"
743
+ if not script.is_file():
744
+ raise RuntimeError(f"installed Booking worker is missing: {script}")
745
+ output_dir = root / "deploy"
746
+ output_dir.mkdir(parents=True, exist_ok=True)
747
+ env_file = root / ".env"
748
+ worker_log = root / ".maggie" / "booking-worker.log"
749
+ worker_log.parent.mkdir(parents=True, exist_ok=True)
750
+ root_text = root.as_posix()
751
+ script_text = script.as_posix()
752
+ env_text = env_file.as_posix()
753
+ output: dict[str, str] = {}
754
+ if scheduler == "systemd":
755
+ service = output_dir / "maggie-booking-worker.service"
756
+ timer = output_dir / "maggie-booking-worker.timer"
757
+ service_text = f"""[Unit]
758
+ Description=Maggie Booking worker and Resend outbox delivery
759
+ After=network-online.target
760
+ Wants=network-online.target
761
+
762
+ [Service]
763
+ Type=oneshot
764
+ # Review and set an unprivileged deployment user before enabling this unit.
765
+ WorkingDirectory={root_text}
766
+ EnvironmentFile=-{env_text}
767
+ ExecStart=/usr/bin/env node {script_text} --write --confirm
768
+ NoNewPrivileges=true
769
+ PrivateTmp=true
770
+ ProtectSystem=strict
771
+ ProtectHome=true
772
+ ReadWritePaths={root_text}/.maggie
773
+
774
+ [Install]
775
+ WantedBy=multi-user.target
776
+ """
777
+ timer_text = """[Unit]
778
+ Description=Run Maggie Booking worker every minute
779
+
780
+ [Timer]
781
+ OnBootSec=1min
782
+ OnUnitActiveSec=1min
783
+ AccuracySec=10s
784
+ Persistent=true
785
+ Unit=maggie-booking-worker.service
786
+
787
+ [Install]
788
+ WantedBy=timers.target
789
+ """
790
+ for path, text in ((service, service_text), (timer, timer_text)):
791
+ if path.exists() and not force:
792
+ output[path.name] = "unchanged"
793
+ continue
794
+ path.write_text(text, encoding="utf-8")
795
+ output[path.name] = "written"
796
+ else:
797
+ cron = output_dir / "maggie-booking-worker.cron"
798
+ command = "cd {root} && {worker}".format(
799
+ root=shlex.quote(root_text),
800
+ worker=" ".join([
801
+ "/usr/bin/flock -n /tmp/maggie-booking-worker.lock",
802
+ "/usr/bin/env node",
803
+ shlex.quote(script_text),
804
+ "--write --confirm",
805
+ ]),
806
+ )
807
+ cron_text = f"""# Maggie Booking worker; install with the deploy user's crontab after review.
808
+ # The worker performs its own PostgreSQL migration preflight and tenant lock.
809
+ * * * * * {command} >> {shlex.quote(worker_log.as_posix())} 2>&1
810
+ """
811
+ if cron.exists() and not force:
812
+ output[cron.name] = "unchanged"
813
+ else:
814
+ cron.write_text(cron_text, encoding="utf-8")
815
+ output[cron.name] = "written"
816
+ return {"status": "generated", "scheduler": scheduler, "files": output, "note": "Review and enable the host scheduler after migration and release evidence; no service or crontab was changed."}
817
+
818
+
717
819
  def command_install(args: argparse.Namespace) -> int:
718
820
  """Install Booking, optionally adding missing host packages and schema."""
719
- if (args.bootstrap or args.install_dependencies) and (args.dry_run or args.diff):
720
- print("INSTALL_MUTATION_INCOMPATIBLE: --bootstrap and --install-dependencies cannot be combined with --dry-run or --diff.", file=sys.stderr)
821
+ schedule = getattr(args, "schedule", "none")
822
+ ready = bool(getattr(args, "ready", False))
823
+ install_dependencies = bool(getattr(args, "install_dependencies", False) or ready)
824
+ bootstrap = bool(getattr(args, "bootstrap", False) or ready)
825
+ if (bootstrap or install_dependencies or schedule != "none") and (args.dry_run or args.diff):
826
+ print("INSTALL_MUTATION_INCOMPATIBLE: --ready, --bootstrap, --install-dependencies, and --schedule cannot be combined with --dry-run or --diff.", file=sys.stderr)
721
827
  return 2
722
828
  if not args.dry_run and not args.diff and not args.confirm:
723
829
  print("CONFIRMATION_REQUIRED: add --confirm before installing Booking.", file=sys.stderr)
@@ -742,26 +848,47 @@ def command_install(args: argparse.Namespace) -> int:
742
848
  completed = subprocess.run(command, cwd=root, check=False)
743
849
  if completed.returncode != 0:
744
850
  return completed.returncode
745
- if args.install_dependencies:
851
+ if install_dependencies:
746
852
  try:
747
853
  dependency_result = install_host_dependencies(root)
748
854
  except RuntimeError as error:
749
855
  print(f"DEPENDENCY_INSTALL_FAILED: {error}", file=sys.stderr)
750
856
  return 1
751
857
  print(json.dumps({"status": "host-dependencies", **dependency_result}, ensure_ascii=False))
752
- if not args.bootstrap:
753
- return 0
858
+ if bootstrap:
859
+ try:
860
+ ensure_bootstrap_token_secret(root)
861
+ except RuntimeError as error:
862
+ print(f"BOOTSTRAP_FAILED: {error}", file=sys.stderr)
863
+ return 1
864
+ schema_script = root / "scripts" / "maggie-booking-schema.mjs"
865
+ if not schema_script.is_file():
866
+ print(f"BOOTSTRAP_FAILED: installed host schema script is missing: {schema_script}", file=sys.stderr)
867
+ return 1
868
+ schema = subprocess.run(["node", str(schema_script), "--apply", "--confirm"], cwd=root, check=False)
869
+ if schema.returncode != 0:
870
+ return schema.returncode
871
+ if schedule != "none":
872
+ try:
873
+ print(json.dumps({"status": "worker-schedule", **write_worker_schedule(root, schedule, force=args.force)}, ensure_ascii=False))
874
+ except (OSError, RuntimeError) as error:
875
+ print(f"SCHEDULE_FAILED: {error}", file=sys.stderr)
876
+ return 1
877
+ return 0
878
+
879
+
880
+ def command_worker_schedule(args: argparse.Namespace) -> int:
881
+ root = project_path(args)
882
+ if not args.confirm:
883
+ print("CONFIRMATION_REQUIRED: add --confirm before generating worker scheduler files.", file=sys.stderr)
884
+ return 2
754
885
  try:
755
- ensure_bootstrap_token_secret(root)
756
- except RuntimeError as error:
757
- print(f"BOOTSTRAP_FAILED: {error}", file=sys.stderr)
758
- return 1
759
- schema_script = root / "scripts" / "maggie-booking-schema.mjs"
760
- if not schema_script.is_file():
761
- print(f"BOOTSTRAP_FAILED: installed host schema script is missing: {schema_script}", file=sys.stderr)
886
+ result = write_worker_schedule(root, args.scheduler, force=args.force)
887
+ except (OSError, RuntimeError) as error:
888
+ print(f"SCHEDULE_FAILED: {error}", file=sys.stderr)
762
889
  return 1
763
- schema = subprocess.run(["node", str(schema_script), "--apply", "--confirm"], cwd=root, check=False)
764
- return schema.returncode
890
+ print(json.dumps(result, ensure_ascii=False))
891
+ return 0
765
892
 
766
893
 
767
894
  def command_inspect(args: argparse.Namespace) -> int:
@@ -1073,8 +1200,16 @@ def parser() -> argparse.ArgumentParser:
1073
1200
  install.add_argument("--existing-dir")
1074
1201
  install.add_argument("--install-dependencies", action="store_true", help="add missing Astro Booking packages with the host package manager")
1075
1202
  install.add_argument("--bootstrap", action="store_true", help="apply the installed host Booking schema after installation")
1203
+ install.add_argument("--ready", action="store_true", help="install missing host packages and apply the Booking schema after installation")
1204
+ install.add_argument("--schedule", choices=["none", "systemd", "cron"], default="none", help="generate a reviewed worker scheduler artifact after installation")
1076
1205
  install.add_argument("--confirm", action="store_true")
1077
1206
  install.set_defaults(func=command_install)
1207
+ worker_schedule = sub.add_parser("worker-schedule", help="generate a reviewed systemd or cron Booking worker schedule")
1208
+ worker_schedule.add_argument("--project", default=".")
1209
+ worker_schedule.add_argument("--scheduler", choices=["systemd", "cron"], required=True)
1210
+ worker_schedule.add_argument("--force", action="store_true")
1211
+ worker_schedule.add_argument("--confirm", action="store_true")
1212
+ worker_schedule.set_defaults(func=command_worker_schedule)
1078
1213
  contract = sub.add_parser("contract", help="validate the provider-neutral Booking contract")
1079
1214
  contract.add_argument("--contract", default=str(DEFAULT_CONTRACT))
1080
1215
  contract.set_defaults(func=command_contract)