@topy-ai/maggie 0.7.2 → 0.7.4

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.md CHANGED
@@ -191,8 +191,8 @@ artifact schemas.
191
191
  Recommended upgrade sequence for the current release:
192
192
 
193
193
  ```bash
194
- npx @topy-ai/maggie@0.7.2 update --project . --force
195
- npx @topy-ai/maggie@0.7.2 cleanup --project .
194
+ npx @topy-ai/maggie@0.7.4 update --project . --force
195
+ npx @topy-ai/maggie@0.7.4 cleanup --project .
196
196
  ```
197
197
 
198
198
  Maintainers should pass npm credentials through the repository helper, never
@@ -202,14 +202,15 @@ as a command-line argument:
202
202
  node scripts/publish-npm.mjs --maggie-env-file ../.env
203
203
  ```
204
204
 
205
- The 0.7.2 workflow adds the installable MaggieDash admin distribution and
205
+ The 0.7.4 workflow adds the installable MaggieDash admin distribution and
206
206
  audited CMS operations (`cms revisions`,
207
- `trash`, `restore`, `schedule`, `duplicate`, `redirect`, and signed
208
- `preview`), import-authoritative service matching, shared translation indexes,
209
- sanitized seed manifests, lockfile/analytics traffic checks, semantic sitemap
210
- validation, locale-aware `llms.txt`/`sitemap.md`/`insights.md` generation,
211
- explicit integration states, and deployment Origin/infrastructure/data
212
- rollback gates.
207
+ `trash`, `restore`, `schedule`, `publish-due`, `duplicate`, `redirect`, and
208
+ signed `preview`). It also adds a read-only MaggieDash `--diff`/`--dry-run`
209
+ migration inventory, body-only dashboard prop validation, dialog accessibility
210
+ contract checks, 404-only redirect policy, trash/scheduler/preview invariants,
211
+ and a shared first-party traffic filter for every measurement channel. The
212
+ release retains the existing service matching, localization, seed-manifest,
213
+ lockfile/analytics, sitemap, deployment and rollback workflows.
213
214
 
214
215
  ## MaggieDash lifecycle
215
216
 
@@ -231,6 +232,17 @@ for local development or a configured private Git URL. The host project keeps
231
232
  ownership of routes, email/password auth, database, provider credentials, and
232
233
  the `/api/maggie/*` adapter.
233
234
 
235
+ Compare an existing dashboard before replacing it:
236
+
237
+ ```bash
238
+ maggie dash install --project . --diff \
239
+ --existing-dir src/plugins/maggie-studio/src
240
+ maggie dash install --project . --dry-run
241
+ ```
242
+
243
+ These commands do not write files or installation state. Review the report and
244
+ map host-owned files before using `--force`.
245
+
234
246
  Content remains draft-first and external writes remain explicit. Use the
235
247
  project-local memory workflow for confirmed preferences and reusable lessons;
236
248
  feedback drafts are never promoted to active memory automatically.
@@ -14,6 +14,10 @@ frontend framework.
14
14
  dry-run, backup, checksum, cutover, and rollback evidence;
15
15
  - [`storage-model.md`](storage-model.md): SQLite/PostgreSQL table ownership and
16
16
  adapter rules.
17
+ - [`redirect-policy-v1.json`](redirect-policy-v1.json): 404-only lookup and
18
+ self/chain safety for canonical route redirects;
19
+ - [`telemetry-policy-v1.json`](telemetry-policy-v1.json): first-party/toolchain
20
+ traffic exclusion for every measurement channel.
17
21
 
18
22
  MaggieDash owns these contracts. Provider adapters may add namespaced metadata,
19
23
  but they may not change the required identity, status, provenance, or approval
@@ -32,3 +36,15 @@ published → archived
32
36
 
33
37
  No import, AI generation, rewrite, or adapter sync may publish content without
34
38
  an approval record and an auditable actor/correlation ID.
39
+
40
+ ## CMS invariants
41
+
42
+ - `trashed` is a status. Public readers exclude it; trash is reversible.
43
+ - A no-op save creates no revision, and the first update of an imported row
44
+ snapshots the existing state before replacing it.
45
+ - Scheduled content with a past timestamp is published on the next scheduler
46
+ tick. A trashed row is never selected by the due query.
47
+ - Preview responses are real preview routes protected by HMAC tokens and
48
+ advertise `noindex`, `X-Robots-Tag: noindex`, and `Cache-Control: no-store`.
49
+ - Redirect lookup happens after the route has returned 404. Self-redirects and
50
+ redirect chains are rejected at write time.
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggiedash.noblox.app/contracts/redirect-policy-v1.schema.json",
4
+ "title": "MaggieDash redirect policy v1",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "lookupAfterStatus", "rejectSelf", "rejectChains"],
7
+ "properties": {
8
+ "schemaVersion": {"const": "maggiedash-redirect-policy.v1"},
9
+ "lookupAfterStatus": {"const": 404},
10
+ "rejectSelf": {"const": true},
11
+ "rejectChains": {"const": true},
12
+ "allowedStatusCodes": {"type": "array", "items": {"enum": [301, 302, 307, 308]}}
13
+ },
14
+ "additionalProperties": false
15
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggiedash.noblox.app/contracts/telemetry-policy-v1.schema.json",
4
+ "title": "MaggieDash telemetry policy v1",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "filterBeforeMeasurement", "channels"],
7
+ "properties": {
8
+ "schemaVersion": {"const": "maggiedash-telemetry-policy.v1"},
9
+ "filterBeforeMeasurement": {"const": true},
10
+ "channels": {
11
+ "type": "array",
12
+ "items": {"enum": ["page_view", "not_found", "redirect_hit", "booking_click", "analytics", "audit"]},
13
+ "minItems": 1
14
+ },
15
+ "identityInference": {"const": "forbidden"}
16
+ },
17
+ "additionalProperties": false
18
+ }
@@ -0,0 +1,12 @@
1
+ # MaggieDash telemetry contract
2
+
3
+ Every measurement path must call the shared first-party traffic filter before
4
+ incrementing a counter or writing a measurement event. This includes page
5
+ views, 404/not-found rows, redirect hits, booking clicks, analytics events and
6
+ operational audit counters. Filtering only page views leaves synthetic sweeps
7
+ in the other totals.
8
+
9
+ Use `should_record_measurement(event)` from
10
+ `tools/runtime/analytics_traffic.py`. The filter accepts explicit Maggie/test
11
+ markers and known toolchain user agents; it never guesses identity from IP
12
+ addresses or private user fields.
@@ -25,6 +25,22 @@ explicitly supplied, and records the resolved revision in
25
25
  CLI argument. For local development or a pinned checkout, use
26
26
  `--source /path/to/MaggieDash` or `--source URL --ref REF`.
27
27
 
28
+ Before replacing an existing hand-written dashboard, create a read-only
29
+ migration report. It compares the first-party manifest with the existing
30
+ workspace and labels additions, unchanged files, updates that would be
31
+ preserved, and unmanaged files:
32
+
33
+ ```bash
34
+ maggie dash install --project . --diff \
35
+ --existing-dir src/plugins/maggie-studio/src
36
+ maggie dash install --project . --dry-run
37
+ ```
38
+
39
+ These modes do not require `--confirm` and do not write dashboard files or
40
+ `.maggie/dash-install.json`. Review the report and explicitly map or back up
41
+ hand-written files before using `--force`; an idempotent installer is not a
42
+ schema migration.
43
+
28
44
  The installed dashboard owns the React workspace UI. The host project still
29
45
  owns the framework route, email/password session middleware, database, media
30
46
  storage, provider credentials, and `/api/maggie/*` adapter endpoints. Read the
@@ -105,6 +121,8 @@ maggie dash cms trash --project . --project-id local-project --document-id <id>
105
121
  maggie dash cms restore --project . --project-id local-project --document-id <id> --reason "restore" --confirm
106
122
  maggie dash cms schedule --project . --project-id local-project --document-id <id> \
107
123
  --publish-at 2026-09-20T10:00:00Z --reason "approved release" --confirm
124
+ maggie dash cms publish-due --project . --project-id local-project \
125
+ --reason "scheduled publish tick" --confirm
108
126
  maggie dash cms duplicate --project . --project-id local-project --document-id <id> \
109
127
  --new-id <new-id> --new-slug <new-slug> --reason "create draft" --confirm
110
128
  maggie dash cms redirect --project . --project-id local-project --from-path /old --to-path /new \
@@ -114,6 +132,13 @@ maggie dash cms preview --project . --project-id local-project --document-id <id
114
132
  ```
115
133
 
116
134
  Content writes create immutable revision snapshots. Trash is reversible and
117
- does not destroy the document. Scheduling is accepted only for approved
118
- content and requires a timezone. Preview tokens are short-lived HMAC-signed
119
- tokens; never place the secret in source control or generated reports.
135
+ does not destroy the document; public readers must filter `status=trashed`.
136
+ Scheduling is accepted only for approved content and requires a timezone; a
137
+ past timestamp is published on the next scheduler tick, while trashed rows
138
+ are never eligible. A no-op save creates no revision, and the first update of
139
+ an imported row snapshots its prior state. Redirects are consulted only after
140
+ the route returns 404 and self/chain redirects are rejected. Preview tokens
141
+ are short-lived HMAC-signed tokens; preview responses must use a real preview
142
+ URL with `noindex`, `X-Robots-Tag: noindex`, and `Cache-Control: no-store`.
143
+ Never place the secret in source control or generated reports. See the
144
+ provider-neutral [MaggieDash contract set](../../contracts/maggiedash/README.md).
@@ -220,5 +220,9 @@ The seed manifest must be opt-in, sanitized, and contain non-empty fixtures;
220
220
  an empty dev database is not evidence that an operational check passed.
221
221
  Known Maggie/toolchain traffic can be measured without polluting first-party
222
222
  analytics using `maggie analytics traffic-audit --events events.json`. The
223
- classifier excludes only explicit tool/test markers and never infers identity
224
- from IP or private fields.
223
+ classifier must run before every measurement channel, including page views,
224
+ 404/not-found rows, redirect hits, booking clicks and audit counters. The
225
+ shared contract is in
226
+ [references/maggiedash-telemetry-contract.md](../../references/maggiedash-telemetry-contract.md);
227
+ it excludes only explicit tool/test markers and never infers identity from IP
228
+ or private fields.
@@ -13,19 +13,20 @@
13
13
  "components": [
14
14
  {
15
15
  "name": "WorkspaceBar",
16
- "props": ["workspaceName", "activeSection", "actions", "children"],
16
+ "props": ["tabs", "active", "onSelect", "lead", "actions"],
17
17
  "forbiddenProps": []
18
18
  },
19
19
  {
20
20
  "name": "WorkspaceCard",
21
- "props": ["title", "children"],
21
+ "props": ["children", "className"],
22
22
  "forbiddenProps": []
23
- },
24
- {
25
- "name": "ContentTabs",
26
- "props": ["tabs", "active", "actions", "children"],
27
- "forbiddenProps": ["title", "description"]
28
23
  }
29
24
  ],
25
+ "dialogs": {
26
+ "primitive": "Modal",
27
+ "overlayPrimitive": "Overlay",
28
+ "forbiddenSourcePatterns": ["window.confirm(", "window.prompt("],
29
+ "requiredLiterals": ["role=\"dialog\"", "aria-modal", "Escape", "opener.current"]
30
+ },
30
31
  "accessibility": ["active tab exposes aria-selected", "actions have accessible names", "workspace navigation has a landmark"]
31
32
  }
@@ -77,12 +77,68 @@ def copy_dashboard_tree(source: Path, target: Path, force: bool) -> tuple[list[s
77
77
  return installed, preserved
78
78
 
79
79
 
80
+ def manifest_file_pairs(source: Path, manifest: dict[str, object], target: Path) -> list[tuple[Path, Path, Path]]:
81
+ """Return source, install-target, and manifest-relative file paths."""
82
+ pairs: list[tuple[Path, Path, Path]] = []
83
+ for item in manifest.get("files", []):
84
+ relative = Path(str(item))
85
+ source_item = source / relative
86
+ if source_item.is_file():
87
+ relative_target = Path(*relative.parts[1:]) if relative.parts and relative.parts[0] == "admin" else relative
88
+ pairs.append((source_item, target / relative_target, relative))
89
+ continue
90
+ for path in sorted(source_item.rglob("*")):
91
+ if not path.is_file():
92
+ continue
93
+ child = path.relative_to(source_item)
94
+ relative_target = Path(*relative.parts[1:]) / child if relative.parts and relative.parts[0] == "admin" else relative / child
95
+ pairs.append((path, target / relative_target, relative / child))
96
+ return pairs
97
+
98
+
99
+ def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool) -> dict[str, object]:
100
+ pairs = manifest_file_pairs(source, manifest, target)
101
+ files: list[dict[str, str]] = []
102
+ source_relative = {relative for _, _, relative in pairs}
103
+ for source_file, destination, relative in pairs:
104
+ if compare_dir:
105
+ compare_relative = Path(*relative.parts[2:]) if relative.parts[:2] == ("admin", "src") else (Path(*relative.parts[1:]) if relative.parts and relative.parts[0] == "admin" else relative)
106
+ compare_file = compare_dir / compare_relative
107
+ else:
108
+ compare_file = destination
109
+ if not compare_file.exists():
110
+ action = "add"
111
+ elif compare_file.read_bytes() == source_file.read_bytes():
112
+ action = "unchanged"
113
+ else:
114
+ action = "update" if force else "preserve"
115
+ files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
116
+ if compare_dir and compare_dir.exists():
117
+ for existing in sorted(compare_dir.rglob("*")):
118
+ if not existing.is_file():
119
+ continue
120
+ relative = existing.relative_to(compare_dir)
121
+ manifest_relative = Path("admin", "src", *relative.parts)
122
+ if manifest_relative not in source_relative:
123
+ files.append({"path": str(manifest_relative), "action": "unmanaged-existing", "comparePath": str(existing)})
124
+ summary: dict[str, int] = {}
125
+ for item in files:
126
+ summary[item["action"]] = summary.get(item["action"], 0) + 1
127
+ return {"status": "dry-run", "version": manifest.get("version"), "target": str(target), "compareDir": str(compare_dir) if compare_dir else None, "summary": summary, "files": files}
128
+
129
+
80
130
  def command_install(args: argparse.Namespace) -> int:
81
- require_confirm(args)
131
+ if not args.dry_run and not args.diff:
132
+ require_confirm(args)
82
133
  root = project_root(args)
83
134
  source_value = args.source or os.environ.get("MAGGIE_DASH_SOURCE") or DEFAULT_DASH_SOURCE
84
135
  ref = args.ref
85
- target = root / args.target
136
+ target_relative = Path(args.target)
137
+ if target_relative.is_absolute() or ".." in target_relative.parts:
138
+ raise RuntimeError("MaggieDash target must be a relative path inside the project")
139
+ target = (root / target_relative).resolve()
140
+ if root not in target.parents and target != root:
141
+ raise RuntimeError("MaggieDash target must remain inside the project")
86
142
  temporary: tempfile.TemporaryDirectory[str] | None = None
87
143
  source = Path(source_value).expanduser().resolve() if not source_value.startswith(("https://", "http://", "git@")) else None
88
144
  try:
@@ -107,6 +163,15 @@ def command_install(args: argparse.Namespace) -> int:
107
163
  raise RuntimeError("unsupported MaggieDash distribution schema")
108
164
  if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
109
165
  raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
166
+ compare_dir = None
167
+ if args.existing_dir:
168
+ compare_dir = Path(args.existing_dir).expanduser()
169
+ if not compare_dir.is_absolute():
170
+ compare_dir = root / compare_dir
171
+ compare_dir = compare_dir.resolve()
172
+ if args.dry_run or args.diff:
173
+ emit(command_install_diff(source, manifest, target, compare_dir, args.force))
174
+ return 0
110
175
  installed: list[str] = []
111
176
  preserved: list[str] = []
112
177
  for item in manifest.get("files", []):
@@ -223,6 +288,8 @@ def command_cms(args: argparse.Namespace) -> int:
223
288
  result = store.restore(args.project_id, args.document_id, args.actor, args.reason)
224
289
  elif args.cms_command == "schedule":
225
290
  result = store.schedule_publish(args.project_id, args.document_id, args.publish_at, args.actor, args.reason)
291
+ elif args.cms_command == "publish-due":
292
+ result = store.publish_due(args.project_id, args.now, args.actor, args.reason)
226
293
  elif args.cms_command == "duplicate":
227
294
  result = store.duplicate(args.project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
228
295
  elif args.cms_command == "redirect":
@@ -294,6 +361,9 @@ def parser() -> argparse.ArgumentParser:
294
361
  install.add_argument("--ref", default="main", help="Git ref when installing from a remote source")
295
362
  install.add_argument("--target", default="_maggie/admin", help="installation target relative to the project")
296
363
  install.add_argument("--force", action="store_true", help="replace existing dashboard files")
364
+ install.add_argument("--dry-run", action="store_true", help="show the install plan without writing files")
365
+ install.add_argument("--diff", action="store_true", help="show the install plan and compare with an existing workspace")
366
+ install.add_argument("--existing-dir", help="existing dashboard source directory to compare during --diff")
297
367
  install.add_argument("--confirm", action="store_true")
298
368
  install.set_defaults(func=command_install)
299
369
  status = sub.add_parser("status", help="inspect project and documents")
@@ -321,6 +391,8 @@ def parser() -> argparse.ArgumentParser:
321
391
  command.add_argument("--document-id", required=True); command.add_argument("--actor", default="cli"); command.add_argument("--reason", default="CMS operation"); command.add_argument("--confirm", action="store_true")
322
392
  schedule = cms_sub.add_parser("schedule")
323
393
  schedule.add_argument("--project", default="."); schedule.add_argument("--project-id", default="local-project"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
394
+ publish_due = cms_sub.add_parser("publish-due", help="publish approved scheduled content whose time has arrived")
395
+ publish_due.add_argument("--project", default="."); publish_due.add_argument("--project-id", default="local-project"); publish_due.add_argument("--now"); publish_due.add_argument("--actor", default="scheduler"); publish_due.add_argument("--reason", default="scheduled publish tick"); publish_due.add_argument("--confirm", action="store_true")
324
396
  duplicate = cms_sub.add_parser("duplicate")
325
397
  duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id", default="local-project"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
326
398
  redirect = cms_sub.add_parser("redirect")
@@ -17,13 +17,18 @@ def is_tool_traffic(event: dict) -> bool:
17
17
  return bool(TOOL_USER_AGENT.search(str(event.get("userAgent") or "")))
18
18
 
19
19
 
20
+ def should_record_measurement(event: dict) -> bool:
21
+ """Apply the same exclusion before every counter or event logger."""
22
+ return not is_tool_traffic(event)
23
+
24
+
20
25
  def audit_events(events: Iterable[dict]) -> dict:
21
26
  totals = Counter()
22
27
  excluded = Counter()
23
28
  included = 0
24
29
  for event in events:
25
30
  totals[str(event.get("event") or "unknown")] += 1
26
- if is_tool_traffic(event):
31
+ if not should_record_measurement(event):
27
32
  excluded[str(event.get("event") or "unknown")] += 1
28
33
  else:
29
34
  included += 1
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any
15
15
 
16
16
 
17
- STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived")
17
+ STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived", "trashed")
18
18
  TRANSITIONS = {
19
19
  "draft": {"review"},
20
20
  "review": {"approved", "draft"},
@@ -22,6 +22,7 @@ TRANSITIONS = {
22
22
  "scheduled": {"published", "draft"},
23
23
  "published": {"archived"},
24
24
  "archived": set(),
25
+ "trashed": set(),
25
26
  }
26
27
 
27
28
 
@@ -120,8 +121,15 @@ class MaggieDashStore:
120
121
  status = document.get("status", "draft")
121
122
  if status not in STATUSES:
122
123
  raise ValueError(f"invalid document status: {status}")
123
- stable = {key: value for key, value in document.items() if key not in {"provenance", "checksum"}}
124
- document_checksum = document.get("provenance", {}).get("checksum") or checksum(stable)
124
+ stable = {
125
+ "id": document["id"], "kind": document["kind"], "title": document["title"],
126
+ "slug": document["slug"], "locale": document["locale"], "status": status,
127
+ "excerpt": document.get("excerpt", ""), "content": document["content"],
128
+ "canonicalUrl": document.get("canonicalUrl"), "publishAt": document.get("publishAt"),
129
+ }
130
+ # Provenance is evidence about the source, not the content identity. A
131
+ # provider checksum must never turn a no-op save into a new revision.
132
+ document_checksum = checksum(stable)
125
133
  existing = self.connection.execute("SELECT * FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone()
126
134
  if existing and existing["status"] != status:
127
135
  raise ValueError("status changes must use transition()")
@@ -183,10 +191,10 @@ class MaggieDashStore:
183
191
  row = self.connection.execute("SELECT status, trashed_at FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
184
192
  if not row:
185
193
  raise ValueError("document not found")
186
- if row["trashed_at"]:
194
+ if row["trashed_at"] or row["status"] == "trashed":
187
195
  return self.get_document(project_id, document_id) or {}
188
196
  timestamp = now()
189
- self.connection.execute("UPDATE maggiedash_documents SET trashed_at=?, trashed_from_status=?, updated_at=? WHERE id=?", (timestamp, row["status"], timestamp, document_id))
197
+ self.connection.execute("UPDATE maggiedash_documents SET status='trashed', trashed_at=?, trashed_from_status=?, updated_at=? WHERE id=?", (timestamp, row["status"], timestamp, document_id))
190
198
  self._audit(project_id, "document.trashed", "document", document_id, actor_id, reason)
191
199
  self.connection.commit()
192
200
  return self.get_document(project_id, document_id) or {}
@@ -195,15 +203,38 @@ class MaggieDashStore:
195
203
  row = self.connection.execute("SELECT trashed_at, trashed_from_status FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
196
204
  if not row:
197
205
  raise ValueError("document not found")
198
- if not row["trashed_at"]:
206
+ if not row["trashed_at"] and row["trashed_from_status"] is None:
199
207
  return self.get_document(project_id, document_id) or {}
200
208
  status = row["trashed_from_status"] if row["trashed_from_status"] in STATUSES else "draft"
209
+ if status == "trashed":
210
+ status = "draft"
201
211
  timestamp = now()
202
212
  self.connection.execute("UPDATE maggiedash_documents SET status=?, trashed_at=NULL, trashed_from_status=NULL, updated_at=? WHERE id=?", (status, timestamp, document_id))
203
213
  self._audit(project_id, "document.restored", "document", document_id, actor_id, reason)
204
214
  self.connection.commit()
205
215
  return self.get_document(project_id, document_id) or {}
206
216
 
217
+ def publish_due(self, project_id: str, now_value: str | None = None, actor_id: str = "scheduler", reason: str = "scheduled publish tick") -> list[dict[str, Any]]:
218
+ """Publish scheduled documents whose timestamp has arrived.
219
+
220
+ The status predicate is intentional: a trashed document may retain an
221
+ old publish timestamp, but it is never eligible for promotion.
222
+ """
223
+ current = datetime.fromisoformat((now_value or now()).replace("Z", "+00:00"))
224
+ rows = self.connection.execute(
225
+ "SELECT id, publish_at FROM maggiedash_documents WHERE project_id=? AND status='scheduled' AND publish_at IS NOT NULL ORDER BY id",
226
+ (project_id,),
227
+ ).fetchall()
228
+ published: list[dict[str, Any]] = []
229
+ for row in rows:
230
+ try:
231
+ scheduled = datetime.fromisoformat(str(row["publish_at"]).replace("Z", "+00:00"))
232
+ except ValueError:
233
+ continue
234
+ if scheduled.tzinfo is not None and scheduled <= current:
235
+ published.append(self.transition(project_id, row["id"], "published", actor_id, reason))
236
+ return published
237
+
207
238
  def schedule_publish(self, project_id: str, document_id: str, publish_at: str, actor_id: str, reason: str) -> dict[str, Any]:
208
239
  try:
209
240
  target = datetime.fromisoformat(publish_at.replace("Z", "+00:00"))
@@ -238,10 +269,17 @@ class MaggieDashStore:
238
269
  return result
239
270
 
240
271
  def add_redirect(self, project_id: str, from_path: str, to_path: str, actor_id: str, reason: str, status_code: int = 301) -> dict[str, Any]:
241
- if not from_path.startswith("/") or not to_path.startswith("/") or from_path == to_path:
272
+ if not from_path.startswith("/") or not to_path.startswith("/") or self._normalise_path(from_path) == self._normalise_path(to_path):
242
273
  raise ValueError("redirect paths must be distinct absolute paths")
243
274
  if status_code not in {301, 302, 307, 308}:
244
275
  raise ValueError("status_code must be 301, 302, 307 or 308")
276
+ destination = self._normalise_path(to_path)
277
+ chain = self.connection.execute(
278
+ "SELECT 1 FROM maggiedash_redirects WHERE project_id=? AND ((from_path=? OR rtrim(from_path, '/')=?) OR (to_path=? OR rtrim(to_path, '/')=?)) LIMIT 1",
279
+ (project_id, destination, destination, from_path, self._normalise_path(from_path)),
280
+ ).fetchone()
281
+ if chain:
282
+ raise ValueError("redirect destination must not already redirect")
245
283
  record = (str(uuid.uuid4()), project_id, from_path, to_path, status_code, reason, actor_id, now())
246
284
  self.connection.execute("INSERT INTO maggiedash_redirects VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(project_id,from_path) DO UPDATE SET to_path=excluded.to_path, status_code=excluded.status_code, reason=excluded.reason, actor_id=excluded.actor_id, created_at=excluded.created_at", record)
247
285
  self._audit(project_id, "redirect.upserted", "redirect", from_path, actor_id, reason)
@@ -249,6 +287,22 @@ class MaggieDashStore:
249
287
  row = self.connection.execute("SELECT * FROM maggiedash_redirects WHERE project_id=? AND from_path=?", (project_id, from_path)).fetchone()
250
288
  return dict(row)
251
289
 
290
+ @staticmethod
291
+ def _normalise_path(path: str) -> str:
292
+ path = path.split("?", 1)[0].split("#", 1)[0]
293
+ return path.rstrip("/") or "/"
294
+
295
+ def resolve_redirect(self, project_id: str, path: str, response_status: int) -> dict[str, Any] | None:
296
+ """Consult redirects only after the route has already returned 404."""
297
+ if response_status != 404:
298
+ return None
299
+ normalised = self._normalise_path(path)
300
+ row = self.connection.execute(
301
+ "SELECT * FROM maggiedash_redirects WHERE project_id=? AND (from_path=? OR rtrim(from_path, '/')=?) ORDER BY CASE WHEN from_path=? THEN 0 ELSE 1 END LIMIT 1",
302
+ (project_id, path, normalised, path),
303
+ ).fetchone()
304
+ return dict(row) if row else None
305
+
252
306
  def issue_preview(self, project_id: str, document_id: str, secret: str, ttl_seconds: int = 900) -> dict[str, Any]:
253
307
  if not secret:
254
308
  raise ValueError("preview secret is required")
@@ -258,7 +312,15 @@ class MaggieDashStore:
258
312
  payload = f"{project_id}:{document_id}:{expires}".encode()
259
313
  encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
260
314
  signature = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).hexdigest()
261
- return {"token": f"{encoded}.{signature}", "expiresAt": datetime.fromtimestamp(expires, timezone.utc).isoformat().replace("+00:00", "Z")}
315
+ return {
316
+ "token": f"{encoded}.{signature}",
317
+ "expiresAt": datetime.fromtimestamp(expires, timezone.utc).isoformat().replace("+00:00", "Z"),
318
+ "headers": {
319
+ "Cache-Control": "no-store",
320
+ "X-Robots-Tag": "noindex, nofollow",
321
+ "metaRobots": "noindex, nofollow",
322
+ },
323
+ }
262
324
 
263
325
  @staticmethod
264
326
  def verify_preview(token: str, secret: str, project_id: str, document_id: str) -> bool:
@@ -15,6 +15,23 @@ def _component_source(name: str, source: str) -> str:
15
15
  return match.group(0) if match else ""
16
16
 
17
17
 
18
+ def _render_body(component_source: str) -> str:
19
+ """Return the component implementation, excluding its props signature.
20
+
21
+ A declaration and its TypeScript type are evidence that a prop exists, not
22
+ evidence that the rendered UI uses it. Counting only the implementation
23
+ prevents a dead prop from passing because it appears twice in the signature.
24
+ """
25
+ # Typed destructured parameters contain their own `{...}` block. The
26
+ # function body is the last opening brace before the first return.
27
+ return_position = component_source.find("return")
28
+ opening = component_source.rfind("{", 0, return_position if return_position != -1 else len(component_source))
29
+ if opening == -1:
30
+ arrow = component_source.find("=>")
31
+ opening = component_source.find("{", arrow) if arrow != -1 else -1
32
+ return component_source[opening + 1:] if opening != -1 else ""
33
+
34
+
18
35
  def validate(contract: object, sources: Iterable[str] = ()) -> dict:
19
36
  errors: list[str] = []
20
37
  if not isinstance(contract, dict):
@@ -44,12 +61,29 @@ def validate(contract: object, sources: Iterable[str] = ()) -> dict:
44
61
  forbidden = [str(prop) for prop in component.get("forbiddenProps", [])]
45
62
  if component_source:
46
63
  signature = component_source.split("=>", 1)[0] if "=>" in component_source else component_source[:500]
64
+ body = _render_body(component_source)
47
65
  for prop in props:
48
- if len(re.findall(rf"\b{re.escape(prop)}\b", component_source)) < 2:
66
+ if len(re.findall(rf"\b{re.escape(prop)}\b", body)) < 1:
49
67
  errors.append(f"{name}.{prop} is declared but not evidenced in rendered output")
50
68
  for prop in forbidden:
51
69
  if re.search(rf"\b{re.escape(prop)}\b", signature):
52
70
  errors.append(f"{name} declares forbidden dead prop: {prop}")
71
+ dialogs = contract.get("dialogs")
72
+ if isinstance(dialogs, dict) and source:
73
+ for pattern in dialogs.get("forbiddenSourcePatterns", []):
74
+ pattern = str(pattern)
75
+ if pattern in source:
76
+ errors.append(f"dialog contract forbids source pattern: {pattern}")
77
+ required = [str(item) for item in dialogs.get("requiredLiterals", [])]
78
+ for key in ("primitive", "overlayPrimitive"):
79
+ name = str(dialogs.get(key) or "")
80
+ component_source = _component_source(name, source) if name else ""
81
+ if not component_source:
82
+ errors.append(f"dialog source is missing: {name}")
83
+ continue
84
+ for literal in required:
85
+ if literal not in component_source:
86
+ errors.append(f"{name} is missing dialog requirement: {literal}")
53
87
  return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "components": [item.get("name") for item in components if isinstance(item, dict)]}
54
88
 
55
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,12 @@
1
+ # MaggieDash telemetry contract
2
+
3
+ Every measurement path must call the shared first-party traffic filter before
4
+ incrementing a counter or writing a measurement event. This includes page
5
+ views, 404/not-found rows, redirect hits, booking clicks, analytics events and
6
+ operational audit counters. Filtering only page views leaves synthetic sweeps
7
+ in the other totals.
8
+
9
+ Use `should_record_measurement(event)` from
10
+ `tools/runtime/analytics_traffic.py`. The filter accepts explicit Maggie/test
11
+ markers and known toolchain user agents; it never guesses identity from IP
12
+ addresses or private user fields.