@topy-ai/maggie 0.7.0 → 0.7.1

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 (31) hide show
  1. package/README.md +22 -7
  2. package/bin/maggie.js +6 -6
  3. package/bundled-references/maggiedash-dashboard-ui.md +28 -0
  4. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  5. package/bundled-skills/maggie-content-localization/SKILL.md +9 -0
  6. package/bundled-skills/maggie-dash/SKILL.md +21 -0
  7. package/bundled-skills/maggie-deployment/SKILL.md +17 -0
  8. package/bundled-skills/maggie-ops/SKILL.md +27 -0
  9. package/bundled-skills/maggie-seo-geo/SKILL.md +21 -1
  10. package/bundled-skills/maggie-service-booking/SKILL.md +6 -0
  11. package/bundled-templates/maggiedash/README.md +4 -0
  12. package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
  13. package/bundled-tools/clis/maggie_analytics.py +14 -1
  14. package/bundled-tools/clis/maggie_blog.py +6 -0
  15. package/bundled-tools/clis/maggie_dash.py +54 -0
  16. package/bundled-tools/clis/maggie_deployment.py +43 -0
  17. package/bundled-tools/clis/maggie_feedback.py +16 -1
  18. package/bundled-tools/clis/maggie_ops.py +21 -1
  19. package/bundled-tools/clis/maggie_service_booking.py +6 -3
  20. package/bundled-tools/clis/maggie_sitemap.py +16 -2
  21. package/bundled-tools/runtime/analytics_traffic.py +30 -0
  22. package/bundled-tools/runtime/content_localization.py +63 -1
  23. package/bundled-tools/runtime/dependency_lock.py +43 -0
  24. package/bundled-tools/runtime/integration_state.py +17 -0
  25. package/bundled-tools/runtime/maggie_dash_store.py +150 -6
  26. package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
  27. package/bundled-tools/runtime/maggie_sitemap.py +50 -4
  28. package/bundled-tools/runtime/route_imports.py +51 -0
  29. package/bundled-tools/runtime/seed_evidence.py +25 -0
  30. package/package.json +1 -1
  31. package/references/maggiedash-dashboard-ui.md +28 -0
@@ -9,6 +9,10 @@ import sys
9
9
  from datetime import datetime, timezone
10
10
  from pathlib import Path
11
11
 
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
13
+ from dependency_lock import audit as audit_lockfiles
14
+ from seed_evidence import validate_manifest
15
+
12
16
 
13
17
  REQUIRED_ROUTES = (
14
18
  "/ops", "/ops/posts", "/ops/sitemap", "/ops/reports",
@@ -132,14 +136,30 @@ def command_verify(args: argparse.Namespace) -> int:
132
136
  return 0 if result["checks"]["ready"] and result["stateFile"] else 1
133
137
 
134
138
 
139
+ def command_lockfiles(args: argparse.Namespace) -> int:
140
+ result = audit_lockfiles(root(args))
141
+ print(json.dumps(result, indent=2, ensure_ascii=False))
142
+ return 0 if result["passed"] else 1
143
+
144
+
145
+ def command_seed_manifest(args: argparse.Namespace) -> int:
146
+ value = read_json(Path(args.manifest).resolve())
147
+ result = validate_manifest(value)
148
+ print(json.dumps(result, indent=2, ensure_ascii=False))
149
+ return 0 if result["passed"] else 1
150
+
151
+
135
152
  def main() -> int:
136
153
  parser = argparse.ArgumentParser(description=__doc__)
137
154
  parser.add_argument("--project", default=".")
138
155
  sub = parser.add_subparsers(dest="command", required=True)
139
- for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify)):
156
+ for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify), ("lockfiles", command_lockfiles)):
140
157
  command = sub.add_parser(name)
141
158
  if name == "preflight": command.add_argument("--write", action="store_true")
142
159
  command.set_defaults(func=handler)
160
+ seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
161
+ seed.add_argument("--manifest", required=True)
162
+ seed.set_defaults(func=command_seed_manifest)
143
163
  record = sub.add_parser("record", help="record an explicitly authorised operation")
144
164
  record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
145
165
  record.add_argument("--dry-run", action="store_true")
@@ -10,6 +10,9 @@ from pathlib import Path
10
10
  from urllib.parse import quote, urljoin, urlsplit
11
11
  from urllib.request import Request, urlopen
12
12
 
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
14
+ from route_imports import imported_components # noqa: E402
15
+
13
16
  NOW = lambda: datetime.now(timezone.utc).isoformat()
14
17
  MONEY = re.compile(r"(?:£|GBP\s*)\s*([0-9]+(?:[.,][0-9]{1,2})?)", re.I)
15
18
  DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
@@ -751,7 +754,7 @@ def inventory_candidates(project, pages_root):
751
754
  found={}
752
755
  for page in pages_root.rglob("*") if pages_root.exists() else []:
753
756
  if page.is_file() and page.suffix.lower() in {".astro",".md",".mdx",".html"} and page.stem not in {"index","404"}:
754
- found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem"}
757
+ found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem","imports":imported_components(page)}
755
758
  for name in ("pages.json","page-content.json"):
756
759
  source=project/"docs"/name
757
760
  if not source.exists(): continue
@@ -762,7 +765,7 @@ def inventory_candidates(project, pages_root):
762
765
  if not isinstance(item,dict): continue
763
766
  value=item.get("path") or item.get("url") or item.get("route")
764
767
  if not value: continue
765
- current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory"}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
768
+ current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory","imports":[]}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
766
769
  return list(found.values())
767
770
  def cmd_match_pages_review(args):
768
771
  project=root(args); data=load(project); candidates=inventory_candidates(project,(project/args.pages_dir).resolve()); aliases={"lymphatic drainage":{"lymphatic","lymph drainage","lymphdrainage","manual lymphatic","ml d"},"deep tissue":{"deep tissue","therapeutic massage"},"thai":{"thai massage","thai"},"postpartum":{"postpartum","new mom"},"facial":{"facial","hydra facial","hydrafacial"},"massage":{"massage"}}
@@ -779,7 +782,7 @@ def cmd_match_pages_review(args):
779
782
  matches=[]
780
783
  for page in candidates:
781
784
  path_slug=slug(page["path"].rsplit("/",1)[-1].rsplit(".",1)[0]); page_words=page_tokens(path_slug+" "+page["text"][:2000]); exact=path_slug==service.get("slug"); overlap=len(expanded & page_words)/max(1,len(expanded)); generic=bool(concepts & {"massage","facial","acupuncture","waxing"}) and overlap<0.75; score=1.0 if exact else min(0.49 if generic else overlap,0.99)
782
- if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
785
+ if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"imports":page.get("imports",[]),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
783
786
  matches.sort(key=lambda x:(-x["confidence"],x["path"])); report.append({"serviceId":service["id"],"title":service["title"],"candidates":matches})
784
787
  for path_value in selections.get(service["id"], []):
785
788
  if path_value in {m["path"] for m in matches}: selected.append((service,path_value))
@@ -10,7 +10,7 @@ import sys
10
10
  from pathlib import Path
11
11
 
12
12
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
13
- from maggie_sitemap import build_plan, parse_routes, validate_plan_data # noqa: E402
13
+ from maggie_sitemap import agent_files, build_plan, parse_routes, validate_plan_data # noqa: E402
14
14
 
15
15
 
16
16
  def write_json(path: str, value: dict) -> None:
@@ -31,6 +31,12 @@ def main() -> int:
31
31
  plan.add_argument("--output", required=True)
32
32
  validate = sub.add_parser("validate")
33
33
  validate.add_argument("--plan", required=True)
34
+ validate.add_argument("--strict-semantic", action="store_true")
35
+ agent = sub.add_parser("agent-files")
36
+ agent.add_argument("--origin", required=True)
37
+ agent.add_argument("--routes-file", required=True)
38
+ agent.add_argument("--output-dir", required=True)
39
+ agent.add_argument("--locale", default="en")
34
40
  apply = sub.add_parser("apply")
35
41
  apply.add_argument("--plan", required=True)
36
42
  apply.add_argument("--public-dir", required=True)
@@ -65,9 +71,17 @@ def main() -> int:
65
71
  restored += 1
66
72
  print(json.dumps({"status": "rolled-back", "restored": restored}, indent=2))
67
73
  return 0
74
+ if args.command == "agent-files":
75
+ output = Path(args.output_dir)
76
+ output.mkdir(parents=True, exist_ok=True)
77
+ files = agent_files(parse_routes(Path(args.routes_file).read_text(encoding="utf-8")), args.origin, args.locale)
78
+ for name, content in files.items():
79
+ (output / name).write_text(content, encoding="utf-8")
80
+ print(json.dumps({"status": "generated", "locale": args.locale, "files": [str(output / name) for name in files]}, indent=2))
81
+ return 0
68
82
  plan_value = json.loads(Path(args.plan).read_text(encoding="utf-8"))
69
83
  if args.command == "validate":
70
- result = validate_plan_data(plan_value)
84
+ result = validate_plan_data(plan_value, args.strict_semantic)
71
85
  print(json.dumps(result, indent=2))
72
86
  return 0 if result["status"] == "pass" else 1
73
87
  if not args.confirm:
@@ -0,0 +1,30 @@
1
+ """Safe classification of known first-party/toolchain traffic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from typing import Iterable
8
+
9
+ TOOL_USER_AGENT = re.compile(r"(?:maggie|playwright|puppeteer|selenium|lighthouse|headlesschrome|synthetic|test[-_ ]?sweep)", re.I)
10
+
11
+
12
+ def is_tool_traffic(event: dict) -> bool:
13
+ if str(event.get("maggieTest") or "").lower() == "true":
14
+ return True
15
+ if str(event.get("xMaggieTest") or "").lower() == "true":
16
+ return True
17
+ return bool(TOOL_USER_AGENT.search(str(event.get("userAgent") or "")))
18
+
19
+
20
+ def audit_events(events: Iterable[dict]) -> dict:
21
+ totals = Counter()
22
+ excluded = Counter()
23
+ included = 0
24
+ for event in events:
25
+ totals[str(event.get("event") or "unknown")] += 1
26
+ if is_tool_traffic(event):
27
+ excluded[str(event.get("event") or "unknown")] += 1
28
+ else:
29
+ included += 1
30
+ return {"schemaVersion": "maggie-analytics-traffic.v1", "total": sum(totals.values()), "included": included, "excluded": sum(excluded.values()), "byEvent": dict(sorted(totals.items())), "excludedByEvent": dict(sorted(excluded.items())), "policy": "exclude only explicit Maggie/toolchain markers; no IP or identity inference"}
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- from typing import Any
6
+ from typing import Any, Iterable
7
7
 
8
8
  # Deliberately broad, dependency-free registry of the major web/content
9
9
  # languages. Script variants remain distinct where they affect copy, search,
@@ -19,6 +19,68 @@ OPERATIONS = {"translate", "polish", "rewrite", "localise", "rebrand", "manual_e
19
19
  PROTECTED_FIELDS = {"price", "currency", "rating", "provider", "providerId", "bookingUrl", "paymentUrl", "legalClaims", "healthClaims", "contentId", "slug", "canonicalUrl", "translationGroupId"}
20
20
 
21
21
 
22
+ class TranslationIndex:
23
+ """One canonical index for translated records and their public wording.
24
+
25
+ Hosts may persist this structure wherever they keep content, but they must
26
+ build it once and reject conflicting duplicate `(contentId, locale)` rows.
27
+ This prevents a registry and a second dictionary from silently disagreeing.
28
+ """
29
+
30
+ def __init__(self, records: Iterable[dict[str, Any]] = ()):
31
+ self._records: dict[tuple[str, str], dict[str, Any]] = {}
32
+ self.conflicts: list[dict[str, Any]] = []
33
+ for record in records:
34
+ self.add(record)
35
+
36
+ def add(self, record: dict[str, Any]) -> None:
37
+ content_id = str(record.get("contentId") or "").strip()
38
+ locale = str(record.get("locale") or "").strip()
39
+ if not content_id or not locale:
40
+ raise ValueError("translation index records require contentId and locale")
41
+ key = (content_id, locale)
42
+ previous = self._records.get(key)
43
+ if previous and previous != record:
44
+ self.conflicts.append({"key": key, "existing": previous, "incoming": record})
45
+ raise ValueError(f"conflicting translation record for {content_id}/{locale}")
46
+ self._records[key] = dict(record)
47
+
48
+ def get(self, content_id: str, locale: str) -> dict[str, Any] | None:
49
+ record = self._records.get((content_id, locale))
50
+ return dict(record) if record else None
51
+
52
+ def records(self) -> list[dict[str, Any]]:
53
+ return [dict(self._records[key]) for key in sorted(self._records)]
54
+
55
+ def as_dict(self) -> dict[str, dict[str, Any]]:
56
+ return {f"{content_id}:{locale}": dict(record) for (content_id, locale), record in sorted(self._records.items())}
57
+
58
+
59
+ def is_translated_path(path: str, exact_paths: Iterable[str] = (), prefixes: Iterable[str] = ()) -> bool:
60
+ """Use exact routes and normalized prefix routes with identical semantics."""
61
+ normalized = "/" + str(path or "").lstrip("/")
62
+ normalized = normalized.rstrip("/") or "/"
63
+ exact = {("/" + str(item).lstrip("/")).rstrip("/") or "/" for item in exact_paths}
64
+ normalized_prefixes = {("/" + str(item).lstrip("/")).rstrip("/") or "/" for item in prefixes}
65
+ return normalized in exact or any(normalized == prefix or normalized.startswith(prefix + "/") for prefix in normalized_prefixes)
66
+
67
+
68
+ def should_localize_path(path: str, locale_prefixes: Iterable[str], static_extensions: Iterable[str] = (".txt", ".md", ".xml", ".json")) -> bool:
69
+ """Locale middleware only handles localized routes, never root static files."""
70
+ normalized = "/" + str(path or "").lstrip("/")
71
+ if any(normalized.lower().endswith(extension.lower()) for extension in static_extensions):
72
+ return False
73
+ return is_translated_path(normalized, prefixes=locale_prefixes)
74
+
75
+
76
+ def rewrite_once(request_key: str, seen: set[str]) -> bool:
77
+ """Return true only for the first rewrite observation of a request."""
78
+ if request_key in seen:
79
+ return False
80
+ seen.add(request_key)
81
+ return True
82
+
83
+
22
84
  def parse_locale(value: Any) -> tuple[str | None, str | None, str | None]:
23
85
  if not isinstance(value, str):
24
86
  return None, None, None
@@ -0,0 +1,43 @@
1
+ """Detect package-manager drift before an npm-ci deployment."""
2
+ from __future__ import annotations
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+
8
+ def package_dependencies(project: Path) -> set[str]:
9
+ data = json.loads((project / "package.json").read_text(encoding="utf-8"))
10
+ return set(data.get("dependencies", {})) | set(data.get("devDependencies", {})) | set(data.get("optionalDependencies", {}))
11
+
12
+
13
+ def npm_lock_dependencies(path: Path) -> set[str]:
14
+ data = json.loads(path.read_text(encoding="utf-8"))
15
+ packages = data.get("packages", {})
16
+ names = {key.removeprefix("node_modules/") for key in packages
17
+ if key.startswith("node_modules/") and "/node_modules/" not in key}
18
+ if not packages and isinstance(data.get("dependencies"), dict):
19
+ names = set(data["dependencies"])
20
+ return names
21
+
22
+
23
+ def pnpm_lock_dependencies(path: Path) -> set[str]:
24
+ text = path.read_text(encoding="utf-8", errors="replace")
25
+ return {match.group(1) for match in re.finditer(r"^\s{4,}/?(@[^/\s]+/[^/\s]+|[A-Za-z0-9_.-]+)@[^:]+:", text, re.M)}
26
+
27
+
28
+ def audit(project: Path) -> dict:
29
+ errors = []
30
+ try:
31
+ declared = package_dependencies(project)
32
+ except (OSError, ValueError, json.JSONDecodeError) as error:
33
+ return {"passed": False, "errors": [f"package.json: {error}"]}
34
+ npm = project / "package-lock.json"
35
+ pnpm = project / "pnpm-lock.yaml"
36
+ npm_names = npm_lock_dependencies(npm) if npm.exists() else set()
37
+ pnpm_names = pnpm_lock_dependencies(pnpm) if pnpm.exists() else set()
38
+ if not npm.exists(): errors.append("package-lock.json is required by npm ci")
39
+ missing = sorted(declared - npm_names) if npm.exists() else sorted(declared)
40
+ if missing: errors.append("package-lock missing declared dependencies: " + ", ".join(missing))
41
+ return {"schemaVersion": "maggie-dependency-lock-audit.v1", "passed": not errors,
42
+ "errors": errors, "declared": sorted(declared), "npmLock": sorted(npm_names),
43
+ "pnpmLock": sorted(pnpm_names), "lockfiles": {"npm": npm.exists(), "pnpm": pnpm.exists()}}
@@ -0,0 +1,17 @@
1
+ """Explicit state model for optional Search/AI visibility integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def integration_state(*, configured: bool, consent_required: bool = False, consent: bool = False, authorized: bool = False, error: str | None = None) -> dict:
7
+ if error:
8
+ status = "error"
9
+ elif not configured:
10
+ status = "not-configured"
11
+ elif consent_required and not consent:
12
+ status = "awaiting-consent"
13
+ elif not authorized:
14
+ status = "awaiting-authorization"
15
+ else:
16
+ status = "ready"
17
+ return {"status": status, "configured": configured, "consentRequired": consent_required, "consent": consent, "authorized": authorized, "reason": error or status}
@@ -4,10 +4,12 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import hashlib
7
+ import base64
8
+ import hmac
7
9
  import json
8
10
  import sqlite3
9
11
  import uuid
10
- from datetime import datetime, timezone
12
+ from datetime import datetime, timedelta, timezone
11
13
  from pathlib import Path
12
14
  from typing import Any
13
15
 
@@ -55,6 +57,20 @@ class MaggieDashStore:
55
57
  created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
56
58
  UNIQUE(project_id, slug)
57
59
  );
60
+ CREATE TABLE IF NOT EXISTS maggiedash_document_revisions (
61
+ id TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES maggiedash_documents(id),
62
+ revision INTEGER NOT NULL, title TEXT NOT NULL, slug TEXT NOT NULL,
63
+ locale TEXT NOT NULL, excerpt TEXT NOT NULL, content_json TEXT NOT NULL,
64
+ canonical_url TEXT, provenance_json TEXT NOT NULL, checksum TEXT NOT NULL,
65
+ created_at TEXT NOT NULL, created_by TEXT NOT NULL,
66
+ UNIQUE(document_id, revision)
67
+ );
68
+ CREATE TABLE IF NOT EXISTS maggiedash_redirects (
69
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES maggiedash_projects(id),
70
+ from_path TEXT NOT NULL, to_path TEXT NOT NULL, status_code INTEGER NOT NULL DEFAULT 301,
71
+ reason TEXT NOT NULL, actor_id TEXT NOT NULL, created_at TEXT NOT NULL,
72
+ UNIQUE(project_id, from_path)
73
+ );
58
74
  CREATE TABLE IF NOT EXISTS maggiedash_approvals (
59
75
  id TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES maggiedash_documents(id),
60
76
  from_status TEXT NOT NULL, to_status TEXT NOT NULL, actor_id TEXT NOT NULL,
@@ -68,6 +84,15 @@ class MaggieDashStore:
68
84
  );
69
85
  """
70
86
  )
87
+ self._ensure_column("maggiedash_documents", "trashed_at", "TEXT")
88
+ self._ensure_column("maggiedash_documents", "trashed_from_status", "TEXT")
89
+ self._ensure_column("maggiedash_documents", "publish_at", "TEXT")
90
+ self.connection.commit()
91
+
92
+ def _ensure_column(self, table: str, column: str, definition: str) -> None:
93
+ columns = {row[1] for row in self.connection.execute(f"PRAGMA table_info({table})")}
94
+ if column not in columns:
95
+ self.connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
71
96
 
72
97
  def close(self) -> None:
73
98
  self.connection.close()
@@ -97,22 +122,27 @@ class MaggieDashStore:
97
122
  raise ValueError(f"invalid document status: {status}")
98
123
  stable = {key: value for key, value in document.items() if key not in {"provenance", "checksum"}}
99
124
  document_checksum = document.get("provenance", {}).get("checksum") or checksum(stable)
100
- existing = self.connection.execute("SELECT status FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone()
125
+ existing = self.connection.execute("SELECT * FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone()
101
126
  if existing and existing["status"] != status:
102
127
  raise ValueError("status changes must use transition()")
128
+ if existing and existing["checksum"] != document_checksum:
129
+ self._store_revision(existing, actor_id)
103
130
  self.connection.execute(
104
131
  """INSERT INTO maggiedash_documents
105
- (id,project_id,kind,title,slug,locale,status,excerpt,content_json,canonical_url,provenance_json,checksum,created_at,updated_at)
106
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
132
+ (id,project_id,kind,title,slug,locale,status,excerpt,content_json,canonical_url,provenance_json,checksum,created_at,updated_at,publish_at)
133
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
107
134
  ON CONFLICT(id) DO UPDATE SET title=excluded.title, slug=excluded.slug,
108
135
  locale=excluded.locale, excerpt=excluded.excerpt, content_json=excluded.content_json,
109
136
  canonical_url=excluded.canonical_url, provenance_json=excluded.provenance_json,
110
- checksum=excluded.checksum, updated_at=excluded.updated_at""",
137
+ checksum=excluded.checksum, updated_at=excluded.updated_at,
138
+ publish_at=excluded.publish_at, trashed_at=NULL, trashed_from_status=NULL""",
111
139
  (document["id"], project_id, document["kind"], document["title"], document["slug"], document["locale"],
112
140
  status, document.get("excerpt", ""), json.dumps(document["content"], ensure_ascii=False),
113
141
  document.get("canonicalUrl"), json.dumps(document["provenance"], ensure_ascii=False), document_checksum,
114
- timestamp, timestamp),
142
+ timestamp, timestamp, document.get("publishAt")),
115
143
  )
144
+ if not existing:
145
+ self._store_revision(self.connection.execute("SELECT * FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone(), actor_id)
116
146
  self._audit(project_id, "document.upserted", "document", document["id"], actor_id, "draft content stored")
117
147
  self.connection.commit()
118
148
  return self.get_document(project_id, document["id"]) or {}
@@ -125,8 +155,122 @@ class MaggieDashStore:
125
155
  result["content"] = json.loads(result.pop("content_json"))
126
156
  result["provenance"] = json.loads(result.pop("provenance_json"))
127
157
  result["canonicalUrl"] = result.pop("canonical_url")
158
+ result["trashedAt"] = result.pop("trashed_at")
159
+ result["trashedFromStatus"] = result.pop("trashed_from_status")
160
+ result["publishAt"] = result.pop("publish_at")
128
161
  return result
129
162
 
163
+ def _store_revision(self, row: sqlite3.Row, actor_id: str) -> None:
164
+ revision = self.connection.execute(
165
+ "SELECT COALESCE(MAX(revision), 0) + 1 FROM maggiedash_document_revisions WHERE document_id=?",
166
+ (row["id"],),
167
+ ).fetchone()[0]
168
+ self.connection.execute(
169
+ "INSERT INTO maggiedash_document_revisions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
170
+ (str(uuid.uuid4()), row["id"], revision, row["title"], row["slug"], row["locale"], row["excerpt"],
171
+ row["content_json"], row["canonical_url"], row["provenance_json"], row["checksum"], row["updated_at"], actor_id),
172
+ )
173
+
174
+ def list_revisions(self, project_id: str, document_id: str) -> list[dict[str, Any]]:
175
+ if not self.get_document(project_id, document_id):
176
+ raise ValueError("document not found")
177
+ rows = self.connection.execute(
178
+ "SELECT * FROM maggiedash_document_revisions WHERE document_id=? ORDER BY revision DESC", (document_id,)
179
+ ).fetchall()
180
+ return [dict(row) for row in rows]
181
+
182
+ def trash(self, project_id: str, document_id: str, actor_id: str, reason: str) -> dict[str, Any]:
183
+ row = self.connection.execute("SELECT status, trashed_at FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
184
+ if not row:
185
+ raise ValueError("document not found")
186
+ if row["trashed_at"]:
187
+ return self.get_document(project_id, document_id) or {}
188
+ 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))
190
+ self._audit(project_id, "document.trashed", "document", document_id, actor_id, reason)
191
+ self.connection.commit()
192
+ return self.get_document(project_id, document_id) or {}
193
+
194
+ def restore(self, project_id: str, document_id: str, actor_id: str, reason: str) -> dict[str, Any]:
195
+ row = self.connection.execute("SELECT trashed_at, trashed_from_status FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
196
+ if not row:
197
+ raise ValueError("document not found")
198
+ if not row["trashed_at"]:
199
+ return self.get_document(project_id, document_id) or {}
200
+ status = row["trashed_from_status"] if row["trashed_from_status"] in STATUSES else "draft"
201
+ timestamp = now()
202
+ self.connection.execute("UPDATE maggiedash_documents SET status=?, trashed_at=NULL, trashed_from_status=NULL, updated_at=? WHERE id=?", (status, timestamp, document_id))
203
+ self._audit(project_id, "document.restored", "document", document_id, actor_id, reason)
204
+ self.connection.commit()
205
+ return self.get_document(project_id, document_id) or {}
206
+
207
+ def schedule_publish(self, project_id: str, document_id: str, publish_at: str, actor_id: str, reason: str) -> dict[str, Any]:
208
+ try:
209
+ target = datetime.fromisoformat(publish_at.replace("Z", "+00:00"))
210
+ except ValueError as exc:
211
+ raise ValueError("publish_at must be an ISO-8601 timestamp") from exc
212
+ if target.tzinfo is None:
213
+ raise ValueError("publish_at must include a timezone")
214
+ document = self.get_document(project_id, document_id)
215
+ if not document:
216
+ raise ValueError("document not found")
217
+ if document["status"] not in {"approved", "scheduled"}:
218
+ raise ValueError("only approved documents can be scheduled")
219
+ if document["status"] == "approved":
220
+ self.transition(project_id, document_id, "scheduled", actor_id, reason)
221
+ self.connection.execute("UPDATE maggiedash_documents SET publish_at=?, updated_at=? WHERE id=?", (publish_at, now(), document_id))
222
+ self._audit(project_id, "document.publish_scheduled", "document", document_id, actor_id, reason)
223
+ self.connection.commit()
224
+ return self.get_document(project_id, document_id) or {}
225
+
226
+ def duplicate(self, project_id: str, document_id: str, new_id: str, new_slug: str, actor_id: str, reason: str) -> dict[str, Any]:
227
+ source = self.get_document(project_id, document_id)
228
+ if not source:
229
+ raise ValueError("document not found")
230
+ if self.connection.execute("SELECT 1 FROM maggiedash_documents WHERE project_id=? AND slug=?", (project_id, new_slug)).fetchone():
231
+ raise ValueError("new slug already exists")
232
+ copy = dict(source)
233
+ copy.update({"id": new_id, "slug": new_slug, "status": "draft", "provenance": {**source["provenance"], "duplicatedFrom": document_id}})
234
+ copy.pop("trashedAt", None); copy.pop("trashedFromStatus", None); copy.pop("publishAt", None)
235
+ result = self.put_document(project_id, copy, actor_id)
236
+ self._audit(project_id, "document.duplicated", "document", new_id, actor_id, reason)
237
+ self.connection.commit()
238
+ return result
239
+
240
+ 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:
242
+ raise ValueError("redirect paths must be distinct absolute paths")
243
+ if status_code not in {301, 302, 307, 308}:
244
+ raise ValueError("status_code must be 301, 302, 307 or 308")
245
+ record = (str(uuid.uuid4()), project_id, from_path, to_path, status_code, reason, actor_id, now())
246
+ 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
+ self._audit(project_id, "redirect.upserted", "redirect", from_path, actor_id, reason)
248
+ self.connection.commit()
249
+ row = self.connection.execute("SELECT * FROM maggiedash_redirects WHERE project_id=? AND from_path=?", (project_id, from_path)).fetchone()
250
+ return dict(row)
251
+
252
+ def issue_preview(self, project_id: str, document_id: str, secret: str, ttl_seconds: int = 900) -> dict[str, Any]:
253
+ if not secret:
254
+ raise ValueError("preview secret is required")
255
+ if not self.get_document(project_id, document_id):
256
+ raise ValueError("document not found")
257
+ expires = int((datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).timestamp())
258
+ payload = f"{project_id}:{document_id}:{expires}".encode()
259
+ encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
260
+ 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")}
262
+
263
+ @staticmethod
264
+ def verify_preview(token: str, secret: str, project_id: str, document_id: str) -> bool:
265
+ try:
266
+ encoded, signature = token.split(".", 1)
267
+ expected = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).hexdigest()
268
+ payload = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode()
269
+ token_project, token_document, expires = payload.split(":", 2)
270
+ return hmac.compare_digest(signature, expected) and token_project == project_id and token_document == document_id and int(expires) >= int(datetime.now(timezone.utc).timestamp())
271
+ except (ValueError, TypeError, UnicodeDecodeError):
272
+ return False
273
+
130
274
  def transition(self, project_id: str, document_id: str, target: str, actor_id: str, reason: str) -> dict[str, Any]:
131
275
  if target not in STATUSES:
132
276
  raise ValueError(f"invalid target status: {target}")
@@ -0,0 +1,60 @@
1
+ """Validate the provider-neutral MaggieDash dashboard chrome contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+
10
+ SCHEMA = "maggiedash-dashboard-ui.v1"
11
+
12
+
13
+ def _component_source(name: str, source: str) -> str:
14
+ match = re.search(rf"(?:function\s+{re.escape(name)}\s*\([^)]*\)|const\s+{re.escape(name)}\s*=\s*\([^)]*\))(?P<body>[\s\S]{{0,12000}})", source)
15
+ return match.group(0) if match else ""
16
+
17
+
18
+ def validate(contract: object, sources: Iterable[str] = ()) -> dict:
19
+ errors: list[str] = []
20
+ if not isinstance(contract, dict):
21
+ return {"schemaVersion": SCHEMA, "passed": False, "errors": ["contract must be an object"]}
22
+ if contract.get("schemaVersion") != SCHEMA:
23
+ errors.append(f"schemaVersion must be {SCHEMA}")
24
+ shell = contract.get("shell")
25
+ if not isinstance(shell, dict):
26
+ errors.append("shell is required")
27
+ elif shell.get("reference") != "users-workspace" or shell.get("navigation") != "sidebar-plus-content-tabs":
28
+ errors.append("shell must declare the users-workspace reference and sidebar-plus-content-tabs navigation")
29
+ components = contract.get("components")
30
+ if not isinstance(components, list) or not components:
31
+ errors.append("components must be a non-empty list")
32
+ components = []
33
+ source = "\n".join(str(item) for item in sources)
34
+ for component in components:
35
+ if not isinstance(component, dict) or not component.get("name"):
36
+ errors.append("each component needs a name")
37
+ continue
38
+ name = str(component["name"])
39
+ component_source = _component_source(name, source)
40
+ if source and not component_source:
41
+ errors.append(f"component source is missing: {name}")
42
+ continue
43
+ props = [str(prop) for prop in component.get("props", [])]
44
+ forbidden = [str(prop) for prop in component.get("forbiddenProps", [])]
45
+ if component_source:
46
+ signature = component_source.split("=>", 1)[0] if "=>" in component_source else component_source[:500]
47
+ for prop in props:
48
+ if len(re.findall(rf"\b{re.escape(prop)}\b", component_source)) < 2:
49
+ errors.append(f"{name}.{prop} is declared but not evidenced in rendered output")
50
+ for prop in forbidden:
51
+ if re.search(rf"\b{re.escape(prop)}\b", signature):
52
+ errors.append(f"{name} declares forbidden dead prop: {prop}")
53
+ return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "components": [item.get("name") for item in components if isinstance(item, dict)]}
54
+
55
+
56
+ def load_and_validate(contract_path: Path, source_paths: Iterable[Path] = ()) -> dict:
57
+ import json
58
+ contract = json.loads(contract_path.read_text(encoding="utf-8"))
59
+ sources = [path.read_text(encoding="utf-8", errors="replace") for path in source_paths]
60
+ return validate(contract, sources)