@volter/twin-linkedin 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/linkedin-mirror.bundle.js +175 -178
- package/dist/client/linkedin-mirror.js +10 -0
- package/dist/src/linkedin-mirror-ui.d.ts +15 -0
- package/dist/src/linkedin-mirror-ui.js +37 -3
- package/dist/src/linkedin-twin.js +3 -3
- package/package.json +3 -3
- package/src/linkedin-mirror-ui.ts +41 -3
- package/src/linkedin-twin.ts +3 -3
|
@@ -14,6 +14,15 @@ const SESSION_TOKEN = 'linkedin-mirror.token';
|
|
|
14
14
|
function readSession() {
|
|
15
15
|
if (typeof sessionStorage === 'undefined')
|
|
16
16
|
return null;
|
|
17
|
+
// the World signed these screens in as the member its config names (`signIn.as`): taken once, as a sign-in would
|
|
18
|
+
// (off the page once taken, and not again in this tab once the person signs out)
|
|
19
|
+
const w = window;
|
|
20
|
+
const given = sessionStorage.getItem('volter.signedOut') ? undefined : w.__VOLTER_SIGN_IN__;
|
|
21
|
+
delete w.__VOLTER_SIGN_IN__;
|
|
22
|
+
if (given && !sessionStorage.getItem(SESSION_TOKEN)) {
|
|
23
|
+
sessionStorage.setItem(SESSION_EMAIL, given.account);
|
|
24
|
+
sessionStorage.setItem(SESSION_TOKEN, given.token);
|
|
25
|
+
}
|
|
17
26
|
const email = sessionStorage.getItem(SESSION_EMAIL);
|
|
18
27
|
const token = sessionStorage.getItem(SESSION_TOKEN);
|
|
19
28
|
return email && token ? { email, token } : null;
|
|
@@ -374,6 +383,7 @@ function App() {
|
|
|
374
383
|
const signOut = () => {
|
|
375
384
|
sessionStorage.removeItem(SESSION_EMAIL);
|
|
376
385
|
sessionStorage.removeItem(SESSION_TOKEN);
|
|
386
|
+
sessionStorage.setItem('volter.signedOut', '1');
|
|
377
387
|
setSession(null);
|
|
378
388
|
go('login');
|
|
379
389
|
};
|
|
@@ -57,3 +57,18 @@ export declare function createLinkedinMirrorServer(options: {
|
|
|
57
57
|
export declare function linkedinMirrorHtml(): string;
|
|
58
58
|
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
59
59
|
export declare function linkedinMirrorStyles(): Promise<string>;
|
|
60
|
+
/** How the World reaches this twin while it serves: its root, and the twin's own fetch (same-origin paths). */
|
|
61
|
+
export type MirrorSignInContext = {
|
|
62
|
+
root: string;
|
|
63
|
+
twin: (path: string, init?: RequestInit) => Promise<Response>;
|
|
64
|
+
};
|
|
65
|
+
/** The member `as` names (an email, a member id, or the vanity name of an organization it administers; with or without
|
|
66
|
+
* a leading @, any case) and a fresh token minted for it through the twin's own `/_twin/tokens` door, as the World's
|
|
67
|
+
* seed issues one: what LinkedIn's screens sign in with when the World's config says `signIn: { as }` (the World hands
|
|
68
|
+
* them over, served-world.ts). `account` is the member's email, the identity the screens' own sign-in keeps. No stored
|
|
69
|
+
* bearer is read back (the twin keeps a token as its own id, as the seed's are kept). Null when the World holds no
|
|
70
|
+
* such member, or the door refuses the token. */
|
|
71
|
+
export declare function linkedinMirrorSignIn(as: string, ctx: MirrorSignInContext): Promise<{
|
|
72
|
+
account: string;
|
|
73
|
+
token: string;
|
|
74
|
+
} | null>;
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
|
|
17
17
|
// fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
18
18
|
import { readFile } from 'node:fs/promises';
|
|
19
|
-
import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
19
|
+
import { filePathOf, bundleClient, fileResponse, ownFields, serveHttp, twinResources } from '@volter/world-core';
|
|
20
20
|
import { createLinkedinTwinFetch } from "./linkedin-server.js";
|
|
21
|
-
const CLIENT_ENTRY = () => new URL('../client/linkedin-mirror.tsx', import.meta.url)
|
|
22
|
-
const CLIENT_CSS = () => new URL('../client/linkedin-mirror.css', import.meta.url)
|
|
21
|
+
const CLIENT_ENTRY = () => filePathOf(new URL('../client/linkedin-mirror.tsx', import.meta.url)); // lazy: workerd rejects a top-level relative import.meta.url
|
|
22
|
+
const CLIENT_CSS = () => filePathOf(new URL('../client/linkedin-mirror.css', import.meta.url));
|
|
23
23
|
/** The version the mirror's reads pin — the twin's newest. */
|
|
24
24
|
export const MIRROR_VERSION = '202609';
|
|
25
25
|
/** linkedin.com's feed timestamp: "now", "5m", "3h", "2d", "1w", "3mo", "1yr". `nowMs` is passed in. */
|
|
@@ -172,3 +172,37 @@ export function linkedinMirrorHtml() {
|
|
|
172
172
|
export function linkedinMirrorStyles() {
|
|
173
173
|
return readFile(CLIENT_CSS(), 'utf8');
|
|
174
174
|
}
|
|
175
|
+
/** Only what the screens read: who is signed in (with the email the screens compare), the organizations the member
|
|
176
|
+
* administers, and their posts. The member's organization roles still gate each read. */
|
|
177
|
+
const SIGN_IN_SCOPES = ['openid', 'email', 'r_organization_admin', 'r_organization_social'];
|
|
178
|
+
/** The member `as` names (an email, a member id, or the vanity name of an organization it administers; with or without
|
|
179
|
+
* a leading @, any case) and a fresh token minted for it through the twin's own `/_twin/tokens` door, as the World's
|
|
180
|
+
* seed issues one: what LinkedIn's screens sign in with when the World's config says `signIn: { as }` (the World hands
|
|
181
|
+
* them over, served-world.ts). `account` is the member's email, the identity the screens' own sign-in keeps. No stored
|
|
182
|
+
* bearer is read back (the twin keeps a token as its own id, as the seed's are kept). Null when the World holds no
|
|
183
|
+
* such member, or the door refuses the token. */
|
|
184
|
+
export async function linkedinMirrorSignIn(as, ctx) {
|
|
185
|
+
const rows = twinResources('linkedin', ctx.root);
|
|
186
|
+
const f = (r) => ownFields(r);
|
|
187
|
+
const want = as.trim().replace(/^@/, '').toLowerCase();
|
|
188
|
+
const members = rows.filter((r) => r.type === 'member');
|
|
189
|
+
let member = members.find((m) => String(f(m).email ?? '').toLowerCase() === want || m.id.toLowerCase() === want);
|
|
190
|
+
if (!member) {
|
|
191
|
+
// an organization's vanity name signs in as the member who administers it, when exactly one does (with two, which
|
|
192
|
+
// would depend on the order of the rows, so nobody is signed in)
|
|
193
|
+
const org = rows.find((r) => r.type === 'organization' && String(f(r).vanityName ?? '').toLowerCase() === want);
|
|
194
|
+
const admins = org ? rows.filter((r) => r.type === 'organization_acl' && f(r).organization === `urn:li:organization:${org.id}` && f(r).role === 'ADMINISTRATOR' && (f(r).state ?? 'APPROVED') === 'APPROVED') : [];
|
|
195
|
+
const acl = admins.length === 1 ? admins[0] : undefined;
|
|
196
|
+
const person = acl ? /^urn:li:person:(.+)$/.exec(String(f(acl).roleAssignee)) : null;
|
|
197
|
+
member = person ? members.find((m) => m.id === person[1]) : undefined;
|
|
198
|
+
}
|
|
199
|
+
const email = member ? f(member).email : undefined;
|
|
200
|
+
if (!member || typeof email !== 'string')
|
|
201
|
+
return null;
|
|
202
|
+
const token = `volter-view-${crypto.randomUUID()}`;
|
|
203
|
+
const minted = await ctx.twin('/_twin/tokens', {
|
|
204
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ token, member: `urn:li:person:${member.id}`, scopes: SIGN_IN_SCOPES }),
|
|
206
|
+
});
|
|
207
|
+
return minted.ok ? { account: email, token } : null;
|
|
208
|
+
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
//
|
|
29
29
|
// DETERMINISM. Every served answer is a function of (request, stored state). Times are the write's
|
|
30
30
|
// `occurredAt`; a video's processing is a fold of its finalize instant and the request's (PROCESSING_MS), never a timer and never a read that writes.
|
|
31
|
-
import { applyTwinWrite, applyTwinWriteAtomic, blobDigest, ownFields, projectResources, resolveSubjectId, subjectAliases } from '@volter/world-core';
|
|
31
|
+
import { applyTwinWrite, applyTwinWriteAtomic, blobDigest, ownFields, projectResources, resolveSubjectId, subjectAliases, worldNow } from '@volter/world-core';
|
|
32
32
|
import { accessDenied, emptyAccessToken, invalidAccessToken, invalidUrnId, invalidUrnType, invalidValue, missingField, noContent, notFound, ok, problem, readOnlyRefusal, unmodeled, versionInactive, versionMissing, } from "./linkedin-errors.js";
|
|
33
33
|
import { aspectRatio, clearParts, isAssetId, listAssetIds, MAX_IMAGE_PIXELS, MAX_VIDEO_BYTES, MAX_VIDEO_MS, MIN_VIDEO_BYTES, MIN_VIDEO_MS, putLinkedinBlob, putPart, readAsset, readPart, assetAsOf, newUploadSecret, signUpload, withAsset, assetIsOwn, hasUploadSecret, sniffImage, sniffVideo, UPLOAD_URL_LIFETIME_MS, verifyUpload, VIDEO_PART_BYTES, writeAsset, } from "./linkedin-media.js";
|
|
34
34
|
const SERVICE = 'linkedin';
|
|
@@ -93,8 +93,8 @@ function resources(root) {
|
|
|
93
93
|
}
|
|
94
94
|
const ofType = (all, type) => all.filter((r) => r.type === type);
|
|
95
95
|
const fields = (r) => ownFields(r);
|
|
96
|
-
const nowMs = (req) => Date.parse(req.occurredAt ??
|
|
97
|
-
const at = (req) => req.occurredAt ??
|
|
96
|
+
const nowMs = (req) => Date.parse(req.occurredAt ?? worldNow());
|
|
97
|
+
const at = (req) => req.occurredAt ?? worldNow();
|
|
98
98
|
const ORG_URN = /^urn:li:organization:(\d{1,19})$/;
|
|
99
99
|
const PERSON_URN = /^urn:li:person:([A-Za-z0-9_-]{1,40})$/;
|
|
100
100
|
const POST_URN = /^urn:li:(share|ugcPost):(\d{1,19})$/;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-linkedin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Local LinkedIn twin (organization posts with image, video or article; the Images and Videos upload protocols; admin roles) with a linkedin.com company-page mirror, built on @volter/world-core.",
|
|
5
5
|
"author": "Volter (https://github.com/volter-ai)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@volter/world-core": "2.0.
|
|
39
|
+
"@volter/world-core": "2.0.2"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/bun": "^1.2.20",
|
|
43
43
|
"@types/node": "^24.0.0",
|
|
44
44
|
"@types/react": "^19.2.17",
|
|
45
45
|
"@types/react-dom": "^19.2.3",
|
|
46
|
-
"@volter/world-core": "2.0.
|
|
46
|
+
"@volter/world-core": "2.0.2",
|
|
47
47
|
"@volter/world-tooling": "0.1.0",
|
|
48
48
|
"linkedin-api-client": "^0.3.0",
|
|
49
49
|
"typescript": "^5.9.0"
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
|
|
17
17
|
// fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
18
18
|
import { readFile } from 'node:fs/promises';
|
|
19
|
-
import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
19
|
+
import { filePathOf, bundleClient, fileResponse, ownFields, serveHttp, twinResources } from '@volter/world-core';
|
|
20
20
|
import { createLinkedinTwinFetch } from './linkedin-server.ts';
|
|
21
21
|
|
|
22
|
-
const CLIENT_ENTRY = () => new URL('../client/linkedin-mirror.tsx', import.meta.url)
|
|
23
|
-
const CLIENT_CSS = () => new URL('../client/linkedin-mirror.css', import.meta.url)
|
|
22
|
+
const CLIENT_ENTRY = () => filePathOf(new URL('../client/linkedin-mirror.tsx', import.meta.url)); // lazy: workerd rejects a top-level relative import.meta.url
|
|
23
|
+
const CLIENT_CSS = () => filePathOf(new URL('../client/linkedin-mirror.css', import.meta.url));
|
|
24
24
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// Pure, dependency-free render/format helpers (importable by the React client; Bun tree-shakes the
|
|
@@ -180,3 +180,41 @@ export function linkedinMirrorHtml(): string {
|
|
|
180
180
|
export function linkedinMirrorStyles(): Promise<string> {
|
|
181
181
|
return readFile(CLIENT_CSS(), 'utf8');
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/** How the World reaches this twin while it serves: its root, and the twin's own fetch (same-origin paths). */
|
|
185
|
+
export type MirrorSignInContext = { root: string; twin: (path: string, init?: RequestInit) => Promise<Response> };
|
|
186
|
+
|
|
187
|
+
/** Only what the screens read: who is signed in (with the email the screens compare), the organizations the member
|
|
188
|
+
* administers, and their posts. The member's organization roles still gate each read. */
|
|
189
|
+
const SIGN_IN_SCOPES = ['openid', 'email', 'r_organization_admin', 'r_organization_social'];
|
|
190
|
+
|
|
191
|
+
/** The member `as` names (an email, a member id, or the vanity name of an organization it administers; with or without
|
|
192
|
+
* a leading @, any case) and a fresh token minted for it through the twin's own `/_twin/tokens` door, as the World's
|
|
193
|
+
* seed issues one: what LinkedIn's screens sign in with when the World's config says `signIn: { as }` (the World hands
|
|
194
|
+
* them over, served-world.ts). `account` is the member's email, the identity the screens' own sign-in keeps. No stored
|
|
195
|
+
* bearer is read back (the twin keeps a token as its own id, as the seed's are kept). Null when the World holds no
|
|
196
|
+
* such member, or the door refuses the token. */
|
|
197
|
+
export async function linkedinMirrorSignIn(as: string, ctx: MirrorSignInContext): Promise<{ account: string; token: string } | null> {
|
|
198
|
+
const rows = twinResources('linkedin', ctx.root);
|
|
199
|
+
const f = (r: (typeof rows)[number]) => ownFields(r);
|
|
200
|
+
const want = as.trim().replace(/^@/, '').toLowerCase();
|
|
201
|
+
const members = rows.filter((r) => r.type === 'member');
|
|
202
|
+
let member = members.find((m) => String(f(m).email ?? '').toLowerCase() === want || m.id.toLowerCase() === want);
|
|
203
|
+
if (!member) {
|
|
204
|
+
// an organization's vanity name signs in as the member who administers it, when exactly one does (with two, which
|
|
205
|
+
// would depend on the order of the rows, so nobody is signed in)
|
|
206
|
+
const org = rows.find((r) => r.type === 'organization' && String(f(r).vanityName ?? '').toLowerCase() === want);
|
|
207
|
+
const admins = org ? rows.filter((r) => r.type === 'organization_acl' && f(r).organization === `urn:li:organization:${org.id}` && f(r).role === 'ADMINISTRATOR' && (f(r).state ?? 'APPROVED') === 'APPROVED') : [];
|
|
208
|
+
const acl = admins.length === 1 ? admins[0] : undefined;
|
|
209
|
+
const person = acl ? /^urn:li:person:(.+)$/.exec(String(f(acl).roleAssignee)) : null;
|
|
210
|
+
member = person ? members.find((m) => m.id === person[1]) : undefined;
|
|
211
|
+
}
|
|
212
|
+
const email = member ? f(member).email : undefined;
|
|
213
|
+
if (!member || typeof email !== 'string') return null;
|
|
214
|
+
const token = `volter-view-${crypto.randomUUID()}`;
|
|
215
|
+
const minted = await ctx.twin('/_twin/tokens', {
|
|
216
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
217
|
+
body: JSON.stringify({ token, member: `urn:li:person:${member.id}`, scopes: SIGN_IN_SCOPES }),
|
|
218
|
+
});
|
|
219
|
+
return minted.ok ? { account: email, token } : null;
|
|
220
|
+
}
|
package/src/linkedin-twin.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
//
|
|
29
29
|
// DETERMINISM. Every served answer is a function of (request, stored state). Times are the write's
|
|
30
30
|
// `occurredAt`; a video's processing is a fold of its finalize instant and the request's (PROCESSING_MS), never a timer and never a read that writes.
|
|
31
|
-
import { applyTwinWrite, applyTwinWriteAtomic, blobDigest, ownFields, projectResources, resolveSubjectId, subjectAliases } from '@volter/world-core';
|
|
31
|
+
import { applyTwinWrite, applyTwinWriteAtomic, blobDigest, ownFields, projectResources, resolveSubjectId, subjectAliases, worldNow } from '@volter/world-core';
|
|
32
32
|
import {
|
|
33
33
|
accessDenied,
|
|
34
34
|
emptyAccessToken,
|
|
@@ -159,8 +159,8 @@ function resources(root?: string): Resource[] {
|
|
|
159
159
|
|
|
160
160
|
const ofType = (all: Resource[], type: string): Resource[] => all.filter((r) => r.type === type);
|
|
161
161
|
const fields = (r: Resource): Record<string, any> => ownFields(r as never) as Record<string, any>;
|
|
162
|
-
const nowMs = (req: LinkedinRequest): number => Date.parse(req.occurredAt ??
|
|
163
|
-
const at = (req: LinkedinRequest): string => req.occurredAt ??
|
|
162
|
+
const nowMs = (req: LinkedinRequest): number => Date.parse(req.occurredAt ?? worldNow());
|
|
163
|
+
const at = (req: LinkedinRequest): string => req.occurredAt ?? worldNow();
|
|
164
164
|
|
|
165
165
|
const ORG_URN = /^urn:li:organization:(\d{1,19})$/;
|
|
166
166
|
const PERSON_URN = /^urn:li:person:([A-Za-z0-9_-]{1,40})$/;
|