@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.
@@ -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).pathname; // lazy: workerd rejects a top-level relative import.meta.url
22
- const CLIENT_CSS = () => new URL('../client/linkedin-mirror.css', import.meta.url).pathname;
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 ?? new Date().toISOString());
97
- const at = (req) => req.occurredAt ?? new Date().toISOString();
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.0",
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.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.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).pathname; // lazy: workerd rejects a top-level relative import.meta.url
23
- const CLIENT_CSS = () => new URL('../client/linkedin-mirror.css', import.meta.url).pathname;
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
+ }
@@ -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 ?? new Date().toISOString());
163
- const at = (req: LinkedinRequest): string => req.occurredAt ?? new Date().toISOString();
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})$/;