@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
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference -- packed source types must include Bun asset modules
|
|
2
|
+
/// <reference path="./assets.d.ts" />
|
|
3
|
+
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
createHash,
|
|
7
|
+
randomBytes,
|
|
8
|
+
randomUUID,
|
|
9
|
+
} from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
constants,
|
|
13
|
+
fstatSync,
|
|
14
|
+
lstatSync,
|
|
15
|
+
openSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
readSync,
|
|
18
|
+
type BigIntStats,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { createServer, type ServerResponse } from "node:http";
|
|
21
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import nebulaSansBookAsset from "./assets/fonts/nebula-sans/NebulaSans-Book.woff2" with { type: "file" };
|
|
25
|
+
import {
|
|
26
|
+
createAuth,
|
|
27
|
+
loadAuthSnapshotIfPresent,
|
|
28
|
+
saveAuth,
|
|
29
|
+
type GhostgetAuth,
|
|
30
|
+
} from "./auth";
|
|
31
|
+
import { canonicalJson } from "./canonical-json";
|
|
32
|
+
import {
|
|
33
|
+
loadOAuthCredential,
|
|
34
|
+
loadOAuthToken,
|
|
35
|
+
ProviderHttpClient,
|
|
36
|
+
type GoogleInstalledAppRefresh,
|
|
37
|
+
type LoadedOAuthToken,
|
|
38
|
+
type OAuthTokenAuth,
|
|
39
|
+
type ProviderFetch,
|
|
40
|
+
} from "./provider-http";
|
|
41
|
+
import { isGmailAccountSubject } from "./provider-subject";
|
|
42
|
+
import { pinnedHttpsFetch, type PinnedHttpsFetch } from "./pinned-https";
|
|
43
|
+
import {
|
|
44
|
+
createPrivateJsonIfAbsent,
|
|
45
|
+
readPrivateStateFileIfPresent,
|
|
46
|
+
removePrivateStateFileIfUnchanged,
|
|
47
|
+
ghostgetStateHome,
|
|
48
|
+
writePrivateJsonIfUnchanged,
|
|
49
|
+
} from "./storage";
|
|
50
|
+
|
|
51
|
+
const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
52
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
53
|
+
const GOOGLE_PROFILE_URL = "https://gmail.googleapis.com/gmail/v1/users/me/profile?fields=emailAddress";
|
|
54
|
+
const GOOGLE_TOKEN_HOSTS = ["oauth2.googleapis.com"] as const;
|
|
55
|
+
const GOOGLE_GMAIL_HOSTS = ["gmail.googleapis.com"] as const;
|
|
56
|
+
const GOOGLE_OAUTH_TIMEOUT_MS = 60_000;
|
|
57
|
+
const GOOGLE_AUTHORIZATION_TIMEOUT_MS = 10 * 60_000;
|
|
58
|
+
const MAX_GOOGLE_CLIENT_FILE_BYTES = 64 * 1024;
|
|
59
|
+
const MAX_GOOGLE_RESPONSE_BYTES = 64 * 1024;
|
|
60
|
+
const CALLBACK_PATH = "/oauth2/callback";
|
|
61
|
+
const GOOGLE_OAUTH_NEBULA_SANS_PATH = isAbsolute(nebulaSansBookAsset)
|
|
62
|
+
? nebulaSansBookAsset
|
|
63
|
+
: fileURLToPath(new URL(nebulaSansBookAsset, import.meta.url));
|
|
64
|
+
const GOOGLE_OAUTH_NEBULA_SANS_BYTES = readFileSync(GOOGLE_OAUTH_NEBULA_SANS_PATH);
|
|
65
|
+
const GOOGLE_OAUTH_NEBULA_SANS_SHA256 = "4d396c7c7f93b3f9d8e90d5a8c5e28b29266243946d4320783abc3628d9ef8df";
|
|
66
|
+
if (
|
|
67
|
+
GOOGLE_OAUTH_NEBULA_SANS_BYTES.byteLength !== 70_652
|
|
68
|
+
|| createHash("sha256").update(GOOGLE_OAUTH_NEBULA_SANS_BYTES).digest("hex")
|
|
69
|
+
!== GOOGLE_OAUTH_NEBULA_SANS_SHA256
|
|
70
|
+
) {
|
|
71
|
+
throw new Error("Google OAuth completion font did not match the reviewed Nebula Sans release");
|
|
72
|
+
}
|
|
73
|
+
const GOOGLE_OAUTH_NEBULA_SANS_DATA_URL =
|
|
74
|
+
`data:font/woff2;base64,${GOOGLE_OAUTH_NEBULA_SANS_BYTES.toString("base64")}`;
|
|
75
|
+
|
|
76
|
+
export const GOOGLE_GMAIL_READ_SCOPES = Object.freeze([
|
|
77
|
+
"https://www.googleapis.com/auth/contacts.other.readonly",
|
|
78
|
+
"https://www.googleapis.com/auth/contacts.readonly",
|
|
79
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
type JsonRecord = Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
type GoogleDesktopClient = Readonly<{
|
|
85
|
+
clientId: string;
|
|
86
|
+
clientSecret: string | null;
|
|
87
|
+
projectId: string;
|
|
88
|
+
}>;
|
|
89
|
+
|
|
90
|
+
type GoogleTokenExchange = Readonly<{
|
|
91
|
+
accessToken: string;
|
|
92
|
+
expiresInSeconds: number;
|
|
93
|
+
refreshToken: string | null;
|
|
94
|
+
refreshTokenExpiresInSeconds: number | null;
|
|
95
|
+
}>;
|
|
96
|
+
|
|
97
|
+
export type GoogleOAuthCredentialDocument = Readonly<{
|
|
98
|
+
schemaVersion: 2;
|
|
99
|
+
provider: "gmail";
|
|
100
|
+
subject: string;
|
|
101
|
+
scopes: readonly string[];
|
|
102
|
+
accessToken: string;
|
|
103
|
+
expiresAt: string;
|
|
104
|
+
refresh: GoogleInstalledAppRefresh;
|
|
105
|
+
}>;
|
|
106
|
+
|
|
107
|
+
export type GoogleOAuthLoginResult = Readonly<{
|
|
108
|
+
subject: string;
|
|
109
|
+
scopes: readonly string[];
|
|
110
|
+
credential: GoogleOAuthCredentialDocument;
|
|
111
|
+
refreshTokenExpiresAt: string | null;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
export type GoogleOAuthAuthorizationRequest = Readonly<{
|
|
115
|
+
clientId: string;
|
|
116
|
+
scopes: readonly string[];
|
|
117
|
+
state: string;
|
|
118
|
+
codeChallenge: string;
|
|
119
|
+
openBrowser: boolean;
|
|
120
|
+
onAuthorizationUrl: (url: string) => void;
|
|
121
|
+
signal?: AbortSignal;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
export type GoogleOAuthLoginDependencies = Readonly<{
|
|
125
|
+
authorize?: (
|
|
126
|
+
request: GoogleOAuthAuthorizationRequest,
|
|
127
|
+
) => Promise<Readonly<{ code: string; redirectUri: string }>>;
|
|
128
|
+
fetch?: ProviderFetch;
|
|
129
|
+
pinnedFetch?: PinnedHttpsFetch;
|
|
130
|
+
randomBytes?: (size: number) => Uint8Array;
|
|
131
|
+
now?: () => Date;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
134
|
+
function strictRecord(value: unknown, label: string): JsonRecord {
|
|
135
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
136
|
+
throw new Error(`${label} must be an object`);
|
|
137
|
+
}
|
|
138
|
+
return value as JsonRecord;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function exactKeys(
|
|
142
|
+
value: JsonRecord,
|
|
143
|
+
required: readonly string[],
|
|
144
|
+
optional: readonly string[],
|
|
145
|
+
label: string,
|
|
146
|
+
): void {
|
|
147
|
+
const allowed = new Set([...required, ...optional]);
|
|
148
|
+
for (const key of Object.keys(value)) {
|
|
149
|
+
if (!allowed.has(key)) throw new Error(`${label} contains unsupported field ${key}`);
|
|
150
|
+
}
|
|
151
|
+
for (const key of required) {
|
|
152
|
+
if (!Object.hasOwn(value, key)) throw new Error(`${label} is missing field ${key}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function safeText(
|
|
157
|
+
value: unknown,
|
|
158
|
+
label: string,
|
|
159
|
+
minimumBytes: number,
|
|
160
|
+
maximumBytes: number,
|
|
161
|
+
): string {
|
|
162
|
+
if (
|
|
163
|
+
typeof value !== "string"
|
|
164
|
+
|| Buffer.byteLength(value, "utf8") < minimumBytes
|
|
165
|
+
|| Buffer.byteLength(value, "utf8") > maximumBytes
|
|
166
|
+
|| [...value].some((character) => {
|
|
167
|
+
const code = character.codePointAt(0) ?? -1;
|
|
168
|
+
return code <= 0x20 || code === 0x7f;
|
|
169
|
+
})
|
|
170
|
+
) throw new Error(`${label} must be bounded text without whitespace or control characters`);
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function safeInteger(
|
|
175
|
+
value: unknown,
|
|
176
|
+
label: string,
|
|
177
|
+
minimum: number,
|
|
178
|
+
maximum: number,
|
|
179
|
+
): number {
|
|
180
|
+
if (
|
|
181
|
+
typeof value !== "number"
|
|
182
|
+
|| !Number.isSafeInteger(value)
|
|
183
|
+
|| value < minimum
|
|
184
|
+
|| value > maximum
|
|
185
|
+
) throw new Error(`${label} must be an integer from ${minimum} through ${maximum}`);
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function exactSortedScopes(value: unknown, expected: readonly string[], label: string): void {
|
|
190
|
+
if (!Array.isArray(value) || !value.every((scope) => typeof scope === "string")) {
|
|
191
|
+
throw new Error(`${label} must be a scope list`);
|
|
192
|
+
}
|
|
193
|
+
const actual = [...value].sort();
|
|
194
|
+
if (
|
|
195
|
+
actual.length !== expected.length
|
|
196
|
+
|| actual.some((scope, index) => scope !== expected[index])
|
|
197
|
+
) throw new Error(`${label} does not exactly match the requested Google scopes`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sameFile(left: BigIntStats, right: BigIntStats): boolean {
|
|
201
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readBoundedGoogleClientFile(pathValue: string): string {
|
|
205
|
+
const path = resolve(pathValue);
|
|
206
|
+
const pathStats = lstatSync(path, { bigint: true });
|
|
207
|
+
const uid = typeof process.getuid === "function" ? BigInt(process.getuid()) : null;
|
|
208
|
+
if (
|
|
209
|
+
pathStats.isSymbolicLink()
|
|
210
|
+
|| !pathStats.isFile()
|
|
211
|
+
|| (uid !== null && pathStats.uid !== uid)
|
|
212
|
+
|| pathStats.size < 1n
|
|
213
|
+
|| pathStats.size > BigInt(MAX_GOOGLE_CLIENT_FILE_BYTES)
|
|
214
|
+
) {
|
|
215
|
+
throw new Error("Google OAuth client file must be a current-user-owned regular file of at most 65536 bytes");
|
|
216
|
+
}
|
|
217
|
+
const descriptor = openSync(
|
|
218
|
+
path,
|
|
219
|
+
constants.O_RDONLY
|
|
220
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
|
|
221
|
+
| ("O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0),
|
|
222
|
+
);
|
|
223
|
+
try {
|
|
224
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
225
|
+
if (!sameFile(pathStats, before) || before.size !== pathStats.size) {
|
|
226
|
+
throw new Error("Google OAuth client file changed while it was opened");
|
|
227
|
+
}
|
|
228
|
+
const buffer = Buffer.alloc(Number(before.size));
|
|
229
|
+
let offset = 0;
|
|
230
|
+
while (offset < buffer.byteLength) {
|
|
231
|
+
const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, offset);
|
|
232
|
+
if (count === 0) break;
|
|
233
|
+
offset += count;
|
|
234
|
+
}
|
|
235
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
236
|
+
if (
|
|
237
|
+
offset !== buffer.byteLength
|
|
238
|
+
|| !sameFile(before, after)
|
|
239
|
+
|| before.size !== after.size
|
|
240
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
241
|
+
|| before.ctimeNs !== after.ctimeNs
|
|
242
|
+
) throw new Error("Google OAuth client file changed while it was read");
|
|
243
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
244
|
+
} finally {
|
|
245
|
+
closeSync(descriptor);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function parseGoogleDesktopClient(value: unknown): GoogleDesktopClient {
|
|
250
|
+
const root = strictRecord(value, "Google OAuth client document");
|
|
251
|
+
exactKeys(root, ["installed"], [], "Google OAuth client document");
|
|
252
|
+
const installed = strictRecord(root.installed, "Google OAuth installed client");
|
|
253
|
+
exactKeys(installed, [
|
|
254
|
+
"client_id",
|
|
255
|
+
"project_id",
|
|
256
|
+
"auth_uri",
|
|
257
|
+
"token_uri",
|
|
258
|
+
"auth_provider_x509_cert_url",
|
|
259
|
+
"redirect_uris",
|
|
260
|
+
], ["client_secret", "universe_domain"], "Google OAuth installed client");
|
|
261
|
+
const clientId = safeText(installed.client_id, "Google OAuth client_id", 16, 1_024);
|
|
262
|
+
if (!/^[A-Za-z0-9._-]+\.apps\.googleusercontent\.com$/u.test(clientId)) {
|
|
263
|
+
throw new Error("Google OAuth client_id is not a Desktop app client ID");
|
|
264
|
+
}
|
|
265
|
+
const clientSecret = installed.client_secret === undefined
|
|
266
|
+
? null
|
|
267
|
+
: safeText(installed.client_secret, "Google OAuth client_secret", 1, 4_096);
|
|
268
|
+
const projectId = safeText(installed.project_id, "Google OAuth project_id", 1, 512);
|
|
269
|
+
if (
|
|
270
|
+
installed.auth_uri !== "https://accounts.google.com/o/oauth2/auth"
|
|
271
|
+
&& installed.auth_uri !== GOOGLE_AUTHORIZATION_URL
|
|
272
|
+
) throw new Error("Google OAuth client has an unreviewed authorization endpoint");
|
|
273
|
+
if (installed.token_uri !== GOOGLE_TOKEN_URL) {
|
|
274
|
+
throw new Error("Google OAuth client has an unreviewed token endpoint");
|
|
275
|
+
}
|
|
276
|
+
if (installed.auth_provider_x509_cert_url !== "https://www.googleapis.com/oauth2/v1/certs") {
|
|
277
|
+
throw new Error("Google OAuth client has an unreviewed certificate endpoint");
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
!Array.isArray(installed.redirect_uris)
|
|
281
|
+
|| installed.redirect_uris.length < 1
|
|
282
|
+
|| installed.redirect_uris.length > 8
|
|
283
|
+
|| !installed.redirect_uris.every((uri) => uri === "http://localhost")
|
|
284
|
+
) throw new Error("Google OAuth Desktop client must declare only the localhost loopback redirect");
|
|
285
|
+
if (installed.universe_domain !== undefined && installed.universe_domain !== "googleapis.com") {
|
|
286
|
+
throw new Error("Google OAuth client has an unreviewed universe domain");
|
|
287
|
+
}
|
|
288
|
+
return Object.freeze({ clientId, clientSecret, projectId });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function loadGoogleDesktopClient(path: string): GoogleDesktopClient {
|
|
292
|
+
let parsed: unknown;
|
|
293
|
+
try {
|
|
294
|
+
parsed = JSON.parse(readBoundedGoogleClientFile(path)) as unknown;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
throw new Error("could not load the Google Desktop OAuth client file", { cause: error });
|
|
297
|
+
}
|
|
298
|
+
return parseGoogleDesktopClient(parsed);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function base64Url(bytes: Uint8Array): string {
|
|
302
|
+
return Buffer.from(bytes).toString("base64url");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function authorizationUrl(
|
|
306
|
+
request: GoogleOAuthAuthorizationRequest,
|
|
307
|
+
redirectUri: string,
|
|
308
|
+
): string {
|
|
309
|
+
const url = new URL(GOOGLE_AUTHORIZATION_URL);
|
|
310
|
+
url.searchParams.set("client_id", request.clientId);
|
|
311
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
312
|
+
url.searchParams.set("response_type", "code");
|
|
313
|
+
url.searchParams.set("scope", request.scopes.join(" "));
|
|
314
|
+
url.searchParams.set("state", request.state);
|
|
315
|
+
url.searchParams.set("code_challenge", request.codeChallenge);
|
|
316
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
317
|
+
url.searchParams.set("access_type", "offline");
|
|
318
|
+
url.searchParams.set("prompt", "consent");
|
|
319
|
+
url.searchParams.set("include_granted_scopes", "false");
|
|
320
|
+
return url.href;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function openSystemBrowser(url: string): Promise<void> {
|
|
324
|
+
const command = process.platform === "darwin"
|
|
325
|
+
? ["open", url]
|
|
326
|
+
: process.platform === "win32"
|
|
327
|
+
? ["cmd", "/d", "/s", "/c", "start", "", url]
|
|
328
|
+
: ["xdg-open", url];
|
|
329
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
330
|
+
const child = spawn(command[0] ?? "", command.slice(1), {
|
|
331
|
+
stdio: "ignore",
|
|
332
|
+
windowsHide: true,
|
|
333
|
+
});
|
|
334
|
+
child.once("error", (error) => reject(new Error("could not open the system browser", { cause: error })));
|
|
335
|
+
child.once("exit", (code, signal) => {
|
|
336
|
+
if (code === 0) resolvePromise();
|
|
337
|
+
else reject(new Error(`system browser opener failed (${signal ?? code ?? "unknown"})`));
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function callbackHtml(response: ServerResponse, status: number, message: string): void {
|
|
343
|
+
response.writeHead(status, {
|
|
344
|
+
"Cache-Control": "no-store",
|
|
345
|
+
"Content-Security-Policy": "default-src 'none'; font-src data:; style-src 'unsafe-inline'",
|
|
346
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
347
|
+
"Referrer-Policy": "no-referrer",
|
|
348
|
+
"X-Content-Type-Options": "nosniff",
|
|
349
|
+
});
|
|
350
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>Ghostget Google login</title><style>@font-face{font-family:"Nebula Sans";src:url("${GOOGLE_OAUTH_NEBULA_SANS_DATA_URL}") format("woff2");font-style:normal;font-weight:400;font-display:swap}body{font:18px/1.5 "Nebula Sans",ui-sans-serif,system-ui,sans-serif;max-width:36rem;margin:15vh auto;padding:2rem}</style><p>${message}</p>`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function defaultAuthorize(
|
|
354
|
+
request: GoogleOAuthAuthorizationRequest,
|
|
355
|
+
): Promise<Readonly<{ code: string; redirectUri: string }>> {
|
|
356
|
+
if (request.signal?.aborted === true) throw new Error("Google authorization was cancelled");
|
|
357
|
+
return await new Promise((resolvePromise, reject) => {
|
|
358
|
+
let settled = false;
|
|
359
|
+
let redirectUri = "";
|
|
360
|
+
const finish = (
|
|
361
|
+
error: Error | null,
|
|
362
|
+
value?: Readonly<{ code: string; redirectUri: string }>,
|
|
363
|
+
): void => {
|
|
364
|
+
if (settled) return;
|
|
365
|
+
settled = true;
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
368
|
+
server.close();
|
|
369
|
+
server.closeAllConnections?.();
|
|
370
|
+
if (error !== null) reject(error);
|
|
371
|
+
else if (value !== undefined) resolvePromise(value);
|
|
372
|
+
else reject(new Error("Google authorization ended without a result"));
|
|
373
|
+
};
|
|
374
|
+
const server = createServer((incoming, response) => {
|
|
375
|
+
if (incoming.method !== "GET" || redirectUri === "") {
|
|
376
|
+
callbackHtml(response, 405, "This request is not part of the Ghostget login.");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let callback: URL;
|
|
380
|
+
try {
|
|
381
|
+
callback = new URL(incoming.url ?? "", redirectUri);
|
|
382
|
+
} catch {
|
|
383
|
+
callbackHtml(response, 400, "Google returned an invalid authorization response.");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (callback.pathname !== CALLBACK_PATH) {
|
|
387
|
+
callbackHtml(response, 404, "This is not the Ghostget authorization callback.");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const allowed = new Set([
|
|
391
|
+
"authuser",
|
|
392
|
+
"code",
|
|
393
|
+
"error",
|
|
394
|
+
"error_description",
|
|
395
|
+
"error_uri",
|
|
396
|
+
"hd",
|
|
397
|
+
"iss",
|
|
398
|
+
"prompt",
|
|
399
|
+
"scope",
|
|
400
|
+
"state",
|
|
401
|
+
]);
|
|
402
|
+
if ([...callback.searchParams.keys()].some((key) => !allowed.has(key))) {
|
|
403
|
+
callbackHtml(response, 400, "Google returned an unsupported authorization response.");
|
|
404
|
+
finish(new Error("Google authorization callback contained unsupported fields"));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const issuers = callback.searchParams.getAll("iss");
|
|
408
|
+
if (
|
|
409
|
+
issuers.length > 1
|
|
410
|
+
|| (issuers.length === 1 && issuers[0] !== "https://accounts.google.com")
|
|
411
|
+
) {
|
|
412
|
+
callbackHtml(response, 400, "The Google authorization issuer did not match. Return to Ghostget and try again.");
|
|
413
|
+
finish(new Error("Google authorization callback issuer did not match"));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (callback.searchParams.get("state") !== request.state) {
|
|
417
|
+
callbackHtml(response, 400, "The Google authorization state did not match. Return to Ghostget and try again.");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const providerError = callback.searchParams.get("error");
|
|
421
|
+
if (providerError !== null) {
|
|
422
|
+
callbackHtml(response, 400, "Google authorization was not granted. You can close this tab.");
|
|
423
|
+
finish(new Error(`Google authorization failed with ${safeText(providerError, "Google OAuth error", 1, 128)}`));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const code = callback.searchParams.get("code");
|
|
427
|
+
if (code === null) {
|
|
428
|
+
callbackHtml(response, 400, "Google did not return an authorization code.");
|
|
429
|
+
finish(new Error("Google authorization callback omitted its code"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
let validatedCode: string;
|
|
433
|
+
try {
|
|
434
|
+
validatedCode = safeText(code, "Google authorization code", 8, 4_096);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
callbackHtml(response, 400, "Google returned an invalid authorization code.");
|
|
437
|
+
finish(error instanceof Error ? error : new Error("Google returned an invalid authorization code"));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
callbackHtml(response, 200, "Google is connected. You can close this tab and return to Ghostget.");
|
|
441
|
+
finish(null, { code: validatedCode, redirectUri });
|
|
442
|
+
});
|
|
443
|
+
const timer = setTimeout(
|
|
444
|
+
() => finish(new Error("Google authorization timed out before consent completed")),
|
|
445
|
+
GOOGLE_AUTHORIZATION_TIMEOUT_MS,
|
|
446
|
+
);
|
|
447
|
+
timer.unref?.();
|
|
448
|
+
const onAbort = (): void => finish(new Error("Google authorization was cancelled"));
|
|
449
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
450
|
+
server.once("error", (error) => finish(new Error("Google authorization callback could not start", { cause: error })));
|
|
451
|
+
server.listen(0, "127.0.0.1", () => {
|
|
452
|
+
const address = server.address();
|
|
453
|
+
if (typeof address !== "object" || address === null) {
|
|
454
|
+
finish(new Error("Google authorization callback did not receive a loopback port"));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
|
|
458
|
+
const url = authorizationUrl(request, redirectUri);
|
|
459
|
+
request.onAuthorizationUrl(url);
|
|
460
|
+
if (!request.openBrowser) return;
|
|
461
|
+
void openSystemBrowser(url).catch((error: unknown) => {
|
|
462
|
+
finish(error instanceof Error ? error : new Error("could not open the system browser"));
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function scopedFetch(
|
|
469
|
+
injected: ProviderFetch | undefined,
|
|
470
|
+
pinned: PinnedHttpsFetch,
|
|
471
|
+
signal: AbortSignal | undefined,
|
|
472
|
+
): ProviderFetch {
|
|
473
|
+
const base: ProviderFetch = injected ?? ((input, init = {}) => {
|
|
474
|
+
const url = input instanceof URL
|
|
475
|
+
? new URL(input)
|
|
476
|
+
: new URL(typeof input === "string" ? input : input.url);
|
|
477
|
+
return pinned(url, init, GOOGLE_OAUTH_TIMEOUT_MS);
|
|
478
|
+
});
|
|
479
|
+
return (input, init = {}) => {
|
|
480
|
+
if (signal?.aborted === true) return Promise.reject(new Error("Google OAuth request was cancelled"));
|
|
481
|
+
const requestSignal = init.signal;
|
|
482
|
+
const combined = signal === undefined
|
|
483
|
+
? requestSignal
|
|
484
|
+
: requestSignal === undefined || requestSignal === null
|
|
485
|
+
? signal
|
|
486
|
+
: AbortSignal.any([signal, requestSignal]);
|
|
487
|
+
return base(input, {
|
|
488
|
+
...init,
|
|
489
|
+
...(combined === undefined || combined === null ? {} : { signal: combined }),
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseTokenResponse(
|
|
495
|
+
value: unknown,
|
|
496
|
+
requestedScopes: readonly string[],
|
|
497
|
+
requireRefreshToken: boolean,
|
|
498
|
+
): GoogleTokenExchange {
|
|
499
|
+
const response = strictRecord(value, "Google OAuth token response");
|
|
500
|
+
exactKeys(response, ["access_token", "expires_in", "token_type"], [
|
|
501
|
+
"id_token",
|
|
502
|
+
"refresh_token",
|
|
503
|
+
"refresh_token_expires_in",
|
|
504
|
+
"scope",
|
|
505
|
+
], "Google OAuth token response");
|
|
506
|
+
if (response.token_type !== "Bearer") {
|
|
507
|
+
throw new Error("Google OAuth token response has an unsupported token type");
|
|
508
|
+
}
|
|
509
|
+
const accessToken = safeText(response.access_token, "Google OAuth access token", 8, 16 * 1_024);
|
|
510
|
+
const expiresInSeconds = safeInteger(response.expires_in, "Google OAuth expires_in", 60, 86_400);
|
|
511
|
+
if (response.scope !== undefined) {
|
|
512
|
+
if (
|
|
513
|
+
typeof response.scope !== "string"
|
|
514
|
+
|| response.scope.length < 1
|
|
515
|
+
|| Buffer.byteLength(response.scope, "utf8") > 16 * 1_024
|
|
516
|
+
|| [...response.scope].some((character) => {
|
|
517
|
+
const code = character.codePointAt(0) ?? -1;
|
|
518
|
+
return code < 0x20 || code === 0x7f;
|
|
519
|
+
})
|
|
520
|
+
) throw new Error("Google OAuth granted scope must be bounded space-delimited text");
|
|
521
|
+
const scopeText = response.scope;
|
|
522
|
+
exactSortedScopes(scopeText.split(" ").filter(Boolean), requestedScopes, "Google OAuth granted scopes");
|
|
523
|
+
} else if (requireRefreshToken) {
|
|
524
|
+
throw new Error("Google OAuth token response omitted the granted scopes");
|
|
525
|
+
}
|
|
526
|
+
const refreshToken = response.refresh_token === undefined
|
|
527
|
+
? null
|
|
528
|
+
: safeText(response.refresh_token, "Google OAuth refresh token", 8, 16 * 1_024);
|
|
529
|
+
if (requireRefreshToken && refreshToken === null) {
|
|
530
|
+
throw new Error("Google did not issue a refresh token; revoke the prior app grant and repeat login");
|
|
531
|
+
}
|
|
532
|
+
const refreshTokenExpiresInSeconds = response.refresh_token_expires_in === undefined
|
|
533
|
+
? null
|
|
534
|
+
: safeInteger(
|
|
535
|
+
response.refresh_token_expires_in,
|
|
536
|
+
"Google OAuth refresh_token_expires_in",
|
|
537
|
+
60,
|
|
538
|
+
10 * 365 * 24 * 60 * 60,
|
|
539
|
+
);
|
|
540
|
+
return Object.freeze({
|
|
541
|
+
accessToken,
|
|
542
|
+
expiresInSeconds,
|
|
543
|
+
refreshToken,
|
|
544
|
+
refreshTokenExpiresInSeconds,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function exchangeAuthorizationCode(
|
|
549
|
+
client: GoogleDesktopClient,
|
|
550
|
+
code: string,
|
|
551
|
+
codeVerifier: string,
|
|
552
|
+
redirectUri: string,
|
|
553
|
+
scopes: readonly string[],
|
|
554
|
+
fetch_: ProviderFetch,
|
|
555
|
+
): Promise<GoogleTokenExchange> {
|
|
556
|
+
const body = new URLSearchParams({
|
|
557
|
+
client_id: client.clientId,
|
|
558
|
+
code,
|
|
559
|
+
code_verifier: codeVerifier,
|
|
560
|
+
grant_type: "authorization_code",
|
|
561
|
+
redirect_uri: redirectUri,
|
|
562
|
+
});
|
|
563
|
+
if (client.clientSecret !== null) body.set("client_secret", client.clientSecret);
|
|
564
|
+
const http = new ProviderHttpClient(fetch_, GOOGLE_OAUTH_TIMEOUT_MS, MAX_GOOGLE_RESPONSE_BYTES);
|
|
565
|
+
const response = await http.request(
|
|
566
|
+
GOOGLE_TOKEN_URL,
|
|
567
|
+
{
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: {
|
|
570
|
+
Accept: "application/json",
|
|
571
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
572
|
+
},
|
|
573
|
+
body: body.toString(),
|
|
574
|
+
},
|
|
575
|
+
[200],
|
|
576
|
+
GOOGLE_TOKEN_HOSTS,
|
|
577
|
+
MAX_GOOGLE_RESPONSE_BYTES,
|
|
578
|
+
"application/json",
|
|
579
|
+
);
|
|
580
|
+
return parseTokenResponse(response.body, scopes, true);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function authenticatedGmailSubject(
|
|
584
|
+
accessToken: string,
|
|
585
|
+
fetch_: ProviderFetch,
|
|
586
|
+
): Promise<string> {
|
|
587
|
+
const http = new ProviderHttpClient(fetch_, GOOGLE_OAUTH_TIMEOUT_MS, MAX_GOOGLE_RESPONSE_BYTES);
|
|
588
|
+
const response = await http.request(
|
|
589
|
+
GOOGLE_PROFILE_URL,
|
|
590
|
+
{
|
|
591
|
+
method: "GET",
|
|
592
|
+
headers: {
|
|
593
|
+
Accept: "application/json",
|
|
594
|
+
Authorization: `Bearer ${accessToken}`,
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
[200],
|
|
598
|
+
GOOGLE_GMAIL_HOSTS,
|
|
599
|
+
MAX_GOOGLE_RESPONSE_BYTES,
|
|
600
|
+
"application/json",
|
|
601
|
+
);
|
|
602
|
+
const profile = strictRecord(response.body, "Google Gmail profile response");
|
|
603
|
+
exactKeys(profile, ["emailAddress"], [], "Google Gmail profile response");
|
|
604
|
+
if (
|
|
605
|
+
typeof profile.emailAddress !== "string"
|
|
606
|
+
|| !isGmailAccountSubject(profile.emailAddress)
|
|
607
|
+
) {
|
|
608
|
+
throw new Error("Google Gmail profile returned an invalid account email");
|
|
609
|
+
}
|
|
610
|
+
return profile.emailAddress.toLowerCase();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function isoAfter(now: Date, seconds: number): string {
|
|
614
|
+
const nowMs = now.getTime();
|
|
615
|
+
if (!Number.isFinite(nowMs)) throw new Error("Google OAuth reference time is invalid");
|
|
616
|
+
return new Date(nowMs + seconds * 1_000).toISOString();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export async function loginGoogleOAuth(
|
|
620
|
+
input: Readonly<{
|
|
621
|
+
clientFile: string;
|
|
622
|
+
scopes?: readonly string[];
|
|
623
|
+
openBrowser?: boolean;
|
|
624
|
+
onAuthorizationUrl?: (url: string) => void;
|
|
625
|
+
signal?: AbortSignal;
|
|
626
|
+
}>,
|
|
627
|
+
dependencies: GoogleOAuthLoginDependencies = {},
|
|
628
|
+
): Promise<GoogleOAuthLoginResult> {
|
|
629
|
+
const client = loadGoogleDesktopClient(input.clientFile);
|
|
630
|
+
const scopes = Object.freeze([...(input.scopes ?? GOOGLE_GMAIL_READ_SCOPES)].sort());
|
|
631
|
+
exactSortedScopes(scopes, GOOGLE_GMAIL_READ_SCOPES, "Google OAuth requested scopes");
|
|
632
|
+
const random = dependencies.randomBytes ?? ((size: number) => randomBytes(size));
|
|
633
|
+
const state = base64Url(random(32));
|
|
634
|
+
const codeVerifier = base64Url(random(64));
|
|
635
|
+
if (state.length < 32 || codeVerifier.length < 43 || codeVerifier.length > 128) {
|
|
636
|
+
throw new Error("Google OAuth secure random source returned invalid entropy");
|
|
637
|
+
}
|
|
638
|
+
const codeChallenge = createHash("sha256").update(codeVerifier, "ascii").digest("base64url");
|
|
639
|
+
const request: GoogleOAuthAuthorizationRequest = Object.freeze({
|
|
640
|
+
clientId: client.clientId,
|
|
641
|
+
scopes,
|
|
642
|
+
state,
|
|
643
|
+
codeChallenge,
|
|
644
|
+
openBrowser: input.openBrowser ?? true,
|
|
645
|
+
onAuthorizationUrl: input.onAuthorizationUrl ?? (() => undefined),
|
|
646
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
647
|
+
});
|
|
648
|
+
const authorization = await (dependencies.authorize ?? defaultAuthorize)(request);
|
|
649
|
+
const redirect = new URL(authorization.redirectUri);
|
|
650
|
+
if (
|
|
651
|
+
redirect.protocol !== "http:"
|
|
652
|
+
|| redirect.hostname !== "127.0.0.1"
|
|
653
|
+
|| redirect.pathname !== CALLBACK_PATH
|
|
654
|
+
|| redirect.search !== ""
|
|
655
|
+
|| redirect.hash !== ""
|
|
656
|
+
|| redirect.username !== ""
|
|
657
|
+
|| redirect.password !== ""
|
|
658
|
+
|| redirect.port === ""
|
|
659
|
+
) throw new Error("Google OAuth authorization returned an invalid loopback redirect");
|
|
660
|
+
const code = safeText(authorization.code, "Google authorization code", 8, 4_096);
|
|
661
|
+
const fetch_ = scopedFetch(
|
|
662
|
+
dependencies.fetch,
|
|
663
|
+
dependencies.pinnedFetch ?? pinnedHttpsFetch,
|
|
664
|
+
input.signal,
|
|
665
|
+
);
|
|
666
|
+
const exchange = await exchangeAuthorizationCode(
|
|
667
|
+
client,
|
|
668
|
+
code,
|
|
669
|
+
codeVerifier,
|
|
670
|
+
redirect.href,
|
|
671
|
+
scopes,
|
|
672
|
+
fetch_,
|
|
673
|
+
);
|
|
674
|
+
const subject = await authenticatedGmailSubject(exchange.accessToken, fetch_);
|
|
675
|
+
const now = (dependencies.now ?? (() => new Date()))();
|
|
676
|
+
const expiresAt = isoAfter(now, exchange.expiresInSeconds);
|
|
677
|
+
const refreshTokenExpiresAt = exchange.refreshTokenExpiresInSeconds === null
|
|
678
|
+
? null
|
|
679
|
+
: isoAfter(now, exchange.refreshTokenExpiresInSeconds);
|
|
680
|
+
if (exchange.refreshToken === null) {
|
|
681
|
+
throw new Error("Google did not issue a refresh token");
|
|
682
|
+
}
|
|
683
|
+
const refresh: GoogleInstalledAppRefresh = Object.freeze({
|
|
684
|
+
kind: "google-installed-app" as const,
|
|
685
|
+
clientId: client.clientId,
|
|
686
|
+
clientSecret: client.clientSecret,
|
|
687
|
+
refreshToken: exchange.refreshToken,
|
|
688
|
+
refreshTokenExpiresAt,
|
|
689
|
+
});
|
|
690
|
+
const credential: GoogleOAuthCredentialDocument = Object.freeze({
|
|
691
|
+
schemaVersion: 2 as const,
|
|
692
|
+
provider: "gmail" as const,
|
|
693
|
+
subject,
|
|
694
|
+
scopes,
|
|
695
|
+
accessToken: exchange.accessToken,
|
|
696
|
+
expiresAt,
|
|
697
|
+
refresh,
|
|
698
|
+
});
|
|
699
|
+
return Object.freeze({ subject, scopes, credential, refreshTokenExpiresAt });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function managedTokenDirectory(
|
|
703
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
704
|
+
): string {
|
|
705
|
+
return join(ghostgetStateHome(environment), "auth", "oauth-tokens");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isManagedTokenPath(
|
|
709
|
+
auth: OAuthTokenAuth,
|
|
710
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
711
|
+
): boolean {
|
|
712
|
+
if (auth.managed !== true) return false;
|
|
713
|
+
const path = resolve(auth.path);
|
|
714
|
+
return dirname(path) === managedTokenDirectory(environment)
|
|
715
|
+
&& new RegExp(`^${auth.id}-[0-9a-f-]{36}\\.json$`, "u").test(basename(path));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function removeManagedTokenIfPresent(
|
|
719
|
+
auth: GhostgetAuth | undefined,
|
|
720
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
721
|
+
): void {
|
|
722
|
+
if (auth?.kind !== "oauth-token-file" || !isManagedTokenPath(auth, environment)) return;
|
|
723
|
+
const content = readPrivateStateFileIfPresent(
|
|
724
|
+
auth.path,
|
|
725
|
+
MAX_GOOGLE_CLIENT_FILE_BYTES,
|
|
726
|
+
"managed OAuth credential",
|
|
727
|
+
environment,
|
|
728
|
+
);
|
|
729
|
+
if (content === null) return;
|
|
730
|
+
const removed = removePrivateStateFileIfUnchanged(
|
|
731
|
+
auth.path,
|
|
732
|
+
{ expectedCurrentContentSha256: createHash("sha256").update(content, "utf8").digest("hex") },
|
|
733
|
+
environment,
|
|
734
|
+
);
|
|
735
|
+
if (!removed) throw new Error("managed OAuth credential changed before cleanup");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function installManagedGoogleOAuth(
|
|
739
|
+
id: string,
|
|
740
|
+
login: GoogleOAuthLoginResult,
|
|
741
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
742
|
+
options: Readonly<{ force?: boolean }> = {},
|
|
743
|
+
): Readonly<{ auth: OAuthTokenAuth; path: string }> {
|
|
744
|
+
const previous = loadAuthSnapshotIfPresent(id, environment)?.auth;
|
|
745
|
+
const tokenPath = join(managedTokenDirectory(environment), `${id}-${randomUUID()}.json`);
|
|
746
|
+
const content = `${canonicalJson(login.credential)}\n`;
|
|
747
|
+
if (!createPrivateJsonIfAbsent(tokenPath, login.credential, {
|
|
748
|
+
environment,
|
|
749
|
+
privateParent: true,
|
|
750
|
+
}).created) throw new Error("managed OAuth credential path already exists");
|
|
751
|
+
const auth = createAuth(id, {
|
|
752
|
+
oauthProvider: "gmail",
|
|
753
|
+
tokenFile: tokenPath,
|
|
754
|
+
scopes: login.scopes,
|
|
755
|
+
subject: login.subject,
|
|
756
|
+
managed: true,
|
|
757
|
+
});
|
|
758
|
+
if (auth.kind !== "oauth-token-file") throw new Error("managed OAuth login created the wrong auth kind");
|
|
759
|
+
let path: string;
|
|
760
|
+
try {
|
|
761
|
+
path = saveAuth(
|
|
762
|
+
auth,
|
|
763
|
+
environment,
|
|
764
|
+
options.force === undefined ? {} : { force: options.force },
|
|
765
|
+
);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
removePrivateStateFileIfUnchanged(
|
|
768
|
+
tokenPath,
|
|
769
|
+
{ expectedCurrentContentSha256: createHash("sha256").update(content, "utf8").digest("hex") },
|
|
770
|
+
environment,
|
|
771
|
+
);
|
|
772
|
+
throw error;
|
|
773
|
+
}
|
|
774
|
+
if (
|
|
775
|
+
previous?.kind === "oauth-token-file"
|
|
776
|
+
&& previous.path !== tokenPath
|
|
777
|
+
) removeManagedTokenIfPresent(previous, environment);
|
|
778
|
+
return Object.freeze({ auth, path });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function credentialDocument(
|
|
782
|
+
auth: OAuthTokenAuth,
|
|
783
|
+
accessToken: string,
|
|
784
|
+
expiresAt: string,
|
|
785
|
+
refresh: GoogleInstalledAppRefresh,
|
|
786
|
+
): GoogleOAuthCredentialDocument {
|
|
787
|
+
if (auth.subject === undefined || !isGmailAccountSubject(auth.subject)) {
|
|
788
|
+
throw new Error("managed Google OAuth auth requires an exact Gmail subject");
|
|
789
|
+
}
|
|
790
|
+
return Object.freeze({
|
|
791
|
+
schemaVersion: 2 as const,
|
|
792
|
+
provider: "gmail" as const,
|
|
793
|
+
subject: auth.subject,
|
|
794
|
+
scopes: auth.scopes,
|
|
795
|
+
accessToken,
|
|
796
|
+
expiresAt,
|
|
797
|
+
refresh,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export function resolveOAuthToken(
|
|
802
|
+
auth: OAuthTokenAuth,
|
|
803
|
+
options: Readonly<{
|
|
804
|
+
environment?: Readonly<Record<string, string | undefined>>;
|
|
805
|
+
now?: Date;
|
|
806
|
+
minimumValidityMs?: number;
|
|
807
|
+
fetch?: ProviderFetch;
|
|
808
|
+
pinnedFetch?: PinnedHttpsFetch;
|
|
809
|
+
signal?: AbortSignal;
|
|
810
|
+
}> = {},
|
|
811
|
+
): LoadedOAuthToken | Promise<LoadedOAuthToken> {
|
|
812
|
+
const environment = options.environment ?? process.env;
|
|
813
|
+
const now = options.now ?? new Date();
|
|
814
|
+
const nowMs = now.getTime();
|
|
815
|
+
const minimumValidityMs = options.minimumValidityMs ?? 30_000;
|
|
816
|
+
if (
|
|
817
|
+
!Number.isFinite(nowMs)
|
|
818
|
+
|| !Number.isSafeInteger(minimumValidityMs)
|
|
819
|
+
|| minimumValidityMs < 0
|
|
820
|
+
) throw new Error("OAuth token refresh validity budget is invalid");
|
|
821
|
+
let credential = loadOAuthCredential(auth);
|
|
822
|
+
if (
|
|
823
|
+
credential.expiresAt === null
|
|
824
|
+
|| Date.parse(credential.expiresAt) - nowMs > minimumValidityMs
|
|
825
|
+
) return Object.freeze({ accessToken: credential.accessToken, expiresAt: credential.expiresAt });
|
|
826
|
+
if (
|
|
827
|
+
credential.schemaVersion !== 2
|
|
828
|
+
|| credential.refresh === null
|
|
829
|
+
|| !isManagedTokenPath(auth, environment)
|
|
830
|
+
) {
|
|
831
|
+
return loadOAuthToken(auth, now, minimumValidityMs);
|
|
832
|
+
}
|
|
833
|
+
const refresh = credential.refresh;
|
|
834
|
+
if (
|
|
835
|
+
refresh.refreshTokenExpiresAt !== null
|
|
836
|
+
&& Date.parse(refresh.refreshTokenExpiresAt) <= nowMs
|
|
837
|
+
) throw new Error("the managed Google refresh credential expired; run ghostget auth login again");
|
|
838
|
+
return (async (): Promise<LoadedOAuthToken> => {
|
|
839
|
+
const fetch_ = scopedFetch(
|
|
840
|
+
options.fetch,
|
|
841
|
+
options.pinnedFetch ?? pinnedHttpsFetch,
|
|
842
|
+
options.signal,
|
|
843
|
+
);
|
|
844
|
+
const body = new URLSearchParams({
|
|
845
|
+
client_id: refresh.clientId,
|
|
846
|
+
grant_type: "refresh_token",
|
|
847
|
+
refresh_token: refresh.refreshToken,
|
|
848
|
+
});
|
|
849
|
+
if (refresh.clientSecret !== null) {
|
|
850
|
+
body.set("client_secret", refresh.clientSecret);
|
|
851
|
+
}
|
|
852
|
+
const http = new ProviderHttpClient(fetch_, GOOGLE_OAUTH_TIMEOUT_MS, MAX_GOOGLE_RESPONSE_BYTES);
|
|
853
|
+
const response = await http.request(
|
|
854
|
+
GOOGLE_TOKEN_URL,
|
|
855
|
+
{
|
|
856
|
+
method: "POST",
|
|
857
|
+
headers: {
|
|
858
|
+
Accept: "application/json",
|
|
859
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
860
|
+
},
|
|
861
|
+
body: body.toString(),
|
|
862
|
+
},
|
|
863
|
+
[200],
|
|
864
|
+
GOOGLE_TOKEN_HOSTS,
|
|
865
|
+
MAX_GOOGLE_RESPONSE_BYTES,
|
|
866
|
+
"application/json",
|
|
867
|
+
);
|
|
868
|
+
const refreshed = parseTokenResponse(response.body, auth.scopes, false);
|
|
869
|
+
const expiresAt = isoAfter(now, refreshed.expiresInSeconds);
|
|
870
|
+
const replacement = credentialDocument(
|
|
871
|
+
auth,
|
|
872
|
+
refreshed.accessToken,
|
|
873
|
+
expiresAt,
|
|
874
|
+
refresh,
|
|
875
|
+
);
|
|
876
|
+
if (!writePrivateJsonIfUnchanged(auth.path, replacement, {
|
|
877
|
+
expectedCurrentContentSha256: credential.contentSha256,
|
|
878
|
+
})) {
|
|
879
|
+
credential = loadOAuthCredential(auth);
|
|
880
|
+
if (
|
|
881
|
+
credential.expiresAt !== null
|
|
882
|
+
&& Date.parse(credential.expiresAt) - nowMs > minimumValidityMs
|
|
883
|
+
) return Object.freeze({ accessToken: credential.accessToken, expiresAt: credential.expiresAt });
|
|
884
|
+
throw new Error("managed Google OAuth credential changed concurrently before refresh");
|
|
885
|
+
}
|
|
886
|
+
return Object.freeze({ accessToken: refreshed.accessToken, expiresAt });
|
|
887
|
+
})();
|
|
888
|
+
}
|