@hraness/ghostget 0.17.2
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/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
package/src/model.ts
ADDED
|
@@ -0,0 +1,2671 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
import { isPrivateAddress, isPrivateHostname } from "@hraness/kb/clip/network";
|
|
4
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
5
|
+
import {
|
|
6
|
+
platformSurfaceIds,
|
|
7
|
+
semanticOperationNames,
|
|
8
|
+
socialPlatformCatalog,
|
|
9
|
+
textWeightPolicies,
|
|
10
|
+
weightedTextLength,
|
|
11
|
+
type AttachmentKind,
|
|
12
|
+
type CompositionName,
|
|
13
|
+
type PlatformSurfaceCatalogEntry,
|
|
14
|
+
type PlatformSurfaceId,
|
|
15
|
+
type SemanticOperationName,
|
|
16
|
+
} from "./platform-catalog";
|
|
17
|
+
import {
|
|
18
|
+
isProviderPluginOperationName,
|
|
19
|
+
isProviderPluginSurfaceId,
|
|
20
|
+
type ProviderPluginOperationName,
|
|
21
|
+
type ProviderPluginSurfaceId,
|
|
22
|
+
} from "./provider-plugin-identifiers";
|
|
23
|
+
import type {
|
|
24
|
+
ProviderPluginBindingV1,
|
|
25
|
+
ProviderPluginOperationV1,
|
|
26
|
+
} from "./provider-plugin";
|
|
27
|
+
import type { ProviderPluginRegistry } from "./provider-plugin-registry";
|
|
28
|
+
import { parseWebSessionTemplate, type WebSessionTemplate } from "./web-session-template";
|
|
29
|
+
import type { WebSessionSiteId } from "./web-session-sites";
|
|
30
|
+
import { DOM_ACTION_TRANSPORT_DISABLED_MESSAGE } from "./transport-policy";
|
|
31
|
+
|
|
32
|
+
export { canonicalJson, sha256 } from "./canonical-json";
|
|
33
|
+
export { DOM_ACTION_TRANSPORT_DISABLED_MESSAGE } from "./transport-policy";
|
|
34
|
+
export type { WebSessionSiteId } from "./web-session-sites";
|
|
35
|
+
|
|
36
|
+
export const GHOSTGET_MANIFEST_SCHEMA_VERSION = 2 as const;
|
|
37
|
+
export const GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION = 3 as const;
|
|
38
|
+
export const GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION = 4 as const;
|
|
39
|
+
export const GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION = 5 as const;
|
|
40
|
+
export const GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION = 6 as const;
|
|
41
|
+
export const GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION = 1 as const;
|
|
42
|
+
/** Canonical manifest hash of the sole schema-v1 LinkedIn migration fixture. */
|
|
43
|
+
export const GHOSTGET_LEGACY_LINKEDIN_MANIFEST_HASH = "bbdd1f8c1a532d621a367776770c968fd06cb7cf3b343d64ccda4b53690bb42f";
|
|
44
|
+
export const operationRisks = ["R1", "R2", "R3", "R4"] as const;
|
|
45
|
+
export type OperationRisk = (typeof operationRisks)[number];
|
|
46
|
+
export const idempotencyKinds = ["none", "local-at-most-once"] as const;
|
|
47
|
+
export type IdempotencyKind = (typeof idempotencyKinds)[number];
|
|
48
|
+
|
|
49
|
+
export type ScalarInputField = {
|
|
50
|
+
readonly type: "string" | "boolean" | "number";
|
|
51
|
+
readonly description: string;
|
|
52
|
+
readonly minLength?: number;
|
|
53
|
+
readonly maxLength?: number;
|
|
54
|
+
readonly minimum?: number;
|
|
55
|
+
readonly maximum?: number;
|
|
56
|
+
readonly enum?: readonly (string | number | boolean)[];
|
|
57
|
+
readonly format?: "url" | "path-segment";
|
|
58
|
+
readonly urlPathPrefixes?: readonly string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type FileInputField = {
|
|
62
|
+
readonly type: "file";
|
|
63
|
+
readonly description: string;
|
|
64
|
+
readonly maxBytes: number;
|
|
65
|
+
readonly mediaTypes?: readonly string[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ArrayInputField = {
|
|
69
|
+
readonly type: "array";
|
|
70
|
+
readonly description: string;
|
|
71
|
+
readonly items: ScalarInputField | FileInputField;
|
|
72
|
+
readonly minItems: number;
|
|
73
|
+
readonly maxItems: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type InputField = ScalarInputField | FileInputField | ArrayInputField;
|
|
77
|
+
|
|
78
|
+
export type FileInputValue = {
|
|
79
|
+
/** An opaque plan-bound reference. Only the runtime's injected resolver may materialize it. */
|
|
80
|
+
readonly kind: "file";
|
|
81
|
+
readonly reference: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type ScalarInputValue = string | number | boolean;
|
|
85
|
+
export type ArrayInputValue = readonly (ScalarInputValue | FileInputValue)[];
|
|
86
|
+
export type InputValue = ScalarInputValue | FileInputValue | ArrayInputValue;
|
|
87
|
+
export type OperationInput = Readonly<Record<string, InputValue>>;
|
|
88
|
+
|
|
89
|
+
export type InputSchema = {
|
|
90
|
+
readonly properties: Readonly<Record<string, InputField>>;
|
|
91
|
+
readonly required: readonly string[];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type SemanticLocator =
|
|
95
|
+
| { readonly by: "role"; readonly value: string; readonly name?: string; readonly exact?: boolean }
|
|
96
|
+
| { readonly by: "text" | "label" | "placeholder" | "alt" | "title" | "testid"; readonly value: string; readonly exact?: boolean };
|
|
97
|
+
|
|
98
|
+
export type ExactSemanticReferenceLocator = {
|
|
99
|
+
readonly by: "role";
|
|
100
|
+
readonly value: string;
|
|
101
|
+
readonly name: string;
|
|
102
|
+
readonly exact: true;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type BrowserValueSource =
|
|
106
|
+
| { readonly input: string }
|
|
107
|
+
| { readonly item: true };
|
|
108
|
+
|
|
109
|
+
export type BrowserStepEffect =
|
|
110
|
+
| { readonly kind: "prepare"; readonly description: string }
|
|
111
|
+
| { readonly kind: "dispatch"; readonly id: string; readonly description: string };
|
|
112
|
+
|
|
113
|
+
export type BrowserAssertion =
|
|
114
|
+
| { readonly kind: "assert-text"; readonly text: string }
|
|
115
|
+
| { readonly kind: "assert-url"; readonly pattern: string }
|
|
116
|
+
| { readonly kind: "assert-input-empty"; readonly locator: ExactSemanticReferenceLocator }
|
|
117
|
+
| { readonly kind: "assert-value"; readonly locator: ExactSemanticReferenceLocator; readonly equals: BrowserValueSource }
|
|
118
|
+
| { readonly kind: "assert-checked"; readonly locator: ExactSemanticReferenceLocator; readonly checked: boolean };
|
|
119
|
+
|
|
120
|
+
export type BrowserRecipeStep =
|
|
121
|
+
| { readonly kind: "navigate"; readonly path: string; readonly query?: Readonly<Record<string, string>> }
|
|
122
|
+
| { readonly kind: "navigate-input"; readonly input: string }
|
|
123
|
+
| {
|
|
124
|
+
readonly kind: "find";
|
|
125
|
+
readonly locator: SemanticLocator;
|
|
126
|
+
readonly action: "click" | "fill" | "type" | "hover" | "upload" | "select" | "check" | "uncheck";
|
|
127
|
+
readonly with?: string | BrowserValueSource;
|
|
128
|
+
readonly effect?: BrowserStepEffect;
|
|
129
|
+
/** v1 compatibility only; v2 manifests use effect. */
|
|
130
|
+
readonly dispatch?: boolean;
|
|
131
|
+
}
|
|
132
|
+
| { readonly kind: "press"; readonly key: string; readonly effect?: BrowserStepEffect; readonly dispatch?: boolean }
|
|
133
|
+
| { readonly kind: "wait"; readonly milliseconds: number }
|
|
134
|
+
| { readonly kind: "wait-text"; readonly text: string }
|
|
135
|
+
| { readonly kind: "snapshot"; readonly interactive?: boolean }
|
|
136
|
+
| { readonly kind: "read" }
|
|
137
|
+
| { readonly kind: "assert-text"; readonly text: string }
|
|
138
|
+
| { readonly kind: "assert-url"; readonly pattern: string }
|
|
139
|
+
| {
|
|
140
|
+
readonly kind: "assert-input-empty";
|
|
141
|
+
readonly locator: ExactSemanticReferenceLocator & { readonly value: "textbox" };
|
|
142
|
+
}
|
|
143
|
+
| { readonly kind: "assert-value"; readonly locator: ExactSemanticReferenceLocator; readonly equals: BrowserValueSource }
|
|
144
|
+
| { readonly kind: "assert-checked"; readonly locator: ExactSemanticReferenceLocator; readonly checked: boolean }
|
|
145
|
+
| { readonly kind: "verify-dispatch"; readonly dispatch: string; readonly assertions: readonly BrowserAssertion[] }
|
|
146
|
+
| {
|
|
147
|
+
readonly kind: "for-each";
|
|
148
|
+
readonly input: string;
|
|
149
|
+
readonly steps: readonly BrowserRecipeStep[];
|
|
150
|
+
readonly between?: readonly BrowserRecipeStep[];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type BrowserDispatchPlan = {
|
|
154
|
+
readonly id: string;
|
|
155
|
+
readonly description: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export type ExpandedBrowserRecipeStep = {
|
|
159
|
+
readonly step: Exclude<BrowserRecipeStep, { readonly kind: "for-each" }>;
|
|
160
|
+
readonly item?: ScalarInputValue | FileInputValue;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type ExpandedBrowserRecipe = Omit<BrowserRecipe, "steps"> & {
|
|
164
|
+
readonly steps: readonly ExpandedBrowserRecipeStep[];
|
|
165
|
+
readonly dispatches: readonly BrowserDispatchPlan[];
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type BrowserRecipe = {
|
|
169
|
+
readonly steps: readonly BrowserRecipeStep[];
|
|
170
|
+
readonly timeoutMs: number;
|
|
171
|
+
readonly maxOutputBytes: number;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export type OfficialProviderId = ProviderPluginSurfaceId;
|
|
175
|
+
export type BundledOfficialProviderId = OfficialProviderId;
|
|
176
|
+
|
|
177
|
+
export type ProviderRecipe = {
|
|
178
|
+
/** Names code-owned endpoint logic; manifests cannot supply methods or URLs. */
|
|
179
|
+
readonly provider: OfficialProviderId;
|
|
180
|
+
readonly action: ProviderPluginOperationName;
|
|
181
|
+
/** Must match the code-owned registry revision bound into confirmation plans. */
|
|
182
|
+
readonly contractVersion: number;
|
|
183
|
+
readonly timeoutMs: number;
|
|
184
|
+
readonly maxOutputBytes: number;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export type WebSessionRecipe = {
|
|
188
|
+
/** Names a code-owned first-party web contract; no arbitrary request surface is exposed. */
|
|
189
|
+
readonly site: WebSessionSiteId;
|
|
190
|
+
readonly action: ProviderPluginOperationName;
|
|
191
|
+
readonly contractVersion: number;
|
|
192
|
+
readonly timeoutMs: number;
|
|
193
|
+
readonly maxOutputBytes: number;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export type LocalCliRecipe = {
|
|
197
|
+
/** Names one code-owned provider surface; the exact tool identity lives on its binding. */
|
|
198
|
+
readonly surface: OfficialProviderId;
|
|
199
|
+
readonly action: ProviderPluginOperationName;
|
|
200
|
+
readonly contractVersion: number;
|
|
201
|
+
readonly timeoutMs: number;
|
|
202
|
+
readonly maxOutputBytes: number;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export type ReviewedTemplateRecipe =
|
|
206
|
+
| {
|
|
207
|
+
readonly state: "capture-required";
|
|
208
|
+
readonly contractVersion: 1;
|
|
209
|
+
/** Non-executable reviewer guidance only; never populated from raw request values. */
|
|
210
|
+
readonly instructions: string;
|
|
211
|
+
}
|
|
212
|
+
| {
|
|
213
|
+
/** Reserved for migration tests; schema-v5 manifests reject executable v1 templates. */
|
|
214
|
+
readonly state: "reviewed";
|
|
215
|
+
readonly contractVersion: 1;
|
|
216
|
+
readonly reviewedAt: string;
|
|
217
|
+
/** Binds the review record to a secret-free evidence or review artifact. */
|
|
218
|
+
readonly evidenceSha256: string;
|
|
219
|
+
readonly timeoutMs: number;
|
|
220
|
+
readonly template: WebSessionTemplate;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type GhostgetOperationCommon = {
|
|
224
|
+
readonly description: string;
|
|
225
|
+
readonly risk: OperationRisk;
|
|
226
|
+
readonly sideEffect: string;
|
|
227
|
+
readonly idempotency: IdempotencyKind;
|
|
228
|
+
readonly dedupeWindowMs: number;
|
|
229
|
+
readonly input: InputSchema;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export type BrowserGhostgetOperation = GhostgetOperationCommon & {
|
|
233
|
+
readonly browser: BrowserRecipe;
|
|
234
|
+
readonly provider?: never;
|
|
235
|
+
readonly webSession?: never;
|
|
236
|
+
readonly reviewedTemplate?: never;
|
|
237
|
+
readonly localCli?: never;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export type ProviderGhostgetOperation = GhostgetOperationCommon & {
|
|
241
|
+
readonly browser?: never;
|
|
242
|
+
readonly provider: ProviderRecipe;
|
|
243
|
+
readonly webSession?: never;
|
|
244
|
+
readonly reviewedTemplate?: never;
|
|
245
|
+
readonly localCli?: never;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export type WebSessionGhostgetOperation = GhostgetOperationCommon & {
|
|
249
|
+
readonly browser?: never;
|
|
250
|
+
readonly provider?: never;
|
|
251
|
+
readonly webSession: WebSessionRecipe;
|
|
252
|
+
readonly reviewedTemplate?: never;
|
|
253
|
+
readonly localCli?: never;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export type ReviewedTemplateGhostgetOperation = GhostgetOperationCommon & {
|
|
257
|
+
readonly browser?: never;
|
|
258
|
+
readonly provider?: never;
|
|
259
|
+
readonly webSession?: never;
|
|
260
|
+
readonly reviewedTemplate: ReviewedTemplateRecipe;
|
|
261
|
+
readonly localCli?: never;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export type LocalCliGhostgetOperation = GhostgetOperationCommon & {
|
|
265
|
+
readonly browser?: never;
|
|
266
|
+
readonly provider?: never;
|
|
267
|
+
readonly webSession?: never;
|
|
268
|
+
readonly reviewedTemplate?: never;
|
|
269
|
+
readonly localCli: LocalCliRecipe;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export type GhostgetOperation =
|
|
273
|
+
| BrowserGhostgetOperation
|
|
274
|
+
| ProviderGhostgetOperation
|
|
275
|
+
| WebSessionGhostgetOperation
|
|
276
|
+
| ReviewedTemplateGhostgetOperation
|
|
277
|
+
| LocalCliGhostgetOperation;
|
|
278
|
+
|
|
279
|
+
export function isProviderOperation(operation: GhostgetOperation): operation is ProviderGhostgetOperation {
|
|
280
|
+
return operation.provider !== undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function isWebSessionOperation(operation: GhostgetOperation): operation is WebSessionGhostgetOperation {
|
|
284
|
+
return operation.webSession !== undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function isReviewedTemplateOperation(operation: GhostgetOperation): operation is ReviewedTemplateGhostgetOperation {
|
|
288
|
+
return operation.reviewedTemplate !== undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function isLocalCliOperation(
|
|
292
|
+
operation: GhostgetOperation,
|
|
293
|
+
): operation is LocalCliGhostgetOperation {
|
|
294
|
+
return operation.localCli !== undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function isBrowserOperation(operation: GhostgetOperation): operation is BrowserGhostgetOperation {
|
|
298
|
+
return operation.browser !== undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export type GhostgetManifest = {
|
|
302
|
+
readonly schemaVersion:
|
|
303
|
+
| typeof GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
304
|
+
| typeof GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION
|
|
305
|
+
| typeof GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION
|
|
306
|
+
| typeof GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION
|
|
307
|
+
| typeof GHOSTGET_MANIFEST_SCHEMA_VERSION
|
|
308
|
+
| typeof GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION;
|
|
309
|
+
readonly id: string;
|
|
310
|
+
readonly version: string;
|
|
311
|
+
readonly displayName: string;
|
|
312
|
+
/** Binds a schema-v2 adapter to wrench's reviewed policy for a known surface. */
|
|
313
|
+
readonly surfaceId?: ProviderPluginSurfaceId;
|
|
314
|
+
readonly origins: readonly string[];
|
|
315
|
+
readonly browserDomains: readonly string[];
|
|
316
|
+
readonly operations: Readonly<Record<string, GhostgetOperation>>;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
export type ParseResult<T> =
|
|
320
|
+
| { readonly ok: true; readonly value: T }
|
|
321
|
+
| { readonly ok: false; readonly issues: readonly string[] };
|
|
322
|
+
|
|
323
|
+
const operationCompositions: Readonly<Partial<Record<SemanticOperationName, CompositionName>>> = {
|
|
324
|
+
"messaging.send": "message",
|
|
325
|
+
"comments.create": "comment",
|
|
326
|
+
"replies.create": "reply",
|
|
327
|
+
"posts.publish": "post",
|
|
328
|
+
"media.publish": "media",
|
|
329
|
+
"articles.draft.save": "article",
|
|
330
|
+
"articles.publish": "article",
|
|
331
|
+
"listings.publish": "listing",
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const genericSemanticRisks = {
|
|
335
|
+
"content.read": "R1",
|
|
336
|
+
"content.clip": "R1",
|
|
337
|
+
"profiles.read": "R1",
|
|
338
|
+
"organizations.read": "R1",
|
|
339
|
+
"contacts.list": "R1",
|
|
340
|
+
"contacts.search": "R1",
|
|
341
|
+
"contacts.read": "R1",
|
|
342
|
+
"feeds.read": "R1",
|
|
343
|
+
"messaging.list": "R1",
|
|
344
|
+
"messaging.search": "R1",
|
|
345
|
+
"messaging.read": "R1",
|
|
346
|
+
"messaging.send": "R3",
|
|
347
|
+
"comments.read": "R1",
|
|
348
|
+
"comments.create": "R3",
|
|
349
|
+
"replies.create": "R3",
|
|
350
|
+
"posts.read": "R1",
|
|
351
|
+
"posts.publish": "R3",
|
|
352
|
+
"threads.publish": "R3",
|
|
353
|
+
"reactions.set": "R2",
|
|
354
|
+
"likes.set": "R2",
|
|
355
|
+
"media.read": "R1",
|
|
356
|
+
"media.publish": "R3",
|
|
357
|
+
"articles.read": "R1",
|
|
358
|
+
"articles.draft.save": "R2",
|
|
359
|
+
"articles.publish": "R3",
|
|
360
|
+
"listings.read": "R1",
|
|
361
|
+
"listings.publish": "R3",
|
|
362
|
+
"relationships.follow.set": "R2",
|
|
363
|
+
"relationships.recommendations.read": "R1",
|
|
364
|
+
"relationships.connect": "R3",
|
|
365
|
+
"posts.repost": "R3",
|
|
366
|
+
"posts.quote": "R3",
|
|
367
|
+
"content.share": "R3",
|
|
368
|
+
"content.save": "R2",
|
|
369
|
+
"content.edit": "R3",
|
|
370
|
+
"content.delete": "R4",
|
|
371
|
+
"content.schedule": "R3",
|
|
372
|
+
"content.audience.set": "R4",
|
|
373
|
+
"communities.membership.set": "R2",
|
|
374
|
+
"communities.membership.manage": "R4",
|
|
375
|
+
"administration.manage": "R4",
|
|
376
|
+
"commerce.purchase": "R4",
|
|
377
|
+
"account.delete": "R4",
|
|
378
|
+
"moderation.bulk": "R4",
|
|
379
|
+
} as const satisfies Readonly<Record<SemanticOperationName, OperationRisk>>;
|
|
380
|
+
|
|
381
|
+
export function isReviewedTemplateProtectedHostname(
|
|
382
|
+
hostnameOrPattern: string,
|
|
383
|
+
registry: ProviderPluginRegistry,
|
|
384
|
+
): boolean {
|
|
385
|
+
const normalized = hostnameOrPattern.toLowerCase().replace(/\.$/u, "");
|
|
386
|
+
const wildcardSuffix = normalized.startsWith("*.") ? normalized.slice(2) : null;
|
|
387
|
+
const registeredFamilies = registry.list().flatMap((plugin) =>
|
|
388
|
+
plugin.bindings.flatMap((binding) => binding.protectedHostnameFamilies));
|
|
389
|
+
return registeredFamilies.some((family) => {
|
|
390
|
+
if (wildcardSuffix === null) {
|
|
391
|
+
return normalized === family || normalized.endsWith(`.${family}`);
|
|
392
|
+
}
|
|
393
|
+
return wildcardSuffix === family
|
|
394
|
+
|| wildcardSuffix.endsWith(`.${family}`)
|
|
395
|
+
|| family.endsWith(`.${wildcardSuffix}`);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function hasCodeOwnedPluginSurface(
|
|
400
|
+
surfaceId: string,
|
|
401
|
+
registry: ProviderPluginRegistry,
|
|
402
|
+
): boolean {
|
|
403
|
+
return registry.resolveRoute("provider-api", surfaceId) !== undefined
|
|
404
|
+
|| registry.resolveRoute("local-cli", surfaceId) !== undefined
|
|
405
|
+
|| registry.resolveSessionRoute(surfaceId) !== undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function mediaTypeKinds(mediaType: string): readonly AttachmentKind[] {
|
|
409
|
+
if (mediaType === "image/gif") return ["image", "gif"];
|
|
410
|
+
if (mediaType === "image/*" || mediaType.startsWith("image/")) return ["image"];
|
|
411
|
+
if (mediaType === "video/*" || mediaType.startsWith("video/")) return ["video"];
|
|
412
|
+
if (mediaType === "audio/*" || mediaType.startsWith("audio/")) return ["audio"];
|
|
413
|
+
return ["document"];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function validatePlatformCompositionSchema(
|
|
417
|
+
surfaceId: PlatformSurfaceId,
|
|
418
|
+
operationId: SemanticOperationName,
|
|
419
|
+
operation: GhostgetOperation,
|
|
420
|
+
path: string,
|
|
421
|
+
issues: string[],
|
|
422
|
+
): void {
|
|
423
|
+
const compositionName = operationCompositions[operationId];
|
|
424
|
+
if (compositionName === undefined) return;
|
|
425
|
+
const surface = socialPlatformCatalog[surfaceId] as PlatformSurfaceCatalogEntry;
|
|
426
|
+
const policy = surface.compositions[compositionName];
|
|
427
|
+
if (policy === undefined) {
|
|
428
|
+
issues.push(`${path} has no reviewed ${compositionName} composition policy on ${surfaceId}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const required = new Set(operation.input.required);
|
|
432
|
+
for (const text of policy.text) {
|
|
433
|
+
const field = operation.input.properties[text.name];
|
|
434
|
+
if (field === undefined) {
|
|
435
|
+
if (text.required) issues.push(`${path}.input.properties.${text.name} is required by the reviewed ${surfaceId} ${compositionName} policy`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (field.type !== "string") {
|
|
439
|
+
issues.push(`${path}.input.properties.${text.name} must be a string under the reviewed composition policy`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (field.maxLength === undefined || field.maxLength > text.safeMaxUnits) {
|
|
443
|
+
issues.push(`${path}.input.properties.${text.name}.maxLength must be at most ${text.safeMaxUnits}`);
|
|
444
|
+
}
|
|
445
|
+
if (
|
|
446
|
+
text.format === "currency-code"
|
|
447
|
+
&& (field.minLength !== 3 || field.maxLength !== 3)
|
|
448
|
+
) {
|
|
449
|
+
issues.push(`${path}.input.properties.${text.name} must allow exactly one three-letter currency code`);
|
|
450
|
+
}
|
|
451
|
+
if (text.required && (field.minLength === undefined || field.minLength < 1)) {
|
|
452
|
+
issues.push(`${path}.input.properties.${text.name}.minLength must be at least 1`);
|
|
453
|
+
}
|
|
454
|
+
if (
|
|
455
|
+
text.format === "provider-option"
|
|
456
|
+
&& (field.enum === undefined || field.enum.length < 1 || field.enum.some((value) => typeof value !== "string"))
|
|
457
|
+
) {
|
|
458
|
+
issues.push(`${path}.input.properties.${text.name}.enum must declare the adapter's reviewed provider options`);
|
|
459
|
+
}
|
|
460
|
+
if (text.required && !required.has(text.name)) {
|
|
461
|
+
issues.push(`${path}.input.required must include ${text.name}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const attachmentPolicy = policy.attachments;
|
|
466
|
+
if (attachmentPolicy.state === "none") {
|
|
467
|
+
if (Object.values(operation.input.properties).some((field) =>
|
|
468
|
+
field.type === "file" || (field.type === "array" && field.items.type === "file"))) {
|
|
469
|
+
issues.push(`${path}.input cannot declare file attachments for ${surfaceId} ${compositionName}`);
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
let maximumFiles = 0;
|
|
474
|
+
let minimumRequiredFiles = 0;
|
|
475
|
+
for (const [name, field] of Object.entries(operation.input.properties)) {
|
|
476
|
+
let file: FileInputField;
|
|
477
|
+
if (field.type === "file") file = field;
|
|
478
|
+
else if (field.type === "array" && field.items.type === "file") file = field.items;
|
|
479
|
+
else continue;
|
|
480
|
+
maximumFiles += field.type === "file" ? 1 : field.maxItems;
|
|
481
|
+
if (required.has(name)) minimumRequiredFiles += field.type === "file" ? 1 : field.minItems;
|
|
482
|
+
if (file.mediaTypes === undefined) {
|
|
483
|
+
issues.push(`${path}.input.properties.${name} must declare mediaTypes under a platform attachment policy`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
for (const mediaType of file.mediaTypes) {
|
|
487
|
+
if (!mediaTypeKinds(mediaType).some((kind) => attachmentPolicy.kinds.includes(kind))) {
|
|
488
|
+
issues.push(`${path}.input.properties.${name}.mediaTypes includes ${mediaType}, outside the reviewed attachment kinds`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (maximumFiles > attachmentPolicy.maxItems) {
|
|
493
|
+
issues.push(`${path}.input can accept at most ${attachmentPolicy.maxItems} binary attachment(s)`);
|
|
494
|
+
}
|
|
495
|
+
if (minimumRequiredFiles < attachmentPolicy.minItems) {
|
|
496
|
+
issues.push(`${path}.input must require at least ${attachmentPolicy.minItems} binary attachment(s)`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function threadTextPolicy(surfaceId: PlatformSurfaceId): {
|
|
501
|
+
readonly maxItems: number;
|
|
502
|
+
readonly maxWeightedLength: number;
|
|
503
|
+
readonly measurement: keyof typeof textWeightPolicies;
|
|
504
|
+
} | null {
|
|
505
|
+
const surface = socialPlatformCatalog[surfaceId] as PlatformSurfaceCatalogEntry;
|
|
506
|
+
if (surface.threads.publish.state !== "adapter-eligible") return null;
|
|
507
|
+
const root = surface.compositions.post?.text.find((field) => field.name === "body");
|
|
508
|
+
const continuation = surface.compositions.reply?.text.find((field) => field.name === "body");
|
|
509
|
+
if (root === undefined || continuation === undefined || root.measurement !== continuation.measurement) return null;
|
|
510
|
+
return {
|
|
511
|
+
maxItems: surface.threads.publish.safeMaxItems,
|
|
512
|
+
maxWeightedLength: Math.min(root.safeMaxUnits, continuation.safeMaxUnits),
|
|
513
|
+
measurement: root.measurement,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function validatePlatformThreadSchema(
|
|
518
|
+
surfaceId: PlatformSurfaceId,
|
|
519
|
+
operation: GhostgetOperation,
|
|
520
|
+
path: string,
|
|
521
|
+
issues: string[],
|
|
522
|
+
): void {
|
|
523
|
+
const policy = threadTextPolicy(surfaceId);
|
|
524
|
+
if (policy === null) {
|
|
525
|
+
issues.push(`${path} has no internally consistent reviewed thread composition policy`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const items = operation.input.properties.items;
|
|
529
|
+
if (items?.type !== "array" || items.items.type !== "string") {
|
|
530
|
+
issues.push(`${path}.input.properties.items must be an array of thread text strings`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (!operation.input.required.includes("items") || items.minItems < 1 || items.maxItems > policy.maxItems) {
|
|
534
|
+
issues.push(`${path}.input.items must be required with 1-${policy.maxItems} items`);
|
|
535
|
+
}
|
|
536
|
+
if (items.items.minLength === undefined || items.items.minLength < 1 || items.items.maxLength === undefined || items.items.maxLength > policy.maxWeightedLength) {
|
|
537
|
+
issues.push(`${path}.input.properties.items.items must use length bounds 1-${policy.maxWeightedLength}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function validateGenericThreadSchema(operation: GhostgetOperation, path: string, issues: string[]): void {
|
|
542
|
+
const items = operation.input.properties.items;
|
|
543
|
+
if (items?.type !== "array" || items.items.type !== "string") {
|
|
544
|
+
issues.push(`${path}.input.properties.items must be an array of thread text strings`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (!operation.input.required.includes("items") || items.minItems < 1 || items.maxItems > 25) {
|
|
548
|
+
issues.push(`${path}.input.items must be required with 1-25 items`);
|
|
549
|
+
}
|
|
550
|
+
if (items.items.minLength === undefined || items.items.minLength < 1 || items.items.maxLength === undefined) {
|
|
551
|
+
issues.push(`${path}.input.properties.items.items must declare non-empty bounded strings`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
556
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
557
|
+
|
|
558
|
+
function isPlatformSurfaceId(value: string): value is PlatformSurfaceId {
|
|
559
|
+
return platformSurfaceIds.includes(value as PlatformSurfaceId);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function hasUnpairedSurrogate(value: string): boolean {
|
|
563
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
564
|
+
const code = value.charCodeAt(index);
|
|
565
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
566
|
+
const next = value.charCodeAt(index + 1);
|
|
567
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
568
|
+
index += 1;
|
|
569
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) return true;
|
|
570
|
+
}
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function exactKeys(value: Record<string, unknown>, keys: readonly string[], path: string, issues: string[]): void {
|
|
575
|
+
const allowed = new Set(keys);
|
|
576
|
+
for (const key of Object.keys(value)) {
|
|
577
|
+
if (!allowed.has(key)) issues.push(`${path}.${key} is not supported`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function boundedString(
|
|
582
|
+
value: unknown,
|
|
583
|
+
path: string,
|
|
584
|
+
issues: string[],
|
|
585
|
+
minimum = 1,
|
|
586
|
+
maximum = 4_096,
|
|
587
|
+
): string | null {
|
|
588
|
+
if (typeof value !== "string" || value.length < minimum || value.length > maximum) {
|
|
589
|
+
issues.push(`${path} must be a ${minimum}-${maximum} character string without control characters`);
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
593
|
+
const code = value.charCodeAt(index);
|
|
594
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
595
|
+
issues.push(`${path} must be a ${minimum}-${maximum} character string without control characters`);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (hasUnpairedSurrogate(value)) {
|
|
600
|
+
issues.push(`${path} must contain well-formed Unicode`);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function safeInteger(value: unknown, path: string, minimum: number, maximum: number, issues: string[]): number | null {
|
|
607
|
+
if (!Number.isSafeInteger(value) || typeof value !== "number" || value < minimum || value > maximum) {
|
|
608
|
+
issues.push(`${path} must be an integer between ${minimum} and ${maximum}`);
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
return value;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function hasAmbiguousPathSyntax(value: string): boolean {
|
|
615
|
+
return value.includes("\\")
|
|
616
|
+
|| /%(?:25|2e|2f|5c)/iu.test(value)
|
|
617
|
+
|| value.split("/").some((segment) => segment === "." || segment === "..");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function matchesUrlPathPrefix(pathname: string, prefix: string): boolean {
|
|
621
|
+
return prefix.endsWith("/")
|
|
622
|
+
? pathname.startsWith(prefix)
|
|
623
|
+
: pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function rawUrlPath(value: string): string {
|
|
627
|
+
const authority = value.indexOf("://");
|
|
628
|
+
const start = authority < 0 ? 0 : value.indexOf("/", authority + 3);
|
|
629
|
+
if (start < 0) return "/";
|
|
630
|
+
const end = value.search(/[?#]/u);
|
|
631
|
+
return value.slice(start, end >= start ? end : undefined);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function parseField(
|
|
635
|
+
value: unknown,
|
|
636
|
+
path: string,
|
|
637
|
+
issues: string[],
|
|
638
|
+
maximumArrayItems: 25 | 100,
|
|
639
|
+
nested = false,
|
|
640
|
+
): InputField | null {
|
|
641
|
+
if (!isRecord(value)) {
|
|
642
|
+
issues.push(`${path} must be an object`);
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
if (value.type === "file") {
|
|
646
|
+
exactKeys(value, ["type", "description", "maxBytes", "mediaTypes"], path, issues);
|
|
647
|
+
const description = boundedString(value.description, `${path}.description`, issues, 1, 500);
|
|
648
|
+
const maxBytes = safeInteger(value.maxBytes, `${path}.maxBytes`, 1, 1024 * 1024 * 1024, issues);
|
|
649
|
+
let mediaTypes: readonly string[] | undefined;
|
|
650
|
+
if (value.mediaTypes !== undefined) {
|
|
651
|
+
if (
|
|
652
|
+
!Array.isArray(value.mediaTypes)
|
|
653
|
+
|| value.mediaTypes.length < 1
|
|
654
|
+
|| value.mediaTypes.length > 32
|
|
655
|
+
|| value.mediaTypes.some((candidate) => typeof candidate !== "string" || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+*-]+$/u.test(candidate))
|
|
656
|
+
) {
|
|
657
|
+
issues.push(`${path}.mediaTypes must contain 1-32 valid media types`);
|
|
658
|
+
} else mediaTypes = [...new Set(value.mediaTypes as string[])];
|
|
659
|
+
}
|
|
660
|
+
if (description === null || maxBytes === null) return null;
|
|
661
|
+
return { type: "file", description, maxBytes, ...(mediaTypes === undefined ? {} : { mediaTypes }) };
|
|
662
|
+
}
|
|
663
|
+
if (value.type === "array") {
|
|
664
|
+
exactKeys(value, ["type", "description", "items", "minItems", "maxItems"], path, issues);
|
|
665
|
+
const description = boundedString(value.description, `${path}.description`, issues, 1, 500);
|
|
666
|
+
const minItems = safeInteger(value.minItems, `${path}.minItems`, 0, maximumArrayItems, issues);
|
|
667
|
+
const maxItems = safeInteger(value.maxItems, `${path}.maxItems`, 1, maximumArrayItems, issues);
|
|
668
|
+
if (nested) issues.push(`${path} cannot contain a nested array`);
|
|
669
|
+
const items = nested ? null : parseField(value.items, `${path}.items`, issues, maximumArrayItems, true);
|
|
670
|
+
if (items?.type === "array") issues.push(`${path}.items cannot be an array`);
|
|
671
|
+
if (minItems !== null && maxItems !== null && minItems > maxItems) {
|
|
672
|
+
issues.push(`${path}.minItems cannot exceed maxItems`);
|
|
673
|
+
}
|
|
674
|
+
if (description === null || minItems === null || maxItems === null || items === null || items.type === "array") return null;
|
|
675
|
+
return { type: "array", description, items, minItems, maxItems };
|
|
676
|
+
}
|
|
677
|
+
exactKeys(value, ["type", "description", "minLength", "maxLength", "minimum", "maximum", "enum", "format", "urlPathPrefixes"], path, issues);
|
|
678
|
+
const type = value.type;
|
|
679
|
+
if (type !== "string" && type !== "boolean" && type !== "number") {
|
|
680
|
+
issues.push(`${path}.type must be string, boolean, number, file, or array`);
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
const description = boundedString(value.description, `${path}.description`, issues, 1, 500);
|
|
684
|
+
if (description === null) return null;
|
|
685
|
+
const minLength = value.minLength === undefined ? undefined : safeInteger(value.minLength, `${path}.minLength`, 0, 1_000_000, issues) ?? undefined;
|
|
686
|
+
const maxLength = value.maxLength === undefined ? undefined : safeInteger(value.maxLength, `${path}.maxLength`, 1, 1_000_000, issues) ?? undefined;
|
|
687
|
+
const minimum = value.minimum;
|
|
688
|
+
const maximum = value.maximum;
|
|
689
|
+
if (minimum !== undefined && (typeof minimum !== "number" || !Number.isFinite(minimum))) {
|
|
690
|
+
issues.push(`${path}.minimum must be a finite number`);
|
|
691
|
+
}
|
|
692
|
+
if (maximum !== undefined && (typeof maximum !== "number" || !Number.isFinite(maximum))) {
|
|
693
|
+
issues.push(`${path}.maximum must be a finite number`);
|
|
694
|
+
}
|
|
695
|
+
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
|
|
696
|
+
issues.push(`${path}.minLength cannot exceed maxLength`);
|
|
697
|
+
}
|
|
698
|
+
if (typeof minimum === "number" && typeof maximum === "number" && minimum > maximum) {
|
|
699
|
+
issues.push(`${path}.minimum cannot exceed maximum`);
|
|
700
|
+
}
|
|
701
|
+
let enumValues: readonly (string | number | boolean)[] | undefined;
|
|
702
|
+
if (value.enum !== undefined) {
|
|
703
|
+
if (
|
|
704
|
+
!Array.isArray(value.enum)
|
|
705
|
+
|| value.enum.length < 1
|
|
706
|
+
|| value.enum.length > 256
|
|
707
|
+
|| value.enum.some((candidate) => typeof candidate !== type)
|
|
708
|
+
) {
|
|
709
|
+
issues.push(`${path}.enum must contain 1-256 values matching the field type`);
|
|
710
|
+
} else {
|
|
711
|
+
enumValues = value.enum as readonly (string | number | boolean)[];
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const format = value.format;
|
|
715
|
+
if (format !== undefined && ((format !== "url" && format !== "path-segment") || type !== "string")) {
|
|
716
|
+
issues.push(`${path}.format supports url or path-segment on string fields`);
|
|
717
|
+
}
|
|
718
|
+
let urlPathPrefixes: readonly string[] | undefined;
|
|
719
|
+
if (value.urlPathPrefixes !== undefined) {
|
|
720
|
+
if (format !== "url" || type !== "string" || !Array.isArray(value.urlPathPrefixes) || value.urlPathPrefixes.length < 1 || value.urlPathPrefixes.length > 20) {
|
|
721
|
+
issues.push(`${path}.urlPathPrefixes requires a url string field and 1-20 origin-relative prefixes`);
|
|
722
|
+
} else {
|
|
723
|
+
const parsed: string[] = [];
|
|
724
|
+
for (const [index, candidate] of value.urlPathPrefixes.entries()) {
|
|
725
|
+
const prefix = boundedString(candidate, `${path}.urlPathPrefixes[${index}]`, issues, 1, 2_048);
|
|
726
|
+
if (prefix === null || !prefix.startsWith("/") || prefix.startsWith("//") || hasAmbiguousPathSyntax(prefix) || prefix.includes("?") || prefix.includes("#")) {
|
|
727
|
+
issues.push(`${path}.urlPathPrefixes[${index}] must be an origin-relative path prefix`);
|
|
728
|
+
} else if (!parsed.includes(prefix)) parsed.push(prefix);
|
|
729
|
+
}
|
|
730
|
+
if (parsed.length > 0) urlPathPrefixes = parsed;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
type,
|
|
735
|
+
description,
|
|
736
|
+
...(minLength === undefined ? {} : { minLength }),
|
|
737
|
+
...(maxLength === undefined ? {} : { maxLength }),
|
|
738
|
+
...(typeof minimum !== "number" ? {} : { minimum }),
|
|
739
|
+
...(typeof maximum !== "number" ? {} : { maximum }),
|
|
740
|
+
...(enumValues === undefined ? {} : { enum: enumValues }),
|
|
741
|
+
...(format !== "url" && format !== "path-segment" ? {} : { format }),
|
|
742
|
+
...(urlPathPrefixes === undefined ? {} : { urlPathPrefixes }),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function parseInputSchema(
|
|
747
|
+
value: unknown,
|
|
748
|
+
path: string,
|
|
749
|
+
issues: string[],
|
|
750
|
+
maximumArrayItems: 25 | 100,
|
|
751
|
+
): InputSchema | null {
|
|
752
|
+
if (!isRecord(value)) {
|
|
753
|
+
issues.push(`${path} must be an object`);
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
exactKeys(value, ["properties", "required"], path, issues);
|
|
757
|
+
if (!isRecord(value.properties) || Object.keys(value.properties).length > 100) {
|
|
758
|
+
issues.push(`${path}.properties must be an object with at most 100 fields`);
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const properties: Record<string, InputField> = {};
|
|
762
|
+
for (const [name, field] of Object.entries(value.properties)) {
|
|
763
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/u.test(name)) {
|
|
764
|
+
issues.push(`${path}.properties.${name} has an invalid field name`);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const parsed = parseField(field, `${path}.properties.${name}`, issues, maximumArrayItems);
|
|
768
|
+
if (parsed !== null) properties[name] = parsed;
|
|
769
|
+
}
|
|
770
|
+
if (!Array.isArray(value.required) || value.required.length > 100 || value.required.some((name) => typeof name !== "string")) {
|
|
771
|
+
issues.push(`${path}.required must be an array of at most 100 field names`);
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
const required = [...new Set(value.required as string[])];
|
|
775
|
+
for (const name of required) {
|
|
776
|
+
if (!(name in properties)) issues.push(`${path}.required references unknown field ${name}`);
|
|
777
|
+
}
|
|
778
|
+
return { properties, required };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function parseLocator(value: unknown, path: string, issues: string[]): SemanticLocator | null {
|
|
782
|
+
if (!isRecord(value)) {
|
|
783
|
+
issues.push(`${path} must be an object`);
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
exactKeys(value, ["by", "value", "name", "exact"], path, issues);
|
|
787
|
+
const by = value.by;
|
|
788
|
+
const supported = ["role", "text", "label", "placeholder", "alt", "title", "testid"];
|
|
789
|
+
if (typeof by !== "string" || !supported.includes(by)) {
|
|
790
|
+
issues.push(`${path}.by must be a supported semantic locator`);
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
const locatorValue = boundedString(value.value, `${path}.value`, issues, 1, 1_000);
|
|
794
|
+
if (locatorValue === null) return null;
|
|
795
|
+
if (value.exact !== undefined && typeof value.exact !== "boolean") issues.push(`${path}.exact must be boolean`);
|
|
796
|
+
if (by === "role") {
|
|
797
|
+
const name = value.name === undefined ? undefined : boundedString(value.name, `${path}.name`, issues, 1, 1_000) ?? undefined;
|
|
798
|
+
return { by, value: locatorValue, ...(name === undefined ? {} : { name }), ...(value.exact === true ? { exact: true } : {}) };
|
|
799
|
+
}
|
|
800
|
+
if (value.name !== undefined) issues.push(`${path}.name is valid only for role locators`);
|
|
801
|
+
if (by === "text" || by === "label" || by === "placeholder" || by === "alt" || by === "title" || by === "testid") {
|
|
802
|
+
return { by, value: locatorValue, ...(value.exact === true ? { exact: true } : {}) };
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function exactReferenceLocator(
|
|
808
|
+
value: unknown,
|
|
809
|
+
path: string,
|
|
810
|
+
issues: string[],
|
|
811
|
+
requiredRole?: string,
|
|
812
|
+
): ExactSemanticReferenceLocator | null {
|
|
813
|
+
const locator = parseLocator(value, path, issues);
|
|
814
|
+
if (
|
|
815
|
+
locator === null
|
|
816
|
+
|| locator.by !== "role"
|
|
817
|
+
|| locator.name === undefined
|
|
818
|
+
|| locator.exact !== true
|
|
819
|
+
|| (requiredRole !== undefined && locator.value !== requiredRole)
|
|
820
|
+
) {
|
|
821
|
+
issues.push(`${path} must be one exact named${requiredRole === undefined ? "" : ` ${requiredRole}`} role resolved from a semantic snapshot`);
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
return { by: "role", value: locator.value, name: locator.name, exact: true };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function parseValueSource(
|
|
828
|
+
value: unknown,
|
|
829
|
+
path: string,
|
|
830
|
+
fields: Readonly<Record<string, InputField>>,
|
|
831
|
+
required: ReadonlySet<string>,
|
|
832
|
+
allowItem: boolean,
|
|
833
|
+
issues: string[],
|
|
834
|
+
): BrowserValueSource | null {
|
|
835
|
+
if (!isRecord(value)) {
|
|
836
|
+
issues.push(`${path} must be { input: field }${allowItem ? " or { item: true }" : ""}`);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
if (Object.hasOwn(value, "input")) {
|
|
840
|
+
exactKeys(value, ["input"], path, issues);
|
|
841
|
+
const input = boundedString(value.input, `${path}.input`, issues, 1, 64);
|
|
842
|
+
if (input === null || !(input in fields)) {
|
|
843
|
+
issues.push(`${path}.input must name a declared input field`);
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
if (!required.has(input)) issues.push(`${path}.input must name a required input field`);
|
|
847
|
+
return { input };
|
|
848
|
+
}
|
|
849
|
+
exactKeys(value, ["item"], path, issues);
|
|
850
|
+
if (!allowItem || value.item !== true) {
|
|
851
|
+
issues.push(`${path} may reference the current item only inside for-each`);
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
return { item: true };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function parseEffect(value: unknown, path: string, issues: string[]): BrowserStepEffect | null {
|
|
858
|
+
if (!isRecord(value)) {
|
|
859
|
+
issues.push(`${path} must be an explicit prepare or dispatch effect`);
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
if (value.kind === "prepare") {
|
|
863
|
+
exactKeys(value, ["kind", "description"], path, issues);
|
|
864
|
+
const description = boundedString(value.description, `${path}.description`, issues, 1, 500);
|
|
865
|
+
return description === null ? null : { kind: "prepare", description };
|
|
866
|
+
}
|
|
867
|
+
if (value.kind === "dispatch") {
|
|
868
|
+
exactKeys(value, ["kind", "id", "description"], path, issues);
|
|
869
|
+
const id = boundedString(value.id, `${path}.id`, issues, 1, 64);
|
|
870
|
+
if (id !== null && !/^[a-z][a-z0-9-]*$/u.test(id)) issues.push(`${path}.id must be lowercase kebab-case`);
|
|
871
|
+
const description = boundedString(value.description, `${path}.description`, issues, 1, 500);
|
|
872
|
+
return id === null || description === null ? null : { kind: "dispatch", id, description };
|
|
873
|
+
}
|
|
874
|
+
issues.push(`${path}.kind must be prepare or dispatch`);
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function validateTemplate(
|
|
879
|
+
value: string,
|
|
880
|
+
path: string,
|
|
881
|
+
fields: Readonly<Record<string, InputField>>,
|
|
882
|
+
required: ReadonlySet<string>,
|
|
883
|
+
issues: string[],
|
|
884
|
+
allowItem = false,
|
|
885
|
+
): void {
|
|
886
|
+
const seen = new Set<string>();
|
|
887
|
+
for (const match of value.matchAll(/\$\{input\.([a-z][a-z0-9_]*)\}/gu)) {
|
|
888
|
+
const key = match[1];
|
|
889
|
+
if (key !== undefined) seen.add(key);
|
|
890
|
+
}
|
|
891
|
+
const stripped = value
|
|
892
|
+
.replace(/\$\{input\.[a-z][a-z0-9_]*\}/gu, "")
|
|
893
|
+
.replace(/\$\{item\}/gu, "");
|
|
894
|
+
if (stripped.includes("${")) issues.push(`${path} contains a malformed input placeholder`);
|
|
895
|
+
const itemPlaceholder = "$" + "{item}";
|
|
896
|
+
if (!allowItem && value.includes(itemPlaceholder)) issues.push(`${path} may use ${itemPlaceholder} only inside for-each`);
|
|
897
|
+
for (const key of seen) {
|
|
898
|
+
if (!(key in fields)) issues.push(`${path} references unknown input.${key}`);
|
|
899
|
+
else if (!required.has(key)) issues.push(`${path} references optional input.${key}; recipe-bound inputs must be required`);
|
|
900
|
+
else if (fields[key]?.type === "file" || fields[key]?.type === "array") {
|
|
901
|
+
issues.push(`${path} may interpolate only a scalar input.${key}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function parseStep(
|
|
907
|
+
value: unknown,
|
|
908
|
+
path: string,
|
|
909
|
+
fields: Readonly<Record<string, InputField>>,
|
|
910
|
+
required: ReadonlySet<string>,
|
|
911
|
+
issues: string[],
|
|
912
|
+
schemaVersion: 1 | 2,
|
|
913
|
+
allowItem = false,
|
|
914
|
+
depth = 0,
|
|
915
|
+
itemField?: ScalarInputField | FileInputField,
|
|
916
|
+
): BrowserRecipeStep | null {
|
|
917
|
+
if (!isRecord(value)) {
|
|
918
|
+
issues.push(`${path} must be an object`);
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
const kind = value.kind;
|
|
922
|
+
if (typeof kind !== "string") {
|
|
923
|
+
issues.push(`${path}.kind must be a string`);
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
if (kind === "navigate") {
|
|
927
|
+
exactKeys(value, ["kind", "path", "query"], path, issues);
|
|
928
|
+
const targetPath = boundedString(value.path, `${path}.path`, issues, 1, 4_096);
|
|
929
|
+
if (targetPath === null || !targetPath.startsWith("/") || targetPath.startsWith("//") || hasAmbiguousPathSyntax(targetPath)) {
|
|
930
|
+
issues.push(`${path}.path must be an origin-relative path`);
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
validateTemplate(targetPath, `${path}.path`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
934
|
+
for (const match of targetPath.matchAll(/\$\{input\.([a-z][a-z0-9_]*)\}/gu)) {
|
|
935
|
+
const key = match[1];
|
|
936
|
+
const field = key === undefined ? undefined : fields[key];
|
|
937
|
+
if (key !== undefined && (field?.type !== "string" || field.format !== "path-segment")) {
|
|
938
|
+
issues.push(`${path}.path input.${key} must declare format path-segment`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (targetPath.includes("$" + "{item}") && (itemField?.type !== "string" || itemField.format !== "path-segment")) {
|
|
942
|
+
issues.push(`${path}.path item must declare string format path-segment`);
|
|
943
|
+
}
|
|
944
|
+
let query: Record<string, string> | undefined;
|
|
945
|
+
if (value.query !== undefined) {
|
|
946
|
+
if (!isRecord(value.query) || Object.keys(value.query).length > 50) {
|
|
947
|
+
issues.push(`${path}.query must be an object with at most 50 entries`);
|
|
948
|
+
} else {
|
|
949
|
+
query = {};
|
|
950
|
+
for (const [key, input] of Object.entries(value.query)) {
|
|
951
|
+
if (!/^[A-Za-z0-9_.~-]{1,128}$/u.test(key) || typeof input !== "string" || !(input in fields)) {
|
|
952
|
+
issues.push(`${path}.query.${key} must name a declared input field`);
|
|
953
|
+
} else if (!required.has(input)) {
|
|
954
|
+
issues.push(`${path}.query.${key} must name a required input field`);
|
|
955
|
+
} else if (fields[input]?.type === "file" || fields[input]?.type === "array") {
|
|
956
|
+
issues.push(`${path}.query.${key} must name a scalar input field`);
|
|
957
|
+
} else query[key] = input;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return { kind, path: targetPath, ...(query === undefined ? {} : { query }) };
|
|
962
|
+
}
|
|
963
|
+
if (kind === "navigate-input") {
|
|
964
|
+
exactKeys(value, ["kind", "input"], path, issues);
|
|
965
|
+
const input = boundedString(value.input, `${path}.input`, issues, 1, 64);
|
|
966
|
+
const field = input === null ? undefined : fields[input];
|
|
967
|
+
if (input === null || field?.type !== "string" || field.format !== "url" || !required.has(input)) {
|
|
968
|
+
issues.push(`${path}.input must name a required declared url field`);
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
return { kind, input };
|
|
972
|
+
}
|
|
973
|
+
if (kind === "find") {
|
|
974
|
+
exactKeys(value, schemaVersion === 1
|
|
975
|
+
? ["kind", "locator", "action", "with", "dispatch"]
|
|
976
|
+
: ["kind", "locator", "action", "with", "effect"], path, issues);
|
|
977
|
+
const locator = parseLocator(value.locator, `${path}.locator`, issues);
|
|
978
|
+
const action = value.action;
|
|
979
|
+
const actions = schemaVersion === 1
|
|
980
|
+
? ["click", "fill", "type", "hover"]
|
|
981
|
+
: ["click", "fill", "type", "hover", "upload", "select", "check", "uncheck"];
|
|
982
|
+
if (typeof action !== "string" || !actions.includes(action)) {
|
|
983
|
+
issues.push(`${path}.action must be ${actions.join(", ")}`);
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
const needsValue = action === "fill" || action === "type" || action === "upload" || action === "select";
|
|
987
|
+
let withValue: string | BrowserValueSource | undefined;
|
|
988
|
+
if (needsValue) {
|
|
989
|
+
if (schemaVersion === 1) {
|
|
990
|
+
if (typeof value.with !== "string" || !(value.with in fields)) {
|
|
991
|
+
issues.push(`${path}.with must name a declared input for ${action}`);
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
if (!required.has(value.with)) issues.push(`${path}.with must name a required input`);
|
|
995
|
+
withValue = value.with;
|
|
996
|
+
} else {
|
|
997
|
+
const parsed = parseValueSource(value.with, `${path}.with`, fields, required, allowItem, issues);
|
|
998
|
+
if (parsed === null) return null;
|
|
999
|
+
withValue = parsed;
|
|
1000
|
+
const field = "input" in parsed ? fields[parsed.input] : itemField;
|
|
1001
|
+
const isFileSource = field?.type === "file" || (field?.type === "array" && field.items.type === "file");
|
|
1002
|
+
const isArraySource = field?.type === "array";
|
|
1003
|
+
if (action === "upload" && !isFileSource) {
|
|
1004
|
+
issues.push(`${path}.with must reference a file or file array input for upload`);
|
|
1005
|
+
}
|
|
1006
|
+
if (action !== "upload" && isFileSource) {
|
|
1007
|
+
issues.push(`${path}.with cannot expose a file input to ${action}`);
|
|
1008
|
+
}
|
|
1009
|
+
if ((action === "fill" || action === "type") && isArraySource) {
|
|
1010
|
+
issues.push(`${path}.with must reference one scalar input for ${action}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
} else if (value.with !== undefined) issues.push(`${path}.with is valid only for fill, type, upload, or select`);
|
|
1014
|
+
let effect: BrowserStepEffect | undefined;
|
|
1015
|
+
if (schemaVersion === 2) {
|
|
1016
|
+
effect = parseEffect(value.effect, `${path}.effect`, issues) ?? undefined;
|
|
1017
|
+
if (value.dispatch !== undefined) issues.push(`${path}.dispatch is v1-only; use an explicit effect`);
|
|
1018
|
+
if (action === "upload" && effect?.kind !== "dispatch") {
|
|
1019
|
+
issues.push(`${path}.effect must mark upload as dispatch because selecting a file may transfer bytes immediately`);
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
if (value.dispatch !== undefined && typeof value.dispatch !== "boolean") issues.push(`${path}.dispatch must be boolean`);
|
|
1023
|
+
if (value.dispatch === true && action !== "click") issues.push(`${path}.dispatch is valid only for click; use a marked press step for keyboard dispatch`);
|
|
1024
|
+
}
|
|
1025
|
+
if ((action === "upload" || action === "select" || action === "check" || action === "uncheck")) {
|
|
1026
|
+
const exact = exactReferenceLocator(value.locator, `${path}.locator`, issues);
|
|
1027
|
+
if (exact === null) return null;
|
|
1028
|
+
}
|
|
1029
|
+
if (locator === null) return null;
|
|
1030
|
+
validateTemplate(locator.value, `${path}.locator.value`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1031
|
+
if (locator.by === "role" && locator.name !== undefined) {
|
|
1032
|
+
validateTemplate(locator.name, `${path}.locator.name`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
kind,
|
|
1036
|
+
locator,
|
|
1037
|
+
action: action as Extract<BrowserRecipeStep, { kind: "find" }>["action"],
|
|
1038
|
+
...(withValue === undefined ? {} : { with: withValue }),
|
|
1039
|
+
...(effect === undefined ? {} : { effect }),
|
|
1040
|
+
...(schemaVersion === 1 && value.dispatch === true ? { dispatch: true } : {}),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
if (kind === "press") {
|
|
1044
|
+
exactKeys(value, schemaVersion === 1 ? ["kind", "key", "dispatch"] : ["kind", "key", "effect"], path, issues);
|
|
1045
|
+
const key = boundedString(value.key, `${path}.key`, issues, 1, 100);
|
|
1046
|
+
if (schemaVersion === 1) {
|
|
1047
|
+
if (value.dispatch !== undefined && typeof value.dispatch !== "boolean") issues.push(`${path}.dispatch must be boolean`);
|
|
1048
|
+
return key === null ? null : { kind, key, ...(value.dispatch === true ? { dispatch: true } : {}) };
|
|
1049
|
+
}
|
|
1050
|
+
const effect = parseEffect(value.effect, `${path}.effect`, issues);
|
|
1051
|
+
return key === null || effect === null ? null : { kind, key, effect };
|
|
1052
|
+
}
|
|
1053
|
+
if (kind === "wait") {
|
|
1054
|
+
exactKeys(value, ["kind", "milliseconds"], path, issues);
|
|
1055
|
+
const milliseconds = safeInteger(value.milliseconds, `${path}.milliseconds`, 1, 30_000, issues);
|
|
1056
|
+
return milliseconds === null ? null : { kind, milliseconds };
|
|
1057
|
+
}
|
|
1058
|
+
if (kind === "wait-text" || kind === "assert-text") {
|
|
1059
|
+
exactKeys(value, ["kind", "text"], path, issues);
|
|
1060
|
+
const text = boundedString(value.text, `${path}.text`, issues, 1, 2_000);
|
|
1061
|
+
if (text !== null) validateTemplate(text, `${path}.text`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1062
|
+
return text === null ? null : { kind, text };
|
|
1063
|
+
}
|
|
1064
|
+
if (kind === "assert-url") {
|
|
1065
|
+
exactKeys(value, ["kind", "pattern"], path, issues);
|
|
1066
|
+
const pattern = boundedString(value.pattern, `${path}.pattern`, issues, 1, 2_000);
|
|
1067
|
+
if (pattern !== null) validateTemplate(pattern, `${path}.pattern`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1068
|
+
return pattern === null ? null : { kind, pattern };
|
|
1069
|
+
}
|
|
1070
|
+
if (kind === "assert-input-empty") {
|
|
1071
|
+
exactKeys(value, ["kind", "locator"], path, issues);
|
|
1072
|
+
const locator = exactReferenceLocator(value.locator, `${path}.locator`, issues, "textbox");
|
|
1073
|
+
if (locator === null) return null;
|
|
1074
|
+
validateTemplate(locator.name, `${path}.locator.name`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1075
|
+
return { kind, locator: { ...locator, value: "textbox" } };
|
|
1076
|
+
}
|
|
1077
|
+
if (kind === "assert-value") {
|
|
1078
|
+
if (schemaVersion === 1) {
|
|
1079
|
+
issues.push(`${path}.kind is supported only in schemaVersion 2`);
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
exactKeys(value, ["kind", "locator", "equals"], path, issues);
|
|
1083
|
+
const locator = exactReferenceLocator(value.locator, `${path}.locator`, issues);
|
|
1084
|
+
const equals = parseValueSource(value.equals, `${path}.equals`, fields, required, allowItem, issues);
|
|
1085
|
+
if (locator === null || equals === null) return null;
|
|
1086
|
+
const expectedField = "input" in equals ? fields[equals.input] : itemField;
|
|
1087
|
+
if (expectedField?.type === "file" || expectedField?.type === "array") {
|
|
1088
|
+
issues.push(`${path}.equals must reference one scalar value`);
|
|
1089
|
+
}
|
|
1090
|
+
validateTemplate(locator.name, `${path}.locator.name`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1091
|
+
return { kind, locator, equals };
|
|
1092
|
+
}
|
|
1093
|
+
if (kind === "assert-checked") {
|
|
1094
|
+
if (schemaVersion === 1) {
|
|
1095
|
+
issues.push(`${path}.kind is supported only in schemaVersion 2`);
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
exactKeys(value, ["kind", "locator", "checked"], path, issues);
|
|
1099
|
+
const locator = exactReferenceLocator(value.locator, `${path}.locator`, issues);
|
|
1100
|
+
if (typeof value.checked !== "boolean") issues.push(`${path}.checked must be boolean`);
|
|
1101
|
+
if (locator === null || typeof value.checked !== "boolean") return null;
|
|
1102
|
+
validateTemplate(locator.name, `${path}.locator.name`, fields, required, issues, allowItem && itemField?.type !== "file");
|
|
1103
|
+
return { kind, locator, checked: value.checked };
|
|
1104
|
+
}
|
|
1105
|
+
if (kind === "verify-dispatch") {
|
|
1106
|
+
if (schemaVersion === 1) {
|
|
1107
|
+
issues.push(`${path}.kind is supported only in schemaVersion 2`);
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
exactKeys(value, ["kind", "dispatch", "assertions"], path, issues);
|
|
1111
|
+
const dispatch = boundedString(value.dispatch, `${path}.dispatch`, issues, 1, 64);
|
|
1112
|
+
if (dispatch !== null && !/^[a-z][a-z0-9-]*$/u.test(dispatch)) issues.push(`${path}.dispatch must be lowercase kebab-case`);
|
|
1113
|
+
const assertions: BrowserAssertion[] = [];
|
|
1114
|
+
if (!Array.isArray(value.assertions) || value.assertions.length < 1 || value.assertions.length > 10) {
|
|
1115
|
+
issues.push(`${path}.assertions must contain 1-10 bounded observations`);
|
|
1116
|
+
} else {
|
|
1117
|
+
value.assertions.forEach((assertion, index) => {
|
|
1118
|
+
const parsed = parseStep(assertion, `${path}.assertions[${index}]`, fields, required, issues, 2, allowItem, depth + 1, itemField);
|
|
1119
|
+
if (
|
|
1120
|
+
parsed !== null
|
|
1121
|
+
&& (parsed.kind === "assert-text"
|
|
1122
|
+
|| parsed.kind === "assert-url"
|
|
1123
|
+
|| parsed.kind === "assert-input-empty"
|
|
1124
|
+
|| parsed.kind === "assert-value"
|
|
1125
|
+
|| parsed.kind === "assert-checked")
|
|
1126
|
+
) assertions.push(parsed);
|
|
1127
|
+
else if (parsed !== null) issues.push(`${path}.assertions[${index}] must be an assertion`);
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
return dispatch === null || assertions.length === 0 ? null : { kind, dispatch, assertions };
|
|
1131
|
+
}
|
|
1132
|
+
if (kind === "for-each") {
|
|
1133
|
+
if (schemaVersion === 1) {
|
|
1134
|
+
issues.push(`${path}.kind is supported only in schemaVersion 2`);
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
exactKeys(value, ["kind", "input", "steps", "between"], path, issues);
|
|
1138
|
+
const input = boundedString(value.input, `${path}.input`, issues, 1, 64);
|
|
1139
|
+
const field = input === null ? undefined : fields[input];
|
|
1140
|
+
if (input === null || field?.type !== "array" || !required.has(input)) {
|
|
1141
|
+
issues.push(`${path}.input must name a required bounded array input`);
|
|
1142
|
+
}
|
|
1143
|
+
if (depth > 0) issues.push(`${path} cannot nest for-each`);
|
|
1144
|
+
const steps: BrowserRecipeStep[] = [];
|
|
1145
|
+
if (!Array.isArray(value.steps) || value.steps.length < 1 || value.steps.length > 50) {
|
|
1146
|
+
issues.push(`${path}.steps must contain 1-50 steps`);
|
|
1147
|
+
} else {
|
|
1148
|
+
value.steps.forEach((step, index) => {
|
|
1149
|
+
const parsed = parseStep(step, `${path}.steps[${index}]`, fields, required, issues, 2, true, depth + 1, field?.type === "array" ? field.items : undefined);
|
|
1150
|
+
if (parsed !== null) steps.push(parsed);
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
let between: BrowserRecipeStep[] | undefined;
|
|
1154
|
+
if (value.between !== undefined) {
|
|
1155
|
+
if (!Array.isArray(value.between) || value.between.length > 20) {
|
|
1156
|
+
issues.push(`${path}.between must contain at most 20 steps`);
|
|
1157
|
+
} else {
|
|
1158
|
+
between = [];
|
|
1159
|
+
value.between.forEach((step, index) => {
|
|
1160
|
+
const parsed = parseStep(step, `${path}.between[${index}]`, fields, required, issues, 2, true, depth + 1, field?.type === "array" ? field.items : undefined);
|
|
1161
|
+
if (parsed !== null) between?.push(parsed);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (input === null || field?.type !== "array" || steps.length === 0) return null;
|
|
1166
|
+
return { kind, input, steps, ...(between === undefined ? {} : { between }) };
|
|
1167
|
+
}
|
|
1168
|
+
if (kind === "snapshot") {
|
|
1169
|
+
exactKeys(value, ["kind", "interactive"], path, issues);
|
|
1170
|
+
if (value.interactive !== undefined && typeof value.interactive !== "boolean") {
|
|
1171
|
+
issues.push(`${path}.interactive must be boolean`);
|
|
1172
|
+
}
|
|
1173
|
+
return { kind, ...(value.interactive === true ? { interactive: true } : {}) };
|
|
1174
|
+
}
|
|
1175
|
+
if (kind === "read") {
|
|
1176
|
+
exactKeys(value, ["kind"], path, issues);
|
|
1177
|
+
return { kind };
|
|
1178
|
+
}
|
|
1179
|
+
issues.push(`${path}.kind is not supported`);
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function dispatchEffect(step: BrowserRecipeStep): Extract<BrowserStepEffect, { kind: "dispatch" }> | null {
|
|
1184
|
+
return (step.kind === "find" || step.kind === "press") && step.effect?.kind === "dispatch"
|
|
1185
|
+
? step.effect
|
|
1186
|
+
: null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function validateV2Flow(
|
|
1190
|
+
steps: readonly BrowserRecipeStep[],
|
|
1191
|
+
path: string,
|
|
1192
|
+
fields: Readonly<Record<string, InputField>>,
|
|
1193
|
+
issues: string[],
|
|
1194
|
+
seenIds: Set<string>,
|
|
1195
|
+
): number {
|
|
1196
|
+
let openDispatch: string | null = null;
|
|
1197
|
+
let maximumDispatches = 0;
|
|
1198
|
+
for (const [index, step] of steps.entries()) {
|
|
1199
|
+
const stepPath = `${path}[${index}]`;
|
|
1200
|
+
if (step.kind === "for-each") {
|
|
1201
|
+
if (openDispatch !== null) issues.push(`${stepPath} cannot begin before dispatch ${openDispatch} is verified`);
|
|
1202
|
+
const innerIds = new Set<string>();
|
|
1203
|
+
const innerCount = validateV2Flow(step.steps, `${stepPath}.steps`, fields, issues, innerIds);
|
|
1204
|
+
const field = fields[step.input];
|
|
1205
|
+
const repetitions = field?.type === "array" ? field.maxItems : 0;
|
|
1206
|
+
maximumDispatches += innerCount * repetitions;
|
|
1207
|
+
for (const id of innerIds) {
|
|
1208
|
+
if (seenIds.has(id)) issues.push(`${stepPath} reuses dispatch id ${id}`);
|
|
1209
|
+
seenIds.add(id);
|
|
1210
|
+
}
|
|
1211
|
+
if (step.between !== undefined) {
|
|
1212
|
+
const betweenIds = new Set<string>();
|
|
1213
|
+
const betweenCount = validateV2Flow(step.between, `${stepPath}.between`, fields, issues, betweenIds);
|
|
1214
|
+
if (betweenCount > 0) issues.push(`${stepPath}.between cannot dispatch; place dispatches in the repeated steps`);
|
|
1215
|
+
}
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const dispatch = dispatchEffect(step);
|
|
1219
|
+
if (dispatch !== null) {
|
|
1220
|
+
if (openDispatch !== null) issues.push(`${stepPath} cannot start dispatch ${dispatch.id} before ${openDispatch} is verified`);
|
|
1221
|
+
if (seenIds.has(dispatch.id)) issues.push(`${stepPath} reuses dispatch id ${dispatch.id}`);
|
|
1222
|
+
seenIds.add(dispatch.id);
|
|
1223
|
+
openDispatch = dispatch.id;
|
|
1224
|
+
maximumDispatches += 1;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (step.kind === "verify-dispatch") {
|
|
1228
|
+
if (openDispatch === null) issues.push(`${stepPath} verifies ${step.dispatch} without a started dispatch`);
|
|
1229
|
+
else if (step.dispatch !== openDispatch) issues.push(`${stepPath} must verify active dispatch ${openDispatch}`);
|
|
1230
|
+
else openDispatch = null;
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (
|
|
1234
|
+
openDispatch !== null
|
|
1235
|
+
&& (step.kind === "find" || step.kind === "press" || step.kind === "navigate" || step.kind === "navigate-input")
|
|
1236
|
+
) issues.push(`${stepPath} cannot interact before dispatch ${openDispatch} is verified`);
|
|
1237
|
+
if (
|
|
1238
|
+
openDispatch !== null
|
|
1239
|
+
&& (
|
|
1240
|
+
step.kind === "assert-text"
|
|
1241
|
+
|| step.kind === "assert-url"
|
|
1242
|
+
|| step.kind === "assert-input-empty"
|
|
1243
|
+
|| step.kind === "assert-value"
|
|
1244
|
+
|| step.kind === "assert-checked"
|
|
1245
|
+
)
|
|
1246
|
+
) issues.push(`${stepPath} must be nested in verify-dispatch in schemaVersion 2`);
|
|
1247
|
+
}
|
|
1248
|
+
if (openDispatch !== null) issues.push(`${path} leaves dispatch ${openDispatch} without a verify-dispatch group`);
|
|
1249
|
+
return maximumDispatches;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function maximumExpandedStepCount(
|
|
1253
|
+
steps: readonly BrowserRecipeStep[],
|
|
1254
|
+
fields: Readonly<Record<string, InputField>>,
|
|
1255
|
+
): number {
|
|
1256
|
+
let total = 0;
|
|
1257
|
+
for (const step of steps) {
|
|
1258
|
+
if (step.kind !== "for-each") {
|
|
1259
|
+
total += 1;
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const field = fields[step.input];
|
|
1263
|
+
const repetitions = field?.type === "array" ? field.maxItems : 0;
|
|
1264
|
+
total += repetitions * maximumExpandedStepCount(step.steps, fields);
|
|
1265
|
+
total += Math.max(0, repetitions - 1) * maximumExpandedStepCount(step.between ?? [], fields);
|
|
1266
|
+
}
|
|
1267
|
+
return total;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function minimumExpandedDispatchCount(
|
|
1271
|
+
steps: readonly BrowserRecipeStep[],
|
|
1272
|
+
fields: Readonly<Record<string, InputField>>,
|
|
1273
|
+
): number {
|
|
1274
|
+
let total = 0;
|
|
1275
|
+
for (const step of steps) {
|
|
1276
|
+
if (step.kind === "for-each") {
|
|
1277
|
+
const field = fields[step.input];
|
|
1278
|
+
const repetitions = field?.type === "array" ? field.minItems : 0;
|
|
1279
|
+
total += repetitions * minimumExpandedDispatchCount(step.steps, fields);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
if (dispatchEffect(step) !== null) total += 1;
|
|
1283
|
+
}
|
|
1284
|
+
return total;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function containsInteraction(steps: readonly BrowserRecipeStep[]): boolean {
|
|
1288
|
+
return steps.some((step) =>
|
|
1289
|
+
step.kind === "press"
|
|
1290
|
+
|| step.kind === "find"
|
|
1291
|
+
|| (step.kind === "for-each" && (containsInteraction(step.steps) || containsInteraction(step.between ?? []))));
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function parseOperation(
|
|
1295
|
+
value: unknown,
|
|
1296
|
+
path: string,
|
|
1297
|
+
issues: string[],
|
|
1298
|
+
schemaVersion: 1 | 2 | 3 | 4 | 5 | 6,
|
|
1299
|
+
allowedOrigins: readonly string[],
|
|
1300
|
+
): GhostgetOperation | null {
|
|
1301
|
+
if (!isRecord(value)) {
|
|
1302
|
+
issues.push(`${path} must be an object`);
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
exactKeys(
|
|
1306
|
+
value,
|
|
1307
|
+
[
|
|
1308
|
+
"description",
|
|
1309
|
+
"risk",
|
|
1310
|
+
"sideEffect",
|
|
1311
|
+
"idempotency",
|
|
1312
|
+
"dedupeWindowMs",
|
|
1313
|
+
"input",
|
|
1314
|
+
"browser",
|
|
1315
|
+
...(schemaVersion === 3 ? ["provider"] : []),
|
|
1316
|
+
...(schemaVersion === 4 ? ["webSession"] : []),
|
|
1317
|
+
...(schemaVersion === 5 ? ["reviewedTemplate"] : []),
|
|
1318
|
+
...(schemaVersion === 6 ? ["localCli"] : []),
|
|
1319
|
+
],
|
|
1320
|
+
path,
|
|
1321
|
+
issues,
|
|
1322
|
+
);
|
|
1323
|
+
const description = boundedString(value.description, `${path}.description`, issues, 1, 500);
|
|
1324
|
+
const sideEffect = boundedString(value.sideEffect, `${path}.sideEffect`, issues, 1, 500);
|
|
1325
|
+
const risk = value.risk;
|
|
1326
|
+
if (typeof risk !== "string" || !operationRisks.includes(risk as OperationRisk)) issues.push(`${path}.risk must be R1, R2, R3, or R4`);
|
|
1327
|
+
const idempotency = value.idempotency;
|
|
1328
|
+
if (typeof idempotency !== "string" || !idempotencyKinds.includes(idempotency as IdempotencyKind)) {
|
|
1329
|
+
issues.push(`${path}.idempotency is not supported`);
|
|
1330
|
+
}
|
|
1331
|
+
const dedupeWindowMs = safeInteger(value.dedupeWindowMs, `${path}.dedupeWindowMs`, 0, 30 * 24 * 60 * 60_000, issues);
|
|
1332
|
+
const input = parseInputSchema(
|
|
1333
|
+
value.input,
|
|
1334
|
+
`${path}.input`,
|
|
1335
|
+
issues,
|
|
1336
|
+
(
|
|
1337
|
+
schemaVersion === GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
1338
|
+
&& value.provider !== undefined
|
|
1339
|
+
) || (
|
|
1340
|
+
schemaVersion === GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION
|
|
1341
|
+
&& value.localCli !== undefined
|
|
1342
|
+
) ? 100 : 25,
|
|
1343
|
+
);
|
|
1344
|
+
if (schemaVersion === 1 && input !== null && Object.values(input.properties).some((field) => field.type === "file" || field.type === "array")) {
|
|
1345
|
+
issues.push(`${path}.input file and array fields require schemaVersion 2`);
|
|
1346
|
+
}
|
|
1347
|
+
if (
|
|
1348
|
+
risk === "R1"
|
|
1349
|
+
&& input !== null
|
|
1350
|
+
&& Object.values(input.properties).some((field) =>
|
|
1351
|
+
field.type === "file" || (field.type === "array" && field.items.type === "file"))
|
|
1352
|
+
) {
|
|
1353
|
+
issues.push(`${path}.input file fields require a confirmed R2/R3 upload workflow`);
|
|
1354
|
+
}
|
|
1355
|
+
if ((risk === "R2" || risk === "R3") && idempotency === "none") {
|
|
1356
|
+
issues.push(`${path} mutates remote state and must declare local-at-most-once dispatch`);
|
|
1357
|
+
}
|
|
1358
|
+
if ((risk === "R2" || risk === "R3") && (dedupeWindowMs === null || dedupeWindowMs < 60_000)) {
|
|
1359
|
+
issues.push(`${path} mutates remote state and needs a dedupeWindowMs of at least 60000`);
|
|
1360
|
+
}
|
|
1361
|
+
if (risk === "R1" && dedupeWindowMs !== 0) issues.push(`${path} is R1 and must use dedupeWindowMs 0`);
|
|
1362
|
+
if (risk === "R1" && sideEffect !== "none") issues.push(`${path} is R1 and must declare sideEffect as none`);
|
|
1363
|
+
|
|
1364
|
+
if (schemaVersion === 3 && value.browser !== undefined && value.provider !== undefined) {
|
|
1365
|
+
issues.push(`${path} must declare exactly one of browser or provider`);
|
|
1366
|
+
}
|
|
1367
|
+
if (schemaVersion === 3 && value.browser === undefined && value.provider === undefined) {
|
|
1368
|
+
issues.push(`${path} must declare exactly one of browser or provider`);
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
if (schemaVersion === 4) {
|
|
1372
|
+
const transports = [value.browser, value.provider, value.webSession].filter((candidate) => candidate !== undefined);
|
|
1373
|
+
if (transports.length !== 1 || value.webSession === undefined) {
|
|
1374
|
+
issues.push(`${path} must declare exactly one webSession transport in schemaVersion 4`);
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (schemaVersion === 5) {
|
|
1379
|
+
const transports = [value.browser, value.provider, value.webSession, value.reviewedTemplate]
|
|
1380
|
+
.filter((candidate) => candidate !== undefined);
|
|
1381
|
+
if (transports.length !== 1 || value.reviewedTemplate === undefined) {
|
|
1382
|
+
issues.push(`${path} must declare exactly one reviewedTemplate transport in schemaVersion 5`);
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (schemaVersion === 6) {
|
|
1387
|
+
const transports = [
|
|
1388
|
+
value.browser,
|
|
1389
|
+
value.provider,
|
|
1390
|
+
value.webSession,
|
|
1391
|
+
value.reviewedTemplate,
|
|
1392
|
+
value.localCli,
|
|
1393
|
+
].filter((candidate) => candidate !== undefined);
|
|
1394
|
+
if (transports.length !== 1 || value.localCli === undefined) {
|
|
1395
|
+
issues.push(`${path} must declare exactly one localCli transport in schemaVersion 6`);
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (schemaVersion === 3 && value.provider !== undefined) {
|
|
1401
|
+
if (!isRecord(value.provider)) {
|
|
1402
|
+
issues.push(`${path}.provider must be an object`);
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|
|
1405
|
+
exactKeys(value.provider, ["provider", "action", "contractVersion", "timeoutMs", "maxOutputBytes"], `${path}.provider`, issues);
|
|
1406
|
+
const provider = value.provider.provider;
|
|
1407
|
+
if (!isProviderPluginSurfaceId(provider)) {
|
|
1408
|
+
issues.push(`${path}.provider.provider must be a bounded lowercase kebab-case provider surface ID`);
|
|
1409
|
+
}
|
|
1410
|
+
const action = value.provider.action;
|
|
1411
|
+
if (!isProviderPluginOperationName(action)) {
|
|
1412
|
+
issues.push(`${path}.provider.action must name a bounded dotted provider operation`);
|
|
1413
|
+
}
|
|
1414
|
+
const contractVersion = safeInteger(value.provider.contractVersion, `${path}.provider.contractVersion`, 1, 1_000_000, issues);
|
|
1415
|
+
const timeoutMs = safeInteger(value.provider.timeoutMs, `${path}.provider.timeoutMs`, 1_000, 10 * 60_000, issues);
|
|
1416
|
+
const maxOutputBytes = safeInteger(value.provider.maxOutputBytes, `${path}.provider.maxOutputBytes`, 1_024, 10 * 1024 * 1024, issues);
|
|
1417
|
+
if (
|
|
1418
|
+
description === null
|
|
1419
|
+
|| sideEffect === null
|
|
1420
|
+
|| typeof risk !== "string"
|
|
1421
|
+
|| !operationRisks.includes(risk as OperationRisk)
|
|
1422
|
+
|| typeof idempotency !== "string"
|
|
1423
|
+
|| !idempotencyKinds.includes(idempotency as IdempotencyKind)
|
|
1424
|
+
|| input === null
|
|
1425
|
+
|| dedupeWindowMs === null
|
|
1426
|
+
|| !isProviderPluginSurfaceId(provider)
|
|
1427
|
+
|| !isProviderPluginOperationName(action)
|
|
1428
|
+
|| contractVersion === null
|
|
1429
|
+
|| timeoutMs === null
|
|
1430
|
+
|| maxOutputBytes === null
|
|
1431
|
+
) return null;
|
|
1432
|
+
return {
|
|
1433
|
+
description,
|
|
1434
|
+
risk: risk as OperationRisk,
|
|
1435
|
+
sideEffect,
|
|
1436
|
+
idempotency: idempotency as IdempotencyKind,
|
|
1437
|
+
dedupeWindowMs,
|
|
1438
|
+
input,
|
|
1439
|
+
provider: {
|
|
1440
|
+
provider,
|
|
1441
|
+
action,
|
|
1442
|
+
contractVersion,
|
|
1443
|
+
timeoutMs,
|
|
1444
|
+
maxOutputBytes,
|
|
1445
|
+
},
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (schemaVersion === 4 && value.webSession !== undefined) {
|
|
1450
|
+
if (!isRecord(value.webSession)) {
|
|
1451
|
+
issues.push(`${path}.webSession must be an object`);
|
|
1452
|
+
return null;
|
|
1453
|
+
}
|
|
1454
|
+
exactKeys(value.webSession, ["site", "action", "contractVersion", "timeoutMs", "maxOutputBytes"], `${path}.webSession`, issues);
|
|
1455
|
+
const site = value.webSession.site;
|
|
1456
|
+
if (!isProviderPluginSurfaceId(site)) {
|
|
1457
|
+
issues.push(`${path}.webSession.site must be a bounded lowercase kebab-case provider surface ID`);
|
|
1458
|
+
}
|
|
1459
|
+
const action = value.webSession.action;
|
|
1460
|
+
if (!isProviderPluginOperationName(action)) {
|
|
1461
|
+
issues.push(`${path}.webSession.action must name a bounded dotted provider operation`);
|
|
1462
|
+
}
|
|
1463
|
+
const contractVersion = safeInteger(value.webSession.contractVersion, `${path}.webSession.contractVersion`, 1, 1_000_000, issues);
|
|
1464
|
+
const timeoutMs = safeInteger(value.webSession.timeoutMs, `${path}.webSession.timeoutMs`, 1_000, 10 * 60_000, issues);
|
|
1465
|
+
const maxOutputBytes = safeInteger(value.webSession.maxOutputBytes, `${path}.webSession.maxOutputBytes`, 1_024, 10 * 1024 * 1024, issues);
|
|
1466
|
+
if (
|
|
1467
|
+
description === null
|
|
1468
|
+
|| sideEffect === null
|
|
1469
|
+
|| typeof risk !== "string"
|
|
1470
|
+
|| !operationRisks.includes(risk as OperationRisk)
|
|
1471
|
+
|| typeof idempotency !== "string"
|
|
1472
|
+
|| !idempotencyKinds.includes(idempotency as IdempotencyKind)
|
|
1473
|
+
|| input === null
|
|
1474
|
+
|| dedupeWindowMs === null
|
|
1475
|
+
|| !isProviderPluginSurfaceId(site)
|
|
1476
|
+
|| !isProviderPluginOperationName(action)
|
|
1477
|
+
|| contractVersion === null
|
|
1478
|
+
|| timeoutMs === null
|
|
1479
|
+
|| maxOutputBytes === null
|
|
1480
|
+
) return null;
|
|
1481
|
+
return {
|
|
1482
|
+
description,
|
|
1483
|
+
risk: risk as OperationRisk,
|
|
1484
|
+
sideEffect,
|
|
1485
|
+
idempotency: idempotency as IdempotencyKind,
|
|
1486
|
+
dedupeWindowMs,
|
|
1487
|
+
input,
|
|
1488
|
+
webSession: {
|
|
1489
|
+
site,
|
|
1490
|
+
action,
|
|
1491
|
+
contractVersion,
|
|
1492
|
+
timeoutMs,
|
|
1493
|
+
maxOutputBytes,
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (schemaVersion === 6 && value.localCli !== undefined) {
|
|
1499
|
+
if (!isRecord(value.localCli)) {
|
|
1500
|
+
issues.push(`${path}.localCli must be an object`);
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
exactKeys(
|
|
1504
|
+
value.localCli,
|
|
1505
|
+
["surface", "action", "contractVersion", "timeoutMs", "maxOutputBytes"],
|
|
1506
|
+
`${path}.localCli`,
|
|
1507
|
+
issues,
|
|
1508
|
+
);
|
|
1509
|
+
const surface = value.localCli.surface;
|
|
1510
|
+
if (!isProviderPluginSurfaceId(surface)) {
|
|
1511
|
+
issues.push(`${path}.localCli.surface must be a bounded lowercase kebab-case provider surface ID`);
|
|
1512
|
+
}
|
|
1513
|
+
const action = value.localCli.action;
|
|
1514
|
+
if (!isProviderPluginOperationName(action)) {
|
|
1515
|
+
issues.push(`${path}.localCli.action must name a bounded dotted provider operation`);
|
|
1516
|
+
}
|
|
1517
|
+
const contractVersion = safeInteger(
|
|
1518
|
+
value.localCli.contractVersion,
|
|
1519
|
+
`${path}.localCli.contractVersion`,
|
|
1520
|
+
1,
|
|
1521
|
+
1_000_000,
|
|
1522
|
+
issues,
|
|
1523
|
+
);
|
|
1524
|
+
const timeoutMs = safeInteger(
|
|
1525
|
+
value.localCli.timeoutMs,
|
|
1526
|
+
`${path}.localCli.timeoutMs`,
|
|
1527
|
+
1_000,
|
|
1528
|
+
10 * 60_000,
|
|
1529
|
+
issues,
|
|
1530
|
+
);
|
|
1531
|
+
const maxOutputBytes = safeInteger(
|
|
1532
|
+
value.localCli.maxOutputBytes,
|
|
1533
|
+
`${path}.localCli.maxOutputBytes`,
|
|
1534
|
+
1_024,
|
|
1535
|
+
10 * 1024 * 1024,
|
|
1536
|
+
issues,
|
|
1537
|
+
);
|
|
1538
|
+
if (
|
|
1539
|
+
description === null
|
|
1540
|
+
|| sideEffect === null
|
|
1541
|
+
|| typeof risk !== "string"
|
|
1542
|
+
|| !operationRisks.includes(risk as OperationRisk)
|
|
1543
|
+
|| typeof idempotency !== "string"
|
|
1544
|
+
|| !idempotencyKinds.includes(idempotency as IdempotencyKind)
|
|
1545
|
+
|| input === null
|
|
1546
|
+
|| dedupeWindowMs === null
|
|
1547
|
+
|| !isProviderPluginSurfaceId(surface)
|
|
1548
|
+
|| !isProviderPluginOperationName(action)
|
|
1549
|
+
|| contractVersion === null
|
|
1550
|
+
|| timeoutMs === null
|
|
1551
|
+
|| maxOutputBytes === null
|
|
1552
|
+
) return null;
|
|
1553
|
+
return {
|
|
1554
|
+
description,
|
|
1555
|
+
risk: risk as OperationRisk,
|
|
1556
|
+
sideEffect,
|
|
1557
|
+
idempotency: idempotency as IdempotencyKind,
|
|
1558
|
+
dedupeWindowMs,
|
|
1559
|
+
input,
|
|
1560
|
+
localCli: {
|
|
1561
|
+
surface,
|
|
1562
|
+
action,
|
|
1563
|
+
contractVersion,
|
|
1564
|
+
timeoutMs,
|
|
1565
|
+
maxOutputBytes,
|
|
1566
|
+
},
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (schemaVersion === 5 && value.reviewedTemplate !== undefined) {
|
|
1571
|
+
if (!isRecord(value.reviewedTemplate)) {
|
|
1572
|
+
issues.push(`${path}.reviewedTemplate must be an object`);
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
const state = value.reviewedTemplate.state;
|
|
1576
|
+
const contractVersion = value.reviewedTemplate.contractVersion;
|
|
1577
|
+
if (contractVersion !== 1) issues.push(`${path}.reviewedTemplate.contractVersion must be 1`);
|
|
1578
|
+
if (state === "capture-required") {
|
|
1579
|
+
exactKeys(value.reviewedTemplate, ["state", "contractVersion", "instructions"], `${path}.reviewedTemplate`, issues);
|
|
1580
|
+
const instructions = boundedString(
|
|
1581
|
+
value.reviewedTemplate.instructions,
|
|
1582
|
+
`${path}.reviewedTemplate.instructions`,
|
|
1583
|
+
issues,
|
|
1584
|
+
1,
|
|
1585
|
+
2_000,
|
|
1586
|
+
);
|
|
1587
|
+
if (
|
|
1588
|
+
description === null
|
|
1589
|
+
|| sideEffect === null
|
|
1590
|
+
|| typeof risk !== "string"
|
|
1591
|
+
|| !operationRisks.includes(risk as OperationRisk)
|
|
1592
|
+
|| typeof idempotency !== "string"
|
|
1593
|
+
|| !idempotencyKinds.includes(idempotency as IdempotencyKind)
|
|
1594
|
+
|| input === null
|
|
1595
|
+
|| dedupeWindowMs === null
|
|
1596
|
+
|| contractVersion !== 1
|
|
1597
|
+
|| instructions === null
|
|
1598
|
+
) return null;
|
|
1599
|
+
return {
|
|
1600
|
+
description,
|
|
1601
|
+
risk: risk as OperationRisk,
|
|
1602
|
+
sideEffect,
|
|
1603
|
+
idempotency: idempotency as IdempotencyKind,
|
|
1604
|
+
dedupeWindowMs,
|
|
1605
|
+
input,
|
|
1606
|
+
reviewedTemplate: { state: "capture-required", contractVersion: 1, instructions },
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
if (state !== "reviewed") {
|
|
1610
|
+
issues.push(`${path}.reviewedTemplate.state must be capture-required or reviewed`);
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
issues.push(
|
|
1614
|
+
`${path}.reviewedTemplate.state reviewed requires reviewed-template contractVersion 2 with a current-account identity preflight; keep schemaVersion 5 operations capture-required`,
|
|
1615
|
+
);
|
|
1616
|
+
exactKeys(
|
|
1617
|
+
value.reviewedTemplate,
|
|
1618
|
+
["state", "contractVersion", "reviewedAt", "evidenceSha256", "timeoutMs", "template"],
|
|
1619
|
+
`${path}.reviewedTemplate`,
|
|
1620
|
+
issues,
|
|
1621
|
+
);
|
|
1622
|
+
const reviewedAt = boundedString(value.reviewedTemplate.reviewedAt, `${path}.reviewedTemplate.reviewedAt`, issues, 20, 40);
|
|
1623
|
+
if (
|
|
1624
|
+
reviewedAt !== null
|
|
1625
|
+
&& (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u.test(reviewedAt)
|
|
1626
|
+
|| !Number.isFinite(Date.parse(reviewedAt)))
|
|
1627
|
+
) issues.push(`${path}.reviewedTemplate.reviewedAt must be an exact UTC ISO-8601 instant`);
|
|
1628
|
+
const evidenceSha256 = value.reviewedTemplate.evidenceSha256;
|
|
1629
|
+
if (typeof evidenceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(evidenceSha256)) {
|
|
1630
|
+
issues.push(`${path}.reviewedTemplate.evidenceSha256 must be one lowercase SHA-256 digest`);
|
|
1631
|
+
}
|
|
1632
|
+
const timeoutMs = safeInteger(
|
|
1633
|
+
value.reviewedTemplate.timeoutMs,
|
|
1634
|
+
`${path}.reviewedTemplate.timeoutMs`,
|
|
1635
|
+
1_000,
|
|
1636
|
+
10 * 60_000,
|
|
1637
|
+
issues,
|
|
1638
|
+
);
|
|
1639
|
+
const parsedTemplate = input === null
|
|
1640
|
+
? null
|
|
1641
|
+
: parseWebSessionTemplate(value.reviewedTemplate.template, { input, allowedOrigins });
|
|
1642
|
+
if (parsedTemplate !== null && !parsedTemplate.ok) {
|
|
1643
|
+
for (const issue of parsedTemplate.issues) {
|
|
1644
|
+
issues.push(issue === "$"
|
|
1645
|
+
? `${path}.reviewedTemplate.template is invalid`
|
|
1646
|
+
: `${path}.reviewedTemplate.template${issue.startsWith("$") ? issue.slice(1) : `: ${issue}`}`);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
const template = parsedTemplate?.ok === true ? parsedTemplate.value : null;
|
|
1650
|
+
if (
|
|
1651
|
+
(risk === "R2" || risk === "R3")
|
|
1652
|
+
&& template !== null
|
|
1653
|
+
&& template.response.variants.some((variant) =>
|
|
1654
|
+
variant.body.kind === "discard"
|
|
1655
|
+
|| (variant.body.kind === "json" && variant.body.bindings.length < 1))
|
|
1656
|
+
) {
|
|
1657
|
+
issues.push(`${path}.reviewedTemplate.template write responses must be empty or include at least one exact target binding`);
|
|
1658
|
+
}
|
|
1659
|
+
if (
|
|
1660
|
+
description === null
|
|
1661
|
+
|| sideEffect === null
|
|
1662
|
+
|| typeof risk !== "string"
|
|
1663
|
+
|| !operationRisks.includes(risk as OperationRisk)
|
|
1664
|
+
|| typeof idempotency !== "string"
|
|
1665
|
+
|| !idempotencyKinds.includes(idempotency as IdempotencyKind)
|
|
1666
|
+
|| input === null
|
|
1667
|
+
|| dedupeWindowMs === null
|
|
1668
|
+
|| contractVersion !== 1
|
|
1669
|
+
|| reviewedAt === null
|
|
1670
|
+
|| typeof evidenceSha256 !== "string"
|
|
1671
|
+
|| !/^[a-f0-9]{64}$/u.test(evidenceSha256)
|
|
1672
|
+
|| timeoutMs === null
|
|
1673
|
+
|| template === null
|
|
1674
|
+
) return null;
|
|
1675
|
+
return {
|
|
1676
|
+
description,
|
|
1677
|
+
risk: risk as OperationRisk,
|
|
1678
|
+
sideEffect,
|
|
1679
|
+
idempotency: idempotency as IdempotencyKind,
|
|
1680
|
+
dedupeWindowMs,
|
|
1681
|
+
input,
|
|
1682
|
+
reviewedTemplate: {
|
|
1683
|
+
state: "reviewed",
|
|
1684
|
+
contractVersion: 1,
|
|
1685
|
+
reviewedAt,
|
|
1686
|
+
evidenceSha256,
|
|
1687
|
+
timeoutMs,
|
|
1688
|
+
template,
|
|
1689
|
+
},
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (!isRecord(value.browser)) {
|
|
1694
|
+
issues.push(`${path}.browser must be an object`);
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
const browserSchemaVersion: 1 | 2 = schemaVersion === 1 ? 1 : 2;
|
|
1698
|
+
exactKeys(value.browser, ["steps", "timeoutMs", "maxOutputBytes"], `${path}.browser`, issues);
|
|
1699
|
+
const timeoutMs = safeInteger(value.browser.timeoutMs, `${path}.browser.timeoutMs`, 1_000, 10 * 60_000, issues);
|
|
1700
|
+
const maxOutputBytes = safeInteger(value.browser.maxOutputBytes, `${path}.browser.maxOutputBytes`, 1_024, 10 * 1024 * 1024, issues);
|
|
1701
|
+
const steps: BrowserRecipeStep[] = [];
|
|
1702
|
+
if (!Array.isArray(value.browser.steps) || value.browser.steps.length < 1 || value.browser.steps.length > 100) {
|
|
1703
|
+
issues.push(`${path}.browser.steps must contain 1-100 steps`);
|
|
1704
|
+
} else if (input !== null) {
|
|
1705
|
+
const required = new Set(input.required);
|
|
1706
|
+
value.browser.steps.forEach((step, index) => {
|
|
1707
|
+
const parsed = parseStep(step, `${path}.browser.steps[${index}]`, input.properties, required, issues, browserSchemaVersion);
|
|
1708
|
+
if (parsed !== null) steps.push(parsed);
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
if (steps[0]?.kind !== "navigate" && steps[0]?.kind !== "navigate-input") {
|
|
1712
|
+
issues.push(`${path}.browser.steps must begin with a declared-origin navigation`);
|
|
1713
|
+
}
|
|
1714
|
+
if (schemaVersion === 1) {
|
|
1715
|
+
const finalStep = steps.at(-1);
|
|
1716
|
+
if (
|
|
1717
|
+
(risk === "R2" || risk === "R3")
|
|
1718
|
+
&& finalStep?.kind !== "assert-text"
|
|
1719
|
+
&& finalStep?.kind !== "assert-url"
|
|
1720
|
+
&& finalStep?.kind !== "assert-input-empty"
|
|
1721
|
+
) issues.push(`${path} mutates remote state and must end with an observable postcondition`);
|
|
1722
|
+
const dispatchSteps = steps.filter((step) => (step.kind === "find" || step.kind === "press") && step.dispatch === true);
|
|
1723
|
+
if ((risk === "R2" || risk === "R3") && dispatchSteps.length !== 1) {
|
|
1724
|
+
issues.push(`${path} mutates remote state and must mark exactly one dispatch step`);
|
|
1725
|
+
}
|
|
1726
|
+
if (risk === "R1" && dispatchSteps.length > 0) issues.push(`${path} is R1 and cannot mark a dispatch step`);
|
|
1727
|
+
if (risk === "R2" || risk === "R3") {
|
|
1728
|
+
const dispatchIndex = steps.findIndex((step) => (step.kind === "find" || step.kind === "press") && step.dispatch === true);
|
|
1729
|
+
if (dispatchIndex >= steps.length - 1) issues.push(`${path} dispatch must precede its final postcondition`);
|
|
1730
|
+
if (
|
|
1731
|
+
dispatchIndex >= 0
|
|
1732
|
+
&& steps.slice(dispatchIndex + 1).some((step) => step.kind === "find" || step.kind === "press" || step.kind === "navigate" || step.kind === "navigate-input")
|
|
1733
|
+
) issues.push(`${path} cannot interact after dispatch; only observation and postcondition steps may follow it`);
|
|
1734
|
+
}
|
|
1735
|
+
if (
|
|
1736
|
+
(risk === "R2" || risk === "R3")
|
|
1737
|
+
&& steps.some((step) => (step.kind === "press" || (step.kind === "find" && step.action === "click")) && step.dispatch !== true)
|
|
1738
|
+
) issues.push(`${path} cannot click or press outside its single marked dispatch step`);
|
|
1739
|
+
} else if (input !== null) {
|
|
1740
|
+
const seenIds = new Set<string>();
|
|
1741
|
+
const maximumDispatches = validateV2Flow(steps, `${path}.browser.steps`, input.properties, issues, seenIds);
|
|
1742
|
+
const minimumDispatches = minimumExpandedDispatchCount(steps, input.properties);
|
|
1743
|
+
const maximumSteps = maximumExpandedStepCount(steps, input.properties);
|
|
1744
|
+
if ((risk === "R2" || risk === "R3") && minimumDispatches < 1) {
|
|
1745
|
+
issues.push(`${path} mutates remote state and every valid input must schedule at least one named dispatch`);
|
|
1746
|
+
}
|
|
1747
|
+
if (maximumDispatches > 25) issues.push(`${path} can expand to at most 25 dispatches`);
|
|
1748
|
+
if (maximumSteps > 500) issues.push(`${path} can expand to at most 500 browser steps`);
|
|
1749
|
+
if (risk === "R1" && maximumDispatches > 0) issues.push(`${path} is R1 and cannot dispatch`);
|
|
1750
|
+
}
|
|
1751
|
+
if (
|
|
1752
|
+
risk === "R1"
|
|
1753
|
+
&& containsInteraction(steps)
|
|
1754
|
+
) {
|
|
1755
|
+
issues.push(`${path} is R1 and cannot click, fill, type, hover, or press; upload, select, check, and uncheck are also forbidden; split interactive flows into an explicit write capability`);
|
|
1756
|
+
}
|
|
1757
|
+
if (
|
|
1758
|
+
description === null
|
|
1759
|
+
|| sideEffect === null
|
|
1760
|
+
|| typeof risk !== "string"
|
|
1761
|
+
|| !operationRisks.includes(risk as OperationRisk)
|
|
1762
|
+
|| typeof idempotency !== "string"
|
|
1763
|
+
|| !idempotencyKinds.includes(idempotency as IdempotencyKind)
|
|
1764
|
+
|| input === null
|
|
1765
|
+
|| dedupeWindowMs === null
|
|
1766
|
+
|| timeoutMs === null
|
|
1767
|
+
|| maxOutputBytes === null
|
|
1768
|
+
) return null;
|
|
1769
|
+
return {
|
|
1770
|
+
description,
|
|
1771
|
+
risk: risk as OperationRisk,
|
|
1772
|
+
sideEffect,
|
|
1773
|
+
idempotency: idempotency as IdempotencyKind,
|
|
1774
|
+
dedupeWindowMs,
|
|
1775
|
+
input,
|
|
1776
|
+
browser: { steps, timeoutMs, maxOutputBytes },
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
type ManifestPluginContract = {
|
|
1781
|
+
readonly binding: ProviderPluginBindingV1;
|
|
1782
|
+
readonly operation: ProviderPluginOperationV1;
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
function resolveManifestPluginContract(
|
|
1786
|
+
registry: ProviderPluginRegistry,
|
|
1787
|
+
transport: "provider-api" | "session-api" | "local-cli",
|
|
1788
|
+
surfaceId: string,
|
|
1789
|
+
operationName: string,
|
|
1790
|
+
contractVersion: number,
|
|
1791
|
+
requireCurrent: boolean,
|
|
1792
|
+
): ManifestPluginContract | undefined {
|
|
1793
|
+
const binding = transport === "session-api"
|
|
1794
|
+
? registry.resolveSessionRoute(surfaceId)
|
|
1795
|
+
: registry.resolveRoute(transport, surfaceId);
|
|
1796
|
+
if (
|
|
1797
|
+
binding === undefined
|
|
1798
|
+
|| (transport === "provider-api"
|
|
1799
|
+
? binding.transport !== "provider-api"
|
|
1800
|
+
: transport === "local-cli"
|
|
1801
|
+
? binding.transport !== "local-cli"
|
|
1802
|
+
: binding.transport === "provider-api" || binding.transport === "local-cli")
|
|
1803
|
+
) {
|
|
1804
|
+
return undefined;
|
|
1805
|
+
}
|
|
1806
|
+
const exact = registry.resolveOperationDefinition(
|
|
1807
|
+
binding.transport,
|
|
1808
|
+
surfaceId,
|
|
1809
|
+
operationName,
|
|
1810
|
+
contractVersion,
|
|
1811
|
+
);
|
|
1812
|
+
if (exact !== undefined) {
|
|
1813
|
+
return { binding: exact.binding, operation: exact.operation };
|
|
1814
|
+
}
|
|
1815
|
+
if (requireCurrent) return undefined;
|
|
1816
|
+
const operation = binding.operations.find((candidate) =>
|
|
1817
|
+
candidate.name === operationName);
|
|
1818
|
+
return operation === undefined ? undefined : { binding, operation };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function validateManifestPluginOrigins(
|
|
1822
|
+
binding: ProviderPluginBindingV1,
|
|
1823
|
+
origins: readonly string[],
|
|
1824
|
+
browserDomains: readonly string[],
|
|
1825
|
+
issues: string[],
|
|
1826
|
+
): void {
|
|
1827
|
+
const expectedOrigins = [...binding.manifestOrigins].sort();
|
|
1828
|
+
const actualOrigins = [...origins].sort();
|
|
1829
|
+
if (
|
|
1830
|
+
expectedOrigins.length !== actualOrigins.length
|
|
1831
|
+
|| expectedOrigins.some((origin, index) => origin !== actualOrigins[index])
|
|
1832
|
+
) {
|
|
1833
|
+
issues.push(
|
|
1834
|
+
`manifest.origins must exactly match provider plugin surface ${binding.surfaceId}: ${expectedOrigins.join(", ")}`,
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
const expectedDomains = [
|
|
1838
|
+
...new Set(expectedOrigins.map((origin) =>
|
|
1839
|
+
new URL(origin).hostname.toLowerCase())),
|
|
1840
|
+
].sort();
|
|
1841
|
+
const actualDomains = [...browserDomains].sort();
|
|
1842
|
+
if (
|
|
1843
|
+
expectedDomains.length !== actualDomains.length
|
|
1844
|
+
|| expectedDomains.some((domain, index) => domain !== actualDomains[index])
|
|
1845
|
+
) {
|
|
1846
|
+
issues.push(
|
|
1847
|
+
`manifest.browserDomains must exactly match provider plugin surface ${binding.surfaceId}: ${expectedDomains.join(", ")}`,
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function validateManifestPluginSemantics(
|
|
1853
|
+
operationId: string,
|
|
1854
|
+
manifestOperation:
|
|
1855
|
+
| ProviderGhostgetOperation
|
|
1856
|
+
| WebSessionGhostgetOperation
|
|
1857
|
+
| LocalCliGhostgetOperation,
|
|
1858
|
+
descriptor: ProviderPluginOperationV1,
|
|
1859
|
+
contractLabel: string,
|
|
1860
|
+
riskLabel: string,
|
|
1861
|
+
issues: string[],
|
|
1862
|
+
): void {
|
|
1863
|
+
if (descriptor.risk !== manifestOperation.risk) {
|
|
1864
|
+
issues.push(
|
|
1865
|
+
`manifest.operations.${operationId}.risk must match ${riskLabel} risk ${descriptor.risk}`,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
if (canonicalJson(descriptor.input) !== canonicalJson(manifestOperation.input)) {
|
|
1869
|
+
issues.push(
|
|
1870
|
+
`manifest.operations.${operationId}.input must exactly match ${contractLabel}`,
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
if (descriptor.sideEffect !== manifestOperation.sideEffect) {
|
|
1874
|
+
issues.push(
|
|
1875
|
+
`manifest.operations.${operationId}.sideEffect must exactly match ${contractLabel}`,
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
if (descriptor.idempotency !== manifestOperation.idempotency) {
|
|
1879
|
+
issues.push(
|
|
1880
|
+
`manifest.operations.${operationId}.idempotency must exactly match ${contractLabel}`,
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
if (descriptor.dedupeWindowMs !== manifestOperation.dedupeWindowMs) {
|
|
1884
|
+
issues.push(
|
|
1885
|
+
`manifest.operations.${operationId}.dedupeWindowMs must exactly match ${contractLabel}`,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function parseManifestWithContractValidation(
|
|
1891
|
+
value: unknown,
|
|
1892
|
+
requireCurrentCodeOwnedContracts: boolean,
|
|
1893
|
+
registry: ProviderPluginRegistry,
|
|
1894
|
+
): ParseResult<GhostgetManifest> {
|
|
1895
|
+
const issues: string[] = [];
|
|
1896
|
+
if (!isRecord(value)) return { ok: false, issues: ["manifest must be an object"] };
|
|
1897
|
+
exactKeys(value, ["schemaVersion", "id", "version", "displayName", "surfaceId", "origins", "browserDomains", "operations"], "manifest", issues);
|
|
1898
|
+
const schemaVersion = value.schemaVersion === GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION
|
|
1899
|
+
? GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION
|
|
1900
|
+
: value.schemaVersion === GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION
|
|
1901
|
+
? GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION
|
|
1902
|
+
: value.schemaVersion === GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION
|
|
1903
|
+
? GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION
|
|
1904
|
+
: value.schemaVersion === GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION
|
|
1905
|
+
? GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION
|
|
1906
|
+
: value.schemaVersion === GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
1907
|
+
? GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
1908
|
+
: GHOSTGET_MANIFEST_SCHEMA_VERSION;
|
|
1909
|
+
if (
|
|
1910
|
+
value.schemaVersion !== GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION
|
|
1911
|
+
&& value.schemaVersion !== GHOSTGET_MANIFEST_SCHEMA_VERSION
|
|
1912
|
+
&& value.schemaVersion !== GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
1913
|
+
&& value.schemaVersion !== GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION
|
|
1914
|
+
&& value.schemaVersion !== GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION
|
|
1915
|
+
&& value.schemaVersion !== GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION
|
|
1916
|
+
) {
|
|
1917
|
+
issues.push("manifest.schemaVersion must be 1, 2, 3, 4, 5, or 6");
|
|
1918
|
+
}
|
|
1919
|
+
const id = boundedString(value.id, "manifest.id", issues, 1, 48);
|
|
1920
|
+
if (id !== null && !/^[a-z][a-z0-9-]*$/u.test(id)) issues.push("manifest.id must be lowercase kebab-case");
|
|
1921
|
+
const version = boundedString(value.version, "manifest.version", issues, 1, 64);
|
|
1922
|
+
if (version !== null && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
|
|
1923
|
+
issues.push("manifest.version must be a semantic version");
|
|
1924
|
+
}
|
|
1925
|
+
const displayName = boundedString(value.displayName, "manifest.displayName", issues, 1, 100);
|
|
1926
|
+
let surfaceId: ProviderPluginSurfaceId | undefined;
|
|
1927
|
+
if (value.surfaceId !== undefined) {
|
|
1928
|
+
if (schemaVersion === GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION) {
|
|
1929
|
+
issues.push("manifest.surfaceId requires schemaVersion 2 or 3");
|
|
1930
|
+
} else if (
|
|
1931
|
+
schemaVersion === GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
1932
|
+
|| schemaVersion === GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION
|
|
1933
|
+
|| schemaVersion === GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION
|
|
1934
|
+
) {
|
|
1935
|
+
if (!isProviderPluginSurfaceId(value.surfaceId)) {
|
|
1936
|
+
issues.push("manifest.surfaceId must be a bounded lowercase kebab-case provider surface ID");
|
|
1937
|
+
} else {
|
|
1938
|
+
surfaceId = value.surfaceId;
|
|
1939
|
+
}
|
|
1940
|
+
} else if (typeof value.surfaceId !== "string" || !platformSurfaceIds.includes(value.surfaceId as PlatformSurfaceId)) {
|
|
1941
|
+
issues.push("manifest.surfaceId must name a reviewed platform surface");
|
|
1942
|
+
} else {
|
|
1943
|
+
surfaceId = value.surfaceId;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
const origins: string[] = [];
|
|
1947
|
+
if (!Array.isArray(value.origins) || value.origins.length < 1 || value.origins.length > 20) {
|
|
1948
|
+
issues.push("manifest.origins must contain 1-20 exact HTTPS origins");
|
|
1949
|
+
} else {
|
|
1950
|
+
for (const [index, raw] of value.origins.entries()) {
|
|
1951
|
+
if (typeof raw !== "string") {
|
|
1952
|
+
issues.push(`manifest.origins[${index}] must be a string`);
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
try {
|
|
1956
|
+
const url = new URL(raw);
|
|
1957
|
+
if (url.protocol !== "https:" || url.origin !== raw || url.username !== "" || url.password !== "") {
|
|
1958
|
+
issues.push(`manifest.origins[${index}] must be an exact HTTPS origin without credentials or a path`);
|
|
1959
|
+
} else if (isPrivateHostname(url.hostname) || (isIP(url.hostname) !== 0 && isPrivateAddress(url.hostname))) {
|
|
1960
|
+
issues.push(`manifest.origins[${index}] cannot target a private network host`);
|
|
1961
|
+
} else if (!origins.includes(url.origin)) origins.push(url.origin);
|
|
1962
|
+
} catch {
|
|
1963
|
+
issues.push(`manifest.origins[${index}] must be a valid origin`);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
const browserDomains: string[] = [];
|
|
1968
|
+
if (!Array.isArray(value.browserDomains) || value.browserDomains.length < 1 || value.browserDomains.length > 100) {
|
|
1969
|
+
issues.push("manifest.browserDomains must contain 1-100 exact or wildcard hostnames");
|
|
1970
|
+
} else {
|
|
1971
|
+
for (const [index, raw] of value.browserDomains.entries()) {
|
|
1972
|
+
if (typeof raw !== "string" || !/^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/u.test(raw) || raw.includes("..")) {
|
|
1973
|
+
issues.push(`manifest.browserDomains[${index}] is invalid`);
|
|
1974
|
+
} else if (!browserDomains.includes(raw)) browserDomains.push(raw);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
for (const origin of origins) {
|
|
1978
|
+
const hostname = new URL(origin).hostname.toLowerCase();
|
|
1979
|
+
if (!browserDomains.some((pattern) => pattern === hostname || (pattern.startsWith("*.") && (hostname === pattern.slice(2) || hostname.endsWith(`.${pattern.slice(2)}`))))) {
|
|
1980
|
+
issues.push(`manifest.browserDomains must cover origin host ${hostname}`);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
const operations: Record<string, GhostgetOperation> = {};
|
|
1984
|
+
if (!isRecord(value.operations) || Object.keys(value.operations).length > 200) {
|
|
1985
|
+
issues.push("manifest.operations must be an object with at most 200 operations");
|
|
1986
|
+
} else {
|
|
1987
|
+
for (const [operationId, operation] of Object.entries(value.operations)) {
|
|
1988
|
+
if (!isProviderPluginOperationName(operationId)) {
|
|
1989
|
+
issues.push(`manifest.operations.${operationId} must be a dotted semantic capability ID`);
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
const parsed = parseOperation(operation, `manifest.operations.${operationId}`, issues, schemaVersion, origins);
|
|
1993
|
+
if (parsed !== null) operations[operationId] = parsed;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
// These two frozen manifests predate the source-only local-CLI binding. They
|
|
1997
|
+
// remain parseable only as exact inert migration evidence: no synthetic
|
|
1998
|
+
// session route is installed and parseRuntimeManifest still rejects them.
|
|
1999
|
+
const retiredBeeperDiagnosticHash =
|
|
2000
|
+
!requireCurrentCodeOwnedContracts
|
|
2001
|
+
&& schemaVersion === GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION
|
|
2002
|
+
&& id !== null
|
|
2003
|
+
&& version !== null
|
|
2004
|
+
&& displayName !== null
|
|
2005
|
+
? sha256(canonicalJson({
|
|
2006
|
+
schemaVersion,
|
|
2007
|
+
id,
|
|
2008
|
+
version,
|
|
2009
|
+
displayName,
|
|
2010
|
+
...(surfaceId === undefined ? {} : { surfaceId }),
|
|
2011
|
+
origins,
|
|
2012
|
+
browserDomains,
|
|
2013
|
+
operations,
|
|
2014
|
+
}))
|
|
2015
|
+
: null;
|
|
2016
|
+
const isExactRetiredBeeperDiagnostic =
|
|
2017
|
+
retiredBeeperDiagnosticHash ===
|
|
2018
|
+
"2662d0b8f1580ce19085bc5f0b6b03e8f30e7a143f385a64ab6ecdea7fc0f3bd"
|
|
2019
|
+
|| retiredBeeperDiagnosticHash ===
|
|
2020
|
+
"7da2914ae8660108e31be2032c10678a5deee2129fd02186a2844329808754fa";
|
|
2021
|
+
const matchingKnownSurfaces = platformSurfaceIds.filter((candidate) =>
|
|
2022
|
+
origins.some((origin) => socialPlatformCatalog[candidate].originPolicy.exactOrigins.includes(origin as `https://${string}`)));
|
|
2023
|
+
if (schemaVersion === GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION && matchingKnownSurfaces.length > 0) {
|
|
2024
|
+
const legacyLinkedIn = id === "linkedin"
|
|
2025
|
+
&& matchingKnownSurfaces.length === 1
|
|
2026
|
+
&& matchingKnownSurfaces[0] === "linkedin"
|
|
2027
|
+
&& origins.every((origin) => origin === "https://www.linkedin.com")
|
|
2028
|
+
&& Object.entries(operations).every(([operationId, operation]) =>
|
|
2029
|
+
(operationId === "profile.read" && operation.risk === "R1")
|
|
2030
|
+
|| (operationId === "messaging.send" && operation.risk === "R3"));
|
|
2031
|
+
if (!legacyLinkedIn) {
|
|
2032
|
+
issues.push("schemaVersion 1 platform adapters are restricted to the bundled LinkedIn compatibility contract; migrate to schemaVersion 2 with surfaceId");
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (
|
|
2036
|
+
schemaVersion === GHOSTGET_MANIFEST_SCHEMA_VERSION
|
|
2037
|
+
|| schemaVersion === GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
2038
|
+
|| schemaVersion === GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION
|
|
2039
|
+
) {
|
|
2040
|
+
const policyOperations = Object.entries(operations).filter(([, operation]) =>
|
|
2041
|
+
schemaVersion !== GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
2042
|
+
|| isBrowserOperation(operation));
|
|
2043
|
+
if (surfaceId === undefined && matchingKnownSurfaces.length > 0) {
|
|
2044
|
+
issues.push(`manifest.surfaceId is required for reviewed platform origins (${matchingKnownSurfaces.join(", ")})`);
|
|
2045
|
+
}
|
|
2046
|
+
if (surfaceId === undefined) {
|
|
2047
|
+
for (const [operationId, operation] of policyOperations) {
|
|
2048
|
+
if (!semanticOperationNames.includes(operationId as SemanticOperationName)) {
|
|
2049
|
+
issues.push(`manifest.operations.${operationId} is not in wrench's reviewed generic semantic vocabulary`);
|
|
2050
|
+
continue;
|
|
2051
|
+
}
|
|
2052
|
+
const expectedRisk = genericSemanticRisks[operationId as SemanticOperationName];
|
|
2053
|
+
if (operation.risk !== expectedRisk) {
|
|
2054
|
+
issues.push(`manifest.operations.${operationId}.risk must be ${expectedRisk} under the generic semantic policy`);
|
|
2055
|
+
}
|
|
2056
|
+
if (operationId === "threads.publish") {
|
|
2057
|
+
validateGenericThreadSchema(operation, `manifest.operations.${operationId}`, issues);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (
|
|
2062
|
+
surfaceId !== undefined
|
|
2063
|
+
&& isPlatformSurfaceId(surfaceId)
|
|
2064
|
+
&& (
|
|
2065
|
+
schemaVersion !== GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION
|
|
2066
|
+
|| policyOperations.length > 0
|
|
2067
|
+
)
|
|
2068
|
+
) {
|
|
2069
|
+
const surface = socialPlatformCatalog[surfaceId];
|
|
2070
|
+
const baseOrigins = new Set<string>(surface.originPolicy.exactOrigins);
|
|
2071
|
+
if (!origins.some((origin) => baseOrigins.has(origin))) {
|
|
2072
|
+
issues.push(`manifest.origins must include a reviewed ${surfaceId} origin`);
|
|
2073
|
+
}
|
|
2074
|
+
const additionalOrigins = origins.filter((origin) => !baseOrigins.has(origin));
|
|
2075
|
+
if (surface.originPolicy.additionalExactOrigins.state === "forbidden" && additionalOrigins.length > 0) {
|
|
2076
|
+
issues.push(`manifest.origins contains an origin outside the reviewed ${surfaceId} policy`);
|
|
2077
|
+
} else if (
|
|
2078
|
+
surface.originPolicy.additionalExactOrigins.state === "adapter-declared"
|
|
2079
|
+
&& additionalOrigins.length > surface.originPolicy.additionalExactOrigins.maxOrigins
|
|
2080
|
+
) {
|
|
2081
|
+
issues.push(`manifest.origins may add at most ${surface.originPolicy.additionalExactOrigins.maxOrigins} reviewed publication origin for ${surfaceId}`);
|
|
2082
|
+
}
|
|
2083
|
+
for (const [operationId, operation] of policyOperations) {
|
|
2084
|
+
if (!semanticOperationNames.includes(operationId as SemanticOperationName)) {
|
|
2085
|
+
issues.push(`manifest.operations.${operationId} is not a reviewed semantic operation for ${surfaceId}`);
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
const policy = surface.operations[operationId as SemanticOperationName];
|
|
2089
|
+
if (policy.state === "unsupported" || policy.state === "not-applicable") {
|
|
2090
|
+
issues.push(`manifest.operations.${operationId} is ${policy.state} on ${surfaceId}`);
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
const expectedRisk = policy.state === "R4" ? "R4" : policy.risk;
|
|
2094
|
+
if (operation.risk !== expectedRisk) {
|
|
2095
|
+
issues.push(`manifest.operations.${operationId}.risk must be ${expectedRisk} under the reviewed ${surfaceId} policy`);
|
|
2096
|
+
}
|
|
2097
|
+
if (policy.state === "adapter-eligible") {
|
|
2098
|
+
validatePlatformCompositionSchema(
|
|
2099
|
+
surfaceId,
|
|
2100
|
+
operationId as SemanticOperationName,
|
|
2101
|
+
operation,
|
|
2102
|
+
`manifest.operations.${operationId}`,
|
|
2103
|
+
issues,
|
|
2104
|
+
);
|
|
2105
|
+
if (operationId === "threads.publish") {
|
|
2106
|
+
validatePlatformThreadSchema(surfaceId, operation, `manifest.operations.${operationId}`, issues);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (schemaVersion === GHOSTGET_PROVIDER_MANIFEST_SCHEMA_VERSION) {
|
|
2113
|
+
const hasProviderOperation = Object.values(operations).some(isProviderOperation);
|
|
2114
|
+
const hasBrowserOperation = Object.values(operations).some(isBrowserOperation);
|
|
2115
|
+
if (hasProviderOperation && surfaceId === undefined) {
|
|
2116
|
+
issues.push("manifest.surfaceId is required for schemaVersion 3 official-provider adapters");
|
|
2117
|
+
}
|
|
2118
|
+
if (
|
|
2119
|
+
requireCurrentCodeOwnedContracts
|
|
2120
|
+
&& hasProviderOperation
|
|
2121
|
+
&& surfaceId !== undefined
|
|
2122
|
+
) {
|
|
2123
|
+
const binding = registry.resolveRoute("provider-api", surfaceId);
|
|
2124
|
+
if (binding !== undefined) {
|
|
2125
|
+
validateManifestPluginOrigins(binding, origins, browserDomains, issues);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
if (hasBrowserOperation) {
|
|
2129
|
+
for (const domain of browserDomains) {
|
|
2130
|
+
if (isReviewedTemplateProtectedHostname(domain, registry)) {
|
|
2131
|
+
issues.push(
|
|
2132
|
+
`schemaVersion 3 browser actions are prohibited on protected signed-in site domain ${domain}; use a code-owned provider or schemaVersion 4 contract`,
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
for (const origin of origins) {
|
|
2137
|
+
const hostname = new URL(origin).hostname;
|
|
2138
|
+
if (isReviewedTemplateProtectedHostname(hostname, registry)) {
|
|
2139
|
+
issues.push(
|
|
2140
|
+
`schemaVersion 3 browser actions are prohibited on protected signed-in site hostname ${hostname}; use a code-owned provider or schemaVersion 4 contract`,
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
for (const [operationId, operation] of Object.entries(operations)) {
|
|
2146
|
+
if (!isProviderOperation(operation)) {
|
|
2147
|
+
if (
|
|
2148
|
+
surfaceId !== undefined
|
|
2149
|
+
&& hasCodeOwnedPluginSurface(surfaceId, registry)
|
|
2150
|
+
) {
|
|
2151
|
+
issues.push(`schemaVersion 3 browser actions are prohibited on registered provider plugin surface ${surfaceId}`);
|
|
2152
|
+
}
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
if (surfaceId !== operation.provider.provider) {
|
|
2156
|
+
issues.push(`manifest.operations.${operationId}.provider.provider must match manifest.surfaceId`);
|
|
2157
|
+
}
|
|
2158
|
+
if (operation.provider.action !== operationId) {
|
|
2159
|
+
issues.push(`manifest.operations.${operationId}.provider.action must equal its canonical operation ID`);
|
|
2160
|
+
}
|
|
2161
|
+
const descriptor = resolveManifestPluginContract(
|
|
2162
|
+
registry,
|
|
2163
|
+
"provider-api",
|
|
2164
|
+
operation.provider.provider,
|
|
2165
|
+
operation.provider.action,
|
|
2166
|
+
operation.provider.contractVersion,
|
|
2167
|
+
requireCurrentCodeOwnedContracts,
|
|
2168
|
+
);
|
|
2169
|
+
if (descriptor === undefined) {
|
|
2170
|
+
issues.push(
|
|
2171
|
+
`official provider contract ${operation.provider.provider}/${operation.provider.action}@${operation.provider.contractVersion} is not installed`,
|
|
2172
|
+
);
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
if (requireCurrentCodeOwnedContracts) {
|
|
2176
|
+
validateManifestPluginSemantics(
|
|
2177
|
+
operationId,
|
|
2178
|
+
operation,
|
|
2179
|
+
descriptor.operation,
|
|
2180
|
+
`provider contract ${operation.provider.provider}/${operationId}@${operation.provider.contractVersion}`,
|
|
2181
|
+
"provider contract",
|
|
2182
|
+
issues,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
if (schemaVersion === GHOSTGET_WEB_SESSION_MANIFEST_SCHEMA_VERSION) {
|
|
2188
|
+
const hasWebSessionOperation = Object.values(operations).some(isWebSessionOperation);
|
|
2189
|
+
if (hasWebSessionOperation && surfaceId === undefined) {
|
|
2190
|
+
issues.push("manifest.surfaceId is required for schemaVersion 4 authenticated web-session adapters");
|
|
2191
|
+
}
|
|
2192
|
+
if (
|
|
2193
|
+
requireCurrentCodeOwnedContracts
|
|
2194
|
+
&& hasWebSessionOperation
|
|
2195
|
+
&& surfaceId !== undefined
|
|
2196
|
+
) {
|
|
2197
|
+
const binding = registry.resolveSessionRoute(surfaceId);
|
|
2198
|
+
if (binding !== undefined) {
|
|
2199
|
+
validateManifestPluginOrigins(binding, origins, browserDomains, issues);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
for (const [operationId, operation] of Object.entries(operations)) {
|
|
2203
|
+
if (!isWebSessionOperation(operation)) {
|
|
2204
|
+
issues.push(`manifest.operations.${operationId} must use a code-owned webSession contract in schemaVersion 4`);
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
if (surfaceId !== operation.webSession.site) {
|
|
2208
|
+
issues.push(`manifest.operations.${operationId}.webSession.site must match manifest.surfaceId`);
|
|
2209
|
+
}
|
|
2210
|
+
if (operation.webSession.action !== operationId) {
|
|
2211
|
+
issues.push(`manifest.operations.${operationId}.webSession.action must equal its canonical operation ID`);
|
|
2212
|
+
}
|
|
2213
|
+
const descriptor = resolveManifestPluginContract(
|
|
2214
|
+
registry,
|
|
2215
|
+
"session-api",
|
|
2216
|
+
operation.webSession.site,
|
|
2217
|
+
operation.webSession.action,
|
|
2218
|
+
operation.webSession.contractVersion,
|
|
2219
|
+
requireCurrentCodeOwnedContracts,
|
|
2220
|
+
);
|
|
2221
|
+
if (descriptor === undefined) {
|
|
2222
|
+
if (isExactRetiredBeeperDiagnostic) continue;
|
|
2223
|
+
issues.push(
|
|
2224
|
+
`authenticated web contract ${operation.webSession.site}/${operation.webSession.action}@${operation.webSession.contractVersion} is not installed`,
|
|
2225
|
+
);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
if (requireCurrentCodeOwnedContracts) {
|
|
2229
|
+
validateManifestPluginSemantics(
|
|
2230
|
+
operationId,
|
|
2231
|
+
operation,
|
|
2232
|
+
descriptor.operation,
|
|
2233
|
+
`authenticated web contract ${operation.webSession.site}/${operationId}@${operation.webSession.contractVersion}`,
|
|
2234
|
+
"authenticated web contract",
|
|
2235
|
+
issues,
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
if (schemaVersion === GHOSTGET_LOCAL_CLI_MANIFEST_SCHEMA_VERSION) {
|
|
2241
|
+
const hasLocalCliOperation = Object.values(operations).some(isLocalCliOperation);
|
|
2242
|
+
if (hasLocalCliOperation && surfaceId === undefined) {
|
|
2243
|
+
issues.push("manifest.surfaceId is required for schemaVersion 6 local CLI adapters");
|
|
2244
|
+
}
|
|
2245
|
+
if (
|
|
2246
|
+
requireCurrentCodeOwnedContracts
|
|
2247
|
+
&& hasLocalCliOperation
|
|
2248
|
+
&& surfaceId !== undefined
|
|
2249
|
+
) {
|
|
2250
|
+
const binding = registry.resolveRoute("local-cli", surfaceId);
|
|
2251
|
+
if (binding !== undefined) {
|
|
2252
|
+
validateManifestPluginOrigins(binding, origins, browserDomains, issues);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
for (const [operationId, operation] of Object.entries(operations)) {
|
|
2256
|
+
if (!isLocalCliOperation(operation)) {
|
|
2257
|
+
issues.push(`manifest.operations.${operationId} must use a code-owned localCli contract in schemaVersion 6`);
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
if (surfaceId !== operation.localCli.surface) {
|
|
2261
|
+
issues.push(`manifest.operations.${operationId}.localCli.surface must match manifest.surfaceId`);
|
|
2262
|
+
}
|
|
2263
|
+
if (operation.localCli.action !== operationId) {
|
|
2264
|
+
issues.push(`manifest.operations.${operationId}.localCli.action must equal its canonical operation ID`);
|
|
2265
|
+
}
|
|
2266
|
+
const descriptor = resolveManifestPluginContract(
|
|
2267
|
+
registry,
|
|
2268
|
+
"local-cli",
|
|
2269
|
+
operation.localCli.surface,
|
|
2270
|
+
operation.localCli.action,
|
|
2271
|
+
operation.localCli.contractVersion,
|
|
2272
|
+
requireCurrentCodeOwnedContracts,
|
|
2273
|
+
);
|
|
2274
|
+
if (descriptor === undefined) {
|
|
2275
|
+
issues.push(
|
|
2276
|
+
`local CLI contract ${operation.localCli.surface}/${operation.localCli.action}@${operation.localCli.contractVersion} is not installed`,
|
|
2277
|
+
);
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
if (requireCurrentCodeOwnedContracts) {
|
|
2281
|
+
validateManifestPluginSemantics(
|
|
2282
|
+
operationId,
|
|
2283
|
+
operation,
|
|
2284
|
+
descriptor.operation,
|
|
2285
|
+
`local CLI contract ${operation.localCli.surface}/${operationId}@${operation.localCli.contractVersion}`,
|
|
2286
|
+
"local CLI contract",
|
|
2287
|
+
issues,
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
if (schemaVersion === GHOSTGET_REVIEWED_TEMPLATE_MANIFEST_SCHEMA_VERSION) {
|
|
2293
|
+
for (const origin of origins) {
|
|
2294
|
+
const url = new URL(origin);
|
|
2295
|
+
if (url.port !== "") {
|
|
2296
|
+
issues.push(`schemaVersion 5 reviewed-template reservations require the default HTTPS port; ${origin} is not allowed`);
|
|
2297
|
+
}
|
|
2298
|
+
if (isReviewedTemplateProtectedHostname(url.hostname, registry)) {
|
|
2299
|
+
issues.push(
|
|
2300
|
+
`schemaVersion 5 reviewed-template reservations are prohibited on protected signed-in site hostname ${url.hostname}; use a code-owned schemaVersion 4 contract`,
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
if (
|
|
2305
|
+
surfaceId !== undefined
|
|
2306
|
+
&& hasCodeOwnedPluginSurface(surfaceId, registry)
|
|
2307
|
+
) {
|
|
2308
|
+
issues.push(`schemaVersion 5 reviewed templates are prohibited on registered provider plugin surface ${surfaceId}`);
|
|
2309
|
+
}
|
|
2310
|
+
for (const [operationId, operation] of Object.entries(operations)) {
|
|
2311
|
+
if (!isReviewedTemplateOperation(operation)) {
|
|
2312
|
+
issues.push(`manifest.operations.${operationId} must use a reviewedTemplate contract in schemaVersion 5`);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
if (
|
|
2317
|
+
schemaVersion === GHOSTGET_MANIFEST_SCHEMA_VERSION
|
|
2318
|
+
&& Object.keys(operations).length > 0
|
|
2319
|
+
) {
|
|
2320
|
+
const hasBrowserOperation = Object.values(operations).some(isBrowserOperation);
|
|
2321
|
+
if (
|
|
2322
|
+
surfaceId !== undefined
|
|
2323
|
+
&& hasCodeOwnedPluginSurface(surfaceId, registry)
|
|
2324
|
+
) {
|
|
2325
|
+
issues.push(`schemaVersion 2 browser actions are prohibited on registered provider plugin surface ${surfaceId}`);
|
|
2326
|
+
}
|
|
2327
|
+
if (hasBrowserOperation) {
|
|
2328
|
+
for (const domain of browserDomains) {
|
|
2329
|
+
if (isReviewedTemplateProtectedHostname(domain, registry)) {
|
|
2330
|
+
issues.push(
|
|
2331
|
+
`schemaVersion 2 browser actions are prohibited on protected signed-in site domain ${domain}; use a code-owned provider or schemaVersion 4 contract`,
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
for (const origin of origins) {
|
|
2336
|
+
const hostname = new URL(origin).hostname;
|
|
2337
|
+
if (isReviewedTemplateProtectedHostname(hostname, registry)) {
|
|
2338
|
+
issues.push(
|
|
2339
|
+
`schemaVersion 2 browser actions are prohibited on protected signed-in site hostname ${hostname}; use a code-owned provider or schemaVersion 4 contract`,
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (issues.length > 0 || id === null || version === null || displayName === null) return { ok: false, issues };
|
|
2346
|
+
const manifest: GhostgetManifest = {
|
|
2347
|
+
schemaVersion,
|
|
2348
|
+
id,
|
|
2349
|
+
version,
|
|
2350
|
+
displayName,
|
|
2351
|
+
...(surfaceId === undefined ? {} : { surfaceId }),
|
|
2352
|
+
origins,
|
|
2353
|
+
browserDomains,
|
|
2354
|
+
operations,
|
|
2355
|
+
};
|
|
2356
|
+
if (
|
|
2357
|
+
schemaVersion === GHOSTGET_LEGACY_MANIFEST_SCHEMA_VERSION
|
|
2358
|
+
&& id === "linkedin"
|
|
2359
|
+
&& sha256(canonicalJson(value)) !== GHOSTGET_LEGACY_LINKEDIN_MANIFEST_HASH
|
|
2360
|
+
) {
|
|
2361
|
+
return {
|
|
2362
|
+
ok: false,
|
|
2363
|
+
issues: ["schemaVersion 1 LinkedIn compatibility is restricted to the exact archived v0.4.0 manifest"],
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
return { ok: true, value: manifest };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/** Parse a secret-free ghostget adapter manifest and require every current code-owned contract. */
|
|
2370
|
+
export function parseManifest(
|
|
2371
|
+
value: unknown,
|
|
2372
|
+
registry: ProviderPluginRegistry,
|
|
2373
|
+
): ParseResult<GhostgetManifest> {
|
|
2374
|
+
return parseManifestWithContractValidation(value, true, registry);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* Parse retired adapter bytes as inert migration evidence.
|
|
2379
|
+
*
|
|
2380
|
+
* This preserves structural, platform-policy, surface, and action validation,
|
|
2381
|
+
* but deliberately does not make a retired provider or web-session contract
|
|
2382
|
+
* current again.
|
|
2383
|
+
*/
|
|
2384
|
+
export function parseDiagnosticManifest(
|
|
2385
|
+
value: unknown,
|
|
2386
|
+
registry: ProviderPluginRegistry,
|
|
2387
|
+
): ParseResult<GhostgetManifest> {
|
|
2388
|
+
return parseManifestWithContractValidation(value, false, registry);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* Parse a manifest that may cross an install or invocation boundary.
|
|
2393
|
+
*
|
|
2394
|
+
* `parseManifest` intentionally retains the retired schema-v1/v2 browser
|
|
2395
|
+
* grammar so old files can be diagnosed precisely. Retired DOM recipes are
|
|
2396
|
+
* never installable or executable, including when a typed value is passed
|
|
2397
|
+
* directly instead of being read from disk.
|
|
2398
|
+
*/
|
|
2399
|
+
export function parseRuntimeManifest(
|
|
2400
|
+
value: unknown,
|
|
2401
|
+
registry: ProviderPluginRegistry,
|
|
2402
|
+
): ParseResult<GhostgetManifest> {
|
|
2403
|
+
const parsed = parseManifest(value, registry);
|
|
2404
|
+
if (!parsed.ok) return parsed;
|
|
2405
|
+
const browserOperations = Object.entries(parsed.value.operations)
|
|
2406
|
+
.filter(([, operation]) => isBrowserOperation(operation))
|
|
2407
|
+
.map(([operationId]) => operationId);
|
|
2408
|
+
if (browserOperations.length > 0) {
|
|
2409
|
+
return {
|
|
2410
|
+
ok: false,
|
|
2411
|
+
issues: browserOperations.map((operationId) =>
|
|
2412
|
+
`manifest.operations.${operationId}.browser: ${DOM_ACTION_TRANSPORT_DISABLED_MESSAGE}`),
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
return parsed;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
export function validateOperationInput(
|
|
2419
|
+
schema: InputSchema,
|
|
2420
|
+
value: unknown,
|
|
2421
|
+
origins: readonly string[],
|
|
2422
|
+
): ParseResult<OperationInput> {
|
|
2423
|
+
const issues: string[] = [];
|
|
2424
|
+
if (!isRecord(value)) return { ok: false, issues: ["input must be a JSON object"] };
|
|
2425
|
+
const output: Record<string, InputValue> = {};
|
|
2426
|
+
for (const key of Object.keys(value)) {
|
|
2427
|
+
if (!(key in schema.properties)) issues.push(`input.${key} is not supported`);
|
|
2428
|
+
}
|
|
2429
|
+
for (const key of schema.required) {
|
|
2430
|
+
if (!(key in value)) issues.push(`input.${key} is required`);
|
|
2431
|
+
}
|
|
2432
|
+
const validateValue = (
|
|
2433
|
+
field: ScalarInputField | FileInputField,
|
|
2434
|
+
candidate: unknown,
|
|
2435
|
+
path: string,
|
|
2436
|
+
): ScalarInputValue | FileInputValue | null => {
|
|
2437
|
+
if (field.type === "file") {
|
|
2438
|
+
if (
|
|
2439
|
+
typeof candidate !== "string"
|
|
2440
|
+
|| candidate.length < 1
|
|
2441
|
+
|| candidate.length > 4_096
|
|
2442
|
+
|| candidate.includes(String.fromCharCode(0))
|
|
2443
|
+
|| hasUnpairedSurrogate(candidate)
|
|
2444
|
+
) {
|
|
2445
|
+
issues.push(`${path} must be a non-empty opaque file reference`);
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
return { kind: "file", reference: candidate };
|
|
2449
|
+
}
|
|
2450
|
+
if (typeof candidate !== field.type) {
|
|
2451
|
+
issues.push(`${path} must be ${field.type}`);
|
|
2452
|
+
return null;
|
|
2453
|
+
}
|
|
2454
|
+
if (typeof candidate === "string") {
|
|
2455
|
+
if (candidate.length < (field.minLength ?? 0) || candidate.length > (field.maxLength ?? 64 * 1024)) {
|
|
2456
|
+
issues.push(`${path} has an invalid length`);
|
|
2457
|
+
return null;
|
|
2458
|
+
}
|
|
2459
|
+
if (candidate.includes(String.fromCharCode(0))) {
|
|
2460
|
+
issues.push(`${path} must not contain NUL`);
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2463
|
+
if (hasUnpairedSurrogate(candidate)) {
|
|
2464
|
+
issues.push(`${path} must contain well-formed Unicode`);
|
|
2465
|
+
return null;
|
|
2466
|
+
}
|
|
2467
|
+
if (field.format === "url") {
|
|
2468
|
+
try {
|
|
2469
|
+
if (field.urlPathPrefixes !== undefined && hasAmbiguousPathSyntax(rawUrlPath(candidate))) {
|
|
2470
|
+
issues.push(`${path} must use an unambiguous allowed URL path`);
|
|
2471
|
+
return null;
|
|
2472
|
+
}
|
|
2473
|
+
const url = new URL(candidate);
|
|
2474
|
+
if (!origins.includes(url.origin) || url.username !== "" || url.password !== "") {
|
|
2475
|
+
issues.push(`${path} must use an adapter origin and contain no credentials`);
|
|
2476
|
+
return null;
|
|
2477
|
+
}
|
|
2478
|
+
if (
|
|
2479
|
+
field.urlPathPrefixes !== undefined
|
|
2480
|
+
&& !field.urlPathPrefixes.some((prefix) => matchesUrlPathPrefix(url.pathname, prefix))
|
|
2481
|
+
) {
|
|
2482
|
+
issues.push(`${path} must use an allowed URL path prefix`);
|
|
2483
|
+
return null;
|
|
2484
|
+
}
|
|
2485
|
+
} catch {
|
|
2486
|
+
issues.push(`${path} must be a valid URL`);
|
|
2487
|
+
return null;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
if (
|
|
2491
|
+
field.format === "path-segment"
|
|
2492
|
+
&& (candidate === "." || candidate === ".." || candidate.includes("/") || candidate.includes("\\") || candidate.includes("%"))
|
|
2493
|
+
) {
|
|
2494
|
+
issues.push(`${path} must be one unambiguous URL path segment`);
|
|
2495
|
+
return null;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
if (typeof candidate === "number" && (!Number.isFinite(candidate) || candidate < (field.minimum ?? -Infinity) || candidate > (field.maximum ?? Infinity))) {
|
|
2499
|
+
issues.push(`${path} is outside its numeric bounds`);
|
|
2500
|
+
return null;
|
|
2501
|
+
}
|
|
2502
|
+
if (field.enum !== undefined && !field.enum.some((enumValue) => Object.is(enumValue, candidate))) {
|
|
2503
|
+
issues.push(`${path} is not an allowed value`);
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
return candidate as ScalarInputValue;
|
|
2507
|
+
};
|
|
2508
|
+
for (const [key, field] of Object.entries(schema.properties)) {
|
|
2509
|
+
const candidate = value[key];
|
|
2510
|
+
if (candidate === undefined) continue;
|
|
2511
|
+
if (field.type === "array") {
|
|
2512
|
+
if (!Array.isArray(candidate)) {
|
|
2513
|
+
issues.push(`input.${key} must be array`);
|
|
2514
|
+
continue;
|
|
2515
|
+
}
|
|
2516
|
+
if (candidate.length < field.minItems || candidate.length > field.maxItems) {
|
|
2517
|
+
issues.push(`input.${key} must contain ${field.minItems}-${field.maxItems} items`);
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
const parsed: (ScalarInputValue | FileInputValue)[] = [];
|
|
2521
|
+
candidate.forEach((item, index) => {
|
|
2522
|
+
const result = validateValue(field.items, item, `input.${key}[${index}]`);
|
|
2523
|
+
if (result !== null) parsed.push(result);
|
|
2524
|
+
});
|
|
2525
|
+
if (parsed.length === candidate.length) output[key] = parsed;
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
const parsed = validateValue(field, candidate, `input.${key}`);
|
|
2529
|
+
if (parsed !== null) output[key] = parsed;
|
|
2530
|
+
}
|
|
2531
|
+
return issues.length === 0 ? { ok: true, value: output } : { ok: false, issues };
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/** Enforce value-level text limits from a manifest's bound platform policy. */
|
|
2535
|
+
export function validatePlatformOperationInput(
|
|
2536
|
+
manifest: GhostgetManifest,
|
|
2537
|
+
operationId: string,
|
|
2538
|
+
input: OperationInput,
|
|
2539
|
+
): ParseResult<OperationInput> {
|
|
2540
|
+
if (
|
|
2541
|
+
manifest.surfaceId === undefined
|
|
2542
|
+
|| !isPlatformSurfaceId(manifest.surfaceId)
|
|
2543
|
+
|| !semanticOperationNames.includes(operationId as SemanticOperationName)
|
|
2544
|
+
) {
|
|
2545
|
+
return { ok: true, value: input };
|
|
2546
|
+
}
|
|
2547
|
+
if (operationId === "threads.publish") {
|
|
2548
|
+
const policy = threadTextPolicy(manifest.surfaceId);
|
|
2549
|
+
const items = input.items;
|
|
2550
|
+
if (policy === null || !Array.isArray(items)) {
|
|
2551
|
+
return { ok: false, issues: [`threads.publish has no valid reviewed item policy on ${manifest.surfaceId}`] };
|
|
2552
|
+
}
|
|
2553
|
+
const issues: string[] = [];
|
|
2554
|
+
for (const [index, item] of items.entries()) {
|
|
2555
|
+
if (typeof item !== "string") {
|
|
2556
|
+
issues.push(`input.items[${index}] must be thread text`);
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
const length = weightedTextLength(item, textWeightPolicies[policy.measurement]);
|
|
2560
|
+
if (length > policy.maxWeightedLength) {
|
|
2561
|
+
issues.push(`input.items[${index}] weighs ${length}, above the reviewed ${policy.maxWeightedLength}-unit ${manifest.surfaceId} limit`);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
return issues.length === 0 ? { ok: true, value: input } : { ok: false, issues };
|
|
2565
|
+
}
|
|
2566
|
+
const compositionName = operationCompositions[operationId as SemanticOperationName];
|
|
2567
|
+
if (compositionName === undefined) return { ok: true, value: input };
|
|
2568
|
+
const surface = socialPlatformCatalog[manifest.surfaceId] as PlatformSurfaceCatalogEntry;
|
|
2569
|
+
const composition = surface.compositions[compositionName];
|
|
2570
|
+
if (composition === undefined) return { ok: false, issues: [`${operationId} has no reviewed composition policy on ${manifest.surfaceId}`] };
|
|
2571
|
+
const operation = manifest.operations[operationId];
|
|
2572
|
+
const structuredArticleDraft = operationId === "articles.draft.save"
|
|
2573
|
+
&& operation !== undefined
|
|
2574
|
+
&& isWebSessionOperation(operation)
|
|
2575
|
+
&& operation.input.properties.document !== undefined;
|
|
2576
|
+
const issues: string[] = [];
|
|
2577
|
+
for (const field of composition.text) {
|
|
2578
|
+
// The draft contract replaces the plain body with a strictly parsed
|
|
2579
|
+
// versioned document. Its provider runtime owns text/range bounds, while the
|
|
2580
|
+
// platform composition continues to enforce the title policy here.
|
|
2581
|
+
if (structuredArticleDraft && field.name === "body") continue;
|
|
2582
|
+
const value = input[field.name];
|
|
2583
|
+
if (value === undefined) {
|
|
2584
|
+
if (field.required) issues.push(`input.${field.name} is required by the reviewed ${manifest.surfaceId} policy`);
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
if (typeof value !== "string") {
|
|
2588
|
+
issues.push(`input.${field.name} must be text under the reviewed ${manifest.surfaceId} policy`);
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
const length = weightedTextLength(value, textWeightPolicies[field.measurement]);
|
|
2592
|
+
if (length > field.safeMaxUnits) {
|
|
2593
|
+
issues.push(`input.${field.name} weighs ${length}, above the reviewed ${field.safeMaxUnits}-unit ${manifest.surfaceId} limit`);
|
|
2594
|
+
}
|
|
2595
|
+
if (field.format === "decimal-amount" && !/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,2})?$/u.test(value)) {
|
|
2596
|
+
issues.push(`input.${field.name} must be a non-negative decimal amount with at most two fractional digits`);
|
|
2597
|
+
}
|
|
2598
|
+
if (field.format === "currency-code" && !/^[A-Z]{3}$/u.test(value)) {
|
|
2599
|
+
issues.push(`input.${field.name} must be a three-letter uppercase currency code`);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return issues.length === 0 ? { ok: true, value: input } : { ok: false, issues };
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
function bindRepeatedDispatch(
|
|
2606
|
+
step: Exclude<BrowserRecipeStep, { readonly kind: "for-each" }>,
|
|
2607
|
+
suffix: string,
|
|
2608
|
+
): Exclude<BrowserRecipeStep, { readonly kind: "for-each" }> {
|
|
2609
|
+
if ((step.kind === "find" || step.kind === "press") && step.effect?.kind === "dispatch") {
|
|
2610
|
+
return { ...step, effect: { ...step.effect, id: `${step.effect.id}${suffix}` } };
|
|
2611
|
+
}
|
|
2612
|
+
if (step.kind === "verify-dispatch") return { ...step, dispatch: `${step.dispatch}${suffix}` };
|
|
2613
|
+
return step;
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/**
|
|
2617
|
+
* Expand bounded control structure before a browser session starts. The returned
|
|
2618
|
+
* dispatch list is the exact, ordered schedule the runtime can persist in a plan.
|
|
2619
|
+
*/
|
|
2620
|
+
export function expandBrowserRecipe(recipe: BrowserRecipe, input: OperationInput): ExpandedBrowserRecipe {
|
|
2621
|
+
const steps: ExpandedBrowserRecipeStep[] = [];
|
|
2622
|
+
const dispatches: BrowserDispatchPlan[] = [];
|
|
2623
|
+
let legacyDispatches = 0;
|
|
2624
|
+
const append = (
|
|
2625
|
+
step: Exclude<BrowserRecipeStep, { readonly kind: "for-each" }>,
|
|
2626
|
+
item?: ScalarInputValue | FileInputValue,
|
|
2627
|
+
): void => {
|
|
2628
|
+
const effect = dispatchEffect(step);
|
|
2629
|
+
if (effect !== null) dispatches.push({ id: effect.id, description: effect.description });
|
|
2630
|
+
else if ((step.kind === "find" || step.kind === "press") && step.dispatch === true) {
|
|
2631
|
+
legacyDispatches += 1;
|
|
2632
|
+
dispatches.push({ id: legacyDispatches === 1 ? "dispatch" : `dispatch-${legacyDispatches}`, description: "Legacy manifest dispatch" });
|
|
2633
|
+
}
|
|
2634
|
+
steps.push({ step, ...(item === undefined ? {} : { item }) });
|
|
2635
|
+
if (steps.length > 500) throw new Error("browser recipe expands beyond 500 steps");
|
|
2636
|
+
if (dispatches.length > 25) throw new Error("browser recipe expands beyond 25 dispatches");
|
|
2637
|
+
};
|
|
2638
|
+
for (const step of recipe.steps) {
|
|
2639
|
+
if (step.kind !== "for-each") {
|
|
2640
|
+
append(step);
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
const rawValues = input[step.input];
|
|
2644
|
+
if (!Array.isArray(rawValues)) throw new Error(`for-each input.${step.input} must be a validated array`);
|
|
2645
|
+
const values = rawValues as readonly (ScalarInputValue | FileInputValue)[];
|
|
2646
|
+
if (values.length > 25) throw new Error(`for-each input.${step.input} exceeds 25 items`);
|
|
2647
|
+
for (const [index, item] of values.entries()) {
|
|
2648
|
+
const suffix = `[${index + 1}]`;
|
|
2649
|
+
for (const nested of step.steps) {
|
|
2650
|
+
if (nested.kind === "for-each") throw new Error("nested for-each is not supported");
|
|
2651
|
+
append(bindRepeatedDispatch(nested, suffix), item);
|
|
2652
|
+
}
|
|
2653
|
+
if (index < values.length - 1) {
|
|
2654
|
+
for (const nested of step.between ?? []) {
|
|
2655
|
+
if (nested.kind === "for-each") throw new Error("nested for-each is not supported");
|
|
2656
|
+
append(bindRepeatedDispatch(nested, suffix), item);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
const ids = new Set<string>();
|
|
2662
|
+
for (const dispatch of dispatches) {
|
|
2663
|
+
if (ids.has(dispatch.id)) throw new Error(`browser recipe has duplicate expanded dispatch id ${dispatch.id}`);
|
|
2664
|
+
ids.add(dispatch.id);
|
|
2665
|
+
}
|
|
2666
|
+
return { timeoutMs: recipe.timeoutMs, maxOutputBytes: recipe.maxOutputBytes, steps, dispatches };
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
export function manifestHash(manifest: GhostgetManifest): string {
|
|
2670
|
+
return sha256(canonicalJson(manifest));
|
|
2671
|
+
}
|