@firedrill-tools/linkedin 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +189 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +1834 -0
  5. package/firedrill/comment-throttled.scenario.json +11 -0
  6. package/firedrill/conformance.suite.json +27 -0
  7. package/firedrill/linkedin-comment-throttled.drill.json +51 -0
  8. package/firedrill/linkedin-comments-connection.drill.json +43 -0
  9. package/firedrill/linkedin-comments.drill.json +196 -0
  10. package/firedrill/linkedin-denied.drill.json +383 -0
  11. package/firedrill/linkedin-fresh-install.drill.json +43 -0
  12. package/firedrill/linkedin-identity.drill.json +73 -0
  13. package/firedrill/linkedin-invalid-token.drill.json +393 -0
  14. package/firedrill/linkedin-limited-scopes.drill.json +88 -0
  15. package/firedrill/linkedin-openid-only.drill.json +383 -0
  16. package/firedrill/linkedin-orgs-connection.drill.json +28 -0
  17. package/firedrill/linkedin-orgs-network.drill.json +193 -0
  18. package/firedrill/linkedin-post-lost-response.drill.json +61 -0
  19. package/firedrill/linkedin-posts-lifecycle.drill.json +228 -0
  20. package/firedrill/linkedin-reactions-metadata.drill.json +196 -0
  21. package/firedrill/linkedin-reactions-outage.drill.json +66 -0
  22. package/firedrill/linkedin-tight-limits.drill.json +228 -0
  23. package/firedrill/linkedin-ugc-legacy.drill.json +43 -0
  24. package/firedrill/linkedin-visibility-connection.drill.json +73 -0
  25. package/firedrill/linkedin-visibility-stranger.drill.json +88 -0
  26. package/firedrill/linkedin-wire-errors.drill.json +353 -0
  27. package/firedrill/post-lost-response.scenario.json +11 -0
  28. package/firedrill/reactions-outage.scenario.json +11 -0
  29. package/firedrill/tight-limits.scenario.json +16 -0
  30. package/firedrill/tools/linkedin/app/assets/ATTRIBUTION.md +28 -0
  31. package/firedrill/tools/linkedin/app/assets/fonts/OFL.txt +93 -0
  32. package/firedrill/tools/linkedin/app/assets/linkedin-in.svg +1 -0
  33. package/firedrill/tools/linkedin/app/assets/linkedin-wordmark.svg +1 -0
  34. package/firedrill/tools/linkedin/app/assets/linkedin.svg +1 -0
  35. package/firedrill/tools/linkedin/app/site/app.js +80 -0
  36. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-400-normal.woff2 +0 -0
  37. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-600-normal.woff2 +0 -0
  38. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-700-normal.woff2 +0 -0
  39. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-400-normal.woff2 +0 -0
  40. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-600-normal.woff2 +0 -0
  41. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-700-normal.woff2 +0 -0
  42. package/firedrill/tools/linkedin/app/site/assets/linkedin-wordmark.svg +1 -0
  43. package/firedrill/tools/linkedin/app/site/assets/linkedin.svg +1 -0
  44. package/firedrill/tools/linkedin/app/site/base.css +87 -0
  45. package/firedrill/tools/linkedin/app/site/comment-box.js +119 -0
  46. package/firedrill/tools/linkedin/app/site/comments.css +59 -0
  47. package/firedrill/tools/linkedin/app/site/comments.js +188 -0
  48. package/firedrill/tools/linkedin/app/site/company.js +115 -0
  49. package/firedrill/tools/linkedin/app/site/composer.js +112 -0
  50. package/firedrill/tools/linkedin/app/site/feed.css +105 -0
  51. package/firedrill/tools/linkedin/app/site/feed.js +134 -0
  52. package/firedrill/tools/linkedin/app/site/icons.js +70 -0
  53. package/firedrill/tools/linkedin/app/site/index.html +58 -0
  54. package/firedrill/tools/linkedin/app/site/nav.css +51 -0
  55. package/firedrill/tools/linkedin/app/site/network.js +77 -0
  56. package/firedrill/tools/linkedin/app/site/overlay.css +62 -0
  57. package/firedrill/tools/linkedin/app/site/overlay.js +108 -0
  58. package/firedrill/tools/linkedin/app/site/pages.css +78 -0
  59. package/firedrill/tools/linkedin/app/site/post-actions.js +83 -0
  60. package/firedrill/tools/linkedin/app/site/post-menu.js +72 -0
  61. package/firedrill/tools/linkedin/app/site/post.css +82 -0
  62. package/firedrill/tools/linkedin/app/site/post.js +131 -0
  63. package/firedrill/tools/linkedin/app/site/profile.js +139 -0
  64. package/firedrill/tools/linkedin/app/site/reactions.js +46 -0
  65. package/firedrill/tools/linkedin/app/site/reactors.js +71 -0
  66. package/firedrill/tools/linkedin/app/site/shell.js +107 -0
  67. package/firedrill/tools/linkedin/app/site/store.js +62 -0
  68. package/firedrill/tools/linkedin/app/site/text.js +80 -0
  69. package/firedrill/tools/linkedin/app/site/ui.js +142 -0
  70. package/firedrill/tools/linkedin/behavior.mjs +66 -0
  71. package/firedrill/tools/linkedin/lib/access.mjs +69 -0
  72. package/firedrill/tools/linkedin/lib/auth.mjs +115 -0
  73. package/firedrill/tools/linkedin/lib/cascade.mjs +28 -0
  74. package/firedrill/tools/linkedin/lib/errors.mjs +50 -0
  75. package/firedrill/tools/linkedin/lib/message.mjs +43 -0
  76. package/firedrill/tools/linkedin/lib/paging.mjs +54 -0
  77. package/firedrill/tools/linkedin/lib/postinput.mjs +47 -0
  78. package/firedrill/tools/linkedin/lib/render.mjs +95 -0
  79. package/firedrill/tools/linkedin/lib/routes.mjs +219 -0
  80. package/firedrill/tools/linkedin/lib/social.mjs +25 -0
  81. package/firedrill/tools/linkedin/lib/store.mjs +54 -0
  82. package/firedrill/tools/linkedin/lib/text.mjs +55 -0
  83. package/firedrill/tools/linkedin/lib/urn.mjs +93 -0
  84. package/firedrill/tools/linkedin/lib/wire.mjs +125 -0
  85. package/firedrill/tools/linkedin/linkedin.tool.json +8935 -0
  86. package/firedrill/tools/linkedin/ops/comments-read.mjs +57 -0
  87. package/firedrill/tools/linkedin/ops/comments-write.mjs +104 -0
  88. package/firedrill/tools/linkedin/ops/feed.mjs +74 -0
  89. package/firedrill/tools/linkedin/ops/network.mjs +46 -0
  90. package/firedrill/tools/linkedin/ops/orgs.mjs +93 -0
  91. package/firedrill/tools/linkedin/ops/posts-edit.mjs +71 -0
  92. package/firedrill/tools/linkedin/ops/posts-read.mjs +54 -0
  93. package/firedrill/tools/linkedin/ops/posts-write.mjs +74 -0
  94. package/firedrill/tools/linkedin/ops/profile.mjs +38 -0
  95. package/firedrill/tools/linkedin/ops/reactions.mjs +78 -0
  96. package/firedrill/tools/linkedin/ops/social.mjs +34 -0
  97. package/firedrill/world.json +2585 -0
  98. package/firedrill.json +5 -0
  99. package/package.json +52 -0
  100. package/starter.json +1833 -0
  101. package/test/client.mjs +71 -0
  102. package/test/conformance.mjs +20 -0
  103. package/test/fixtures.mjs +182 -0
  104. package/test/flows/comments.mjs +116 -0
  105. package/test/flows/every.mjs +38 -0
  106. package/test/flows/faults.mjs +56 -0
  107. package/test/flows/identity.mjs +70 -0
  108. package/test/flows/orgs.mjs +107 -0
  109. package/test/flows/posts.mjs +196 -0
  110. package/test/flows/reactions.mjs +101 -0
  111. package/test/flows/wire.mjs +62 -0
@@ -0,0 +1,57 @@
1
+ // comments.list and comments.get.
2
+ import { commentIdArg, loadThread } from "../lib/access.mjs";
3
+ import { caller, checkWire } from "../lib/auth.mjs";
4
+ import { reactionsOn, threadComments } from "../lib/cascade.mjs";
5
+ import { notFound } from "../lib/errors.mjs";
6
+ import { buildPage, pageArgs } from "../lib/paging.mjs";
7
+ import { compareIds } from "../lib/postinput.mjs";
8
+ import { renderComment } from "../lib/render.mjs";
9
+ import { commentUrn } from "../lib/urn.mjs";
10
+
11
+ const oldestFirst = (a, b) => a.createdAtMs - b.createdAtMs || compareIds(a.id, b.id);
12
+
13
+ /** Groups a thread's replies by parent id once per request. */
14
+ function repliesByParent(rows) {
15
+ const map = new Map();
16
+ for (const row of rows) {
17
+ if (row.parentCommentId === null) continue;
18
+ if (!map.has(row.parentCommentId)) map.set(row.parentCommentId, []);
19
+ map.get(row.parentCommentId).push(row);
20
+ }
21
+ for (const list of map.values()) list.sort(oldestFirst);
22
+ return map;
23
+ }
24
+
25
+ function encodePath(value) {
26
+ try {
27
+ return encodeURIComponent(value);
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
32
+
33
+ export function listComments(input, context) {
34
+ const who = caller(context);
35
+ checkWire(context, input, "GET_ALL");
36
+ const { post, comment, parsed } = loadThread(context, who, "target", input.target, "GET /socialActions/comments",
37
+ { inPath: input.wire !== undefined });
38
+ const { start, count } = pageArgs(context, input);
39
+ const rows = threadComments(context, post.activityId);
40
+ const replies = repliesByParent(rows);
41
+ const items = rows.filter((row) => (comment === null ? row.parentCommentId === null : row.parentCommentId === comment.id)).sort(oldestFirst);
42
+ const render = (row) => renderComment(row, reactionsOn(context, commentUrn(row.activityId, row.id)), replies.get(row.id) ?? [], who.urn);
43
+ return buildPage(context, items, render, { start, count, path: `/rest/socialActions/${encodePath(parsed.urn)}/comments` });
44
+ }
45
+
46
+ export function getComment(input, context) {
47
+ const who = caller(context);
48
+ checkWire(context, input, "GET");
49
+ const inPath = input.wire !== undefined;
50
+ const { post } = loadThread(context, who, "target", input.target, "GET /socialActions/comments", { inPath });
51
+ const commentId = commentIdArg(context, input.commentId, inPath);
52
+ const row = context.state.get("comments", `${post.activityId}|${commentId}`);
53
+ if (row === null) notFound(context, `Comment ${commentId} not found`);
54
+ const replies = row.parentCommentId === null
55
+ ? threadComments(context, post.activityId).filter((reply) => reply.parentCommentId === row.id).sort(oldestFirst) : [];
56
+ return renderComment(row, reactionsOn(context, commentUrn(row.activityId, row.id)), replies, who.urn);
57
+ }
@@ -0,0 +1,104 @@
1
+ // comments.create, comments.update and comments.delete.
2
+ import { commentIdArg, loadThread } from "../lib/access.mjs";
3
+ import { actingAs, caller, checkWire, managesAuthor, requireScope } from "../lib/auth.mjs";
4
+ import { deleteComments, reactionsOn, threadComments } from "../lib/cascade.mjs";
5
+ import { fail, invalidValue, missing, notFound, unprocessable } from "../lib/errors.mjs";
6
+ import { messageArg } from "../lib/message.mjs";
7
+ import { renderComment } from "../lib/render.mjs";
8
+ import { nextId, nowMs } from "../lib/store.mjs";
9
+ import { activityUrn, commentUrn, parseUrn } from "../lib/urn.mjs";
10
+
11
+ const RESOURCE = "POST /socialActions/comments";
12
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
13
+
14
+ /** The actor a comment edit or delete acts as: `?actor=` when given, otherwise the calling member. */
15
+ function effectiveActor(context, who, input, resource) {
16
+ if (input.actor !== undefined && input.actor !== null) return actingAs(context, who, "actor", input.actor, resource);
17
+ requireScope(context, who, ["w_member_social"], resource);
18
+ return { urn: who.urn, kind: "person", id: who.personId, agent: null };
19
+ }
20
+
21
+ function loadComment(context, who, input, resource) {
22
+ const inPath = input.wire !== undefined;
23
+ const thread = loadThread(context, who, "target", input.target, resource, { inPath, write: true });
24
+ const commentId = commentIdArg(context, input.commentId, inPath);
25
+ const comment = context.state.get("comments", `${thread.post.activityId}|${commentId}`);
26
+ if (comment === null) notFound(context, `Comment ${commentId} not found`);
27
+ return { ...thread, row: comment };
28
+ }
29
+
30
+ export function createComment(input, context) {
31
+ const who = caller(context);
32
+ checkWire(context, input, "CREATE");
33
+ const { post, comment: targetComment } = loadThread(context, who, "target", input.target, RESOURCE,
34
+ { inPath: input.wire !== undefined, write: true });
35
+ if (input.actor === undefined || input.actor === null) missing(context, "actor");
36
+ const actor = actingAs(context, who, "actor", input.actor, RESOURCE);
37
+ if (input.content !== undefined) {
38
+ fail(context, "ACCESS_DENIED", "Unpermitted fields present in REQUEST_BODY: Data Processing Exception while processing fields [/content]");
39
+ }
40
+ const message = messageArg(context, input.message);
41
+ const thread = activityUrn(post.activityId);
42
+ if (input.object !== undefined && input.object !== null) {
43
+ const object = parseUrn(input.object);
44
+ const sameThread = object !== null && (input.object === thread || (object.type !== "comment" && object.type !== "activity"
45
+ && object.id === post.id && object.type === post.kind));
46
+ if (!sameThread) invalidValue(context, "object", input.object);
47
+ }
48
+ let parent = targetComment;
49
+ if (input.parentComment !== undefined && input.parentComment !== null) {
50
+ const parsed = parseUrn(input.parentComment);
51
+ if (parsed === null || parsed.type !== "comment" || parsed.activityId !== post.activityId
52
+ || (targetComment !== null && parsed.commentId !== targetComment.id)) invalidValue(context, "parentComment", input.parentComment);
53
+ parent = context.state.get("comments", `${parsed.activityId}|${parsed.commentId}`);
54
+ if (parent === null) notFound(context, `Comment ${parsed.urn} not found`);
55
+ }
56
+ if (parent !== null && parent.parentCommentId !== null) unprocessable(context, "Replies to a nested comment are not supported");
57
+ if (post.lifecycleState === "DRAFT") unprocessable(context, "Cannot comment on a draft post");
58
+ if (post.commentsState === "CLOSED") unprocessable(context, "Thread is closed to comments");
59
+ const now = nowMs(context);
60
+ const id = nextId(context);
61
+ const row = {
62
+ id, activityId: post.activityId, actor: actor.urn, agent: actor.agent, text: message.text, attributes: message.attributes,
63
+ parentCommentId: parent === null ? null : parent.id, createdAtMs: now, lastModifiedAtMs: now,
64
+ };
65
+ context.state.put("comments", `${post.activityId}|${id}`, row);
66
+ context.events.emit("comment.created", {
67
+ commentUrn: commentUrn(post.activityId, id), object: thread, postUrn: `urn:li:${post.kind}:${post.id}`, actor: actor.urn,
68
+ agent: actor.agent === null ? null : `urn:li:person:${actor.agent}`, parentComment: parent === null ? null : commentUrn(post.activityId, parent.id),
69
+ created: now,
70
+ });
71
+ return renderComment(row, [], [], who.urn);
72
+ }
73
+
74
+ export function updateComment(input, context) {
75
+ const who = caller(context);
76
+ checkWire(context, input, "PARTIAL_UPDATE");
77
+ const { row } = loadComment(context, who, input, "POST /socialActions/comments");
78
+ if (typeof input.wire?.fieldError === "string") invalidValue(context, input.wire.fieldError, "an unsupported patch");
79
+ const actor = effectiveActor(context, who, input, "POST /socialActions/comments");
80
+ if (actor.urn !== row.actor) fail(context, "ACCESS_DENIED", "Not enough permissions to edit this comment");
81
+ const message = messageArg(context, input.message);
82
+ const urn = commentUrn(row.activityId, row.id);
83
+ const replies = row.parentCommentId === null ? threadComments(context, row.activityId).filter((c) => c.parentCommentId === row.id) : [];
84
+ if (message.text === row.text && sameJson(message.attributes, row.attributes)) {
85
+ return renderComment(row, reactionsOn(context, urn), replies, who.urn);
86
+ }
87
+ const next = { ...row, text: message.text, attributes: message.attributes, lastModifiedAtMs: nowMs(context) };
88
+ context.state.put("comments", `${row.activityId}|${row.id}`, next);
89
+ return renderComment(next, reactionsOn(context, urn), replies, who.urn);
90
+ }
91
+
92
+ export function deleteComment(input, context) {
93
+ const who = caller(context);
94
+ checkWire(context, input, "DELETE");
95
+ const { post, row } = loadComment(context, who, input, "DELETE /socialActions/comments");
96
+ const actor = effectiveActor(context, who, input, "DELETE /socialActions/comments");
97
+ if (actor.urn !== row.actor && !managesAuthor(context, who, post.author)) {
98
+ fail(context, "ACCESS_DENIED", "Not enough permissions to delete this comment");
99
+ }
100
+ const replies = row.parentCommentId === null ? threadComments(context, row.activityId).filter((c) => c.parentCommentId === row.id) : [];
101
+ const deletedReplies = deleteComments(context, replies);
102
+ deleteComments(context, [row]);
103
+ return { commentUrn: commentUrn(row.activityId, row.id), deleted: true, deletedReplies };
104
+ }
@@ -0,0 +1,74 @@
1
+ // feed.list: reverse-chronological home feed of the caller, its connections and followed or managed organizations.
2
+ import { visibility } from "../lib/access.mjs";
3
+ import { caller, requireScope } from "../lib/auth.mjs";
4
+ import { buildPage, pageArgs } from "../lib/paging.mjs";
5
+ import { compareIds } from "../lib/postinput.mjs";
6
+ import { fullName, renderPost } from "../lib/render.mjs";
7
+ import { reactionSummaries, summarize } from "../lib/social.mjs";
8
+ import { scanValues } from "../lib/store.mjs";
9
+ import { organizationUrn, parseUrn, postUrnOf } from "../lib/urn.mjs";
10
+
11
+ function actorInfo(context, urn) {
12
+ const parsed = parseUrn(urn);
13
+ if (parsed?.type === "organization") {
14
+ const org = context.state.get("organizations", parsed.id);
15
+ return { urn, kind: "organization", name: org?.name ?? "LinkedIn Member", headline: null, vanityName: org?.vanityName ?? "" };
16
+ }
17
+ const person = parsed === null ? null : context.state.get("people", parsed.id);
18
+ return { urn, kind: "person", name: person === null ? "LinkedIn Member" : fullName(person), headline: person?.headline ?? null,
19
+ vanityName: person?.vanityName ?? "" };
20
+ }
21
+
22
+ export function listFeed(input, context) {
23
+ const who = caller(context);
24
+ requireScope(context, who, ["r_member_social"], "GET /feed");
25
+ const { start, count } = pageArgs(context, input, { defaultCount: 10, maxCount: 50 });
26
+ const connections = new Set(scanValues(context, "connections", `${who.personId}|`).map((edge) => edge.connectedPersonId));
27
+ const pages = new Map();
28
+ for (const acl of scanValues(context, "organization-acls")) {
29
+ if (acl.personId !== who.personId || acl.state !== "APPROVED") continue;
30
+ if (!pages.has(acl.organizationId)) pages.set(acl.organizationId, []);
31
+ pages.get(acl.organizationId).push(acl.role);
32
+ }
33
+ const followed = new Set([...who.person.followingOrganizationIds.map(String), ...[...pages.keys()].map(String)]);
34
+ const all = scanValues(context, "posts");
35
+ const byUrn = new Map(all.map((post) => [postUrnOf(post), post]));
36
+ const reposts = new Map();
37
+ for (const post of all) {
38
+ if (post.reshareParent !== null && post.deletedAtMs === null && post.lifecycleState === "PUBLISHED") {
39
+ reposts.set(post.reshareParent, (reposts.get(post.reshareParent) ?? 0) + 1);
40
+ }
41
+ }
42
+ const inNetwork = (author) => {
43
+ if (author === who.urn) return true;
44
+ const parsed = parseUrn(author);
45
+ return parsed !== null && (parsed.type === "person" ? connections.has(parsed.id) : followed.has(parsed.id));
46
+ };
47
+ const items = all
48
+ .filter((post) => post.lifecycleState === "PUBLISHED" && inNetwork(post.author) && visibility(context, who, post) === "ok")
49
+ .sort((a, b) => b.publishedAtMs - a.publishedAtMs || compareIds(b.id, a.id));
50
+ const render = (post) => {
51
+ const { commentSummary, reactions } = summarize(context, post, null);
52
+ const parent = post.reshareParent === null ? null : byUrn.get(post.reshareParent) ?? null;
53
+ const showParent = parent !== null && parent.lifecycleState === "PUBLISHED" && visibility(context, who, parent) === "ok";
54
+ return {
55
+ post: renderPost(post), actor: actorInfo(context, post.author),
56
+ reshareOf: showParent ? { post: renderPost(parent), actor: actorInfo(context, parent.author) } : null,
57
+ social: { reactionSummaries: reactionSummaries(reactions), commentSummary, commentsState: post.commentsState,
58
+ repostCount: reposts.get(postUrnOf(post)) ?? 0 },
59
+ viewerReaction: reactions.find((reaction) => reaction.actor === who.urn)?.reactionType ?? null,
60
+ };
61
+ };
62
+ const page = buildPage(context, items, render, { start, count, path: "/v1/operations/linkedin/feed.list" });
63
+ const nowMs = Math.floor(context.clock.nowUs() / 1000);
64
+ const viewerPages = [...pages.entries()].map(([organizationId, roles]) => {
65
+ const org = context.state.get("organizations", String(organizationId));
66
+ return { organizationUrn: organizationUrn(organizationId), name: org?.name ?? "", vanityName: org?.vanityName ?? "", roles: roles.sort() };
67
+ });
68
+ return {
69
+ serverTime: new Date(nowMs).toISOString(), serverTimeMs: nowMs,
70
+ viewer: { personUrn: who.urn, name: fullName(who.person), headline: who.person.headline, vanityName: who.person.vanityName,
71
+ connectionCount: connections.size, pages: viewerPages },
72
+ ...page,
73
+ };
74
+ }
@@ -0,0 +1,46 @@
1
+ // network_sizes.get and connections.list.
2
+ import { caller, checkWire, requireScope, urnField } from "../lib/auth.mjs";
3
+ import { fail, invalidValue, missing, notFound } from "../lib/errors.mjs";
4
+ import { buildPage, pageArgs } from "../lib/paging.mjs";
5
+ import { liteProfile } from "../lib/render.mjs";
6
+ import { scanPrefix, scanValues } from "../lib/store.mjs";
7
+ import { fold } from "../lib/text.mjs";
8
+
9
+ export function getNetworkSize(input, context) {
10
+ const who = caller(context);
11
+ checkWire(context, input, "GET");
12
+ const entity = urnField(context, "entity", input.entity, ["organization", "person"], { inPath: input.wire !== undefined });
13
+ if (entity.type === "organization") {
14
+ requireScope(context, who, ["rw_organization_admin", "r_organization_social"], "GET /networkSizes");
15
+ if (input.edgeType === undefined || input.edgeType === null) missing(context, "edgeType");
16
+ if (input.edgeType !== "COMPANY_FOLLOWED_BY_MEMBER") invalidValue(context, "edgeType", input.edgeType);
17
+ const org = context.state.get("organizations", entity.id);
18
+ if (org === null) notFound(context, `Organization ${entity.id} is inactive`);
19
+ return { firstDegreeSize: org.followerCount };
20
+ }
21
+ requireScope(context, who, ["r_1st_connections_size"], "GET /connections");
22
+ if (input.edgeType !== undefined && input.edgeType !== null) invalidValue(context, "edgeType", input.edgeType);
23
+ if (entity.id !== who.personId) fail(context, "ACCESS_DENIED", `Not enough permissions to access: GET /connections for ${entity.urn}`);
24
+ return { firstDegreeSize: scanPrefix(context, "connections", `${who.personId}|`).length };
25
+ }
26
+
27
+ export function listConnections(input, context) {
28
+ const who = caller(context);
29
+ requireScope(context, who, ["r_1st_connections_size"], "GET /connections");
30
+ const query = input.query ?? "";
31
+ if (typeof query !== "string" || query.length > 100 || query.includes("�")) invalidValue(context, "query", query);
32
+ const { start, count } = pageArgs(context, input, { defaultCount: 20, maxCount: 100 });
33
+ const needle = fold(query.trim());
34
+ const rows = [];
35
+ for (const edge of scanValues(context, "connections", `${who.personId}|`)) {
36
+ const person = context.state.get("people", edge.connectedPersonId);
37
+ if (person === null) continue;
38
+ const haystacks = [person.firstName, person.lastName, person.headline ?? ""].map(fold);
39
+ if (needle !== "" && !haystacks.some((text) => text.includes(needle))) continue;
40
+ rows.push({ person, connectedAt: edge.connectedAtMs });
41
+ }
42
+ rows.sort((a, b) => b.connectedAt - a.connectedAt || (a.person.id < b.person.id ? -1 : a.person.id > b.person.id ? 1 : 0));
43
+ return buildPage(context, rows, (row) => ({ person: liteProfile(row.person), connectedAt: row.connectedAt }), {
44
+ start, count, path: "/v1/operations/linkedin/connections.list", query: needle === "" ? [] : [["query", query]],
45
+ });
46
+ }
@@ -0,0 +1,93 @@
1
+ // organizations.get, organizations.find_by_vanity_name and organization_acls.list.
2
+ import { caller, checkWire, requireScope, rolesOn, urnField } from "../lib/auth.mjs";
3
+ import { badRequest, fail, invalidValue, missing, notFound } from "../lib/errors.mjs";
4
+ import { buildPage, pageArgs } from "../lib/paging.mjs";
5
+ import { scanValues } from "../lib/store.mjs";
6
+ import { organizationUrn, personUrn } from "../lib/urn.mjs";
7
+
8
+ const ORG_ID = /^[1-9][0-9]{0,11}$/;
9
+ const ROLES = new Set(["ADMINISTRATOR", "DIRECT_SPONSORED_CONTENT_POSTER", "RECRUITING_POSTER", "LEAD_CAPTURE_ADMINISTRATOR",
10
+ "LEAD_GEN_FORMS_MANAGER", "ANALYST", "CURATOR", "CONTENT_ADMINISTRATOR"]);
11
+ const STATES = new Set(["APPROVED", "REQUESTED", "REJECTED", "REVOKED"]);
12
+
13
+ const localized = (org, value) => ({
14
+ localized: { [`${org.localeLanguage}_${org.localeCountry}`]: value }, preferredLocale: { country: org.localeCountry, language: org.localeLanguage },
15
+ });
16
+ const locations = (org) => org.locations.map((location) => ({
17
+ address: { city: location.city, country: location.country, ...(location.geographicArea === null ? {} : { geographicArea: location.geographicArea }),
18
+ ...(location.postalCode === null ? {} : { postalCode: location.postalCode }) },
19
+ locationType: "OTHER",
20
+ }));
21
+
22
+ function nonAdminFields(org) {
23
+ return {
24
+ id: org.id, name: localized(org, org.name), localizedName: org.name, ...(org.website === null ? {} : { localizedWebsite: org.website }),
25
+ vanityName: org.vanityName, locations: locations(org), primaryOrganizationType: org.primaryOrganizationType,
26
+ };
27
+ }
28
+
29
+ export function getOrganization(input, context) {
30
+ const who = caller(context);
31
+ checkWire(context, input, "GET");
32
+ requireScope(context, who, ["rw_organization_admin"], "GET /organizations");
33
+ const id = input.organizationId;
34
+ if (typeof id !== "string" || !ORG_ID.test(id)) badRequest(context, "Syntax exception in path variables");
35
+ const org = context.state.get("organizations", id);
36
+ if (org === null) notFound(context, `Organization ${id} is inactive`);
37
+ if (!rolesOn(context, id, who.personId).has("ADMINISTRATOR")) {
38
+ fail(context, "ACCESS_DENIED", `Viewer don't have permission to the ADMIN_ONLY VisibilityReduction for urn:li:organization:${id}`);
39
+ }
40
+ return {
41
+ ...nonAdminFields(org), $URN: organizationUrn(id),
42
+ ...(org.description === null ? {} : { localizedDescription: org.description, description: localized(org, org.description) }),
43
+ ...(org.website === null ? {} : { website: localized(org, org.website) }),
44
+ industries: org.industries, staffCountRange: org.staffCountRange, organizationType: org.organizationType,
45
+ ...(org.foundedOn === null ? {} : { foundedOn: org.foundedOn }),
46
+ defaultLocale: { country: org.localeCountry, language: org.localeLanguage }, versionTag: String(org.createdAtMs % 1000000),
47
+ alternativeNames: [], specialties: [], localizedSpecialties: [], groups: [],
48
+ };
49
+ }
50
+
51
+ export function findByVanityName(input, context) {
52
+ const who = caller(context);
53
+ checkWire(context, input, "FINDER");
54
+ requireScope(context, who, ["rw_organization_admin", "r_organization_social"], "GET /organizations");
55
+ const vanity = input.vanityName;
56
+ if (typeof vanity !== "string" || !/^[A-Za-z0-9-]{1,100}$/.test(vanity)) invalidValue(context, "vanityName", vanity);
57
+ const entry = context.state.get("org-vanity", vanity.toLowerCase());
58
+ const org = entry === null ? null : context.state.get("organizations", String(entry.organizationId));
59
+ const elements = org === null ? [] : [nonAdminFields(org)];
60
+ return { paging: { start: 0, count: 10, links: [], total: elements.length }, elements };
61
+ }
62
+
63
+ export function listAcls(input, context) {
64
+ const who = caller(context);
65
+ checkWire(context, input, "FINDER");
66
+ requireScope(context, who, ["rw_organization_admin"], "GET /organizationAcls");
67
+ if (input.q === undefined || input.q === null) missing(context, "q");
68
+ if (input.q !== "roleAssignee" && input.q !== "organization") invalidValue(context, "q", input.q);
69
+ if (input.role !== undefined && input.role !== null && !(typeof input.role === "string" && ROLES.has(input.role))) invalidValue(context, "role", input.role);
70
+ if (input.state !== undefined && input.state !== null && !(typeof input.state === "string" && STATES.has(input.state))) {
71
+ invalidValue(context, "state", input.state);
72
+ }
73
+ const { start, count } = pageArgs(context, input);
74
+ let rows;
75
+ let target = null;
76
+ if (input.q === "organization") {
77
+ target = urnField(context, "organization", input.organization, ["organization"]);
78
+ if (context.state.get("organizations", target.id) === null) notFound(context, `Organization ${target.id} is inactive`);
79
+ if (!rolesOn(context, target.id, who.personId).has("ADMINISTRATOR")) {
80
+ fail(context, "ACCESS_DENIED", `Not enough permissions to access: GET /organizationAcls for ${target.urn}`);
81
+ }
82
+ rows = scanValues(context, "organization-acls", `${target.id}|`);
83
+ } else {
84
+ rows = scanValues(context, "organization-acls").filter((acl) => acl.personId === who.personId);
85
+ }
86
+ rows = rows.filter((acl) => (input.role == null || acl.role === input.role) && (input.state == null || acl.state === input.state));
87
+ const field = target === null ? "organization" : "organizationTarget";
88
+ const render = (acl) => ({ role: acl.role, [field]: organizationUrn(acl.organizationId), roleAssignee: personUrn(acl.personId), state: acl.state });
89
+ return buildPage(context, rows, render, {
90
+ start, count, path: "/rest/organizationAcls",
91
+ query: [["q", input.q], ["organization", target?.urn], ["role", input.role], ["state", input.state]],
92
+ });
93
+ }
@@ -0,0 +1,71 @@
1
+ // posts.update (partial update) and posts.delete (idempotent tombstone with cascade).
2
+ import { findPost, visibility } from "../lib/access.mjs";
3
+ import { caller, checkWire, managesAuthor, requireScope, urnField } from "../lib/auth.mjs";
4
+ import { deleteThread } from "../lib/cascade.mjs";
5
+ import { blank, fail, invalidValue, missing, notFound } from "../lib/errors.mjs";
6
+ import { commentaryArg } from "../lib/postinput.mjs";
7
+ import { renderPost } from "../lib/render.mjs";
8
+ import { nowMs } from "../lib/store.mjs";
9
+ import { activityUrn, parseUrn, personUrn, postUrnOf } from "../lib/urn.mjs";
10
+
11
+ /** Loads a post the caller may manage (author, or a poster of the authoring page) and checks the write scope. */
12
+ function managedPost(context, who, input, resource) {
13
+ const parsed = urnField(context, "postUrn", input.postUrn, ["share", "ugcPost"], { inPath: input.wire !== undefined });
14
+ const post = findPost(context, parsed);
15
+ if (post === null) notFound(context, `Post ${parsed.urn} not found`);
16
+ const organization = parseUrn(post.author)?.type === "organization";
17
+ requireScope(context, who, [organization ? "w_organization_social" : "w_member_social"], resource);
18
+ const seen = post.deletedAtMs === null ? visibility(context, who, post, "AUTHOR") : "ok";
19
+ if (seen === "missing") notFound(context, `Post ${parsed.urn} not found`);
20
+ if (seen === "hidden" || !managesAuthor(context, who, post.author)) {
21
+ fail(context, "ACCESS_DENIED", `Not enough permissions to access: ${resource} ${parsed.urn}`);
22
+ }
23
+ return { post, parsed };
24
+ }
25
+
26
+ export function updatePost(input, context) {
27
+ const who = caller(context);
28
+ checkWire(context, input, "PARTIAL_UPDATE");
29
+ const { post, parsed } = managedPost(context, who, input, "POST /posts");
30
+ if (typeof input.wire?.fieldError === "string") invalidValue(context, input.wire.fieldError, "an unsupported patch");
31
+ if (post.deletedAtMs !== null) notFound(context, `Post ${parsed.urn} not found`);
32
+ const fields = ["commentary", "lifecycleState", "contentCallToActionLabel", "contentLandingPage"].filter((f) => input[f] !== undefined);
33
+ if (fields.length === 0) missing(context, "patch/$set");
34
+ if (input.contentCallToActionLabel !== undefined) invalidValue(context, "contentCallToActionLabel", input.contentCallToActionLabel);
35
+ if (input.contentLandingPage !== undefined) invalidValue(context, "contentLandingPage", input.contentLandingPage);
36
+ const next = { ...post };
37
+ if (input.lifecycleState !== undefined) {
38
+ if (input.lifecycleState !== "PUBLISHED") invalidValue(context, "lifecycleState", input.lifecycleState);
39
+ next.lifecycleState = "PUBLISHED";
40
+ }
41
+ if (input.commentary !== undefined) {
42
+ const commentary = commentaryArg(context, input.commentary);
43
+ if (commentary === "" && post.article === null && post.reshareParent === null) blank(context, "commentary");
44
+ next.commentary = commentary;
45
+ if (commentary !== post.commentary && post.lifecycleState === "PUBLISHED") next.isEditedByAuthor = true;
46
+ }
47
+ const publishing = post.lifecycleState === "DRAFT" && next.lifecycleState === "PUBLISHED";
48
+ if (!publishing && next.commentary === post.commentary && next.isEditedByAuthor === post.isEditedByAuthor) return renderPost(post);
49
+ const now = nowMs(context);
50
+ next.lastModifiedAtMs = now;
51
+ if (publishing) next.publishedAtMs = now;
52
+ context.state.put("posts", post.id, next);
53
+ if (publishing) {
54
+ context.events.emit("post.published", {
55
+ postUrn: postUrnOf(next), activityUrn: activityUrn(next.activityId), author: next.author, visibility: next.visibility,
56
+ reshareParent: next.reshareParent, publishedAt: now, createdBy: personUrn(who.personId),
57
+ });
58
+ }
59
+ return renderPost(next);
60
+ }
61
+
62
+ export function deletePost(input, context) {
63
+ const who = caller(context);
64
+ checkWire(context, input, "DELETE");
65
+ const { post, parsed } = managedPost(context, who, input, "DELETE /posts");
66
+ if (post.deletedAtMs !== null) return { id: parsed.urn, deleted: true };
67
+ deleteThread(context, post.activityId);
68
+ const now = nowMs(context);
69
+ context.state.put("posts", post.id, { ...post, commentary: "", article: null, deletedAtMs: now, lastModifiedAtMs: now });
70
+ return { id: parsed.urn, deleted: true };
71
+ }
@@ -0,0 +1,54 @@
1
+ // posts.get and posts.list_by_author.
2
+ import { loadVisiblePost } from "../lib/access.mjs";
3
+ import { READER_ROLES, caller, checkWire, holdsAny, managesAuthor, requireScope, rolesOn, urnField } from "../lib/auth.mjs";
4
+ import { fail, invalidValue, notFound } from "../lib/errors.mjs";
5
+ import { buildPage, pageArgs } from "../lib/paging.mjs";
6
+ import { compareIds } from "../lib/postinput.mjs";
7
+ import { renderPost } from "../lib/render.mjs";
8
+ import { scanValues } from "../lib/store.mjs";
9
+
10
+ const VIEW_CONTEXTS = new Set(["READER", "AUTHOR"]);
11
+ const SORTS = new Set(["LAST_MODIFIED", "CREATED"]);
12
+
13
+ export function viewContextArg(context, value) {
14
+ if (value === undefined || value === null) return "READER";
15
+ if (typeof value !== "string" || !VIEW_CONTEXTS.has(value)) return invalidValue(context, "viewContext", value);
16
+ return value;
17
+ }
18
+
19
+ export function getPost(input, context) {
20
+ const who = caller(context);
21
+ checkWire(context, input, "GET");
22
+ const parsed = urnField(context, "postUrn", input.postUrn, ["share", "ugcPost"], { inPath: input.wire !== undefined });
23
+ const viewContext = viewContextArg(context, input.viewContext);
24
+ return renderPost(loadVisiblePost(context, who, parsed, "GET /posts", viewContext));
25
+ }
26
+
27
+ export function listByAuthor(input, context) {
28
+ const who = caller(context);
29
+ checkWire(context, input, "FINDER");
30
+ const author = urnField(context, "author", input.author, ["person", "organization"]);
31
+ const viewContext = viewContextArg(context, input.viewContext);
32
+ const sortBy = input.sortBy ?? "LAST_MODIFIED";
33
+ if (typeof sortBy !== "string" || !SORTS.has(sortBy)) invalidValue(context, "sortBy", sortBy);
34
+ const { start, count } = pageArgs(context, input);
35
+ if (author.type === "person") {
36
+ requireScope(context, who, ["r_member_social"], "GET /posts");
37
+ if (author.id !== who.personId) fail(context, "ACCESS_DENIED", `Not enough permissions to access: GET /posts for ${author.urn}`);
38
+ } else {
39
+ requireScope(context, who, ["r_organization_social"], "GET /posts");
40
+ if (context.state.get("organizations", author.id) === null) notFound(context, `Organization ${author.id} is inactive`);
41
+ if (!holdsAny(rolesOn(context, author.id, who.personId), READER_ROLES)) {
42
+ fail(context, "ACCESS_DENIED", `Not enough permissions to access: GET /posts for ${author.urn}`);
43
+ }
44
+ }
45
+ const withDrafts = viewContext === "AUTHOR" && managesAuthor(context, who, author.urn);
46
+ const key = sortBy === "CREATED" ? "createdAtMs" : "lastModifiedAtMs";
47
+ const posts = scanValues(context, "posts")
48
+ .filter((post) => post.author === author.urn && post.deletedAtMs === null && (post.lifecycleState === "PUBLISHED" || withDrafts))
49
+ .sort((a, b) => b[key] - a[key] || compareIds(b.id, a.id));
50
+ return buildPage(context, posts, renderPost, {
51
+ start, count, path: "/rest/posts",
52
+ query: [["q", "author"], ["author", author.urn], ["viewContext", input.viewContext], ["sortBy", input.sortBy]],
53
+ });
54
+ }
@@ -0,0 +1,74 @@
1
+ // posts.create, posts.update and posts.delete.
2
+ import { findPost, visibility } from "../lib/access.mjs";
3
+ import { actingAs, caller, checkWire, urnField } from "../lib/auth.mjs";
4
+ import { blank, invalidValue, missing, notFound, unprocessable } from "../lib/errors.mjs";
5
+ import { FEED_DISTRIBUTIONS, VISIBILITIES, commentaryArg, contentArg } from "../lib/postinput.mjs";
6
+ import { renderPost } from "../lib/render.mjs";
7
+ import { nextId, nowMs } from "../lib/store.mjs";
8
+ import { activityUrn, personUrn, postUrnOf } from "../lib/urn.mjs";
9
+
10
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
11
+
12
+ function emitPublished(context, post, who) {
13
+ context.events.emit("post.published", {
14
+ postUrn: postUrnOf(post), activityUrn: activityUrn(post.activityId), author: post.author, visibility: post.visibility,
15
+ reshareParent: post.reshareParent, publishedAt: post.publishedAtMs, createdBy: personUrn(who.personId),
16
+ });
17
+ }
18
+
19
+ function distributionArg(context, distribution) {
20
+ if (distribution === undefined || distribution === null) return missing(context, "distribution");
21
+ if (!isObject(distribution)) return invalidValue(context, "distribution", distribution);
22
+ const feed = distribution.feedDistribution;
23
+ if (feed === undefined || feed === null) return missing(context, "distribution/feedDistribution");
24
+ if (typeof feed !== "string" || !FEED_DISTRIBUTIONS.has(feed)) return invalidValue(context, "distribution/feedDistribution", feed);
25
+ for (const field of ["targetEntities", "thirdPartyDistributionChannels"]) {
26
+ const value = distribution[field];
27
+ if (value !== undefined && value !== null && (!Array.isArray(value) || value.length > 0)) invalidValue(context, `distribution/${field}`, value);
28
+ }
29
+ return feed;
30
+ }
31
+
32
+ function reshareArg(context, who, reshareContext) {
33
+ if (reshareContext === undefined || reshareContext === null) return null;
34
+ if (!isObject(reshareContext)) return invalidValue(context, "reshareContext", reshareContext);
35
+ const parsed = urnField(context, "reshareContext/parent", reshareContext.parent, ["share", "ugcPost"]);
36
+ const parent = findPost(context, parsed);
37
+ if (parent === null || parent.deletedAtMs !== null) notFound(context, `Post ${parsed.urn} not found`);
38
+ if (parent.lifecycleState === "DRAFT") unprocessable(context, "Cannot reshare a draft post");
39
+ if (visibility(context, who, parent) !== "ok") notFound(context, `Post ${parsed.urn} not found`);
40
+ if (parent.isReshareDisabledByAuthor) unprocessable(context, "Reshare is disabled by the author");
41
+ return { parent: parsed.urn, root: parent.reshareRoot ?? parsed.urn };
42
+ }
43
+
44
+ export function createPost(input, context) {
45
+ const who = caller(context);
46
+ checkWire(context, input, "CREATE");
47
+ if (input.author === undefined || input.author === null) missing(context, "author");
48
+ const actor = actingAs(context, who, "author", input.author, "POST /posts");
49
+ if (input.visibility === undefined || input.visibility === null) missing(context, "visibility");
50
+ if (typeof input.visibility !== "string" || !VISIBILITIES.has(input.visibility)) invalidValue(context, "visibility", input.visibility);
51
+ if (actor.kind === "organization" && input.visibility === "CONNECTIONS") invalidValue(context, "visibility", input.visibility);
52
+ const feedDistribution = distributionArg(context, input.distribution);
53
+ if (input.lifecycleState === undefined || input.lifecycleState === null) missing(context, "lifecycleState");
54
+ if (input.lifecycleState !== "PUBLISHED") invalidValue(context, "lifecycleState", input.lifecycleState);
55
+ const article = contentArg(context, input.content);
56
+ const reshare = reshareArg(context, who, input.reshareContext);
57
+ const commentary = commentaryArg(context, input.commentary);
58
+ if (commentary === "" && article === null && reshare === null) blank(context, "commentary");
59
+ const reshareDisabled = input.isReshareDisabledByAuthor ?? false;
60
+ if (typeof reshareDisabled !== "boolean") invalidValue(context, "isReshareDisabledByAuthor", reshareDisabled);
61
+ const now = nowMs(context);
62
+ const id = nextId(context);
63
+ const activityId = nextId(context);
64
+ const post = {
65
+ id, kind: input.kind === "ugcPost" ? "ugcPost" : "share", activityId, author: actor.urn, commentary, visibility: input.visibility,
66
+ feedDistribution, lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: reshareDisabled, isEditedByAuthor: false, article,
67
+ reshareParent: reshare?.parent ?? null, reshareRoot: reshare?.root ?? null, commentsState: "OPEN", createdBy: who.personId,
68
+ createdAtMs: now, lastModifiedAtMs: now, publishedAtMs: now, deletedAtMs: null,
69
+ };
70
+ context.state.put("posts", id, post);
71
+ context.state.put("activities", activityId, { postId: id });
72
+ emitPublished(context, post, who);
73
+ return renderPost(post);
74
+ }
@@ -0,0 +1,38 @@
1
+ // profile.userinfo, profile.me and people.get.
2
+ import { caller, checkWire, requireScope } from "../lib/auth.mjs";
3
+ import { badRequest, fail, notFound } from "../lib/errors.mjs";
4
+ import { fullName, liteProfile } from "../lib/render.mjs";
5
+
6
+ const PERSON_ID = /^[A-Za-z0-9_-]{10}$/;
7
+
8
+ export function userinfo(input, context) {
9
+ const who = caller(context);
10
+ if (!who.scopes.has("openid") || !who.scopes.has("profile")) fail(context, "ACCESS_DENIED", "Not enough permissions to access: GET /userinfo");
11
+ const person = who.person;
12
+ return {
13
+ sub: person.id,
14
+ name: fullName(person),
15
+ given_name: person.firstName,
16
+ family_name: person.lastName,
17
+ ...(person.pictureUrl === null ? {} : { picture: person.pictureUrl }),
18
+ locale: `${person.locale.language}-${person.locale.country}`,
19
+ ...(who.scopes.has("email") && person.email !== null ? { email: person.email, email_verified: person.emailVerified } : {}),
20
+ };
21
+ }
22
+
23
+ export function me(input, context) {
24
+ const who = caller(context);
25
+ requireScope(context, who, ["profile"], "GET /me");
26
+ return liteProfile(who.person);
27
+ }
28
+
29
+ export function getPerson(input, context) {
30
+ const who = caller(context);
31
+ checkWire(context, input, "GET", false);
32
+ requireScope(context, who, ["profile"], "GET /people");
33
+ const personId = input.personId;
34
+ if (typeof personId !== "string" || !PERSON_ID.test(personId)) badRequest(context, "Syntax exception in path variables");
35
+ const person = context.state.get("people", personId);
36
+ if (person === null) notFound(context, `Member ${personId} not found`);
37
+ return liteProfile(person);
38
+ }