@volter/twin-instagram 0.1.0

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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +56 -0
  3. package/dist/client/instagram-mirror.bundle.js +321 -0
  4. package/dist/client/instagram-mirror.d.ts +58 -0
  5. package/dist/client/instagram-mirror.js +257 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +30 -0
  8. package/dist/src/index.d.ts +10 -0
  9. package/dist/src/index.js +71 -0
  10. package/dist/src/instagram-budget.d.ts +44 -0
  11. package/dist/src/instagram-budget.js +112 -0
  12. package/dist/src/instagram-capabilities.d.ts +4 -0
  13. package/dist/src/instagram-capabilities.js +1249 -0
  14. package/dist/src/instagram-conformance.d.ts +8 -0
  15. package/dist/src/instagram-conformance.js +44 -0
  16. package/dist/src/instagram-connector.d.ts +79 -0
  17. package/dist/src/instagram-connector.js +437 -0
  18. package/dist/src/instagram-errors.d.ts +33 -0
  19. package/dist/src/instagram-errors.js +73 -0
  20. package/dist/src/instagram-media.d.ts +134 -0
  21. package/dist/src/instagram-media.js +388 -0
  22. package/dist/src/instagram-mirror-ui.d.ts +57 -0
  23. package/dist/src/instagram-mirror-ui.js +158 -0
  24. package/dist/src/instagram-server.d.ts +14 -0
  25. package/dist/src/instagram-server.js +146 -0
  26. package/dist/src/instagram-twin.d.ts +52 -0
  27. package/dist/src/instagram-twin.js +884 -0
  28. package/package.json +57 -0
  29. package/src/cli.ts +28 -0
  30. package/src/index.ts +117 -0
  31. package/src/instagram-budget.ts +130 -0
  32. package/src/instagram-capabilities.ts +1191 -0
  33. package/src/instagram-conformance.ts +58 -0
  34. package/src/instagram-connector.ts +400 -0
  35. package/src/instagram-errors.ts +89 -0
  36. package/src/instagram-media.ts +403 -0
  37. package/src/instagram-mirror-ui.ts +173 -0
  38. package/src/instagram-server.ts +146 -0
  39. package/src/instagram-twin.ts +859 -0
@@ -0,0 +1,58 @@
1
+ import { type IgRow, type MirrorProfile } from '../src/instagram-mirror-ui.js';
2
+ type Session = {
3
+ username: string;
4
+ token: string;
5
+ };
6
+ export type Route = {
7
+ page: 'profile';
8
+ username?: string;
9
+ tab: 'posts' | 'reels';
10
+ } | {
11
+ page: 'reel';
12
+ shortcode: string | null;
13
+ };
14
+ export declare function Wordmark({ size }: {
15
+ size?: number;
16
+ }): import("react").JSX.Element;
17
+ /** The account's avatar: its profile picture, else instagram.com's empty silhouette. */
18
+ export declare function Avatar({ src, size, ring }: {
19
+ src?: string;
20
+ size: number;
21
+ ring?: boolean;
22
+ }): import("react").JSX.Element;
23
+ export declare function Caption({ text }: {
24
+ text: unknown;
25
+ }): import("react").JSX.Element;
26
+ export declare function ProfileHeader({ profile }: {
27
+ profile: MirrorProfile;
28
+ }): import("react").JSX.Element;
29
+ export declare function ProfileTabs({ username, tab }: {
30
+ username: string;
31
+ tab: 'posts' | 'reels';
32
+ }): import("react").JSX.Element;
33
+ export declare function ReelsGrid({ reels, shape }: {
34
+ reels: IgRow[];
35
+ shape: 'reels' | 'posts';
36
+ }): import("react").JSX.Element;
37
+ export type Account = {
38
+ profile: MirrorProfile | null;
39
+ reels: IgRow[] | null;
40
+ error: string | null;
41
+ };
42
+ export declare function ProfileScreen({ account, route }: {
43
+ account: Account;
44
+ route: Extract<Route, {
45
+ page: 'profile';
46
+ }>;
47
+ }): import("react").JSX.Element;
48
+ export declare function ReelViewer({ reel, profile, nowMs, prev, next }: {
49
+ reel: IgRow;
50
+ profile: MirrorProfile;
51
+ nowMs: number;
52
+ prev?: string;
53
+ next?: string;
54
+ }): import("react").JSX.Element;
55
+ export declare function LoginScreen({ onSignedIn }: {
56
+ onSignedIn: (s: Session) => void;
57
+ }): import("react").JSX.Element;
58
+ export {};
@@ -0,0 +1,257 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Instagram mirror — React/TSX client. instagram.com's own view of the twin's state: log in as the
3
+ // professional account, see its profile (avatar, username, counts, bio) with its Reels grid, and play a
4
+ // Reel full height. Every read is the twin's OWN Graph API on the same origin; nothing is kept but the
5
+ // signed-in username and token, in the tab's sessionStorage. Routes are `#/…` links, never assigned.
6
+ import { createRoot } from 'react-dom/client';
7
+ import { useEffect, useRef, useState } from 'react';
8
+ import { captionSegments, compactCount, isReel, mediaQuery, MIRROR_VERSION, PROFILE_FIELDS, profileFromRead, reelRoute, tabReels, timeAgo, } from "../src/instagram-mirror-ui.js";
9
+ // WHERE THIS MIRROR LIVES. At a vendor root the base is '' (fetches are root-relative); under a
10
+ // World's <base> every read resolves inside that prefix.
11
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
12
+ const SESSION_USER = 'instagram-mirror.username';
13
+ const SESSION_TOKEN = 'instagram-mirror.token';
14
+ function readSession() {
15
+ if (typeof sessionStorage === 'undefined')
16
+ return null;
17
+ const username = sessionStorage.getItem(SESSION_USER);
18
+ const token = sessionStorage.getItem(SESSION_TOKEN);
19
+ return username && token ? { username, token } : null;
20
+ }
21
+ // THE WORLD'S CLOCK: every answer carries the World's instant as its HTTP `Date`, so ages are drawn
22
+ // against the World's now, never the browser's.
23
+ let worldNowMs = null;
24
+ const currentNow = () => worldNowMs ?? Date.now();
25
+ async function wire(token, path) {
26
+ const res = await fetch(`${WIRE_BASE}${path}`, { headers: { authorization: `Bearer ${token}` } });
27
+ const served = Date.parse(res.headers.get('date') ?? '');
28
+ if (Number.isFinite(served))
29
+ worldNowMs = served;
30
+ let body = {};
31
+ try {
32
+ body = await res.json();
33
+ }
34
+ catch {
35
+ body = {};
36
+ }
37
+ return { status: res.status, body };
38
+ }
39
+ const problemText = (w) => String(w.body.error?.message ?? `HTTP ${w.status}`);
40
+ function parseRoute(hash) {
41
+ const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
42
+ if (parts[0] === 'reel' && parts[1])
43
+ return { page: 'reel', shortcode: parts[1] };
44
+ if (parts[0] === 'reels' && !parts[1])
45
+ return { page: 'reel', shortcode: null };
46
+ if (parts[0])
47
+ return { page: 'profile', username: parts[0], tab: parts[1] === 'reels' ? 'reels' : 'posts' };
48
+ return { page: 'profile', tab: 'reels' };
49
+ }
50
+ // ── icons (vendor mirror: instagram.com's look, drawn here) ────────────────────────────────────
51
+ const S = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
52
+ function Svg({ size = 24, children, label }) {
53
+ return _jsx("svg", { viewBox: "0 0 24 24", width: size, height: size, "aria-label": label, "aria-hidden": label ? undefined : true, role: label ? 'img' : undefined, children: children });
54
+ }
55
+ const IconHome = () => _jsx(Svg, { children: _jsx("path", { ...S, d: "M9.005 16.545a2.997 2.997 0 0 1 2.997-2.997A2.997 2.997 0 0 1 15 16.545V22h7V11.543L12 2 2 11.543V22h7.005Z" }) });
56
+ const IconSearch = () => _jsxs(Svg, { children: [_jsx("circle", { ...S, cx: "10.5", cy: "10.5", r: "7.5" }), _jsx("path", { ...S, d: "m16.5 16.5 5.5 5.5" })] });
57
+ const IconExplore = () => _jsxs(Svg, { children: [_jsx("circle", { ...S, cx: "12", cy: "12", r: "10" }), _jsx("path", { ...S, d: "m13.94 13.94-5.66 1.82 1.82-5.66 5.66-1.82z" })] });
58
+ const IconReels = ({ size = 24 }) => _jsxs(Svg, { size: size, children: [_jsx("rect", { ...S, x: "2", y: "2", width: "20", height: "20", rx: "5" }), _jsx("path", { ...S, d: "M2.05 7.5h19.9M13.5 2.05l3.4 5.45M7.36 2.2l3.35 5.3" }), _jsx("path", { d: "M9.8 17.2V11.1a.5.5 0 0 1 .76-.43l5.02 3.05a.5.5 0 0 1 0 .86l-5.02 3.05a.5.5 0 0 1-.76-.43Z", fill: "currentColor" })] });
59
+ const IconMessages = () => _jsx(Svg, { children: _jsx("path", { ...S, d: "M22 3 9.2 10.1M22 3l-7 19-5.8-11.9L2 7.6z" }) });
60
+ const IconHeart = ({ size = 24 }) => _jsx(Svg, { size: size, children: _jsx("path", { ...S, d: "M16.8 3.2a5.4 5.4 0 0 0-4.8 2.9 5.4 5.4 0 0 0-4.8-2.9A5.2 5.2 0 0 0 2 8.6c0 5.3 5.5 8.4 10 12.2 4.5-3.8 10-6.9 10-12.2a5.2 5.2 0 0 0-5.2-5.4Z" }) });
61
+ const IconCreate = () => _jsxs(Svg, { children: [_jsx("rect", { ...S, x: "2", y: "2", width: "20", height: "20", rx: "5" }), _jsx("path", { ...S, d: "M12 7v10M7 12h10" })] });
62
+ const IconMenu = () => _jsx(Svg, { children: _jsx("path", { ...S, d: "M3 5h18M3 12h18M3 19h18" }) });
63
+ const IconComment = ({ size = 24 }) => _jsx(Svg, { size: size, children: _jsx("path", { ...S, d: "M20.66 17A9.9 9.9 0 1 0 17 20.66L22 22Z" }) });
64
+ const IconShare = ({ size = 24 }) => _jsx(Svg, { size: size, children: _jsx("path", { ...S, d: "M22 3 9.2 10.1M22 3l-7 19-5.8-11.9L2 7.6z" }) });
65
+ const IconSave = ({ size = 24 }) => _jsx(Svg, { size: size, children: _jsx("path", { ...S, d: "M20 21 12 13.44 4 21V3h16z" }) });
66
+ const IconMore = ({ size = 24 }) => _jsxs(Svg, { size: size, children: [_jsx("circle", { cx: "5", cy: "12", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "12", cy: "12", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "19", cy: "12", r: "1.6", fill: "currentColor" })] });
67
+ const IconGrid = () => _jsx(Svg, { size: 12, children: _jsx("path", { ...S, d: "M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" }) });
68
+ const IconTagged = () => _jsx(Svg, { size: 12, children: _jsx("path", { ...S, d: "M10.2 18.1 12 21l1.8-2.9H21V3H3v15.1zM12 11.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM7 15.5c.8-1.7 2.7-2.7 5-2.7s4.2 1 5 2.7" }) });
69
+ const IconPlay = ({ size = 22 }) => _jsx(Svg, { size: size, children: _jsx("path", { d: "M5.9 3.4a1 1 0 0 1 1.5-.9l13 8.2a1.5 1.5 0 0 1 0 2.6l-13 8.2a1 1 0 0 1-1.5-.9Z", fill: "currentColor" }) });
70
+ const IconMuted = () => _jsx(Svg, { size: 14, children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9Zm13.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4Z", fill: "currentColor" }) });
71
+ const IconSound = () => _jsx(Svg, { size: 14, children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.5 4.5 0 0 0 2.5-4Zm-2.5-9v2.1a7 7 0 0 1 0 13.8V21a9 9 0 0 0 0-18Z", fill: "currentColor" }) });
72
+ const IconGear = () => _jsxs(Svg, { children: [_jsx("circle", { ...S, cx: "12", cy: "12", r: "3" }), _jsx("path", { ...S, d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" })] });
73
+ const IconClose = () => _jsx(Svg, { size: 20, children: _jsx("path", { ...S, d: "M5 5l14 14M19 5 5 19" }) });
74
+ const IconUp = () => _jsx(Svg, { size: 16, children: _jsx("path", { ...S, d: "m6 15 6-6 6 6" }) });
75
+ const IconDown = () => _jsx(Svg, { size: 16, children: _jsx("path", { ...S, d: "m6 9 6 6 6-6" }) });
76
+ export function Wordmark({ size = 29 }) {
77
+ return _jsx("span", { className: "wordmark", style: { fontSize: size }, "aria-label": "Instagram", children: "Instagram" });
78
+ }
79
+ /** The account's avatar: its profile picture, else instagram.com's empty silhouette. */
80
+ export function Avatar({ src, size, ring = false }) {
81
+ const inner = src
82
+ ? _jsx("img", { className: "avatar-img", src: src, width: size, height: size, alt: "Profile picture" })
83
+ : (_jsxs("svg", { className: "avatar-img avatar-empty", viewBox: "0 0 40 40", width: size, height: size, "aria-label": "Profile picture", role: "img", children: [_jsx("rect", { width: "40", height: "40", fill: "#dbdbdb" }), _jsx("circle", { cx: "20", cy: "15.5", r: "7", fill: "#fff" }), _jsx("path", { d: "M6 38c1.6-7.2 7.3-11 14-11s12.4 3.8 14 11Z", fill: "#fff" })] }));
84
+ return _jsx("span", { className: `avatar${ring ? ' avatar-ring' : ''}${src ? ' avatar-has-img' : ''}`, style: { width: size, height: size }, children: inner });
85
+ }
86
+ export function Caption({ text }) {
87
+ return (_jsx(_Fragment, { children: captionSegments(text).map((s, i) => (s.kind === 'text' ? _jsx("span", { children: s.value }, i) : _jsx("span", { className: "ig-link", children: s.value }, i))) }));
88
+ }
89
+ // ── the profile ─────────────────────────────────────────────────────────────────────────────────
90
+ export function ProfileHeader({ profile }) {
91
+ const count = (n, one, many) => typeof n === 'number' ? _jsxs("li", { children: [_jsx("span", { className: "count", children: compactCount(n) }), " ", n === 1 ? one : many] }) : null;
92
+ return (_jsxs("header", { className: "profile-header", children: [_jsx("div", { className: "profile-avatar", children: _jsx(Avatar, { src: profile.avatarUrl, size: 150 }) }), _jsxs("section", { className: "profile-info", children: [_jsxs("div", { className: "profile-row", children: [_jsx("h2", { className: "profile-username", children: profile.username }), _jsx("span", { className: "btn-secondary", children: "Edit profile" }), _jsx("span", { className: "btn-secondary", children: "View archive" }), _jsx("span", { className: "profile-gear", "aria-label": "Options", children: _jsx(IconGear, {}) })] }), _jsxs("ul", { className: "profile-counts", children: [count(profile.posts, 'post', 'posts'), count(profile.followers, 'follower', 'followers'), typeof profile.following === 'number' ? _jsxs("li", { children: [_jsx("span", { className: "count", children: compactCount(profile.following) }), " following"] }) : null] }), profile.name ? _jsx("div", { className: "profile-name", children: profile.name }) : null, profile.biography ? _jsx("div", { className: "profile-bio", children: profile.biography }) : null, profile.website ? _jsx("a", { className: "profile-website", href: profile.website, target: "_blank", rel: "noreferrer", children: profile.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') }) : null] })] }));
93
+ }
94
+ export function ProfileTabs({ username, tab }) {
95
+ const u = encodeURIComponent(username);
96
+ return (_jsxs("nav", { className: "profile-tabs", children: [_jsxs("a", { className: tab === 'posts' ? 'profile-tab active' : 'profile-tab', href: `#/${u}/`, children: [_jsx(IconGrid, {}), _jsx("span", { children: "POSTS" })] }), _jsxs("a", { className: tab === 'reels' ? 'profile-tab active' : 'profile-tab', href: `#/${u}/reels/`, children: [_jsx(IconReels, { size: 12 }), _jsx("span", { children: "REELS" })] }), _jsxs("span", { className: "profile-tab", children: [_jsx(IconTagged, {}), _jsx("span", { children: "TAGGED" })] })] }));
97
+ }
98
+ /** A Reel's still: its cover when it has one; the twin's thumbnail is a stand-in (an SVG — it decodes
99
+ * no frame), so the browser draws the Reel's own first frame instead. */
100
+ function ReelStill({ reel }) {
101
+ const thumb = typeof reel.thumbnail_url === 'string' ? reel.thumbnail_url : '';
102
+ if (thumb && !/\.svg(\?|$)/.test(thumb))
103
+ return _jsx("img", { className: "tile-media", src: thumb, alt: String(reel.caption ?? 'Reel') });
104
+ if (typeof reel.media_url !== 'string')
105
+ return _jsx("div", { className: "tile-media tile-empty" });
106
+ return _jsx("video", { className: "tile-media", src: `${reel.media_url}#t=0.1`, preload: "metadata", muted: true, playsInline: true, "aria-label": String(reel.caption ?? 'Reel') });
107
+ }
108
+ export function ReelsGrid({ reels, shape }) {
109
+ return (_jsx("div", { className: shape === 'reels' ? 'grid grid-reels' : 'grid grid-posts', children: reels.map((r) => (_jsxs("a", { className: "tile", href: reelRoute(String(r.shortcode)), "data-media-id": String(r.id), children: [_jsx(ReelStill, { reel: r }), shape === 'reels'
110
+ ? _jsxs("span", { className: "tile-views", children: [_jsx(IconPlay, { size: 16 }), typeof r.view_count === 'number' ? _jsx("span", { children: compactCount(r.view_count) }) : null] })
111
+ : _jsx("span", { className: "tile-badge", children: _jsx(IconReels, { size: 18 }) }), _jsxs("span", { className: "tile-hover", children: [typeof r.like_count === 'number' ? _jsxs("span", { children: [_jsx(IconHeart, { size: 19 }), " ", compactCount(r.like_count)] }) : null, typeof r.comments_count === 'number' ? _jsxs("span", { children: [_jsx(IconComment, { size: 19 }), " ", compactCount(r.comments_count)] }) : null] })] }, String(r.id)))) }));
112
+ }
113
+ function useAccount(session) {
114
+ const [state, setState] = useState({ profile: null, reels: null, error: null });
115
+ useEffect(() => {
116
+ let live = true;
117
+ void (async () => {
118
+ const me = await wire(session.token, `/${MIRROR_VERSION}/me?fields=${PROFILE_FIELDS}`);
119
+ if (!live)
120
+ return;
121
+ if (me.status !== 200) {
122
+ setState({ profile: null, reels: null, error: problemText(me) });
123
+ return;
124
+ }
125
+ const profile = profileFromRead(me.body);
126
+ setState({ profile, reels: null, error: null });
127
+ const rows = [];
128
+ let after;
129
+ for (let page = 0; page < 10; page += 1) {
130
+ const w = await wire(session.token, mediaQuery(profile.id, after));
131
+ if (!live)
132
+ return;
133
+ if (w.status !== 200 || !Array.isArray(w.body.data)) {
134
+ setState({ profile, reels: null, error: problemText(w) });
135
+ return;
136
+ }
137
+ rows.push(...w.body.data);
138
+ after = typeof w.body.paging?.next === 'string' ? w.body.paging?.cursors?.after : undefined;
139
+ if (!after)
140
+ break;
141
+ }
142
+ setState({ profile, reels: rows.filter(isReel), error: null });
143
+ })();
144
+ return () => { live = false; };
145
+ }, [session.token]);
146
+ return state;
147
+ }
148
+ function Unavailable({ why }) {
149
+ return (_jsxs("div", { className: "unavailable", children: [_jsx("h2", { children: "Sorry, this page isn't available." }), _jsxs("p", { children: [why ?? 'The link you followed may be broken, or the page may have been removed.', " ", _jsx("a", { href: "#/", children: "Go back to Instagram." })] })] }));
150
+ }
151
+ export function ProfileScreen({ account, route }) {
152
+ if (account.error)
153
+ return _jsx(Unavailable, { why: account.error });
154
+ if (!account.profile)
155
+ return _jsx("div", { className: "loading" });
156
+ const p = account.profile;
157
+ if (route.username && route.username !== p.username)
158
+ return _jsx(Unavailable, {});
159
+ const tab = route.tab;
160
+ const shown = tabReels(account.reels ?? [], tab);
161
+ return (_jsxs("div", { className: "profile", children: [_jsx(ProfileHeader, { profile: p }), _jsx(ProfileTabs, { username: p.username, tab: tab }), account.reels === null ? _jsx("div", { className: "loading" }) : shown.length === 0 ? (_jsxs("div", { className: "empty", children: [_jsx("div", { className: "empty-icon", children: _jsx(IconReels, { size: 40 }) }), _jsx("h2", { children: tab === 'reels' ? 'Share a moment with the world' : 'Share Photos' }), _jsxs("p", { children: ["When you share ", tab === 'reels' ? 'reels' : 'photos and videos', ", they will appear on your profile."] })] })) : _jsx(ReelsGrid, { reels: shown, shape: tab }), _jsxs("footer", { className: "footer", children: ["Meta \u00B7 About \u00B7 Blog \u00B7 Jobs \u00B7 Help \u00B7 API \u00B7 Privacy \u00B7 Terms \u00B7 Locations \u00B7 Instagram Lite \u00B7 Threads", _jsx("br", {}), "\u00A9 2026 Instagram from Meta"] })] }));
162
+ }
163
+ // ── the Reel viewer ──────────────────────────────────────────────────────────────────────────────
164
+ export function ReelViewer({ reel, profile, nowMs, prev, next }) {
165
+ const video = useRef(null);
166
+ const [paused, setPaused] = useState(false);
167
+ const [muted, setMuted] = useState(true);
168
+ const [open, setOpen] = useState(false);
169
+ const toggle = () => {
170
+ const v = video.current;
171
+ if (!v)
172
+ return;
173
+ if (v.paused) {
174
+ void v.play().catch(() => undefined);
175
+ setPaused(false);
176
+ }
177
+ else {
178
+ v.pause();
179
+ setPaused(true);
180
+ }
181
+ };
182
+ const back = `#/${encodeURIComponent(profile.username)}/reels/`;
183
+ return (_jsxs("div", { className: "viewer", children: [_jsx("a", { className: "viewer-close", href: back, "aria-label": "Close", children: _jsx(IconClose, {}) }), _jsxs("div", { className: "viewer-stage", children: [_jsxs("div", { className: "player", "data-media-id": String(reel.id), children: [_jsx("video", { ref: video, className: "player-video", src: typeof reel.media_url === 'string' ? reel.media_url : undefined, autoPlay: true, loop: true, muted: muted, playsInline: true, onClick: toggle, "aria-label": String(reel.caption ?? 'Reel') }), paused ? _jsx("button", { className: "player-paused", onClick: toggle, "aria-label": "Play", children: _jsx(IconPlay, { size: 36 }) }) : null, _jsx("button", { className: "player-mute", onClick: () => setMuted(!muted), "aria-label": muted ? 'Audio is muted' : 'Audio is playing', children: muted ? _jsx(IconMuted, {}) : _jsx(IconSound, {}) }), _jsxs("div", { className: "player-overlay", children: [_jsxs("div", { className: "player-who", children: [_jsx(Avatar, { src: profile.avatarUrl, size: 32 }), _jsx("a", { className: "player-username", href: `#/${encodeURIComponent(profile.username)}/`, children: profile.username }), _jsx("span", { className: "player-dot", children: "\u2022" }), _jsx("span", { className: "player-age", children: timeAgo(reel.timestamp, nowMs) })] }), typeof reel.caption === 'string' && reel.caption !== '' ? (_jsx("div", { className: open ? 'player-caption open' : 'player-caption', onClick: () => setOpen(!open), children: _jsx(Caption, { text: reel.caption }) })) : null] })] }), _jsxs("div", { className: "viewer-actions", children: [_jsxs("span", { className: "action", children: [_jsx(IconHeart, { size: 26 }), typeof reel.like_count === 'number' ? _jsx("span", { children: compactCount(reel.like_count) }) : null] }), _jsxs("span", { className: "action", children: [_jsx(IconComment, { size: 26 }), typeof reel.comments_count === 'number' ? _jsx("span", { children: compactCount(reel.comments_count) }) : null] }), _jsx("span", { className: "action", children: _jsx(IconShare, { size: 26 }) }), _jsx("span", { className: "action", children: _jsx(IconSave, { size: 26 }) }), _jsx("span", { className: "action", children: _jsx(IconMore, { size: 26 }) }), _jsx("span", { className: "action-audio", children: _jsx(Avatar, { src: profile.avatarUrl, size: 26 }) })] })] }), _jsxs("div", { className: "viewer-nav", children: [prev ? _jsx("a", { className: "viewer-step", href: reelRoute(prev), "aria-label": "Previous reel", children: _jsx(IconUp, {}) }) : null, next ? _jsx("a", { className: "viewer-step", href: reelRoute(next), "aria-label": "Next reel", children: _jsx(IconDown, {}) }) : null] })] }));
184
+ }
185
+ function ReelScreen({ account, shortcode }) {
186
+ if (account.error)
187
+ return _jsx(Unavailable, { why: account.error });
188
+ if (!account.profile || account.reels === null)
189
+ return _jsx("div", { className: "loading" });
190
+ const reels = account.reels;
191
+ const at = shortcode === null ? 0 : reels.findIndex((r) => r.shortcode === shortcode);
192
+ const reel = reels[at];
193
+ if (!reel)
194
+ return _jsx(Unavailable, {});
195
+ return _jsx(ReelViewer, { reel: reel, profile: account.profile, nowMs: currentNow(), prev: reels[at - 1]?.shortcode, next: reels[at + 1]?.shortcode });
196
+ }
197
+ // ── the chrome ────────────────────────────────────────────────────────────────────────────────────
198
+ function Sidebar({ session, avatarUrl, route, onSignOut }) {
199
+ const u = encodeURIComponent(session.username);
200
+ const item = (icon, label, href, active = false) => href
201
+ ? _jsxs("a", { className: active ? 'nav-item active' : 'nav-item', href: href, children: [icon, _jsx("span", { children: label })] })
202
+ : _jsxs("span", { className: "nav-item", children: [icon, _jsx("span", { children: label })] });
203
+ return (_jsxs("nav", { className: "sidebar", children: [_jsx("a", { className: "sidebar-logo", href: "#/", "aria-label": "Instagram", children: _jsx(Wordmark, { size: 26 }) }), item(_jsx(IconHome, {}), 'Home', '#/'), item(_jsx(IconSearch, {}), 'Search'), item(_jsx(IconExplore, {}), 'Explore'), item(_jsx(IconReels, {}), 'Reels', '#/reels/', route.page === 'reel'), item(_jsx(IconMessages, {}), 'Messages'), item(_jsx(IconHeart, {}), 'Notifications'), item(_jsx(IconCreate, {}), 'Create'), item(_jsx(Avatar, { src: avatarUrl, size: 24 }), 'Profile', `#/${u}/`, route.page === 'profile'), _jsxs("button", { className: "nav-item nav-more", onClick: onSignOut, title: `Log out ${session.username}`, children: [_jsx(IconMenu, {}), _jsx("span", { children: "Log out" })] })] }));
204
+ }
205
+ // ── log in: instagram.com's login, the password being the twin-issued token ─────────────────────
206
+ export function LoginScreen({ onSignedIn }) {
207
+ const [username, setUsername] = useState('');
208
+ const [token, setToken] = useState('');
209
+ const [error, setError] = useState(null);
210
+ const [busy, setBusy] = useState(false);
211
+ const clean = username.trim().replace(/^@/, '');
212
+ const logIn = async () => {
213
+ setBusy(true);
214
+ const me = await wire(token.trim(), `/${MIRROR_VERSION}/me?fields=id,username`);
215
+ setBusy(false);
216
+ if (me.status !== 200) {
217
+ setError(me.body.error?.code === 190 || me.body.error?.code === 104 ? 'Sorry, your access token was incorrect. Please double-check your token.' : problemText(me));
218
+ return;
219
+ }
220
+ // Is this token THIS account's? /me answers the token's own account; a token for another is refused here.
221
+ if (String(me.body.username ?? '') !== clean) {
222
+ setError(`That token doesn't log in as ${clean}.`);
223
+ return;
224
+ }
225
+ onSignedIn({ username: clean, token: token.trim() });
226
+ };
227
+ return (_jsxs("div", { className: "login-page", children: [_jsxs("div", { className: "login-card", children: [_jsx("div", { className: "login-logo", children: _jsx(Wordmark, { size: 42 }) }), _jsxs("form", { onSubmit: (e) => { e.preventDefault(); void logIn(); }, children: [_jsxs("label", { className: "field", children: [_jsx("input", { name: "username", placeholder: " ", value: username, onChange: (e) => setUsername(e.target.value), autoComplete: "username", autoFocus: true }), _jsx("span", { children: "Phone number, username, or email" })] }), _jsxs("label", { className: "field", children: [_jsx("input", { name: "password", type: "password", placeholder: " ", value: token, onChange: (e) => setToken(e.target.value), autoComplete: "current-password" }), _jsx("span", { children: "Access token" })] }), _jsx("button", { className: "btn-login", type: "submit", disabled: clean === '' || token.trim().length < 8 || busy, children: "Log in" })] }), _jsx("div", { className: "login-or", children: _jsx("span", { children: "OR" }) }), error ? _jsx("p", { className: "login-error", role: "alert", children: error }) : null, _jsxs("p", { className: "login-note", children: ["This twin logs you in with the access token it issued for your account (", _jsx("code", { children: "/_twin/tokens" }), ") in place of a password."] })] }), _jsxs("div", { className: "login-card login-signup", children: ["Don't have an account? ", _jsx("span", { className: "ig-blue", children: "Sign up" })] })] }));
228
+ }
229
+ function App() {
230
+ const [session, setSession] = useState(readSession);
231
+ const [route, setRoute] = useState(() => parseRoute(typeof location === 'undefined' ? '' : location.hash));
232
+ useEffect(() => {
233
+ const onHash = () => { setRoute(parseRoute(location.hash)); window.scrollTo(0, 0); };
234
+ window.addEventListener('hashchange', onHash);
235
+ return () => window.removeEventListener('hashchange', onHash);
236
+ }, []);
237
+ const signIn = (s) => {
238
+ sessionStorage.setItem(SESSION_USER, s.username);
239
+ sessionStorage.setItem(SESSION_TOKEN, s.token);
240
+ setSession(s);
241
+ };
242
+ const signOut = () => {
243
+ sessionStorage.removeItem(SESSION_USER);
244
+ sessionStorage.removeItem(SESSION_TOKEN);
245
+ setSession(null);
246
+ };
247
+ if (!session)
248
+ return _jsx(LoginScreen, { onSignedIn: signIn });
249
+ return _jsx(SignedIn, { session: session, route: route, onSignOut: signOut });
250
+ }
251
+ function SignedIn({ session, route, onSignOut }) {
252
+ const account = useAccount(session);
253
+ return (_jsxs("div", { className: route.page === 'reel' ? 'app app-dark' : 'app', children: [_jsx(Sidebar, { session: session, avatarUrl: account.profile?.avatarUrl, route: route, onSignOut: onSignOut }), _jsx("main", { className: "main", children: route.page === 'reel' ? _jsx(ReelScreen, { account: account, shortcode: route.shortcode }) : _jsx(ProfileScreen, { account: account, route: route }) })] }));
254
+ }
255
+ const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
256
+ if (el)
257
+ createRoot(el).render(_jsx(App, {}));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ import { hasFlag, optionValue } from '@volter/world-core/args';
4
+ import { createInstagramTwinServer } from "./instagram-server.js";
5
+ import { createInstagramMirrorServer } from "./instagram-mirror-ui.js";
6
+ const [cmd, ...rest] = process.argv.slice(2);
7
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
8
+ const root = optionValue(rest, '--root') || undefined;
9
+ const readOnly = hasFlag(rest, '--read-only');
10
+ if (cmd === 'serve') {
11
+ const server = await createInstagramTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
12
+ process.stdout.write(`instagram twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${server.port}\n`);
13
+ await keepProcessAlive();
14
+ }
15
+ else if (cmd === 'mirror') {
16
+ const s = await createInstagramMirrorServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
17
+ process.stdout.write(`instagram mirror UI (instagram.com: log in, profile, Reels grid, Reel viewer) at ${s.url}\n`);
18
+ await keepProcessAlive();
19
+ }
20
+ else if (cmd === 'conformance') {
21
+ // Lazy — the conformance module is DEV-ONLY and must not be reachable from the runtime entrypoints.
22
+ const { checkInstagramConformance } = await import("./instagram-conformance.js");
23
+ const report = await checkInstagramConformance({ ...(root ? { root } : {}) });
24
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
25
+ if (!report.ok)
26
+ process.exitCode = 1;
27
+ }
28
+ else {
29
+ process.stdout.write('Usage: world-instagram serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
30
+ }
@@ -0,0 +1,10 @@
1
+ export { GRAPH_VERSIONS, handleInstagramTwinRequest, igTimestamp, LATEST_VERSION, projectMedia, PUBLISH_QUOTA_TOTAL, reelVideoUrl, shortcodeFor, tokenDigest, usableVersions } from './instagram-twin.js';
2
+ export type { InstagramRequest } from './instagram-twin.js';
3
+ export type { InstagramResponse } from './instagram-errors.js';
4
+ export { createInstagramTwinFetch, createInstagramTwinServer, RANGE_CAP, type InstagramTwinFetchOptions } from './instagram-server.js';
5
+ export { budgetedInstagramExecute, containerParamsForVendor, INSTAGRAM_VERSION, InstagramCallError, MAX_STATUS_WAIT_MS, performInstagramAction, performInstagramActionWithin, STATUS_POLL_MS, syncInstagramFromReal, syncInstagramFromRemote, UPLOAD_CHUNK_BYTES, uploadReel, uploadTarget, waitForContainer, } from './instagram-connector.js';
6
+ export { buildInstagramMirrorClient, captionSegments, compactCount, createInstagramMirrorServer, instagramMirrorHtml, instagramMirrorStyles, MIRROR_VERSION, profileFromRead, reelRoute, tabReels, timeAgo, } from './instagram-mirror-ui.js';
7
+ export type { IgRow, Segment } from './instagram-mirror-ui.js';
8
+ export { INSTAGRAM_BUDGET_BURST_CEILING, INSTAGRAM_BUDGET_CEILING, INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S, INSTAGRAM_BUDGET_WINDOW_MS, INSTAGRAM_CALL_WEIGHTS, INSTAGRAM_RATE_BUDGET, InstagramBudget, InstagramBudgetError, instagramBudgetPath, instagramCallWeight, } from './instagram-budget.js';
9
+ import { type TwinPack } from '@volter/world-core';
10
+ export declare const pack: TwinPack;
@@ -0,0 +1,71 @@
1
+ export { GRAPH_VERSIONS, handleInstagramTwinRequest, igTimestamp, LATEST_VERSION, projectMedia, PUBLISH_QUOTA_TOTAL, reelVideoUrl, shortcodeFor, tokenDigest, usableVersions } from "./instagram-twin.js";
2
+ export { createInstagramTwinFetch, createInstagramTwinServer, RANGE_CAP } from "./instagram-server.js";
3
+ export { budgetedInstagramExecute, containerParamsForVendor, INSTAGRAM_VERSION, InstagramCallError, MAX_STATUS_WAIT_MS, performInstagramAction, performInstagramActionWithin, STATUS_POLL_MS, syncInstagramFromReal, syncInstagramFromRemote, UPLOAD_CHUNK_BYTES, uploadReel, uploadTarget, waitForContainer, } from "./instagram-connector.js";
4
+ export { buildInstagramMirrorClient, captionSegments, compactCount, createInstagramMirrorServer, instagramMirrorHtml, instagramMirrorStyles, MIRROR_VERSION, profileFromRead, reelRoute, tabReels, timeAgo, } from "./instagram-mirror-ui.js";
5
+ // The client-side rate budget the perform and refresh adapters charge (instagram-budget.ts says how the
6
+ // numbers were chosen). The mechanism is the kernel's; these are this vendor's numbers.
7
+ export { INSTAGRAM_BUDGET_BURST_CEILING, INSTAGRAM_BUDGET_CEILING, INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S, INSTAGRAM_BUDGET_WINDOW_MS, INSTAGRAM_CALL_WEIGHTS, INSTAGRAM_RATE_BUDGET, InstagramBudget, InstagramBudgetError, instagramBudgetPath, instagramCallWeight, } from "./instagram-budget.js";
8
+ import { registerPack } from '@volter/world-core';
9
+ import { performInstagramAction, syncInstagramFromRemote } from "./instagram-connector.js";
10
+ import { INSTAGRAM_RATE_BUDGET as RATE_BUDGET } from "./instagram-budget.js";
11
+ /**
12
+ * The Graph paths THIS pack serves, as a RegExp SOURCE for the descriptor's `hosts` path rule: an
13
+ * optional `/vNN.N` version, then `/me` or a numeric node (an IG User, an IG Media or an IG Container)
14
+ * with at most one of the edges modelled here — and the twin-only control prefix. ANCHORED, so
15
+ * `/{id}/insights` or `/{page-id}/feed` stays unclaimed and refuses loudly at the real vendor. A bare
16
+ * numeric node is claimed whatever it names (the path cannot tell an IG id from a Page's); a node the
17
+ * twin does not hold answers Meta's own "Unsupported get request".
18
+ */
19
+ const GRAPH_PATHS = '^(?:/v\\d+\\.\\d+)?/(?:me|\\d{1,25})(?:/(?:media|media_publish|content_publishing_limit))?/?$|^/_twin/';
20
+ export const pack = {
21
+ vendor: 'instagram',
22
+ // The SAME object instagram-budget.ts declares at module load — one source of truth.
23
+ rateBudget: RATE_BUDGET,
24
+ transport: 'rest',
25
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a
26
+ // plugin — its wire, its tree, and its half of the real state system. Born on it.
27
+ protocol: '2',
28
+ archetype: 'crud',
29
+ bin: 'world-instagram',
30
+ resources: ['ig_user', 'media'],
31
+ specSource: 'Instagram Platform docs (developers.facebook.com/docs/instagram-platform, Graph API v26.0), read 2026-09-27: Content Publishing (incl. Resumable Upload Session and Reels), the IG User, IG User Media, IG Container, IG User Media Publish, IG Media and IG User Content Publishing Limit references, Error Codes; and the Graph API guides for Versioning, Handle Errors and Resumable Upload. Meta publishes no machine-readable spec for this surface.',
32
+ description: "Instagram twin — a professional account's Reels through the Graph API's content publishing flow (a resumable container, the rupload.facebook.com upload, the status_code lifecycle, media_publish), the account and media nodes, the publishing limit, and an instagram.com profile mirror with a Reels grid and a Reel viewer; everything else refuses by name.",
33
+ // An account's Reels barely move and Meta's limits are daily: hourly, and on demand.
34
+ refresh: { every: '1h', webhook: false, onDemand: { atMost: '60s' } },
35
+ stateSystem: { perform: performInstagramAction, refresh: syncInstagramFromRemote },
36
+ // The round trip: the professional account and the token the Facebook Login leg would have minted
37
+ // (the vendor has no create-from-nothing for either), then a Reels container on that account — staging
38
+ // the twin holds outside the log, as Meta holds it outside the account's media.
39
+ roundTrip: [
40
+ { method: 'POST', path: '/_twin/users', body: { id: '17841400000000001', username: 'round.trip', name: 'Round Trip', followers_count: 7 } },
41
+ { method: 'POST', path: '/_twin/tokens', body: { token: 'round-trip-token', user: '17841400000000001', permissions: ['instagram_basic', 'instagram_content_publish', 'pages_read_engagement'] } },
42
+ {
43
+ method: 'POST', path: '/v26.0/17841400000000001/media',
44
+ headers: { authorization: 'Bearer round-trip-token' },
45
+ body: { media_type: 'REELS', upload_type: 'resumable', caption: 'round trip' },
46
+ },
47
+ ],
48
+ parityOrigin: 'http://twin',
49
+ // the write handler and the refresh adapter store the same account shape (scripts/shape-parity.test.ts)
50
+ shapeParity: 'held',
51
+ // ADOPTION. Meta publishes no official JavaScript client for the Instagram Platform (the
52
+ // facebook-nodejs-business-sdk is the Marketing API's, and claiming it would attribute ads traffic
53
+ // to this twin). INSTAGRAM is the stem of INSTAGRAM_ACCESS_TOKEN / INSTAGRAM_USER_ID et al.
54
+ adoption: {
55
+ envStems: ['INSTAGRAM'],
56
+ },
57
+ // INTERCEPTION. graph.facebook.com (Facebook Login — the host the resumable upload is documented on)
58
+ // and graph.instagram.com (Instagram Login), both path-scoped to the nodes and edges above;
59
+ // rupload.facebook.com only for `/ig-api-upload/` — the upload host, which takes the SAME token, so
60
+ // the kernel executor sends the sealed credential there only because it is declared here as an exact
61
+ // host with this path; scontent.cdninstagram.com only for the CDN paths the twin mints for a Reel,
62
+ // its stand-in thumbnail and a profile picture.
63
+ hosts: [
64
+ { host: 'graph.facebook.com', pathPattern: GRAPH_PATHS },
65
+ { host: 'graph.instagram.com', pathPattern: GRAPH_PATHS },
66
+ { host: 'rupload.facebook.com', pathPattern: '^/ig-api-upload/' },
67
+ { host: 'scontent.cdninstagram.com', pathPattern: '^/(?:o1/v/t16/f2/m86/|v/t51\\.(?:71878-15|2885-19)/)' },
68
+ ],
69
+ endpointEnvNone: 'Meta ships no Instagram Platform client that reads a base-URL environment variable: apps call https://graph.facebook.com/<version>/… and the rupload host by constant, so a World reaches them through the hosts above; inventing an INSTAGRAM_BASE_URL the app never reads would report coverage the app does not have.',
70
+ };
71
+ registerPack(pack);
@@ -0,0 +1,44 @@
1
+ import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
2
+ /** Rolling window, in ms — the longest the kernel's ledger keeps (Meta's windows are 24 hours). */
3
+ export declare const INSTAGRAM_BUDGET_WINDOW_MS: number;
4
+ /** Weighted units per window: the daily publish and container limits spread over the day, rounded down. */
5
+ export declare const INSTAGRAM_BUDGET_CEILING = 250;
6
+ /** Weighted units in any 60 s: 30 calls at the default weight (the fallback's burst), one whole Reel perform. */
7
+ export declare const INSTAGRAM_BUDGET_BURST_CEILING = 180;
8
+ /** Seconds. A Retry-After above this means the account or app is throttled for the day — fail loudly. */
9
+ export declare const INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S = 3600;
10
+ export declare const INSTAGRAM_CALL_WEIGHTS: {
11
+ /** media_publish — the account speaking in public, 50 of them a day. */
12
+ readonly publish: 100;
13
+ /** A container, 400 of them a day. */
14
+ readonly container: 16;
15
+ /** A delete. */
16
+ readonly delete: 8;
17
+ /** One rupload POST (an 8 MiB chunk at most). */
18
+ readonly upload: 1;
19
+ /** A read: a container's status, a node, the media list, the publishing limit. */
20
+ readonly read: 1;
21
+ /** Anything unnamed. */
22
+ readonly other: 6;
23
+ };
24
+ /** THE PACK'S DECLARATION — pure data, the only Instagram-specific thing in the whole budget. */
25
+ export declare const INSTAGRAM_RATE_BUDGET: RateBudgetDeclaration;
26
+ /**
27
+ * Price one call. `path` is the Graph path (`/v26.0/178…/media`) or, for the upload host, the absolute
28
+ * rupload URL — keyed `"<METHOD> <path>"` with the query split off (and the upload host kept in front
29
+ * of its path, so a twin's anchored `/ig-api-upload/…` and Meta's host price the same).
30
+ */
31
+ export declare function instagramCallWeight(method: string, path: string): number;
32
+ /** Where this vendor's ledger lives: token-keyed and cwd-independent by default. */
33
+ export declare function instagramBudgetPath(opts?: {
34
+ root?: string;
35
+ token?: string;
36
+ } | string): string;
37
+ export type InstagramBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
38
+ /** This vendor's budget — the kernel guard bound to this declaration. */
39
+ export declare class InstagramBudget extends RateBudget {
40
+ constructor(opts?: InstagramBudgetOptions);
41
+ }
42
+ export { RateBudgetError as InstagramBudgetError } from '@volter/world-core';
43
+ export type InstagramBudgetReservation = RateBudgetReservation;
44
+ export type InstagramBudgetSnapshot = RateBudgetSnapshot;
@@ -0,0 +1,112 @@
1
+ // Instagram's CLIENT-SIDE RATE BUDGET — this pack's DECLARATION (the numbers) plus the thin typed
2
+ // bindings the perform and refresh adapters use. The MECHANISM — the durable token-keyed ledger, the
3
+ // rolling window, reserve-under-lock, the Retry-After / 429 cooldown, fail-CLOSED on a corrupt ledger
4
+ // — lives ONCE in the kernel (`@volter/world-core` → rateBudget.ts).
5
+ //
6
+ // ── WHAT META PUBLISHES (developers.facebook.com/docs/instagram-platform, fetched 2026-09-27) ──
7
+ // • Content Publishing guide, "Rate Limit": "Instagram accounts are limited to 100 API-published
8
+ // posts within a 24-hour moving period … enforced on the POST /<IG_ID>/media_publish endpoint".
9
+ // • IG User Media Publish reference: "An Instagram professional account can only publish 50 posts
10
+ // within a 24 hour moving period"; the Content Publishing Limit reference reports quota_total
11
+ // "(currently 50)" over quota_duration 86400. THE STRICTER, 50, IS THE FIGURE THIS BUDGET KEEPS.
12
+ // • IG User Media reference: "An Instagram account can only create 400 containers within a rolling
13
+ // 24 hour period".
14
+ // • The general Graph API call allowance is a Business Use Case formula over the account's
15
+ // impressions — no scalar a client can transcribe.
16
+ //
17
+ // ── HOW THE DECLARATION HOLDS THOSE DAILY FIGURES ──────────────────────────────────────────
18
+ // The kernel's longest window is ONE HOUR, so a daily limit is spread over the day and rounded DOWN
19
+ // (the YouTube pack's method): 250 weighted units per rolling hour, with
20
+ // a publish (POST /{id}/media_publish) 100 units → at most 2 an hour = 48 a day ≤ 50
21
+ // a container (POST /{id}/media) 16 units → at most 15 an hour = 360 a day ≤ 400
22
+ // an upload POST to rupload (8 MiB chunk) 1 unit (a 300 MB Reel is 36 chunks)
23
+ // a delete 8 units
24
+ // a read (status, node, list, limit) 1 unit
25
+ // anything else 6 units
26
+ // A client that spends this flat out for a whole day still lands under both documented limits.
27
+ //
28
+ // ── THE BURST SUB-CEILING ────────────────────────────────────────────────────────────────────
29
+ // 180 units in any 60 s, SET BY THE PERFORM: one whole perform of the largest Reel inside a minute
30
+ // (container 16 + 36 upload chunks + three status reads + publish 100 = 155) with room to spare. A burst
31
+ // below that would refuse the publish of a Reel Meta finished quickly; the guard would be broken, not
32
+ // tighter. The default weight (6, for a call no rule prices — the connector makes none) is then chosen so
33
+ // the burst is 30 calls at it, the kernel fallback's own, so no burst anchor is claimed.
34
+ import { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
35
+ const VENDOR = 'instagram';
36
+ /** Rolling window, in ms — the longest the kernel's ledger keeps (Meta's windows are 24 hours). */
37
+ export const INSTAGRAM_BUDGET_WINDOW_MS = 60 * 60_000;
38
+ /** Weighted units per window: the daily publish and container limits spread over the day, rounded down. */
39
+ export const INSTAGRAM_BUDGET_CEILING = 250;
40
+ /** Weighted units in any 60 s: 30 calls at the default weight (the fallback's burst), one whole Reel perform. */
41
+ export const INSTAGRAM_BUDGET_BURST_CEILING = 180;
42
+ /** Seconds. A Retry-After above this means the account or app is throttled for the day — fail loudly. */
43
+ export const INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S = 3600;
44
+ export const INSTAGRAM_CALL_WEIGHTS = {
45
+ /** media_publish — the account speaking in public, 50 of them a day. */
46
+ publish: 100,
47
+ /** A container, 400 of them a day. */
48
+ container: 16,
49
+ /** A delete. */
50
+ delete: 8,
51
+ /** One rupload POST (an 8 MiB chunk at most). */
52
+ upload: 1,
53
+ /** A read: a container's status, a node, the media list, the publishing limit. */
54
+ read: 1,
55
+ /** Anything unnamed. */
56
+ other: 6,
57
+ };
58
+ /** THE PACK'S DECLARATION — pure data, the only Instagram-specific thing in the whole budget. */
59
+ export const INSTAGRAM_RATE_BUDGET = {
60
+ windowMs: INSTAGRAM_BUDGET_WINDOW_MS,
61
+ ceiling: INSTAGRAM_BUDGET_CEILING,
62
+ burstCeiling: INSTAGRAM_BUDGET_BURST_CEILING,
63
+ defaultWeight: INSTAGRAM_CALL_WEIGHTS.other,
64
+ maxRetryAfterSeconds: INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S,
65
+ rules: [
66
+ { match: '^POST (/v\\d+\\.\\d+)?/\\d+/media_publish$', weight: INSTAGRAM_CALL_WEIGHTS.publish },
67
+ { match: '^POST (/v\\d+\\.\\d+)?/\\d+/media$', weight: INSTAGRAM_CALL_WEIGHTS.container },
68
+ { match: '^DELETE (/v\\d+\\.\\d+)?/\\d+$', weight: INSTAGRAM_CALL_WEIGHTS.delete },
69
+ { match: '^POST (rupload\\.facebook\\.com)?/ig-api-upload/', weight: INSTAGRAM_CALL_WEIGHTS.upload },
70
+ { match: '^GET ', weight: INSTAGRAM_CALL_WEIGHTS.read },
71
+ ],
72
+ reason: 'Meta limits an Instagram professional account to 50 API-published posts in a 24-hour moving period (IG User Media '
73
+ + 'Publish and Content Publishing Limit references, "currently 50"; the Content Publishing guide says 100 — the '
74
+ + 'stricter 50 is kept) and to 400 containers in a rolling 24 hours (IG User Media reference), '
75
+ + 'developers.facebook.com/docs/instagram-platform, fetched 2026-09-27. The general call allowance is a Business Use '
76
+ + 'Case formula with no scalar. The daily figures are spread over the kernel\'s one-hour window and rounded down: '
77
+ + '250 units an hour, a publish 100 (2 an hour, 48 a day), a container 16 (15 an hour, 360 a day), a rupload POST '
78
+ + '(an 8 MiB chunk) 1, a delete 8, a read 1, anything else 6; a 180-unit burst per minute (30 calls at the default '
79
+ + "weight, the fallback's own) admits one whole perform of the largest Reel (155 units).",
80
+ };
81
+ declareRateBudget(VENDOR, INSTAGRAM_RATE_BUDGET);
82
+ /**
83
+ * Price one call. `path` is the Graph path (`/v26.0/178…/media`) or, for the upload host, the absolute
84
+ * rupload URL — keyed `"<METHOD> <path>"` with the query split off (and the upload host kept in front
85
+ * of its path, so a twin's anchored `/ig-api-upload/…` and Meta's host price the same).
86
+ */
87
+ export function instagramCallWeight(method, path) {
88
+ let raw = path;
89
+ if (/^https?:\/\//i.test(raw)) {
90
+ const u = new URL(raw);
91
+ raw = `${u.hostname === 'rupload.facebook.com' ? u.hostname : ''}${u.pathname}${u.search}`;
92
+ }
93
+ const at = raw.indexOf('?');
94
+ const query = {};
95
+ if (at !== -1)
96
+ for (const [k, v] of new URLSearchParams(raw.slice(at + 1)))
97
+ query[k] = v;
98
+ const bare = (at === -1 ? raw : raw.slice(0, at)).replace(/(.)\/+$/, '$1');
99
+ return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
100
+ }
101
+ /** Where this vendor's ledger lives: token-keyed and cwd-independent by default. */
102
+ export function instagramBudgetPath(opts = {}) {
103
+ const o = typeof opts === 'string' ? { root: opts } : opts;
104
+ return rateBudgetPath({ ...o, vendor: VENDOR });
105
+ }
106
+ /** This vendor's budget — the kernel guard bound to this declaration. */
107
+ export class InstagramBudget extends RateBudget {
108
+ constructor(opts = {}) {
109
+ super({ ...opts, vendor: VENDOR });
110
+ }
111
+ }
112
+ export { RateBudgetError as InstagramBudgetError } from '@volter/world-core';
@@ -0,0 +1,4 @@
1
+ import { type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
2
+ export declare const INSTAGRAM_AREAS: readonly ["versioning", "auth", "users", "containers", "upload", "publish", "media", "limits", "publishing", "comments", "insights", "discovery", "webhooks", "login", "errors", "twin_control", "connector", "rate_limit", "mirror"];
3
+ export declare const INSTAGRAM_CAPABILITIES: CapabilitySpec[];
4
+ export declare function instagramCapabilities(): Promise<CapabilityReport>;