@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 +21 -9
- package/bundled-contracts/maggiedash/README.md +16 -0
- package/bundled-contracts/maggiedash/redirect-policy-v1.json +15 -0
- package/bundled-contracts/maggiedash/telemetry-policy-v1.json +18 -0
- package/bundled-references/maggiedash-telemetry-contract.md +12 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -3
- package/bundled-skills/maggie-ops/SKILL.md +6 -2
- package/bundled-templates/maggiedash/dashboard-ui-contract.json +8 -7
- package/bundled-tools/clis/maggie_dash.py +74 -2
- package/bundled-tools/runtime/analytics_traffic.py +6 -1
- package/bundled-tools/runtime/maggie_dash_store.py +70 -8
- package/bundled-tools/runtime/maggie_dash_ui.py +35 -1
- package/package.json +1 -1
- package/references/maggiedash-telemetry-contract.md +12 -0
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.
|
|
195
|
-
npx @topy-ai/maggie@0.7.
|
|
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.
|
|
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
|
|
208
|
-
`preview`)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
118
|
-
content and requires a timezone
|
|
119
|
-
|
|
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
|
|
224
|
-
|
|
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": ["
|
|
16
|
+
"props": ["tabs", "active", "onSelect", "lead", "actions"],
|
|
17
17
|
"forbiddenProps": []
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"name": "WorkspaceCard",
|
|
21
|
-
"props": ["
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
124
|
-
|
|
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 {
|
|
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",
|
|
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
|
@@ -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.
|