@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,1612 @@
|
|
|
1
|
+
import { types as nodeTypes } from "node:util";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LOCAL_MESSAGE_BUNDLE_V1_LIMITS,
|
|
5
|
+
LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID,
|
|
6
|
+
LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID,
|
|
7
|
+
LOCAL_MESSAGE_BUNDLE_V1_SOURCE_TRANSFORM_VERSION,
|
|
8
|
+
} from "@hraness/message-like-me/message-bundle-v1";
|
|
9
|
+
|
|
10
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
11
|
+
import {
|
|
12
|
+
parseBeeperMessageLikeMeCompletion,
|
|
13
|
+
parseBeeperMessageLikeMeDescriptor,
|
|
14
|
+
parseBeeperMessageLikeMeRecord,
|
|
15
|
+
type BeeperMessageLikeMeConversation,
|
|
16
|
+
type BeeperMessageLikeMeExportSource,
|
|
17
|
+
type BeeperMessageLikeMeMessage,
|
|
18
|
+
type BeeperMessageLikeMeRecord,
|
|
19
|
+
} from "./beeper-message-bundle-v1";
|
|
20
|
+
import type {
|
|
21
|
+
BeeperMessageLikeMeSourceCoordinate,
|
|
22
|
+
} from "./beeper-message-like-me-source";
|
|
23
|
+
import { BEEPER_CLI_PIN } from "./providers/beeper-local";
|
|
24
|
+
import { GHOSTGET_VERSION } from "./version";
|
|
25
|
+
|
|
26
|
+
export const BEEPER_CONTACT_INTERACTION_SCHEMA_VERSION = 1 as const;
|
|
27
|
+
export const BEEPER_CONTACT_INTERACTION_FORMAT =
|
|
28
|
+
"wrench.contact-interaction-summary" as const;
|
|
29
|
+
export const BEEPER_CONTACT_INTERACTION_RECEIPT_FORMAT =
|
|
30
|
+
"wrench.beeper-contact-interaction-export-receipt" as const;
|
|
31
|
+
export const BEEPER_CONTACT_INTERACTION_TRANSFORM = Object.freeze({
|
|
32
|
+
id: "beeper-direct-contact-interactions",
|
|
33
|
+
version: 1 as const,
|
|
34
|
+
sourceVersion: LOCAL_MESSAGE_BUNDLE_V1_SOURCE_TRANSFORM_VERSION,
|
|
35
|
+
});
|
|
36
|
+
export const BEEPER_CONTACT_INTERACTION_IMPLEMENTATION = Object.freeze({
|
|
37
|
+
producer: Object.freeze({
|
|
38
|
+
package: "@hraness/ghostget" as const,
|
|
39
|
+
version: GHOSTGET_VERSION,
|
|
40
|
+
}),
|
|
41
|
+
officialCli: Object.freeze({
|
|
42
|
+
implementation: BEEPER_CLI_PIN.implementation,
|
|
43
|
+
version: BEEPER_CLI_PIN.version,
|
|
44
|
+
commit: BEEPER_CLI_PIN.commit,
|
|
45
|
+
platform: "darwin-arm64" as const,
|
|
46
|
+
binarySha256: BEEPER_CLI_PIN.darwinArm64BinarySha256,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const MAX_RECORDS = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.records;
|
|
51
|
+
const MAX_COORDINATE_BYTES = 4 * 1024;
|
|
52
|
+
const MAX_NETWORK_BYTES = 64;
|
|
53
|
+
const CONTACT_INTERACTION_WARNING_CODES = Object.freeze([
|
|
54
|
+
"group-messages-excluded",
|
|
55
|
+
"incomplete-direct-rosters-excluded",
|
|
56
|
+
"message-content-excluded",
|
|
57
|
+
"replacement-message-versions-excluded",
|
|
58
|
+
] as const);
|
|
59
|
+
const MAX_WARNINGS = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.warnings
|
|
60
|
+
+ CONTACT_INTERACTION_WARNING_CODES.length;
|
|
61
|
+
const MAX_OUTPUT_BYTES = 128 * 1024 * 1024;
|
|
62
|
+
export const BEEPER_CONTACT_INTERACTION_WIRE_MAX_BYTES =
|
|
63
|
+
(MAX_OUTPUT_BYTES * 3) + (1024 * 1024);
|
|
64
|
+
const PROGRESS_INTERVAL_MS = 30_000;
|
|
65
|
+
|
|
66
|
+
export type BeeperContactInteractionAccount = Readonly<{
|
|
67
|
+
/** Exact stable account coordinate returned by the official Beeper export. */
|
|
68
|
+
accountId: string;
|
|
69
|
+
accountProviderId: string;
|
|
70
|
+
network: string;
|
|
71
|
+
/** Exact stable self-user coordinate in this account realm. */
|
|
72
|
+
selfParticipantId: string;
|
|
73
|
+
selfParticipantProviderId: string;
|
|
74
|
+
observedAt: string;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
export type BeeperContactInteraction = Readonly<{
|
|
78
|
+
/** Exact stable connected-account coordinate. */
|
|
79
|
+
accountId: string;
|
|
80
|
+
accountProviderId: string;
|
|
81
|
+
/** Exact stable account-scoped peer coordinate. */
|
|
82
|
+
contactId: string;
|
|
83
|
+
contactProviderId: string;
|
|
84
|
+
network: string;
|
|
85
|
+
sentCount: number;
|
|
86
|
+
receivedCount: number;
|
|
87
|
+
interactionCount: number;
|
|
88
|
+
conversationCount: number;
|
|
89
|
+
firstInteractionAt: string;
|
|
90
|
+
lastInteractionAt: string;
|
|
91
|
+
reciprocal: boolean;
|
|
92
|
+
completeness: "lower-bound";
|
|
93
|
+
provenance: Readonly<{
|
|
94
|
+
sourceId: typeof LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID;
|
|
95
|
+
sourceVersion: typeof LOCAL_MESSAGE_BUNDLE_V1_SOURCE_TRANSFORM_VERSION;
|
|
96
|
+
providerId: typeof LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID;
|
|
97
|
+
providerVersion: string;
|
|
98
|
+
observedAt: string;
|
|
99
|
+
}>;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
export type BeeperContactInteractionSummary = Readonly<{
|
|
103
|
+
schemaVersion: 1;
|
|
104
|
+
format: typeof BEEPER_CONTACT_INTERACTION_FORMAT;
|
|
105
|
+
transform: typeof BEEPER_CONTACT_INTERACTION_TRANSFORM;
|
|
106
|
+
source: Readonly<{
|
|
107
|
+
id: typeof LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID;
|
|
108
|
+
version: typeof LOCAL_MESSAGE_BUNDLE_V1_SOURCE_TRANSFORM_VERSION;
|
|
109
|
+
}>;
|
|
110
|
+
provider: Readonly<{
|
|
111
|
+
id: typeof LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID;
|
|
112
|
+
version: string;
|
|
113
|
+
}>;
|
|
114
|
+
observedAt: string | null;
|
|
115
|
+
scope: Readonly<{
|
|
116
|
+
conversations: "complete-direct-only";
|
|
117
|
+
messages: "current-direction-known-only";
|
|
118
|
+
}>;
|
|
119
|
+
completeness: Readonly<{
|
|
120
|
+
kind: "lower-bound";
|
|
121
|
+
sourceKind: "bounded-local" | "truncated" | "unknown";
|
|
122
|
+
reason: string | null;
|
|
123
|
+
observedFrom: string | null;
|
|
124
|
+
observedThrough: string | null;
|
|
125
|
+
}>;
|
|
126
|
+
warnings: readonly string[];
|
|
127
|
+
privacy: Readonly<{
|
|
128
|
+
messageBodies: "excluded";
|
|
129
|
+
attachments: "excluded";
|
|
130
|
+
reactions: "excluded";
|
|
131
|
+
media: "excluded";
|
|
132
|
+
groupMessages: "excluded";
|
|
133
|
+
localPaths: "excluded";
|
|
134
|
+
credentials: "excluded";
|
|
135
|
+
}>;
|
|
136
|
+
counts: Readonly<{
|
|
137
|
+
accounts: number;
|
|
138
|
+
directRelationships: number;
|
|
139
|
+
directConversations: number;
|
|
140
|
+
interactions: number;
|
|
141
|
+
sent: number;
|
|
142
|
+
received: number;
|
|
143
|
+
}>;
|
|
144
|
+
accounts: readonly BeeperContactInteractionAccount[];
|
|
145
|
+
interactions: readonly BeeperContactInteraction[];
|
|
146
|
+
integrity: Readonly<{
|
|
147
|
+
algorithm: "sha256";
|
|
148
|
+
summarySha256: string;
|
|
149
|
+
}>;
|
|
150
|
+
}>;
|
|
151
|
+
|
|
152
|
+
export type BeeperContactInteractionExportBounds = Readonly<{
|
|
153
|
+
limitChats: number | null;
|
|
154
|
+
limitMessages: number | null;
|
|
155
|
+
maxParticipants: number | null;
|
|
156
|
+
}>;
|
|
157
|
+
|
|
158
|
+
export type BeeperContactInteractionExportReceipt = Readonly<{
|
|
159
|
+
schemaVersion: 1;
|
|
160
|
+
format: typeof BEEPER_CONTACT_INTERACTION_RECEIPT_FORMAT;
|
|
161
|
+
runId: string;
|
|
162
|
+
operation: "beeper.export-contact-interactions";
|
|
163
|
+
status: "succeeded";
|
|
164
|
+
transport: "linked-device";
|
|
165
|
+
implementation: typeof BEEPER_CONTACT_INTERACTION_IMPLEMENTATION;
|
|
166
|
+
startedAt: string;
|
|
167
|
+
finishedAt: string;
|
|
168
|
+
auth: Readonly<{
|
|
169
|
+
id: string;
|
|
170
|
+
kind: "linked-device-store";
|
|
171
|
+
provider: "beeper";
|
|
172
|
+
identitySha256: string;
|
|
173
|
+
}>;
|
|
174
|
+
bounds: BeeperContactInteractionExportBounds;
|
|
175
|
+
source: BeeperContactInteractionSummary["source"];
|
|
176
|
+
provider: BeeperContactInteractionSummary["provider"];
|
|
177
|
+
transform: typeof BEEPER_CONTACT_INTERACTION_TRANSFORM;
|
|
178
|
+
completeness: BeeperContactInteractionSummary["completeness"];
|
|
179
|
+
counts: BeeperContactInteractionSummary["counts"];
|
|
180
|
+
output: Readonly<{
|
|
181
|
+
schemaVersion: typeof BEEPER_CONTACT_INTERACTION_SCHEMA_VERSION;
|
|
182
|
+
format: typeof BEEPER_CONTACT_INTERACTION_FORMAT;
|
|
183
|
+
summarySha256: string;
|
|
184
|
+
}>;
|
|
185
|
+
privacy: Readonly<{
|
|
186
|
+
messageBodies: "excluded";
|
|
187
|
+
attachments: "excluded";
|
|
188
|
+
reactions: "excluded";
|
|
189
|
+
media: "excluded";
|
|
190
|
+
localPaths: "excluded";
|
|
191
|
+
credentials: "excluded";
|
|
192
|
+
}>;
|
|
193
|
+
integrity: Readonly<{
|
|
194
|
+
algorithm: "sha256";
|
|
195
|
+
receiptSha256: string;
|
|
196
|
+
}>;
|
|
197
|
+
}>;
|
|
198
|
+
|
|
199
|
+
export type BeeperContactInteractionExportResult = Readonly<{
|
|
200
|
+
receipt: BeeperContactInteractionExportReceipt;
|
|
201
|
+
output: BeeperContactInteractionSummary;
|
|
202
|
+
}>;
|
|
203
|
+
|
|
204
|
+
export type BeeperContactInteractionProgress =
|
|
205
|
+
| Readonly<{
|
|
206
|
+
phase: "summary-building";
|
|
207
|
+
elapsedSeconds: number;
|
|
208
|
+
records: number;
|
|
209
|
+
}>
|
|
210
|
+
| Readonly<{
|
|
211
|
+
phase: "summary-completed";
|
|
212
|
+
records: number;
|
|
213
|
+
relationships: number;
|
|
214
|
+
interactions: number;
|
|
215
|
+
}>;
|
|
216
|
+
|
|
217
|
+
export type SummarizeBeeperContactInteractionsRequest = Readonly<{
|
|
218
|
+
source: BeeperMessageLikeMeExportSource;
|
|
219
|
+
coordinateForRecord: (
|
|
220
|
+
record: unknown,
|
|
221
|
+
) => BeeperMessageLikeMeSourceCoordinate | undefined;
|
|
222
|
+
onProgress?: (progress: BeeperContactInteractionProgress) => void;
|
|
223
|
+
signal?: AbortSignal;
|
|
224
|
+
}>;
|
|
225
|
+
|
|
226
|
+
type AccountFact = Readonly<{
|
|
227
|
+
id: string;
|
|
228
|
+
accountId: string;
|
|
229
|
+
providerId: string;
|
|
230
|
+
network: string;
|
|
231
|
+
selfParticipantId: string;
|
|
232
|
+
observedAt: string;
|
|
233
|
+
}>;
|
|
234
|
+
|
|
235
|
+
type ParticipantFact = Readonly<{
|
|
236
|
+
id: string;
|
|
237
|
+
accountLocalId: string;
|
|
238
|
+
accountId: string;
|
|
239
|
+
participantId: string;
|
|
240
|
+
providerId: string;
|
|
241
|
+
isSelf: boolean;
|
|
242
|
+
}>;
|
|
243
|
+
|
|
244
|
+
type ConversationFact = Readonly<{
|
|
245
|
+
id: string;
|
|
246
|
+
accountLocalId: string;
|
|
247
|
+
accountId: string;
|
|
248
|
+
conversationId: string;
|
|
249
|
+
network: string;
|
|
250
|
+
type: BeeperMessageLikeMeConversation["type"];
|
|
251
|
+
participantIds: readonly string[];
|
|
252
|
+
participantsComplete: boolean | null;
|
|
253
|
+
}>;
|
|
254
|
+
|
|
255
|
+
type MessageFact = Readonly<{
|
|
256
|
+
id: string;
|
|
257
|
+
accountLocalId: string;
|
|
258
|
+
accountId: string;
|
|
259
|
+
conversationLocalId: string;
|
|
260
|
+
conversationId: string;
|
|
261
|
+
messageId: string;
|
|
262
|
+
providerId: string;
|
|
263
|
+
senderParticipantId: string | null;
|
|
264
|
+
direction: BeeperMessageLikeMeMessage["direction"];
|
|
265
|
+
sentAt: string;
|
|
266
|
+
observedAt: string;
|
|
267
|
+
replacement: Readonly<{
|
|
268
|
+
replacesMessageId: string | null;
|
|
269
|
+
replacesProviderId: string;
|
|
270
|
+
}> | null;
|
|
271
|
+
}>;
|
|
272
|
+
|
|
273
|
+
type MutableInteraction = {
|
|
274
|
+
accountId: string;
|
|
275
|
+
accountProviderId: string;
|
|
276
|
+
contactId: string;
|
|
277
|
+
contactProviderId: string;
|
|
278
|
+
network: string;
|
|
279
|
+
sentCount: number;
|
|
280
|
+
receivedCount: number;
|
|
281
|
+
conversations: Set<string>;
|
|
282
|
+
firstInteractionAt: string;
|
|
283
|
+
lastInteractionAt: string;
|
|
284
|
+
observedAt: string;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
function fail(message: string): never {
|
|
288
|
+
throw new Error(`Beeper contact interaction summary: ${message}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
292
|
+
if (
|
|
293
|
+
typeof value !== "object"
|
|
294
|
+
|| value === null
|
|
295
|
+
|| Array.isArray(value)
|
|
296
|
+
|| nodeTypes.isProxy(value)
|
|
297
|
+
|| (
|
|
298
|
+
Object.getPrototypeOf(value) !== Object.prototype
|
|
299
|
+
&& Object.getPrototypeOf(value) !== null
|
|
300
|
+
)
|
|
301
|
+
) return fail(`${label} must be a plain object`);
|
|
302
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
303
|
+
const keys = Reflect.ownKeys(descriptors);
|
|
304
|
+
if (keys.some((key) => typeof key !== "string")) {
|
|
305
|
+
return fail(`${label} must not contain symbol fields`);
|
|
306
|
+
}
|
|
307
|
+
for (const key of keys as string[]) {
|
|
308
|
+
const descriptor = descriptors[key];
|
|
309
|
+
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
|
310
|
+
return fail(`${label}.${key} must be an enumerable data property`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return value as Record<string, unknown>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function boundedArray(
|
|
317
|
+
value: unknown,
|
|
318
|
+
label: string,
|
|
319
|
+
maximum: number,
|
|
320
|
+
): readonly unknown[] {
|
|
321
|
+
if (
|
|
322
|
+
!Array.isArray(value)
|
|
323
|
+
|| nodeTypes.isProxy(value)
|
|
324
|
+
|| value.length > maximum
|
|
325
|
+
) return fail(`${label} must be a bounded plain array`);
|
|
326
|
+
const keys = Reflect.ownKeys(value);
|
|
327
|
+
if (
|
|
328
|
+
keys.some((key) => typeof key !== "string")
|
|
329
|
+
|| keys.length !== value.length + 1
|
|
330
|
+
|| keys[keys.length - 1] !== "length"
|
|
331
|
+
) return fail(`${label} must not contain holes or custom fields`);
|
|
332
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
333
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
334
|
+
const descriptor = descriptors[String(index)];
|
|
335
|
+
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
|
336
|
+
return fail(`${label}[${String(index)}] must be an enumerable data property`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function exactKeys(
|
|
343
|
+
value: Record<string, unknown>,
|
|
344
|
+
keys: readonly string[],
|
|
345
|
+
label: string,
|
|
346
|
+
): void {
|
|
347
|
+
const actual = Object.keys(value).sort();
|
|
348
|
+
const expected = [...keys].sort();
|
|
349
|
+
if (
|
|
350
|
+
actual.length !== expected.length
|
|
351
|
+
|| actual.some((key, index) => key !== expected[index])
|
|
352
|
+
) fail(`${label} contains unsupported or missing fields`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function coordinate(value: unknown, label: string): string {
|
|
356
|
+
if (
|
|
357
|
+
typeof value !== "string"
|
|
358
|
+
|| value.length < 1
|
|
359
|
+
|| Buffer.byteLength(value, "utf8") > MAX_COORDINATE_BYTES
|
|
360
|
+
|| /[\u0000-\u001f\u007f]/u.test(value)
|
|
361
|
+
) return fail(`${label} must be bounded provider text`);
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function compareCanonicalText(left: string, right: string): number {
|
|
366
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function token(value: unknown, label: string, maximum = 128): string {
|
|
370
|
+
const parsed = coordinate(value, label);
|
|
371
|
+
if (
|
|
372
|
+
Buffer.byteLength(parsed, "utf8") > maximum
|
|
373
|
+
|| !/^[A-Za-z0-9](?:[A-Za-z0-9._+-]*[A-Za-z0-9])?$/u.test(parsed)
|
|
374
|
+
) return fail(`${label} must be a token`);
|
|
375
|
+
return parsed;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function digest(value: unknown, label: string): string {
|
|
379
|
+
const parsed = token(value, label, 64);
|
|
380
|
+
if (!/^[a-f0-9]{64}$/u.test(parsed)) return fail(`${label} must be a SHA-256 digest`);
|
|
381
|
+
return parsed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function integer(value: unknown, label: string): number {
|
|
385
|
+
if (
|
|
386
|
+
typeof value !== "number"
|
|
387
|
+
|| !Number.isSafeInteger(value)
|
|
388
|
+
|| value < 0
|
|
389
|
+
) return fail(`${label} must be a non-negative integer`);
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function timestamp(value: unknown, label: string): string {
|
|
394
|
+
const parsed = coordinate(value, label);
|
|
395
|
+
const date = new Date(parsed);
|
|
396
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString() !== parsed) {
|
|
397
|
+
return fail(`${label} must be a canonical UTC timestamp`);
|
|
398
|
+
}
|
|
399
|
+
return parsed;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function nullableTimestamp(value: unknown, label: string): string | null {
|
|
403
|
+
return value === null ? null : timestamp(value, label);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
407
|
+
if (signal?.aborted === true) fail("operation was aborted");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function localId(kind: string, ...parts: readonly string[]): string {
|
|
411
|
+
return `${kind}:${sha256(canonicalJson(parts))}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function providerId(kind: string, ...parts: readonly string[]): string {
|
|
415
|
+
return `beeper-${kind}:${sha256(canonicalJson(parts))}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function parseSourceCoordinate(
|
|
419
|
+
value: unknown,
|
|
420
|
+
kind: BeeperMessageLikeMeSourceCoordinate["kind"],
|
|
421
|
+
label: string,
|
|
422
|
+
): BeeperMessageLikeMeSourceCoordinate {
|
|
423
|
+
const source = record(value, label);
|
|
424
|
+
if (kind === "account") {
|
|
425
|
+
exactKeys(source, ["kind", "accountId"], label);
|
|
426
|
+
if (source.kind !== kind) return fail(`${label}.kind does not match its record`);
|
|
427
|
+
return Object.freeze({ kind, accountId: coordinate(source.accountId, `${label}.accountId`) });
|
|
428
|
+
}
|
|
429
|
+
if (kind === "participant") {
|
|
430
|
+
exactKeys(source, ["kind", "accountId", "participantId"], label);
|
|
431
|
+
if (source.kind !== kind) return fail(`${label}.kind does not match its record`);
|
|
432
|
+
return Object.freeze({
|
|
433
|
+
kind,
|
|
434
|
+
accountId: coordinate(source.accountId, `${label}.accountId`),
|
|
435
|
+
participantId: coordinate(source.participantId, `${label}.participantId`),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (kind === "conversation") {
|
|
439
|
+
exactKeys(source, ["kind", "accountId", "conversationId"], label);
|
|
440
|
+
if (source.kind !== kind) return fail(`${label}.kind does not match its record`);
|
|
441
|
+
return Object.freeze({
|
|
442
|
+
kind,
|
|
443
|
+
accountId: coordinate(source.accountId, `${label}.accountId`),
|
|
444
|
+
conversationId: coordinate(source.conversationId, `${label}.conversationId`),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
exactKeys(source, ["kind", "accountId", "conversationId", "messageId"], label);
|
|
448
|
+
if (source.kind !== kind) return fail(`${label}.kind does not match its record`);
|
|
449
|
+
return Object.freeze({
|
|
450
|
+
kind,
|
|
451
|
+
accountId: coordinate(source.accountId, `${label}.accountId`),
|
|
452
|
+
conversationId: coordinate(source.conversationId, `${label}.conversationId`),
|
|
453
|
+
messageId: coordinate(source.messageId, `${label}.messageId`),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function requireCoordinate(
|
|
458
|
+
request: SummarizeBeeperContactInteractionsRequest,
|
|
459
|
+
candidate: unknown,
|
|
460
|
+
kind: BeeperMessageLikeMeSourceCoordinate["kind"],
|
|
461
|
+
index: number,
|
|
462
|
+
): BeeperMessageLikeMeSourceCoordinate {
|
|
463
|
+
const value = request.coordinateForRecord(candidate);
|
|
464
|
+
if (value === undefined) {
|
|
465
|
+
return fail(`record[${String(index)}] omitted its stable provider coordinate`);
|
|
466
|
+
}
|
|
467
|
+
return parseSourceCoordinate(value, kind, `record[${String(index)}] coordinate`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function assertCoordinateBinding(
|
|
471
|
+
value: BeeperMessageLikeMeRecord,
|
|
472
|
+
source: BeeperMessageLikeMeSourceCoordinate,
|
|
473
|
+
): void {
|
|
474
|
+
const accountLocalId = localId("account", source.accountId);
|
|
475
|
+
const accountProviderId = providerId("account", source.accountId);
|
|
476
|
+
if (
|
|
477
|
+
value.accountId !== accountLocalId
|
|
478
|
+
|| value.provenance.connectedAccountProviderId !== accountProviderId
|
|
479
|
+
) fail("a record coordinate does not bind its connected account");
|
|
480
|
+
if (value.kind === "account" && source.kind === "account") {
|
|
481
|
+
if (
|
|
482
|
+
value.id !== accountLocalId
|
|
483
|
+
|| value.provenance.providerId !== accountProviderId
|
|
484
|
+
) fail("an account coordinate does not bind its record");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (value.kind === "participant" && source.kind === "participant") {
|
|
488
|
+
if (
|
|
489
|
+
value.id !== localId("participant", source.accountId, source.participantId)
|
|
490
|
+
|| value.provenance.providerId
|
|
491
|
+
!== providerId("participant", source.accountId, source.participantId)
|
|
492
|
+
) fail("a participant coordinate does not bind its record");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (value.kind === "conversation" && source.kind === "conversation") {
|
|
496
|
+
if (
|
|
497
|
+
value.id !== localId("conversation", source.accountId, source.conversationId)
|
|
498
|
+
|| value.provenance.providerId
|
|
499
|
+
!== providerId("conversation", source.accountId, source.conversationId)
|
|
500
|
+
) fail("a conversation coordinate does not bind its record");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (value.kind === "message" && source.kind === "message") {
|
|
504
|
+
if (
|
|
505
|
+
value.id !== localId(
|
|
506
|
+
"message",
|
|
507
|
+
source.accountId,
|
|
508
|
+
source.conversationId,
|
|
509
|
+
source.messageId,
|
|
510
|
+
)
|
|
511
|
+
|| value.provenance.providerId !== providerId(
|
|
512
|
+
"message",
|
|
513
|
+
source.accountId,
|
|
514
|
+
source.conversationId,
|
|
515
|
+
source.messageId,
|
|
516
|
+
)
|
|
517
|
+
) fail("a message coordinate does not bind its record");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
fail("a record coordinate kind does not match its record");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function updateObservedAt(current: string | null, candidate: string): string {
|
|
524
|
+
return current === null || candidate > current ? candidate : current;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function summaryProjection(
|
|
528
|
+
value: Omit<BeeperContactInteractionSummary, "integrity">,
|
|
529
|
+
): Omit<BeeperContactInteractionSummary, "integrity"> {
|
|
530
|
+
return value;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Consume the exact same admitted, bounded history stream as the private
|
|
535
|
+
* Message Like Me bundle while retaining only relationship metadata. Message
|
|
536
|
+
* bodies and attachment metadata are parsed for source-contract integrity and
|
|
537
|
+
* immediately discarded.
|
|
538
|
+
*/
|
|
539
|
+
export async function summarizeBeeperContactInteractions(
|
|
540
|
+
request: SummarizeBeeperContactInteractionsRequest,
|
|
541
|
+
): Promise<BeeperContactInteractionSummary> {
|
|
542
|
+
if (
|
|
543
|
+
typeof request.source !== "object"
|
|
544
|
+
|| request.source === null
|
|
545
|
+
|| typeof request.source.completion !== "function"
|
|
546
|
+
|| typeof request.source.records !== "object"
|
|
547
|
+
|| request.source.records === null
|
|
548
|
+
|| typeof request.source.records[Symbol.asyncIterator] !== "function"
|
|
549
|
+
) return fail("source is malformed");
|
|
550
|
+
const descriptor = parseBeeperMessageLikeMeDescriptor(request.source.descriptor);
|
|
551
|
+
if (descriptor.source.version !== BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion) {
|
|
552
|
+
return fail("source transform version is unsupported");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const accounts = new Map<string, AccountFact>();
|
|
556
|
+
const participants = new Map<string, ParticipantFact>();
|
|
557
|
+
const conversations = new Map<string, ConversationFact>();
|
|
558
|
+
const messages = new Map<string, MessageFact>();
|
|
559
|
+
const providerMessages = new Map<string, MessageFact>();
|
|
560
|
+
const rawAccounts = new Set<string>();
|
|
561
|
+
const rawParticipants = new Set<string>();
|
|
562
|
+
const rawConversations = new Set<string>();
|
|
563
|
+
const rawMessages = new Set<string>();
|
|
564
|
+
let observedAt: string | null = null;
|
|
565
|
+
let recordCount = 0;
|
|
566
|
+
const startedAt = Date.now();
|
|
567
|
+
let progressFailed = false;
|
|
568
|
+
const report = (): void => {
|
|
569
|
+
try {
|
|
570
|
+
request.onProgress?.(Object.freeze({
|
|
571
|
+
phase: "summary-building",
|
|
572
|
+
elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1_000)),
|
|
573
|
+
records: recordCount,
|
|
574
|
+
}));
|
|
575
|
+
} catch {
|
|
576
|
+
progressFailed = true;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
report();
|
|
580
|
+
const heartbeat = request.onProgress === undefined
|
|
581
|
+
? undefined
|
|
582
|
+
: setInterval(report, PROGRESS_INTERVAL_MS);
|
|
583
|
+
try {
|
|
584
|
+
for await (const candidate of request.source.records) {
|
|
585
|
+
throwIfAborted(request.signal);
|
|
586
|
+
if (progressFailed) return fail("progress reporting failed");
|
|
587
|
+
if (recordCount >= MAX_RECORDS) return fail("source exceeded its record bound");
|
|
588
|
+
const parsed = parseBeeperMessageLikeMeRecord(candidate, recordCount);
|
|
589
|
+
observedAt = updateObservedAt(observedAt, parsed.provenance.observedAt);
|
|
590
|
+
if (
|
|
591
|
+
parsed.kind === "account"
|
|
592
|
+
|| parsed.kind === "participant"
|
|
593
|
+
|| parsed.kind === "conversation"
|
|
594
|
+
|| parsed.kind === "message"
|
|
595
|
+
) {
|
|
596
|
+
const source = requireCoordinate(request, candidate, parsed.kind, recordCount);
|
|
597
|
+
assertCoordinateBinding(parsed, source);
|
|
598
|
+
if (parsed.kind === "account" && source.kind === "account") {
|
|
599
|
+
if (accounts.has(parsed.id) || rawAccounts.has(source.accountId)) {
|
|
600
|
+
return fail("account coordinates repeat");
|
|
601
|
+
}
|
|
602
|
+
accounts.set(parsed.id, Object.freeze({
|
|
603
|
+
id: parsed.id,
|
|
604
|
+
accountId: source.accountId,
|
|
605
|
+
providerId: parsed.provenance.providerId,
|
|
606
|
+
network: parsed.network,
|
|
607
|
+
selfParticipantId: parsed.selfParticipantId,
|
|
608
|
+
observedAt: parsed.provenance.observedAt,
|
|
609
|
+
}));
|
|
610
|
+
rawAccounts.add(source.accountId);
|
|
611
|
+
} else if (parsed.kind === "participant" && source.kind === "participant") {
|
|
612
|
+
const raw = `${source.accountId}\0${source.participantId}`;
|
|
613
|
+
if (participants.has(parsed.id) || rawParticipants.has(raw)) {
|
|
614
|
+
return fail("participant coordinates repeat");
|
|
615
|
+
}
|
|
616
|
+
participants.set(parsed.id, Object.freeze({
|
|
617
|
+
id: parsed.id,
|
|
618
|
+
accountLocalId: parsed.accountId,
|
|
619
|
+
accountId: source.accountId,
|
|
620
|
+
participantId: source.participantId,
|
|
621
|
+
providerId: parsed.provenance.providerId,
|
|
622
|
+
isSelf: parsed.isSelf,
|
|
623
|
+
}));
|
|
624
|
+
rawParticipants.add(raw);
|
|
625
|
+
} else if (parsed.kind === "conversation" && source.kind === "conversation") {
|
|
626
|
+
const raw = `${source.accountId}\0${source.conversationId}`;
|
|
627
|
+
if (conversations.has(parsed.id) || rawConversations.has(raw)) {
|
|
628
|
+
return fail("conversation coordinates repeat");
|
|
629
|
+
}
|
|
630
|
+
conversations.set(parsed.id, Object.freeze({
|
|
631
|
+
id: parsed.id,
|
|
632
|
+
accountLocalId: parsed.accountId,
|
|
633
|
+
accountId: source.accountId,
|
|
634
|
+
conversationId: source.conversationId,
|
|
635
|
+
network: parsed.network,
|
|
636
|
+
type: parsed.type,
|
|
637
|
+
participantIds: parsed.participantIds,
|
|
638
|
+
participantsComplete: parsed.participantsComplete,
|
|
639
|
+
}));
|
|
640
|
+
rawConversations.add(raw);
|
|
641
|
+
} else if (parsed.kind === "message" && source.kind === "message") {
|
|
642
|
+
const raw = `${source.accountId}\0${source.conversationId}\0${source.messageId}`;
|
|
643
|
+
const provider = `${parsed.accountId}\0${parsed.provenance.providerId}`;
|
|
644
|
+
if (
|
|
645
|
+
messages.has(parsed.id)
|
|
646
|
+
|| rawMessages.has(raw)
|
|
647
|
+
|| providerMessages.has(provider)
|
|
648
|
+
) return fail("message coordinates repeat");
|
|
649
|
+
const fact = Object.freeze({
|
|
650
|
+
id: parsed.id,
|
|
651
|
+
accountLocalId: parsed.accountId,
|
|
652
|
+
accountId: source.accountId,
|
|
653
|
+
conversationLocalId: parsed.conversationId,
|
|
654
|
+
conversationId: source.conversationId,
|
|
655
|
+
messageId: source.messageId,
|
|
656
|
+
providerId: parsed.provenance.providerId,
|
|
657
|
+
senderParticipantId: parsed.senderParticipantId,
|
|
658
|
+
direction: parsed.direction,
|
|
659
|
+
sentAt: parsed.sentAt,
|
|
660
|
+
observedAt: parsed.provenance.observedAt,
|
|
661
|
+
replacement: parsed.edit?.kind === "replacement"
|
|
662
|
+
? Object.freeze({
|
|
663
|
+
replacesMessageId: parsed.edit.replacesMessageId,
|
|
664
|
+
replacesProviderId: parsed.edit.replacesProviderId,
|
|
665
|
+
})
|
|
666
|
+
: null,
|
|
667
|
+
});
|
|
668
|
+
messages.set(parsed.id, fact);
|
|
669
|
+
providerMessages.set(provider, fact);
|
|
670
|
+
rawMessages.add(raw);
|
|
671
|
+
} else {
|
|
672
|
+
return fail("a coordinate kind changed during parsing");
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
recordCount += 1;
|
|
676
|
+
if (recordCount % 10_000 === 0) report();
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
680
|
+
}
|
|
681
|
+
if (progressFailed) return fail("progress reporting failed");
|
|
682
|
+
throwIfAborted(request.signal);
|
|
683
|
+
const completion = parseBeeperMessageLikeMeCompletion(
|
|
684
|
+
await request.source.completion(),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
for (const participant of participants.values()) {
|
|
688
|
+
const account = accounts.get(participant.accountLocalId);
|
|
689
|
+
if (account === undefined || account.accountId !== participant.accountId) {
|
|
690
|
+
return fail("a participant references an unknown account");
|
|
691
|
+
}
|
|
692
|
+
if (participant.isSelf && account.selfParticipantId !== participant.id) {
|
|
693
|
+
return fail("an account has an unreferenced self participant");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (const account of accounts.values()) {
|
|
697
|
+
const self = participants.get(account.selfParticipantId);
|
|
698
|
+
if (
|
|
699
|
+
self === undefined
|
|
700
|
+
|| self.accountLocalId !== account.id
|
|
701
|
+
|| self.accountId !== account.accountId
|
|
702
|
+
|| !self.isSelf
|
|
703
|
+
) return fail("an account does not have its exact self participant");
|
|
704
|
+
}
|
|
705
|
+
for (const conversation of conversations.values()) {
|
|
706
|
+
const account = accounts.get(conversation.accountLocalId);
|
|
707
|
+
if (account === undefined || account.accountId !== conversation.accountId) {
|
|
708
|
+
return fail("a conversation references an unknown account");
|
|
709
|
+
}
|
|
710
|
+
for (const participantId of conversation.participantIds) {
|
|
711
|
+
const participant = participants.get(participantId);
|
|
712
|
+
if (
|
|
713
|
+
participant === undefined
|
|
714
|
+
|| participant.accountLocalId !== conversation.accountLocalId
|
|
715
|
+
|| participant.accountId !== conversation.accountId
|
|
716
|
+
) return fail("a conversation participant crosses accounts");
|
|
717
|
+
}
|
|
718
|
+
if (conversation.type === "direct" && conversation.participantsComplete === true) {
|
|
719
|
+
const roster = conversation.participantIds.map((participantId) =>
|
|
720
|
+
participants.get(participantId)!);
|
|
721
|
+
if (
|
|
722
|
+
roster.length !== 2
|
|
723
|
+
|| roster.filter((participant) => participant.isSelf).length !== 1
|
|
724
|
+
|| !conversation.participantIds.includes(account.selfParticipantId)
|
|
725
|
+
) return fail("a complete direct conversation does not have one exact peer");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
for (const message of messages.values()) {
|
|
729
|
+
const conversation = conversations.get(message.conversationLocalId);
|
|
730
|
+
if (
|
|
731
|
+
conversation === undefined
|
|
732
|
+
|| conversation.accountLocalId !== message.accountLocalId
|
|
733
|
+
|| conversation.accountId !== message.accountId
|
|
734
|
+
|| conversation.conversationId !== message.conversationId
|
|
735
|
+
) return fail("a message references an unknown conversation");
|
|
736
|
+
if (message.senderParticipantId !== null) {
|
|
737
|
+
const sender = participants.get(message.senderParticipantId);
|
|
738
|
+
if (
|
|
739
|
+
sender === undefined
|
|
740
|
+
|| sender.accountLocalId !== message.accountLocalId
|
|
741
|
+
) return fail("a message sender crosses accounts");
|
|
742
|
+
if (message.direction !== (sender.isSelf ? "outgoing" : "incoming")) {
|
|
743
|
+
return fail("message direction conflicts with its sender participant");
|
|
744
|
+
}
|
|
745
|
+
if (
|
|
746
|
+
conversation.participantsComplete === true
|
|
747
|
+
&& !conversation.participantIds.includes(sender.id)
|
|
748
|
+
) return fail("message sender is absent from the complete conversation roster");
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const replacementEdges = new Map<string, string>();
|
|
753
|
+
const replacedProviderCoordinates = new Set<string>();
|
|
754
|
+
const replacers = new Set<string>();
|
|
755
|
+
for (const message of messages.values()) {
|
|
756
|
+
const edit = message.replacement;
|
|
757
|
+
if (edit === null) continue;
|
|
758
|
+
const sourceCoordinate = `${message.accountLocalId}\0${message.providerId}`;
|
|
759
|
+
const targetCoordinate = `${message.accountLocalId}\0${edit.replacesProviderId}`;
|
|
760
|
+
if (sourceCoordinate === targetCoordinate) return fail("a replacement edit targets itself");
|
|
761
|
+
if (replacers.has(targetCoordinate)) {
|
|
762
|
+
return fail("one message version has multiple replacements");
|
|
763
|
+
}
|
|
764
|
+
const localTarget = edit.replacesMessageId === null
|
|
765
|
+
? undefined
|
|
766
|
+
: messages.get(edit.replacesMessageId);
|
|
767
|
+
const providerTarget = providerMessages.get(targetCoordinate);
|
|
768
|
+
if (
|
|
769
|
+
localTarget !== undefined
|
|
770
|
+
&& `${localTarget.accountLocalId}\0${localTarget.providerId}` !== targetCoordinate
|
|
771
|
+
) return fail("replacement local and provider coordinates disagree");
|
|
772
|
+
if (
|
|
773
|
+
localTarget !== undefined
|
|
774
|
+
&& providerTarget !== undefined
|
|
775
|
+
&& localTarget.id !== providerTarget.id
|
|
776
|
+
) return fail("replacement local and provider coordinates disagree");
|
|
777
|
+
const target = localTarget ?? providerTarget;
|
|
778
|
+
if (
|
|
779
|
+
target !== undefined
|
|
780
|
+
&& target.conversationLocalId !== message.conversationLocalId
|
|
781
|
+
) return fail("a replacement edit crosses conversations");
|
|
782
|
+
replacers.add(targetCoordinate);
|
|
783
|
+
replacementEdges.set(sourceCoordinate, targetCoordinate);
|
|
784
|
+
if (target !== undefined) replacedProviderCoordinates.add(targetCoordinate);
|
|
785
|
+
}
|
|
786
|
+
const completedReplacementNodes = new Set<string>();
|
|
787
|
+
for (const start of replacementEdges.keys()) {
|
|
788
|
+
if (completedReplacementNodes.has(start)) continue;
|
|
789
|
+
const seen = new Set<string>();
|
|
790
|
+
const chain: string[] = [];
|
|
791
|
+
let current: string | undefined = start;
|
|
792
|
+
while (current !== undefined && !completedReplacementNodes.has(current)) {
|
|
793
|
+
if (seen.has(current)) return fail("replacement edits contain a cycle");
|
|
794
|
+
seen.add(current);
|
|
795
|
+
chain.push(current);
|
|
796
|
+
current = replacementEdges.get(current);
|
|
797
|
+
}
|
|
798
|
+
for (const value of chain) completedReplacementNodes.add(value);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const summaries = new Map<string, MutableInteraction>();
|
|
802
|
+
for (const message of messages.values()) {
|
|
803
|
+
const direction = message.direction;
|
|
804
|
+
if (direction === "unknown") continue;
|
|
805
|
+
const providerCoordinate =
|
|
806
|
+
`${message.accountLocalId}\0${message.providerId}`;
|
|
807
|
+
if (replacedProviderCoordinates.has(providerCoordinate)) continue;
|
|
808
|
+
const conversation = conversations.get(message.conversationLocalId);
|
|
809
|
+
if (
|
|
810
|
+
conversation === undefined
|
|
811
|
+
|| conversation.type !== "direct"
|
|
812
|
+
|| conversation.participantsComplete !== true
|
|
813
|
+
) continue;
|
|
814
|
+
const account = accounts.get(message.accountLocalId);
|
|
815
|
+
if (account === undefined) return fail("a message account disappeared");
|
|
816
|
+
const peers = conversation.participantIds
|
|
817
|
+
.map((participantId) => participants.get(participantId))
|
|
818
|
+
.filter((participant): participant is ParticipantFact =>
|
|
819
|
+
participant !== undefined && !participant.isSelf);
|
|
820
|
+
const selves = conversation.participantIds
|
|
821
|
+
.map((participantId) => participants.get(participantId))
|
|
822
|
+
.filter((participant): participant is ParticipantFact =>
|
|
823
|
+
participant !== undefined && participant.isSelf);
|
|
824
|
+
if (
|
|
825
|
+
peers.length !== 1
|
|
826
|
+
|| selves.length !== 1
|
|
827
|
+
|| selves[0]!.id !== account.selfParticipantId
|
|
828
|
+
) return fail("a complete direct conversation does not have one exact peer");
|
|
829
|
+
const peer = peers[0]!;
|
|
830
|
+
const key = `${account.accountId}\0${peer.participantId}`;
|
|
831
|
+
const current = summaries.get(key) ?? {
|
|
832
|
+
accountId: account.accountId,
|
|
833
|
+
accountProviderId: account.providerId,
|
|
834
|
+
contactId: peer.participantId,
|
|
835
|
+
contactProviderId: peer.providerId,
|
|
836
|
+
network: conversation.network,
|
|
837
|
+
sentCount: 0,
|
|
838
|
+
receivedCount: 0,
|
|
839
|
+
conversations: new Set<string>(),
|
|
840
|
+
firstInteractionAt: message.sentAt,
|
|
841
|
+
lastInteractionAt: message.sentAt,
|
|
842
|
+
observedAt: message.observedAt,
|
|
843
|
+
};
|
|
844
|
+
if (
|
|
845
|
+
current.accountProviderId !== account.providerId
|
|
846
|
+
|| current.contactProviderId !== peer.providerId
|
|
847
|
+
|| current.network !== conversation.network
|
|
848
|
+
) return fail("one direct relationship changed provider provenance");
|
|
849
|
+
if (direction === "outgoing") current.sentCount += 1;
|
|
850
|
+
else current.receivedCount += 1;
|
|
851
|
+
current.conversations.add(conversation.id);
|
|
852
|
+
if (message.sentAt < current.firstInteractionAt) {
|
|
853
|
+
current.firstInteractionAt = message.sentAt;
|
|
854
|
+
}
|
|
855
|
+
if (message.sentAt > current.lastInteractionAt) {
|
|
856
|
+
current.lastInteractionAt = message.sentAt;
|
|
857
|
+
}
|
|
858
|
+
if (message.observedAt > current.observedAt) {
|
|
859
|
+
current.observedAt = message.observedAt;
|
|
860
|
+
}
|
|
861
|
+
summaries.set(key, current);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const projectedAccounts = Object.freeze([...accounts.values()]
|
|
865
|
+
.map((account): BeeperContactInteractionAccount => {
|
|
866
|
+
const self = participants.get(account.selfParticipantId);
|
|
867
|
+
if (self === undefined) return fail("an account self participant disappeared");
|
|
868
|
+
return Object.freeze({
|
|
869
|
+
accountId: account.accountId,
|
|
870
|
+
accountProviderId: account.providerId,
|
|
871
|
+
network: account.network,
|
|
872
|
+
selfParticipantId: self.participantId,
|
|
873
|
+
selfParticipantProviderId: self.providerId,
|
|
874
|
+
observedAt: account.observedAt,
|
|
875
|
+
});
|
|
876
|
+
})
|
|
877
|
+
.sort((left, right) => compareCanonicalText(left.accountId, right.accountId)));
|
|
878
|
+
const interactions = Object.freeze([...summaries.values()]
|
|
879
|
+
.map((summary): BeeperContactInteraction => Object.freeze({
|
|
880
|
+
accountId: summary.accountId,
|
|
881
|
+
accountProviderId: summary.accountProviderId,
|
|
882
|
+
contactId: summary.contactId,
|
|
883
|
+
contactProviderId: summary.contactProviderId,
|
|
884
|
+
network: summary.network,
|
|
885
|
+
sentCount: summary.sentCount,
|
|
886
|
+
receivedCount: summary.receivedCount,
|
|
887
|
+
interactionCount: summary.sentCount + summary.receivedCount,
|
|
888
|
+
conversationCount: summary.conversations.size,
|
|
889
|
+
firstInteractionAt: summary.firstInteractionAt,
|
|
890
|
+
lastInteractionAt: summary.lastInteractionAt,
|
|
891
|
+
reciprocal: summary.sentCount > 0 && summary.receivedCount > 0,
|
|
892
|
+
completeness: "lower-bound",
|
|
893
|
+
provenance: Object.freeze({
|
|
894
|
+
sourceId: "beeper-local",
|
|
895
|
+
sourceVersion: BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion,
|
|
896
|
+
providerId: "beeper",
|
|
897
|
+
providerVersion: descriptor.provider.version,
|
|
898
|
+
observedAt: summary.observedAt,
|
|
899
|
+
}),
|
|
900
|
+
}))
|
|
901
|
+
.sort((left, right) => compareCanonicalText(left.accountId, right.accountId)
|
|
902
|
+
|| compareCanonicalText(left.contactId, right.contactId)));
|
|
903
|
+
const sent = interactions.reduce((sum, item) => sum + item.sentCount, 0);
|
|
904
|
+
const received = interactions.reduce((sum, item) => sum + item.receivedCount, 0);
|
|
905
|
+
const directConversations = new Set(
|
|
906
|
+
[...summaries.values()].flatMap((summary) => [...summary.conversations]),
|
|
907
|
+
).size;
|
|
908
|
+
const warnings = Object.freeze([...new Set([
|
|
909
|
+
...completion.warnings,
|
|
910
|
+
...CONTACT_INTERACTION_WARNING_CODES,
|
|
911
|
+
])].sort());
|
|
912
|
+
const projection = Object.freeze({
|
|
913
|
+
schemaVersion: BEEPER_CONTACT_INTERACTION_SCHEMA_VERSION,
|
|
914
|
+
format: BEEPER_CONTACT_INTERACTION_FORMAT,
|
|
915
|
+
transform: BEEPER_CONTACT_INTERACTION_TRANSFORM,
|
|
916
|
+
source: Object.freeze({
|
|
917
|
+
id: "beeper-local" as const,
|
|
918
|
+
version: BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion,
|
|
919
|
+
}),
|
|
920
|
+
provider: Object.freeze({ id: "beeper" as const, version: descriptor.provider.version }),
|
|
921
|
+
observedAt,
|
|
922
|
+
scope: Object.freeze({
|
|
923
|
+
conversations: "complete-direct-only" as const,
|
|
924
|
+
messages: "current-direction-known-only" as const,
|
|
925
|
+
}),
|
|
926
|
+
completeness: Object.freeze({
|
|
927
|
+
kind: "lower-bound" as const,
|
|
928
|
+
sourceKind: completion.completeness.kind,
|
|
929
|
+
reason: completion.completeness.reason,
|
|
930
|
+
observedFrom: completion.completeness.observedFrom,
|
|
931
|
+
observedThrough: completion.completeness.observedThrough,
|
|
932
|
+
}),
|
|
933
|
+
warnings,
|
|
934
|
+
privacy: Object.freeze({
|
|
935
|
+
messageBodies: "excluded" as const,
|
|
936
|
+
attachments: "excluded" as const,
|
|
937
|
+
reactions: "excluded" as const,
|
|
938
|
+
media: "excluded" as const,
|
|
939
|
+
groupMessages: "excluded" as const,
|
|
940
|
+
localPaths: "excluded" as const,
|
|
941
|
+
credentials: "excluded" as const,
|
|
942
|
+
}),
|
|
943
|
+
counts: Object.freeze({
|
|
944
|
+
accounts: projectedAccounts.length,
|
|
945
|
+
directRelationships: interactions.length,
|
|
946
|
+
directConversations,
|
|
947
|
+
interactions: sent + received,
|
|
948
|
+
sent,
|
|
949
|
+
received,
|
|
950
|
+
}),
|
|
951
|
+
accounts: projectedAccounts,
|
|
952
|
+
interactions,
|
|
953
|
+
}) satisfies Omit<BeeperContactInteractionSummary, "integrity">;
|
|
954
|
+
const summary = Object.freeze({
|
|
955
|
+
...projection,
|
|
956
|
+
integrity: Object.freeze({
|
|
957
|
+
algorithm: "sha256" as const,
|
|
958
|
+
summarySha256: sha256(canonicalJson(summaryProjection(projection))),
|
|
959
|
+
}),
|
|
960
|
+
});
|
|
961
|
+
if (Buffer.byteLength(canonicalJson(summary), "utf8") > MAX_OUTPUT_BYTES) {
|
|
962
|
+
return fail("summary exceeded its output byte bound");
|
|
963
|
+
}
|
|
964
|
+
request.onProgress?.(Object.freeze({
|
|
965
|
+
phase: "summary-completed",
|
|
966
|
+
records: recordCount,
|
|
967
|
+
relationships: interactions.length,
|
|
968
|
+
interactions: sent + received,
|
|
969
|
+
}));
|
|
970
|
+
return summary;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function parseStringArray(
|
|
974
|
+
value: unknown,
|
|
975
|
+
label: string,
|
|
976
|
+
maximum: number,
|
|
977
|
+
): readonly string[] {
|
|
978
|
+
const parsed = boundedArray(value, label, maximum).map((item, index) =>
|
|
979
|
+
token(item, `${label}[${String(index)}]`));
|
|
980
|
+
if (new Set(parsed).size !== parsed.length) return fail(`${label} contains duplicates`);
|
|
981
|
+
return Object.freeze(parsed);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** Strict parser for the body-free public CLI/client artifact. */
|
|
985
|
+
export function parseBeeperContactInteractionSummary(
|
|
986
|
+
value: unknown,
|
|
987
|
+
): BeeperContactInteractionSummary {
|
|
988
|
+
const source = record(value, "summary");
|
|
989
|
+
exactKeys(source, [
|
|
990
|
+
"schemaVersion", "format", "transform", "source", "provider", "observedAt",
|
|
991
|
+
"scope", "completeness", "warnings", "privacy", "counts", "accounts",
|
|
992
|
+
"interactions", "integrity",
|
|
993
|
+
], "summary");
|
|
994
|
+
if (
|
|
995
|
+
source.schemaVersion !== BEEPER_CONTACT_INTERACTION_SCHEMA_VERSION
|
|
996
|
+
|| source.format !== BEEPER_CONTACT_INTERACTION_FORMAT
|
|
997
|
+
) return fail("schema is unsupported");
|
|
998
|
+
const transform = record(source.transform, "summary.transform");
|
|
999
|
+
exactKeys(transform, ["id", "version", "sourceVersion"], "summary.transform");
|
|
1000
|
+
if (
|
|
1001
|
+
transform.id !== BEEPER_CONTACT_INTERACTION_TRANSFORM.id
|
|
1002
|
+
|| transform.version !== BEEPER_CONTACT_INTERACTION_TRANSFORM.version
|
|
1003
|
+
|| transform.sourceVersion !== BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion
|
|
1004
|
+
) return fail("transform is unsupported");
|
|
1005
|
+
const sourceValue = record(source.source, "summary.source");
|
|
1006
|
+
exactKeys(sourceValue, ["id", "version"], "summary.source");
|
|
1007
|
+
if (
|
|
1008
|
+
sourceValue.id !== "beeper-local"
|
|
1009
|
+
|| sourceValue.version !== BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion
|
|
1010
|
+
) return fail("source is unsupported");
|
|
1011
|
+
const provider = record(source.provider, "summary.provider");
|
|
1012
|
+
exactKeys(provider, ["id", "version"], "summary.provider");
|
|
1013
|
+
if (provider.id !== "beeper") return fail("provider is unsupported");
|
|
1014
|
+
const providerVersion = token(provider.version, "summary.provider.version");
|
|
1015
|
+
const observedAt = nullableTimestamp(source.observedAt, "summary.observedAt");
|
|
1016
|
+
const scope = record(source.scope, "summary.scope");
|
|
1017
|
+
exactKeys(scope, ["conversations", "messages"], "summary.scope");
|
|
1018
|
+
if (
|
|
1019
|
+
scope.conversations !== "complete-direct-only"
|
|
1020
|
+
|| scope.messages !== "current-direction-known-only"
|
|
1021
|
+
) return fail("scope is unsupported");
|
|
1022
|
+
const completeness = record(source.completeness, "summary.completeness");
|
|
1023
|
+
exactKeys(completeness, [
|
|
1024
|
+
"kind", "sourceKind", "reason", "observedFrom", "observedThrough",
|
|
1025
|
+
], "summary.completeness");
|
|
1026
|
+
if (completeness.kind !== "lower-bound") return fail("completeness kind is unsupported");
|
|
1027
|
+
if (
|
|
1028
|
+
completeness.sourceKind !== "bounded-local"
|
|
1029
|
+
&& completeness.sourceKind !== "truncated"
|
|
1030
|
+
&& completeness.sourceKind !== "unknown"
|
|
1031
|
+
) return fail("source completeness kind is unsupported");
|
|
1032
|
+
const reason = completeness.reason === null
|
|
1033
|
+
? null
|
|
1034
|
+
: token(completeness.reason, "summary.completeness.reason");
|
|
1035
|
+
const observedFrom = nullableTimestamp(
|
|
1036
|
+
completeness.observedFrom,
|
|
1037
|
+
"summary.completeness.observedFrom",
|
|
1038
|
+
);
|
|
1039
|
+
const observedThrough = nullableTimestamp(
|
|
1040
|
+
completeness.observedThrough,
|
|
1041
|
+
"summary.completeness.observedThrough",
|
|
1042
|
+
);
|
|
1043
|
+
if (observedFrom !== null && observedThrough !== null && observedFrom > observedThrough) {
|
|
1044
|
+
return fail("completeness timestamps are reversed");
|
|
1045
|
+
}
|
|
1046
|
+
const warnings = parseStringArray(source.warnings, "summary.warnings", MAX_WARNINGS);
|
|
1047
|
+
const privacy = record(source.privacy, "summary.privacy");
|
|
1048
|
+
exactKeys(privacy, [
|
|
1049
|
+
"messageBodies", "attachments", "reactions", "media", "groupMessages",
|
|
1050
|
+
"localPaths", "credentials",
|
|
1051
|
+
], "summary.privacy");
|
|
1052
|
+
if (
|
|
1053
|
+
privacy.messageBodies !== "excluded"
|
|
1054
|
+
|| privacy.attachments !== "excluded"
|
|
1055
|
+
|| privacy.reactions !== "excluded"
|
|
1056
|
+
|| privacy.media !== "excluded"
|
|
1057
|
+
|| privacy.groupMessages !== "excluded"
|
|
1058
|
+
|| privacy.localPaths !== "excluded"
|
|
1059
|
+
|| privacy.credentials !== "excluded"
|
|
1060
|
+
) return fail("privacy boundary is unsupported");
|
|
1061
|
+
const counts = record(source.counts, "summary.counts");
|
|
1062
|
+
exactKeys(counts, [
|
|
1063
|
+
"accounts", "directRelationships", "directConversations", "interactions",
|
|
1064
|
+
"sent", "received",
|
|
1065
|
+
], "summary.counts");
|
|
1066
|
+
const parsedCounts = Object.freeze({
|
|
1067
|
+
accounts: integer(counts.accounts, "summary.counts.accounts"),
|
|
1068
|
+
directRelationships: integer(
|
|
1069
|
+
counts.directRelationships,
|
|
1070
|
+
"summary.counts.directRelationships",
|
|
1071
|
+
),
|
|
1072
|
+
directConversations: integer(
|
|
1073
|
+
counts.directConversations,
|
|
1074
|
+
"summary.counts.directConversations",
|
|
1075
|
+
),
|
|
1076
|
+
interactions: integer(counts.interactions, "summary.counts.interactions"),
|
|
1077
|
+
sent: integer(counts.sent, "summary.counts.sent"),
|
|
1078
|
+
received: integer(counts.received, "summary.counts.received"),
|
|
1079
|
+
});
|
|
1080
|
+
const accountValues = boundedArray(source.accounts, "summary.accounts", MAX_RECORDS);
|
|
1081
|
+
const accounts = Object.freeze(accountValues.map((item, index) => {
|
|
1082
|
+
const account = record(item, `summary.accounts[${String(index)}]`);
|
|
1083
|
+
exactKeys(account, [
|
|
1084
|
+
"accountId", "accountProviderId", "network", "selfParticipantId",
|
|
1085
|
+
"selfParticipantProviderId", "observedAt",
|
|
1086
|
+
], `summary.accounts[${String(index)}]`);
|
|
1087
|
+
const accountId = coordinate(account.accountId, `summary.accounts[${String(index)}].accountId`);
|
|
1088
|
+
const expectedProviderId = providerId("account", accountId);
|
|
1089
|
+
const accountProviderId = coordinate(
|
|
1090
|
+
account.accountProviderId,
|
|
1091
|
+
`summary.accounts[${String(index)}].accountProviderId`,
|
|
1092
|
+
);
|
|
1093
|
+
if (accountProviderId !== expectedProviderId) return fail("an account provider coordinate is invalid");
|
|
1094
|
+
const selfParticipantId = coordinate(
|
|
1095
|
+
account.selfParticipantId,
|
|
1096
|
+
`summary.accounts[${String(index)}].selfParticipantId`,
|
|
1097
|
+
);
|
|
1098
|
+
const selfParticipantProviderId = coordinate(
|
|
1099
|
+
account.selfParticipantProviderId,
|
|
1100
|
+
`summary.accounts[${String(index)}].selfParticipantProviderId`,
|
|
1101
|
+
);
|
|
1102
|
+
if (
|
|
1103
|
+
selfParticipantProviderId
|
|
1104
|
+
!== providerId("participant", accountId, selfParticipantId)
|
|
1105
|
+
) return fail("an account self provider coordinate is invalid");
|
|
1106
|
+
return Object.freeze({
|
|
1107
|
+
accountId,
|
|
1108
|
+
accountProviderId,
|
|
1109
|
+
network: token(account.network, `summary.accounts[${String(index)}].network`, MAX_NETWORK_BYTES),
|
|
1110
|
+
selfParticipantId,
|
|
1111
|
+
selfParticipantProviderId,
|
|
1112
|
+
observedAt: timestamp(account.observedAt, `summary.accounts[${String(index)}].observedAt`),
|
|
1113
|
+
});
|
|
1114
|
+
}));
|
|
1115
|
+
const accountKeys = accounts.map((account) => account.accountId);
|
|
1116
|
+
if (
|
|
1117
|
+
new Set(accountKeys).size !== accountKeys.length
|
|
1118
|
+
|| accountKeys.some((key, index) =>
|
|
1119
|
+
index > 0 && compareCanonicalText(key, accountKeys[index - 1]!) <= 0)
|
|
1120
|
+
) return fail("accounts are not unique and canonically ordered");
|
|
1121
|
+
const interactionValues = boundedArray(
|
|
1122
|
+
source.interactions,
|
|
1123
|
+
"summary.interactions",
|
|
1124
|
+
MAX_RECORDS,
|
|
1125
|
+
);
|
|
1126
|
+
const accountsById = new Map(accounts.map((account) => [account.accountId, account]));
|
|
1127
|
+
const interactions = Object.freeze(interactionValues.map((item, index) => {
|
|
1128
|
+
const interaction = record(item, `summary.interactions[${String(index)}]`);
|
|
1129
|
+
exactKeys(interaction, [
|
|
1130
|
+
"accountId", "accountProviderId", "contactId", "contactProviderId",
|
|
1131
|
+
"network", "sentCount", "receivedCount", "interactionCount",
|
|
1132
|
+
"conversationCount", "firstInteractionAt", "lastInteractionAt",
|
|
1133
|
+
"reciprocal", "completeness", "provenance",
|
|
1134
|
+
], `summary.interactions[${String(index)}]`);
|
|
1135
|
+
const accountId = coordinate(
|
|
1136
|
+
interaction.accountId,
|
|
1137
|
+
`summary.interactions[${String(index)}].accountId`,
|
|
1138
|
+
);
|
|
1139
|
+
const account = accountsById.get(accountId);
|
|
1140
|
+
if (account === undefined) return fail("an interaction references an unknown account");
|
|
1141
|
+
const accountProviderId = coordinate(
|
|
1142
|
+
interaction.accountProviderId,
|
|
1143
|
+
`summary.interactions[${String(index)}].accountProviderId`,
|
|
1144
|
+
);
|
|
1145
|
+
if (accountProviderId !== account.accountProviderId) {
|
|
1146
|
+
return fail("an interaction account provider coordinate is invalid");
|
|
1147
|
+
}
|
|
1148
|
+
const contactId = coordinate(
|
|
1149
|
+
interaction.contactId,
|
|
1150
|
+
`summary.interactions[${String(index)}].contactId`,
|
|
1151
|
+
);
|
|
1152
|
+
if (contactId === account.selfParticipantId) {
|
|
1153
|
+
return fail("an interaction contact cannot be the account self participant");
|
|
1154
|
+
}
|
|
1155
|
+
const contactProviderId = coordinate(
|
|
1156
|
+
interaction.contactProviderId,
|
|
1157
|
+
`summary.interactions[${String(index)}].contactProviderId`,
|
|
1158
|
+
);
|
|
1159
|
+
if (contactProviderId !== providerId("participant", accountId, contactId)) {
|
|
1160
|
+
return fail("an interaction contact provider coordinate is invalid");
|
|
1161
|
+
}
|
|
1162
|
+
const network = token(
|
|
1163
|
+
interaction.network,
|
|
1164
|
+
`summary.interactions[${String(index)}].network`,
|
|
1165
|
+
MAX_NETWORK_BYTES,
|
|
1166
|
+
);
|
|
1167
|
+
if (network !== account.network) return fail("an interaction changed account networks");
|
|
1168
|
+
const sentCount = integer(
|
|
1169
|
+
interaction.sentCount,
|
|
1170
|
+
`summary.interactions[${String(index)}].sentCount`,
|
|
1171
|
+
);
|
|
1172
|
+
const receivedCount = integer(
|
|
1173
|
+
interaction.receivedCount,
|
|
1174
|
+
`summary.interactions[${String(index)}].receivedCount`,
|
|
1175
|
+
);
|
|
1176
|
+
const interactionCount = integer(
|
|
1177
|
+
interaction.interactionCount,
|
|
1178
|
+
`summary.interactions[${String(index)}].interactionCount`,
|
|
1179
|
+
);
|
|
1180
|
+
const conversationCount = integer(
|
|
1181
|
+
interaction.conversationCount,
|
|
1182
|
+
`summary.interactions[${String(index)}].conversationCount`,
|
|
1183
|
+
);
|
|
1184
|
+
if (
|
|
1185
|
+
interactionCount !== sentCount + receivedCount
|
|
1186
|
+
|| interactionCount < 1
|
|
1187
|
+
|| conversationCount < 1
|
|
1188
|
+
|| conversationCount > interactionCount
|
|
1189
|
+
|| interaction.reciprocal !== (sentCount > 0 && receivedCount > 0)
|
|
1190
|
+
|| interaction.completeness !== "lower-bound"
|
|
1191
|
+
) return fail("an interaction has inconsistent counts or completeness");
|
|
1192
|
+
const firstInteractionAt = timestamp(
|
|
1193
|
+
interaction.firstInteractionAt,
|
|
1194
|
+
`summary.interactions[${String(index)}].firstInteractionAt`,
|
|
1195
|
+
);
|
|
1196
|
+
const lastInteractionAt = timestamp(
|
|
1197
|
+
interaction.lastInteractionAt,
|
|
1198
|
+
`summary.interactions[${String(index)}].lastInteractionAt`,
|
|
1199
|
+
);
|
|
1200
|
+
if (firstInteractionAt > lastInteractionAt) return fail("interaction timestamps are reversed");
|
|
1201
|
+
const provenance = record(
|
|
1202
|
+
interaction.provenance,
|
|
1203
|
+
`summary.interactions[${String(index)}].provenance`,
|
|
1204
|
+
);
|
|
1205
|
+
exactKeys(provenance, [
|
|
1206
|
+
"sourceId", "sourceVersion", "providerId", "providerVersion", "observedAt",
|
|
1207
|
+
], `summary.interactions[${String(index)}].provenance`);
|
|
1208
|
+
if (
|
|
1209
|
+
provenance.sourceId !== "beeper-local"
|
|
1210
|
+
|| provenance.sourceVersion !== BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion
|
|
1211
|
+
|| provenance.providerId !== "beeper"
|
|
1212
|
+
|| provenance.providerVersion !== providerVersion
|
|
1213
|
+
) return fail("interaction provenance is unsupported");
|
|
1214
|
+
return Object.freeze({
|
|
1215
|
+
accountId,
|
|
1216
|
+
accountProviderId,
|
|
1217
|
+
contactId,
|
|
1218
|
+
contactProviderId,
|
|
1219
|
+
network,
|
|
1220
|
+
sentCount,
|
|
1221
|
+
receivedCount,
|
|
1222
|
+
interactionCount,
|
|
1223
|
+
conversationCount,
|
|
1224
|
+
firstInteractionAt,
|
|
1225
|
+
lastInteractionAt,
|
|
1226
|
+
reciprocal: interaction.reciprocal as boolean,
|
|
1227
|
+
completeness: "lower-bound" as const,
|
|
1228
|
+
provenance: Object.freeze({
|
|
1229
|
+
sourceId: "beeper-local" as const,
|
|
1230
|
+
sourceVersion: BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion,
|
|
1231
|
+
providerId: "beeper" as const,
|
|
1232
|
+
providerVersion,
|
|
1233
|
+
observedAt: timestamp(
|
|
1234
|
+
provenance.observedAt,
|
|
1235
|
+
`summary.interactions[${String(index)}].provenance.observedAt`,
|
|
1236
|
+
),
|
|
1237
|
+
}),
|
|
1238
|
+
});
|
|
1239
|
+
}));
|
|
1240
|
+
const interactionKeys = interactions.map((item) => `${item.accountId}\0${item.contactId}`);
|
|
1241
|
+
if (
|
|
1242
|
+
new Set(interactionKeys).size !== interactionKeys.length
|
|
1243
|
+
|| interactionKeys.some((key, index) =>
|
|
1244
|
+
index > 0 && compareCanonicalText(key, interactionKeys[index - 1]!) <= 0)
|
|
1245
|
+
) return fail("interactions are not unique and canonically ordered");
|
|
1246
|
+
if (
|
|
1247
|
+
(accounts.length > 0 || interactions.length > 0)
|
|
1248
|
+
&& (
|
|
1249
|
+
observedAt === null
|
|
1250
|
+
|| accounts.some((account) => account.observedAt > observedAt)
|
|
1251
|
+
|| interactions.some((interaction) =>
|
|
1252
|
+
interaction.provenance.observedAt > observedAt)
|
|
1253
|
+
)
|
|
1254
|
+
) return fail("summary observation does not cover retained relationship facts");
|
|
1255
|
+
const expectedSent = interactions.reduce((sum, item) => sum + item.sentCount, 0);
|
|
1256
|
+
const expectedReceived = interactions.reduce((sum, item) => sum + item.receivedCount, 0);
|
|
1257
|
+
if (
|
|
1258
|
+
parsedCounts.accounts !== accounts.length
|
|
1259
|
+
|| parsedCounts.directRelationships !== interactions.length
|
|
1260
|
+
|| parsedCounts.interactions !== expectedSent + expectedReceived
|
|
1261
|
+
|| parsedCounts.sent !== expectedSent
|
|
1262
|
+
|| parsedCounts.received !== expectedReceived
|
|
1263
|
+
|| parsedCounts.directConversations
|
|
1264
|
+
!== interactions.reduce((sum, item) => sum + item.conversationCount, 0)
|
|
1265
|
+
) return fail("summary counts are inconsistent");
|
|
1266
|
+
const integrity = record(source.integrity, "summary.integrity");
|
|
1267
|
+
exactKeys(integrity, ["algorithm", "summarySha256"], "summary.integrity");
|
|
1268
|
+
if (integrity.algorithm !== "sha256") return fail("integrity algorithm is unsupported");
|
|
1269
|
+
const summarySha256 = digest(integrity.summarySha256, "summary.integrity.summarySha256");
|
|
1270
|
+
const projection = Object.freeze({
|
|
1271
|
+
schemaVersion: BEEPER_CONTACT_INTERACTION_SCHEMA_VERSION,
|
|
1272
|
+
format: BEEPER_CONTACT_INTERACTION_FORMAT,
|
|
1273
|
+
transform: BEEPER_CONTACT_INTERACTION_TRANSFORM,
|
|
1274
|
+
source: Object.freeze({
|
|
1275
|
+
id: "beeper-local" as const,
|
|
1276
|
+
version: BEEPER_CONTACT_INTERACTION_TRANSFORM.sourceVersion,
|
|
1277
|
+
}),
|
|
1278
|
+
provider: Object.freeze({ id: "beeper" as const, version: providerVersion }),
|
|
1279
|
+
observedAt,
|
|
1280
|
+
scope: Object.freeze({
|
|
1281
|
+
conversations: "complete-direct-only" as const,
|
|
1282
|
+
messages: "current-direction-known-only" as const,
|
|
1283
|
+
}),
|
|
1284
|
+
completeness: Object.freeze({
|
|
1285
|
+
kind: "lower-bound" as const,
|
|
1286
|
+
sourceKind: completeness.sourceKind,
|
|
1287
|
+
reason,
|
|
1288
|
+
observedFrom,
|
|
1289
|
+
observedThrough,
|
|
1290
|
+
}),
|
|
1291
|
+
warnings,
|
|
1292
|
+
privacy: Object.freeze({
|
|
1293
|
+
messageBodies: "excluded" as const,
|
|
1294
|
+
attachments: "excluded" as const,
|
|
1295
|
+
reactions: "excluded" as const,
|
|
1296
|
+
media: "excluded" as const,
|
|
1297
|
+
groupMessages: "excluded" as const,
|
|
1298
|
+
localPaths: "excluded" as const,
|
|
1299
|
+
credentials: "excluded" as const,
|
|
1300
|
+
}),
|
|
1301
|
+
counts: parsedCounts,
|
|
1302
|
+
accounts,
|
|
1303
|
+
interactions,
|
|
1304
|
+
}) satisfies Omit<BeeperContactInteractionSummary, "integrity">;
|
|
1305
|
+
if (sha256(canonicalJson(summaryProjection(projection))) !== summarySha256) {
|
|
1306
|
+
return fail("integrity digest does not bind the summary projection");
|
|
1307
|
+
}
|
|
1308
|
+
return Object.freeze({
|
|
1309
|
+
...projection,
|
|
1310
|
+
integrity: Object.freeze({ algorithm: "sha256", summarySha256 }),
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
type CreateBeeperContactInteractionExportResultRequest = Readonly<{
|
|
1315
|
+
runId: string;
|
|
1316
|
+
startedAt: string;
|
|
1317
|
+
finishedAt: string;
|
|
1318
|
+
authId: string;
|
|
1319
|
+
authIdentitySha256: string;
|
|
1320
|
+
bounds: BeeperContactInteractionExportBounds;
|
|
1321
|
+
output: BeeperContactInteractionSummary;
|
|
1322
|
+
}>;
|
|
1323
|
+
|
|
1324
|
+
function receiptProjection(
|
|
1325
|
+
receipt: Omit<BeeperContactInteractionExportReceipt, "integrity">,
|
|
1326
|
+
): Omit<BeeperContactInteractionExportReceipt, "integrity"> {
|
|
1327
|
+
return receipt;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function boundedTerminalWire(value: BeeperContactInteractionExportResult): string {
|
|
1331
|
+
const json = JSON.stringify(value);
|
|
1332
|
+
const wire = `${json.replace(
|
|
1333
|
+
/[\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/gu,
|
|
1334
|
+
(character) =>
|
|
1335
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
1336
|
+
)}\n`;
|
|
1337
|
+
if (Buffer.byteLength(wire, "utf8") > BEEPER_CONTACT_INTERACTION_WIRE_MAX_BYTES) {
|
|
1338
|
+
return fail("export result exceeded its terminal wire byte bound");
|
|
1339
|
+
}
|
|
1340
|
+
return wire;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Build and reparse the public success envelope after all private cleanup. */
|
|
1344
|
+
export function createBeeperContactInteractionExportResult(
|
|
1345
|
+
request: CreateBeeperContactInteractionExportResultRequest,
|
|
1346
|
+
): BeeperContactInteractionExportResult {
|
|
1347
|
+
const projection = Object.freeze({
|
|
1348
|
+
schemaVersion: 1 as const,
|
|
1349
|
+
format: BEEPER_CONTACT_INTERACTION_RECEIPT_FORMAT,
|
|
1350
|
+
runId: request.runId,
|
|
1351
|
+
operation: "beeper.export-contact-interactions" as const,
|
|
1352
|
+
status: "succeeded" as const,
|
|
1353
|
+
transport: "linked-device" as const,
|
|
1354
|
+
implementation: BEEPER_CONTACT_INTERACTION_IMPLEMENTATION,
|
|
1355
|
+
startedAt: request.startedAt,
|
|
1356
|
+
finishedAt: request.finishedAt,
|
|
1357
|
+
auth: Object.freeze({
|
|
1358
|
+
id: request.authId,
|
|
1359
|
+
kind: "linked-device-store" as const,
|
|
1360
|
+
provider: "beeper" as const,
|
|
1361
|
+
identitySha256: request.authIdentitySha256,
|
|
1362
|
+
}),
|
|
1363
|
+
bounds: Object.freeze(request.bounds),
|
|
1364
|
+
source: request.output.source,
|
|
1365
|
+
provider: request.output.provider,
|
|
1366
|
+
transform: request.output.transform,
|
|
1367
|
+
completeness: request.output.completeness,
|
|
1368
|
+
counts: request.output.counts,
|
|
1369
|
+
output: Object.freeze({
|
|
1370
|
+
schemaVersion: request.output.schemaVersion,
|
|
1371
|
+
format: request.output.format,
|
|
1372
|
+
summarySha256: request.output.integrity.summarySha256,
|
|
1373
|
+
}),
|
|
1374
|
+
privacy: Object.freeze({
|
|
1375
|
+
messageBodies: "excluded" as const,
|
|
1376
|
+
attachments: "excluded" as const,
|
|
1377
|
+
reactions: "excluded" as const,
|
|
1378
|
+
media: "excluded" as const,
|
|
1379
|
+
localPaths: "excluded" as const,
|
|
1380
|
+
credentials: "excluded" as const,
|
|
1381
|
+
}),
|
|
1382
|
+
}) satisfies Omit<BeeperContactInteractionExportReceipt, "integrity">;
|
|
1383
|
+
const receipt = Object.freeze({
|
|
1384
|
+
...projection,
|
|
1385
|
+
integrity: Object.freeze({
|
|
1386
|
+
algorithm: "sha256" as const,
|
|
1387
|
+
receiptSha256: sha256(canonicalJson(receiptProjection(projection))),
|
|
1388
|
+
}),
|
|
1389
|
+
});
|
|
1390
|
+
const result = parseBeeperContactInteractionExportResult(Object.freeze({
|
|
1391
|
+
receipt,
|
|
1392
|
+
output: request.output,
|
|
1393
|
+
}));
|
|
1394
|
+
boundedTerminalWire(result);
|
|
1395
|
+
return result;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function nullableBound(
|
|
1399
|
+
value: unknown,
|
|
1400
|
+
label: string,
|
|
1401
|
+
maximum: number,
|
|
1402
|
+
): number | null {
|
|
1403
|
+
if (value === null) return null;
|
|
1404
|
+
const parsed = integer(value, label);
|
|
1405
|
+
if (parsed < 1 || parsed > maximum) {
|
|
1406
|
+
return fail(`${label} is outside its supported bound`);
|
|
1407
|
+
}
|
|
1408
|
+
return parsed;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Strict parser for the public receipt plus body-free output envelope. */
|
|
1412
|
+
export function parseBeeperContactInteractionExportResult(
|
|
1413
|
+
value: unknown,
|
|
1414
|
+
): BeeperContactInteractionExportResult {
|
|
1415
|
+
const envelope = record(value, "export result");
|
|
1416
|
+
exactKeys(envelope, ["receipt", "output"], "export result");
|
|
1417
|
+
const output = parseBeeperContactInteractionSummary(envelope.output);
|
|
1418
|
+
const source = record(envelope.receipt, "export receipt");
|
|
1419
|
+
exactKeys(source, [
|
|
1420
|
+
"schemaVersion", "format", "runId", "operation", "status", "transport",
|
|
1421
|
+
"implementation", "startedAt", "finishedAt", "auth", "bounds", "source",
|
|
1422
|
+
"provider", "transform", "completeness", "counts", "output", "privacy",
|
|
1423
|
+
"integrity",
|
|
1424
|
+
], "export receipt");
|
|
1425
|
+
if (
|
|
1426
|
+
source.schemaVersion !== 1
|
|
1427
|
+
|| source.format !== BEEPER_CONTACT_INTERACTION_RECEIPT_FORMAT
|
|
1428
|
+
|| source.operation !== "beeper.export-contact-interactions"
|
|
1429
|
+
|| source.status !== "succeeded"
|
|
1430
|
+
|| source.transport !== "linked-device"
|
|
1431
|
+
) return fail("export receipt identity is unsupported");
|
|
1432
|
+
const implementation = record(
|
|
1433
|
+
source.implementation,
|
|
1434
|
+
"export receipt.implementation",
|
|
1435
|
+
);
|
|
1436
|
+
exactKeys(
|
|
1437
|
+
implementation,
|
|
1438
|
+
["producer", "officialCli"],
|
|
1439
|
+
"export receipt.implementation",
|
|
1440
|
+
);
|
|
1441
|
+
const producer = record(
|
|
1442
|
+
implementation.producer,
|
|
1443
|
+
"export receipt.implementation.producer",
|
|
1444
|
+
);
|
|
1445
|
+
exactKeys(
|
|
1446
|
+
producer,
|
|
1447
|
+
["package", "version"],
|
|
1448
|
+
"export receipt.implementation.producer",
|
|
1449
|
+
);
|
|
1450
|
+
const officialCli = record(
|
|
1451
|
+
implementation.officialCli,
|
|
1452
|
+
"export receipt.implementation.officialCli",
|
|
1453
|
+
);
|
|
1454
|
+
exactKeys(
|
|
1455
|
+
officialCli,
|
|
1456
|
+
["implementation", "version", "commit", "platform", "binarySha256"],
|
|
1457
|
+
"export receipt.implementation.officialCli",
|
|
1458
|
+
);
|
|
1459
|
+
if (
|
|
1460
|
+
producer.package !== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.producer.package
|
|
1461
|
+
|| producer.version !== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.producer.version
|
|
1462
|
+
|| officialCli.implementation
|
|
1463
|
+
!== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.officialCli.implementation
|
|
1464
|
+
|| officialCli.version
|
|
1465
|
+
!== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.officialCli.version
|
|
1466
|
+
|| officialCli.commit
|
|
1467
|
+
!== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.officialCli.commit
|
|
1468
|
+
|| officialCli.platform
|
|
1469
|
+
!== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.officialCli.platform
|
|
1470
|
+
|| officialCli.binarySha256
|
|
1471
|
+
!== BEEPER_CONTACT_INTERACTION_IMPLEMENTATION.officialCli.binarySha256
|
|
1472
|
+
) return fail("export receipt implementation identity is unsupported");
|
|
1473
|
+
const runId = coordinate(source.runId, "export receipt.runId");
|
|
1474
|
+
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u.test(runId)) {
|
|
1475
|
+
return fail("export receipt.runId must be a lowercase UUID v4");
|
|
1476
|
+
}
|
|
1477
|
+
const startedAt = timestamp(source.startedAt, "export receipt.startedAt");
|
|
1478
|
+
const finishedAt = timestamp(source.finishedAt, "export receipt.finishedAt");
|
|
1479
|
+
if (startedAt > finishedAt) return fail("export receipt timestamps are reversed");
|
|
1480
|
+
|
|
1481
|
+
const auth = record(source.auth, "export receipt.auth");
|
|
1482
|
+
exactKeys(auth, ["id", "kind", "provider", "identitySha256"], "export receipt.auth");
|
|
1483
|
+
const authId = coordinate(auth.id, "export receipt.auth.id");
|
|
1484
|
+
if (
|
|
1485
|
+
!/^[a-z][a-z0-9-]{0,127}$/u.test(authId)
|
|
1486
|
+
|| auth.kind !== "linked-device-store"
|
|
1487
|
+
|| auth.provider !== "beeper"
|
|
1488
|
+
) return fail("export receipt auth identity is unsupported");
|
|
1489
|
+
const identitySha256 = digest(
|
|
1490
|
+
auth.identitySha256,
|
|
1491
|
+
"export receipt.auth.identitySha256",
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const bounds = record(source.bounds, "export receipt.bounds");
|
|
1495
|
+
exactKeys(
|
|
1496
|
+
bounds,
|
|
1497
|
+
["limitChats", "limitMessages", "maxParticipants"],
|
|
1498
|
+
"export receipt.bounds",
|
|
1499
|
+
);
|
|
1500
|
+
const parsedBounds = Object.freeze({
|
|
1501
|
+
limitChats: nullableBound(bounds.limitChats, "export receipt.bounds.limitChats", 100_000),
|
|
1502
|
+
limitMessages: nullableBound(
|
|
1503
|
+
bounds.limitMessages,
|
|
1504
|
+
"export receipt.bounds.limitMessages",
|
|
1505
|
+
1_000_000,
|
|
1506
|
+
),
|
|
1507
|
+
maxParticipants: nullableBound(
|
|
1508
|
+
bounds.maxParticipants,
|
|
1509
|
+
"export receipt.bounds.maxParticipants",
|
|
1510
|
+
2_000,
|
|
1511
|
+
),
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
for (const [field, expected] of [
|
|
1515
|
+
["source", output.source],
|
|
1516
|
+
["provider", output.provider],
|
|
1517
|
+
["transform", output.transform],
|
|
1518
|
+
["completeness", output.completeness],
|
|
1519
|
+
["counts", output.counts],
|
|
1520
|
+
] as const) {
|
|
1521
|
+
const parsed = record(source[field], `export receipt.${field}`);
|
|
1522
|
+
if (canonicalJson(parsed) !== canonicalJson(expected)) {
|
|
1523
|
+
return fail(`export receipt.${field} does not bind the output`);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
const outputBinding = record(source.output, "export receipt.output");
|
|
1527
|
+
exactKeys(
|
|
1528
|
+
outputBinding,
|
|
1529
|
+
["schemaVersion", "format", "summarySha256"],
|
|
1530
|
+
"export receipt.output",
|
|
1531
|
+
);
|
|
1532
|
+
const summarySha256 = digest(
|
|
1533
|
+
outputBinding.summarySha256,
|
|
1534
|
+
"export receipt.output.summarySha256",
|
|
1535
|
+
);
|
|
1536
|
+
if (
|
|
1537
|
+
outputBinding.schemaVersion !== output.schemaVersion
|
|
1538
|
+
|| outputBinding.format !== output.format
|
|
1539
|
+
|| summarySha256 !== output.integrity.summarySha256
|
|
1540
|
+
) return fail("export receipt output identity does not bind the summary");
|
|
1541
|
+
|
|
1542
|
+
const privacy = record(source.privacy, "export receipt.privacy");
|
|
1543
|
+
exactKeys(privacy, [
|
|
1544
|
+
"messageBodies", "attachments", "reactions", "media", "localPaths",
|
|
1545
|
+
"credentials",
|
|
1546
|
+
], "export receipt.privacy");
|
|
1547
|
+
if (Object.values(privacy).some((item) => item !== "excluded")) {
|
|
1548
|
+
return fail("export receipt privacy boundary is unsupported");
|
|
1549
|
+
}
|
|
1550
|
+
const projection = Object.freeze({
|
|
1551
|
+
schemaVersion: 1 as const,
|
|
1552
|
+
format: BEEPER_CONTACT_INTERACTION_RECEIPT_FORMAT,
|
|
1553
|
+
runId,
|
|
1554
|
+
operation: "beeper.export-contact-interactions" as const,
|
|
1555
|
+
status: "succeeded" as const,
|
|
1556
|
+
transport: "linked-device" as const,
|
|
1557
|
+
implementation: BEEPER_CONTACT_INTERACTION_IMPLEMENTATION,
|
|
1558
|
+
startedAt,
|
|
1559
|
+
finishedAt,
|
|
1560
|
+
auth: Object.freeze({
|
|
1561
|
+
id: authId,
|
|
1562
|
+
kind: "linked-device-store" as const,
|
|
1563
|
+
provider: "beeper" as const,
|
|
1564
|
+
identitySha256,
|
|
1565
|
+
}),
|
|
1566
|
+
bounds: parsedBounds,
|
|
1567
|
+
source: output.source,
|
|
1568
|
+
provider: output.provider,
|
|
1569
|
+
transform: output.transform,
|
|
1570
|
+
completeness: output.completeness,
|
|
1571
|
+
counts: output.counts,
|
|
1572
|
+
output: Object.freeze({
|
|
1573
|
+
schemaVersion: output.schemaVersion,
|
|
1574
|
+
format: output.format,
|
|
1575
|
+
summarySha256,
|
|
1576
|
+
}),
|
|
1577
|
+
privacy: Object.freeze({
|
|
1578
|
+
messageBodies: "excluded" as const,
|
|
1579
|
+
attachments: "excluded" as const,
|
|
1580
|
+
reactions: "excluded" as const,
|
|
1581
|
+
media: "excluded" as const,
|
|
1582
|
+
localPaths: "excluded" as const,
|
|
1583
|
+
credentials: "excluded" as const,
|
|
1584
|
+
}),
|
|
1585
|
+
}) satisfies Omit<BeeperContactInteractionExportReceipt, "integrity">;
|
|
1586
|
+
const integrity = record(source.integrity, "export receipt.integrity");
|
|
1587
|
+
exactKeys(integrity, ["algorithm", "receiptSha256"], "export receipt.integrity");
|
|
1588
|
+
if (integrity.algorithm !== "sha256") {
|
|
1589
|
+
return fail("export receipt integrity algorithm is unsupported");
|
|
1590
|
+
}
|
|
1591
|
+
const receiptSha256 = digest(
|
|
1592
|
+
integrity.receiptSha256,
|
|
1593
|
+
"export receipt.integrity.receiptSha256",
|
|
1594
|
+
);
|
|
1595
|
+
if (sha256(canonicalJson(receiptProjection(projection))) !== receiptSha256) {
|
|
1596
|
+
return fail("export receipt integrity does not bind its projection");
|
|
1597
|
+
}
|
|
1598
|
+
return Object.freeze({
|
|
1599
|
+
receipt: Object.freeze({
|
|
1600
|
+
...projection,
|
|
1601
|
+
integrity: Object.freeze({ algorithm: "sha256", receiptSha256 }),
|
|
1602
|
+
}),
|
|
1603
|
+
output,
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Encode one strictly parsed result as bounded compact terminal-safe JSON. */
|
|
1608
|
+
export function encodeBeeperContactInteractionExportResult(
|
|
1609
|
+
value: unknown,
|
|
1610
|
+
): string {
|
|
1611
|
+
return boundedTerminalWire(parseBeeperContactInteractionExportResult(value));
|
|
1612
|
+
}
|