@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,884 @@
1
+ // The Instagram Platform surface a professional account PUBLISHES REELS through, twinned: the IG
2
+ // User node, its media edge (list; create a Reels container for a resumable upload), the container's
3
+ // status_code lifecycle, media_publish, the IG Media node, content_publishing_limit, and the
4
+ // rupload.facebook.com upload the container's `uri` names — offline, against local state.
5
+ //
6
+ // SCOPE. What this pack exists for: a company posts release videos to its Instagram professional
7
+ // account as Reels, and each Reel must be previewable as instagram.com shows it before a gated deploy
8
+ // sends it. So the write half is a Reel (a resumable upload, caption, share_to_feed, a cover or a
9
+ // thumbnail offset) and its deletion; the read half is everything a profile (and the mirror,
10
+ // instagram-mirror-ui.ts) needs to show it. Images, carousels, stories, video_url uploads, tags,
11
+ // collaborators, locations, comments, insights, hashtag search and business discovery are real
12
+ // Instagram this pack does not model: each has a manifest todo, and a request that reaches one is
13
+ // refused by name (instagram-errors.ts `unmodeled`).
14
+ //
15
+ // GROUNDING. developers.facebook.com/docs/instagram-platform, read 2026-09-27: Content Publishing,
16
+ // the IG User, IG User Media, IG Container, IG User Media Publish, IG Media and IG User Content
17
+ // Publishing Limit references, Error Codes; and the Graph API guides for Versioning, Handle Errors
18
+ // and Resumable Upload. Shapes below quote those pages' samples.
19
+ //
20
+ // VERSIONED. A path may start with `/vNN.0/`. The version table (Graph API changelog) lists v21.0 …
21
+ // v26.0 as usable on 2026-09-27 (v20.0 expired 2026-09-24); "once a version is no longer usable, any
22
+ // calls made to it will be defaulted to the next oldest, usable version", so an older one is served
23
+ // as v21.0, and an unversioned call is served too. A version above v26.0 does not exist yet.
24
+ //
25
+ // AUTH. A token rides `access_token` (query or body) or `Authorization: Bearer|OAuth <token>`. Tokens
26
+ // are seeded through `/_twin/tokens` and held ONLY as their SHA-256 under the `_token` bookkeeping
27
+ // type (never deployed, never in the clear in the log). The rupload host takes the SAME token as
28
+ // `Authorization: OAuth <token>`.
29
+ //
30
+ // STATE. Subjects are stored in Instagram's own response shape (architecture "shape parity"): a
31
+ // media IS its `GET /{ig-media-id}` fields, keyed by its id, with bookkeeping under `_` fields that
32
+ // never reach the wire. Containers are staging, not kernel state (instagram-media.ts says why).
33
+ //
34
+ // DETERMINISM. Every served READ is a function of (request, stored state), and a media id is a digest
35
+ // of its write. Times are the write's `occurredAt`; a container's status is a fold of its stored
36
+ // instants and the request's (PROCESSING_MS, CONTAINER_LIFETIME_MS), never a timer and never a read
37
+ // that writes. The one exception is a container's id, drawn from entropy (as LinkedIn's asset ids): a
38
+ // container is staging a branch inherits from its ancestors' annex, where an ordinal or a digest could
39
+ // collide between a parent and a child; the determinism replay captures it and never compares it.
40
+ import { applyTwinWrite, applyTwinWriteAtomic, blobDigest, ownFields, projectResources, resolveSubjectId, subjectAliases } from '@volter/world-core';
41
+ import { graphError, invalidParam, invalidToken, missingParam, noToken, ok, permissionDenied, readOnlyRefusal, ruploadFailure, ruploadOk, unknownPath, unmodeled, unsupported, } from "./instagram-errors.js";
42
+ import { clearChunks, CONTAINER_LIFETIME_MS, containerStatus, isNumericId, listContainers, MAX_REEL_BYTES, putChunk, putInstagramBlob, readChunks, readContainer, reelSpecViolation, sniffImage, sniffVideo, withContainer, writeContainer, } from "./instagram-media.js";
43
+ const SERVICE = 'instagram';
44
+ /** The newest Graph API version (the references' "The latest version is: v26.0"). */
45
+ export const LATEST_VERSION = 'v26.0';
46
+ /** The Graph API changelog's version table (read 2026-09-27): each version's release and expiry. A
47
+ * version is usable until its expiry, judged at the request's (World) instant — its release date is
48
+ * not held against a World whose clock is set back (an app's code names today's versions). */
49
+ export const GRAPH_VERSIONS = [
50
+ { major: 19, released: '2024-01-23', expires: '2026-05-21' },
51
+ { major: 20, released: '2024-05-21', expires: '2026-09-24' },
52
+ { major: 21, released: '2024-10-02', expires: '2027-01-21' },
53
+ { major: 22, released: '2025-01-21', expires: '2027-05-20' },
54
+ { major: 23, released: '2025-05-29', expires: '2027-10-08' },
55
+ { major: 24, released: '2025-10-08', expires: '2028-02-18' },
56
+ { major: 25, released: '2026-02-18', expires: '2028-07-29' },
57
+ { major: 26, released: '2026-07-29', expires: null },
58
+ ];
59
+ /** The versions usable at `nowMs`, oldest first. */
60
+ export function usableVersions(nowMs) {
61
+ return GRAPH_VERSIONS.filter((v) => v.expires === null || nowMs < Date.parse(v.expires)).map((v) => v.major);
62
+ }
63
+ /** Meta's own hosts, minted when no served base is known. */
64
+ const RUPLOAD_HOST = 'https://rupload.facebook.com';
65
+ const CDN_HOST = 'https://scontent.cdninstagram.com';
66
+ const GRAPH_HOST = 'https://graph.facebook.com';
67
+ /** Content Publishing guide: "100 API-published posts within a 24-hour moving period"; the IG User
68
+ * Media Publish and Content Publishing Limit references: 50, "quota_total … (currently 50)". The twin
69
+ * serves and enforces the references' 50 — the stricter figure, and the one the endpoint reports. */
70
+ export const PUBLISH_QUOTA_TOTAL = 50;
71
+ export const PUBLISH_QUOTA_DURATION_S = 86_400;
72
+ /** IG User Media: "An Instagram account can only create 400 containers within a rolling 24 hour period". */
73
+ export const CONTAINER_QUOTA = 400;
74
+ /** IG User Media, caption: "Maximum 2200 characters, 30 hashtags, and 20 @ tags." */
75
+ export const MAX_CAPTION_CHARS = 2200;
76
+ export const MAX_HASHTAGS = 30;
77
+ export const MAX_MENTIONS = 20;
78
+ /** Media ids the twin mints: 18 digits starting 9 — above every real IG media id (17-18 digits
79
+ * starting 17/18 in 2026), so a pulled id and a minted one cannot meet. */
80
+ const MEDIA_ID_FLOOR = 900000000000000000n;
81
+ /** Container ids: 18 digits starting 8, drawn from entropy (containers are staging, never replayed). */
82
+ const CONTAINER_ID_FLOOR = 800000000000000000n;
83
+ // ── the documented closed sets ──────────────────────────────────────────────────────────────
84
+ /** Permissions a seeded token may carry — the ones these pages name (Facebook Login and Instagram Login). */
85
+ const PERMISSIONS = new Set([
86
+ 'instagram_basic', 'instagram_content_publish', 'pages_read_engagement', 'pages_show_list', 'ads_management', 'ads_read',
87
+ 'business_management', 'instagram_manage_comments', 'instagram_manage_contents', 'instagram_business_basic',
88
+ 'instagram_business_content_publish', 'instagram_business_manage_comments',
89
+ ]);
90
+ const IG_LOGIN_READ = ['instagram_business_basic'];
91
+ const IG_LOGIN_PUBLISH = ['instagram_business_basic', 'instagram_business_content_publish'];
92
+ /** IG User: instagram_basic, pages_read_engagement. */
93
+ const READ_USER = [['instagram_basic', 'pages_read_engagement'], IG_LOGIN_READ];
94
+ /** IG User Media, Reading: instagram_basic and pages_read_engagement or pages_show_list. */
95
+ const LIST_MEDIA = [['instagram_basic', 'pages_read_engagement'], ['instagram_basic', 'pages_show_list'], IG_LOGIN_READ];
96
+ /** IG Media, Reading: instagram_basic, pages_read_engagement. */
97
+ const READ_MEDIA = [['instagram_basic', 'pages_read_engagement'], IG_LOGIN_READ];
98
+ /** IG User Media, Creating; IG Container; Content Publishing Limit: instagram_basic,
99
+ * instagram_content_publish, pages_read_engagement. */
100
+ const CREATE = [['instagram_basic', 'instagram_content_publish', 'pages_read_engagement'], IG_LOGIN_PUBLISH];
101
+ /** IG User Media Publish: instagram_basic, instagram_content_publish. */
102
+ const PUBLISH = [['instagram_basic', 'instagram_content_publish'], IG_LOGIN_PUBLISH];
103
+ /** A resumable upload session and its rupload POSTs: "Only for apps that have implemented Facebook
104
+ * Login for Business" — the Facebook Login family alone. */
105
+ const RESUMABLE = [['instagram_basic', 'instagram_content_publish']];
106
+ /** IG Media, Deleting (Facebook Login only): instagram_basic, instagram_manage_contents. */
107
+ const DELETE = [['instagram_basic', 'instagram_manage_contents']];
108
+ /** IG User fields this twin serves, and the documented ones it does not. */
109
+ const USER_FIELDS = new Set(['id', 'username', 'name', 'biography', 'website', 'followers_count', 'follows_count', 'media_count', 'profile_picture_url', 'has_profile_pic']);
110
+ const USER_FIELDS_UNMODELED = new Set(['alt_text', 'is_published', 'legacy_instagram_user_id', 'shopping_product_tag_eligibility', 'collaborative_media_search', 'business_discovery', 'ig_id']);
111
+ /** IG Media fields this twin serves, and the documented ones it does not. */
112
+ const MEDIA_FIELDS = new Set(['id', 'media_type', 'media_product_type', 'media_url', 'permalink', 'caption', 'timestamp', 'thumbnail_url', 'shortcode', 'username', 'owner', 'is_shared_to_feed', 'is_comment_enabled', 'comments_count', 'like_count', 'is_ai_generated']);
113
+ const MEDIA_FIELDS_UNMODELED = new Set(['alt_text', 'boost_ads_list', 'boost_eligibility_info', 'copyright_check_information', 'legacy_instagram_media_id', 'media_audio_type', 'view_count', 'reposts_count', 'saved_count', 'shares_count', 'total_comments_count', 'total_like_count', 'total_views_count', 'children', 'collaborators', 'comments', 'insights']);
114
+ const CONTAINER_FIELDS = new Set(['id', 'status_code', 'status']);
115
+ /** IG User edges this twin does not model (the IG User reference's edge table). */
116
+ const USER_EDGES_UNMODELED = new Set(['agencies', 'authorized_adaccounts', 'connected_threads_user', 'insights', 'instagram_backed_threads_user', 'live_media', 'mentions', 'mentioned_comment', 'mentioned_media', 'recently_searched_hashtags', 'stories', 'tags', 'upcoming_events', 'collaboration_invites', 'collaborative_media', 'available_catalogs', 'catalog_product_search', 'product_appeal']);
117
+ /** Container parameters this twin does not model on a Reel. */
118
+ const REEL_PARAMS_UNMODELED = ['collaborators', 'user_tags', 'location_id', 'trial_params', 'product_tags', 'branded_content_sponsor_ids', 'is_paid_partnership'];
119
+ // ── helpers ─────────────────────────────────────────────────────────────────────────────────
120
+ function header(headers, name) {
121
+ if (!headers)
122
+ return undefined;
123
+ for (const [k, v] of Object.entries(headers))
124
+ if (k.toLowerCase() === name)
125
+ return v;
126
+ return undefined;
127
+ }
128
+ /** The request's parameters: the query string, then a JSON-object or form-encoded body over it
129
+ * (the Graph API takes either). Values stay as sent — a JSON body may carry booleans and numbers. */
130
+ function params(url, req) {
131
+ const out = {};
132
+ for (const [k, v] of url.searchParams)
133
+ out[k] = v;
134
+ const raw = req.body;
135
+ if (raw === undefined || raw.trim() === '')
136
+ return out;
137
+ const type = (header(req.headers, 'content-type') ?? '').toLowerCase();
138
+ if (type.includes('application/x-www-form-urlencoded') || (!type.includes('json') && !raw.trim().startsWith('{'))) {
139
+ for (const [k, v] of new URLSearchParams(raw))
140
+ out[k] = v;
141
+ return out;
142
+ }
143
+ try {
144
+ const parsed = JSON.parse(raw);
145
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
146
+ return 'malformed';
147
+ return { ...out, ...parsed };
148
+ }
149
+ catch {
150
+ return 'malformed';
151
+ }
152
+ }
153
+ function resources(root) {
154
+ return projectResources(SERVICE, root);
155
+ }
156
+ const ofType = (all, type) => all.filter((r) => r.type === type);
157
+ const fields = (r) => ownFields(r);
158
+ const nowMs = (req) => Date.parse(req.occurredAt ?? new Date().toISOString());
159
+ const at = (req) => req.occurredAt ?? new Date().toISOString();
160
+ /** Meta's timestamp format: `2019-09-26T22:36:43+0000`. */
161
+ export function igTimestamp(iso) {
162
+ return `${new Date(iso).toISOString().slice(0, 19)}+0000`;
163
+ }
164
+ /** A Meta timestamp back to epoch ms. */
165
+ function timestampMs(v) {
166
+ return Date.parse(String(v).replace(/([+-]\d\d)(\d\d)$/, '$1:$2'));
167
+ }
168
+ /** The SHA-256 a token is held under — never the token itself. */
169
+ export function tokenDigest(token) {
170
+ return blobDigest(new TextEncoder().encode(token));
171
+ }
172
+ /** A boolean parameter as the Graph API takes it: a JSON boolean or the strings true/false. */
173
+ function boolParam(v) {
174
+ if (v === undefined)
175
+ return undefined;
176
+ if (v === true || v === 'true')
177
+ return true;
178
+ if (v === false || v === 'false')
179
+ return false;
180
+ return 'invalid';
181
+ }
182
+ const isResponse = (v) => typeof v === 'object' && v !== null && 'status' in v && 'body' in v;
183
+ function presentedToken(req, p, schemes) {
184
+ const auth = header(req.headers, 'authorization');
185
+ const m = auth ? schemes.exec(auth.trim()) : null;
186
+ if (m)
187
+ return m[1];
188
+ return typeof p.access_token === 'string' && p.access_token !== '' ? p.access_token : undefined;
189
+ }
190
+ function authorize(all, token) {
191
+ if (!token)
192
+ return noToken();
193
+ const row = ofType(all, '_token').find((t) => String(t.id) === tokenDigest(token));
194
+ if (!row)
195
+ return invalidToken();
196
+ const f = fields(row);
197
+ return { user: String(f.user), permissions: new Set(Array.isArray(f.permissions) ? f.permissions.map(String) : []) };
198
+ }
199
+ const holds = (auth, families) => families.some((f) => f.every((p) => auth.permissions.has(p)));
200
+ function need(auth, families, what) {
201
+ return holds(auth, families) ? undefined : permissionDenied(`${what} needs ${families.map((f) => f.join(' + ')).join(', or ')}`);
202
+ }
203
+ // ── field selection ──────────────────────────────────────────────────────────────────────────
204
+ /** `fields=a,b,owner{id}` → ['a','b','owner'] (field expansion's braces dropped: owner answers {id}). */
205
+ function parseFields(raw) {
206
+ if (typeof raw !== 'string' || raw.trim() === '')
207
+ return undefined;
208
+ const out = [];
209
+ let depth = 0;
210
+ let cur = '';
211
+ for (const ch of raw) {
212
+ if (ch === '{')
213
+ depth += 1;
214
+ else if (ch === '}')
215
+ depth = Math.max(0, depth - 1);
216
+ else if (ch === ',' && depth === 0) {
217
+ if (cur.trim())
218
+ out.push(cur.trim());
219
+ cur = '';
220
+ continue;
221
+ }
222
+ if (depth === 0 && ch !== '}')
223
+ cur += ch;
224
+ }
225
+ if (cur.trim())
226
+ out.push(cur.trim());
227
+ return out;
228
+ }
229
+ function select(node, full, requested, served, unmodeledSet) {
230
+ const want = requested ?? ['id'];
231
+ for (const f of want) {
232
+ if (unmodeledSet.has(f))
233
+ return unmodeled(`The ${f} field on ${node}`);
234
+ if (!served.has(f))
235
+ return invalidParam(`Tried accessing nonexisting field (${f}) on node type (${node})`);
236
+ }
237
+ const out = {};
238
+ for (const f of want)
239
+ if (full[f] !== undefined)
240
+ out[f] = full[f];
241
+ out.id = full.id;
242
+ return out;
243
+ }
244
+ // ── ids ───────────────────────────────────────────────────────────────────────────────────────
245
+ /** A new media id, 18 digits in 9.0e17 … 9.9e17: a digest of the write (instant, owner, the Reel's
246
+ * digest and caption — never the container's entropy-drawn id)
247
+ * and the media already held, re-drawn while any held id or re-keyed local id matches — so an id a
248
+ * client was once answered is never handed out again, and two identical fresh worlds replaying the
249
+ * same writes serve the same ids (R9). */
250
+ function mintMediaId(all, root, seed) {
251
+ const taken = new Set(ofType(all, 'media').map((m) => String(m.id)));
252
+ for (const key of subjectAliases(SERVICE, root).keys())
253
+ if (key.startsWith('media:'))
254
+ taken.add(key.slice('media:'.length));
255
+ for (let attempt = 0;; attempt += 1) {
256
+ const digest = blobDigest(new TextEncoder().encode(`${seed}|${taken.size}|${attempt}`));
257
+ const id = String(MEDIA_ID_FLOOR + BigInt(`0x${digest.slice(0, 16)}`) % 99000000000000000n);
258
+ if (!taken.has(id))
259
+ return id;
260
+ }
261
+ }
262
+ /** A new container id from entropy, re-drawn while any container this branch or its ancestors hold matches. */
263
+ async function mintContainerId(root) {
264
+ const held = new Set((await listContainers(root)).map((c) => c.id));
265
+ for (;;) {
266
+ const r = crypto.getRandomValues(new Uint32Array(2));
267
+ const id = String(CONTAINER_ID_FLOOR + (BigInt(r[0]) * 4294967296n + BigInt(r[1])) % 99000000000000000n);
268
+ if (!held.has(id))
269
+ return id;
270
+ }
271
+ }
272
+ /** instagram.com's 11-character shortcode for a media id (a digest: stable for the id). */
273
+ export function shortcodeFor(mediaId) {
274
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
275
+ const hex = blobDigest(new TextEncoder().encode(`shortcode|${mediaId}`));
276
+ let out = '';
277
+ for (let i = 0; i < 11; i += 1)
278
+ out += alphabet[parseInt(hex.slice(i * 2, i * 2 + 2), 16) % 64];
279
+ return out;
280
+ }
281
+ // ── projection: THE one place a stored subject becomes a response body ────────────────────────
282
+ const base = (req, host) => (req.publicBase ?? host).replace(/\/+$/, '');
283
+ /** Where a held Reel's bytes are served: instagram's CDN path shape under the twin's base. */
284
+ export function reelVideoUrl(req, sha256) {
285
+ return `${(req.publicBase ?? CDN_HOST).replace(/\/+$/, '')}/o1/v/t16/f2/m86/${sha256}.mp4`;
286
+ }
287
+ function isDeleted(r) {
288
+ return fields(r)._deleted === true || fields(r).deleted === true;
289
+ }
290
+ /** The account's API-published posts at or after `sinceMs`: media published through media_publish here
291
+ * (carrying `_container`) — a deleted one still counts ("the number of times the app user has
292
+ * published an IG Container"); a Reel pulled by a refresh does not, since it may have been posted in
293
+ * the Instagram app and Meta counts API publishes. */
294
+ function apiPublishedSince(all, owner, sinceMs) {
295
+ return ofType(all, 'media').filter((m) => { const f = fields(m); return typeof f._container === 'string' && String(f.owner?.id) === owner && timestampMs(f.timestamp) >= sinceMs; }).length;
296
+ }
297
+ function liveMedia(all, owner) {
298
+ return ofType(all, 'media').filter((m) => !isDeleted(m) && (owner === undefined || String(fields(m).owner?.id) === owner));
299
+ }
300
+ function findUser(all, id) {
301
+ return ofType(all, 'ig_user').find((u) => String(u.id) === id);
302
+ }
303
+ /** A held user as the IG User node answers it (every field it holds). */
304
+ function projectUser(req, all, user) {
305
+ const f = fields(user);
306
+ const pic = f._profile_picture;
307
+ const picUrl = pic ? `${base(req, CDN_HOST)}/v/t51.2885-19/${pic.sha256}.${pic.media_type === 'image/png' ? 'png' : 'jpg'}` : typeof f.profile_picture_url === 'string' ? f.profile_picture_url : undefined;
308
+ return {
309
+ id: String(user.id),
310
+ username: f.username,
311
+ ...(typeof f.name === 'string' ? { name: f.name } : {}),
312
+ ...(typeof f.biography === 'string' ? { biography: f.biography } : {}),
313
+ ...(typeof f.website === 'string' ? { website: f.website } : {}),
314
+ ...(typeof f.followers_count === 'number' ? { followers_count: f.followers_count } : {}),
315
+ ...(typeof f.follows_count === 'number' ? { follows_count: f.follows_count } : {}),
316
+ media_count: liveMedia(all, String(user.id)).length,
317
+ ...(picUrl ? { profile_picture_url: picUrl } : {}),
318
+ has_profile_pic: picUrl !== undefined,
319
+ };
320
+ }
321
+ /** A held media as the IG Media node answers it: its vendor fields, the URLs of the bytes the twin
322
+ * holds, no `_` bookkeeping. */
323
+ export function projectMedia(req, all, media) {
324
+ const f = fields(media);
325
+ const out = {};
326
+ for (const [k, v] of Object.entries(f))
327
+ if (!k.startsWith('_'))
328
+ out[k] = v;
329
+ const video = f._video;
330
+ if (video) {
331
+ out.media_url = reelVideoUrl(req, video.sha256);
332
+ out.thumbnail_url = typeof f._cover_url === 'string' ? f._cover_url : `${(req.publicBase ?? CDN_HOST).replace(/\/+$/, '')}/v/t51.71878-15/${video.sha256}_poster.svg`;
333
+ }
334
+ const owner = findUser(all, String(f.owner?.id ?? ''));
335
+ if (owner)
336
+ out.username = fields(owner).username;
337
+ out.id = String(media.id);
338
+ return out;
339
+ }
340
+ function findMedia(all, id, root) {
341
+ const resolved = resolveSubjectId(SERVICE, 'media', id, root);
342
+ const m = ofType(all, 'media').find((r) => String(r.id) === resolved);
343
+ return m && !isDeleted(m) ? m : undefined;
344
+ }
345
+ /** Whether a published media names this container (the PUBLISHED status_code). */
346
+ function publishedFrom(all, containerId) {
347
+ return ofType(all, 'media').some((m) => String(fields(m)._container ?? '') === containerId);
348
+ }
349
+ // ── the IG User node and its edges ────────────────────────────────────────────────────────────
350
+ function getUser(req, all, auth, user, p) {
351
+ const refusal = need(auth, READ_USER, 'Reading an IG User');
352
+ if (refusal)
353
+ return refusal;
354
+ if (String(user.id) !== auth.user)
355
+ return unsupported('GET', String(user.id));
356
+ const selected = select('IGUser', projectUser(req, all, user), parseFields(p.fields), USER_FIELDS, USER_FIELDS_UNMODELED);
357
+ return isResponse(selected) ? selected : ok(selected);
358
+ }
359
+ function listMedia(req, url, all, auth, user, p, version) {
360
+ const refusal = need(auth, LIST_MEDIA, 'Reading an IG User\'s media');
361
+ if (refusal)
362
+ return refusal;
363
+ if (String(user.id) !== auth.user)
364
+ return unsupported('GET', String(user.id));
365
+ if (p.since !== undefined || p.until !== undefined)
366
+ return unmodeled('Time-based pagination (since / until) on /{ig-user-id}/media');
367
+ const requested = parseFields(p.fields);
368
+ for (const f of requested ?? []) {
369
+ if (MEDIA_FIELDS_UNMODELED.has(f))
370
+ return unmodeled(`The ${f} field on IGMedia`);
371
+ if (!MEDIA_FIELDS.has(f))
372
+ return invalidParam(`Tried accessing nonexisting field (${f}) on node type (IGMedia)`);
373
+ }
374
+ const rawLimit = p.limit;
375
+ const limit = rawLimit === undefined ? 25 : Number(rawLimit);
376
+ if (!Number.isInteger(limit) || limit < 1)
377
+ return invalidParam(`The parameter limit must be a positive integer`);
378
+ const pageSize = Math.min(limit, 100);
379
+ // newest first by timestamp; one instant newest-written first (the tree keeps write order)
380
+ const media = liveMedia(all, String(user.id)).map((m, i) => ({ m, i }))
381
+ .sort((a, b) => String(fields(b.m).timestamp).localeCompare(String(fields(a.m).timestamp)) || (b.i - a.i)).map(({ m }) => m);
382
+ const cursorOf = (id) => btoa(`cursor:${id}`).replace(/=+$/, '');
383
+ const idOf = (cursor) => { if (typeof cursor !== 'string')
384
+ return undefined; try {
385
+ const d = atob(cursor);
386
+ return d.startsWith('cursor:') ? d.slice(7) : undefined;
387
+ }
388
+ catch {
389
+ return undefined;
390
+ } };
391
+ let start = 0;
392
+ if (p.after !== undefined) {
393
+ const i = media.findIndex((m) => String(m.id) === idOf(p.after));
394
+ if (i < 0)
395
+ return invalidParam('The after cursor is not valid');
396
+ start = i + 1;
397
+ }
398
+ const page = media.slice(start, start + pageSize);
399
+ const data = page.map((m) => { const full = projectMedia(req, all, m); const out = {}; for (const f of requested ?? ['id'])
400
+ if (full[f] !== undefined)
401
+ out[f] = full[f]; out.id = full.id; return out; });
402
+ const body = { data };
403
+ if (page.length > 0) {
404
+ const paging = { cursors: { before: cursorOf(String(page[0].id)), after: cursorOf(String(page[page.length - 1].id)) } };
405
+ if (start + pageSize < media.length) {
406
+ const next = new URLSearchParams(url.searchParams);
407
+ next.delete('access_token');
408
+ next.set('limit', String(pageSize));
409
+ next.set('after', cursorOf(String(page[page.length - 1].id)));
410
+ paging.next = `${base(req, GRAPH_HOST)}/${version}/${user.id}/media?${next.toString()}`;
411
+ }
412
+ body.paging = paging;
413
+ }
414
+ return ok(body);
415
+ }
416
+ async function createContainer(req, all, auth, user, p, version) {
417
+ const refusal = need(auth, CREATE, 'Creating a media container');
418
+ if (refusal)
419
+ return refusal;
420
+ if (String(user.id) !== auth.user)
421
+ return permissionDenied(`Creating media on IG User ${String(user.id)}, which this token's user is not`);
422
+ const mediaType = p.media_type;
423
+ if (mediaType === undefined)
424
+ return p.image_url !== undefined ? unmodeled('Image posts (image_url containers)') : missingParam('image_url');
425
+ if (mediaType === 'CAROUSEL' || mediaType === 'STORIES' || mediaType === 'VIDEO')
426
+ return unmodeled(`${mediaType} containers`);
427
+ if (mediaType !== 'REELS')
428
+ return graphError(400, 100, `The media type ${String(mediaType)} is unknown.`, { subcode: 2207023, title: 'Unknown media type', userMsg: `The media type ${String(mediaType)} is unknown.` });
429
+ if (p.upload_type === undefined) {
430
+ return p.video_url !== undefined ? unmodeled('A Reel from video_url (Meta fetching a hosted video)') : missingParam('video_url');
431
+ }
432
+ if (p.upload_type !== 'resumable')
433
+ return invalidParam(`upload_type must be the lowercase string resumable (got ${JSON.stringify(p.upload_type)})`);
434
+ if (!holds(auth, RESUMABLE))
435
+ return permissionDenied('upload_type=resumable is only for apps that have implemented Facebook Login for Business');
436
+ if (p.video_url !== undefined)
437
+ return invalidParam('video_url is not taken by a resumable upload session; upload the video to the returned uri');
438
+ for (const k of REEL_PARAMS_UNMODELED)
439
+ if (p[k] !== undefined)
440
+ return unmodeled(`\`${k}\` on a Reel`);
441
+ if (p.alt_text !== undefined)
442
+ return invalidParam('alt_text is for image posts only; Reels and stories are not supported');
443
+ if (p.is_carousel_item !== undefined || p.children !== undefined || p.image_url !== undefined)
444
+ return invalidParam('A Reel takes no image_url, children or is_carousel_item; Reels cannot appear in carousels');
445
+ const caption = p.caption;
446
+ if (caption !== undefined && typeof caption !== 'string')
447
+ return invalidParam('caption must be a string');
448
+ if (typeof caption === 'string') {
449
+ if ([...caption].length > MAX_CAPTION_CHARS) {
450
+ const msg = `The submitted image's caption was ${[...caption].length} characters long. The maximum number of characters permitted for a caption is ${MAX_CAPTION_CHARS}. Please submit media with a shorter caption.`;
451
+ return graphError(400, 36004, msg, { subcode: 2207010, title: 'Caption too long', userMsg: msg });
452
+ }
453
+ const hashtags = caption.match(/(^|\s)#[\p{L}\p{N}_]+/gu)?.length ?? 0;
454
+ if (hashtags > MAX_HASHTAGS)
455
+ return invalidParam(`A caption may carry at most ${MAX_HASHTAGS} hashtags (it has ${hashtags})`);
456
+ const mentions = caption.match(/(^|\s)@[A-Za-z0-9._]+/g)?.length ?? 0;
457
+ if (mentions > MAX_MENTIONS)
458
+ return graphError(400, 100, `Cannot use more than ${MAX_MENTIONS} tags per created media.`, { subcode: 2207040, title: 'Too many tags', userMsg: `Cannot use more than ${MAX_MENTIONS} tags per created media.` });
459
+ }
460
+ const share = boolParam(p.share_to_feed);
461
+ if (share === 'invalid')
462
+ return invalidParam('share_to_feed must be true or false');
463
+ const ai = boolParam(p.is_ai_generated);
464
+ if (ai === 'invalid')
465
+ return invalidParam('is_ai_generated must be true or false');
466
+ const cover = p.cover_url;
467
+ if (cover !== undefined && (typeof cover !== 'string' || !/^https?:\/\/\S+$/i.test(cover)))
468
+ return invalidParam('cover_url must be an http(s) URL');
469
+ let thumbOffset;
470
+ if (p.thumb_offset !== undefined) {
471
+ thumbOffset = Number(p.thumb_offset);
472
+ if (!Number.isInteger(thumbOffset) || thumbOffset < 0) {
473
+ return graphError(400, 1, 'Thumbnail offset must be greater than or equal to 0 and less than video duration', { subcode: 2207057, title: 'Invalid thumbnail offset' });
474
+ }
475
+ }
476
+ const audio = p.audio_name;
477
+ if (audio !== undefined && (typeof audio !== 'string' || audio.trim() === ''))
478
+ return invalidParam('audio_name must be a non-empty string');
479
+ const created = nowMs(req);
480
+ const recent = (await listContainers(req.root)).filter((c) => c.owner === String(user.id) && created - c.created_ms < CONTAINER_LIFETIME_MS);
481
+ if (recent.length >= CONTAINER_QUOTA) {
482
+ // THE TWIN'S OWN CODE: the limit is documented, its refusal is not (the Error Codes table names
483
+ // code 9 only for the publishing limit); code 9, "API Too Many Calls"-family, is the closest.
484
+ return graphError(400, 9, `An Instagram account can only create ${CONTAINER_QUOTA} containers within a rolling 24 hour period`);
485
+ }
486
+ const id = await mintContainerId(req.root);
487
+ const record = {
488
+ id, owner: String(user.id), media_type: 'REELS', upload_type: 'resumable', created_ms: created, received: 0,
489
+ share_to_feed: share ?? true,
490
+ ...(typeof caption === 'string' ? { caption } : {}),
491
+ ...(typeof cover === 'string' ? { cover_url: cover } : {}),
492
+ ...(thumbOffset !== undefined ? { thumb_offset: thumbOffset } : {}),
493
+ ...(typeof audio === 'string' ? { audio_name: audio } : {}),
494
+ ...(ai !== undefined ? { is_ai_generated: ai } : {}),
495
+ };
496
+ await writeContainer(record, req.root);
497
+ // "On success, an ig-container-id and a uri is returned"
498
+ return ok({ id, uri: `${base(req, RUPLOAD_HOST)}/ig-api-upload/${version}/${id}` });
499
+ }
500
+ function getContainer(req, all, auth, c, p) {
501
+ const refusal = need(auth, CREATE, 'Reading an IG Container');
502
+ if (refusal)
503
+ return refusal;
504
+ if (c.owner !== auth.user)
505
+ return unsupported('GET', c.id);
506
+ const requested = parseFields(p.fields);
507
+ if (requested?.includes('copyright_check_status'))
508
+ return unmodeled('copyright_check_status on an IG Container');
509
+ const status = containerStatus(c, nowMs(req), publishedFrom(all, c.id));
510
+ // "status — Publishing status. If status_code is ERROR, this value will be an error subcode."
511
+ const full = { id: c.id, status_code: status, status: status === 'ERROR' ? String(c.error_subcode) : status };
512
+ const selected = select('ShadowIGMediaBuilder', full, requested, CONTAINER_FIELDS, new Set());
513
+ return isResponse(selected) ? selected : ok(selected);
514
+ }
515
+ async function publish(req, all, auth, user, p) {
516
+ const refusal = need(auth, PUBLISH, 'Publishing media');
517
+ if (refusal)
518
+ return refusal;
519
+ if (String(user.id) !== auth.user)
520
+ return permissionDenied(`Publishing on IG User ${String(user.id)}, which this token's user is not`);
521
+ const creation = p.creation_id;
522
+ if (creation === undefined || creation === '')
523
+ return missingParam('creation_id');
524
+ const cid = String(creation);
525
+ const notFound = () => graphError(400, 24, `The media builder with creation id = ${cid} does not exist or has been expired.`, { subcode: 2207008, title: 'Media builder expired', userMsg: `The media builder with creation id = ${cid} does not exist or has been expired.` });
526
+ const c = isNumericId(cid) ? await readContainer(cid, req.root) : undefined;
527
+ if (!c || c.owner !== String(user.id))
528
+ return notFound();
529
+ const status = containerStatus(c, nowMs(req), publishedFrom(all, c.id));
530
+ if (status === 'PUBLISHED')
531
+ return notFound();
532
+ if (status === 'EXPIRED')
533
+ return graphError(400, -2, 'The media you are trying to access has expired. Please try to upload again.', { subcode: 2207020, title: 'Media expired' });
534
+ if (status === 'IN_PROGRESS')
535
+ return graphError(400, 9007, 'The media is not ready for publishing, please wait for a moment', { subcode: 2207027, title: 'Media not ready', userMsg: 'The media is not ready for publishing, please wait for a moment' });
536
+ if (status === 'ERROR')
537
+ return graphError(400, -1, 'Create media fail, please try to re-create media', { subcode: 2207032, title: 'Create media fail', userMsg: c.error_reason ?? 'Create media fail, please try to re-create media' });
538
+ // "Instagram accounts are limited to … API-published posts within a 24-hour moving period … enforced
539
+ // on the POST /<IG_ID>/media_publish endpoint"
540
+ const now = nowMs(req);
541
+ if (apiPublishedSince(all, String(user.id), now - PUBLISH_QUOTA_DURATION_S * 1000 + 1) >= PUBLISH_QUOTA_TOTAL) {
542
+ return graphError(400, 9, 'You reached maximum number of posts that is allowed to be published by Content Publishing API.', { subcode: 2207042, title: 'Publishing limit reached', userMsg: 'You reached maximum number of posts that is allowed to be published by Content Publishing API.' });
543
+ }
544
+ const { value: id } = await applyTwinWriteAtomic(SERVICE, (held) => {
545
+ const minted = mintMediaId(held, req.root, `${at(req)}|${user.id}|${c.sha256}|${c.caption ?? ''}|${c.share_to_feed}`);
546
+ return {
547
+ kind: 'write',
548
+ value: minted,
549
+ write: {
550
+ operation: 'instagram.media.publish',
551
+ subjectType: 'media',
552
+ subjectId: minted,
553
+ fields: {
554
+ media_type: 'VIDEO',
555
+ media_product_type: 'REELS',
556
+ ...(c.caption !== undefined ? { caption: c.caption } : {}),
557
+ timestamp: igTimestamp(at(req)),
558
+ shortcode: shortcodeFor(minted),
559
+ permalink: `https://www.instagram.com/reel/${shortcodeFor(minted)}/`,
560
+ owner: { id: String(user.id) },
561
+ is_shared_to_feed: c.share_to_feed,
562
+ is_comment_enabled: true,
563
+ comments_count: 0,
564
+ like_count: 0,
565
+ ...(c.is_ai_generated !== undefined ? { is_ai_generated: c.is_ai_generated } : {}),
566
+ // The bytes by digest, and what the container asked of them: how the mirror's player and a
567
+ // deploy's upload find the Reel with no staged record needed (bookkeeping — never on the wire).
568
+ _video: { sha256: c.sha256, size: c.size, width: c.video?.width, height: c.video?.height, duration_ms: c.video?.durationMs },
569
+ _container: c.id,
570
+ ...(c.cover_url !== undefined ? { _cover_url: c.cover_url } : {}),
571
+ ...(c.thumb_offset !== undefined ? { _thumb_offset: c.thumb_offset } : {}),
572
+ ...(c.audio_name !== undefined ? { _audio_name: c.audio_name } : {}),
573
+ _deleted: false,
574
+ },
575
+ occurredAt: at(req),
576
+ actor: { kind: 'agent', id: String(user.id) },
577
+ },
578
+ };
579
+ }, req.root);
580
+ return ok({ id });
581
+ }
582
+ function publishingLimit(req, all, auth, user, p) {
583
+ const refusal = need(auth, CREATE, 'Reading content_publishing_limit');
584
+ if (refusal)
585
+ return refusal;
586
+ if (String(user.id) !== auth.user)
587
+ return unsupported('GET', String(user.id));
588
+ const now = nowMs(req);
589
+ let since = now - PUBLISH_QUOTA_DURATION_S * 1000;
590
+ if (p.since !== undefined) {
591
+ const s = Number(p.since);
592
+ // "A Unix timestamp no older than 24 hours."
593
+ if (!Number.isInteger(s) || s * 1000 < now - PUBLISH_QUOTA_DURATION_S * 1000 || s * 1000 > now)
594
+ return invalidParam('since must be a Unix timestamp no older than 24 hours');
595
+ since = s * 1000;
596
+ }
597
+ const requested = parseFields(p.fields) ?? ['quota_usage'];
598
+ for (const f of requested) {
599
+ if (f !== 'quota_usage' && f !== 'config' && f !== 'rate_limit_settings')
600
+ return invalidParam(`Tried accessing nonexisting field (${f}) on node type (ContentPublishingLimit)`);
601
+ }
602
+ const row = {};
603
+ if (requested.includes('quota_usage'))
604
+ row.quota_usage = apiPublishedSince(all, String(user.id), since);
605
+ // the reference's own example asks for `quota_usage,rate_limit_settings` and is answered `config`
606
+ if (requested.includes('config') || requested.includes('rate_limit_settings'))
607
+ row.config = { quota_total: PUBLISH_QUOTA_TOTAL, quota_duration: PUBLISH_QUOTA_DURATION_S };
608
+ return ok({ data: [row] });
609
+ }
610
+ // ── the IG Media node ─────────────────────────────────────────────────────────────────────────
611
+ function getMedia(req, all, auth, media, p) {
612
+ const refusal = need(auth, READ_MEDIA, 'Reading an IG Media');
613
+ if (refusal)
614
+ return refusal;
615
+ if (String(fields(media).owner?.id) !== auth.user)
616
+ return unsupported('GET', String(media.id));
617
+ const selected = select('IGMedia', projectMedia(req, all, media), parseFields(p.fields), MEDIA_FIELDS, MEDIA_FIELDS_UNMODELED);
618
+ return isResponse(selected) ? selected : ok(selected);
619
+ }
620
+ async function deleteMedia(req, auth, media) {
621
+ // IG Media reference, Deleting: instagram_basic + instagram_manage_contents, Facebook Login only
622
+ const refusal = need(auth, DELETE, 'Deleting an IG Media');
623
+ if (refusal)
624
+ return refusal;
625
+ if (String(fields(media).owner?.id) !== auth.user)
626
+ return unsupported('DELETE', String(media.id));
627
+ await applyTwinWrite(SERVICE, {
628
+ operation: 'instagram.media.delete',
629
+ subjectType: 'media',
630
+ subjectId: String(media.id),
631
+ fields: { ...fields(media), _deleted: true },
632
+ occurredAt: at(req),
633
+ actor: { kind: 'agent', id: auth.user },
634
+ }, req.root);
635
+ return ok({ success: true, deleted_id: String(media.id) });
636
+ }
637
+ // ── rupload.facebook.com/ig-api-upload/{version}/{container-id} ───────────────────────────────
638
+ async function rupload(req, url, containerId) {
639
+ const all = resources(req.root);
640
+ const p = Object.fromEntries(url.searchParams);
641
+ // "-H "Authorization: OAuth <ACCESS_TOKEN>"" — the host's documented scheme; `access_token` is listed
642
+ // too. No page says the host refuses Bearer, so the twin takes it as the Graph hosts do.
643
+ const token = presentedToken(req, p, /^(?:OAuth|Bearer)\s+(\S+)$/i);
644
+ const unauthorized = () => ruploadFailure(400, 'ProcessingFailedError', 'unauthorized user request');
645
+ const auth = authorize(all, token);
646
+ if (isResponse(auth))
647
+ return unauthorized();
648
+ if (!holds(auth, RESUMABLE))
649
+ return unauthorized();
650
+ if (req.method === 'GET') {
651
+ const c = await readContainer(containerId, req.root);
652
+ if (!c || c.owner !== auth.user)
653
+ return ruploadFailure(400, 'ProcessingFailedError', `Invalid container id ${containerId}`);
654
+ // THE TWIN'S OWN resume read: the rupload pages document no way to ask what the host holds, so the
655
+ // twin answers as the Graph API's Resumable Upload guide does for graph.facebook.com (`file_offset`)
656
+ // — the byte a resumed POST starts at (instagram.upload.offset_discovery).
657
+ return ok({ id: containerId, file_offset: c.received, ...(c.file_size !== undefined ? { file_size: c.file_size } : {}) });
658
+ }
659
+ if (header(req.headers, 'file_url') !== undefined)
660
+ return unmodeled('Uploading a Reel from a hosted file_url (Meta fetching it)');
661
+ return withContainer(containerId, req.root, async () => {
662
+ const c = await readContainer(containerId, req.root);
663
+ if (!c || c.owner !== auth.user)
664
+ return ruploadFailure(400, 'ProcessingFailedError', `Invalid container id ${containerId}`);
665
+ const now = nowMs(req);
666
+ if (now >= c.created_ms + CONTAINER_LIFETIME_MS)
667
+ return ruploadFailure(400, 'ProcessingFailedError', `Container ${containerId} has expired`);
668
+ if (c.file_size !== undefined && c.received === c.file_size)
669
+ return ruploadFailure(400, 'ProcessingFailedError', `Container ${containerId} has already received its whole file`);
670
+ const offset = Number(header(req.headers, 'offset') ?? '0');
671
+ const fileSize = Number(header(req.headers, 'file_size'));
672
+ if (!Number.isInteger(fileSize) || fileSize <= 0)
673
+ return ruploadFailure(400, 'ProcessingFailedError', 'The file_size header must be the size of the file in bytes');
674
+ // THE TWIN'S OWN BOUND: a declared file over the Reels maximum is refused before its bytes are staged
675
+ // (Meta's answer to one is undocumented; the twin will not hold a file no Reel may be)
676
+ if (fileSize > MAX_REEL_BYTES)
677
+ return ruploadFailure(400, 'ProcessingFailedError', `file_size ${fileSize} is over the Reels maximum of 300MB`);
678
+ if (c.file_size !== undefined && c.file_size !== fileSize)
679
+ return ruploadFailure(400, 'ProcessingFailedError', `file_size ${fileSize} differs from the ${c.file_size} this upload began with`);
680
+ if (!Number.isInteger(offset) || offset < 0)
681
+ return ruploadFailure(400, 'ProcessingFailedError', 'The offset header must be the first byte being uploaded');
682
+ if (offset !== c.received)
683
+ return ruploadFailure(400, 'ProcessingFailedError', `offset ${offset} does not continue the upload: resume at offset ${c.received}`);
684
+ const bytes = req.bytes ?? new Uint8Array();
685
+ if (bytes.length === 0)
686
+ return ruploadFailure(400, 'ProcessingFailedError', 'The request carries no bytes');
687
+ if (offset + bytes.length > fileSize)
688
+ return ruploadFailure(400, 'ProcessingFailedError', `${bytes.length} bytes at offset ${offset} run past file_size ${fileSize}`);
689
+ await putChunk(c.id, offset, bytes, req.root);
690
+ const received = offset + bytes.length;
691
+ if (received < fileSize) {
692
+ await writeContainer({ ...c, file_size: fileSize, received }, req.root);
693
+ // An upload that stops short is kept: the next POST resumes at `offset` = the bytes received. THE
694
+ // TWIN'S OWN ANSWER (the pages document only the whole-file success and the failure): the offset
695
+ // held, and deliberately no `success`, so no client reads a piece as the whole file.
696
+ return ok({ offset: received, file_size: fileSize });
697
+ }
698
+ const chunks = await readChunks(c.id, req.root);
699
+ const whole = new Uint8Array(chunks.reduce((n, x) => n + x.length, 0));
700
+ let o = 0;
701
+ for (const x of chunks) {
702
+ whole.set(x, o);
703
+ o += x.length;
704
+ }
705
+ if (whole.length !== fileSize)
706
+ return ruploadFailure(400, 'ProcessingFailedError', `The stored chunks hold ${whole.length} of ${fileSize} bytes; upload again from offset 0`);
707
+ const stored = await putInstagramBlob(whole, req.root);
708
+ await clearChunks(c.id, req.root);
709
+ // Processing, as Meta does it after the upload: a file outside the Reels specification leaves the
710
+ // container in ERROR (its subcode on `status`) rather than refusing the upload.
711
+ const info = sniffVideo(whole);
712
+ // "If you specify both cover_url and thumb_offset, we use cover_url and ignore thumb_offset."
713
+ const violation = reelSpecViolation(info, whole.length, c.cover_url === undefined ? c.thumb_offset : undefined);
714
+ await writeContainer({
715
+ ...c, file_size: fileSize, received, uploaded_ms: now, sha256: stored.sha256, size: whole.length,
716
+ ...(info ? { video: info } : {}),
717
+ ...(violation ? { error_subcode: violation.subcode, error_reason: violation.reason } : {}),
718
+ }, req.root);
719
+ return ruploadOk();
720
+ });
721
+ }
722
+ // ── the twin control plane ───────────────────────────────────────────────────────────────────
723
+ // Not vendor surface. `/_twin/*` is how a WORLD seeds what Instagram's own app and the Facebook Login
724
+ // leg would have made: the professional account, and the access token the OAuth flow would have minted
725
+ // (held only as its SHA-256).
726
+ async function handleTwinControl(req, all, path, body) {
727
+ if (req.method !== 'POST')
728
+ return unknownPath(path);
729
+ if (path === '/_twin/users') {
730
+ const id = String(body.id ?? '');
731
+ if (!isNumericId(id))
732
+ return invalidParam('id must be a numeric IG user id');
733
+ if (typeof body.username !== 'string' || !/^[a-z0-9._]{1,30}$/.test(body.username))
734
+ return invalidParam('username must be 1-30 of a-z 0-9 . _');
735
+ if (ofType(all, 'ig_user').some((u) => String(u.id) !== id && fields(u).username === body.username))
736
+ return invalidParam(`username ${body.username} is already another account's`);
737
+ for (const k of ['name', 'biography', 'website'])
738
+ if (body[k] !== undefined && typeof body[k] !== 'string')
739
+ return invalidParam(`${k} must be a string`);
740
+ for (const k of ['followers_count', 'follows_count'])
741
+ if (body[k] !== undefined && (!Number.isInteger(body[k]) || body[k] < 0))
742
+ return invalidParam(`${k} must be a non-negative integer`);
743
+ let picture;
744
+ if (body.profile_picture !== undefined) {
745
+ if (typeof body.profile_picture !== 'string')
746
+ return invalidParam('profile_picture must be base64 JPEG or PNG bytes');
747
+ const bytes = Uint8Array.from(atob(body.profile_picture), (ch) => ch.charCodeAt(0));
748
+ const info = sniffImage(bytes);
749
+ if (!info)
750
+ return invalidParam('profile_picture must be base64 JPEG or PNG bytes');
751
+ picture = { sha256: (await putInstagramBlob(bytes, req.root)).sha256, media_type: info.mediaType };
752
+ }
753
+ const f = {
754
+ username: body.username,
755
+ ...(body.name !== undefined ? { name: body.name } : {}),
756
+ ...(body.biography !== undefined ? { biography: body.biography } : {}),
757
+ ...(body.website !== undefined ? { website: body.website } : {}),
758
+ ...(body.followers_count !== undefined ? { followers_count: body.followers_count } : {}),
759
+ ...(body.follows_count !== undefined ? { follows_count: body.follows_count } : {}),
760
+ ...(picture ? { _profile_picture: picture } : {}),
761
+ };
762
+ await applyTwinWrite(SERVICE, { operation: 'instagram.twin.seed_user', subjectType: 'ig_user', subjectId: id, fields: f, occurredAt: at(req) }, req.root);
763
+ return ok({ id, username: body.username });
764
+ }
765
+ if (path === '/_twin/tokens') {
766
+ const token = body.token;
767
+ if (typeof token !== 'string' || !/^[A-Za-z0-9._~-]{8,512}$/.test(token))
768
+ return invalidParam('token must be 8-512 characters of A-Z a-z 0-9 . _ ~ -');
769
+ const user = String(body.user ?? '');
770
+ if (!findUser(all, user))
771
+ return invalidParam(`user ${user} is not a seeded IG user id`);
772
+ const permissions = Array.isArray(body.permissions) ? body.permissions.map(String) : String(body.permissions ?? '').split(/[\s,]+/).filter((s) => s !== '');
773
+ for (const x of permissions)
774
+ if (!PERMISSIONS.has(x))
775
+ return invalidParam(`permission ${x} is not one these pages name`);
776
+ // held as its SHA-256 under a bookkeeping type: the token never enters the log in the clear
777
+ await applyTwinWrite(SERVICE, { operation: 'instagram.twin.seed_token', subjectType: '_token', subjectId: tokenDigest(token), fields: { user, permissions }, occurredAt: at(req) }, req.root);
778
+ return ok({ user, permissions });
779
+ }
780
+ return unknownPath(path);
781
+ }
782
+ // ── the router ───────────────────────────────────────────────────────────────────────────────
783
+ const RUPLOAD = /^\/ig-api-upload\/(?:(v\d+\.\d+)\/)?(\d{1,25})$/;
784
+ const VERSIONED = /^\/(v(\d+)\.(\d+))(\/.*)?$/;
785
+ export async function handleInstagramTwinRequest(req) {
786
+ const url = new URL(req.path, 'http://twin.local');
787
+ const rawPath = url.pathname.replace(/\/+$/, '') || '/';
788
+ const method = req.method.toUpperCase();
789
+ const up = RUPLOAD.exec(rawPath);
790
+ // a rupload version the changelog table does not hold is no upload path (the twin's own refusal)
791
+ if (up && up[1] !== undefined && !GRAPH_VERSIONS.some((v) => up[1] === `v${v.major}.0`))
792
+ return ruploadFailure(400, 'ProcessingFailedError', 'Invalid upload path');
793
+ if (up) {
794
+ if (method !== 'POST' && method !== 'GET')
795
+ return ruploadFailure(405, 'ProcessingFailedError', `${method} is not an upload`);
796
+ if (method === 'POST' && req.readOnly)
797
+ return readOnlyRefusal();
798
+ return rupload(req, url, up[2]);
799
+ }
800
+ if (rawPath.startsWith('/ig-api-upload'))
801
+ return ruploadFailure(400, 'ProcessingFailedError', 'Invalid upload path');
802
+ const p = params(url, req);
803
+ if (p === 'malformed')
804
+ return invalidParam('The request body could not be parsed');
805
+ const all = resources(req.root);
806
+ if (rawPath.startsWith('/_twin/')) {
807
+ if (req.readOnly)
808
+ return readOnlyRefusal();
809
+ return handleTwinControl(req, all, rawPath, p);
810
+ }
811
+ // the version: served, defaulted to the oldest usable (the versioning guide), or one newer than the
812
+ // table knows (the twin's refusal wording: the pages read give none)
813
+ const usable = usableVersions(nowMs(req));
814
+ let version = `v${usable[usable.length - 1] ?? 26}.0`;
815
+ let path = rawPath;
816
+ const v = VERSIONED.exec(rawPath);
817
+ if (v) {
818
+ const major = Number(v[2]);
819
+ if (v[3] !== '0' || major > (usable[usable.length - 1] ?? 26))
820
+ return unknownPath(rawPath);
821
+ version = major < (usable[0] ?? major) ? `v${usable[0]}.0` : v[1];
822
+ path = v[4] ?? '/';
823
+ }
824
+ const segments = path.split('/').filter(Boolean);
825
+ if (segments.length === 0 || segments.length > 2)
826
+ return unknownPath(rawPath);
827
+ const auth = authorize(all, presentedToken(req, p, /^(?:Bearer|OAuth)\s+(\S+)$/i));
828
+ if (isResponse(auth))
829
+ return auth;
830
+ const write = method !== 'GET' && method !== 'HEAD';
831
+ if (write && req.readOnly)
832
+ return readOnlyRefusal();
833
+ const [node, edge] = segments;
834
+ const id = node === 'me' ? auth.user : node;
835
+ if (!isNumericId(id))
836
+ return unsupported(method, id);
837
+ const user = findUser(all, id);
838
+ if (user) {
839
+ if (edge === undefined) {
840
+ if (method === 'GET')
841
+ return getUser(req, all, auth, user, p);
842
+ return unsupported(method, id);
843
+ }
844
+ if (edge === 'media') {
845
+ if (method === 'GET')
846
+ return listMedia(req, url, all, auth, user, p, version);
847
+ if (method === 'POST')
848
+ return createContainer(req, all, auth, user, p, version);
849
+ return unsupported(method, id);
850
+ }
851
+ if (edge === 'media_publish') {
852
+ if (method === 'POST')
853
+ return publish(req, all, auth, user, p);
854
+ return unsupported(method, id);
855
+ }
856
+ if (edge === 'content_publishing_limit') {
857
+ if (method === 'GET')
858
+ return publishingLimit(req, all, auth, user, p);
859
+ return unsupported(method, id);
860
+ }
861
+ if (USER_EDGES_UNMODELED.has(edge))
862
+ return unmodeled(`The ${edge} edge on an IG User`);
863
+ return invalidParam(`Tried accessing nonexisting field (${edge}) on node type (IGUser)`);
864
+ }
865
+ const media = findMedia(all, id, req.root);
866
+ if (media) {
867
+ if (edge !== undefined) {
868
+ if (edge === 'children' || edge === 'comments' || edge === 'insights' || edge === 'collaborators')
869
+ return unmodeled(`The ${edge} edge on an IG Media`);
870
+ return invalidParam(`Tried accessing nonexisting field (${edge}) on node type (IGMedia)`);
871
+ }
872
+ if (method === 'GET')
873
+ return getMedia(req, all, auth, media, p);
874
+ if (method === 'DELETE')
875
+ return deleteMedia(req, auth, media);
876
+ if (method === 'POST')
877
+ return unmodeled('Updating an IG Media (comment_enabled)');
878
+ return unsupported(method, id);
879
+ }
880
+ const container = edge === undefined ? await readContainer(id, req.root) : undefined;
881
+ if (container && method === 'GET')
882
+ return getContainer(req, all, auth, container, p);
883
+ return unsupported(method, id);
884
+ }