@volter/twin-linkedin 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 +39 -0
  3. package/dist/client/linkedin-mirror.bundle.js +323 -0
  4. package/dist/client/linkedin-mirror.d.ts +43 -0
  5. package/dist/client/linkedin-mirror.js +393 -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 +74 -0
  10. package/dist/src/linkedin-budget.d.ts +36 -0
  11. package/dist/src/linkedin-budget.js +87 -0
  12. package/dist/src/linkedin-capabilities.d.ts +4 -0
  13. package/dist/src/linkedin-capabilities.js +1027 -0
  14. package/dist/src/linkedin-conformance.d.ts +8 -0
  15. package/dist/src/linkedin-conformance.js +58 -0
  16. package/dist/src/linkedin-connector.d.ts +66 -0
  17. package/dist/src/linkedin-connector.js +326 -0
  18. package/dist/src/linkedin-errors.d.ts +21 -0
  19. package/dist/src/linkedin-errors.js +38 -0
  20. package/dist/src/linkedin-media.d.ts +125 -0
  21. package/dist/src/linkedin-media.js +331 -0
  22. package/dist/src/linkedin-mirror-ui.d.ts +59 -0
  23. package/dist/src/linkedin-mirror-ui.js +174 -0
  24. package/dist/src/linkedin-server.d.ts +10 -0
  25. package/dist/src/linkedin-server.js +154 -0
  26. package/dist/src/linkedin-twin.d.ts +23 -0
  27. package/dist/src/linkedin-twin.js +1220 -0
  28. package/package.json +58 -0
  29. package/src/cli.ts +28 -0
  30. package/src/index.ts +118 -0
  31. package/src/linkedin-budget.ts +108 -0
  32. package/src/linkedin-capabilities.ts +1057 -0
  33. package/src/linkedin-conformance.ts +73 -0
  34. package/src/linkedin-connector.ts +314 -0
  35. package/src/linkedin-errors.ts +58 -0
  36. package/src/linkedin-media.ts +363 -0
  37. package/src/linkedin-mirror-ui.ts +182 -0
  38. package/src/linkedin-server.ts +150 -0
  39. package/src/linkedin-twin.ts +1143 -0
@@ -0,0 +1,363 @@
1
+ // Image and video ASSETS for the LinkedIn twin — what `POST /rest/images?action=initializeUpload`,
2
+ // `POST /rest/videos?action=initializeUpload`, the upload PUTs and `action=finalizeUpload` hold
3
+ // before a post names the asset.
4
+ //
5
+ // The method is the slack pack's (slack-blobs.ts), as the X pack transcribed it (x-media.ts). Bytes
6
+ // ride the KERNEL'S BLOB SEAM (`getActiveBlobStore()`, runtime contract R11), content-addressed by
7
+ // sha256, under this service's own `resources` directory. An upload is NOT a kernel action: at
8
+ // LinkedIn an uploaded image or video publishes nothing — it is an asset only a later
9
+ // `POST /rest/posts` makes public. Recording the upload through `applyTwinWrite` would create a
10
+ // durably pending entry with no vendor write of its own to perform, and wedge every deploy behind
11
+ // it. So the asset record (owner, status, parts, finished digest) is a bare pointer record in the
12
+ // same byte annex, carrying no approval weight. What becomes state is the POST: the post's own
13
+ // entry names its asset by URN and DIGEST (linkedin-twin.ts), and that is what a deploy performs
14
+ // (linkedin-connector.ts uploads the bytes to the vendor, then posts).
15
+ //
16
+ // Reads go through `readResourceBlob`, which looks in this branch and then its retained ancestors,
17
+ // so a World branch sees assets its parent uploaded and a perform finds the bytes a post names.
18
+ //
19
+ // UPLOAD URLS ARE SIGNED, as LinkedIn's are. The vendor hands back an upload URL on
20
+ // www.linkedin.com/dms-uploads/… carrying its own authorization in the query; a video part's PUT
21
+ // carries no bearer, and an image's PUT carries the member's access token as well (the handler checks
22
+ // it, linkedin-twin.ts `receiveUpload`). The twin signs the same way: each URL names the asset and the
23
+ // slot (a video part, an image, a thumbnail) and carries `ut`, an HMAC-SHA256 over
24
+ // `<asset>/<slot>/<expiresAt>` keyed by THAT ASSET'S OWN secret, drawn from entropy at initialize and
25
+ // kept in its record. No key is shared: two clones (or branches, or processes) each sign only the assets
26
+ // they created, a push carrying an asset carries the one secret its URLs were signed with, and nothing is
27
+ // created on first use, so there is nothing to race. The PUT is refused unless the signature holds, its
28
+ // expiry is the asset's own, and it has not passed.
29
+ //
30
+ // Every read-modify-write of an asset record runs in that asset's chain (`withAsset`).
31
+ import { join } from 'node:path';
32
+ import { blobDigest, getActiveBlobStore, readBranchMeta, readResourceBlob, worldPaths } from '@volter/world-core';
33
+
34
+ const SERVICE = 'linkedin';
35
+
36
+ export type AssetStatus = 'WAITING_UPLOAD' | 'PROCESSING' | 'AVAILABLE' | 'PROCESSING_FAILED';
37
+
38
+ /** One part of a video's multipart upload, as the upload instructions name it. */
39
+ export type VideoPart = { firstByte: number; lastByte: number; etag?: string; sha256?: string };
40
+
41
+ /** The stored asset: an image or a video, keyed by its URN's id. */
42
+ export type AssetRecord = {
43
+ kind: 'image' | 'video';
44
+ /** The asset id — the tail of `urn:li:image:<id>` / `urn:li:video:<id>`. */
45
+ id: string;
46
+ /** `urn:li:organization:<id>` (or a person) — who may attach it. */
47
+ owner: string;
48
+ status: AssetStatus;
49
+ created_ms: number;
50
+ /** Epoch ms after which the upload URLs refuse. */
51
+ upload_expires_ms: number;
52
+ /** IMAGE: the whole file. VIDEO: the assembled file once finalized. */
53
+ sha256?: string;
54
+ size?: number;
55
+ media_type?: string;
56
+ width?: number;
57
+ height?: number;
58
+ duration_ms?: number;
59
+ /** VIDEO: the declared size and its parts. */
60
+ file_size_bytes?: number;
61
+ parts?: VideoPart[];
62
+ upload_thumbnail?: boolean;
63
+ upload_captions?: boolean;
64
+ thumbnail_sha256?: string;
65
+ thumbnail_media_type?: string;
66
+ /** VIDEO: the world instant finalize accepted the file; processing completes PROCESSING_MS later. */
67
+ finalized_ms?: number;
68
+ processing_failure_reason?: string;
69
+ /** The asset's own upload secret (hex): the key its upload URLs are signed with. Per asset, so no key
70
+ * is shared between assets, branches or clones, and nothing is ever created on first use. */
71
+ upload_secret: string;
72
+ };
73
+
74
+ /** LinkedIn cuts a video into 4 MB parts: 0-4194303, 4194304-8388607, … (videos-api). */
75
+ export const VIDEO_PART_BYTES = 4_194_304;
76
+ /** Videos API, "Video File Size Specifications": "File size: Between 75kb and 500MB." — decimal
77
+ * units as written: 75,000 and 500,000,000 bytes. */
78
+ export const MIN_VIDEO_BYTES = 75_000;
79
+ export const MAX_VIDEO_BYTES = 500_000_000;
80
+ /** Videos API: "Length: Three seconds to 30 minutes." */
81
+ export const MIN_VIDEO_MS = 3_000;
82
+ export const MAX_VIDEO_MS = 30 * 60_000;
83
+ /** Images API: "Images with less than 36,152,320 pixels." */
84
+ export const MAX_IMAGE_PIXELS = 36_152_320;
85
+ /** How long (world time) a finalized video stays PROCESSING before it is AVAILABLE. A read folds the
86
+ * state from the stored instant and the request's own — reading never moves anything. */
87
+ export const PROCESSING_MS = 2_000;
88
+ /** "Typically URLs expire 30 days from the time an upload is initialized." */
89
+ export const UPLOAD_URL_LIFETIME_MS = 30 * 86_400_000;
90
+
91
+ const resourcesDir = (root?: string): string => worldPaths(SERVICE, root).resources;
92
+ /** Where the bytes of a digest live under this service's resources (the key the ranged reads take). */
93
+ export const blobKey = (sha256: string): string => join('blobs', 'sha256', sha256.slice(0, 2), sha256);
94
+ const recordKey = (id: string): string => join('assets', `${safeId(id)}.json`);
95
+ const partKey = (id: string, index: number): string => join('asset-parts', safeId(id), String(index).padStart(4, '0'));
96
+
97
+ export function isAssetId(id: string): boolean {
98
+ return /^[A-Za-z0-9_-]{8,40}$/.test(id);
99
+ }
100
+
101
+ function safeId(id: string): string {
102
+ if (!isAssetId(id)) throw new Error(`invalid LinkedIn asset id for storage: ${JSON.stringify(id)}`);
103
+ return id;
104
+ }
105
+
106
+ /** Store bytes content-addressed; a re-upload of identical content is a no-op write. */
107
+ export async function putLinkedinBlob(bytes: Uint8Array, root?: string): Promise<{ sha256: string; size: number }> {
108
+ const sha256 = blobDigest(bytes);
109
+ const key = join(resourcesDir(root), blobKey(sha256));
110
+ if (!(await getActiveBlobStore().exists(key))) await getActiveBlobStore().put(key, bytes);
111
+ return { sha256, size: bytes.length };
112
+ }
113
+
114
+ /** Stored bytes by digest (this branch, then its ancestors); null when absent or malformed. */
115
+ export async function readLinkedinBlob(sha256: string, root?: string): Promise<Uint8Array | null> {
116
+ if (!/^[0-9a-f]{64}$/.test(sha256)) return null;
117
+ return await readResourceBlob(SERVICE, blobKey(sha256), root);
118
+ }
119
+
120
+ export async function writeAsset(record: AssetRecord, root?: string): Promise<void> {
121
+ await getActiveBlobStore().put(join(resourcesDir(root), recordKey(record.id)), new TextEncoder().encode(JSON.stringify(record)));
122
+ }
123
+
124
+ export async function readAsset(id: string, root?: string): Promise<AssetRecord | undefined> {
125
+ if (!isAssetId(id)) return undefined;
126
+ try {
127
+ const stored = await readResourceBlob(SERVICE, recordKey(id), root);
128
+ if (stored === null) return undefined;
129
+ const parsed = JSON.parse(new TextDecoder().decode(stored)) as AssetRecord;
130
+ return typeof parsed.id === 'string' ? parsed : undefined;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ /** Whether this BRANCH holds the asset's record itself (not only an ancestor it reads through). A
137
+ * branch writes only its own assets: an upload an ancestor started is finished there. */
138
+ export async function assetIsOwn(id: string, root?: string): Promise<boolean> {
139
+ return isAssetId(id) && (await getActiveBlobStore().exists(join(resourcesDir(root), recordKey(id))));
140
+ }
141
+
142
+ /** Whether a record carries the per-asset upload secret its URLs are signed with (records written
143
+ * before per-asset secrets have none, and are never signed with an empty key). */
144
+ export function hasUploadSecret(asset: Pick<AssetRecord, 'upload_secret'>): boolean {
145
+ return typeof asset.upload_secret === 'string' && /^[0-9a-f]{64}$/.test(asset.upload_secret);
146
+ }
147
+
148
+ /** An asset as it stands at `nowMs`: a finalized video is AVAILABLE once PROCESSING_MS of world time
149
+ * has passed since finalize. A pure fold of the stored record and the instant — nothing is written. */
150
+ export function assetAsOf(asset: AssetRecord, nowMs: number): AssetRecord {
151
+ if (asset.kind === 'video' && asset.status === 'PROCESSING' && typeof asset.finalized_ms === 'number' && nowMs >= asset.finalized_ms + PROCESSING_MS) {
152
+ return { ...asset, status: 'AVAILABLE' };
153
+ }
154
+ return asset;
155
+ }
156
+
157
+ /** Every asset id this branch AND its retained ancestors hold (what a read could resolve). */
158
+ export async function listAssetIds(root?: string): Promise<string[]> {
159
+ const out = new Set<string>();
160
+ const seen = new Set<string>();
161
+ let current = worldPaths(SERVICE, root).root;
162
+ for (;;) {
163
+ if (seen.has(current)) break;
164
+ seen.add(current);
165
+ const prefix = `${join(worldPaths(SERVICE, current).resources, 'assets')}/`;
166
+ for (const key of await getActiveBlobStore().list(prefix)) {
167
+ if (key.startsWith(prefix) && !key.slice(prefix.length).includes('/') && key.endsWith('.json')) out.add(key.slice(prefix.length, -'.json'.length));
168
+ }
169
+ const parent = readBranchMeta(SERVICE, current)?.parent;
170
+ if (!parent) break;
171
+ current = parent.at;
172
+ }
173
+ return [...out];
174
+ }
175
+
176
+ /** One video part, stored under its index (a retried PUT overwrites, as a re-upload does). */
177
+ export async function putPart(id: string, index: number, bytes: Uint8Array, root?: string): Promise<void> {
178
+ await getActiveBlobStore().put(join(resourcesDir(root), partKey(id, index)), bytes);
179
+ }
180
+
181
+ /** A part as stored on THIS branch. Parts are staging, not state a branch inherits: an upload is
182
+ * finalized where its parts were PUT (a parent's later PUTs never leak into a child's finalize). */
183
+ export async function readPart(id: string, index: number, root?: string): Promise<Uint8Array | null> {
184
+ return await getActiveBlobStore().get(join(resourcesDir(root), partKey(id, index)));
185
+ }
186
+
187
+ export async function clearParts(id: string, root?: string): Promise<void> {
188
+ const prefix = `${join(resourcesDir(root), 'asset-parts', safeId(id))}/`;
189
+ for (const key of await getActiveBlobStore().list(prefix)) {
190
+ try { await getActiveBlobStore().remove(key); } catch { /* already gone */ }
191
+ }
192
+ }
193
+
194
+ // ── signed upload URLs ────────────────────────────────────────────────────────────────────────
195
+
196
+ const hex = (bytes: ArrayBuffer): string => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');
197
+
198
+ /** A fresh per-asset upload secret: 32 bytes of entropy, hex. */
199
+ export function newUploadSecret(): string {
200
+ return hex(crypto.getRandomValues(new Uint8Array(32)).buffer as ArrayBuffer);
201
+ }
202
+
203
+ /** The `ut` a signed upload URL carries for one slot of one asset, keyed by that asset's own secret. */
204
+ export async function signUpload(asset: Pick<AssetRecord, 'id' | 'upload_secret'>, slot: string, expiresMs: number): Promise<string> {
205
+ // never an empty or missing key (Bun's WebCrypto refuses a zero-length HMAC key with a DataError anyway)
206
+ if (!hasUploadSecret(asset)) throw new Error(`linkedin: asset ${asset.id} has no upload secret to sign with`);
207
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(asset.upload_secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
208
+ return hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${asset.id}/${slot}/${expiresMs}`)));
209
+ }
210
+
211
+ /** Constant-time check of a presented `ut` against the one the asset's secret signs. */
212
+ export async function verifyUpload(asset: Pick<AssetRecord, 'id' | 'upload_secret'>, slot: string, expiresMs: number, presented: string): Promise<boolean> {
213
+ const expected = await signUpload(asset, slot, expiresMs);
214
+ if (presented.length !== expected.length) return false;
215
+ let diff = 0;
216
+ for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ presented.charCodeAt(i);
217
+ return diff === 0;
218
+ }
219
+
220
+ // cache: one chain of pending work per asset record (`<resources dir>|<id>`): every read-modify-write
221
+ // of `assets/<id>.json` (a part's ETag, a thumbnail, finalize) runs one after another, so concurrent
222
+ // part PUTs keep every ETag and a late part can never overwrite a finalized record.
223
+ const assetChains = new Map<string, Promise<unknown>>();
224
+
225
+ /** Run `fn` after every earlier `withAsset` of the same asset in this process has settled. */
226
+ export async function withAsset<T>(id: string, root: string | undefined, fn: () => Promise<T>): Promise<T> {
227
+ const key = `${resourcesDir(root)}|${id}`;
228
+ const prior = assetChains.get(key) ?? Promise.resolve();
229
+ const run = prior.then(fn, fn);
230
+ const settled = run.then(() => undefined, () => undefined);
231
+ assetChains.set(key, settled);
232
+ try { return await run; } finally { if (assetChains.get(key) === settled) assetChains.delete(key); }
233
+ }
234
+
235
+ // ── a perform's finalized video, kept across retries ─────────────────────────────────────────
236
+ // A perform that finalized a video at the vendor and then failed (the wait, the post) keeps the
237
+ // vendor's video URN here, keyed by the entry, so its retry posts THAT video instead of uploading the
238
+ // file a second time (YouTube's saved session, the same annex pattern).
239
+
240
+ export type PerformVideo = { video: string; sha256: string };
241
+ const performKey = (actionId: string, root?: string): string => join(resourcesDir(root), 'perform-videos', `${blobDigest(new TextEncoder().encode(actionId))}.json`);
242
+
243
+ export async function readPerformVideo(actionId: string, root?: string): Promise<PerformVideo | null> {
244
+ const stored = await getActiveBlobStore().get(performKey(actionId, root));
245
+ if (stored === null) return null;
246
+ try { const v = JSON.parse(new TextDecoder().decode(stored)) as PerformVideo; return typeof v.video === 'string' && typeof v.sha256 === 'string' ? v : null; } catch { return null; }
247
+ }
248
+ export async function writePerformVideo(actionId: string, record: PerformVideo, root?: string): Promise<void> {
249
+ await getActiveBlobStore().put(performKey(actionId, root), new TextEncoder().encode(JSON.stringify(record)));
250
+ }
251
+ export async function clearPerformVideo(actionId: string, root?: string): Promise<void> {
252
+ try { await getActiveBlobStore().remove(performKey(actionId, root)); } catch { /* none */ }
253
+ }
254
+
255
+ // ── what the bytes are ──────────────────────────────────────────────────────────────────────
256
+ // LinkedIn decides an image's type and size from the bytes it received. The twin does the same:
257
+ // the three formats the Images API accepts (JPG, GIF, PNG), read from their headers. Transcribed
258
+ // from x-media.ts (a cross-pack import is refused by scripts/architecture.test.ts, A3).
259
+
260
+ export type ImageInfo = { mediaType: 'image/png' | 'image/jpeg' | 'image/gif'; width: number; height: number };
261
+
262
+ export function sniffImage(bytes: Uint8Array): ImageInfo | undefined {
263
+ const b = bytes;
264
+ const u16be = (i: number) => (b[i]! << 8) | b[i + 1]!;
265
+ const u16le = (i: number) => b[i]! | (b[i + 1]! << 8);
266
+ const u32be = (i: number) => ((b[i]! << 24) >>> 0) + (b[i + 1]! << 16) + (b[i + 2]! << 8) + b[i + 3]!;
267
+ if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
268
+ return { mediaType: 'image/png', width: u32be(16), height: u32be(20) };
269
+ }
270
+ if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) {
271
+ return { mediaType: 'image/gif', width: u16le(6), height: u16le(8) };
272
+ }
273
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
274
+ let i = 2;
275
+ while (i + 9 < b.length) {
276
+ if (b[i] !== 0xff) { i += 1; continue; }
277
+ const marker = b[i + 1]!;
278
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
279
+ const length = u16be(i + 2);
280
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
281
+ return { mediaType: 'image/jpeg', height: u16be(i + 5), width: u16be(i + 7) };
282
+ }
283
+ i += 2 + length;
284
+ }
285
+ }
286
+ return undefined;
287
+ }
288
+
289
+ export type VideoInfo = { width: number; height: number; durationMs: number };
290
+
291
+ /** An MP4 (ISO BMFF) read from its boxes: `ftyp`, then `moov` (anywhere) holding `mvhd` (timescale +
292
+ * duration) and a `trak` whose `tkhd` carries the presented size. Nothing is decoded. */
293
+ export function sniffVideo(bytes: Uint8Array): VideoInfo | undefined {
294
+ const b = bytes;
295
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
296
+ const type = (at: number) => String.fromCharCode(b[at + 4]!, b[at + 5]!, b[at + 6]!, b[at + 7]!);
297
+ const boxes = (start: number, end: number): Array<[string, number, number]> => {
298
+ const out: Array<[string, number, number]> = [];
299
+ let at = start;
300
+ while (at + 8 <= end) {
301
+ let size = view.getUint32(at);
302
+ let header = 8;
303
+ if (size === 1) {
304
+ if (at + 16 > end) break;
305
+ size = Number(view.getBigUint64(at + 8));
306
+ header = 16;
307
+ } else if (size === 0) size = end - at;
308
+ if (size < header || at + size > end) break;
309
+ out.push([type(at), at + header, at + size]);
310
+ at += size;
311
+ }
312
+ return out;
313
+ };
314
+ const top = boxes(0, b.length);
315
+ if (top[0]?.[0] !== 'ftyp') return undefined;
316
+ const moov = top.find(([t]) => t === 'moov');
317
+ if (!moov) return undefined;
318
+ let durationMs: number | undefined;
319
+ let width = 0;
320
+ let height = 0;
321
+ for (const [t, start, end] of boxes(moov[1], moov[2])) {
322
+ if (t === 'mvhd' && end - start >= 32) {
323
+ const version = b[start]!;
324
+ const timescale = version === 1 ? view.getUint32(start + 20) : view.getUint32(start + 12);
325
+ const duration = version === 1 ? Number(view.getBigUint64(start + 24)) : view.getUint32(start + 16);
326
+ if (timescale > 0) durationMs = Math.round((duration * 1000) / timescale);
327
+ }
328
+ if (t === 'trak') {
329
+ const tkhd = boxes(start, end).find(([k]) => k === 'tkhd');
330
+ if (!tkhd) continue;
331
+ const version = b[tkhd[1]]!;
332
+ const at = tkhd[1] + (version === 1 ? 88 : 76);
333
+ if (at + 8 > tkhd[2]) continue;
334
+ const w = view.getUint32(at) >>> 16;
335
+ const h = view.getUint32(at + 4) >>> 16;
336
+ if (w > 0 && h > 0 && width === 0) { width = w; height = h; }
337
+ }
338
+ }
339
+ if (durationMs === undefined || width === 0) return undefined;
340
+ return { width, height, durationMs };
341
+ }
342
+
343
+ /** The reduced aspect ratio LinkedIn reports (16:9 → 16 and 9). */
344
+ export function aspectRatio(width: number, height: number): { w: number; h: number } {
345
+ const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
346
+ const g = gcd(width, height) || 1;
347
+ return { w: width / g, h: height / g };
348
+ }
349
+
350
+ /**
351
+ * The system thumbnail LinkedIn cuts from a video when the uploader sends none. The twin decodes
352
+ * nothing, so it serves a STAND-IN at the video's own aspect — a dark frame with a play glyph — and
353
+ * says so here; an uploaded thumbnail (`uploadThumbnail: true`) is served as uploaded.
354
+ */
355
+ export function videoPosterSvg(width: number, height: number): string {
356
+ const w = width > 0 ? width : 1280;
357
+ const h = height > 0 ? height : 720;
358
+ const r = Math.round(Math.min(w, h) * 0.09);
359
+ const cx = w / 2;
360
+ const cy = h / 2;
361
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#1d2226"/>`
362
+ + `<circle cx="${cx}" cy="${cy}" r="${r}" fill="rgba(0,0,0,0.6)" stroke="#fff" stroke-width="${Math.max(2, r * 0.06)}"/><path d="M${cx - r * 0.3} ${cy - r * 0.45}L${cx + r * 0.5} ${cy}L${cx - r * 0.3} ${cy + r * 0.45}Z" fill="#fff"/></svg>`;
363
+ }
@@ -0,0 +1,182 @@
1
+ // LINKEDIN MIRROR UI — linkedin.com's own view of the twin's state: an organization's company page
2
+ // (its header with logo, name and follower line, and its Posts feed), a post card with commentary
3
+ // and an inline video player or image or article card, and a single post's page. A React/TSX app
4
+ // bundled by Bun that renders by consuming the twin's OWN LinkedIn API on the same origin
5
+ // (GET /v2/userinfo, /rest/organizationAcls, /rest/organizations, /rest/networkSizes,
6
+ // /rest/posts?q=author, /rest/posts/{urn}, /rest/images/{urn}, /rest/videos/{urn}) — the same routes
7
+ // any LinkedIn client uses — so every screen is data-coupled to real twin state. Archetype A
8
+ // (passthrough), transcribed from the X mirror: one serving code path, so API↔UI parity cannot drift.
9
+ //
10
+ // SIGN-IN. linkedin.com's own two-step sign-in (email, then password) with the password leg replaced
11
+ // by the access token the twin issued through /_twin/tokens: the mirror is a client like any other
12
+ // and presents a registered token rather than bypassing the auth gate. The email is checked against
13
+ // the token's own member (`/v2/userinfo`), and the page it opens is the organization that member
14
+ // administers (`organizationAcls?q=roleAssignee`). Both are kept in the TAB'S sessionStorage.
15
+ //
16
+ // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
17
+ // fetch adapter as its API backend and reads every byte of state back over the wire.
18
+ import { readFile } from 'node:fs/promises';
19
+ import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
20
+ import { createLinkedinTwinFetch } from './linkedin-server.ts';
21
+
22
+ const CLIENT_ENTRY = () => new URL('../client/linkedin-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
23
+ const CLIENT_CSS = () => new URL('../client/linkedin-mirror.css', import.meta.url).pathname;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Pure, dependency-free render/format helpers (importable by the React client; Bun tree-shakes the
27
+ // server-only exports out of the browser bundle). A capability verify renders the mirror's OWN
28
+ // functions over twin state.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type LiRow = Record<string, any>;
32
+
33
+ /** The version the mirror's reads pin — the twin's newest. */
34
+ export const MIRROR_VERSION = '202609';
35
+
36
+ /** linkedin.com's feed timestamp: "now", "5m", "3h", "2d", "1w", "3mo", "1yr". `nowMs` is passed in. */
37
+ export function relativeTime(ms: unknown, nowMs: number): string {
38
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';
39
+ const s = Math.max(0, Math.floor((nowMs - ms) / 1000));
40
+ if (s < 60) return 'now';
41
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
42
+ if (s < 86_400) return `${Math.floor(s / 3600)}h`;
43
+ if (s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`;
44
+ if (s < 30 * 86_400) return `${Math.floor(s / (7 * 86_400))}w`;
45
+ if (s < 365 * 86_400) return `${Math.floor(s / (30 * 86_400))}mo`;
46
+ return `${Math.floor(s / (365 * 86_400))}yr`;
47
+ }
48
+
49
+ /** "12,480 followers" — the follower line under a page's name and a post's author. */
50
+ export function followerLine(count: unknown): string {
51
+ const n = typeof count === 'number' && Number.isFinite(count) ? count : 0;
52
+ return `${n.toLocaleString('en-US')} ${n === 1 ? 'follower' : 'followers'}`;
53
+ }
54
+
55
+ export type Segment = { kind: 'text' | 'hashtag' | 'mention' | 'url'; value: string; urn?: string };
56
+
57
+ /**
58
+ * A post's commentary as linkedin.com draws it, from the little text format LinkedIn stores:
59
+ * `{hashtag|\#|coding}` is the hashtag `#coding`, `@[Devtestco](urn:li:organization:2414183)` the
60
+ * mention `Devtestco`, a URL a link; everything else plain text (backslash escapes undone).
61
+ */
62
+ export function commentarySegments(commentary: unknown): Segment[] {
63
+ const source = typeof commentary === 'string' ? commentary : '';
64
+ const out: Segment[] = [];
65
+ const re = /\{hashtag\|\\?#\|([^}]+)\}|@\[([^\]]+)\]\((urn:li:[A-Za-z]+:[A-Za-z0-9_-]+)\)|(https?:\/\/[^\s]+)/g;
66
+ let last = 0;
67
+ const text = (s: string) => { if (s !== '') out.push({ kind: 'text', value: s.replace(/\\([()[\]{}<>@|~_*#\\])/g, '$1') }); };
68
+ for (const m of source.matchAll(re)) {
69
+ const at = m.index ?? 0;
70
+ text(source.slice(last, at));
71
+ if (m[1] !== undefined) out.push({ kind: 'hashtag', value: `#${m[1]}` });
72
+ else if (m[2] !== undefined) out.push({ kind: 'mention', value: m[2], urn: m[3] });
73
+ else {
74
+ const value = m[4]!.replace(/[.,;:!?)\]'"]+$/, '');
75
+ out.push({ kind: 'url', value });
76
+ last = at + value.length;
77
+ continue;
78
+ }
79
+ last = at + m[0].length;
80
+ }
81
+ text(source.slice(last));
82
+ return out;
83
+ }
84
+
85
+ /** Whether a commentary needs linkedin.com's "…more" fold: over three lines or ~210 characters. */
86
+ export function needsFold(commentary: unknown): boolean {
87
+ const s = typeof commentary === 'string' ? commentary : '';
88
+ return s.split('\n').length > 3 || s.length > 210;
89
+ }
90
+
91
+ /** The kind of asset a post's content names, from its URN. */
92
+ export function mediaKind(post: LiRow): 'image' | 'video' | 'article' | undefined {
93
+ const content = post.content as LiRow | undefined;
94
+ if (content?.article) return 'article';
95
+ const id = content?.media?.id;
96
+ if (typeof id !== 'string') return undefined;
97
+ if (id.startsWith('urn:li:video:')) return 'video';
98
+ if (id.startsWith('urn:li:image:')) return 'image';
99
+ return undefined;
100
+ }
101
+
102
+ /** A URL's host, as an article card prints its source ("github.com"). */
103
+ export function sourceHost(url: unknown): string {
104
+ try { return new URL(String(url)).host.replace(/^www\./, ''); } catch { return ''; }
105
+ }
106
+
107
+ /** A company page as the mirror draws it, from the reads it makes: the public lookup by vanity name,
108
+ * the administered organization (its description; absent when the viewer is not an admin), the
109
+ * follower count, and the logo image. Every field comes from those answers, nothing else. */
110
+ export type MirrorOrg = { id: number; urn: string; name: string; vanity: string; logoUrl?: string; followers?: number; description?: string; website?: string };
111
+
112
+ export function orgFromReads(pub: LiRow, admin: LiRow | null, size: LiRow | null, logo: LiRow | null): MirrorOrg {
113
+ return {
114
+ id: Number(pub.id), urn: `urn:li:organization:${pub.id}`, name: String(pub.localizedName), vanity: String(pub.vanityName),
115
+ ...(logo && typeof logo.downloadUrl === 'string' ? { logoUrl: logo.downloadUrl } : {}),
116
+ ...(size && typeof size.firstDegreeSize === 'number' ? { followers: size.firstDegreeSize } : {}),
117
+ ...(admin && typeof admin.localizedDescription === 'string' ? { description: admin.localizedDescription } : {}),
118
+ ...(typeof pub.localizedWebsite === 'string' ? { website: pub.localizedWebsite } : {}),
119
+ };
120
+ }
121
+
122
+ /** The mirror's own route for a post: linkedin.com's `/feed/update/<urn>`. */
123
+ export function postRoute(urn: string): string {
124
+ return `#/feed/update/${encodeURIComponent(urn)}`;
125
+ }
126
+
127
+ /** The finder read a company page's feed draws. */
128
+ export function postsQuery(orgUrn: string, start = 0): string {
129
+ return `/rest/posts?q=author&author=${encodeURIComponent(orgUrn)}&count=10&start=${start}&sortBy=CREATED`;
130
+ }
131
+
132
+ const APP_SHELL = `<!doctype html>
133
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
134
+ <base href="/"><title>LinkedIn</title><link rel="stylesheet" href="assets/styles.css"></head>
135
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
136
+
137
+ let clientBundle: Promise<string> | null = null;
138
+ /** Build the React/TSX mirror client to browser JS; memoized at module scope, so a pack on its own
139
+ * does exactly one `Bun.build`. */
140
+ export function buildLinkedinMirrorClient(): Promise<string> {
141
+ if (!clientBundle) {
142
+ clientBundle = bundleClient(CLIENT_ENTRY()).catch((error) => { clientBundle = null; throw error; });
143
+ }
144
+ return clientBundle;
145
+ }
146
+
147
+ /** Serve the LinkedIn mirror UI (React app) + its backing LinkedIn API on one origin. */
148
+ export async function createLinkedinMirrorServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; url: string; stop: () => void }> {
149
+ const twin = createLinkedinTwinFetch(options);
150
+ const server = await serveHttp({
151
+ hostname: '127.0.0.1',
152
+ port: options.port ?? 0,
153
+ idleTimeout: 60,
154
+ async fetch(request) {
155
+ const url = new URL(request.url);
156
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
157
+ try { return new Response(await buildLinkedinMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
158
+ catch (error) { return new Response(String(error), { status: 500 }); }
159
+ }
160
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
161
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
162
+ }
163
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
164
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
165
+ }
166
+ // Everything else -> the twin's OWN FETCH ADAPTER, the same closure createLinkedinTwinServer serves.
167
+ return twin(request);
168
+ },
169
+ });
170
+ const port = server.port ?? options.port ?? 0;
171
+ return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
172
+ }
173
+
174
+ /** The app-shell HTML (pure). */
175
+ export function linkedinMirrorHtml(): string {
176
+ return APP_SHELL;
177
+ }
178
+
179
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
180
+ export function linkedinMirrorStyles(): Promise<string> {
181
+ return readFile(CLIENT_CSS(), 'utf8');
182
+ }