@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.
- package/LICENSE +202 -0
- package/README.md +39 -0
- package/dist/client/linkedin-mirror.bundle.js +323 -0
- package/dist/client/linkedin-mirror.d.ts +43 -0
- package/dist/client/linkedin-mirror.js +393 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +74 -0
- package/dist/src/linkedin-budget.d.ts +36 -0
- package/dist/src/linkedin-budget.js +87 -0
- package/dist/src/linkedin-capabilities.d.ts +4 -0
- package/dist/src/linkedin-capabilities.js +1027 -0
- package/dist/src/linkedin-conformance.d.ts +8 -0
- package/dist/src/linkedin-conformance.js +58 -0
- package/dist/src/linkedin-connector.d.ts +66 -0
- package/dist/src/linkedin-connector.js +326 -0
- package/dist/src/linkedin-errors.d.ts +21 -0
- package/dist/src/linkedin-errors.js +38 -0
- package/dist/src/linkedin-media.d.ts +125 -0
- package/dist/src/linkedin-media.js +331 -0
- package/dist/src/linkedin-mirror-ui.d.ts +59 -0
- package/dist/src/linkedin-mirror-ui.js +174 -0
- package/dist/src/linkedin-server.d.ts +10 -0
- package/dist/src/linkedin-server.js +154 -0
- package/dist/src/linkedin-twin.d.ts +23 -0
- package/dist/src/linkedin-twin.js +1220 -0
- package/package.json +58 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +118 -0
- package/src/linkedin-budget.ts +108 -0
- package/src/linkedin-capabilities.ts +1057 -0
- package/src/linkedin-conformance.ts +73 -0
- package/src/linkedin-connector.ts +314 -0
- package/src/linkedin-errors.ts +58 -0
- package/src/linkedin-media.ts +363 -0
- package/src/linkedin-mirror-ui.ts +182 -0
- package/src/linkedin-server.ts +150 -0
- package/src/linkedin-twin.ts +1143 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
// THE LINKEDIN PACK'S CAPABILITY MANIFEST — the real vendor surface as the DENOMINATOR, not a list
|
|
2
|
+
// of what got built. The denominator is LinkedIn's Community Management API as an ORGANIZATION uses
|
|
3
|
+
// it to publish (learn.microsoft.com/linkedin, Marketing li-lms-2026-09): the Posts API with its
|
|
4
|
+
// content kinds, the Images, Videos and Documents APIs, the organization's access control, lookup and
|
|
5
|
+
// follower count, the social surfaces around a post (comments, reactions, social metadata), the
|
|
6
|
+
// page and share statistics, and the OpenID userinfo a sign-in reads. The Ads (Campaign Manager)
|
|
7
|
+
// APIs, Lead Sync, Events, Talent and the member-profile surfaces are other LinkedIn products this
|
|
8
|
+
// manifest deliberately does NOT enumerate; claiming them here would inflate a denominator nothing
|
|
9
|
+
// here measures.
|
|
10
|
+
//
|
|
11
|
+
// Every `done` below drives the handler the fetch adapter wraps (`handleLinkedinTwinRequest`) on a
|
|
12
|
+
// fresh root with a pinned instant, and asserts VALUES and the vendor's refusals — never a status
|
|
13
|
+
// alone. The mirror's `done`s render the mirror's OWN components over what the handler served.
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
|
|
18
|
+
import { observeResource, pendingActions, performEntries, wholeLog, writeBranchMeta, writeRoot } from '@volter/world-core';
|
|
19
|
+
import { createElement } from 'react';
|
|
20
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
21
|
+
import { handleLinkedinTwinRequest } from "./linkedin-twin.js";
|
|
22
|
+
import { budgetedLinkedinExecute, performLinkedinAction as performServed, performLinkedinActionWithin, processingPollWait, syncLinkedinFromRemote, untemplateHashtags } from "./linkedin-connector.js";
|
|
23
|
+
import { LINKEDIN_CALL_WEIGHTS, LINKEDIN_RATE_BUDGET, LinkedinBudget, LinkedinBudgetError, linkedinCallWeight } from "./linkedin-budget.js";
|
|
24
|
+
import { commentarySegments, createLinkedinMirrorServer, followerLine, orgFromReads, relativeTime } from "./linkedin-mirror-ui.js";
|
|
25
|
+
import { assetIsOwn, readAsset, signUpload, writeAsset } from "./linkedin-media.js";
|
|
26
|
+
import { CompanyHeader, PostCard } from "../client/linkedin-mirror.js";
|
|
27
|
+
const ORG = 'urn:li:organization:2414183';
|
|
28
|
+
const OTHER_ORG = 'urn:li:organization:5515715';
|
|
29
|
+
const TOKEN = 'org-admin-token-0001';
|
|
30
|
+
const SCOPES = ['w_organization_social', 'r_organization_social', 'rw_organization_admin', 'openid', 'profile', 'email'];
|
|
31
|
+
const API = { authorization: `Bearer ${TOKEN}`, 'linkedin-version': '202609', 'x-restli-protocol-version': '2.0.0' };
|
|
32
|
+
const AT = '2026-09-26T12:00:00.000Z';
|
|
33
|
+
/** Ten seconds before AT: an upload finalized then has finished processing (PROCESSING_MS) by AT. */
|
|
34
|
+
const EARLY = '2026-09-26T11:59:50.000Z';
|
|
35
|
+
const LATER = (ms) => new Date(Date.parse(AT) + ms).toISOString();
|
|
36
|
+
function handleFor(root, at = AT) {
|
|
37
|
+
return (step) => handleLinkedinTwinRequest({
|
|
38
|
+
method: step.m, path: step.p, root, occurredAt: step.at ?? at, readOnly: step.readOnly,
|
|
39
|
+
headers: step.headers ?? API,
|
|
40
|
+
...(step.b === undefined ? {} : { body: JSON.stringify(step.b) }),
|
|
41
|
+
...(step.bytes ? { bytes: step.bytes } : {}),
|
|
42
|
+
...(step.base ? { publicBase: step.base } : {}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function seed(h, opts = {}) {
|
|
46
|
+
const member = opts.member ?? 'orgAdmin';
|
|
47
|
+
await h({ m: 'POST', p: '/_twin/organizations', b: { id: '2414183', localizedName: 'Volter', vanityName: 'volter', localizedWebsite: 'https://volter.ai', localizedDescription: 'Twins for every SaaS.', followerCount: 12480 }, headers: {} });
|
|
48
|
+
await h({ m: 'POST', p: '/_twin/organizations', b: { id: '5515715', localizedName: 'Other Co', vanityName: 'other-co' }, headers: {} });
|
|
49
|
+
await h({ m: 'POST', p: '/_twin/members', b: { id: member, localizedFirstName: 'Aaron', localizedLastName: 'Admin', email: opts.email ?? 'admin@volter.ai' }, headers: {} });
|
|
50
|
+
await h({ m: 'POST', p: '/_twin/organizationAcls', b: { organization: ORG, roleAssignee: `urn:li:person:${member}`, role: opts.role ?? 'ADMINISTRATOR', state: 'APPROVED' }, headers: {} });
|
|
51
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: opts.token ?? TOKEN, member: `urn:li:person:${member}`, scopes: opts.scopes ?? SCOPES }, headers: {} });
|
|
52
|
+
}
|
|
53
|
+
/** A throwaway world with the page, its admin, the admin role and the token the OAuth leg would have minted. */
|
|
54
|
+
async function withWorld(fn, opts = {}) {
|
|
55
|
+
const root = mkdtempSync(join(tmpdir(), 'linkedin-cap-'));
|
|
56
|
+
const h = handleFor(root);
|
|
57
|
+
try {
|
|
58
|
+
return await verifyBoundary('linkedin.withWorld', async () => { await seed(h, opts); return fn(h, root); });
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rmSync(root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const textPost = (commentary, extra = {}) => ({
|
|
65
|
+
author: ORG, commentary, visibility: 'PUBLIC',
|
|
66
|
+
distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
|
|
67
|
+
lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false, ...extra,
|
|
68
|
+
});
|
|
69
|
+
async function createPost(h, body) {
|
|
70
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b: body });
|
|
71
|
+
if (r.status !== 201)
|
|
72
|
+
throw new Error(`create answered ${r.status}: ${JSON.stringify(r.body)}`);
|
|
73
|
+
return String(r.headers?.['x-restli-id']);
|
|
74
|
+
}
|
|
75
|
+
const enc = (urn) => encodeURIComponent(urn);
|
|
76
|
+
// ── media fixtures: the smallest bytes the twin reads as an image and as a video ─────────────
|
|
77
|
+
function box(type, payload) {
|
|
78
|
+
const out = new Uint8Array(8 + payload.length);
|
|
79
|
+
new DataView(out.buffer).setUint32(0, out.length);
|
|
80
|
+
out.set(new TextEncoder().encode(type), 4);
|
|
81
|
+
out.set(payload, 8);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
const concat = (...parts) => {
|
|
85
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
86
|
+
let at = 0;
|
|
87
|
+
for (const p of parts) {
|
|
88
|
+
out.set(p, at);
|
|
89
|
+
at += p.length;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
/** A PNG header naming a 64x48 image (the twin reads type and size from the header, as LinkedIn does). */
|
|
94
|
+
function tinyPng() {
|
|
95
|
+
const ihdr = new Uint8Array(13);
|
|
96
|
+
new DataView(ihdr.buffer).setUint32(0, 64);
|
|
97
|
+
new DataView(ihdr.buffer).setUint32(4, 48);
|
|
98
|
+
ihdr.set([8, 2, 0, 0, 0], 8);
|
|
99
|
+
return concat(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), box('IHDR', ihdr), new Uint8Array(4));
|
|
100
|
+
}
|
|
101
|
+
/** An MP4's boxes for a video of `ms` at 1280x720, with `pad` bytes of mdat (the feed's floor is 75 KB). */
|
|
102
|
+
function mp4(ms = 3000, pad = 80 * 1024) {
|
|
103
|
+
const mvhd = new Uint8Array(100);
|
|
104
|
+
new DataView(mvhd.buffer).setUint32(12, 1000);
|
|
105
|
+
new DataView(mvhd.buffer).setUint32(16, ms);
|
|
106
|
+
const tkhd = new Uint8Array(84);
|
|
107
|
+
new DataView(tkhd.buffer).setUint32(76, 1280 << 16);
|
|
108
|
+
new DataView(tkhd.buffer).setUint32(80, 720 << 16);
|
|
109
|
+
const fill = new Uint8Array(pad);
|
|
110
|
+
for (let i = 0; i < pad; i += 1)
|
|
111
|
+
fill[i] = (i * 31 + 7) % 251;
|
|
112
|
+
return concat(box('ftyp', new TextEncoder().encode('isom\0\0\x02\0isom')), box('mdat', fill), box('moov', concat(box('mvhd', mvhd), box('trak', box('tkhd', tkhd)))));
|
|
113
|
+
}
|
|
114
|
+
const pathOf = (url) => { const u = new URL(url); return u.pathname + u.search; };
|
|
115
|
+
/** The same upload URL with its signature's first hex digit changed — always a different signature. */
|
|
116
|
+
const forge = (path) => path.replace(/ut=([0-9a-f])/, (_m, c) => `ut=${c === '0' ? '1' : '0'}`);
|
|
117
|
+
/** Upload an image through initializeUpload and the bearer-carrying PUT; returns its URN. */
|
|
118
|
+
async function uploadImage(h, bytes = tinyPng(), owner = ORG) {
|
|
119
|
+
const init = (await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner } } })).body;
|
|
120
|
+
const put = await h({ m: 'PUT', p: pathOf(init.value.uploadUrl), bytes, headers: { authorization: API.authorization } });
|
|
121
|
+
if (put.status !== 201)
|
|
122
|
+
throw new Error(`image PUT answered ${put.status}`);
|
|
123
|
+
return String(init.value.image);
|
|
124
|
+
}
|
|
125
|
+
/** A video through initialize, the part PUTs (no bearer) and finalize — done ten seconds before AT, so
|
|
126
|
+
* by AT it has finished processing; `processing: true` does it at AT, so at AT it is still PROCESSING. */
|
|
127
|
+
async function uploadVideo(h, bytes = mp4(), opts = {}) {
|
|
128
|
+
const at = opts.processing ? AT : EARLY;
|
|
129
|
+
const init = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', at, b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: opts.thumbnail !== undefined } } })).body;
|
|
130
|
+
const etags = [];
|
|
131
|
+
for (const part of init.value.uploadInstructions) {
|
|
132
|
+
const put = await h({ m: 'PUT', p: pathOf(part.uploadUrl), at, bytes: bytes.subarray(part.firstByte, part.lastByte + 1), headers: {} });
|
|
133
|
+
etags.push(String(put.headers?.etag));
|
|
134
|
+
}
|
|
135
|
+
if (opts.thumbnail)
|
|
136
|
+
await h({ m: 'PUT', p: pathOf(init.value.thumbnailUploadUrl), at, bytes: opts.thumbnail, headers: {} });
|
|
137
|
+
await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', at, b: { finalizeUploadRequest: { video: init.value.video, uploadToken: '', uploadedPartIds: etags } } });
|
|
138
|
+
return { urn: String(init.value.video), init, etags };
|
|
139
|
+
}
|
|
140
|
+
/** An in-process VENDOR: the kernel executor's shape, answered by a second LinkedIn twin on its own
|
|
141
|
+
* root. A presigned request carries the absolute upload URL the vendor twin minted. */
|
|
142
|
+
function vendorExecutor(vendorRoot, calls) {
|
|
143
|
+
// the vendor's clock moves 3 s per call, so a video it finalized is AVAILABLE by the next status read
|
|
144
|
+
let clock = Date.parse('2026-09-26T13:00:00.000Z');
|
|
145
|
+
return async (request) => {
|
|
146
|
+
clock += 3_000;
|
|
147
|
+
const presigned = request.presigned === true;
|
|
148
|
+
const path = presigned ? pathOf(request.path) : request.path;
|
|
149
|
+
const headers = { ...(request.headers ?? {}), ...(presigned ? {} : { authorization: 'Bearer vendor-token-0002' }) };
|
|
150
|
+
calls.push({ method: request.method, path, presigned, auth: 'authorization' in headers });
|
|
151
|
+
const body = request.body;
|
|
152
|
+
const res = await handleLinkedinTwinRequest({
|
|
153
|
+
method: request.method, path, headers, root: vendorRoot, occurredAt: new Date(clock).toISOString(), publicBase: 'https://www.linkedin.com',
|
|
154
|
+
...(body instanceof Uint8Array ? { bytes: body } : body !== undefined ? { body } : {}),
|
|
155
|
+
});
|
|
156
|
+
return { status: res.status, headers: res.headers ?? {}, body: res.body === null || res.body === undefined || res.body === '' ? '' : JSON.stringify(res.body) };
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function withVendor(fn) {
|
|
160
|
+
const localRoot = mkdtempSync(join(tmpdir(), 'linkedin-cap-local-'));
|
|
161
|
+
const vendorRoot = mkdtempSync(join(tmpdir(), 'linkedin-cap-vendor-'));
|
|
162
|
+
try {
|
|
163
|
+
return await verifyBoundary('linkedin.withVendor', async () => {
|
|
164
|
+
const local = handleFor(localRoot);
|
|
165
|
+
await seed(local);
|
|
166
|
+
await seed(handleFor(vendorRoot), { member: 'vendorAdmin', token: 'vendor-token-0002', email: 'vendor@volter.ai' });
|
|
167
|
+
return fn(local, localRoot, vendorRoot);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
rmSync(localRoot, { recursive: true, force: true });
|
|
172
|
+
rmSync(vendorRoot, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const entryFor = (root, subjectId, operation) => pendingActions('linkedin', root).find((a) => a.subject?.id === subjectId && a.operation === operation);
|
|
176
|
+
const ctxFor = (root) => ({ resolve: (_t, id) => id, root, service: 'linkedin' });
|
|
177
|
+
/** A throwaway ledger per verify: a verify never spends the operator's LinkedIn allowance. */
|
|
178
|
+
const scratchBudget = () => new LinkedinBudget({ path: join(mkdtempSync(join(tmpdir(), 'linkedin-budget-')), 'ledger.json') });
|
|
179
|
+
const noWait = async () => undefined;
|
|
180
|
+
const performLinkedinAction = (execute, action, ctx) => performLinkedinActionWithin(scratchBudget(), execute, action, ctx, noWait);
|
|
181
|
+
const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
182
|
+
const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
183
|
+
export const LINKEDIN_AREAS = ['versioning', 'auth', 'organizations', 'posts', 'images', 'videos', 'documents', 'social', 'statistics', 'errors', 'twin_control', 'connector', 'rate_limit', 'mirror'];
|
|
184
|
+
export const LINKEDIN_CAPABILITIES = [
|
|
185
|
+
// ── versioning (Versioning; Error Responses examples 4 and 5) ────────────────────────────────
|
|
186
|
+
done('linkedin.versioning.header_required', 'versioning', 'A /rest call without Linkedin-Version is the 400 VERSION_MISSING body the docs quote', 'api', 'core', () => withWorld(async (h) => {
|
|
187
|
+
const r = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}`, headers: { authorization: API.authorization } });
|
|
188
|
+
const ok = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` });
|
|
189
|
+
return r.status === 400 && JSON.stringify(r.body) === JSON.stringify({ status: 400, code: 'VERSION_MISSING', message: 'A version must be present. Please specify a version by adding the Linkedin-Version header.' }) && ok.status === 200;
|
|
190
|
+
})),
|
|
191
|
+
done('linkedin.versioning.inactive_version_refused', 'versioning', 'A sunset or unknown version is 426 NONEXISTENT_VERSION; every active one (202510…202609, no 202512) is served', 'api', 'core', () => withWorld(async (h) => {
|
|
192
|
+
const get = (v) => h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}`, headers: { ...API, 'linkedin-version': v } });
|
|
193
|
+
const old = await get('202401');
|
|
194
|
+
const gap = await get('202512');
|
|
195
|
+
const actives = await Promise.all(['202510', '202511', '202601', '202605', '202609'].map(get));
|
|
196
|
+
return old.status === 426 && old.body.code === 'NONEXISTENT_VERSION' && old.body.message === 'Requested version 202401 is not active'
|
|
197
|
+
&& gap.status === 426 && actives.every((r) => r.status === 200);
|
|
198
|
+
})),
|
|
199
|
+
// ── auth ────────────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
done('linkedin.auth.bearer_required', 'auth', 'No bearer is 401 EMPTY_ACCESS_TOKEN; an unknown bearer is 401 INVALID_ACCESS_TOKEN', 'api', 'core', () => withWorld(async (h) => {
|
|
201
|
+
const none = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}`, headers: { 'linkedin-version': '202609' } });
|
|
202
|
+
const bad = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}`, headers: { ...API, authorization: 'Bearer nobody-issued-this' } });
|
|
203
|
+
return none.status === 401 && none.body.code === 'EMPTY_ACCESS_TOKEN' && bad.status === 401 && bad.body.code === 'INVALID_ACCESS_TOKEN';
|
|
204
|
+
})),
|
|
205
|
+
done('linkedin.auth.w_organization_social_gate', 'auth', 'Creating a post needs w_organization_social; a token without it is 403 ACCESS_DENIED and nothing lands', 'api', 'core', () => withWorld(async (h, root) => {
|
|
206
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b: textPost('no scope') });
|
|
207
|
+
return r.status === 403 && r.body.code === 'ACCESS_DENIED' && pendingActions('linkedin', root).every((a) => a.operation !== 'linkedin.post.create');
|
|
208
|
+
}, { scopes: ['r_organization_social', 'rw_organization_admin'] })),
|
|
209
|
+
done('linkedin.auth.r_organization_social_gate', 'auth', "Reading an organization's posts needs r_organization_social", 'api', 'core', () => withWorld(async (h) => {
|
|
210
|
+
const r = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` });
|
|
211
|
+
return r.status === 403 && r.body.code === 'ACCESS_DENIED' && String(r.body.message).includes('r_organization_social');
|
|
212
|
+
}, { scopes: ['w_organization_social'] })),
|
|
213
|
+
done('linkedin.auth.page_role_gate', 'auth', 'Posting as a page needs an APPROVED ADMINISTRATOR, DIRECT_SPONSORED_CONTENT_POSTER or CONTENT_ADMINISTRATOR role on it; ANALYST is refused, and so is another org', 'api', 'core', async () => (await withWorld(async (h) => {
|
|
214
|
+
const own = await h({ m: 'POST', p: '/rest/posts', b: textPost('as a content admin') });
|
|
215
|
+
const other = await h({ m: 'POST', p: '/rest/posts', b: textPost('as someone else', { author: OTHER_ORG }) });
|
|
216
|
+
return own.status === 201 && other.status === 403;
|
|
217
|
+
}, { role: 'CONTENT_ADMINISTRATOR' }))
|
|
218
|
+
&& (await withWorld(async (h) => (await h({ m: 'POST', p: '/rest/posts', b: textPost('analyst') })).status === 403, { role: 'ANALYST' }))),
|
|
219
|
+
// ── the member (OpenID userinfo) ─────────────────────────────────────────────────────────────
|
|
220
|
+
done('linkedin.userinfo.get', 'auth', "GET /v2/userinfo answers the token's member (sub, name, email) under the openid/profile/email scopes, with no version header", 'api', 'core', () => withWorld(async (h) => {
|
|
221
|
+
const r = await h({ m: 'GET', p: '/v2/userinfo', headers: { authorization: API.authorization } });
|
|
222
|
+
const b = r.body;
|
|
223
|
+
return r.status === 200 && b.sub === 'orgAdmin' && b.name === 'Aaron Admin' && b.given_name === 'Aaron' && b.family_name === 'Admin' && b.email === 'admin@volter.ai' && b.email_verified === true;
|
|
224
|
+
})),
|
|
225
|
+
done('linkedin.userinfo.openid_scope', 'auth', 'userinfo refuses a token without openid, and omits email without the email scope', 'api', 'common', async () => (await withWorld(async (h) => (await h({ m: 'GET', p: '/v2/userinfo', headers: { authorization: API.authorization } })).status === 403, { scopes: ['w_organization_social'] }))
|
|
226
|
+
&& (await withWorld(async (h) => {
|
|
227
|
+
const b = (await h({ m: 'GET', p: '/v2/userinfo', headers: { authorization: API.authorization } })).body;
|
|
228
|
+
return b.sub === 'orgAdmin' && b.name === 'Aaron Admin' && b.email === undefined;
|
|
229
|
+
}, { scopes: ['openid', 'profile'] }))),
|
|
230
|
+
// ── organizations (Organization Access Control by Role; Organization Lookup) ─────────────────
|
|
231
|
+
done('linkedin.organizations.acls_by_role_assignee', 'organizations', "organizationAcls?q=roleAssignee lists the member's own roles, filtered by role and state", 'api', 'core', () => withWorld(async (h) => {
|
|
232
|
+
await h({ m: 'POST', p: '/_twin/organizationAcls', b: { organization: OTHER_ORG, roleAssignee: 'urn:li:person:orgAdmin', role: 'ANALYST', state: 'REQUESTED' }, headers: {} });
|
|
233
|
+
const all = (await h({ m: 'GET', p: '/rest/organizationAcls?q=roleAssignee' })).body;
|
|
234
|
+
const admin = (await h({ m: 'GET', p: '/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED' })).body;
|
|
235
|
+
return all.elements?.length === 2 && admin.elements?.length === 1
|
|
236
|
+
&& JSON.stringify(admin.elements[0]) === JSON.stringify({ role: 'ADMINISTRATOR', organization: ORG, roleAssignee: 'urn:li:person:orgAdmin', state: 'APPROVED' })
|
|
237
|
+
&& admin.paging?.start === 0 && admin.paging?.count === 10;
|
|
238
|
+
})),
|
|
239
|
+
done('linkedin.organizations.acls_by_organization', 'organizations', "organizationAcls?q=organization lists a page's roles for its ADMINISTRATOR (organizationTarget), and refuses anyone else", 'api', 'common', () => withWorld(async (h) => {
|
|
240
|
+
const r = (await h({ m: 'GET', p: `/rest/organizationAcls?q=organization&organization=${enc(ORG)}` })).body;
|
|
241
|
+
const other = await h({ m: 'GET', p: `/rest/organizationAcls?q=organization&organization=${enc(OTHER_ORG)}` });
|
|
242
|
+
return r.elements?.[0]?.organizationTarget === ORG && r.elements[0].roleAssignee === 'urn:li:person:orgAdmin' && other.status === 403;
|
|
243
|
+
})),
|
|
244
|
+
done('linkedin.organizations.get_administered', 'organizations', 'GET /rest/organizations/{id} answers the administered page (name, vanityName, description, website, $URN); a non-admin is 403 and an unknown id 404', 'api', 'core', () => withWorld(async (h) => {
|
|
245
|
+
const r = await h({ m: 'GET', p: '/rest/organizations/2414183' });
|
|
246
|
+
const b = r.body;
|
|
247
|
+
const other = await h({ m: 'GET', p: '/rest/organizations/5515715' });
|
|
248
|
+
const none = await h({ m: 'GET', p: '/rest/organizations/999' });
|
|
249
|
+
return r.status === 200 && b.id === 2414183 && b.localizedName === 'Volter' && b.vanityName === 'volter' && b.localizedDescription === 'Twins for every SaaS.'
|
|
250
|
+
&& b.name?.localized?.en_US === 'Volter' && b.$URN === ORG && other.status === 403 && none.status === 404;
|
|
251
|
+
})),
|
|
252
|
+
done('linkedin.organizations.find_by_vanity_name', 'organizations', 'organizations?q=vanityName answers only the non-admin fields, case-insensitively', 'api', 'common', () => withWorld(async (h) => {
|
|
253
|
+
const b = (await h({ m: 'GET', p: '/rest/organizations?q=vanityName&vanityName=VOLTER' })).body;
|
|
254
|
+
const e = b.elements?.[0];
|
|
255
|
+
return b.elements?.length === 1 && e.id === 2414183 && e.vanityName === 'volter' && e.localizedWebsite === 'https://volter.ai' && e.localizedDescription === undefined && e.primaryOrganizationType === 'NONE';
|
|
256
|
+
})),
|
|
257
|
+
done('linkedin.organizations.follower_count', 'organizations', 'networkSizes/{org}?edgeType=COMPANY_FOLLOWED_BY_MEMBER answers the follower count; the pre-202305 edge type is refused', 'api', 'common', () => withWorld(async (h) => {
|
|
258
|
+
const r = await h({ m: 'GET', p: `/rest/networkSizes/${enc(ORG)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` });
|
|
259
|
+
const old = await h({ m: 'GET', p: `/rest/networkSizes/${enc(ORG)}?edgeType=CompanyFollowedByMember` });
|
|
260
|
+
return r.status === 200 && r.body.firstDegreeSize === 12480 && old.status === 400;
|
|
261
|
+
})),
|
|
262
|
+
done('linkedin.organizations.logo', 'organizations', "A page's logoV2 names an image it uploaded; the image answers a downloadUrl", 'api', 'common', () => withWorld(async (h) => {
|
|
263
|
+
const image = await uploadImage(h);
|
|
264
|
+
await h({ m: 'POST', p: '/_twin/organizations', b: { id: '2414183', localizedName: 'Volter', vanityName: 'volter', logo: image }, headers: {} });
|
|
265
|
+
const org = (await h({ m: 'GET', p: '/rest/organizations?q=vanityName&vanityName=volter' })).body;
|
|
266
|
+
const img = (await h({ m: 'GET', p: `/rest/images/${enc(image)}` })).body;
|
|
267
|
+
return org.elements[0].logoV2?.original === image && img.status === 'AVAILABLE' && /\/dms\/image\/[^/]+\/image-shrink_1280\/0\/[0-9a-f]{64}/.test(img.downloadUrl);
|
|
268
|
+
})),
|
|
269
|
+
todo('linkedin.organizations.batch_get', 'organizations', 'Batch get administered organizations (GET /rest/organizations?ids=List(...)) with per-id statuses', 'api', 'common'),
|
|
270
|
+
done('linkedin.organizations.lookup_non_admin', 'organizations', 'organizationsLookup?ids=List(...) answers pages the member does not administer, with only the non-admin fields, per id', 'api', 'common', () => withWorld(async (h) => {
|
|
271
|
+
const b = (await h({ m: 'GET', p: '/rest/organizationsLookup?ids=List(5515715,2414183,404)' })).body;
|
|
272
|
+
return b.results?.['5515715']?.localizedName === 'Other Co' && b.results['5515715'].vanityName === 'other-co' && b.results['2414183']?.localizedDescription === undefined
|
|
273
|
+
&& b.statuses?.['404'] === 404 && b.errors?.['404']?.code === 'NOT_FOUND' && b.statuses['5515715'] === 200;
|
|
274
|
+
})),
|
|
275
|
+
todo('linkedin.organizations.parent_finder', 'organizations', 'organizations?q=parentOrganization (brands under a parent)', 'api', 'niche'),
|
|
276
|
+
todo('linkedin.organizations.request_role', 'organizations', 'PUT /rest/organizationAcls/(…) to request the DIRECT_SPONSORED_CONTENT_POSTER role', 'api', 'niche'),
|
|
277
|
+
// ── posts (Posts API, Post Schema) ───────────────────────────────────────────────────────────
|
|
278
|
+
done('linkedin.posts.create_text', 'posts', 'POST /rest/posts creates a text post: 201, the URN in x-restli-id, no body', 'api', 'core', () => withWorld(async (h) => {
|
|
279
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b: textPost('Sample text Post') });
|
|
280
|
+
return r.status === 201 && /^urn:li:share:\d{19}$/.test(String(r.headers?.['x-restli-id'])) && r.body === null;
|
|
281
|
+
})),
|
|
282
|
+
done('linkedin.posts.get', 'posts', 'GET /rest/posts/{encoded urn} answers the Post schema: author, commentary, visibility, distribution, lifecycleState PUBLISHED, lifecycleStateInfo, createdAt/publishedAt/lastModifiedAt in ms, no bookkeeping', 'api', 'core', () => withWorld(async (h) => {
|
|
283
|
+
const urn = await createPost(h, textPost('read me back'));
|
|
284
|
+
const b = (await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` })).body;
|
|
285
|
+
const raw = (await h({ m: 'GET', p: `/rest/posts/${urn}` })).body;
|
|
286
|
+
return b.id === urn && b.author === ORG && b.commentary === 'read me back' && b.visibility === 'PUBLIC' && b.distribution?.feedDistribution === 'MAIN_FEED'
|
|
287
|
+
&& b.lifecycleState === 'PUBLISHED' && b.lifecycleStateInfo?.isEditedByAuthor === false && b.createdAt === Date.parse(AT) && b.publishedAt === b.createdAt && b.lastModifiedAt === b.createdAt
|
|
288
|
+
&& Object.keys(b).every((k) => !k.startsWith('_')) && raw.id === urn;
|
|
289
|
+
})),
|
|
290
|
+
done('linkedin.posts.hashtag_template', 'posts', 'A #hashtag in commentary is stored and answered as the little-format template {hashtag|\\#|tag}', 'api', 'common', () => withWorld(async (h) => {
|
|
291
|
+
const urn = await createPost(h, textPost('Follow best practices #coding (and #TypeScript), not me#this'));
|
|
292
|
+
const b = (await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` })).body;
|
|
293
|
+
return b.commentary === 'Follow best practices {hashtag|\\#|coding} (and {hashtag|\\#|TypeScript}), not me#this';
|
|
294
|
+
})),
|
|
295
|
+
done('linkedin.posts.create_requires_fields', 'posts', 'A create missing author, commentary, visibility, distribution or lifecycleState is 400 MISSING_FIELD naming it', 'api', 'core', () => withWorld(async (h) => {
|
|
296
|
+
for (const field of ['author', 'commentary', 'visibility', 'distribution', 'lifecycleState']) {
|
|
297
|
+
const body = textPost('x');
|
|
298
|
+
delete body[field];
|
|
299
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b: body });
|
|
300
|
+
if (r.status !== 400 || r.body.code !== 'MISSING_FIELD' || !String(r.body.message).startsWith(field))
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
304
|
+
})),
|
|
305
|
+
done('linkedin.posts.create_enum_values', 'posts', 'visibility, feedDistribution and lifecycleState outside their documented sets (and lifecycleState other than PUBLISHED on create) are 400 INVALID_VALUE_FOR_FIELD', 'api', 'core', () => withWorld(async (h) => {
|
|
306
|
+
const bad = [
|
|
307
|
+
textPost('x', { visibility: 'EVERYONE' }),
|
|
308
|
+
textPost('x', { distribution: { feedDistribution: 'SIDEBAR' } }),
|
|
309
|
+
textPost('x', { lifecycleState: 'DRAFT' }),
|
|
310
|
+
];
|
|
311
|
+
for (const b of bad) {
|
|
312
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b });
|
|
313
|
+
if (r.status !== 400 || r.body.code !== 'INVALID_VALUE_FOR_FIELD')
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
return (await h({ m: 'POST', p: '/rest/posts', b: textPost('dark', { distribution: { feedDistribution: 'NONE' } }) })).status === 201;
|
|
317
|
+
})),
|
|
318
|
+
done('linkedin.posts.author_urn_type', 'posts', 'An author that is not an organization or person URN is 400 INVALID_URN_TYPE', 'api', 'common', () => withWorld(async (h) => {
|
|
319
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b: textPost('x', { author: 'urn:li:company:1' }) });
|
|
320
|
+
return r.status === 400 && r.body.code === 'INVALID_URN_TYPE';
|
|
321
|
+
})),
|
|
322
|
+
done('linkedin.posts.find_by_author', 'posts', 'posts?q=author&author= lists the organization\'s posts newest first as {paging, elements}, excluding deleted ones and other authors', 'api', 'core', () => withWorld(async (h) => {
|
|
323
|
+
const first = await createPost(h, textPost('first'));
|
|
324
|
+
const second = await createPost(h, textPost('second'));
|
|
325
|
+
const gone = await createPost(h, textPost('gone'));
|
|
326
|
+
await h({ m: 'DELETE', p: `/rest/posts/${enc(gone)}` });
|
|
327
|
+
const b = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&sortBy=CREATED` })).body;
|
|
328
|
+
const ids = b.elements.map((e) => e.id);
|
|
329
|
+
return ids.length === 2 && ids[0] === second && ids[1] === first && b.paging?.start === 0 && b.paging?.count === 10 && Array.isArray(b.paging?.links);
|
|
330
|
+
})),
|
|
331
|
+
done('linkedin.posts.find_by_author_paging', 'posts', 'The finder pages by start/count (default 10, max 100) and links the next page', 'api', 'common', () => withWorld(async (h) => {
|
|
332
|
+
for (let i = 0; i < 3; i += 1)
|
|
333
|
+
await createPost(h, textPost(`p${i}`));
|
|
334
|
+
const p1 = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&count=2` })).body;
|
|
335
|
+
const p2 = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&count=2&start=2` })).body;
|
|
336
|
+
const tooMany = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&count=101` });
|
|
337
|
+
return p1.elements.length === 2 && p1.paging.links[0]?.rel === 'next' && /start=2/.test(p1.paging.links[0].href)
|
|
338
|
+
&& p2.elements.length === 1 && p2.paging.links.length === 0 && tooMany.status === 400;
|
|
339
|
+
})),
|
|
340
|
+
done('linkedin.posts.find_by_author_sort', 'posts', 'sortBy=CREATED and LAST_MODIFIED (the default) order descending; another value is 400', 'api', 'common', () => withWorld(async (h, root) => {
|
|
341
|
+
const hLater = handleFor(root, '2026-09-26T12:05:00.000Z');
|
|
342
|
+
const a = await createPost(h, textPost('earlier'));
|
|
343
|
+
const b = await createPost(hLater, textPost('later'));
|
|
344
|
+
const created = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&sortBy=CREATED` })).body;
|
|
345
|
+
const bad = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&sortBy=RANDOM` });
|
|
346
|
+
return created.elements[0].id === b && created.elements[1].id === a && bad.status === 400;
|
|
347
|
+
})),
|
|
348
|
+
done('linkedin.posts.urn_after_landing', 'posts', "After a deploy lands a post under the vendor's URN, its local URN still reads it, and the next create never reissues that local URN", 'api', 'core', () => withWorld(async (h, root) => {
|
|
349
|
+
const local = await createPost(h, textPost('to be landed'));
|
|
350
|
+
writeRoot('linkedin', { url: 'https://api.linkedin.com', deploy: 'gated', credential: join(root, 'unsealed.json') }, root);
|
|
351
|
+
const vendorUrn = 'urn:li:share:7300000000000000042';
|
|
352
|
+
const fake = async () => ({ status: 500, headers: {}, body: '' });
|
|
353
|
+
const report = await performEntries({
|
|
354
|
+
service: 'linkedin', root, execute: fake, checks: [],
|
|
355
|
+
actionIds: [entryFor(root, local, 'linkedin.post.create').id],
|
|
356
|
+
adapters: { perform: async (_e, action) => ({ externalId: action.subject?.id === local ? vendorUrn : String(action.subject?.id) }) },
|
|
357
|
+
});
|
|
358
|
+
const next = await createPost(h, textPost('after the landing'));
|
|
359
|
+
const byLocal = (await h({ m: 'GET', p: `/rest/posts/${enc(local)}` })).body;
|
|
360
|
+
return report.deployed.length === 1 && next !== local && byLocal.commentary === 'to be landed';
|
|
361
|
+
})),
|
|
362
|
+
done('linkedin.posts.kernel_tombstone', 'posts', 'A post the kernel tombstones (an observed `deleted`) is gone from GET and the author finder', 'api', 'common', () => withWorld(async (h, root) => {
|
|
363
|
+
const urn = 'urn:li:share:7300000000000000077';
|
|
364
|
+
const fieldsOf = { author: ORG, commentary: 'pulled', visibility: 'PUBLIC', distribution: { feedDistribution: 'MAIN_FEED', thirdPartyDistributionChannels: [] }, lifecycleState: 'PUBLISHED', createdAt: 1, lastModifiedAt: 1 };
|
|
365
|
+
observeResource('linkedin', { type: 'post', id: urn, fields: fieldsOf }, { root, at: '2026-09-26T11:00:00.000Z' });
|
|
366
|
+
const before = await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` });
|
|
367
|
+
observeResource('linkedin', { type: 'post', id: urn, fields: { ...fieldsOf, deleted: true } }, { root, at: '2026-09-26T11:30:00.000Z' });
|
|
368
|
+
const after = await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` });
|
|
369
|
+
const listed = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` })).body.elements;
|
|
370
|
+
return before.status === 200 && after.status === 404 && !listed.some((p) => p.id === urn);
|
|
371
|
+
})),
|
|
372
|
+
done('linkedin.posts.batch_get', 'posts', 'posts?ids=List(...) answers {results, statuses, errors}: a live post in results, a missing one a 404 in statuses and errors', 'api', 'common', () => withWorld(async (h) => {
|
|
373
|
+
const urn = await createPost(h, textPost('batch me'));
|
|
374
|
+
const missing = 'urn:li:share:9000000000000000999';
|
|
375
|
+
const b = (await h({ m: 'GET', p: `/rest/posts?ids=List(${enc(urn)},${enc(missing)})`, headers: { ...API, 'x-restli-method': 'BATCH_GET' } })).body;
|
|
376
|
+
return b.results?.[urn]?.commentary === 'batch me' && b.statuses?.[urn] === 200 && b.statuses?.[missing] === 404 && b.errors?.[missing]?.code === 'NOT_FOUND';
|
|
377
|
+
})),
|
|
378
|
+
done('linkedin.posts.delete', 'posts', 'DELETE /rest/posts/{urn} answers 204, the post is gone from GET and the finder, and a second delete is again 204 with no second write', 'api', 'core', () => withWorld(async (h, root) => {
|
|
379
|
+
const urn = await createPost(h, textPost('delete me'));
|
|
380
|
+
const d1 = await h({ m: 'DELETE', p: `/rest/posts/${enc(urn)}` });
|
|
381
|
+
const d2 = await h({ m: 'DELETE', p: `/rest/posts/${enc(urn)}` });
|
|
382
|
+
const get = await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` });
|
|
383
|
+
const deletes = pendingActions('linkedin', root).filter((a) => a.operation === 'linkedin.post.delete').length;
|
|
384
|
+
return d1.status === 204 && d2.status === 204 && get.status === 404 && deletes === 1;
|
|
385
|
+
})),
|
|
386
|
+
done('linkedin.posts.delete_unknown_and_foreign', 'posts', 'Deleting a post nobody made is 404; deleting another page\'s post is 403', 'api', 'common', () => withWorld(async (h, root) => {
|
|
387
|
+
const unknown = await h({ m: 'DELETE', p: `/rest/posts/${enc('urn:li:share:9000000000000000777')}` });
|
|
388
|
+
// another page's admin posts; this admin may not delete it
|
|
389
|
+
await h({ m: 'POST', p: '/_twin/members', b: { id: 'otherAdmin', localizedFirstName: 'O', localizedLastName: 'A', email: 'o@other.co' }, headers: {} });
|
|
390
|
+
await h({ m: 'POST', p: '/_twin/organizationAcls', b: { organization: OTHER_ORG, roleAssignee: 'urn:li:person:otherAdmin', role: 'ADMINISTRATOR' }, headers: {} });
|
|
391
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'other-admin-token', member: 'urn:li:person:otherAdmin', scopes: SCOPES }, headers: {} });
|
|
392
|
+
const theirs = String((await handleFor(root)({ m: 'POST', p: '/rest/posts', b: textPost('theirs', { author: OTHER_ORG }), headers: { ...API, authorization: 'Bearer other-admin-token' } })).headers?.['x-restli-id']);
|
|
393
|
+
const foreign = await h({ m: 'DELETE', p: `/rest/posts/${enc(theirs)}` });
|
|
394
|
+
return unknown.status === 404 && foreign.status === 403 && (await h({ m: 'GET', p: `/rest/posts/${enc(theirs)}`, headers: { ...API, authorization: 'Bearer other-admin-token' } })).status === 200;
|
|
395
|
+
})),
|
|
396
|
+
done('linkedin.posts.ids_minted_from_state', 'posts', 'Post URNs are a digest of the write above the real id space and never match a held or re-keyed id: a delete-then-create never reissues a URN, and concurrent creates never share one', 'api', 'core', () => withWorld(async (h) => {
|
|
397
|
+
const a = await createPost(h, textPost('a'));
|
|
398
|
+
await h({ m: 'DELETE', p: `/rest/posts/${enc(a)}` });
|
|
399
|
+
const b = await createPost(h, textPost('b'));
|
|
400
|
+
const n = (u) => BigInt(u.split(':').pop());
|
|
401
|
+
// three MEDIA posts at once: each awaits its asset before minting, so the mint must be inside the append
|
|
402
|
+
const images = [await uploadImage(h), await uploadImage(h), await uploadImage(h)];
|
|
403
|
+
const together = await Promise.all(images.map((id, i) => createPost(h, textPost(`together ${i}`, { content: { media: { id } } }))));
|
|
404
|
+
const listed = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` })).body.elements;
|
|
405
|
+
return a !== b && [a, b, ...together].every((u) => n(u) >= 9000000000000000000n && n(u) < 9200000000000000000n) && new Set(together).size === 3 && listed.length === 4;
|
|
406
|
+
})),
|
|
407
|
+
done('linkedin.posts.media_video', 'posts', 'A post whose content.media names an AVAILABLE video the page owns is created and read back with content.media {title, id}', 'api', 'core', () => withWorld(async (h) => {
|
|
408
|
+
const { urn: video } = await uploadVideo(h);
|
|
409
|
+
const urn = await createPost(h, textPost('with a video', { content: { media: { title: 'tour', id: video } } }));
|
|
410
|
+
const b = (await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` })).body;
|
|
411
|
+
return b.content?.media?.id === video && b.content.media.title === 'tour';
|
|
412
|
+
})),
|
|
413
|
+
done('linkedin.posts.media_image', 'posts', 'A post whose content.media names an AVAILABLE image (with altText) is created and read back', 'api', 'core', () => withWorld(async (h) => {
|
|
414
|
+
const image = await uploadImage(h);
|
|
415
|
+
const urn = await createPost(h, textPost('with an image', { content: { media: { altText: 'a chart', id: image } } }));
|
|
416
|
+
const b = (await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` })).body;
|
|
417
|
+
return b.content?.media?.id === image && b.content.media.altText === 'a chart';
|
|
418
|
+
})),
|
|
419
|
+
done('linkedin.posts.media_refusals', 'posts', "Media that is not uploaded, failed processing, still processing, another owner's, or an unknown URN is refused with the vendor's code, and nothing lands", 'api', 'core', () => withWorld(async (h, root) => {
|
|
420
|
+
const waiting = (await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG } } })).body.value.image;
|
|
421
|
+
const failed = (await uploadVideo(h, mp4(1500))).urn; // under the feed's three seconds
|
|
422
|
+
const processing = (await uploadVideo(h, mp4(), { processing: true })).urn;
|
|
423
|
+
const codes = [];
|
|
424
|
+
for (const id of [waiting, failed, processing, 'urn:li:image:C5T10AQ999999999999', 'urn:li:share:1']) {
|
|
425
|
+
codes.push((await h({ m: 'POST', p: '/rest/posts', b: textPost('x', { content: { media: { id } } }) })).body.code);
|
|
426
|
+
}
|
|
427
|
+
const creates = pendingActions('linkedin', root).filter((a) => a.operation === 'linkedin.post.create').length;
|
|
428
|
+
return JSON.stringify(codes) === JSON.stringify(['MEDIA_ASSET_WAITING_UPLOAD', 'MEDIA_ASSET_PROCESSING_FAILED', 'UNPROCESSABLE_ENTITY', 'INVALID_URN_ID', 'INVALID_URN_TYPE']) && creates === 0;
|
|
429
|
+
})),
|
|
430
|
+
done('linkedin.posts.article', 'posts', 'An article post carries source, title, description and an optional thumbnail image the page owns', 'api', 'common', () => withWorld(async (h) => {
|
|
431
|
+
const thumb = await uploadImage(h);
|
|
432
|
+
const urn = await createPost(h, textPost('read this', { content: { article: { source: 'https://volter.ai/blog/worlds', thumbnail: thumb, title: 'Worlds', description: 'How it works' } } }));
|
|
433
|
+
const b = (await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` })).body;
|
|
434
|
+
const noSource = await h({ m: 'POST', p: '/rest/posts', b: textPost('x', { content: { article: { title: 'no source' } } }) });
|
|
435
|
+
return b.content?.article?.source === 'https://volter.ai/blog/worlds' && b.content.article.thumbnail === thumb && b.content.article.title === 'Worlds' && noSource.status === 400;
|
|
436
|
+
})),
|
|
437
|
+
done('linkedin.posts.unmodelled_content_refused', 'posts', 'Content kinds and fields the twin does not model (poll, multiImage, reshareContext, targetEntities, a person author) are refused by name, never stored', 'api', 'common', () => withWorld(async (h, root) => {
|
|
438
|
+
const bodies = [
|
|
439
|
+
textPost('x', { content: { poll: { question: 'q' } } }),
|
|
440
|
+
textPost('x', { content: { multiImage: { images: [] } } }),
|
|
441
|
+
textPost('x', { reshareContext: { parent: 'urn:li:share:1' } }),
|
|
442
|
+
textPost('x', { distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [{ seniorities: ['urn:li:seniority:3'] }] } }),
|
|
443
|
+
textPost('x', { author: 'urn:li:person:orgAdmin' }),
|
|
444
|
+
];
|
|
445
|
+
for (const b of bodies) {
|
|
446
|
+
const r = await h({ m: 'POST', p: '/rest/posts', b });
|
|
447
|
+
if (r.status !== 422 || !String(r.body.message).startsWith('[twin gap]'))
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
return pendingActions('linkedin', root).every((a) => a.operation !== 'linkedin.post.create');
|
|
451
|
+
})),
|
|
452
|
+
todo('linkedin.posts.partial_update', 'posts', 'Update a post (POST /rest/posts/{urn}, X-RestLi-Method PARTIAL_UPDATE: commentary, CTA, landing page, lifecycleState)', 'api', 'common'),
|
|
453
|
+
todo('linkedin.posts.reshare', 'posts', 'Reshare a post (reshareContext.parent, with the derived root)', 'api', 'common'),
|
|
454
|
+
todo('linkedin.posts.targeted', 'posts', 'Targeted organic posts (distribution.targetEntities, the 300-member audience floor)', 'api', 'niche'),
|
|
455
|
+
todo('linkedin.posts.dark_posts', 'posts', 'Dark posts and Direct Sponsored Content (adContext, feedDistribution NONE for ads, the dscAdAccount finder)', 'api', 'niche'),
|
|
456
|
+
todo('linkedin.posts.cta_landing_page', 'posts', 'contentCallToActionLabel and contentLandingPage on a post', 'api', 'niche'),
|
|
457
|
+
todo('linkedin.posts.celebration', 'posts', 'Celebration posts (the Celebration content kind, organic only)', 'api', 'niche'),
|
|
458
|
+
todo('linkedin.posts.container', 'posts', 'Posts in a container (container URN, CONTAINER visibility delegated to its owner)', 'api', 'niche'),
|
|
459
|
+
todo('linkedin.posts.poll', 'posts', 'Poll posts (the Poll API content kind)', 'api', 'common'),
|
|
460
|
+
todo('linkedin.posts.multi_image', 'posts', 'Multi-image posts (the MultiImage API content kind)', 'api', 'common'),
|
|
461
|
+
todo('linkedin.posts.member_author', 'posts', "Posting as a member (a person author under w_member_social), and the finder over a person's posts (r_member_social)", 'api', 'common'),
|
|
462
|
+
todo('linkedin.posts.publish_requested', 'posts', "The PUBLISH_REQUESTED / PUBLISH_FAILED lifecycle of a post naming a video still processing (the twin refuses it instead)", 'api', 'common'),
|
|
463
|
+
todo('linkedin.posts.mention_annotations', 'posts', 'Mention annotations @[Name](urn) resolved against the named entity (case-sensitive name match) rather than stored as sent', 'api', 'common'),
|
|
464
|
+
todo('linkedin.posts.little_escaping', 'posts', "The little text format's reserved characters: unescaped ( ) [ ] { } < > @ | ~ _ * truncating or failing as LinkedIn does", 'api', 'common'),
|
|
465
|
+
todo('linkedin.posts.commentary_length_limit', 'posts', "The commentary length ceiling (FIELD_LENGTH_TOO_LONG) — the docs this build read give no number", 'api', 'common'),
|
|
466
|
+
todo('linkedin.posts.view_context_author', 'posts', 'viewContext=AUTHOR answering DRAFT and PROCESSING versions (the twin has no drafts, so both views are the published post)', 'api', 'niche'),
|
|
467
|
+
todo('linkedin.posts.query_tunneling', 'posts', 'Query tunneling (POST with X-HTTP-Method-Override for a long GET)', 'api', 'niche'),
|
|
468
|
+
// ── images (Images API) ─────────────────────────────────────────────────────────────────────
|
|
469
|
+
done('linkedin.images.initialize_upload', 'images', 'images?action=initializeUpload answers {value:{uploadUrlExpiresAt, uploadUrl, image}}: a signed /dms-uploads URL, an image URN, WAITING_UPLOAD until the bytes arrive', 'api', 'core', () => withWorld(async (h) => {
|
|
470
|
+
const v = (await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG } } })).body.value;
|
|
471
|
+
const status = (await h({ m: 'GET', p: `/rest/images/${enc(v.image)}` })).body.status;
|
|
472
|
+
return /^urn:li:image:C5T10AQ\d{12}$/.test(v.image) && v.uploadUrlExpiresAt === Date.parse(AT) + 30 * 86_400_000
|
|
473
|
+
&& /^https:\/\/www\.linkedin\.com\/dms-uploads\/[^/]+\/uploaded-image\/0\?.*ut=[0-9a-f]{64}$/.test(v.uploadUrl) && status === 'WAITING_UPLOAD';
|
|
474
|
+
})),
|
|
475
|
+
done('linkedin.images.upload_needs_token_and_signature', 'images', "The image PUT needs both the signed URL and the member token: a forged signature is 401, a URL validly signed for another expiry is 401, no bearer is 401, both is 201 and AVAILABLE with its size from the bytes", 'api', 'core', () => withWorld(async (h, root) => {
|
|
476
|
+
const v = (await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG } } })).body.value;
|
|
477
|
+
const url = pathOf(v.uploadUrl);
|
|
478
|
+
const forged = await h({ m: 'PUT', p: forge(url), bytes: tinyPng(), headers: { authorization: API.authorization } });
|
|
479
|
+
// a URL SIGNED for another expiry: only the expiry-must-be-the-asset's-own rule refuses it
|
|
480
|
+
const assetId = String(v.image).split(':').pop();
|
|
481
|
+
const expiry = Number(/e=(\d+)/.exec(url)[1]) + 1;
|
|
482
|
+
const resigned = url.replace(/e=\d+/, `e=${expiry}`).replace(/ut=[0-9a-f]+/, `ut=${await signUpload((await readAsset(assetId, root)), 'uploaded-image/0', expiry)}`);
|
|
483
|
+
const otherExpiry = await h({ m: 'PUT', p: resigned, bytes: tinyPng(), headers: { authorization: API.authorization } });
|
|
484
|
+
const noBearer = await h({ m: 'PUT', p: url, bytes: tinyPng(), headers: {} });
|
|
485
|
+
const ok = await h({ m: 'PUT', p: url, bytes: tinyPng(), headers: { authorization: API.authorization } });
|
|
486
|
+
const img = (await h({ m: 'GET', p: `/rest/images/${enc(v.image)}` })).body;
|
|
487
|
+
return forged.status === 401 && otherExpiry.status === 401 && noBearer.status === 401 && ok.status === 201 && img.status === 'AVAILABLE' && img.aspectRatioWidth === 4 && img.aspectRatioHeight === 3 && img.owner === ORG;
|
|
488
|
+
})),
|
|
489
|
+
done('linkedin.images.unsupported_format_fails_processing', 'images', 'Bytes that are not JPG, GIF or PNG leave the image PROCESSING_FAILED', 'api', 'common', () => withWorld(async (h) => {
|
|
490
|
+
const v = (await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG } } })).body.value;
|
|
491
|
+
await h({ m: 'PUT', p: pathOf(v.uploadUrl), bytes: new TextEncoder().encode('not an image'), headers: { authorization: API.authorization } });
|
|
492
|
+
return (await h({ m: 'GET', p: `/rest/images/${enc(v.image)}` })).body.status === 'PROCESSING_FAILED';
|
|
493
|
+
})),
|
|
494
|
+
done('linkedin.images.owner_permission', 'images', "Uploading for a page needs ADMIN or DSC on it: another page is 403, and so is a CONTENT_ADMINISTRATOR (who may still post text)", 'api', 'core', async () => (await withWorld(async (h) => {
|
|
495
|
+
const other = await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: OTHER_ORG } } });
|
|
496
|
+
const own = await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG } } });
|
|
497
|
+
return other.status === 403 && other.body.code === 'ACCESS_DENIED' && own.status === 200;
|
|
498
|
+
}))
|
|
499
|
+
&& (await withWorld(async (h) => {
|
|
500
|
+
const img = await h({ m: 'POST', p: '/rest/images?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG } } });
|
|
501
|
+
const vid = await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 100_000 } } });
|
|
502
|
+
const text = await h({ m: 'POST', p: '/rest/posts', b: textPost('text only') });
|
|
503
|
+
return img.status === 403 && vid.status === 403 && text.status === 201;
|
|
504
|
+
}, { role: 'CONTENT_ADMINISTRATOR' }))),
|
|
505
|
+
todo('linkedin.images.batch_get', 'images', 'Batch get images (GET /rest/images?ids=List(...))', 'api', 'common'),
|
|
506
|
+
todo('linkedin.images.media_library', 'images', 'The media library: mediaLibraryMetadata on initialize, PARTIAL_UPDATE of assetName/status, the associatedAccount finder', 'api', 'niche'),
|
|
507
|
+
// ── videos (Videos API) ─────────────────────────────────────────────────────────────────────
|
|
508
|
+
done('linkedin.videos.initialize_upload', 'videos', 'videos?action=initializeUpload splits fileSizeBytes into 4 MB byte-range upload instructions on signed /dms-uploads URLs, with uploadToken "" and a thumbnailUploadUrl when asked', 'api', 'core', () => withWorld(async (h) => {
|
|
509
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 9_000_000, uploadCaptions: false, uploadThumbnail: true } } })).body.value;
|
|
510
|
+
const i = v.uploadInstructions;
|
|
511
|
+
return /^urn:li:video:/.test(v.video) && i.length === 3 && i[0].firstByte === 0 && i[0].lastByte === 4194303 && i[1].firstByte === 4194304 && i[1].lastByte === 8388607
|
|
512
|
+
&& i[2].firstByte === 8388608 && i[2].lastByte === 8999999 && /\/dms-uploads\/[^/]+\/uploadedVideo\/2\?/.test(i[2].uploadUrl) && v.uploadToken === ''
|
|
513
|
+
&& /\/ads-video-userUploadedThumbnail\/0\?/.test(v.thumbnailUploadUrl);
|
|
514
|
+
})),
|
|
515
|
+
done('linkedin.videos.asset_ids_unique', 'videos', 'Concurrent first uploads on a fresh root mint distinct asset ids and URLs that all verify; a video over the feed\'s 500 MB is refused by name before any byte arrives', 'api', 'common', () => withWorld(async (h) => {
|
|
516
|
+
const inits = await Promise.all([1, 2, 3, 4].map(() => h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 100_000 } } })));
|
|
517
|
+
const ids = inits.map((r) => r.body.value?.video);
|
|
518
|
+
// four first uploads on a fresh root raced the signing key: every URL they were answered still verifies
|
|
519
|
+
const puts = await Promise.all(inits.map((r) => h({ m: 'PUT', p: pathOf(r.body.value.uploadInstructions[0].uploadUrl), bytes: new Uint8Array(100_000), headers: {} })));
|
|
520
|
+
const huge = await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 600 * 1024 * 1024 } } });
|
|
521
|
+
return ids.every((id) => /^urn:li:video:C5T10AQ\d{12}$/.test(String(id))) && new Set(ids).size === 4 && puts.every((p) => p.status === 200) && huge.status === 422 && /500 MB/.test(String(huge.body.message));
|
|
522
|
+
})),
|
|
523
|
+
done('linkedin.videos.part_upload_etag', 'videos', 'Each part PUT (no bearer) answers 200 with an ETag; a part of the wrong length or a forged signature is refused', 'api', 'core', () => withWorld(async (h) => {
|
|
524
|
+
const bytes = mp4();
|
|
525
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: bytes.length } } })).body.value;
|
|
526
|
+
const url = pathOf(v.uploadInstructions[0].uploadUrl);
|
|
527
|
+
const short = await h({ m: 'PUT', p: url, bytes: bytes.subarray(1), headers: {} });
|
|
528
|
+
const forged = await h({ m: 'PUT', p: forge(url), bytes, headers: {} });
|
|
529
|
+
const post = await h({ m: 'POST', p: url, bytes, headers: {} });
|
|
530
|
+
const ok = await h({ m: 'PUT', p: url, bytes, headers: {} });
|
|
531
|
+
return short.status === 400 && forged.status === 401 && post.status === 404 && ok.status === 200 && /^\/ambry-video\/signedId\/[0-9a-f]{64}\.bin$/.test(String(ok.headers?.etag));
|
|
532
|
+
})),
|
|
533
|
+
done('linkedin.videos.finalize_checks_part_ids', 'videos', 'finalizeUpload takes the ETags in upload-instruction order; a wrong or missing one is refused and the video stays WAITING_UPLOAD', 'api', 'core', () => withWorld(async (h) => {
|
|
534
|
+
const bytes = mp4(3000, 4_300_000);
|
|
535
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: bytes.length } } })).body.value;
|
|
536
|
+
const etags = [];
|
|
537
|
+
for (const part of v.uploadInstructions)
|
|
538
|
+
etags.push(String((await h({ m: 'PUT', p: pathOf(part.uploadUrl), bytes: bytes.subarray(part.firstByte, part.lastByte + 1), headers: {} })).headers?.etag));
|
|
539
|
+
const swapped = await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: [...etags].reverse() } } });
|
|
540
|
+
const short = await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: [etags[0]] } } });
|
|
541
|
+
const still = (await h({ m: 'GET', p: `/rest/videos/${enc(v.video)}` })).body.status;
|
|
542
|
+
const ok = await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: etags } } });
|
|
543
|
+
return etags.length === 2 && swapped.status === 400 && short.status === 400 && still === 'WAITING_UPLOAD' && ok.status === 200;
|
|
544
|
+
})),
|
|
545
|
+
done('linkedin.videos.processing_to_available', 'videos', 'After finalize a video reads PROCESSING until two seconds of world time have passed, then AVAILABLE with duration, aspect ratio, downloadUrl and thumbnail; reading never moves it', 'api', 'core', () => withWorld(async (h) => {
|
|
546
|
+
const { urn } = await uploadVideo(h, mp4(), { processing: true });
|
|
547
|
+
// any number of reads at the finalize instant leave it PROCESSING: a read is not a step
|
|
548
|
+
const reads = [];
|
|
549
|
+
for (let i = 0; i < 4; i += 1)
|
|
550
|
+
reads.push((await h({ m: 'GET', p: `/rest/videos/${enc(urn)}` })).body.status);
|
|
551
|
+
const first = (await h({ m: 'GET', p: `/rest/videos/${enc(urn)}`, at: LATER(1_999) })).body;
|
|
552
|
+
const second = (await h({ m: 'GET', p: `/rest/videos/${enc(urn)}`, at: LATER(2_000) })).body;
|
|
553
|
+
return reads.every((r) => r === 'PROCESSING') && first.status === 'PROCESSING' && first.downloadUrl === undefined && second.status === 'AVAILABLE' && second.duration === 3000
|
|
554
|
+
&& second.aspectRatioWidth === 16 && second.aspectRatioHeight === 9 && /\/playlist\/[^/]+\/mp4-720p-30fp-crf28\/0\/[0-9a-f]{64}/.test(second.downloadUrl)
|
|
555
|
+
&& /\/ads-video-thumbnail_720_1280\/0\/system/.test(second.thumbnail);
|
|
556
|
+
})),
|
|
557
|
+
done('linkedin.videos.bounds_fail_processing', 'videos', "A video outside the feed's bounds (under three seconds, under 75 KB) or not an MP4 fails processing with processingFailureReason", 'api', 'common', () => withWorld(async (h) => {
|
|
558
|
+
const short = (await h({ m: 'GET', p: `/rest/videos/${enc((await uploadVideo(h, mp4(2000))).urn)}` })).body;
|
|
559
|
+
const small = (await h({ m: 'GET', p: `/rest/videos/${enc((await uploadVideo(h, mp4(3000, 1000))).urn)}` })).body;
|
|
560
|
+
const junk = (await h({ m: 'GET', p: `/rest/videos/${enc((await uploadVideo(h, new Uint8Array(90_000).fill(7))).urn)}` })).body;
|
|
561
|
+
return [short, small, junk].every((v) => v.status === 'PROCESSING_FAILED' && typeof v.processingFailureReason === 'string') && /three seconds/.test(short.processingFailureReason);
|
|
562
|
+
})),
|
|
563
|
+
done('linkedin.videos.concurrent_parts', 'videos', "Two part PUTs at once keep both ETags and finalize succeeds; a part arriving after finalize is refused and the video stays PROCESSING", 'api', 'core', () => withWorld(async (h) => {
|
|
564
|
+
const bytes = mp4(3000, 4_300_000);
|
|
565
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: bytes.length } } })).body.value;
|
|
566
|
+
const parts = v.uploadInstructions;
|
|
567
|
+
// both parts in flight together: each read-modify-write of the record runs in the asset's chain
|
|
568
|
+
const puts = await Promise.all(parts.map((part) => h({ m: 'PUT', p: pathOf(part.uploadUrl), bytes: bytes.subarray(part.firstByte, part.lastByte + 1), headers: {} })));
|
|
569
|
+
const etags = puts.map((r) => String(r.headers?.etag));
|
|
570
|
+
const fin = await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: etags } } });
|
|
571
|
+
const late = await h({ m: 'PUT', p: pathOf(parts[0].uploadUrl), bytes: bytes.subarray(parts[0].firstByte, parts[0].lastByte + 1), headers: {} });
|
|
572
|
+
const status = (await h({ m: 'GET', p: `/rest/videos/${enc(v.video)}` })).body.status;
|
|
573
|
+
return parts.length === 2 && puts.every((r) => r.status === 200) && new Set(etags).size === 2 && fin.status === 200 && late.status === 400 && status === 'PROCESSING';
|
|
574
|
+
})),
|
|
575
|
+
done('linkedin.videos.inherited_upload_refused', 'videos', "A World writes only the uploads it started: a part PUT or finalize for an asset its origin initialized is refused (409, 'finish it there') and writes nothing, while reads of it still work", 'api', 'core', () => withWorld(async (h, root) => {
|
|
576
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 100_000 } } })).body.value;
|
|
577
|
+
const child = mkdtempSync(join(tmpdir(), 'linkedin-cap-branch-'));
|
|
578
|
+
try {
|
|
579
|
+
writeBranchMeta('linkedin', { parent: { at: root, position: wholeLog('linkedin', root).length } }, child);
|
|
580
|
+
const c = handleFor(child);
|
|
581
|
+
const id = String(v.video).split(':').pop();
|
|
582
|
+
const put = await c({ m: 'PUT', p: pathOf(v.uploadInstructions[0].uploadUrl), bytes: new Uint8Array(100_000), headers: {} });
|
|
583
|
+
const fin = await c({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: ['x'] } } });
|
|
584
|
+
const read = await c({ m: 'GET', p: `/rest/videos/${enc(v.video)}` });
|
|
585
|
+
const atOrigin = await h({ m: 'PUT', p: pathOf(v.uploadInstructions[0].uploadUrl), bytes: new Uint8Array(100_000), headers: {} });
|
|
586
|
+
return put.status === 409 && put.body.code === 'CONFLICT' && /finish it there/.test(String(put.body.message)) && fin.status === 409
|
|
587
|
+
&& !(await assetIsOwn(id, child)) && read.status === 200 && read.body.status === 'WAITING_UPLOAD' && atOrigin.status === 200;
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
rmSync(child, { recursive: true, force: true });
|
|
591
|
+
}
|
|
592
|
+
})),
|
|
593
|
+
done('linkedin.videos.unsigned_record_refused', 'videos', 'An upload whose record predates per-asset secrets is refused with a clear 400, never signed with an empty key', 'api', 'common', () => withWorld(async (h, root) => {
|
|
594
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 100_000 } } })).body.value;
|
|
595
|
+
const id = String(v.video).split(':').pop();
|
|
596
|
+
const { upload_secret: _gone, ...legacy } = (await readAsset(id, root));
|
|
597
|
+
await writeAsset(legacy, root);
|
|
598
|
+
const put = await h({ m: 'PUT', p: pathOf(v.uploadInstructions[0].uploadUrl), bytes: new Uint8Array(100_000), headers: {} });
|
|
599
|
+
return put.status === 400 && put.body.code === 'INVALID_UPLOAD_URL' && /initialize a new upload/.test(String(put.body.message));
|
|
600
|
+
})),
|
|
601
|
+
done('linkedin.videos.thumbnail_upload', 'videos', 'uploadThumbnail: the thumbnail PUT is stored and the video answers it as its thumbnail; finalize refuses until it arrives', 'api', 'common', () => withWorld(async (h) => {
|
|
602
|
+
const bytes = mp4();
|
|
603
|
+
const v = (await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: bytes.length, uploadThumbnail: true } } })).body.value;
|
|
604
|
+
const etag = String((await h({ m: 'PUT', p: pathOf(v.uploadInstructions[0].uploadUrl), bytes, headers: {} })).headers?.etag);
|
|
605
|
+
const early = await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: [etag] } } });
|
|
606
|
+
await h({ m: 'PUT', p: pathOf(v.thumbnailUploadUrl), bytes: tinyPng(), headers: {} });
|
|
607
|
+
const ok = await h({ m: 'POST', p: '/rest/videos?action=finalizeUpload', b: { finalizeUploadRequest: { video: v.video, uploadToken: '', uploadedPartIds: [etag] } } });
|
|
608
|
+
const got = (await h({ m: 'GET', p: `/rest/videos/${enc(v.video)}`, at: LATER(5_000) })).body;
|
|
609
|
+
return early.status === 400 && ok.status === 200 && /\/ads-video-thumbnail_720_1280\/0\/[0-9a-f]{64}/.test(got.thumbnail);
|
|
610
|
+
})),
|
|
611
|
+
done('linkedin.videos.bytes_served_with_range', 'videos', "The video's downloadUrl serves the uploaded bytes on the twin's own base: whole files and long ranges streamed, an open range capped at 8 MiB, suffix ranges, 206 and 416", 'api', 'core', async () => {
|
|
612
|
+
const root = mkdtempSync(join(tmpdir(), 'linkedin-cap-bytes-'));
|
|
613
|
+
const { createLinkedinTwinFetch } = await import("./linkedin-server.js");
|
|
614
|
+
const fetcher = createLinkedinTwinFetch({ root });
|
|
615
|
+
const call = async (method, path, init = {}) => fetcher(new Request(`http://twin.test${path}`, { method, ...init }));
|
|
616
|
+
try {
|
|
617
|
+
const json = { 'content-type': 'application/json' };
|
|
618
|
+
await call('POST', '/_twin/organizations', { headers: json, body: JSON.stringify({ id: '2414183', localizedName: 'Volter' }) });
|
|
619
|
+
await call('POST', '/_twin/members', { headers: json, body: JSON.stringify({ id: 'orgAdmin', localizedFirstName: 'A', localizedLastName: 'B', email: 'a@volter.ai' }) });
|
|
620
|
+
await call('POST', '/_twin/organizationAcls', { headers: json, body: JSON.stringify({ organization: ORG, roleAssignee: 'urn:li:person:orgAdmin', role: 'ADMINISTRATOR' }) });
|
|
621
|
+
await call('POST', '/_twin/tokens', { headers: json, body: JSON.stringify({ token: TOKEN, member: 'urn:li:person:orgAdmin', scopes: SCOPES }) });
|
|
622
|
+
const bytes = mp4(3000, 9 * 1024 * 1024);
|
|
623
|
+
const v = await (await call('POST', '/rest/videos?action=initializeUpload', { headers: { ...json, ...API }, body: JSON.stringify({ initializeUploadRequest: { owner: ORG, fileSizeBytes: bytes.length } }) })).json();
|
|
624
|
+
const etags = [];
|
|
625
|
+
for (const part of v.value.uploadInstructions) {
|
|
626
|
+
const put = await call('PUT', pathOf(part.uploadUrl), { body: new Blob([new Uint8Array(bytes.subarray(part.firstByte, part.lastByte + 1))]) });
|
|
627
|
+
etags.push(String(put.headers.get('etag')));
|
|
628
|
+
}
|
|
629
|
+
await call('POST', '/rest/videos?action=finalizeUpload', { headers: { ...json, ...API }, body: JSON.stringify({ finalizeUploadRequest: { video: v.value.video, uploadToken: '', uploadedPartIds: etags } }) });
|
|
630
|
+
// the fetch adapter reads the world clock: processing ends two seconds after finalize
|
|
631
|
+
await new Promise((r) => setTimeout(r, 2_100));
|
|
632
|
+
const got = await (await call('GET', `/rest/videos/${enc(v.value.video)}`, { headers: API })).json();
|
|
633
|
+
const url = new URL(got.downloadUrl);
|
|
634
|
+
const whole = new Uint8Array(await (await call('GET', url.pathname)).arrayBuffer());
|
|
635
|
+
const part = await call('GET', url.pathname, { headers: { range: 'bytes=100-199' } });
|
|
636
|
+
const partBytes = new Uint8Array(await part.arrayBuffer());
|
|
637
|
+
const beyond = await call('GET', url.pathname, { headers: { range: `bytes=${bytes.length + 10}-` } });
|
|
638
|
+
const open = await call('GET', url.pathname, { headers: { range: 'bytes=0-' } });
|
|
639
|
+
const openLen = (await open.arrayBuffer()).byteLength;
|
|
640
|
+
const suffix = new Uint8Array(await (await call('GET', url.pathname, { headers: { range: 'bytes=-50' } })).arrayBuffer());
|
|
641
|
+
const CAP = 8 * 1024 * 1024;
|
|
642
|
+
// an open-ended range is capped at 8 MiB; a whole-file answer and a long closed range stream every byte
|
|
643
|
+
const closed = await call('GET', url.pathname, { headers: { range: `bytes=1-${bytes.length - 1}` } });
|
|
644
|
+
// streamed, not held: the first chunk of a long answer is one capped read
|
|
645
|
+
const reader = closed.body.getReader();
|
|
646
|
+
const chunks = [];
|
|
647
|
+
for (;;) {
|
|
648
|
+
const r = await reader.read();
|
|
649
|
+
if (r.done)
|
|
650
|
+
break;
|
|
651
|
+
chunks.push(r.value);
|
|
652
|
+
}
|
|
653
|
+
const closedBytes = concat(...chunks);
|
|
654
|
+
const firstChunk = chunks[0]?.length ?? 0;
|
|
655
|
+
return url.origin === 'http://twin.test' && bytes.length > CAP && whole.length === bytes.length && whole.every((b, i) => b === bytes[i])
|
|
656
|
+
&& part.status === 206 && part.headers.get('content-range') === `bytes 100-199/${bytes.length}` && partBytes.every((b, i) => b === bytes[100 + i]) && beyond.status === 416
|
|
657
|
+
&& open.status === 206 && open.headers.get('content-range') === `bytes 0-${CAP - 1}/${bytes.length}` && openLen === CAP
|
|
658
|
+
&& suffix.length === 50 && suffix.every((b, i) => b === bytes[bytes.length - 50 + i])
|
|
659
|
+
&& closed.status === 206 && closedBytes.length === bytes.length - 1 && closedBytes.every((b, i) => b === bytes[i + 1]) && firstChunk > 0 && firstChunk <= CAP && chunks.length >= 2;
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
rmSync(root, { recursive: true, force: true });
|
|
663
|
+
}
|
|
664
|
+
}),
|
|
665
|
+
todo('linkedin.videos.captions', 'videos', 'Caption upload (uploadCaptions, the captionsUploadUrl multipart with SRT metadata) and the captions field', 'api', 'common'),
|
|
666
|
+
todo('linkedin.videos.batch_get', 'videos', 'Batch get videos (GET /rest/videos?ids=List(...)) with per-id statuses', 'api', 'common'),
|
|
667
|
+
todo('linkedin.videos.media_library', 'videos', 'The media library for videos: mediaLibraryMetadata, PARTIAL_UPDATE, the associatedAccount finder', 'api', 'niche'),
|
|
668
|
+
todo('linkedin.videos.attribution', 'videos', 'templateName / linkbackContext media attribution (202602+)', 'api', 'niche'),
|
|
669
|
+
todo('linkedin.videos.system_thumbnail', 'videos', "A real frame as the system thumbnail (the twin decodes nothing and serves a stand-in poster)", 'api', 'niche'),
|
|
670
|
+
// ── the rest of the posting surface this pack does not model ────────────────────────────────
|
|
671
|
+
todo('linkedin.documents.upload', 'documents', 'The Documents API (initializeUpload, PUT, document URNs on a post)', 'api', 'common'),
|
|
672
|
+
todo('linkedin.social.comments', 'social', 'Comments on a post (socialActions/{urn}/comments, the Comments API): create, list, delete', 'api', 'core'),
|
|
673
|
+
todo('linkedin.social.reactions', 'social', 'Reactions (the Reactions API): react, list, delete', 'api', 'common'),
|
|
674
|
+
todo('linkedin.social.metadata', 'social', 'Social metadata: comment and reaction counts, enabling or disabling comments on a post', 'api', 'common'),
|
|
675
|
+
todo('linkedin.social.org_notifications', 'social', 'Organizational entity notifications (mentions of the page, shares of its posts)', 'api', 'niche'),
|
|
676
|
+
todo('linkedin.statistics.share_statistics', 'statistics', 'organizationalEntityShareStatistics — impressions, clicks, engagement per post or page', 'api', 'common'),
|
|
677
|
+
todo('linkedin.statistics.follower_statistics', 'statistics', 'organizationalEntityFollowerStatistics — followers by facet and over time', 'api', 'common'),
|
|
678
|
+
todo('linkedin.statistics.page_statistics', 'statistics', 'organizationPageStatistics — page views by section and over time', 'api', 'niche'),
|
|
679
|
+
todo('linkedin.statistics.video_analytics', 'statistics', 'videoAnalytics — views and watch time of a video post', 'api', 'niche'),
|
|
680
|
+
// ── errors ──────────────────────────────────────────────────────────────────────────────────
|
|
681
|
+
done('linkedin.errors.unmodelled_route_404', 'errors', "A /rest resource or method the twin does not serve is the vendor's 404 NOT_FOUND, never a success", 'api', 'core', () => withWorld(async (h) => {
|
|
682
|
+
const a = await h({ m: 'GET', p: '/rest/adAccounts' });
|
|
683
|
+
const b = await h({ m: 'PATCH', p: '/rest/posts' });
|
|
684
|
+
const c = await h({ m: 'GET', p: '/v2/me' });
|
|
685
|
+
return [a, b, c].every((r) => r.status === 404 && r.body.code === 'NOT_FOUND');
|
|
686
|
+
})),
|
|
687
|
+
done('linkedin.errors.envelope_shape', 'errors', 'Every refusal is {status, code, message} with status equal to the HTTP status', 'api', 'common', () => withWorld(async (h) => {
|
|
688
|
+
const rs = [await h({ m: 'POST', p: '/rest/posts', b: {} }), await h({ m: 'GET', p: `/rest/posts/${enc('urn:li:share:9000000000000000555')}` }), await h({ m: 'POST', p: '/rest/posts', b: textPost('x'), headers: { ...API, authorization: 'Bearer nope-nope-nope' } })];
|
|
689
|
+
return rs.every((r) => { const b = r.body; return Object.keys(b).sort().join(',') === 'code,message,status' && b.status === r.status && typeof b.code === 'string'; });
|
|
690
|
+
})),
|
|
691
|
+
done('linkedin.errors.malformed_body', 'errors', 'A body that is not a JSON object is a 400, and nothing lands', 'api', 'common', () => withWorld(async (h, root) => {
|
|
692
|
+
const r = await handleLinkedinTwinRequest({ method: 'POST', path: '/rest/posts', body: '{not json', headers: API, root, occurredAt: AT });
|
|
693
|
+
return r.status === 400 && pendingActions('linkedin', root).every((a) => a.operation !== 'linkedin.post.create');
|
|
694
|
+
})),
|
|
695
|
+
done('linkedin.errors.read_only', 'errors', 'A read-only twin refuses every write (a post, an upload, a seed) with 405 and still serves reads', 'api', 'common', () => withWorld(async (h) => {
|
|
696
|
+
const post = await h({ m: 'POST', p: '/rest/posts', b: textPost('x'), readOnly: true });
|
|
697
|
+
const init = await h({ m: 'POST', p: '/rest/videos?action=initializeUpload', b: { initializeUploadRequest: { owner: ORG, fileSizeBytes: 1 } }, readOnly: true });
|
|
698
|
+
const seedR = await h({ m: 'POST', p: '/_twin/members', b: { id: 'x' }, headers: {}, readOnly: true });
|
|
699
|
+
const read = await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}`, readOnly: true });
|
|
700
|
+
return post.status === 405 && init.status === 405 && seedR.status === 405 && read.status === 200;
|
|
701
|
+
})),
|
|
702
|
+
todo('linkedin.errors.service_error_codes', 'errors', 'serviceErrorCode on each refusal (the docs this build read give no numbers for these)', 'api', 'common'),
|
|
703
|
+
todo('linkedin.errors.message_wording', 'errors', "LinkedIn's exact message wording beyond the two quoted version samples", 'api', 'niche'),
|
|
704
|
+
todo('linkedin.errors.rate_limited_429', 'errors', 'The daily application and member limits answered as 429 TOO_MANY_REQUESTS in the twin', 'api', 'common'),
|
|
705
|
+
// ── the twin control plane ──────────────────────────────────────────────────────────────────
|
|
706
|
+
done('linkedin.twin_control.seed_and_validate', 'twin_control', 'A world seeds a page, a member, a role and a token; malformed seeds (bad id, unknown member, undocumented scope or role) are refused', 'api', 'core', () => withWorld(async (h) => {
|
|
707
|
+
const badOrg = await h({ m: 'POST', p: '/_twin/organizations', b: { id: 'abc', localizedName: 'x' }, headers: {} });
|
|
708
|
+
const badAcl = await h({ m: 'POST', p: '/_twin/organizationAcls', b: { organization: ORG, roleAssignee: 'urn:li:person:ghost', role: 'ADMINISTRATOR' }, headers: {} });
|
|
709
|
+
const badRole = await h({ m: 'POST', p: '/_twin/organizationAcls', b: { organization: ORG, roleAssignee: 'urn:li:person:orgAdmin', role: 'OWNER' }, headers: {} });
|
|
710
|
+
const badScope = await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'token-with-bad-scope', member: 'urn:li:person:orgAdmin', scopes: ['w_everything'] }, headers: {} });
|
|
711
|
+
const unknown = await h({ m: 'POST', p: '/_twin/nothing', b: {}, headers: {} });
|
|
712
|
+
return [badOrg, badAcl, badRole, badScope].every((r) => r.status === 400) && unknown.status === 404;
|
|
713
|
+
})),
|
|
714
|
+
done('linkedin.twin_control.logo_must_be_owned', 'twin_control', "A seeded logo must be an AVAILABLE image the page itself owns", 'api', 'common', () => withWorld(async (h) => {
|
|
715
|
+
const r = await h({ m: 'POST', p: '/_twin/organizations', b: { id: '5515715', localizedName: 'Other Co', logo: await uploadImage(h) }, headers: {} });
|
|
716
|
+
return r.status === 400;
|
|
717
|
+
})),
|
|
718
|
+
// ── connector (the real state system, over an in-process vendor) ─────────────────────────────
|
|
719
|
+
done('linkedin.connector.perform_video_post', 'connector', 'performLinkedinAction uploads a video post\'s bytes (initialize, presigned part PUTs with no credential, finalize, the bounded status wait) and creates the post with the vendor\'s video URN', 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
720
|
+
const bytes = mp4(3000, 4_400_000);
|
|
721
|
+
const { urn: video } = await uploadVideo(local, bytes, { thumbnail: tinyPng() });
|
|
722
|
+
const post = await createPost(local, textPost('release #video', { content: { media: { title: 'tour', id: video } } }));
|
|
723
|
+
const entry = entryFor(localRoot, post, 'linkedin.post.create');
|
|
724
|
+
const calls = [];
|
|
725
|
+
const out = await performLinkedinAction(vendorExecutor(vendorRoot, calls), entry, ctxFor(localRoot));
|
|
726
|
+
// read the vendor after its clock (which moved 3 s per call from 13:00)
|
|
727
|
+
const vendor = handleFor(vendorRoot, '2026-09-26T14:00:00.000Z');
|
|
728
|
+
const vApi = { ...API, authorization: 'Bearer vendor-token-0002' };
|
|
729
|
+
const got = (await vendor({ m: 'GET', p: `/rest/posts/${enc(out.externalId)}`, headers: vApi })).body;
|
|
730
|
+
const vVideo = (await vendor({ m: 'GET', p: `/rest/videos/${enc(String(got.content?.media?.id))}`, headers: vApi })).body;
|
|
731
|
+
const parts = calls.filter((c) => c.presigned && /uploadedVideo/.test(c.path));
|
|
732
|
+
const { readLinkedinBlob } = await import("./linkedin-media.js");
|
|
733
|
+
const sha = /\/0\/([0-9a-f]{64})/.exec(String(vVideo.downloadUrl))?.[1] ?? '';
|
|
734
|
+
const vendorBytes = await readLinkedinBlob(sha, vendorRoot);
|
|
735
|
+
return /^urn:li:share:/.test(out.externalId) && out.url === `https://www.linkedin.com/feed/update/${out.externalId}/`
|
|
736
|
+
&& got.commentary === 'release {hashtag|\\#|video}' && got.content.media.title === 'tour' && vVideo.status === 'AVAILABLE'
|
|
737
|
+
&& parts.length === 2 && parts.every((c) => !c.auth) && calls.some((c) => c.presigned && /userUploadedThumbnail/.test(c.path))
|
|
738
|
+
&& calls.filter((c) => !c.presigned).every((c) => c.auth)
|
|
739
|
+
&& vendorBytes !== null && vendorBytes.length === bytes.length && vendorBytes.every((b, i) => b === bytes[i]);
|
|
740
|
+
})),
|
|
741
|
+
done('linkedin.connector.perform_retry_keeps_the_video', 'connector', "A perform that finalized the video and then failed to post keeps the vendor's video URN; its retry posts that video with no second upload", 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
742
|
+
const { urn: video } = await uploadVideo(local);
|
|
743
|
+
const post = await createPost(local, textPost('retry me', { content: { media: { id: video } } }));
|
|
744
|
+
const entry = entryFor(localRoot, post, 'linkedin.post.create');
|
|
745
|
+
const calls = [];
|
|
746
|
+
const inner = vendorExecutor(vendorRoot, calls);
|
|
747
|
+
let failPost = true;
|
|
748
|
+
const flaky = async (request) => {
|
|
749
|
+
if (request.method === 'POST' && request.path === '/rest/posts' && failPost) {
|
|
750
|
+
failPost = false;
|
|
751
|
+
return { status: 500, headers: {}, body: '{"status":500,"code":"INTERNAL_SERVER_ERROR","message":"x"}' };
|
|
752
|
+
}
|
|
753
|
+
return inner(request);
|
|
754
|
+
};
|
|
755
|
+
let first = '';
|
|
756
|
+
try {
|
|
757
|
+
await performLinkedinAction(flaky, entry, ctxFor(localRoot));
|
|
758
|
+
}
|
|
759
|
+
catch (e) {
|
|
760
|
+
first = String(e.message);
|
|
761
|
+
}
|
|
762
|
+
const initsBefore = calls.filter((c) => c.path.includes('action=initializeUpload')).length;
|
|
763
|
+
// a kept video whose status read is not JSON fails the retry loudly — no parse crash, no second upload
|
|
764
|
+
const garbled = async (request) => (request.method === 'GET' && request.path.startsWith('/rest/videos/') ? { status: 200, headers: {}, body: '<html>' } : flaky(request));
|
|
765
|
+
let garbledMessage = '';
|
|
766
|
+
try {
|
|
767
|
+
await performLinkedinAction(garbled, entry, ctxFor(localRoot));
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
garbledMessage = String(e.message);
|
|
771
|
+
}
|
|
772
|
+
const out = await performLinkedinAction(flaky, entry, ctxFor(localRoot));
|
|
773
|
+
const initsAfter = calls.filter((c) => c.path.includes('action=initializeUpload')).length;
|
|
774
|
+
const got = (await handleFor(vendorRoot, '2026-09-26T14:00:00.000Z')({ m: 'GET', p: `/rest/posts/${enc(out.externalId)}`, headers: { ...API, authorization: 'Bearer vendor-token-0002' } })).body;
|
|
775
|
+
return /HTTP 500/.test(first) && /not JSON/.test(garbledMessage) && initsBefore === 1 && initsAfter === 1 && got.content?.media?.id === out.data?.video && /^urn:li:video:/.test(String(got.content?.media?.id));
|
|
776
|
+
})),
|
|
777
|
+
done('linkedin.connector.budget_keyed_by_credential', 'connector', "The served perform charges one ledger per sealed credential: two World roots with one credential spend one allowance", 'connector', 'core', async () => {
|
|
778
|
+
const credential = `verify-${crypto.randomUUID()}`;
|
|
779
|
+
const roots = [mkdtempSync(join(tmpdir(), 'linkedin-cap-cred-a-')), mkdtempSync(join(tmpdir(), 'linkedin-cap-cred-b-'))];
|
|
780
|
+
try {
|
|
781
|
+
const answering = async () => ({ status: 201, headers: { 'x-restli-id': 'urn:li:share:7300000000000000001' }, body: '' });
|
|
782
|
+
for (const root of roots) {
|
|
783
|
+
const h = handleFor(root);
|
|
784
|
+
await seed(h);
|
|
785
|
+
const post = await createPost(h, textPost('one allowance'));
|
|
786
|
+
await performServed(answering, entryFor(root, post, 'linkedin.post.create'), { ...ctxFor(root), credential });
|
|
787
|
+
}
|
|
788
|
+
// both posts (5 units each) are on the credential's ledger, and on neither root's
|
|
789
|
+
return new LinkedinBudget({ token: credential }).spend() === 10 && new LinkedinBudget({ root: roots[0] }).spend() === 0;
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
for (const r of roots)
|
|
793
|
+
rmSync(r, { recursive: true, force: true });
|
|
794
|
+
}
|
|
795
|
+
}),
|
|
796
|
+
done('linkedin.connector.perform_text_and_article', 'connector', 'A text post and an article post cross as POST /rest/posts with the commentary untemplated back to #tags', 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
797
|
+
const post = await createPost(local, textPost('shipping #today', { content: { article: { source: 'https://volter.ai/x', title: 'X' } } }));
|
|
798
|
+
const calls = [];
|
|
799
|
+
const out = await performLinkedinAction(vendorExecutor(vendorRoot, calls), entryFor(localRoot, post, 'linkedin.post.create'), ctxFor(localRoot));
|
|
800
|
+
const got = (await handleFor(vendorRoot)({ m: 'GET', p: `/rest/posts/${enc(out.externalId)}`, headers: { ...API, authorization: 'Bearer vendor-token-0002' } })).body;
|
|
801
|
+
return calls.length === 1 && calls[0].method === 'POST' && calls[0].path === '/rest/posts' && got.commentary === 'shipping {hashtag|\\#|today}'
|
|
802
|
+
&& got.content?.article?.source === 'https://volter.ai/x' && untemplateHashtags('a {hashtag|\\#|b}') === 'a #b';
|
|
803
|
+
})),
|
|
804
|
+
done('linkedin.connector.perform_image_refused', 'connector', "A post with an image fails its perform loudly, naming why (the image PUT needs the token; a presigned upload carries none), and calls nothing", 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
805
|
+
const image = await uploadImage(local);
|
|
806
|
+
const post = await createPost(local, textPost('image', { content: { media: { id: image } } }));
|
|
807
|
+
const calls = [];
|
|
808
|
+
let message = '';
|
|
809
|
+
try {
|
|
810
|
+
await performLinkedinAction(vendorExecutor(vendorRoot, calls), entryFor(localRoot, post, 'linkedin.post.create'), ctxFor(localRoot));
|
|
811
|
+
}
|
|
812
|
+
catch (e) {
|
|
813
|
+
message = String(e.message);
|
|
814
|
+
}
|
|
815
|
+
return calls.length === 0 && message.includes('presigned upload carries no credential') && message.includes('linkedin.connector.perform_image_upload');
|
|
816
|
+
})),
|
|
817
|
+
done('linkedin.connector.perform_delete', 'connector', 'A landed delete crosses as DELETE /rest/posts/{urn} against the resolved vendor URN', 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
818
|
+
const post = await createPost(local, textPost('to retract'));
|
|
819
|
+
const calls = [];
|
|
820
|
+
const exec = vendorExecutor(vendorRoot, calls);
|
|
821
|
+
const created = await performLinkedinAction(exec, entryFor(localRoot, post, 'linkedin.post.create'), ctxFor(localRoot));
|
|
822
|
+
await local({ m: 'DELETE', p: `/rest/posts/${enc(post)}` });
|
|
823
|
+
const del = entryFor(localRoot, post, 'linkedin.post.delete');
|
|
824
|
+
const out = await performLinkedinAction(exec, del, { ...ctxFor(localRoot), resolve: (_t, id) => (id === post ? created.externalId : id) });
|
|
825
|
+
const gone = await handleFor(vendorRoot)({ m: 'GET', p: `/rest/posts/${enc(created.externalId)}`, headers: { ...API, authorization: 'Bearer vendor-token-0002' } });
|
|
826
|
+
return out.externalId === created.externalId && calls.at(-1)?.method === 'DELETE' && calls.at(-1)?.path === `/rest/posts/${enc(created.externalId)}` && gone.status === 404;
|
|
827
|
+
})),
|
|
828
|
+
done('linkedin.connector.perform_twin_records_cross_nothing', 'connector', "A seeded page, member, role or token is the twin's own record and performs as nothing", 'connector', 'common', () => withVendor(async (_local, localRoot, vendorRoot) => {
|
|
829
|
+
const calls = [];
|
|
830
|
+
const seeds = pendingActions('linkedin', localRoot).filter((a) => String(a.operation).startsWith('linkedin.twin.'));
|
|
831
|
+
for (const s of seeds) {
|
|
832
|
+
const out = await performLinkedinAction(vendorExecutor(vendorRoot, calls), s, ctxFor(localRoot));
|
|
833
|
+
if (out.data?.performed !== false)
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
return seeds.length >= 4 && calls.length === 0;
|
|
837
|
+
})),
|
|
838
|
+
done('linkedin.connector.refresh_observes_pages_and_posts', 'connector', "syncLinkedinFromRemote observes the pages the member administers (with their follower count) and each one's posts; a second refresh leaves one copy of each", 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
839
|
+
const vendor = handleFor(vendorRoot);
|
|
840
|
+
const vApi = { ...API, authorization: 'Bearer vendor-token-0002' };
|
|
841
|
+
const made = String((await vendor({ m: 'POST', p: '/rest/posts', b: textPost('said on linkedin.com'), headers: vApi })).headers?.['x-restli-id']);
|
|
842
|
+
const exec = vendorExecutor(vendorRoot, []);
|
|
843
|
+
// a second page only the vendor holds: what the twin knows of it is what the refresh observed
|
|
844
|
+
await vendor({ m: 'POST', p: '/_twin/organizations', b: { id: '7777777', localizedName: 'Volter Labs', vanityName: 'volter-labs', followerCount: 99 }, headers: {} });
|
|
845
|
+
await vendor({ m: 'POST', p: '/_twin/organizationAcls', b: { organization: 'urn:li:organization:7777777', roleAssignee: 'urn:li:person:vendorAdmin', role: 'ADMINISTRATOR' }, headers: {} });
|
|
846
|
+
const r1 = await syncLinkedinFromRemote(exec, { root: localRoot, occurredAt: '2026-09-26T14:00:00.000Z', budget: scratchBudget() });
|
|
847
|
+
const r2 = await syncLinkedinFromRemote(exec, { root: localRoot, occurredAt: '2026-09-26T14:10:00.000Z', budget: scratchBudget() });
|
|
848
|
+
const got = (await local({ m: 'GET', p: `/rest/posts/${enc(made)}` })).body;
|
|
849
|
+
const org = (await local({ m: 'GET', p: '/rest/organizations?q=vanityName&vanityName=volter-labs' })).body.elements?.[0] ?? {};
|
|
850
|
+
const size = (await local({ m: 'GET', p: `/rest/networkSizes/${enc('urn:li:organization:7777777')}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` })).body;
|
|
851
|
+
const listed = (await local({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` })).body.elements;
|
|
852
|
+
return r1.organizations === 2 && r1.posts === 1 && r2.posts === 1 && got.commentary === 'said on linkedin.com' && got.id === made
|
|
853
|
+
&& org.localizedName === 'Volter Labs' && size.firstDegreeSize === 99 && listed.filter((p) => p.id === made).length === 1;
|
|
854
|
+
})),
|
|
855
|
+
done('linkedin.connector.refresh_refusal_throws', 'connector', 'A refused refresh read throws rather than folding an empty account', 'connector', 'core', async () => {
|
|
856
|
+
const refusing = async () => ({ status: 401, headers: {}, body: JSON.stringify({ status: 401, code: 'INVALID_ACCESS_TOKEN', message: 'Invalid access token' }) });
|
|
857
|
+
const emptyBody = async () => ({ status: 200, headers: {}, body: '{}' });
|
|
858
|
+
const root = mkdtempSync(join(tmpdir(), 'linkedin-cap-refuse-'));
|
|
859
|
+
try {
|
|
860
|
+
const messages = [];
|
|
861
|
+
for (const exec of [refusing, emptyBody]) {
|
|
862
|
+
try {
|
|
863
|
+
await syncLinkedinFromRemote(exec, { root, budget: scratchBudget() });
|
|
864
|
+
messages.push('no throw');
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
messages.push(String(e.message));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return /refused organizationAcls .*HTTP 401/.test(messages[0]) && /with no elements/.test(messages[1]);
|
|
871
|
+
}
|
|
872
|
+
finally {
|
|
873
|
+
rmSync(root, { recursive: true, force: true });
|
|
874
|
+
}
|
|
875
|
+
}),
|
|
876
|
+
done('linkedin.connector.perform_video_waits_on_the_schedule', 'connector', "A perform waiting on a video the vendor is still processing waits 2 s, 4 s, 8 s, 15 s … between status reads (the loop's own waits, recorded)", 'connector', 'core', () => withVendor(async (local, localRoot, vendorRoot) => {
|
|
877
|
+
const { urn: video } = await uploadVideo(local);
|
|
878
|
+
const post = await createPost(local, textPost('slow processing', { content: { media: { id: video } } }));
|
|
879
|
+
const inner = vendorExecutor(vendorRoot, []);
|
|
880
|
+
let statusReads = 0;
|
|
881
|
+
// the vendor reports PROCESSING for its first five status reads
|
|
882
|
+
const slow = async (request) => {
|
|
883
|
+
const res = await inner(request);
|
|
884
|
+
if (request.method === 'GET' && request.path.startsWith('/rest/videos/') && (statusReads += 1) <= 5)
|
|
885
|
+
return { ...res, body: JSON.stringify({ ...JSON.parse(res.body), status: 'PROCESSING' }) };
|
|
886
|
+
return res;
|
|
887
|
+
};
|
|
888
|
+
const waits = [];
|
|
889
|
+
const out = await performLinkedinActionWithin(scratchBudget(), slow, entryFor(localRoot, post, 'linkedin.post.create'), ctxFor(localRoot), async (ms) => { waits.push(ms); });
|
|
890
|
+
return /^urn:li:share:/.test(out.externalId) && statusReads === 6 && JSON.stringify(waits) === JSON.stringify([2000, 4000, 8000, 15000, 15000]);
|
|
891
|
+
})),
|
|
892
|
+
done('linkedin.connector.perform_video_wait_within_budget', 'connector', "The perform's wait for a processing video backs off (2 s doubling to 15 s), so its status reads never spend the 30-unit burst the post after it needs", 'connector', 'core', () => {
|
|
893
|
+
// every 60 s window of the schedule over the 10-minute bound holds at most 8 reads (plus initialize,
|
|
894
|
+
// finalize and the post: 2 + 2 + 5), well under the 30-unit burst
|
|
895
|
+
const reads = [];
|
|
896
|
+
for (let n = 0, t = 0; t < 600_000; n += 1) {
|
|
897
|
+
reads.push(t);
|
|
898
|
+
t += processingPollWait(n);
|
|
899
|
+
}
|
|
900
|
+
const worst = Math.max(...reads.map((t) => reads.filter((u) => u >= t && u < t + 60_000).length));
|
|
901
|
+
return processingPollWait(0) === 2_000 && processingPollWait(1) === 4_000 && processingPollWait(10) === 15_000 && worst + 2 + 2 + 5 <= 30 && worst <= 8;
|
|
902
|
+
}),
|
|
903
|
+
todo('linkedin.connector.refresh_deletions', 'connector', 'A refresh that tombstones posts deleted on linkedin.com (the author finder read in full as a complete scope)', 'connector', 'common'),
|
|
904
|
+
todo('linkedin.connector.perform_image_upload', 'connector', "Perform a post with an image: LinkedIn's image PUT to www.linkedin.com/dms-uploads needs the API token in Authorization, which a presigned upload does not carry and the executor sends only to the root's origin", 'connector', 'core'),
|
|
905
|
+
todo('linkedin.connector.refresh_assets', 'connector', "Refresh the images and videos a pulled post names (their status and downloadUrl), so a pulled media post previews locally", 'connector', 'common'),
|
|
906
|
+
todo('linkedin.connector.ingest_webhooks', 'connector', 'Ingest LinkedIn webhooks (organization social action notifications) through a signed ingest door', 'connector', 'niche'),
|
|
907
|
+
// ── the rate budget (the fail-closed backstop on the perform and refresh paths) ──────────────
|
|
908
|
+
done('linkedin.rate_limit.budget_fails_closed', 'rate_limit', 'The budgeted executor refuses BEFORE calling once the declared burst is spent, with the fake\'s call count unchanged', 'connector', 'core', async () => {
|
|
909
|
+
let calls = 0;
|
|
910
|
+
const fake = async () => { calls += 1; return { status: 201, headers: { 'x-restli-id': 'urn:li:share:1' }, body: '' }; };
|
|
911
|
+
const exec = budgetedLinkedinExecute(fake, new LinkedinBudget({ path: join(mkdtempSync(join(tmpdir(), 'linkedin-budget-')), 'ledger.json') }));
|
|
912
|
+
let refusedAt = -1;
|
|
913
|
+
for (let i = 0; i < 20; i += 1) {
|
|
914
|
+
try {
|
|
915
|
+
await exec({ method: 'POST', path: '/rest/posts', body: '{}' });
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
if (error instanceof LinkedinBudgetError) {
|
|
919
|
+
refusedAt = i;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// a post costs 5 and the burst is 30 units a minute: the seventh is refused, the fake saw six
|
|
926
|
+
return refusedAt === 6 && calls === 6;
|
|
927
|
+
}),
|
|
928
|
+
done('linkedin.rate_limit.prices_writes_above_reads', 'rate_limit', 'A post or delete costs more than a read, an upload control call sits between, and a presigned upload is not charged', 'connector', 'core', async () => {
|
|
929
|
+
let calls = 0;
|
|
930
|
+
const fake = async () => { calls += 1; return { status: 200, headers: { etag: 'x' }, body: '' }; };
|
|
931
|
+
const exec = budgetedLinkedinExecute(fake, new LinkedinBudget({ path: join(mkdtempSync(join(tmpdir(), 'linkedin-budget-')), 'ledger.json') }));
|
|
932
|
+
// forty presigned PUTs would spend more than the 30-unit burst if they were charged
|
|
933
|
+
for (let i = 0; i < 40; i += 1)
|
|
934
|
+
await exec({ method: 'PUT', path: `https://www.linkedin.com/dms-uploads/A/uploadedVideo/${i}`, presigned: true, body: new Uint8Array(1) });
|
|
935
|
+
return calls === 40
|
|
936
|
+
&& linkedinCallWeight('POST', '/rest/posts') === 5 && linkedinCallWeight('delete', `/rest/posts/${enc('urn:li:share:1')}`) === 5
|
|
937
|
+
&& linkedinCallWeight('POST', '/rest/videos?action=finalizeUpload') === 2 && linkedinCallWeight('GET', '/rest/videos/urn%3Ali%3Avideo%3AX') === 1
|
|
938
|
+
&& linkedinCallWeight('GET', '/rest/posts?q=author') === 1 && LINKEDIN_CALL_WEIGHTS.write > LINKEDIN_CALL_WEIGHTS.read;
|
|
939
|
+
}),
|
|
940
|
+
done('linkedin.rate_limit.declaration_is_the_descriptor', 'rate_limit', 'The pack descriptor arms the SAME declaration the adapters charge, at or under the kernel fallback', 'connector', 'common', async () => {
|
|
941
|
+
const { pack } = await import("./index.js");
|
|
942
|
+
return pack.rateBudget === LINKEDIN_RATE_BUDGET && LINKEDIN_RATE_BUDGET.burstCeiling === 30 && LINKEDIN_RATE_BUDGET.windowMs === 3_600_000;
|
|
943
|
+
}),
|
|
944
|
+
todo('linkedin.rate_limit.published_figures', 'rate_limit', "Pin the budget to an app's real daily figures (LinkedIn publishes none; each app reads its own in the Developer Portal)", 'connector', 'common'),
|
|
945
|
+
// ══ MIRROR (dimension 'ui' — each DATA-COUPLED: seed through the handler, read the SAME route
|
|
946
|
+
// the screen reads, render the mirror's OWN component over it) ═════════════════════════════════
|
|
947
|
+
done('linkedin.mirror.company_header', 'mirror', "The company header renders the page the lookup and networkSizes routes serve: name, description, website and follower line", 'ui', 'core', () => withWorld(async (h) => {
|
|
948
|
+
const pub = (await h({ m: 'GET', p: '/rest/organizations?q=vanityName&vanityName=volter' })).body.elements[0];
|
|
949
|
+
const admin = (await h({ m: 'GET', p: `/rest/organizations/${pub.id}` })).body;
|
|
950
|
+
const size = (await h({ m: 'GET', p: `/rest/networkSizes/${enc(ORG)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` })).body;
|
|
951
|
+
const org = orgFromReads(pub, admin, size, null);
|
|
952
|
+
const html = renderToStaticMarkup(createElement(CompanyHeader, { org }));
|
|
953
|
+
return html.includes('>Volter</h1>') && html.includes('Twins for every SaaS.') && html.includes('12,480 followers') && html.includes('volter.ai') && html.includes('company-tab active');
|
|
954
|
+
})),
|
|
955
|
+
done('linkedin.mirror.post_card_video', 'mirror', 'A post card renders the commentary (hashtags as links) and the video the post names as a player with its poster and downloadUrl', 'ui', 'core', () => withWorld(async (h) => {
|
|
956
|
+
const { urn: video } = await uploadVideo(h, mp4(), { thumbnail: tinyPng() });
|
|
957
|
+
const urn = await createPost(h, textPost('Out now #release', { content: { media: { title: 'tour', id: video } } }));
|
|
958
|
+
const post = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` })).body.elements[0];
|
|
959
|
+
const asset = (await h({ m: 'GET', p: `/rest/videos/${enc(video)}` })).body;
|
|
960
|
+
const pub = (await h({ m: 'GET', p: '/rest/organizations?q=vanityName&vanityName=volter' })).body.elements[0];
|
|
961
|
+
const size = (await h({ m: 'GET', p: `/rest/networkSizes/${enc(ORG)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` })).body;
|
|
962
|
+
const org = orgFromReads(pub, null, size, null);
|
|
963
|
+
const html = renderToStaticMarkup(createElement(PostCard, { post, org, nowMs: Date.parse(AT) + 3 * 3600_000, resolved: { kind: 'video', asset } }));
|
|
964
|
+
return post.id === urn && html.includes(`data-post-urn="${urn}"`) && html.includes('Out now') && html.includes('li-hashtag">#release<')
|
|
965
|
+
&& html.includes(`src="${asset.downloadUrl.replace(/&/g, '&')}"`) && html.includes(`poster="${asset.thumbnail.replace(/&/g, '&')}"`) && html.includes('aria-label="Play video"')
|
|
966
|
+
&& html.includes('>3h<') && html.includes('12,480 followers');
|
|
967
|
+
})),
|
|
968
|
+
done('linkedin.mirror.post_card_image_and_article', 'mirror', 'An image post renders its downloadUrl; an article post renders its title and source host', 'ui', 'common', () => withWorld(async (h) => {
|
|
969
|
+
const image = await uploadImage(h);
|
|
970
|
+
await createPost(h, textPost('pic', { content: { media: { altText: 'chart', id: image } } }));
|
|
971
|
+
await createPost(h, textPost('read', { content: { article: { source: 'https://www.volter.ai/blog/worlds', title: 'Worlds' } } }));
|
|
972
|
+
const [article, pic] = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}&sortBy=CREATED` })).body.elements;
|
|
973
|
+
const asset = (await h({ m: 'GET', p: `/rest/images/${enc(image)}` })).body;
|
|
974
|
+
const org = { id: 2414183, urn: ORG, name: 'Volter', vanity: 'volter' };
|
|
975
|
+
const a = renderToStaticMarkup(createElement(PostCard, { post: article, org, nowMs: Date.parse(AT), resolved: { kind: 'article' } }));
|
|
976
|
+
const p = renderToStaticMarkup(createElement(PostCard, { post: pic, org, nowMs: Date.parse(AT), resolved: { kind: 'image', asset } }));
|
|
977
|
+
return a.includes('class="article-title">Worlds<') && a.includes('>volter.ai<') && p.includes(`src="${asset.downloadUrl.replace(/&/g, '&')}"`) && p.includes('alt="chart"');
|
|
978
|
+
})),
|
|
979
|
+
done('linkedin.mirror.no_follower_line_without_a_count', 'mirror', "A page whose networkSizes read failed shows no follower line — never \"0 followers\"", 'ui', 'common', () => withWorld(async (h) => {
|
|
980
|
+
await createPost(h, textPost('no count'));
|
|
981
|
+
const pub = (await h({ m: 'GET', p: '/rest/organizations?q=vanityName&vanityName=volter' })).body.elements[0];
|
|
982
|
+
const failed = await h({ m: 'GET', p: `/rest/networkSizes/${enc(ORG)}?edgeType=CompanyFollowedByMember` });
|
|
983
|
+
const org = orgFromReads(pub, null, failed.status === 200 ? failed.body : null, null);
|
|
984
|
+
const post = (await h({ m: 'GET', p: `/rest/posts?q=author&author=${enc(ORG)}` })).body.elements[0];
|
|
985
|
+
const header = renderToStaticMarkup(createElement(CompanyHeader, { org }));
|
|
986
|
+
const card = renderToStaticMarkup(createElement(PostCard, { post, org, nowMs: Date.parse(AT), resolved: null }));
|
|
987
|
+
return failed.status === 400 && org.followers === undefined && !/follower/.test(header) && !/follower/.test(card) && header.includes('>Volter</h1>');
|
|
988
|
+
})),
|
|
989
|
+
done('linkedin.mirror.format_helpers', 'mirror', "The mirror's own formatters: linkedin.com timestamps, the follower line, and little-format commentary segments", 'ui', 'common', () => withWorld(async (h) => {
|
|
990
|
+
const urn = await createPost(h, textPost('Hi @[Devtestco](urn:li:organization:2414183) see https://volter.ai. #ship'));
|
|
991
|
+
const c = (await h({ m: 'GET', p: `/rest/posts/${enc(urn)}` })).body.commentary;
|
|
992
|
+
const segs = commentarySegments(c);
|
|
993
|
+
const now = Date.parse(AT);
|
|
994
|
+
return segs.some((s) => s.kind === 'mention' && s.value === 'Devtestco' && s.urn === 'urn:li:organization:2414183') && segs.some((s) => s.kind === 'url' && s.value === 'https://volter.ai')
|
|
995
|
+
&& segs.some((s) => s.kind === 'hashtag' && s.value === '#ship') && relativeTime(now - 90_000, now) === '1m' && relativeTime(now - 2 * 86_400_000, now) === '2d'
|
|
996
|
+
&& relativeTime(now - 21 * 86_400_000, now) === '3w' && followerLine(1) === '1 follower' && followerLine(12480) === '12,480 followers';
|
|
997
|
+
})),
|
|
998
|
+
done('linkedin.mirror.serves_the_vendor_api_on_its_own_origin', 'mirror', 'The mirror origin serves the REAL LinkedIn handler — one serving code path — with its shell and the version-gated API', 'ui', 'core', async () => {
|
|
999
|
+
const root = mkdtempSync(join(tmpdir(), 'linkedin-cap-mirror-'));
|
|
1000
|
+
const server = await createLinkedinMirrorServer({ root, port: 0 });
|
|
1001
|
+
try {
|
|
1002
|
+
const json = { 'content-type': 'application/json' };
|
|
1003
|
+
await fetch(`${server.url}/_twin/organizations`, { method: 'POST', headers: json, body: JSON.stringify({ id: '2414183', localizedName: 'Volter', vanityName: 'volter' }) });
|
|
1004
|
+
await fetch(`${server.url}/_twin/members`, { method: 'POST', headers: json, body: JSON.stringify({ id: 'orgAdmin', localizedFirstName: 'A', localizedLastName: 'B', email: 'admin@volter.ai' }) });
|
|
1005
|
+
await fetch(`${server.url}/_twin/organizationAcls`, { method: 'POST', headers: json, body: JSON.stringify({ organization: ORG, roleAssignee: 'urn:li:person:orgAdmin', role: 'ADMINISTRATOR' }) });
|
|
1006
|
+
await fetch(`${server.url}/_twin/tokens`, { method: 'POST', headers: json, body: JSON.stringify({ token: TOKEN, member: 'urn:li:person:orgAdmin', scopes: SCOPES }) });
|
|
1007
|
+
const posted = await fetch(`${server.url}/rest/posts`, { method: 'POST', headers: { ...json, ...API }, body: JSON.stringify(textPost('through the mirror origin')) });
|
|
1008
|
+
const urn = posted.headers.get('x-restli-id') ?? '';
|
|
1009
|
+
const direct = await handleLinkedinTwinRequest({ method: 'GET', path: `/rest/posts/${enc(urn)}`, headers: API, root });
|
|
1010
|
+
const me = await (await fetch(`${server.url}/v2/userinfo`, { headers: { authorization: API.authorization } })).json();
|
|
1011
|
+
const anon = await fetch(`${server.url}/rest/posts?q=author&author=${enc(ORG)}`, { headers: { 'linkedin-version': '202609' } });
|
|
1012
|
+
const shell = await (await fetch(`${server.url}/`)).text();
|
|
1013
|
+
return posted.status === 201 && direct.body.commentary === 'through the mirror origin' && me.email === 'admin@volter.ai' && anon.status === 401
|
|
1014
|
+
&& shell.includes('<div id="root">') && shell.includes('<base href="/">') && shell.includes('href="assets/styles.css"');
|
|
1015
|
+
}
|
|
1016
|
+
finally {
|
|
1017
|
+
server.stop();
|
|
1018
|
+
rmSync(root, { recursive: true, force: true });
|
|
1019
|
+
}
|
|
1020
|
+
}),
|
|
1021
|
+
todo('linkedin.mirror.compose', 'mirror', "The page admin's 'Start a post' composer (commentary plus media) writing through POST /rest/posts", 'ui', 'common'),
|
|
1022
|
+
todo('linkedin.mirror.social_counts', 'mirror', 'Reaction, comment and repost counts under a post card (needs the social surfaces)', 'ui', 'common'),
|
|
1023
|
+
todo('linkedin.mirror.about_tab', 'mirror', "The About tab (overview, website, industry, company size, headquarters)", 'ui', 'niche'),
|
|
1024
|
+
];
|
|
1025
|
+
export async function linkedinCapabilities() {
|
|
1026
|
+
return checkCapabilities('linkedin', LINKEDIN_CAPABILITIES);
|
|
1027
|
+
}
|