@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,2274 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
open,
|
|
7
|
+
readdir,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rmdir,
|
|
11
|
+
type FileHandle,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { types as nodeTypes } from "node:util";
|
|
16
|
+
import { dlopen, ptr } from "bun:ffi";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
LOCAL_MESSAGE_BUNDLE_V1_ARTIFACTS,
|
|
20
|
+
LOCAL_MESSAGE_BUNDLE_V1_FORMAT,
|
|
21
|
+
LOCAL_MESSAGE_BUNDLE_V1_LIMITS,
|
|
22
|
+
LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID,
|
|
23
|
+
LOCAL_MESSAGE_BUNDLE_V1_SCHEMA_VERSION,
|
|
24
|
+
LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID,
|
|
25
|
+
LOCAL_MESSAGE_BUNDLE_V1_SOURCE_TRANSFORM_VERSION,
|
|
26
|
+
parseLocalMessageBundleV1Manifest,
|
|
27
|
+
parseLocalMessageBundleV1Record,
|
|
28
|
+
type LocalMessageBundleV1AccountRecord,
|
|
29
|
+
type LocalMessageBundleV1AttachmentRecord,
|
|
30
|
+
type LocalMessageBundleV1ConversationRecord,
|
|
31
|
+
type LocalMessageBundleV1MessageRecord,
|
|
32
|
+
type LocalMessageBundleV1ParticipantRecord,
|
|
33
|
+
type LocalMessageBundleV1Provenance,
|
|
34
|
+
type LocalMessageBundleV1ReactionRecord,
|
|
35
|
+
type LocalMessageBundleV1Record,
|
|
36
|
+
type LocalMessageBundleV1RecordKind,
|
|
37
|
+
type LocalMessageBundleV1TombstoneRecord,
|
|
38
|
+
} from "@hraness/message-like-me/message-bundle-v1";
|
|
39
|
+
|
|
40
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
41
|
+
import {
|
|
42
|
+
createBeeperMessageLikeMeDirectoryLease,
|
|
43
|
+
releaseBeeperMessageLikeMeDirectoryLease,
|
|
44
|
+
type BeeperMessageLikeMeDirectoryLease,
|
|
45
|
+
} from "./beeper-message-like-me-recovery";
|
|
46
|
+
import { removePrivateDirectoryTree } from "./storage";
|
|
47
|
+
|
|
48
|
+
export const BEEPER_MESSAGE_LIKE_ME_SCHEMA_VERSION =
|
|
49
|
+
LOCAL_MESSAGE_BUNDLE_V1_SCHEMA_VERSION;
|
|
50
|
+
export const BEEPER_MESSAGE_LIKE_ME_MAX_RECORDS =
|
|
51
|
+
LOCAL_MESSAGE_BUNDLE_V1_LIMITS.records;
|
|
52
|
+
export const BEEPER_MESSAGE_LIKE_ME_MAX_TOTAL_BYTES =
|
|
53
|
+
LOCAL_MESSAGE_BUNDLE_V1_LIMITS.totalBytes;
|
|
54
|
+
|
|
55
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
56
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
57
|
+
const MAX_IDENTIFIER_BYTES = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.identifierBytes;
|
|
58
|
+
const MAX_SHORT_TEXT_BYTES = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.shortTextBytes;
|
|
59
|
+
const MAX_BODY_BYTES = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.bodyBytes;
|
|
60
|
+
const MAX_WARNING_CODES = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.warnings;
|
|
61
|
+
const MAX_PARTICIPANTS = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.participantsPerConversation;
|
|
62
|
+
const MAX_ATTACHMENTS = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.attachmentsPerMessage;
|
|
63
|
+
const MAX_CONNECTED_ACCOUNTS = LOCAL_MESSAGE_BUNDLE_V1_LIMITS.accounts;
|
|
64
|
+
const BUNDLE_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
65
|
+
const DARWIN_RENAME_EXCL = 0x0000_0004;
|
|
66
|
+
const LINUX_RENAME_NOREPLACE = 0x0000_0001;
|
|
67
|
+
const AT_FDCWD = -100;
|
|
68
|
+
|
|
69
|
+
type NativeExclusiveRename = (
|
|
70
|
+
source: Uint8Array,
|
|
71
|
+
destination: Uint8Array,
|
|
72
|
+
) => number;
|
|
73
|
+
|
|
74
|
+
let cachedNativeExclusiveRename: NativeExclusiveRename | undefined;
|
|
75
|
+
|
|
76
|
+
const HARD_LIMITS = Object.freeze({
|
|
77
|
+
maxRecords: BEEPER_MESSAGE_LIKE_ME_MAX_RECORDS,
|
|
78
|
+
maxRecordBytes: LOCAL_MESSAGE_BUNDLE_V1_LIMITS.recordBytes,
|
|
79
|
+
maxTotalBytes: BEEPER_MESSAGE_LIKE_ME_MAX_TOTAL_BYTES,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export type BeeperMessageLikeMeExportLimits = {
|
|
83
|
+
readonly maxRecords: number;
|
|
84
|
+
readonly maxRecordBytes: number;
|
|
85
|
+
readonly maxTotalBytes: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type BeeperMessageLikeMeBundleProgress =
|
|
89
|
+
| Readonly<{
|
|
90
|
+
phase: "bundle-building";
|
|
91
|
+
elapsedSeconds: number;
|
|
92
|
+
records: number;
|
|
93
|
+
bytes: number;
|
|
94
|
+
}>
|
|
95
|
+
| Readonly<{
|
|
96
|
+
phase: "bundle-validating";
|
|
97
|
+
elapsedSeconds: number;
|
|
98
|
+
records: number;
|
|
99
|
+
bytes: number;
|
|
100
|
+
}>
|
|
101
|
+
| Readonly<{
|
|
102
|
+
phase: "bundle-publishing";
|
|
103
|
+
elapsedSeconds: number;
|
|
104
|
+
records: number;
|
|
105
|
+
bytes: number;
|
|
106
|
+
}>
|
|
107
|
+
| Readonly<{
|
|
108
|
+
phase: "private-cleanup";
|
|
109
|
+
elapsedSeconds: number;
|
|
110
|
+
}>;
|
|
111
|
+
|
|
112
|
+
export type BeeperMessageLikeMeExportSource = {
|
|
113
|
+
/** Parsed as foreign data before any output directory is created. */
|
|
114
|
+
readonly descriptor: unknown;
|
|
115
|
+
/** Each yielded value is parsed strictly and written once. */
|
|
116
|
+
readonly records: AsyncIterable<unknown>;
|
|
117
|
+
/** Called only after the record stream ends successfully. */
|
|
118
|
+
readonly completion: () => Promise<unknown>;
|
|
119
|
+
/** Called exactly once after publication or failure cleanup. */
|
|
120
|
+
readonly dispose?: (published: boolean) => Promise<void>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type BeeperMessageLikeMeExportRequest = {
|
|
124
|
+
readonly outputRoot: string;
|
|
125
|
+
readonly source: BeeperMessageLikeMeExportSource;
|
|
126
|
+
readonly limits?: Partial<BeeperMessageLikeMeExportLimits>;
|
|
127
|
+
readonly signal?: AbortSignal;
|
|
128
|
+
readonly onProgress?: (progress: BeeperMessageLikeMeBundleProgress) => void;
|
|
129
|
+
/** Internal CLI composition seam for durable private-stage recovery. */
|
|
130
|
+
readonly recoveryEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
131
|
+
/** Test seam. Production callers should omit it. */
|
|
132
|
+
readonly clock?: () => Date;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type BeeperMessageLikeMeProvenance = LocalMessageBundleV1Provenance;
|
|
136
|
+
export type BeeperMessageLikeMeAccount = LocalMessageBundleV1AccountRecord;
|
|
137
|
+
export type BeeperMessageLikeMeParticipant = LocalMessageBundleV1ParticipantRecord;
|
|
138
|
+
export type BeeperMessageLikeMeConversation = LocalMessageBundleV1ConversationRecord;
|
|
139
|
+
export type BeeperMessageLikeMeAttachment = LocalMessageBundleV1AttachmentRecord;
|
|
140
|
+
export type BeeperMessageLikeMeMessage = LocalMessageBundleV1MessageRecord;
|
|
141
|
+
export type BeeperMessageLikeMeReaction = LocalMessageBundleV1ReactionRecord;
|
|
142
|
+
export type BeeperMessageLikeMeTombstone = LocalMessageBundleV1TombstoneRecord;
|
|
143
|
+
export type BeeperMessageLikeMeRecord = LocalMessageBundleV1Record;
|
|
144
|
+
|
|
145
|
+
export type BeeperMessageLikeMeArtifact = {
|
|
146
|
+
readonly path: string;
|
|
147
|
+
readonly mediaType: "application/x-ndjson";
|
|
148
|
+
readonly recordKind:
|
|
149
|
+
| "account"
|
|
150
|
+
| "participant"
|
|
151
|
+
| "conversation"
|
|
152
|
+
| "message"
|
|
153
|
+
| "reaction"
|
|
154
|
+
| "tombstone";
|
|
155
|
+
readonly records: number;
|
|
156
|
+
readonly bytes: number;
|
|
157
|
+
readonly sha256: string;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type BeeperMessageLikeMeManifest = {
|
|
161
|
+
readonly schemaVersion: 1;
|
|
162
|
+
readonly format: typeof LOCAL_MESSAGE_BUNDLE_V1_FORMAT;
|
|
163
|
+
readonly source: {
|
|
164
|
+
readonly id: typeof LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID;
|
|
165
|
+
readonly version: string;
|
|
166
|
+
};
|
|
167
|
+
readonly provider: {
|
|
168
|
+
readonly id: typeof LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID;
|
|
169
|
+
readonly version: string;
|
|
170
|
+
};
|
|
171
|
+
readonly timestamps: {
|
|
172
|
+
readonly startedAt: string;
|
|
173
|
+
readonly finishedAt: string;
|
|
174
|
+
readonly createdAt: string;
|
|
175
|
+
};
|
|
176
|
+
readonly completeness: {
|
|
177
|
+
readonly kind: "bounded-local" | "truncated" | "unknown";
|
|
178
|
+
readonly reason: string | null;
|
|
179
|
+
readonly observedFrom: string | null;
|
|
180
|
+
readonly observedThrough: string | null;
|
|
181
|
+
};
|
|
182
|
+
readonly warnings: readonly string[];
|
|
183
|
+
readonly privacy: {
|
|
184
|
+
readonly classification: "private-local";
|
|
185
|
+
readonly attachments: "metadata-only";
|
|
186
|
+
readonly providerUrls: "excluded";
|
|
187
|
+
readonly credentials: "excluded";
|
|
188
|
+
};
|
|
189
|
+
readonly counts: Readonly<Record<BeeperMessageLikeMeArtifact["recordKind"], number>>;
|
|
190
|
+
readonly artifacts: readonly BeeperMessageLikeMeArtifact[];
|
|
191
|
+
readonly integrity: {
|
|
192
|
+
readonly algorithm: "sha256";
|
|
193
|
+
readonly bundleSha256: string;
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export type BeeperMessageLikeMeExportResult = {
|
|
198
|
+
readonly outputRoot: string;
|
|
199
|
+
readonly manifestPath: string;
|
|
200
|
+
readonly manifestSha256: string;
|
|
201
|
+
readonly manifest: BeeperMessageLikeMeManifest;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
type JsonRecord = Record<string, unknown>;
|
|
205
|
+
type RecordKind = LocalMessageBundleV1RecordKind;
|
|
206
|
+
|
|
207
|
+
type ParsedDescriptor = {
|
|
208
|
+
readonly source: {
|
|
209
|
+
readonly id: typeof LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID;
|
|
210
|
+
readonly version: string;
|
|
211
|
+
};
|
|
212
|
+
readonly provider: {
|
|
213
|
+
readonly id: typeof LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID;
|
|
214
|
+
readonly version: string;
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
type ParsedCompletion = Pick<BeeperMessageLikeMeManifest, "completeness" | "warnings">;
|
|
219
|
+
|
|
220
|
+
type ParsedProvenance = {
|
|
221
|
+
readonly providerId: string;
|
|
222
|
+
readonly providerRevision: string | null;
|
|
223
|
+
readonly observedAt: string;
|
|
224
|
+
readonly connectedAccountProviderId: string;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
type ParsedRecord = {
|
|
228
|
+
readonly kind: RecordKind;
|
|
229
|
+
readonly id: string;
|
|
230
|
+
readonly accountId?: string;
|
|
231
|
+
readonly network?: string;
|
|
232
|
+
readonly connectedAccountProviderId?: string;
|
|
233
|
+
readonly value: BeeperMessageLikeMeRecord;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
type BundleGraphFact =
|
|
237
|
+
| Readonly<{
|
|
238
|
+
kind: "account";
|
|
239
|
+
id: string;
|
|
240
|
+
accountId: string;
|
|
241
|
+
providerId: string;
|
|
242
|
+
network: string;
|
|
243
|
+
selfParticipantId: string;
|
|
244
|
+
}>
|
|
245
|
+
| Readonly<{
|
|
246
|
+
kind: "participant";
|
|
247
|
+
id: string;
|
|
248
|
+
accountId: string;
|
|
249
|
+
providerId: string;
|
|
250
|
+
isSelf: boolean;
|
|
251
|
+
}>
|
|
252
|
+
| Readonly<{
|
|
253
|
+
kind: "conversation";
|
|
254
|
+
id: string;
|
|
255
|
+
accountId: string;
|
|
256
|
+
providerId: string;
|
|
257
|
+
type: BeeperMessageLikeMeConversation["type"];
|
|
258
|
+
participantIds: readonly string[];
|
|
259
|
+
participantsComplete: boolean | null;
|
|
260
|
+
}>
|
|
261
|
+
| Readonly<{
|
|
262
|
+
kind: "message";
|
|
263
|
+
id: string;
|
|
264
|
+
accountId: string;
|
|
265
|
+
providerId: string;
|
|
266
|
+
conversationId: string;
|
|
267
|
+
senderParticipantId: string | null;
|
|
268
|
+
direction: BeeperMessageLikeMeMessage["direction"];
|
|
269
|
+
replyTo: BeeperMessageLikeMeMessage["replyTo"];
|
|
270
|
+
edit: BeeperMessageLikeMeMessage["edit"];
|
|
271
|
+
}>
|
|
272
|
+
| Readonly<{
|
|
273
|
+
kind: "reaction";
|
|
274
|
+
id: string;
|
|
275
|
+
accountId: string;
|
|
276
|
+
providerId: string;
|
|
277
|
+
messageId: string | null;
|
|
278
|
+
messageProviderId: string;
|
|
279
|
+
participantId: string | null;
|
|
280
|
+
}>
|
|
281
|
+
| Readonly<{
|
|
282
|
+
kind: "tombstone";
|
|
283
|
+
id: string;
|
|
284
|
+
accountId: string;
|
|
285
|
+
providerId: string;
|
|
286
|
+
entityKind: BeeperMessageLikeMeTombstone["entityKind"];
|
|
287
|
+
entityId: string | null;
|
|
288
|
+
entityProviderId: string;
|
|
289
|
+
}>;
|
|
290
|
+
|
|
291
|
+
type BundleGraphInventory = ReadonlyMap<
|
|
292
|
+
RecordKind,
|
|
293
|
+
ReadonlyMap<string, BundleGraphFact>
|
|
294
|
+
>;
|
|
295
|
+
|
|
296
|
+
type ArtifactWriter = {
|
|
297
|
+
readonly kind: RecordKind;
|
|
298
|
+
readonly fileName: string;
|
|
299
|
+
readonly partPath: string;
|
|
300
|
+
readonly handle: FileHandle;
|
|
301
|
+
readonly hash: ReturnType<typeof createHash>;
|
|
302
|
+
records: number;
|
|
303
|
+
bytes: number;
|
|
304
|
+
closed: boolean;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
type PrivateDirectoryIdentity = {
|
|
308
|
+
readonly device: number;
|
|
309
|
+
readonly inode: number;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
type StagedManifest = {
|
|
313
|
+
readonly bytes: number;
|
|
314
|
+
readonly sha256: string;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
type PublishedDirectory = {
|
|
318
|
+
readonly path: string;
|
|
319
|
+
readonly parent: string;
|
|
320
|
+
readonly identity: PrivateDirectoryIdentity;
|
|
321
|
+
readonly parentIdentity: PrivateDirectoryIdentity;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
type PublishedBundle = {
|
|
325
|
+
readonly result: BeeperMessageLikeMeExportResult;
|
|
326
|
+
readonly directory: PublishedDirectory;
|
|
327
|
+
readonly directoryLease?: BeeperMessageLikeMeDirectoryLease;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const ARTIFACTS = Object.freeze(LOCAL_MESSAGE_BUNDLE_V1_ARTIFACTS.map(
|
|
331
|
+
({ kind, path }) => Object.freeze({ kind, fileName: path }),
|
|
332
|
+
));
|
|
333
|
+
|
|
334
|
+
function fail(message: string): never {
|
|
335
|
+
throw new Error(`Beeper Message Like Me export: ${message}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
339
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
340
|
+
&& (error as { readonly code?: unknown }).code === code;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function foreignRecord(value: unknown, label: string): JsonRecord {
|
|
344
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
345
|
+
return fail(`${label} must be a plain object`);
|
|
346
|
+
}
|
|
347
|
+
if (nodeTypes.isProxy(value)) return fail(`${label} must be a plain object`);
|
|
348
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
349
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
350
|
+
return fail(`${label} must be a plain object`);
|
|
351
|
+
}
|
|
352
|
+
const keys = Reflect.ownKeys(value);
|
|
353
|
+
if (keys.some((key) => typeof key !== "string")) {
|
|
354
|
+
return fail(`${label} must not contain symbol keys`);
|
|
355
|
+
}
|
|
356
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
357
|
+
for (const key of keys as string[]) {
|
|
358
|
+
const descriptor = descriptors[key];
|
|
359
|
+
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
|
360
|
+
return fail(`${label}.${key} must be an enumerable data property`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return value as JsonRecord;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function exactKeys(value: JsonRecord, expected: readonly string[], label: string): void {
|
|
367
|
+
const observed = Object.keys(value).sort();
|
|
368
|
+
const wanted = [...expected].sort();
|
|
369
|
+
if (
|
|
370
|
+
observed.length !== wanted.length
|
|
371
|
+
|| observed.some((key, index) => key !== wanted[index])
|
|
372
|
+
) {
|
|
373
|
+
fail(`${label} must contain exactly: ${expected.join(", ")}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function utf8Length(value: string): number {
|
|
378
|
+
return Buffer.byteLength(value, "utf8");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function text(value: unknown, label: string, maximumBytes: number): string {
|
|
382
|
+
if (typeof value !== "string" || utf8Length(value) > maximumBytes || value.includes("\0")) {
|
|
383
|
+
return fail(`${label} must be a NUL-free string of at most ${String(maximumBytes)} UTF-8 bytes`);
|
|
384
|
+
}
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function nullableText(value: unknown, label: string, maximumBytes: number): string | null {
|
|
389
|
+
return value === null ? null : text(value, label, maximumBytes);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function identifier(value: unknown, label: string): string {
|
|
393
|
+
const parsed = text(value, label, MAX_IDENTIFIER_BYTES);
|
|
394
|
+
if (parsed.length === 0 || /[\u0000-\u001f\u007f]/u.test(parsed)) {
|
|
395
|
+
return fail(`${label} must be a non-empty identifier without ASCII control characters`);
|
|
396
|
+
}
|
|
397
|
+
return parsed;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function nullableIdentifier(value: unknown, label: string): string | null {
|
|
401
|
+
return value === null ? null : identifier(value, label);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function token(value: unknown, label: string, maximumBytes = 128): string {
|
|
405
|
+
const parsed = text(value, label, maximumBytes);
|
|
406
|
+
if (!/^[a-z0-9](?:[a-z0-9._+-]*[a-z0-9])?$/u.test(parsed)) {
|
|
407
|
+
return fail(`${label} must be a lowercase categorical token`);
|
|
408
|
+
}
|
|
409
|
+
return parsed;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function version(value: unknown, label: string): string {
|
|
413
|
+
const parsed = text(value, label, 128);
|
|
414
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._+-]*[A-Za-z0-9])?$/u.test(parsed)) {
|
|
415
|
+
return fail(`${label} must be a bounded version token`);
|
|
416
|
+
}
|
|
417
|
+
return parsed;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function oneOf<const Values extends readonly string[]>(
|
|
421
|
+
value: unknown,
|
|
422
|
+
values: Values,
|
|
423
|
+
label: string,
|
|
424
|
+
): Values[number] {
|
|
425
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
426
|
+
return fail(`${label} must be one of: ${values.join(", ")}`);
|
|
427
|
+
}
|
|
428
|
+
return value as Values[number];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function boolean(value: unknown, label: string): boolean {
|
|
432
|
+
if (typeof value !== "boolean") return fail(`${label} must be a boolean`);
|
|
433
|
+
return value;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function nullableBoolean(value: unknown, label: string): boolean | null {
|
|
437
|
+
return value === null ? null : boolean(value, label);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function integer(value: unknown, label: string, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
441
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > maximum) {
|
|
442
|
+
return fail(`${label} must be a non-negative safe integer at most ${String(maximum)}`);
|
|
443
|
+
}
|
|
444
|
+
return value as number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function nullableInteger(value: unknown, label: string): number | null {
|
|
448
|
+
return value === null ? null : integer(value, label);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function timestamp(value: unknown, label: string): string {
|
|
452
|
+
const parsed = text(value, label, 64);
|
|
453
|
+
const instant = new Date(parsed);
|
|
454
|
+
if (!Number.isFinite(instant.getTime()) || instant.toISOString() !== parsed) {
|
|
455
|
+
return fail(`${label} must be a canonical UTC timestamp with millisecond precision`);
|
|
456
|
+
}
|
|
457
|
+
return parsed;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function nullableTimestamp(value: unknown, label: string): string | null {
|
|
461
|
+
return value === null ? null : timestamp(value, label);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function array(value: unknown, label: string, maximum: number): readonly unknown[] {
|
|
465
|
+
if (!Array.isArray(value) || value.length > maximum) {
|
|
466
|
+
return fail(`${label} must be an array of at most ${String(maximum)} items`);
|
|
467
|
+
}
|
|
468
|
+
return value;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function uniqueIdentifiers(value: unknown, label: string, maximum: number): readonly string[] {
|
|
472
|
+
const parsed = array(value, label, maximum).map((item, index) =>
|
|
473
|
+
identifier(item, `${label}[${String(index)}]`));
|
|
474
|
+
if (new Set(parsed).size !== parsed.length) fail(`${label} must not contain duplicates`);
|
|
475
|
+
return Object.freeze(parsed);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseProvenance(value: unknown, label: string): ParsedProvenance {
|
|
479
|
+
const source = foreignRecord(value, label);
|
|
480
|
+
exactKeys(source, [
|
|
481
|
+
"providerId",
|
|
482
|
+
"providerRevision",
|
|
483
|
+
"observedAt",
|
|
484
|
+
"connectedAccountProviderId",
|
|
485
|
+
], label);
|
|
486
|
+
return Object.freeze({
|
|
487
|
+
providerId: identifier(source.providerId, `${label}.providerId`),
|
|
488
|
+
providerRevision: nullableIdentifier(source.providerRevision, `${label}.providerRevision`),
|
|
489
|
+
observedAt: timestamp(source.observedAt, `${label}.observedAt`),
|
|
490
|
+
connectedAccountProviderId: identifier(
|
|
491
|
+
source.connectedAccountProviderId,
|
|
492
|
+
`${label}.connectedAccountProviderId`,
|
|
493
|
+
),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parseCommon(
|
|
498
|
+
source: JsonRecord,
|
|
499
|
+
kind: RecordKind,
|
|
500
|
+
expected: readonly string[],
|
|
501
|
+
label: string,
|
|
502
|
+
): {
|
|
503
|
+
readonly id: string;
|
|
504
|
+
readonly accountId: string;
|
|
505
|
+
readonly network: string;
|
|
506
|
+
readonly provenance: ParsedProvenance;
|
|
507
|
+
} {
|
|
508
|
+
exactKeys(source, ["schemaVersion", "kind", "id", "accountId", "network", "provenance", ...expected], label);
|
|
509
|
+
if (source.schemaVersion !== 1) fail(`${label}.schemaVersion must equal 1`);
|
|
510
|
+
if (source.kind !== kind) fail(`${label}.kind must equal ${kind}`);
|
|
511
|
+
return {
|
|
512
|
+
id: identifier(source.id, `${label}.id`),
|
|
513
|
+
accountId: identifier(source.accountId, `${label}.accountId`),
|
|
514
|
+
network: token(source.network, `${label}.network`, 64),
|
|
515
|
+
provenance: parseProvenance(source.provenance, `${label}.provenance`),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function parseAccount(source: JsonRecord, label: string): ParsedRecord {
|
|
520
|
+
const common = parseCommon(source, "account", ["displayName", "handle", "selfParticipantId"], label);
|
|
521
|
+
const value = Object.freeze({
|
|
522
|
+
schemaVersion: 1,
|
|
523
|
+
kind: "account",
|
|
524
|
+
...common,
|
|
525
|
+
displayName: nullableText(source.displayName, `${label}.displayName`, MAX_SHORT_TEXT_BYTES),
|
|
526
|
+
handle: nullableText(source.handle, `${label}.handle`, MAX_SHORT_TEXT_BYTES),
|
|
527
|
+
selfParticipantId: identifier(source.selfParticipantId, `${label}.selfParticipantId`),
|
|
528
|
+
});
|
|
529
|
+
if (common.id !== common.accountId) fail(`${label}.id and ${label}.accountId must match`);
|
|
530
|
+
if (common.provenance.providerId !== common.provenance.connectedAccountProviderId) {
|
|
531
|
+
fail(`${label}.provenance.providerId must identify the connected account`);
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
kind: "account",
|
|
535
|
+
id: common.id,
|
|
536
|
+
accountId: common.accountId,
|
|
537
|
+
network: common.network,
|
|
538
|
+
connectedAccountProviderId: common.provenance.connectedAccountProviderId,
|
|
539
|
+
value,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function parseParticipant(source: JsonRecord, label: string): ParsedRecord {
|
|
544
|
+
const common = parseCommon(source, "participant", ["displayName", "handle", "isSelf"], label);
|
|
545
|
+
return {
|
|
546
|
+
kind: "participant",
|
|
547
|
+
id: common.id,
|
|
548
|
+
accountId: common.accountId,
|
|
549
|
+
network: common.network,
|
|
550
|
+
connectedAccountProviderId: common.provenance.connectedAccountProviderId,
|
|
551
|
+
value: Object.freeze({
|
|
552
|
+
schemaVersion: 1,
|
|
553
|
+
kind: "participant",
|
|
554
|
+
...common,
|
|
555
|
+
displayName: nullableText(source.displayName, `${label}.displayName`, MAX_SHORT_TEXT_BYTES),
|
|
556
|
+
handle: nullableText(source.handle, `${label}.handle`, MAX_SHORT_TEXT_BYTES),
|
|
557
|
+
isSelf: boolean(source.isSelf, `${label}.isSelf`),
|
|
558
|
+
}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseConversation(source: JsonRecord, label: string): ParsedRecord {
|
|
563
|
+
const common = parseCommon(source, "conversation", [
|
|
564
|
+
"type", "title", "participantIds", "participantsComplete", "startedAt", "lastMessageAt",
|
|
565
|
+
], label);
|
|
566
|
+
const startedAt = nullableTimestamp(source.startedAt, `${label}.startedAt`);
|
|
567
|
+
const lastMessageAt = nullableTimestamp(source.lastMessageAt, `${label}.lastMessageAt`);
|
|
568
|
+
if (startedAt !== null && lastMessageAt !== null && startedAt > lastMessageAt) {
|
|
569
|
+
fail(`${label}.startedAt must not be after lastMessageAt`);
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
kind: "conversation",
|
|
573
|
+
id: common.id,
|
|
574
|
+
accountId: common.accountId,
|
|
575
|
+
network: common.network,
|
|
576
|
+
connectedAccountProviderId: common.provenance.connectedAccountProviderId,
|
|
577
|
+
value: Object.freeze({
|
|
578
|
+
schemaVersion: 1,
|
|
579
|
+
kind: "conversation",
|
|
580
|
+
...common,
|
|
581
|
+
type: oneOf(source.type, ["direct", "group", "channel", "unknown"] as const, `${label}.type`),
|
|
582
|
+
title: nullableText(source.title, `${label}.title`, MAX_SHORT_TEXT_BYTES),
|
|
583
|
+
participantIds: uniqueIdentifiers(source.participantIds, `${label}.participantIds`, MAX_PARTICIPANTS),
|
|
584
|
+
participantsComplete: nullableBoolean(
|
|
585
|
+
source.participantsComplete,
|
|
586
|
+
`${label}.participantsComplete`,
|
|
587
|
+
),
|
|
588
|
+
startedAt,
|
|
589
|
+
lastMessageAt,
|
|
590
|
+
}),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function parseReply(value: unknown, label: string): BeeperMessageLikeMeMessage["replyTo"] {
|
|
595
|
+
if (value === null) return null;
|
|
596
|
+
const source = foreignRecord(value, label);
|
|
597
|
+
exactKeys(source, ["messageId", "providerId"], label);
|
|
598
|
+
return Object.freeze({
|
|
599
|
+
messageId: source.messageId === null ? null : identifier(source.messageId, `${label}.messageId`),
|
|
600
|
+
providerId: identifier(source.providerId, `${label}.providerId`),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function parseEdit(value: unknown, label: string): BeeperMessageLikeMeMessage["edit"] {
|
|
605
|
+
if (value === null) return null;
|
|
606
|
+
const source = foreignRecord(value, label);
|
|
607
|
+
if (source.kind === "in-place") {
|
|
608
|
+
exactKeys(source, ["kind", "editedAt", "providerRevision"], label);
|
|
609
|
+
return Object.freeze({
|
|
610
|
+
kind: "in-place",
|
|
611
|
+
editedAt: timestamp(source.editedAt, `${label}.editedAt`),
|
|
612
|
+
providerRevision: identifier(source.providerRevision, `${label}.providerRevision`),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (source.kind !== "replacement") fail(`${label}.kind is unsupported`);
|
|
616
|
+
exactKeys(source, [
|
|
617
|
+
"kind",
|
|
618
|
+
"replacesMessageId",
|
|
619
|
+
"replacesProviderId",
|
|
620
|
+
"editedAt",
|
|
621
|
+
"providerRevision",
|
|
622
|
+
], label);
|
|
623
|
+
return Object.freeze({
|
|
624
|
+
kind: "replacement",
|
|
625
|
+
replacesMessageId: source.replacesMessageId === null
|
|
626
|
+
? null
|
|
627
|
+
: identifier(source.replacesMessageId, `${label}.replacesMessageId`),
|
|
628
|
+
replacesProviderId: identifier(
|
|
629
|
+
source.replacesProviderId,
|
|
630
|
+
`${label}.replacesProviderId`,
|
|
631
|
+
),
|
|
632
|
+
editedAt: timestamp(source.editedAt, `${label}.editedAt`),
|
|
633
|
+
providerRevision: identifier(source.providerRevision, `${label}.providerRevision`),
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function parseDeletion(value: unknown, label: string): BeeperMessageLikeMeMessage["deletion"] {
|
|
638
|
+
if (value === null) return null;
|
|
639
|
+
const source = foreignRecord(value, label);
|
|
640
|
+
exactKeys(source, ["state", "observedAt", "providerRevision"], label);
|
|
641
|
+
return Object.freeze({
|
|
642
|
+
state: oneOf(source.state, [
|
|
643
|
+
"revoked", "deleted-for-me", "revoked-and-deleted-for-me",
|
|
644
|
+
] as const, `${label}.state`),
|
|
645
|
+
observedAt: timestamp(source.observedAt, `${label}.observedAt`),
|
|
646
|
+
providerRevision: nullableIdentifier(source.providerRevision, `${label}.providerRevision`),
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function parseAttachments(value: unknown, label: string): readonly BeeperMessageLikeMeAttachment[] {
|
|
651
|
+
return Object.freeze(array(value, label, MAX_ATTACHMENTS).map((item, index) => {
|
|
652
|
+
const itemLabel = `${label}[${String(index)}]`;
|
|
653
|
+
const source = foreignRecord(item, itemLabel);
|
|
654
|
+
exactKeys(source, ["kind", "mimeType", "name", "sizeBytes"], itemLabel);
|
|
655
|
+
const name = nullableText(source.name, `${itemLabel}.name`, MAX_SHORT_TEXT_BYTES);
|
|
656
|
+
if (name !== null && (name === "." || name === ".." || name.includes("/") || name.includes("\\"))) {
|
|
657
|
+
fail(`${itemLabel}.name must be a base name, not a local path`);
|
|
658
|
+
}
|
|
659
|
+
return Object.freeze({
|
|
660
|
+
kind: oneOf(source.kind, [
|
|
661
|
+
"audio", "document", "image", "link", "sticker", "video", "unknown",
|
|
662
|
+
] as const, `${itemLabel}.kind`),
|
|
663
|
+
mimeType: nullableText(source.mimeType, `${itemLabel}.mimeType`, 256),
|
|
664
|
+
name,
|
|
665
|
+
sizeBytes: nullableInteger(source.sizeBytes, `${itemLabel}.sizeBytes`),
|
|
666
|
+
});
|
|
667
|
+
}));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function parseMessage(source: JsonRecord, label: string): ParsedRecord {
|
|
671
|
+
const common = parseCommon(source, "message", [
|
|
672
|
+
"conversationId",
|
|
673
|
+
"senderParticipantId",
|
|
674
|
+
"direction",
|
|
675
|
+
"sentAt",
|
|
676
|
+
"sortKey",
|
|
677
|
+
"body",
|
|
678
|
+
"bodyTruncated",
|
|
679
|
+
"replyTo",
|
|
680
|
+
"edit",
|
|
681
|
+
"deletion",
|
|
682
|
+
"attachments",
|
|
683
|
+
], label);
|
|
684
|
+
const sentAt = timestamp(source.sentAt, `${label}.sentAt`);
|
|
685
|
+
const edit = parseEdit(source.edit, `${label}.edit`);
|
|
686
|
+
if (edit !== null && edit.editedAt < sentAt) {
|
|
687
|
+
fail(`${label}.edit.editedAt must not be before sentAt`);
|
|
688
|
+
}
|
|
689
|
+
const deletion = parseDeletion(source.deletion, `${label}.deletion`);
|
|
690
|
+
const body = nullableText(source.body, `${label}.body`, MAX_BODY_BYTES);
|
|
691
|
+
if (deletion !== null && body !== null) {
|
|
692
|
+
fail(`${label}.body must be null when deletion is present`);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
kind: "message",
|
|
696
|
+
id: common.id,
|
|
697
|
+
accountId: common.accountId,
|
|
698
|
+
network: common.network,
|
|
699
|
+
connectedAccountProviderId: common.provenance.connectedAccountProviderId,
|
|
700
|
+
value: Object.freeze({
|
|
701
|
+
schemaVersion: 1,
|
|
702
|
+
kind: "message",
|
|
703
|
+
...common,
|
|
704
|
+
conversationId: identifier(source.conversationId, `${label}.conversationId`),
|
|
705
|
+
senderParticipantId: source.senderParticipantId === null
|
|
706
|
+
? null
|
|
707
|
+
: identifier(source.senderParticipantId, `${label}.senderParticipantId`),
|
|
708
|
+
direction: oneOf(source.direction, ["incoming", "outgoing", "unknown"] as const, `${label}.direction`),
|
|
709
|
+
sentAt,
|
|
710
|
+
sortKey: identifier(source.sortKey, `${label}.sortKey`),
|
|
711
|
+
body,
|
|
712
|
+
bodyTruncated: nullableBoolean(source.bodyTruncated, `${label}.bodyTruncated`),
|
|
713
|
+
replyTo: parseReply(source.replyTo, `${label}.replyTo`),
|
|
714
|
+
edit,
|
|
715
|
+
deletion,
|
|
716
|
+
attachments: parseAttachments(source.attachments, `${label}.attachments`),
|
|
717
|
+
}),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function parseReaction(source: JsonRecord, label: string): ParsedRecord {
|
|
722
|
+
const common = parseCommon(source, "reaction", [
|
|
723
|
+
"messageId", "messageProviderId", "participantId", "body", "reactedAt", "state",
|
|
724
|
+
], label);
|
|
725
|
+
return {
|
|
726
|
+
kind: "reaction",
|
|
727
|
+
id: common.id,
|
|
728
|
+
accountId: common.accountId,
|
|
729
|
+
network: common.network,
|
|
730
|
+
connectedAccountProviderId: common.provenance.connectedAccountProviderId,
|
|
731
|
+
value: Object.freeze({
|
|
732
|
+
schemaVersion: 1,
|
|
733
|
+
kind: "reaction",
|
|
734
|
+
...common,
|
|
735
|
+
messageId: source.messageId === null
|
|
736
|
+
? null
|
|
737
|
+
: identifier(source.messageId, `${label}.messageId`),
|
|
738
|
+
messageProviderId: identifier(
|
|
739
|
+
source.messageProviderId,
|
|
740
|
+
`${label}.messageProviderId`,
|
|
741
|
+
),
|
|
742
|
+
participantId: source.participantId === null
|
|
743
|
+
? null
|
|
744
|
+
: identifier(source.participantId, `${label}.participantId`),
|
|
745
|
+
body: text(source.body, `${label}.body`, MAX_SHORT_TEXT_BYTES),
|
|
746
|
+
reactedAt: nullableTimestamp(source.reactedAt, `${label}.reactedAt`),
|
|
747
|
+
state: oneOf(source.state, ["active", "removed"] as const, `${label}.state`),
|
|
748
|
+
}),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function parseTombstone(source: JsonRecord, label: string): ParsedRecord {
|
|
753
|
+
const common = parseCommon(source, "tombstone", [
|
|
754
|
+
"entityKind", "entityId", "entityProviderId", "deletedAt", "scope", "providerRevision",
|
|
755
|
+
], label);
|
|
756
|
+
return {
|
|
757
|
+
kind: "tombstone",
|
|
758
|
+
id: common.id,
|
|
759
|
+
accountId: common.accountId,
|
|
760
|
+
network: common.network,
|
|
761
|
+
connectedAccountProviderId: common.provenance.connectedAccountProviderId,
|
|
762
|
+
value: Object.freeze({
|
|
763
|
+
schemaVersion: 1,
|
|
764
|
+
kind: "tombstone",
|
|
765
|
+
...common,
|
|
766
|
+
entityKind: oneOf(source.entityKind, [
|
|
767
|
+
"conversation", "message", "reaction",
|
|
768
|
+
] as const, `${label}.entityKind`),
|
|
769
|
+
entityId: source.entityId === null ? null : identifier(source.entityId, `${label}.entityId`),
|
|
770
|
+
entityProviderId: identifier(source.entityProviderId, `${label}.entityProviderId`),
|
|
771
|
+
deletedAt: timestamp(source.deletedAt, `${label}.deletedAt`),
|
|
772
|
+
scope: oneOf(source.scope, ["remote", "local", "unknown"] as const, `${label}.scope`),
|
|
773
|
+
providerRevision: nullableIdentifier(source.providerRevision, `${label}.providerRevision`),
|
|
774
|
+
}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function parseRecordWithKind(
|
|
779
|
+
source: JsonRecord,
|
|
780
|
+
kind: RecordKind,
|
|
781
|
+
label: string,
|
|
782
|
+
): ParsedRecord {
|
|
783
|
+
switch (kind) {
|
|
784
|
+
case "account": return parseAccount(source, label);
|
|
785
|
+
case "participant": return parseParticipant(source, label);
|
|
786
|
+
case "conversation": return parseConversation(source, label);
|
|
787
|
+
case "message": return parseMessage(source, label);
|
|
788
|
+
case "reaction": return parseReaction(source, label);
|
|
789
|
+
case "tombstone": return parseTombstone(source, label);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function parseRecord(value: unknown, index: number): ParsedRecord {
|
|
794
|
+
const label = `record[${String(index)}]`;
|
|
795
|
+
const candidate = foreignRecord(value, label);
|
|
796
|
+
const kind = oneOf(candidate.kind, [
|
|
797
|
+
"account",
|
|
798
|
+
"participant",
|
|
799
|
+
"conversation",
|
|
800
|
+
"message",
|
|
801
|
+
"reaction",
|
|
802
|
+
"tombstone",
|
|
803
|
+
] as const, `${label}.kind`);
|
|
804
|
+
let contractRecord: unknown;
|
|
805
|
+
try {
|
|
806
|
+
contractRecord = parseLocalMessageBundleV1Record(value, kind, label);
|
|
807
|
+
} catch {
|
|
808
|
+
// Preserve the legacy Ghostget diagnostic when the legacy parser can name
|
|
809
|
+
// the same invalid field. A value that legacy Ghostget accepted but the
|
|
810
|
+
// shared v1 authority rejects still fails at the generic contract gate.
|
|
811
|
+
parseRecordWithKind(candidate, kind, label);
|
|
812
|
+
return fail(`${label} violates the Message Like Me bundle v1 contract`);
|
|
813
|
+
}
|
|
814
|
+
return parseRecordWithKind(foreignRecord(contractRecord, label), kind, label);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Strict internal/publication-boundary parser shared by body-bearing bundle
|
|
819
|
+
* publication and content-free derivatives. Returning the normalized record
|
|
820
|
+
* prevents a derivative from trusting the producer's in-memory TypeScript
|
|
821
|
+
* type alone.
|
|
822
|
+
*/
|
|
823
|
+
export function parseBeeperMessageLikeMeRecord(
|
|
824
|
+
value: unknown,
|
|
825
|
+
index: number,
|
|
826
|
+
): BeeperMessageLikeMeRecord {
|
|
827
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= HARD_LIMITS.maxRecords) {
|
|
828
|
+
fail("record index is outside the bundle bound");
|
|
829
|
+
}
|
|
830
|
+
return parseRecord(value, index).value;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function bundleGraphFact(record: BeeperMessageLikeMeRecord): BundleGraphFact {
|
|
834
|
+
const common = {
|
|
835
|
+
id: record.id,
|
|
836
|
+
accountId: record.accountId,
|
|
837
|
+
providerId: record.provenance.providerId,
|
|
838
|
+
} as const;
|
|
839
|
+
switch (record.kind) {
|
|
840
|
+
case "account":
|
|
841
|
+
return Object.freeze({
|
|
842
|
+
kind: record.kind,
|
|
843
|
+
...common,
|
|
844
|
+
network: record.network,
|
|
845
|
+
selfParticipantId: record.selfParticipantId,
|
|
846
|
+
});
|
|
847
|
+
case "participant":
|
|
848
|
+
return Object.freeze({
|
|
849
|
+
kind: record.kind,
|
|
850
|
+
...common,
|
|
851
|
+
isSelf: record.isSelf,
|
|
852
|
+
});
|
|
853
|
+
case "conversation":
|
|
854
|
+
return Object.freeze({
|
|
855
|
+
kind: record.kind,
|
|
856
|
+
...common,
|
|
857
|
+
type: record.type,
|
|
858
|
+
participantIds: record.participantIds,
|
|
859
|
+
participantsComplete: record.participantsComplete,
|
|
860
|
+
});
|
|
861
|
+
case "message":
|
|
862
|
+
return Object.freeze({
|
|
863
|
+
kind: record.kind,
|
|
864
|
+
...common,
|
|
865
|
+
conversationId: record.conversationId,
|
|
866
|
+
senderParticipantId: record.senderParticipantId,
|
|
867
|
+
direction: record.direction,
|
|
868
|
+
replyTo: record.replyTo,
|
|
869
|
+
edit: record.edit,
|
|
870
|
+
});
|
|
871
|
+
case "reaction":
|
|
872
|
+
return Object.freeze({
|
|
873
|
+
kind: record.kind,
|
|
874
|
+
...common,
|
|
875
|
+
messageId: record.messageId,
|
|
876
|
+
messageProviderId: record.messageProviderId,
|
|
877
|
+
participantId: record.participantId,
|
|
878
|
+
});
|
|
879
|
+
case "tombstone":
|
|
880
|
+
return Object.freeze({
|
|
881
|
+
kind: record.kind,
|
|
882
|
+
...common,
|
|
883
|
+
entityKind: record.entityKind,
|
|
884
|
+
entityId: record.entityId,
|
|
885
|
+
entityProviderId: record.entityProviderId,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function graphRecords<Kind extends RecordKind>(
|
|
891
|
+
inventory: BundleGraphInventory,
|
|
892
|
+
kind: Kind,
|
|
893
|
+
): ReadonlyMap<string, Extract<BundleGraphFact, { readonly kind: Kind }>> {
|
|
894
|
+
return (inventory.get(kind) ?? new Map()) as ReadonlyMap<
|
|
895
|
+
string,
|
|
896
|
+
Extract<BundleGraphFact, { readonly kind: Kind }>
|
|
897
|
+
>;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function assertSameAccount(
|
|
901
|
+
fact: BundleGraphFact | undefined,
|
|
902
|
+
accountId: string,
|
|
903
|
+
label: string,
|
|
904
|
+
): asserts fact is BundleGraphFact {
|
|
905
|
+
if (fact === undefined || fact.accountId !== accountId) {
|
|
906
|
+
fail(`${label} does not resolve inside its account realm`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function graphProviderCoordinate(accountId: string, providerId: string): string {
|
|
911
|
+
return sha256(canonicalJson([accountId, providerId]));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function providerGraphRecords<Kind extends RecordKind>(
|
|
915
|
+
records: ReadonlyMap<
|
|
916
|
+
string,
|
|
917
|
+
Extract<BundleGraphFact, { readonly kind: Kind }>
|
|
918
|
+
>,
|
|
919
|
+
): ReadonlyMap<
|
|
920
|
+
string,
|
|
921
|
+
Extract<BundleGraphFact, { readonly kind: Kind }>
|
|
922
|
+
> {
|
|
923
|
+
const providers = new Map<
|
|
924
|
+
string,
|
|
925
|
+
Extract<BundleGraphFact, { readonly kind: Kind }>
|
|
926
|
+
>();
|
|
927
|
+
for (const fact of records.values()) {
|
|
928
|
+
providers.set(
|
|
929
|
+
graphProviderCoordinate(fact.accountId, fact.providerId),
|
|
930
|
+
fact,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
return providers;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function validateBundleGraph(inventory: BundleGraphInventory): void {
|
|
937
|
+
const accounts = graphRecords(inventory, "account");
|
|
938
|
+
const participants = graphRecords(inventory, "participant");
|
|
939
|
+
const conversations = graphRecords(inventory, "conversation");
|
|
940
|
+
const messages = graphRecords(inventory, "message");
|
|
941
|
+
const reactions = graphRecords(inventory, "reaction");
|
|
942
|
+
const messagesByProvider = providerGraphRecords(messages);
|
|
943
|
+
const conversationRosters = new Map(
|
|
944
|
+
[...conversations.values()].map((conversation) => [
|
|
945
|
+
conversation.id,
|
|
946
|
+
new Set(conversation.participantIds),
|
|
947
|
+
]),
|
|
948
|
+
);
|
|
949
|
+
const providerInventories = new Map<RecordKind, ReadonlyMap<string, BundleGraphFact>>(
|
|
950
|
+
ARTIFACTS.map(({ kind }) => {
|
|
951
|
+
const records = graphRecords(inventory, kind);
|
|
952
|
+
return [kind, providerGraphRecords(records)];
|
|
953
|
+
}),
|
|
954
|
+
);
|
|
955
|
+
const resolveMessageTarget = (
|
|
956
|
+
accountId: string,
|
|
957
|
+
localId: string | null,
|
|
958
|
+
providerId: string,
|
|
959
|
+
label: string,
|
|
960
|
+
): Extract<BundleGraphFact, { readonly kind: "message" }> | undefined => {
|
|
961
|
+
const localTarget = localId === null ? undefined : messages.get(localId);
|
|
962
|
+
if (localId !== null) {
|
|
963
|
+
assertSameAccount(localTarget, accountId, label);
|
|
964
|
+
if (localTarget.kind !== "message" || localTarget.providerId !== providerId) {
|
|
965
|
+
fail(`${label} provider coordinate does not match`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const providerTarget = messagesByProvider.get(
|
|
969
|
+
graphProviderCoordinate(accountId, providerId),
|
|
970
|
+
);
|
|
971
|
+
if (
|
|
972
|
+
localTarget !== undefined
|
|
973
|
+
&& providerTarget !== undefined
|
|
974
|
+
&& localTarget.id !== providerTarget.id
|
|
975
|
+
) fail(`${label} local and provider coordinates disagree`);
|
|
976
|
+
return localTarget ?? providerTarget;
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const stableAccountRealms = new Set<string>();
|
|
980
|
+
for (const account of accounts.values()) {
|
|
981
|
+
const self = participants.get(account.selfParticipantId);
|
|
982
|
+
assertSameAccount(self, account.accountId, "account self participant");
|
|
983
|
+
if (self.kind !== "participant" || !self.isSelf) {
|
|
984
|
+
fail("account self participant is not marked as self");
|
|
985
|
+
}
|
|
986
|
+
const stableRealm = sha256(canonicalJson([
|
|
987
|
+
"beeper",
|
|
988
|
+
account.providerId,
|
|
989
|
+
self.providerId,
|
|
990
|
+
]));
|
|
991
|
+
if (stableAccountRealms.has(stableRealm)) {
|
|
992
|
+
fail("account records repeat one stable provider realm");
|
|
993
|
+
}
|
|
994
|
+
stableAccountRealms.add(stableRealm);
|
|
995
|
+
}
|
|
996
|
+
for (const participant of participants.values()) {
|
|
997
|
+
const account = accounts.get(participant.accountId);
|
|
998
|
+
assertSameAccount(account, participant.accountId, "participant account");
|
|
999
|
+
if (participant.isSelf && account.kind === "account"
|
|
1000
|
+
&& account.selfParticipantId !== participant.id) {
|
|
1001
|
+
fail("account realm contains an unreferenced self participant");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
for (const conversation of conversations.values()) {
|
|
1005
|
+
const account = accounts.get(conversation.accountId);
|
|
1006
|
+
assertSameAccount(account, conversation.accountId, "conversation account");
|
|
1007
|
+
for (const participantId of conversation.participantIds) {
|
|
1008
|
+
assertSameAccount(
|
|
1009
|
+
participants.get(participantId),
|
|
1010
|
+
conversation.accountId,
|
|
1011
|
+
"conversation participant",
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (conversation.type === "direct" && conversation.participantsComplete === true) {
|
|
1015
|
+
const roster = conversation.participantIds.map((participantId) => {
|
|
1016
|
+
const participant = participants.get(participantId);
|
|
1017
|
+
if (participant?.kind !== "participant") {
|
|
1018
|
+
return fail("complete direct conversation participant has the wrong record kind");
|
|
1019
|
+
}
|
|
1020
|
+
return participant;
|
|
1021
|
+
});
|
|
1022
|
+
const selfCount = roster.filter((participant) => participant.isSelf).length;
|
|
1023
|
+
if (
|
|
1024
|
+
account.kind !== "account"
|
|
1025
|
+
|| conversation.participantIds.length !== 2
|
|
1026
|
+
|| selfCount !== 1
|
|
1027
|
+
|| !conversationRosters.get(conversation.id)?.has(account.selfParticipantId)
|
|
1028
|
+
) {
|
|
1029
|
+
fail("complete direct conversation must contain exactly one self participant and one peer");
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const replacementEdges = new Map<string, string>();
|
|
1034
|
+
const replacedProviderCoordinates = new Set<string>();
|
|
1035
|
+
for (const message of messages.values()) {
|
|
1036
|
+
const conversation = conversations.get(message.conversationId);
|
|
1037
|
+
assertSameAccount(conversation, message.accountId, "message conversation");
|
|
1038
|
+
if (conversation.kind !== "conversation") {
|
|
1039
|
+
fail("message conversation has the wrong record kind");
|
|
1040
|
+
}
|
|
1041
|
+
if (message.senderParticipantId !== null) {
|
|
1042
|
+
const sender = participants.get(message.senderParticipantId);
|
|
1043
|
+
assertSameAccount(sender, message.accountId, "message sender");
|
|
1044
|
+
if (sender.kind !== "participant") fail("message sender has the wrong record kind");
|
|
1045
|
+
const expectedDirection = sender.isSelf ? "outgoing" : "incoming";
|
|
1046
|
+
if (message.direction !== expectedDirection) {
|
|
1047
|
+
fail("message direction conflicts with its sender participant");
|
|
1048
|
+
}
|
|
1049
|
+
if (
|
|
1050
|
+
conversation.participantsComplete === true
|
|
1051
|
+
&& !conversationRosters.get(conversation.id)?.has(sender.id)
|
|
1052
|
+
) fail("message sender is absent from the complete conversation roster");
|
|
1053
|
+
}
|
|
1054
|
+
if (message.replyTo !== null) {
|
|
1055
|
+
const target = resolveMessageTarget(
|
|
1056
|
+
message.accountId,
|
|
1057
|
+
message.replyTo.messageId,
|
|
1058
|
+
message.replyTo.providerId,
|
|
1059
|
+
"message reply target",
|
|
1060
|
+
);
|
|
1061
|
+
if (target !== undefined) {
|
|
1062
|
+
if (target.id === message.id) fail("message must not reply to itself");
|
|
1063
|
+
if (target.conversationId !== message.conversationId) {
|
|
1064
|
+
fail("message reply target belongs to a different conversation");
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (message.edit?.kind === "replacement") {
|
|
1069
|
+
if (message.edit.replacesProviderId === message.providerId) {
|
|
1070
|
+
fail("replacement edit must not replace its own provider coordinate");
|
|
1071
|
+
}
|
|
1072
|
+
const targetCoordinate = graphProviderCoordinate(
|
|
1073
|
+
message.accountId,
|
|
1074
|
+
message.edit.replacesProviderId,
|
|
1075
|
+
);
|
|
1076
|
+
if (replacedProviderCoordinates.has(targetCoordinate)) {
|
|
1077
|
+
fail("one provider message has more than one replacement");
|
|
1078
|
+
}
|
|
1079
|
+
replacedProviderCoordinates.add(targetCoordinate);
|
|
1080
|
+
const target = resolveMessageTarget(
|
|
1081
|
+
message.accountId,
|
|
1082
|
+
message.edit.replacesMessageId,
|
|
1083
|
+
message.edit.replacesProviderId,
|
|
1084
|
+
"replacement edit target",
|
|
1085
|
+
);
|
|
1086
|
+
if (target !== undefined) {
|
|
1087
|
+
if (target.conversationId !== message.conversationId) {
|
|
1088
|
+
fail("replacement edit target belongs to a different conversation");
|
|
1089
|
+
}
|
|
1090
|
+
replacementEdges.set(message.id, target.id);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const completedReplacementNodes = new Set<string>();
|
|
1095
|
+
for (const start of replacementEdges.keys()) {
|
|
1096
|
+
if (completedReplacementNodes.has(start)) continue;
|
|
1097
|
+
const path: string[] = [];
|
|
1098
|
+
const positions = new Map<string, number>();
|
|
1099
|
+
let current: string | undefined = start;
|
|
1100
|
+
while (current !== undefined && !completedReplacementNodes.has(current)) {
|
|
1101
|
+
if (positions.has(current)) fail("replacement edit graph contains a cycle");
|
|
1102
|
+
positions.set(current, path.length);
|
|
1103
|
+
path.push(current);
|
|
1104
|
+
current = replacementEdges.get(current);
|
|
1105
|
+
}
|
|
1106
|
+
for (const id of path) completedReplacementNodes.add(id);
|
|
1107
|
+
}
|
|
1108
|
+
for (const reaction of reactions.values()) {
|
|
1109
|
+
let participant: Extract<BundleGraphFact, { readonly kind: "participant" }>
|
|
1110
|
+
| undefined;
|
|
1111
|
+
if (reaction.participantId !== null) {
|
|
1112
|
+
participant = participants.get(reaction.participantId);
|
|
1113
|
+
assertSameAccount(
|
|
1114
|
+
participant,
|
|
1115
|
+
reaction.accountId,
|
|
1116
|
+
"reaction participant",
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
const target = resolveMessageTarget(
|
|
1120
|
+
reaction.accountId,
|
|
1121
|
+
reaction.messageId,
|
|
1122
|
+
reaction.messageProviderId,
|
|
1123
|
+
"reaction message target",
|
|
1124
|
+
);
|
|
1125
|
+
if (participant !== undefined && target !== undefined) {
|
|
1126
|
+
const conversation = conversations.get(target.conversationId);
|
|
1127
|
+
assertSameAccount(
|
|
1128
|
+
conversation,
|
|
1129
|
+
reaction.accountId,
|
|
1130
|
+
"reaction target conversation",
|
|
1131
|
+
);
|
|
1132
|
+
if (
|
|
1133
|
+
conversation.kind === "conversation"
|
|
1134
|
+
&& conversation.participantsComplete === true
|
|
1135
|
+
&& !conversationRosters.get(conversation.id)?.has(participant.id)
|
|
1136
|
+
) fail("reaction participant is absent from the complete conversation roster");
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
for (const tombstone of graphRecords(inventory, "tombstone").values()) {
|
|
1140
|
+
const providerTargets = providerInventories.get(tombstone.entityKind);
|
|
1141
|
+
if (providerTargets === undefined) fail("internal provider inventory is incomplete");
|
|
1142
|
+
const providerTarget = providerTargets.get(graphProviderCoordinate(
|
|
1143
|
+
tombstone.accountId,
|
|
1144
|
+
tombstone.entityProviderId,
|
|
1145
|
+
));
|
|
1146
|
+
if (tombstone.entityId !== null) {
|
|
1147
|
+
const target = graphRecords(inventory, tombstone.entityKind).get(
|
|
1148
|
+
tombstone.entityId,
|
|
1149
|
+
);
|
|
1150
|
+
assertSameAccount(target, tombstone.accountId, "tombstone entity target");
|
|
1151
|
+
if (target.providerId !== tombstone.entityProviderId) {
|
|
1152
|
+
fail("tombstone entity provider coordinate does not match");
|
|
1153
|
+
}
|
|
1154
|
+
if (providerTarget !== undefined && target.id !== providerTarget.id) {
|
|
1155
|
+
fail("tombstone local and provider coordinates disagree");
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function parseDescriptor(value: unknown): ParsedDescriptor {
|
|
1162
|
+
const descriptor = foreignRecord(value, "source descriptor");
|
|
1163
|
+
exactKeys(descriptor, ["source", "provider"], "source descriptor");
|
|
1164
|
+
const source = foreignRecord(descriptor.source, "source descriptor.source");
|
|
1165
|
+
exactKeys(source, ["id", "version"], "source descriptor.source");
|
|
1166
|
+
if (source.id !== LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID) {
|
|
1167
|
+
fail(`source descriptor.source.id must equal ${LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID}`);
|
|
1168
|
+
}
|
|
1169
|
+
const provider = foreignRecord(descriptor.provider, "source descriptor.provider");
|
|
1170
|
+
exactKeys(provider, ["id", "version"], "source descriptor.provider");
|
|
1171
|
+
if (provider.id !== LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID) {
|
|
1172
|
+
fail(`source descriptor.provider.id must equal ${LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID}`);
|
|
1173
|
+
}
|
|
1174
|
+
return Object.freeze({
|
|
1175
|
+
source: Object.freeze({
|
|
1176
|
+
id: LOCAL_MESSAGE_BUNDLE_V1_SOURCE_ID,
|
|
1177
|
+
version: version(source.version, "source descriptor.source.version"),
|
|
1178
|
+
}),
|
|
1179
|
+
provider: Object.freeze({
|
|
1180
|
+
id: LOCAL_MESSAGE_BUNDLE_V1_PROVIDER_ID,
|
|
1181
|
+
version: version(provider.version, "source descriptor.provider.version"),
|
|
1182
|
+
}),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function parseBeeperMessageLikeMeDescriptor(
|
|
1187
|
+
value: unknown,
|
|
1188
|
+
): ParsedDescriptor {
|
|
1189
|
+
return parseDescriptor(value);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function parseCompletion(value: unknown): ParsedCompletion {
|
|
1193
|
+
const source = foreignRecord(value, "source completion");
|
|
1194
|
+
exactKeys(source, ["completeness", "warnings"], "source completion");
|
|
1195
|
+
const completeness = foreignRecord(source.completeness, "source completion.completeness");
|
|
1196
|
+
exactKeys(
|
|
1197
|
+
completeness,
|
|
1198
|
+
["kind", "reason", "observedFrom", "observedThrough"],
|
|
1199
|
+
"source completion.completeness",
|
|
1200
|
+
);
|
|
1201
|
+
const observedFrom = nullableTimestamp(
|
|
1202
|
+
completeness.observedFrom,
|
|
1203
|
+
"source completion.completeness.observedFrom",
|
|
1204
|
+
);
|
|
1205
|
+
const observedThrough = nullableTimestamp(
|
|
1206
|
+
completeness.observedThrough,
|
|
1207
|
+
"source completion.completeness.observedThrough",
|
|
1208
|
+
);
|
|
1209
|
+
if (observedFrom !== null && observedThrough !== null && observedFrom > observedThrough) {
|
|
1210
|
+
fail("source completion observedFrom must not be after observedThrough");
|
|
1211
|
+
}
|
|
1212
|
+
const warnings = array(source.warnings, "source completion.warnings", MAX_WARNING_CODES)
|
|
1213
|
+
.map((warning, index) => token(warning, `source completion.warnings[${String(index)}]`));
|
|
1214
|
+
if (new Set(warnings).size !== warnings.length) fail("source completion.warnings must not contain duplicates");
|
|
1215
|
+
return Object.freeze({
|
|
1216
|
+
completeness: Object.freeze({
|
|
1217
|
+
kind: oneOf(completeness.kind, ["bounded-local", "truncated", "unknown"] as const, "source completion.completeness.kind"),
|
|
1218
|
+
reason: completeness.reason === null
|
|
1219
|
+
? null
|
|
1220
|
+
: token(completeness.reason, "source completion.completeness.reason"),
|
|
1221
|
+
observedFrom,
|
|
1222
|
+
observedThrough,
|
|
1223
|
+
}),
|
|
1224
|
+
warnings: Object.freeze(warnings),
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
export function parseBeeperMessageLikeMeCompletion(
|
|
1229
|
+
value: unknown,
|
|
1230
|
+
): ParsedCompletion {
|
|
1231
|
+
return parseCompletion(value);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function parseLimits(value: unknown): BeeperMessageLikeMeExportLimits {
|
|
1235
|
+
if (value === undefined) return HARD_LIMITS;
|
|
1236
|
+
const source = foreignRecord(value, "export limits");
|
|
1237
|
+
const permitted = ["maxRecords", "maxRecordBytes", "maxTotalBytes"];
|
|
1238
|
+
if (Object.keys(source).some((key) => !permitted.includes(key))) {
|
|
1239
|
+
fail(`export limits may contain only: ${permitted.join(", ")}`);
|
|
1240
|
+
}
|
|
1241
|
+
const parse = (key: keyof BeeperMessageLikeMeExportLimits): number => {
|
|
1242
|
+
const candidate = source[key] ?? HARD_LIMITS[key];
|
|
1243
|
+
const parsed = integer(candidate, `export limits.${key}`, HARD_LIMITS[key]);
|
|
1244
|
+
if (parsed === 0) fail(`export limits.${key} must be greater than zero`);
|
|
1245
|
+
return parsed;
|
|
1246
|
+
};
|
|
1247
|
+
return Object.freeze({
|
|
1248
|
+
maxRecords: parse("maxRecords"),
|
|
1249
|
+
maxRecordBytes: parse("maxRecordBytes"),
|
|
1250
|
+
maxTotalBytes: parse("maxTotalBytes"),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function now(clock: (() => Date) | undefined, label: string): string {
|
|
1255
|
+
const value = (clock ?? (() => new Date()))();
|
|
1256
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
1257
|
+
return fail(`${label} clock value must be a valid Date`);
|
|
1258
|
+
}
|
|
1259
|
+
return value.toISOString();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async function assertAbsent(path: string, label: string): Promise<void> {
|
|
1263
|
+
try {
|
|
1264
|
+
await lstat(path);
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
if (isErrno(error, "ENOENT")) return;
|
|
1267
|
+
throw error;
|
|
1268
|
+
}
|
|
1269
|
+
fail(`${label} already exists`);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function nativeExclusiveRename(): NativeExclusiveRename {
|
|
1273
|
+
if (cachedNativeExclusiveRename !== undefined) {
|
|
1274
|
+
return cachedNativeExclusiveRename;
|
|
1275
|
+
}
|
|
1276
|
+
if (process.platform === "darwin") {
|
|
1277
|
+
const library = dlopen("/usr/lib/libSystem.B.dylib", {
|
|
1278
|
+
renamex_np: {
|
|
1279
|
+
args: ["cstring", "cstring", "u32"],
|
|
1280
|
+
returns: "int",
|
|
1281
|
+
},
|
|
1282
|
+
} as const);
|
|
1283
|
+
cachedNativeExclusiveRename = (source, destination) =>
|
|
1284
|
+
library.symbols.renamex_np(
|
|
1285
|
+
ptr(source),
|
|
1286
|
+
ptr(destination),
|
|
1287
|
+
DARWIN_RENAME_EXCL,
|
|
1288
|
+
);
|
|
1289
|
+
return cachedNativeExclusiveRename;
|
|
1290
|
+
}
|
|
1291
|
+
if (process.platform === "linux") {
|
|
1292
|
+
const library = dlopen("libc.so.6", {
|
|
1293
|
+
renameat2: {
|
|
1294
|
+
args: ["int", "cstring", "int", "cstring", "u32"],
|
|
1295
|
+
returns: "int",
|
|
1296
|
+
},
|
|
1297
|
+
} as const);
|
|
1298
|
+
cachedNativeExclusiveRename = (source, destination) =>
|
|
1299
|
+
library.symbols.renameat2(
|
|
1300
|
+
AT_FDCWD,
|
|
1301
|
+
ptr(source),
|
|
1302
|
+
AT_FDCWD,
|
|
1303
|
+
ptr(destination),
|
|
1304
|
+
LINUX_RENAME_NOREPLACE,
|
|
1305
|
+
);
|
|
1306
|
+
return cachedNativeExclusiveRename;
|
|
1307
|
+
}
|
|
1308
|
+
return fail("atomic no-clobber publication is unsupported on this platform");
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function renameDirectoryExclusive(
|
|
1312
|
+
source: string,
|
|
1313
|
+
destination: string,
|
|
1314
|
+
): Promise<void> {
|
|
1315
|
+
const encodePath = (path: string): Buffer => {
|
|
1316
|
+
if (path.includes("\0")) return fail("atomic publication path was invalid");
|
|
1317
|
+
return Buffer.from(`${path}\0`, "utf8");
|
|
1318
|
+
};
|
|
1319
|
+
const result = nativeExclusiveRename()(
|
|
1320
|
+
encodePath(source),
|
|
1321
|
+
encodePath(destination),
|
|
1322
|
+
);
|
|
1323
|
+
if (result === 0) return;
|
|
1324
|
+
let destinationExists = false;
|
|
1325
|
+
try {
|
|
1326
|
+
await lstat(destination);
|
|
1327
|
+
destinationExists = true;
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
1330
|
+
}
|
|
1331
|
+
if (destinationExists) {
|
|
1332
|
+
return fail("outputRoot appeared before atomic publication");
|
|
1333
|
+
}
|
|
1334
|
+
return fail("atomic no-clobber publication failed");
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function validateOutputRoot(outputRoot: unknown): Promise<{
|
|
1338
|
+
readonly outputRoot: string;
|
|
1339
|
+
readonly parent: string;
|
|
1340
|
+
readonly parentDevice: number;
|
|
1341
|
+
readonly parentInode: number;
|
|
1342
|
+
}> {
|
|
1343
|
+
if (typeof outputRoot !== "string" || !isAbsolute(outputRoot) || resolve(outputRoot) !== outputRoot) {
|
|
1344
|
+
return fail("outputRoot must be a normalized absolute path");
|
|
1345
|
+
}
|
|
1346
|
+
if (outputRoot === sep || utf8Length(outputRoot) > 4_096 || basename(outputRoot).includes("\0")) {
|
|
1347
|
+
return fail("outputRoot is unsafe");
|
|
1348
|
+
}
|
|
1349
|
+
const parent = dirname(outputRoot);
|
|
1350
|
+
const parentMetadata = await lstat(parent);
|
|
1351
|
+
if (!parentMetadata.isDirectory() || parentMetadata.isSymbolicLink()) {
|
|
1352
|
+
return fail("outputRoot parent must be a real directory");
|
|
1353
|
+
}
|
|
1354
|
+
if (await realpath(parent) !== parent) {
|
|
1355
|
+
return fail("outputRoot parent path must not traverse a symbolic link");
|
|
1356
|
+
}
|
|
1357
|
+
const uid = process.getuid?.();
|
|
1358
|
+
if (uid === undefined) fail("private exports require a POSIX user identity");
|
|
1359
|
+
if (parentMetadata.uid !== uid) fail("outputRoot parent must be owned by the current user");
|
|
1360
|
+
if ((parentMetadata.mode & 0o022) !== 0) {
|
|
1361
|
+
fail("outputRoot parent must not be writable by the group or other users");
|
|
1362
|
+
}
|
|
1363
|
+
await assertAbsent(outputRoot, "outputRoot");
|
|
1364
|
+
return {
|
|
1365
|
+
outputRoot,
|
|
1366
|
+
parent,
|
|
1367
|
+
parentDevice: parentMetadata.dev,
|
|
1368
|
+
parentInode: parentMetadata.ino,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function assertParentUnchanged(snapshot: Awaited<ReturnType<typeof validateOutputRoot>>): Promise<void> {
|
|
1373
|
+
const current = await lstat(snapshot.parent);
|
|
1374
|
+
const uid = process.getuid?.();
|
|
1375
|
+
if (
|
|
1376
|
+
!current.isDirectory()
|
|
1377
|
+
|| current.isSymbolicLink()
|
|
1378
|
+
|| uid === undefined
|
|
1379
|
+
|| current.uid !== uid
|
|
1380
|
+
|| (current.mode & 0o022) !== 0
|
|
1381
|
+
|| current.dev !== snapshot.parentDevice
|
|
1382
|
+
|| current.ino !== snapshot.parentInode
|
|
1383
|
+
|| await realpath(snapshot.parent) !== snapshot.parent
|
|
1384
|
+
) {
|
|
1385
|
+
fail("outputRoot parent changed during export setup");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function assertPrivateDirectory(
|
|
1390
|
+
path: string,
|
|
1391
|
+
expected?: PrivateDirectoryIdentity,
|
|
1392
|
+
): Promise<PrivateDirectoryIdentity> {
|
|
1393
|
+
const uid = process.getuid?.();
|
|
1394
|
+
const metadata = await lstat(path);
|
|
1395
|
+
if (
|
|
1396
|
+
!metadata.isDirectory()
|
|
1397
|
+
|| metadata.isSymbolicLink()
|
|
1398
|
+
|| uid === undefined
|
|
1399
|
+
|| metadata.uid !== uid
|
|
1400
|
+
|| (metadata.mode & 0o777) !== PRIVATE_DIRECTORY_MODE
|
|
1401
|
+
|| (expected !== undefined
|
|
1402
|
+
&& (metadata.dev !== expected.device || metadata.ino !== expected.inode))
|
|
1403
|
+
|| await realpath(path) !== path
|
|
1404
|
+
) {
|
|
1405
|
+
fail("private staging directory changed");
|
|
1406
|
+
}
|
|
1407
|
+
return Object.freeze({ device: metadata.dev, inode: metadata.ino });
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function assertPrivateFile(path: string, expectedBytes: number): Promise<void> {
|
|
1411
|
+
const uid = process.getuid?.();
|
|
1412
|
+
const metadata = await lstat(path);
|
|
1413
|
+
if (
|
|
1414
|
+
!metadata.isFile()
|
|
1415
|
+
|| metadata.isSymbolicLink()
|
|
1416
|
+
|| uid === undefined
|
|
1417
|
+
|| metadata.uid !== uid
|
|
1418
|
+
|| metadata.nlink !== 1
|
|
1419
|
+
|| (metadata.mode & 0o777) !== PRIVATE_FILE_MODE
|
|
1420
|
+
|| metadata.size !== expectedBytes
|
|
1421
|
+
) {
|
|
1422
|
+
fail("private staged artifact changed");
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
async function createPrivateStagingDirectory(
|
|
1427
|
+
output: Awaited<ReturnType<typeof validateOutputRoot>>,
|
|
1428
|
+
): Promise<{ readonly path: string; readonly identity: PrivateDirectoryIdentity }> {
|
|
1429
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1430
|
+
await assertParentUnchanged(output);
|
|
1431
|
+
const candidate = await mkdtemp(resolve(output.parent, ".message-like-me-staging-"));
|
|
1432
|
+
let identity: PrivateDirectoryIdentity | undefined;
|
|
1433
|
+
try {
|
|
1434
|
+
await chmod(candidate, PRIVATE_DIRECTORY_MODE);
|
|
1435
|
+
identity = await assertPrivateDirectory(candidate);
|
|
1436
|
+
const metadata = await lstat(candidate);
|
|
1437
|
+
if (dirname(candidate) !== output.parent || metadata.dev !== output.parentDevice) {
|
|
1438
|
+
fail("private staging directory must share the output parent and filesystem");
|
|
1439
|
+
}
|
|
1440
|
+
if (candidate === output.outputRoot) {
|
|
1441
|
+
await removeOwnedPrivateDirectory(candidate, identity);
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
return Object.freeze({ path: candidate, identity });
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
if (identity === undefined) {
|
|
1447
|
+
try {
|
|
1448
|
+
await rmdir(candidate);
|
|
1449
|
+
} catch {
|
|
1450
|
+
// Never recursively remove a directory whose identity was not captured.
|
|
1451
|
+
}
|
|
1452
|
+
} else {
|
|
1453
|
+
await removeOwnedPrivateDirectory(candidate, identity);
|
|
1454
|
+
}
|
|
1455
|
+
throw error;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return fail("could not allocate a private staging directory distinct from outputRoot");
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async function removeOwnedPrivateDirectory(
|
|
1462
|
+
path: string,
|
|
1463
|
+
identity: PrivateDirectoryIdentity,
|
|
1464
|
+
): Promise<void> {
|
|
1465
|
+
try {
|
|
1466
|
+
removePrivateDirectoryTree(path, Object.freeze({
|
|
1467
|
+
device: String(identity.device),
|
|
1468
|
+
inode: String(identity.inode),
|
|
1469
|
+
}));
|
|
1470
|
+
} catch {
|
|
1471
|
+
return fail("private directory could not be removed from quarantine safely");
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
async function syncDirectory(
|
|
1476
|
+
path: string,
|
|
1477
|
+
expected: PrivateDirectoryIdentity,
|
|
1478
|
+
): Promise<void> {
|
|
1479
|
+
const handle = await open(
|
|
1480
|
+
path,
|
|
1481
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
1482
|
+
);
|
|
1483
|
+
try {
|
|
1484
|
+
const metadata = await handle.stat();
|
|
1485
|
+
if (
|
|
1486
|
+
!metadata.isDirectory()
|
|
1487
|
+
|| metadata.dev !== expected.device
|
|
1488
|
+
|| metadata.ino !== expected.inode
|
|
1489
|
+
) {
|
|
1490
|
+
fail("directory changed before synchronization");
|
|
1491
|
+
}
|
|
1492
|
+
await handle.sync();
|
|
1493
|
+
} finally {
|
|
1494
|
+
await handle.close();
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function privateFileSha256(path: string, expectedBytes: number): Promise<string> {
|
|
1499
|
+
const handle = await open(
|
|
1500
|
+
path,
|
|
1501
|
+
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
1502
|
+
);
|
|
1503
|
+
try {
|
|
1504
|
+
const uid = process.getuid?.();
|
|
1505
|
+
const before = await handle.stat();
|
|
1506
|
+
const entryBefore = await lstat(path);
|
|
1507
|
+
if (
|
|
1508
|
+
!before.isFile()
|
|
1509
|
+
|| uid === undefined
|
|
1510
|
+
|| before.uid !== uid
|
|
1511
|
+
|| before.nlink !== 1
|
|
1512
|
+
|| (before.mode & 0o777) !== PRIVATE_FILE_MODE
|
|
1513
|
+
|| before.size !== expectedBytes
|
|
1514
|
+
|| !entryBefore.isFile()
|
|
1515
|
+
|| entryBefore.isSymbolicLink()
|
|
1516
|
+
|| entryBefore.dev !== before.dev
|
|
1517
|
+
|| entryBefore.ino !== before.ino
|
|
1518
|
+
|| entryBefore.nlink !== 1
|
|
1519
|
+
) {
|
|
1520
|
+
fail("private staged artifact changed before bundle validation");
|
|
1521
|
+
}
|
|
1522
|
+
const hash = createHash("sha256");
|
|
1523
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
1524
|
+
let offset = 0;
|
|
1525
|
+
while (offset < expectedBytes) {
|
|
1526
|
+
const length = Math.min(buffer.byteLength, expectedBytes - offset);
|
|
1527
|
+
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
|
1528
|
+
if (bytesRead === 0) fail("private staged artifact ended during bundle validation");
|
|
1529
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
1530
|
+
offset += bytesRead;
|
|
1531
|
+
}
|
|
1532
|
+
const after = await handle.stat();
|
|
1533
|
+
const entryAfter = await lstat(path);
|
|
1534
|
+
if (
|
|
1535
|
+
after.dev !== before.dev
|
|
1536
|
+
|| after.ino !== before.ino
|
|
1537
|
+
|| !after.isFile()
|
|
1538
|
+
|| after.uid !== before.uid
|
|
1539
|
+
|| after.nlink !== 1
|
|
1540
|
+
|| after.size !== before.size
|
|
1541
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
1542
|
+
|| after.ctimeMs !== before.ctimeMs
|
|
1543
|
+
|| (after.mode & 0o777) !== PRIVATE_FILE_MODE
|
|
1544
|
+
|| !entryAfter.isFile()
|
|
1545
|
+
|| entryAfter.isSymbolicLink()
|
|
1546
|
+
|| entryAfter.dev !== after.dev
|
|
1547
|
+
|| entryAfter.ino !== after.ino
|
|
1548
|
+
|| entryAfter.nlink !== 1
|
|
1549
|
+
) {
|
|
1550
|
+
fail("private staged artifact changed during bundle validation");
|
|
1551
|
+
}
|
|
1552
|
+
return hash.digest("hex");
|
|
1553
|
+
} finally {
|
|
1554
|
+
await handle.close();
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
async function createWriter(staging: string, kind: RecordKind, fileName: string): Promise<ArtifactWriter> {
|
|
1559
|
+
const partPath = resolve(staging, `${fileName}.part`);
|
|
1560
|
+
if (!partPath.startsWith(`${staging}${sep}`)) fail("internal artifact path escaped staging");
|
|
1561
|
+
const handle = await open(
|
|
1562
|
+
partPath,
|
|
1563
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
|
1564
|
+
PRIVATE_FILE_MODE,
|
|
1565
|
+
);
|
|
1566
|
+
try {
|
|
1567
|
+
await handle.chmod(PRIVATE_FILE_MODE);
|
|
1568
|
+
const uid = process.getuid?.();
|
|
1569
|
+
const metadata = await handle.stat();
|
|
1570
|
+
if (
|
|
1571
|
+
!metadata.isFile()
|
|
1572
|
+
|| uid === undefined
|
|
1573
|
+
|| metadata.uid !== uid
|
|
1574
|
+
|| metadata.nlink !== 1
|
|
1575
|
+
|| (metadata.mode & 0o777) !== PRIVATE_FILE_MODE
|
|
1576
|
+
) {
|
|
1577
|
+
fail(`could not create private staging file ${fileName}`);
|
|
1578
|
+
}
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
await handle.close();
|
|
1581
|
+
throw error;
|
|
1582
|
+
}
|
|
1583
|
+
return {
|
|
1584
|
+
kind,
|
|
1585
|
+
fileName,
|
|
1586
|
+
partPath,
|
|
1587
|
+
handle,
|
|
1588
|
+
hash: createHash("sha256"),
|
|
1589
|
+
records: 0,
|
|
1590
|
+
bytes: 0,
|
|
1591
|
+
closed: false,
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
async function writeAll(handle: FileHandle, bytes: Uint8Array): Promise<void> {
|
|
1596
|
+
let offset = 0;
|
|
1597
|
+
while (offset < bytes.byteLength) {
|
|
1598
|
+
const result = await handle.write(bytes, offset, bytes.byteLength - offset, null);
|
|
1599
|
+
if (result.bytesWritten === 0) fail("staging file stopped accepting bytes");
|
|
1600
|
+
offset += result.bytesWritten;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
async function writeRecord(
|
|
1605
|
+
writer: ArtifactWriter,
|
|
1606
|
+
value: Readonly<Record<string, unknown>>,
|
|
1607
|
+
limits: BeeperMessageLikeMeExportLimits,
|
|
1608
|
+
totalBytes: number,
|
|
1609
|
+
): Promise<number> {
|
|
1610
|
+
const bytes = Buffer.from(`${canonicalJson(value)}\n`, "utf8");
|
|
1611
|
+
if (bytes.byteLength > limits.maxRecordBytes) {
|
|
1612
|
+
fail(`one ${writer.kind} record exceeds the configured byte bound`);
|
|
1613
|
+
}
|
|
1614
|
+
if (totalBytes + bytes.byteLength > limits.maxTotalBytes) {
|
|
1615
|
+
fail("bundle records exceed the configured total byte bound");
|
|
1616
|
+
}
|
|
1617
|
+
await writeAll(writer.handle, bytes);
|
|
1618
|
+
writer.hash.update(bytes);
|
|
1619
|
+
writer.records += 1;
|
|
1620
|
+
writer.bytes += bytes.byteLength;
|
|
1621
|
+
return totalBytes + bytes.byteLength;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
async function closeWriter(writer: ArtifactWriter): Promise<void> {
|
|
1625
|
+
if (writer.closed) return;
|
|
1626
|
+
writer.closed = true;
|
|
1627
|
+
await writer.handle.close();
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
async function finalizeWriter(writer: ArtifactWriter, staging: string): Promise<BeeperMessageLikeMeArtifact> {
|
|
1631
|
+
await writer.handle.sync();
|
|
1632
|
+
const uid = process.getuid?.();
|
|
1633
|
+
const opened = await writer.handle.stat();
|
|
1634
|
+
if (
|
|
1635
|
+
!opened.isFile()
|
|
1636
|
+
|| uid === undefined
|
|
1637
|
+
|| opened.uid !== uid
|
|
1638
|
+
|| opened.nlink !== 1
|
|
1639
|
+
|| opened.size !== writer.bytes
|
|
1640
|
+
|| (opened.mode & 0o777) !== PRIVATE_FILE_MODE
|
|
1641
|
+
) {
|
|
1642
|
+
fail(`${writer.fileName} changed before finalization`);
|
|
1643
|
+
}
|
|
1644
|
+
await closeWriter(writer);
|
|
1645
|
+
await assertPrivateFile(writer.partPath, writer.bytes);
|
|
1646
|
+
const finalPath = resolve(staging, writer.fileName);
|
|
1647
|
+
await assertAbsent(finalPath, writer.fileName);
|
|
1648
|
+
await rename(writer.partPath, finalPath);
|
|
1649
|
+
await assertPrivateFile(finalPath, writer.bytes);
|
|
1650
|
+
return Object.freeze({
|
|
1651
|
+
path: writer.fileName,
|
|
1652
|
+
mediaType: "application/x-ndjson",
|
|
1653
|
+
recordKind: writer.kind,
|
|
1654
|
+
records: writer.records,
|
|
1655
|
+
bytes: writer.bytes,
|
|
1656
|
+
sha256: writer.hash.digest("hex"),
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async function writeManifest(
|
|
1661
|
+
staging: string,
|
|
1662
|
+
manifest: BeeperMessageLikeMeManifest,
|
|
1663
|
+
): Promise<StagedManifest> {
|
|
1664
|
+
const partPath = resolve(staging, "manifest.json.part");
|
|
1665
|
+
const finalPath = resolve(staging, "manifest.json");
|
|
1666
|
+
const bytes = Buffer.from(`${canonicalJson(manifest)}\n`, "utf8");
|
|
1667
|
+
const handle = await open(
|
|
1668
|
+
partPath,
|
|
1669
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
|
1670
|
+
PRIVATE_FILE_MODE,
|
|
1671
|
+
);
|
|
1672
|
+
try {
|
|
1673
|
+
await handle.chmod(PRIVATE_FILE_MODE);
|
|
1674
|
+
await writeAll(handle, bytes);
|
|
1675
|
+
await handle.sync();
|
|
1676
|
+
const uid = process.getuid?.();
|
|
1677
|
+
const metadata = await handle.stat();
|
|
1678
|
+
if (
|
|
1679
|
+
!metadata.isFile()
|
|
1680
|
+
|| uid === undefined
|
|
1681
|
+
|| metadata.uid !== uid
|
|
1682
|
+
|| metadata.nlink !== 1
|
|
1683
|
+
|| metadata.size !== bytes.byteLength
|
|
1684
|
+
|| (metadata.mode & 0o777) !== PRIVATE_FILE_MODE
|
|
1685
|
+
) {
|
|
1686
|
+
fail("manifest changed before finalization");
|
|
1687
|
+
}
|
|
1688
|
+
} finally {
|
|
1689
|
+
await handle.close();
|
|
1690
|
+
}
|
|
1691
|
+
await assertPrivateFile(partPath, bytes.byteLength);
|
|
1692
|
+
await assertAbsent(finalPath, "manifest.json");
|
|
1693
|
+
await rename(partPath, finalPath);
|
|
1694
|
+
await assertPrivateFile(finalPath, bytes.byteLength);
|
|
1695
|
+
return Object.freeze({
|
|
1696
|
+
bytes: bytes.byteLength,
|
|
1697
|
+
sha256: sha256(bytes.toString("utf8")),
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
async function validateCompleteBundle(
|
|
1702
|
+
root: string,
|
|
1703
|
+
identity: PrivateDirectoryIdentity,
|
|
1704
|
+
artifacts: readonly BeeperMessageLikeMeArtifact[],
|
|
1705
|
+
manifest: StagedManifest,
|
|
1706
|
+
): Promise<void> {
|
|
1707
|
+
await assertPrivateDirectory(root, identity);
|
|
1708
|
+
const expectedNames = [
|
|
1709
|
+
...artifacts.map((artifact) => artifact.path),
|
|
1710
|
+
"manifest.json",
|
|
1711
|
+
].sort();
|
|
1712
|
+
const observedNames = (await readdir(root)).sort();
|
|
1713
|
+
if (
|
|
1714
|
+
observedNames.length !== expectedNames.length
|
|
1715
|
+
|| observedNames.some((name, index) => name !== expectedNames[index])
|
|
1716
|
+
) {
|
|
1717
|
+
fail("private staging directory does not contain the exact complete bundle");
|
|
1718
|
+
}
|
|
1719
|
+
for (const artifact of artifacts) {
|
|
1720
|
+
const path = resolve(root, artifact.path);
|
|
1721
|
+
await assertPrivateFile(path, artifact.bytes);
|
|
1722
|
+
if (await privateFileSha256(path, artifact.bytes) !== artifact.sha256) {
|
|
1723
|
+
fail(`${artifact.path} changed before publication`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
const manifestPath = resolve(root, "manifest.json");
|
|
1727
|
+
await assertPrivateFile(manifestPath, manifest.bytes);
|
|
1728
|
+
if (await privateFileSha256(manifestPath, manifest.bytes) !== manifest.sha256) {
|
|
1729
|
+
fail("manifest.json changed before publication");
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function assertSource(source: BeeperMessageLikeMeExportSource): void {
|
|
1734
|
+
if (
|
|
1735
|
+
typeof source !== "object"
|
|
1736
|
+
|| source === null
|
|
1737
|
+
|| typeof source.completion !== "function"
|
|
1738
|
+
|| typeof source.records !== "object"
|
|
1739
|
+
|| source.records === null
|
|
1740
|
+
|| typeof source.records[Symbol.asyncIterator] !== "function"
|
|
1741
|
+
|| (source.dispose !== undefined && typeof source.dispose !== "function")
|
|
1742
|
+
) {
|
|
1743
|
+
fail("source must expose an async record stream, completion function, and optional dispose function");
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
1748
|
+
if (signal?.aborted === true) fail("export was aborted");
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
type BundlePhaseHeartbeat = Readonly<{
|
|
1752
|
+
stop: () => void;
|
|
1753
|
+
assertHealthy: () => void;
|
|
1754
|
+
}>;
|
|
1755
|
+
|
|
1756
|
+
function startBundlePhaseHeartbeat(
|
|
1757
|
+
request: BeeperMessageLikeMeExportRequest,
|
|
1758
|
+
phase: "bundle-validating" | "bundle-publishing",
|
|
1759
|
+
records: number,
|
|
1760
|
+
bytes: number,
|
|
1761
|
+
): BundlePhaseHeartbeat {
|
|
1762
|
+
const startedAt = Date.now();
|
|
1763
|
+
let failed = false;
|
|
1764
|
+
const report = (elapsedSeconds: number): void => {
|
|
1765
|
+
request.onProgress?.(Object.freeze({
|
|
1766
|
+
phase,
|
|
1767
|
+
elapsedSeconds,
|
|
1768
|
+
records,
|
|
1769
|
+
bytes,
|
|
1770
|
+
}));
|
|
1771
|
+
};
|
|
1772
|
+
report(0);
|
|
1773
|
+
const heartbeat = request.onProgress === undefined
|
|
1774
|
+
? null
|
|
1775
|
+
: setInterval(() => {
|
|
1776
|
+
try {
|
|
1777
|
+
report(Math.max(1, Math.floor((Date.now() - startedAt) / 1_000)));
|
|
1778
|
+
} catch {
|
|
1779
|
+
failed = true;
|
|
1780
|
+
}
|
|
1781
|
+
}, BUNDLE_HEARTBEAT_INTERVAL_MS);
|
|
1782
|
+
return Object.freeze({
|
|
1783
|
+
stop: () => {
|
|
1784
|
+
if (heartbeat !== null) clearInterval(heartbeat);
|
|
1785
|
+
},
|
|
1786
|
+
assertHealthy: () => {
|
|
1787
|
+
if (failed) fail("export progress reporting failed");
|
|
1788
|
+
},
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function startPrivateCleanupHeartbeat(
|
|
1793
|
+
request: BeeperMessageLikeMeExportRequest,
|
|
1794
|
+
): BundlePhaseHeartbeat {
|
|
1795
|
+
const startedAt = Date.now();
|
|
1796
|
+
let failed = false;
|
|
1797
|
+
const report = (elapsedSeconds: number): void => {
|
|
1798
|
+
request.onProgress?.(Object.freeze({
|
|
1799
|
+
phase: "private-cleanup",
|
|
1800
|
+
elapsedSeconds,
|
|
1801
|
+
}));
|
|
1802
|
+
};
|
|
1803
|
+
try {
|
|
1804
|
+
report(0);
|
|
1805
|
+
} catch {
|
|
1806
|
+
failed = true;
|
|
1807
|
+
}
|
|
1808
|
+
const heartbeat = request.onProgress === undefined
|
|
1809
|
+
? null
|
|
1810
|
+
: setInterval(() => {
|
|
1811
|
+
try {
|
|
1812
|
+
report(Math.max(1, Math.floor((Date.now() - startedAt) / 1_000)));
|
|
1813
|
+
} catch {
|
|
1814
|
+
failed = true;
|
|
1815
|
+
}
|
|
1816
|
+
}, BUNDLE_HEARTBEAT_INTERVAL_MS);
|
|
1817
|
+
return Object.freeze({
|
|
1818
|
+
stop: () => {
|
|
1819
|
+
if (heartbeat !== null) clearInterval(heartbeat);
|
|
1820
|
+
},
|
|
1821
|
+
assertHealthy: () => {
|
|
1822
|
+
if (failed) fail("export progress reporting failed");
|
|
1823
|
+
},
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Writes a private, local Message Like Me interchange bundle. The source owns
|
|
1829
|
+
* provider access; this function accepts only bounded foreign records and
|
|
1830
|
+
* never invokes Beeper, follows media references, or receives credentials.
|
|
1831
|
+
*
|
|
1832
|
+
* The complete bundle is built and validated in a mode-0700 sibling directory
|
|
1833
|
+
* on the destination filesystem. `outputRoot` remains absent until one final
|
|
1834
|
+
* atomic rename publishes all seven files together. Failure and cancellation
|
|
1835
|
+
* remove the owned staging directory without publishing a partial bundle.
|
|
1836
|
+
*/
|
|
1837
|
+
async function publishBeeperMessageLikeMeBundle(
|
|
1838
|
+
request: BeeperMessageLikeMeExportRequest,
|
|
1839
|
+
): Promise<PublishedBundle> {
|
|
1840
|
+
let published = false;
|
|
1841
|
+
const descriptor = parseDescriptor(request.source.descriptor);
|
|
1842
|
+
const limits = parseLimits(request.limits);
|
|
1843
|
+
throwIfAborted(request.signal);
|
|
1844
|
+
const startedAt = now(request.clock, "startedAt");
|
|
1845
|
+
const output = await validateOutputRoot(request.outputRoot);
|
|
1846
|
+
const stagedDirectory = await createPrivateStagingDirectory(output);
|
|
1847
|
+
const staging = stagedDirectory.path;
|
|
1848
|
+
let directoryLease: BeeperMessageLikeMeDirectoryLease | undefined;
|
|
1849
|
+
|
|
1850
|
+
if (request.recoveryEnvironment !== undefined) {
|
|
1851
|
+
try {
|
|
1852
|
+
const leaseCreatedAtMs = Date.now();
|
|
1853
|
+
directoryLease = await createBeeperMessageLikeMeDirectoryLease({
|
|
1854
|
+
role: "bundle-stage",
|
|
1855
|
+
path: staging,
|
|
1856
|
+
outputRoot: output.outputRoot,
|
|
1857
|
+
recoverAfterMs: leaseCreatedAtMs,
|
|
1858
|
+
nowMs: leaseCreatedAtMs,
|
|
1859
|
+
environment: request.recoveryEnvironment,
|
|
1860
|
+
});
|
|
1861
|
+
} catch (leaseError) {
|
|
1862
|
+
const cleanupErrors: unknown[] = [];
|
|
1863
|
+
try {
|
|
1864
|
+
await removeOwnedPrivateDirectory(staging, stagedDirectory.identity);
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
cleanupErrors.push(error);
|
|
1867
|
+
}
|
|
1868
|
+
try {
|
|
1869
|
+
await syncDirectory(output.parent, Object.freeze({
|
|
1870
|
+
device: output.parentDevice,
|
|
1871
|
+
inode: output.parentInode,
|
|
1872
|
+
}));
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
cleanupErrors.push(error);
|
|
1875
|
+
}
|
|
1876
|
+
if (cleanupErrors.length > 0) {
|
|
1877
|
+
throw new AggregateError(
|
|
1878
|
+
[leaseError, ...cleanupErrors],
|
|
1879
|
+
"Beeper Message Like Me export: recovery setup and cleanup both failed",
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
throw leaseError;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const writers = new Map<RecordKind, ArtifactWriter>();
|
|
1887
|
+
let bundlePhaseHeartbeat: BundlePhaseHeartbeat | undefined;
|
|
1888
|
+
let renamed = false;
|
|
1889
|
+
let operationFailed = false;
|
|
1890
|
+
let operationError: unknown;
|
|
1891
|
+
try {
|
|
1892
|
+
for (const artifact of ARTIFACTS) {
|
|
1893
|
+
writers.set(artifact.kind, await createWriter(staging, artifact.kind, artifact.fileName));
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const graphInventory = new Map<RecordKind, Map<string, BundleGraphFact>>(
|
|
1897
|
+
ARTIFACTS.map((artifact) => [artifact.kind, new Map<string, BundleGraphFact>()]),
|
|
1898
|
+
);
|
|
1899
|
+
const providerCoordinates = new Map<RecordKind, Set<string>>(
|
|
1900
|
+
ARTIFACTS.map((artifact) => [artifact.kind, new Set<string>()]),
|
|
1901
|
+
);
|
|
1902
|
+
const accountRealms = new Map<string, { readonly network: string; readonly connectedAccountProviderId: string }>();
|
|
1903
|
+
const referencedAccountRealms = new Map<string, { readonly network: string; readonly connectedAccountProviderId: string }>();
|
|
1904
|
+
let totalRecords = 0;
|
|
1905
|
+
let totalBytes = 0;
|
|
1906
|
+
const bundleStartedAt = Date.now();
|
|
1907
|
+
let lastBundleProgressAt = bundleStartedAt;
|
|
1908
|
+
|
|
1909
|
+
for await (const candidate of request.source.records) {
|
|
1910
|
+
throwIfAborted(request.signal);
|
|
1911
|
+
totalRecords += 1;
|
|
1912
|
+
if (totalRecords > limits.maxRecords) fail("record stream exceeds the configured record bound");
|
|
1913
|
+
const parsed = parseRecord(candidate, totalRecords - 1);
|
|
1914
|
+
const records = graphInventory.get(parsed.kind);
|
|
1915
|
+
const coordinates = providerCoordinates.get(parsed.kind);
|
|
1916
|
+
if (records === undefined || coordinates === undefined) {
|
|
1917
|
+
fail("internal record-kind inventory is incomplete");
|
|
1918
|
+
}
|
|
1919
|
+
if (records.has(parsed.id)) {
|
|
1920
|
+
fail(`${parsed.kind} record repeats a bundle-local identity`);
|
|
1921
|
+
}
|
|
1922
|
+
const providerCoordinate = graphProviderCoordinate(
|
|
1923
|
+
parsed.value.accountId,
|
|
1924
|
+
parsed.value.provenance.providerId,
|
|
1925
|
+
);
|
|
1926
|
+
if (coordinates.has(providerCoordinate)) {
|
|
1927
|
+
fail(`${parsed.kind} record repeats an account-scoped provider identity`);
|
|
1928
|
+
}
|
|
1929
|
+
records.set(parsed.id, bundleGraphFact(parsed.value));
|
|
1930
|
+
coordinates.add(providerCoordinate);
|
|
1931
|
+
if (parsed.kind === "account" && records.size > MAX_CONNECTED_ACCOUNTS) {
|
|
1932
|
+
fail("record stream exceeds the connected-account bound");
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (
|
|
1936
|
+
parsed.accountId !== undefined
|
|
1937
|
+
&& parsed.network !== undefined
|
|
1938
|
+
&& parsed.connectedAccountProviderId !== undefined
|
|
1939
|
+
) {
|
|
1940
|
+
const realm = Object.freeze({
|
|
1941
|
+
network: parsed.network,
|
|
1942
|
+
connectedAccountProviderId: parsed.connectedAccountProviderId,
|
|
1943
|
+
});
|
|
1944
|
+
if (parsed.kind === "account") {
|
|
1945
|
+
const current = accountRealms.get(parsed.accountId);
|
|
1946
|
+
if (
|
|
1947
|
+
current !== undefined
|
|
1948
|
+
&& (
|
|
1949
|
+
current.network !== realm.network
|
|
1950
|
+
|| current.connectedAccountProviderId !== realm.connectedAccountProviderId
|
|
1951
|
+
)
|
|
1952
|
+
) {
|
|
1953
|
+
fail("account record has conflicting source identity");
|
|
1954
|
+
}
|
|
1955
|
+
accountRealms.set(parsed.accountId, realm);
|
|
1956
|
+
} else {
|
|
1957
|
+
const current = referencedAccountRealms.get(parsed.accountId);
|
|
1958
|
+
if (
|
|
1959
|
+
current !== undefined
|
|
1960
|
+
&& (
|
|
1961
|
+
current.network !== realm.network
|
|
1962
|
+
|| current.connectedAccountProviderId !== realm.connectedAccountProviderId
|
|
1963
|
+
)
|
|
1964
|
+
) {
|
|
1965
|
+
fail("account is referenced with conflicting source identity");
|
|
1966
|
+
}
|
|
1967
|
+
referencedAccountRealms.set(parsed.accountId, realm);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const writer = writers.get(parsed.kind);
|
|
1972
|
+
if (writer === undefined) fail("internal artifact writer is missing");
|
|
1973
|
+
totalBytes = await writeRecord(writer, parsed.value, limits, totalBytes);
|
|
1974
|
+
const progressAt = Date.now();
|
|
1975
|
+
if (
|
|
1976
|
+
totalRecords === 1
|
|
1977
|
+
|| progressAt - lastBundleProgressAt >= 30_000
|
|
1978
|
+
) {
|
|
1979
|
+
request.onProgress?.(Object.freeze({
|
|
1980
|
+
phase: "bundle-building",
|
|
1981
|
+
elapsedSeconds: Math.max(
|
|
1982
|
+
0,
|
|
1983
|
+
Math.floor((progressAt - bundleStartedAt) / 1_000),
|
|
1984
|
+
),
|
|
1985
|
+
records: totalRecords,
|
|
1986
|
+
bytes: totalBytes,
|
|
1987
|
+
}));
|
|
1988
|
+
lastBundleProgressAt = progressAt;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
for (const [accountId, realm] of referencedAccountRealms) {
|
|
1993
|
+
const accountRealm = accountRealms.get(accountId);
|
|
1994
|
+
if (accountRealm === undefined) {
|
|
1995
|
+
fail("record stream references a missing account");
|
|
1996
|
+
}
|
|
1997
|
+
if (
|
|
1998
|
+
accountRealm.network !== realm.network
|
|
1999
|
+
|| accountRealm.connectedAccountProviderId !== realm.connectedAccountProviderId
|
|
2000
|
+
) {
|
|
2001
|
+
fail("account does not match its referenced source identity");
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
bundlePhaseHeartbeat = startBundlePhaseHeartbeat(
|
|
2006
|
+
request,
|
|
2007
|
+
"bundle-validating",
|
|
2008
|
+
totalRecords,
|
|
2009
|
+
totalBytes,
|
|
2010
|
+
);
|
|
2011
|
+
validateBundleGraph(graphInventory);
|
|
2012
|
+
|
|
2013
|
+
throwIfAborted(request.signal);
|
|
2014
|
+
const completion = parseCompletion(await request.source.completion());
|
|
2015
|
+
const finishedAt = now(request.clock, "finishedAt");
|
|
2016
|
+
if (finishedAt < startedAt) fail("finishedAt must not be before startedAt");
|
|
2017
|
+
|
|
2018
|
+
const artifacts: BeeperMessageLikeMeArtifact[] = [];
|
|
2019
|
+
for (const artifact of ARTIFACTS) {
|
|
2020
|
+
const writer = writers.get(artifact.kind);
|
|
2021
|
+
if (writer === undefined) fail("internal artifact writer is missing during finalization");
|
|
2022
|
+
artifacts.push(await finalizeWriter(writer, staging));
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const counts = Object.freeze(Object.fromEntries(
|
|
2026
|
+
artifacts.map((artifact) => [artifact.recordKind, artifact.records]),
|
|
2027
|
+
)) as Readonly<Record<RecordKind, number>>;
|
|
2028
|
+
const manifestProjection = Object.freeze({
|
|
2029
|
+
schemaVersion: BEEPER_MESSAGE_LIKE_ME_SCHEMA_VERSION,
|
|
2030
|
+
format: LOCAL_MESSAGE_BUNDLE_V1_FORMAT,
|
|
2031
|
+
source: descriptor.source,
|
|
2032
|
+
provider: descriptor.provider,
|
|
2033
|
+
timestamps: Object.freeze({
|
|
2034
|
+
startedAt,
|
|
2035
|
+
finishedAt,
|
|
2036
|
+
createdAt: finishedAt,
|
|
2037
|
+
}),
|
|
2038
|
+
completeness: completion.completeness,
|
|
2039
|
+
warnings: completion.warnings,
|
|
2040
|
+
privacy: Object.freeze({
|
|
2041
|
+
classification: "private-local",
|
|
2042
|
+
attachments: "metadata-only",
|
|
2043
|
+
providerUrls: "excluded",
|
|
2044
|
+
credentials: "excluded",
|
|
2045
|
+
}),
|
|
2046
|
+
counts,
|
|
2047
|
+
artifacts: Object.freeze(artifacts),
|
|
2048
|
+
});
|
|
2049
|
+
const manifest: BeeperMessageLikeMeManifest = Object.freeze({
|
|
2050
|
+
...manifestProjection,
|
|
2051
|
+
integrity: Object.freeze({
|
|
2052
|
+
algorithm: "sha256",
|
|
2053
|
+
bundleSha256: sha256(canonicalJson(manifestProjection)),
|
|
2054
|
+
}),
|
|
2055
|
+
});
|
|
2056
|
+
if (manifest.source.version === LOCAL_MESSAGE_BUNDLE_V1_SOURCE_TRANSFORM_VERSION) {
|
|
2057
|
+
try {
|
|
2058
|
+
parseLocalMessageBundleV1Manifest(manifest);
|
|
2059
|
+
} catch {
|
|
2060
|
+
fail("constructed manifest violates the Message Like Me bundle v1 contract");
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
const stagedManifest = await writeManifest(staging, manifest);
|
|
2064
|
+
await validateCompleteBundle(
|
|
2065
|
+
staging,
|
|
2066
|
+
stagedDirectory.identity,
|
|
2067
|
+
artifacts,
|
|
2068
|
+
stagedManifest,
|
|
2069
|
+
);
|
|
2070
|
+
await syncDirectory(staging, stagedDirectory.identity);
|
|
2071
|
+
throwIfAborted(request.signal);
|
|
2072
|
+
await assertParentUnchanged(output);
|
|
2073
|
+
bundlePhaseHeartbeat.stop();
|
|
2074
|
+
bundlePhaseHeartbeat.assertHealthy();
|
|
2075
|
+
bundlePhaseHeartbeat = startBundlePhaseHeartbeat(
|
|
2076
|
+
request,
|
|
2077
|
+
"bundle-publishing",
|
|
2078
|
+
totalRecords,
|
|
2079
|
+
totalBytes,
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
const result = Object.freeze({
|
|
2083
|
+
outputRoot: output.outputRoot,
|
|
2084
|
+
manifestPath: resolve(output.outputRoot, "manifest.json"),
|
|
2085
|
+
manifestSha256: stagedManifest.sha256,
|
|
2086
|
+
manifest,
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
await renameDirectoryExclusive(staging, output.outputRoot);
|
|
2090
|
+
renamed = true;
|
|
2091
|
+
await validateCompleteBundle(
|
|
2092
|
+
output.outputRoot,
|
|
2093
|
+
stagedDirectory.identity,
|
|
2094
|
+
artifacts,
|
|
2095
|
+
stagedManifest,
|
|
2096
|
+
);
|
|
2097
|
+
throwIfAborted(request.signal);
|
|
2098
|
+
await assertParentUnchanged(output);
|
|
2099
|
+
await syncDirectory(output.parent, Object.freeze({
|
|
2100
|
+
device: output.parentDevice,
|
|
2101
|
+
inode: output.parentInode,
|
|
2102
|
+
}));
|
|
2103
|
+
bundlePhaseHeartbeat.stop();
|
|
2104
|
+
bundlePhaseHeartbeat.assertHealthy();
|
|
2105
|
+
bundlePhaseHeartbeat = undefined;
|
|
2106
|
+
const publishedDirectory = Object.freeze({
|
|
2107
|
+
path: output.outputRoot,
|
|
2108
|
+
parent: output.parent,
|
|
2109
|
+
identity: stagedDirectory.identity,
|
|
2110
|
+
parentIdentity: Object.freeze({
|
|
2111
|
+
device: output.parentDevice,
|
|
2112
|
+
inode: output.parentInode,
|
|
2113
|
+
}),
|
|
2114
|
+
});
|
|
2115
|
+
published = true;
|
|
2116
|
+
return Object.freeze({
|
|
2117
|
+
result,
|
|
2118
|
+
directory: publishedDirectory,
|
|
2119
|
+
...(directoryLease === undefined ? {} : { directoryLease }),
|
|
2120
|
+
});
|
|
2121
|
+
} catch (error) {
|
|
2122
|
+
operationFailed = true;
|
|
2123
|
+
operationError = error;
|
|
2124
|
+
throw error;
|
|
2125
|
+
} finally {
|
|
2126
|
+
const cleanupErrors: unknown[] = [];
|
|
2127
|
+
bundlePhaseHeartbeat?.stop();
|
|
2128
|
+
const closeResults = await Promise.allSettled(
|
|
2129
|
+
[...writers.values()].map((writer) => closeWriter(writer)),
|
|
2130
|
+
);
|
|
2131
|
+
for (const result of closeResults) {
|
|
2132
|
+
if (result.status === "rejected") cleanupErrors.push(result.reason);
|
|
2133
|
+
}
|
|
2134
|
+
if (!published) {
|
|
2135
|
+
let removalDurable = true;
|
|
2136
|
+
try {
|
|
2137
|
+
await removeOwnedPrivateDirectory(
|
|
2138
|
+
renamed ? output.outputRoot : staging,
|
|
2139
|
+
stagedDirectory.identity,
|
|
2140
|
+
);
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
removalDurable = false;
|
|
2143
|
+
cleanupErrors.push(error);
|
|
2144
|
+
}
|
|
2145
|
+
try {
|
|
2146
|
+
await syncDirectory(output.parent, Object.freeze({
|
|
2147
|
+
device: output.parentDevice,
|
|
2148
|
+
inode: output.parentInode,
|
|
2149
|
+
}));
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
removalDurable = false;
|
|
2152
|
+
cleanupErrors.push(error);
|
|
2153
|
+
}
|
|
2154
|
+
if (directoryLease !== undefined && removalDurable) {
|
|
2155
|
+
try {
|
|
2156
|
+
releaseBeeperMessageLikeMeDirectoryLease(directoryLease);
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
cleanupErrors.push(error);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
if (cleanupErrors.length > 0) {
|
|
2163
|
+
if (operationFailed) {
|
|
2164
|
+
throw new AggregateError(
|
|
2165
|
+
[operationError, ...cleanupErrors],
|
|
2166
|
+
"Beeper Message Like Me export: publication and cleanup both failed",
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
if (cleanupErrors.length === 1) throw cleanupErrors[0];
|
|
2170
|
+
throw new AggregateError(
|
|
2171
|
+
cleanupErrors,
|
|
2172
|
+
"Beeper Message Like Me export: cleanup failed",
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
export async function exportBeeperMessageLikeMeBundle(
|
|
2179
|
+
request: BeeperMessageLikeMeExportRequest,
|
|
2180
|
+
): Promise<BeeperMessageLikeMeExportResult> {
|
|
2181
|
+
assertSource(request.source);
|
|
2182
|
+
let publication: PublishedBundle | undefined;
|
|
2183
|
+
let publicationFailed = false;
|
|
2184
|
+
let publicationError: unknown;
|
|
2185
|
+
try {
|
|
2186
|
+
publication = await publishBeeperMessageLikeMeBundle(request);
|
|
2187
|
+
return publication.result;
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
publicationFailed = true;
|
|
2190
|
+
publicationError = error;
|
|
2191
|
+
throw error;
|
|
2192
|
+
} finally {
|
|
2193
|
+
const finalizationErrors: unknown[] = [];
|
|
2194
|
+
const cleanupHeartbeat = request.source.dispose === undefined
|
|
2195
|
+
? undefined
|
|
2196
|
+
: startPrivateCleanupHeartbeat(request);
|
|
2197
|
+
try {
|
|
2198
|
+
await request.source.dispose?.(publication !== undefined);
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
finalizationErrors.push(error);
|
|
2201
|
+
}
|
|
2202
|
+
let releaseAttempted = false;
|
|
2203
|
+
if (
|
|
2204
|
+
publication !== undefined
|
|
2205
|
+
&& finalizationErrors.length === 0
|
|
2206
|
+
&& publication.directoryLease !== undefined
|
|
2207
|
+
) {
|
|
2208
|
+
releaseAttempted = true;
|
|
2209
|
+
try {
|
|
2210
|
+
releaseBeeperMessageLikeMeDirectoryLease(publication.directoryLease);
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
finalizationErrors.push(error);
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
cleanupHeartbeat?.stop();
|
|
2216
|
+
try {
|
|
2217
|
+
cleanupHeartbeat?.assertHealthy();
|
|
2218
|
+
} catch (error) {
|
|
2219
|
+
finalizationErrors.push(error);
|
|
2220
|
+
}
|
|
2221
|
+
if (finalizationErrors.length > 0) {
|
|
2222
|
+
const errors = publicationFailed
|
|
2223
|
+
? [publicationError, ...finalizationErrors]
|
|
2224
|
+
: [...finalizationErrors];
|
|
2225
|
+
if (publication !== undefined) {
|
|
2226
|
+
let rollbackDurable = true;
|
|
2227
|
+
try {
|
|
2228
|
+
await removeOwnedPrivateDirectory(
|
|
2229
|
+
publication.directory.path,
|
|
2230
|
+
publication.directory.identity,
|
|
2231
|
+
);
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
rollbackDurable = false;
|
|
2234
|
+
errors.push(error);
|
|
2235
|
+
}
|
|
2236
|
+
try {
|
|
2237
|
+
await syncDirectory(
|
|
2238
|
+
publication.directory.parent,
|
|
2239
|
+
publication.directory.parentIdentity,
|
|
2240
|
+
);
|
|
2241
|
+
} catch (error) {
|
|
2242
|
+
rollbackDurable = false;
|
|
2243
|
+
errors.push(error);
|
|
2244
|
+
}
|
|
2245
|
+
if (
|
|
2246
|
+
rollbackDurable
|
|
2247
|
+
&& !releaseAttempted
|
|
2248
|
+
&& publication.directoryLease !== undefined
|
|
2249
|
+
) {
|
|
2250
|
+
try {
|
|
2251
|
+
releaseBeeperMessageLikeMeDirectoryLease(
|
|
2252
|
+
publication.directoryLease,
|
|
2253
|
+
);
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
errors.push(error);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
if (errors.length > finalizationErrors.length) {
|
|
2259
|
+
throw new AggregateError(
|
|
2260
|
+
errors,
|
|
2261
|
+
"Beeper Message Like Me export: finalization and published output rollback failed",
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
if (errors.length === 1) throw errors[0];
|
|
2266
|
+
throw new AggregateError(
|
|
2267
|
+
errors,
|
|
2268
|
+
publicationFailed
|
|
2269
|
+
? "Beeper Message Like Me export: publication and source disposal both failed"
|
|
2270
|
+
: "Beeper Message Like Me export: finalization failed",
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|