@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,142 @@
1
+ // DOM, Tool-call, time and toast helpers for the LinkedIn Tool app. Record text only ever reaches the DOM via textContent.
2
+ import { invoke } from "/_firedrill/client.js";
3
+ import { icon } from "./icons.js";
4
+
5
+ export const $ = (selector, root = document) => root.querySelector(selector);
6
+ export const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
7
+
8
+ /** Create an element. options: { class, text, title, attrs, on: {event: handler} }. */
9
+ export function el(tag, options = {}, children = []) {
10
+ const node = document.createElement(tag);
11
+ if (options.class) node.className = options.class;
12
+ if (options.text !== undefined && options.text !== null) node.textContent = String(options.text);
13
+ if (options.title) node.title = options.title;
14
+ if (options.attrs) for (const [name, value] of Object.entries(options.attrs)) if (value !== undefined && value !== null && value !== false) node.setAttribute(name, value === true ? "" : String(value));
15
+ if (options.on) for (const [name, handler] of Object.entries(options.on)) node.addEventListener(name, handler);
16
+ for (const child of Array.isArray(children) ? children : [children]) if (child !== undefined && child !== null && child !== false) node.append(child);
17
+ return node;
18
+ }
19
+
20
+ export function iconButton(name, label, onClick, className = "") {
21
+ return el("button", { class: `icon-btn ${className}`.trim(), title: label, attrs: { type: "button", "aria-label": label }, on: onClick ? { click: onClick } : undefined }, icon(name));
22
+ }
23
+
24
+ // ---- Tool calls ------------------------------------------------------------------------------
25
+
26
+ export class ToolError extends Error {
27
+ constructor(status, code, message, retryable) {
28
+ super(message);
29
+ this.status = status;
30
+ this.code = String(code ?? status).replace(/^tool\./, "");
31
+ this.retryable = Boolean(retryable);
32
+ }
33
+ get denied() { return this.status === "denied" || this.code === "ACCESS_DENIED"; }
34
+ }
35
+
36
+ let writeListener = () => {};
37
+ export const onWrite = (listener) => { writeListener = listener; };
38
+
39
+ /** Invoke one operation; resolves the value or throws ToolError. Mutations pass an idempotency key. */
40
+ export async function call(operationId, args = {}, idempotencyKey) {
41
+ let result;
42
+ try {
43
+ result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
44
+ } catch (error) {
45
+ throw new ToolError("transport", "NETWORK", "Couldn't reach the service. Check that the environment is still running.");
46
+ }
47
+ const outcome = result?.outcome ?? {};
48
+ if (idempotencyKey) writeListener();
49
+ if (outcome.status !== "ok") {
50
+ const error = outcome.error ?? {};
51
+ throw new ToolError(outcome.status, error.code, error.message ?? `The request was ${outcome.status}.`, error.retryable);
52
+ }
53
+ return outcome.value;
54
+ }
55
+
56
+ export const newKey = () => crypto.randomUUID();
57
+
58
+ /** LinkedIn-style wording for an operation failure. */
59
+ export function describe(error) {
60
+ if (!(error instanceof ToolError)) return "Something went wrong. Please try again.";
61
+ if (error.status === "denied") return "You don't have access to do that with this account.";
62
+ switch (error.code) {
63
+ case "INVALID_ACCESS_TOKEN": return "Your session has expired. Sign in again to continue.";
64
+ case "ACCESS_DENIED": return "You don't have permission to do that.";
65
+ case "TOO_MANY_REQUESTS": return "You're commenting too fast. Please wait a moment and try again.";
66
+ case "SERVICE_UNAVAILABLE": return "Reactions are temporarily unavailable. Please try again later.";
67
+ case "NOT_FOUND": return "This content isn't available.";
68
+ case "INTERNAL_SERVER_ERROR": return "Something went wrong on our end. Please try again.";
69
+ default: return error.message || "Something went wrong. Please try again.";
70
+ }
71
+ }
72
+
73
+ // ---- Time (world virtual time only, UTC) -----------------------------------------------------
74
+
75
+ let serverNowMs = 0;
76
+ export const setServerNow = (ms) => { if (Number.isFinite(ms)) serverNowMs = ms; };
77
+ export const serverNow = () => serverNowMs;
78
+
79
+ /** "now", "5m", "2h", "3d", "1w", "4mo", "1yr" as the feed renders them. */
80
+ export function relativeTime(ms) {
81
+ const diff = Math.max(0, serverNowMs - ms);
82
+ const min = Math.floor(diff / 60000);
83
+ if (min < 1) return "now";
84
+ if (min < 60) return `${min}m`;
85
+ const h = Math.floor(min / 60);
86
+ if (h < 24) return `${h}h`;
87
+ const d = Math.floor(h / 24);
88
+ if (d < 7) return `${d}d`;
89
+ if (d < 30) return `${Math.floor(d / 7)}w`;
90
+ if (d < 365) return `${Math.floor(d / 30)}mo`;
91
+ return `${Math.floor(d / 365)}yr`;
92
+ }
93
+
94
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
95
+ export function longDate(ms) {
96
+ const date = new Date(ms);
97
+ return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`;
98
+ }
99
+
100
+ // ---- Avatars ---------------------------------------------------------------------------------
101
+
102
+ const AVATAR_COLOURS = ["#0a66c2", "#057642", "#915907", "#8f5849", "#5f4b8b", "#b24020", "#0e7470", "#56687a"];
103
+ export function avatar(name, seed, size = 48, organization = false) {
104
+ const text = String(name ?? "?").trim();
105
+ const initials = organization
106
+ ? text.slice(0, 1).toUpperCase()
107
+ : text.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => [...part][0]?.toUpperCase() ?? "").join("");
108
+ let hash = 0;
109
+ for (const char of String(seed ?? text)) hash = (hash * 31 + char.codePointAt(0)) >>> 0;
110
+ const node = el("span", { class: `avatar avatar--${size}${organization ? " avatar--org" : ""}`, text: initials || "?", attrs: { "aria-hidden": "true" } });
111
+ node.style.setProperty("--avatar-bg", AVATAR_COLOURS[hash % AVATAR_COLOURS.length]);
112
+ return node;
113
+ }
114
+
115
+ // ---- Toast -----------------------------------------------------------------------------------
116
+
117
+ let toastTimer;
118
+ export function toast(text, { error = false, actionLabel, onAction, timeout = 6000 } = {}) {
119
+ const host = $("#toast");
120
+ $("#toast-text").textContent = text;
121
+ const iconHost = $("#toast-icon");
122
+ iconHost.replaceChildren(icon(error ? "alert" : "check"));
123
+ host.dataset.error = String(error);
124
+ const button = $("#toast-action");
125
+ button.hidden = !actionLabel;
126
+ button.textContent = actionLabel ?? "";
127
+ button.onclick = () => { host.hidden = true; if (onAction) onAction(); };
128
+ host.hidden = false;
129
+ clearTimeout(toastTimer);
130
+ toastTimer = setTimeout(() => { host.hidden = true; }, timeout);
131
+ }
132
+ export function initToast() {
133
+ $("#toast-close").addEventListener("click", () => { $("#toast").hidden = true; });
134
+ }
135
+
136
+ /** Button spinner/disabled state around an async task; ignores re-entry. */
137
+ export async function busy(button, task) {
138
+ if (button?.dataset.busy === "true") return undefined;
139
+ if (button) { button.dataset.busy = "true"; button.disabled = true; }
140
+ try { return await task(); }
141
+ finally { if (button) { button.dataset.busy = "false"; button.disabled = false; } }
142
+ }
@@ -0,0 +1,66 @@
1
+ // Synthetic LinkedIn member network for Firedrill: the versioned Community Management REST API subset under /rest plus
2
+ // the /v2 consumer endpoints. Every operation computes from `context.state`; nothing is sent to LinkedIn.
3
+ import * as R from "./lib/routes.mjs";
4
+ import { encoder } from "./lib/wire.mjs";
5
+ import { createComment, deleteComment, updateComment } from "./ops/comments-write.mjs";
6
+ import { getComment, listComments } from "./ops/comments-read.mjs";
7
+ import { listFeed } from "./ops/feed.mjs";
8
+ import { getNetworkSize, listConnections } from "./ops/network.mjs";
9
+ import { findByVanityName, getOrganization, listAcls } from "./ops/orgs.mjs";
10
+ import { deletePost, updatePost } from "./ops/posts-edit.mjs";
11
+ import { getPost, listByAuthor } from "./ops/posts-read.mjs";
12
+ import { createPost } from "./ops/posts-write.mjs";
13
+ import { getPerson, me, userinfo } from "./ops/profile.mjs";
14
+ import { createReaction, deleteReaction, listReactions } from "./ops/reactions.mjs";
15
+ import { getSocialMetadata, setCommentsState } from "./ops/social.mjs";
16
+
17
+ const body = (value) => ({ body: value });
18
+ const empty = () => ({});
19
+ const json = encoder(body);
20
+ const v2json = encoder(body, true);
21
+ // A compound-key GET answers the bare reaction; the entity finder answers the collection.
22
+ const reactionsEncoder = (result) =>
23
+ encoder((page) => ({ body: result.invocation?.arguments?.single === true ? page.elements[0] : page }))(result);
24
+
25
+ // [operation id, handler, [route id, decoder, encoder]...]
26
+ const TABLE = [
27
+ ["profile.userinfo", userinfo, ["userinfo", R.plain, v2json]],
28
+ ["profile.me", me, ["me", R.plain, v2json]],
29
+ ["people.get", getPerson, ["get-person", R.getPerson, v2json]],
30
+ ["posts.create", createPost,
31
+ ["create-post", R.createPost, encoder((post) => ({ headers: { "x-restli-id": post.id, "x-linkedin-id": post.id } }))],
32
+ ["create-ugc-post", R.createUgcPost, encoder((post) => ({ headers: { "x-restli-id": post.id }, body: { id: post.id } }), true)]],
33
+ ["posts.get", getPost, ["get-post", R.getPost, json]],
34
+ ["posts.list_by_author", listByAuthor, ["find-posts", R.findPosts, json]],
35
+ ["posts.update", updatePost, ["update-post", R.updatePost, encoder(empty)]],
36
+ ["posts.delete", deletePost, ["delete-post", R.deletePost("postUrn", true), encoder(empty)],
37
+ ["delete-ugc-post", R.deletePost("ugcPostUrn", false), encoder(empty, true)]],
38
+ ["feed.list", listFeed],
39
+ ["comments.create", createComment, ["create-comment", R.createComment, encoder((c) => ({ headers: { "x-restli-id": c.id }, body: c }))]],
40
+ ["comments.list", listComments, ["list-comments", R.listComments, json]],
41
+ ["comments.get", getComment, ["get-comment", R.getComment, json]],
42
+ ["comments.update", updateComment,
43
+ ["update-comment", R.updateComment, encoder((c) => ({ headers: { "x-resourceidentity-urn": c.commentUrn }, body: c }))]],
44
+ ["comments.delete", deleteComment, ["delete-comment", R.deleteComment, encoder(empty)]],
45
+ ["reactions.create", createReaction, ["create-reaction", R.createReaction, json]],
46
+ ["reactions.list", listReactions,
47
+ ["get-reactions", R.getReactions, reactionsEncoder]],
48
+ ["reactions.delete", deleteReaction, ["delete-reaction", R.deleteReaction, encoder(empty)]],
49
+ ["social_metadata.get", getSocialMetadata, ["get-social-metadata", R.getSocialMetadata, json]],
50
+ ["social_metadata.set_comments_state", setCommentsState, ["update-social-metadata", R.updateSocialMetadata, encoder(empty)]],
51
+ ["organizations.get", getOrganization, ["get-organization", R.getOrganization, json]],
52
+ ["organizations.find_by_vanity_name", findByVanityName, ["find-organizations", R.findOrganizations, json]],
53
+ ["organization_acls.list", listAcls, ["find-organization-acls", R.findAcls, json]],
54
+ ["network_sizes.get", getNetworkSize, ["get-network-size", R.getNetworkSize, json],
55
+ ["get-connection-size", R.getConnectionSize, encoder(body, true)]],
56
+ ["connections.list", listConnections],
57
+ ];
58
+
59
+ const operations = {};
60
+ const http = {};
61
+ for (const [operationId, handler, ...routes] of TABLE) {
62
+ operations[operationId] = handler;
63
+ for (const [routeId, decode, encode] of routes) http[routeId] = { decode, encode };
64
+ }
65
+
66
+ export default { operations, http };
@@ -0,0 +1,69 @@
1
+ // Post and thread resolution with LinkedIn visibility rules (drafts, CONNECTIONS posts, tombstones).
2
+ import { connected, managesAuthor, requireScope, urnField } from "./auth.mjs";
3
+ import { fail, notFound } from "./errors.mjs";
4
+ import { parseUrn } from "./urn.mjs";
5
+
6
+ const COMMENT_ID = /^[0-9]{1,19}$/;
7
+
8
+ /** Post row for a share, ugcPost or activity URN (already parsed), or null. */
9
+ export function findPost(context, parsed) {
10
+ if (parsed.type === "activity") {
11
+ const activity = context.state.get("activities", parsed.id);
12
+ return activity === null ? null : context.state.get("posts", activity.postId);
13
+ }
14
+ const post = context.state.get("posts", parsed.id);
15
+ return post !== null && post.kind === parsed.type ? post : null;
16
+ }
17
+
18
+ /** Read scope for a thread: member-authored threads need r_member_social, organization threads r_organization_social. */
19
+ export function requireReadScope(context, who, authorUrn, resource) {
20
+ const organization = parseUrn(authorUrn)?.type === "organization";
21
+ requireScope(context, who, [organization ? "r_organization_social" : "r_member_social"], resource);
22
+ }
23
+
24
+ /** "ok", "missing" (deleted, or a draft outside the author view) or "hidden" (a CONNECTIONS post of a non-connection). */
25
+ export function visibility(context, who, post, viewContext = "READER") {
26
+ if (post.deletedAtMs !== null) return "missing";
27
+ if (post.lifecycleState === "DRAFT" && (viewContext !== "AUTHOR" || !managesAuthor(context, who, post.author))) return "missing";
28
+ if (post.visibility === "CONNECTIONS" && post.author !== who.urn) {
29
+ const author = parseUrn(post.author);
30
+ if (author === null || author.type !== "person" || !connected(context, who.personId, author.id)) return "hidden";
31
+ }
32
+ return "ok";
33
+ }
34
+
35
+ /** Loads a post the caller may read, failing NOT_FOUND / ACCESS_DENIED as LinkedIn does. */
36
+ export function loadVisiblePost(context, who, parsed, resource, viewContext = "READER", write = false) {
37
+ const post = findPost(context, parsed);
38
+ if (post === null) notFound(context, `Post ${parsed.urn} not found`);
39
+ if (!write) requireReadScope(context, who, post.author, resource);
40
+ const seen = visibility(context, who, post, viewContext);
41
+ if (seen === "missing") notFound(context, `Post ${parsed.urn} not found`);
42
+ if (seen === "hidden") fail(context, "ACCESS_DENIED", `Not enough permissions to access: ${parsed.urn}`);
43
+ return post;
44
+ }
45
+
46
+ /**
47
+ * Resolves a social-action target (share, ugcPost, activity or comment URN) to `{ post, comment }`; `comment` is the
48
+ * stored comment row when the target is a comment URN. Drafts resolve for their author so writes can refuse them.
49
+ */
50
+ export function loadThread(context, who, field, value, resource, { inPath = false, types, write = false } = {}) {
51
+ const parsed = urnField(context, field, value, types ?? ["share", "ugcPost", "activity", "comment"], { inPath });
52
+ if (parsed.type === "comment") {
53
+ const activity = context.state.get("activities", parsed.activityId);
54
+ if (activity === null) notFound(context, `Comment ${parsed.urn} not found`);
55
+ const post = loadVisiblePost(context, who, { type: "activity", id: parsed.activityId, urn: `urn:li:activity:${parsed.activityId}` },
56
+ resource, "AUTHOR", write);
57
+ const comment = context.state.get("comments", `${parsed.activityId}|${parsed.commentId}`);
58
+ if (comment === null) notFound(context, `Comment ${parsed.urn} not found`);
59
+ return { post, comment, parsed };
60
+ }
61
+ return { post: loadVisiblePost(context, who, parsed, resource, "AUTHOR", write), comment: null, parsed };
62
+ }
63
+
64
+ /** Validates a comment id path key (digits only). */
65
+ export function commentIdArg(context, value, inPath) {
66
+ if (typeof value === "string" && COMMENT_ID.test(value)) return value;
67
+ if (inPath) return fail(context, "BAD_REQUEST", "Syntax exception in path variables");
68
+ return fail(context, "INVALID_URN_ID", "The URN ID provided is invalid");
69
+ }
@@ -0,0 +1,115 @@
1
+ // Caller identity (token), OAuth scopes, Rest.li wire checks and organization roles.
2
+ import { badRequest, fail, missing, shortValue } from "./errors.mjs";
3
+ import { organizationUrn, parseUrn, personUrn, urnType } from "./urn.mjs";
4
+
5
+ export const DEFAULT_SCOPES = ["openid", "profile", "email", "w_member_social", "r_member_social", "rw_organization_admin",
6
+ "r_organization_social", "w_organization_social", "r_1st_connections_size"];
7
+ const ACTIVE_VERSIONS = new Set(["202509", "202510", "202511", "202512", "202601", "202602", "202603", "202604", "202605", "202606",
8
+ "202607", "202608"]);
9
+ export const POSTER_ROLES = new Set(["ADMINISTRATOR", "CONTENT_ADMINISTRATOR", "DIRECT_SPONSORED_CONTENT_POSTER"]);
10
+ export const READER_ROLES = new Set([...POSTER_ROLES, "ANALYST", "CURATOR"]);
11
+ const PERSON_ID = /^[A-Za-z0-9_-]{10}$/;
12
+
13
+ /** Resolves the member the token belongs to: `linkedinPersonId`, else the network's default member. */
14
+ export function caller(context) {
15
+ const attributes = context.actor.attributes ?? {};
16
+ let personId = Object.hasOwn(attributes, "linkedinPersonId") ? attributes.linkedinPersonId : undefined;
17
+ if (personId === undefined || personId === null) personId = context.state.get("meta", "network")?.defaultPersonId;
18
+ const person = typeof personId === "string" && PERSON_ID.test(personId) ? context.state.get("people", personId) : null;
19
+ if (person === null) fail(context, "INVALID_ACCESS_TOKEN", "Invalid access token");
20
+ const raw = Object.hasOwn(attributes, "linkedinScopes") ? attributes.linkedinScopes : undefined;
21
+ const scopes = new Set(Array.isArray(raw) ? raw.filter((scope) => typeof scope === "string") : DEFAULT_SCOPES);
22
+ return { personId, person, urn: personUrn(personId), scopes };
23
+ }
24
+
25
+ /**
26
+ * Header and syntax checks for requests that arrived through an HTTP route (`input.wire`). Canonical, MCP and app callers
27
+ * send no `wire` and skip them. `expected` is the Rest.li method the route implements.
28
+ */
29
+ export function checkWire(context, input, expected, restAllowed = true) {
30
+ const wire = input.wire;
31
+ if (wire === undefined) return;
32
+ if (wire.rest === true && restAllowed) {
33
+ const version = wire.linkedinVersion;
34
+ if (typeof version !== "string" || version.trim() === "") {
35
+ fail(context, "VERSION_MISSING", "A version must be present. Please specify a version by adding the Linkedin-Version header.");
36
+ }
37
+ if (!ACTIVE_VERSIONS.has(version.trim())) fail(context, "NONEXISTENT_VERSION", `Requested version ${shortValue(version)} is not active`);
38
+ const method = wire.restliMethod;
39
+ if (method === null || method === undefined) {
40
+ if (wire.requireMethod === true) badRequest(context, "Invalid X-RestLi-Method header");
41
+ } else if (method.trim().toUpperCase() !== expected) badRequest(context, "Invalid X-RestLi-Method header");
42
+ }
43
+ if (typeof wire.error === "string") badRequest(context, wire.error);
44
+ }
45
+
46
+ /** Fails ACCESS_DENIED unless the token holds at least one of the scopes. */
47
+ export function requireScope(context, who, anyOf, resource) {
48
+ if (!anyOf.some((scope) => who.scopes.has(scope))) fail(context, "ACCESS_DENIED", `Not enough permissions to access: ${resource}`);
49
+ }
50
+
51
+ const ALL_ROLES = ["ADMINISTRATOR", "DIRECT_SPONSORED_CONTENT_POSTER", "RECRUITING_POSTER", "LEAD_CAPTURE_ADMINISTRATOR",
52
+ "LEAD_GEN_FORMS_MANAGER", "ANALYST", "CURATOR", "CONTENT_ADMINISTRATOR"];
53
+
54
+ /** Approved roles of a member on an organization (one direct read per role; no scan). */
55
+ export function rolesOn(context, organizationId, personId) {
56
+ const roles = new Set();
57
+ for (const role of ALL_ROLES) {
58
+ if (context.state.get("organization-acls", `${organizationId}|${personId}|${role}`)?.state === "APPROVED") roles.add(role);
59
+ }
60
+ return roles;
61
+ }
62
+
63
+ export const holdsAny = (roles, wanted) => [...roles].some((role) => wanted.has(role));
64
+
65
+ /**
66
+ * Validates a URN-valued field. `types` lists accepted URN types; `inPath` makes syntax problems Rest.li path errors.
67
+ * Returns the parsed URN, or null when the value is absent and `required` is false.
68
+ */
69
+ export function urnField(context, field, value, types, { required = true, inPath = false } = {}) {
70
+ if (value === undefined || value === null) {
71
+ if (!required) return null;
72
+ return missing(context, field);
73
+ }
74
+ const parsed = parseUrn(value);
75
+ if (inPath && (parsed === null || !types.includes(parsed.type))) return badRequest(context, "Syntax exception in path variables");
76
+ if (parsed === null) {
77
+ const type = urnType(value);
78
+ if (type !== null && !types.includes(type)) {
79
+ return fail(context, "INVALID_URN_TYPE", `${field} value ${shortValue(value)} must be a ${types.join(" or ")} URN`);
80
+ }
81
+ return fail(context, "INVALID_URN_ID", "The URN ID provided is invalid");
82
+ }
83
+ if (!types.includes(parsed.type)) {
84
+ return fail(context, "INVALID_URN_TYPE", `${field} value ${shortValue(value)} must be a ${types.join(" or ")} URN`);
85
+ }
86
+ return parsed;
87
+ }
88
+
89
+ /**
90
+ * Checks that the caller may act as `actorValue` (its own person URN, or an organization it holds a poster role on) and
91
+ * that the token carries the matching write scope. Returns `{ urn, kind, id, agent }`.
92
+ */
93
+ export function actingAs(context, who, field, actorValue, resource) {
94
+ const parsed = urnField(context, field, actorValue, ["person", "organization"]);
95
+ if (parsed.type === "person") {
96
+ requireScope(context, who, ["w_member_social"], resource);
97
+ if (parsed.id !== who.personId) fail(context, "ACCESS_DENIED", `Not enough permissions to act as ${parsed.urn}`);
98
+ return { urn: parsed.urn, kind: "person", id: parsed.id, agent: null };
99
+ }
100
+ requireScope(context, who, ["w_organization_social"], resource);
101
+ const organization = context.state.get("organizations", parsed.id);
102
+ if (organization === null || !holdsAny(rolesOn(context, parsed.id, who.personId), POSTER_ROLES)) {
103
+ fail(context, "ACCESS_DENIED", `Not enough permissions to act as ${parsed.urn}`);
104
+ }
105
+ return { urn: organizationUrn(parsed.id), kind: "organization", id: parsed.id, agent: who.personId };
106
+ }
107
+
108
+ /** Whether the caller may manage content authored by `authorUrn` (the author itself, or a poster of that page). */
109
+ export function managesAuthor(context, who, authorUrn) {
110
+ if (authorUrn === who.urn) return true;
111
+ const parsed = parseUrn(authorUrn);
112
+ return parsed !== null && parsed.type === "organization" && holdsAny(rolesOn(context, parsed.id, who.personId), POSTER_ROLES);
113
+ }
114
+
115
+ export const connected = (context, a, b) => context.state.get("connections", `${a}|${b}`) !== null;
@@ -0,0 +1,28 @@
1
+ // Cascading deletes: comments of a thread and the reactions on posts and comments.
2
+ import { scanPrefix, scanValues } from "./store.mjs";
3
+ import { activityUrn, commentUrn } from "./urn.mjs";
4
+
5
+ export const reactionsOn = (context, entityUrn) => scanValues(context, "reactions", `${entityUrn}|`);
6
+ export const threadComments = (context, activityId) => scanValues(context, "comments", `${activityId}|`);
7
+
8
+ export function deleteReactionsOn(context, entityUrn) {
9
+ const rows = scanPrefix(context, "reactions", `${entityUrn}|`);
10
+ for (const row of rows) context.state.delete("reactions", row.rowId);
11
+ return rows.length;
12
+ }
13
+
14
+ /** Deletes comment rows and the reactions on each; returns the number of comments deleted. */
15
+ export function deleteComments(context, comments) {
16
+ for (const comment of comments) {
17
+ deleteReactionsOn(context, commentUrn(comment.activityId, comment.id));
18
+ context.state.delete("comments", `${comment.activityId}|${comment.id}`);
19
+ }
20
+ return comments.length;
21
+ }
22
+
23
+ /** Deletes every comment of a thread, their reactions and the reactions on the post itself. */
24
+ export function deleteThread(context, activityId) {
25
+ const deleted = deleteComments(context, threadComments(context, activityId));
26
+ deleteReactionsOn(context, activityUrn(activityId));
27
+ return deleted;
28
+ }
@@ -0,0 +1,50 @@
1
+ // Declared Tool error codes, their HTTP status and LinkedIn `serviceErrorCode`, plus failure helpers.
2
+
3
+ export const ERRORS = new Map([
4
+ ["INVALID_ACCESS_TOKEN", [401, 65600]],
5
+ ["VERSION_MISSING", [400, 0]],
6
+ ["NONEXISTENT_VERSION", [426, 0]],
7
+ ["BAD_REQUEST", [400, 0]],
8
+ ["ACCESS_DENIED", [403, 100]],
9
+ ["NOT_FOUND", [404, 0]],
10
+ ["MISSING_FIELD", [400, 0]],
11
+ ["INVALID_VALUE_FOR_FIELD", [400, 0]],
12
+ ["INVALID_VALUE_BLANK_FIELD", [400, 0]],
13
+ ["FIELD_LENGTH_TOO_LONG", [400, 0]],
14
+ ["INVALID_URN_TYPE", [400, 0]],
15
+ ["INVALID_URN_ID", [400, 0]],
16
+ ["UNPROCESSABLE_ENTITY", [422, 0]],
17
+ ["TOO_MANY_REQUESTS", [429, 0]],
18
+ ["INTERNAL_SERVER_ERROR", [500, 0]],
19
+ ["SERVICE_UNAVAILABLE", [503, 0]],
20
+ ]);
21
+
22
+ /** Fails the current operation with a declared code. Messages never name objects created in the same call. */
23
+ export function fail(context, code, message) {
24
+ context.fail({ code, message });
25
+ }
26
+
27
+ export const missing = (context, field) => fail(context, "MISSING_FIELD", `${field} is required but missing`);
28
+ export const invalidValue = (context, field, value) =>
29
+ fail(context, "INVALID_VALUE_FOR_FIELD", `${field} can't be set to ${shortValue(value)}`);
30
+ export const blank = (context, field) => fail(context, "INVALID_VALUE_BLANK_FIELD", `${field} can't be blank`);
31
+ export const tooLong = (context, field, length, max) =>
32
+ fail(context, "FIELD_LENGTH_TOO_LONG", `${field} length ${length} can't exceeded maximum ${max} length`);
33
+ export const notFound = (context, message = "Not Found") => fail(context, "NOT_FOUND", message);
34
+ export const unprocessable = (context, message) => fail(context, "UNPROCESSABLE_ENTITY", message);
35
+ export const badRequest = (context, message) => fail(context, "BAD_REQUEST", message);
36
+
37
+ /** Renders a caller value inside an error message without echoing unbounded text. */
38
+ export function shortValue(value) {
39
+ let text;
40
+ if (typeof value === "string") text = value;
41
+ else if (value === undefined) text = "undefined";
42
+ else {
43
+ try {
44
+ text = JSON.stringify(value) ?? String(value);
45
+ } catch {
46
+ text = "an unsupported value";
47
+ }
48
+ }
49
+ return text.length > 80 ? `${text.slice(0, 77)}...` : text;
50
+ }
@@ -0,0 +1,43 @@
1
+ // Comment message validation: text (≤ 1250 characters) and mention attributes.
2
+ import { blank, invalidValue, missing, tooLong } from "./errors.mjs";
3
+ import { parseUrn } from "./urn.mjs";
4
+
5
+ export const MAX_COMMENT = 1250;
6
+ const MAX_ATTRIBUTES = 50;
7
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
8
+
9
+ /** Returns `{ text, attributes }` in stored form (`{ start, length, person, organization }`). */
10
+ export function messageArg(context, message, field = "message") {
11
+ if (message === undefined || message === null) return missing(context, field);
12
+ if (!isObject(message)) return invalidValue(context, field, message);
13
+ const text = message.text;
14
+ if (text === undefined || text === null || (typeof text === "string" && text.trim() === "")) return blank(context, `${field}/text`);
15
+ if (typeof text !== "string") return invalidValue(context, `${field}/text`, text);
16
+ if (text.length > MAX_COMMENT) return tooLong(context, `${field}/text`, text.length, MAX_COMMENT);
17
+ const raw = message.attributes ?? [];
18
+ if (!Array.isArray(raw) || raw.length > MAX_ATTRIBUTES) return invalidValue(context, `${field}/attributes`, "more than 50 attributes");
19
+ const attributes = [];
20
+ for (let i = 0; i < raw.length; i += 1) {
21
+ const where = `${field}/attributes/${i}`;
22
+ const attribute = raw[i];
23
+ if (!isObject(attribute)) return invalidValue(context, where, attribute);
24
+ const { start, length, value } = attribute;
25
+ if (!Number.isInteger(start) || start < 0 || !Number.isInteger(length) || length < 1 || start + length > text.length) {
26
+ return invalidValue(context, `${where}/start`, `${start}+${length} outside the text`);
27
+ }
28
+ if (!isObject(value)) return invalidValue(context, `${where}/value`, value);
29
+ const personUrn = isObject(value.person) ? value.person.person : undefined;
30
+ const organizationUrn = isObject(value.organization) ? value.organization.organization : undefined;
31
+ const person = parseUrn(personUrn);
32
+ const organization = parseUrn(organizationUrn);
33
+ if (person !== null && person.type === "person" && organizationUrn === undefined && context.state.get("people", person.id) !== null) {
34
+ attributes.push({ start, length, person: person.id, organization: null });
35
+ } else if (organization !== null && organization.type === "organization" && personUrn === undefined
36
+ && context.state.get("organizations", organization.id) !== null) {
37
+ attributes.push({ start, length, person: null, organization: Number(organization.id) });
38
+ } else {
39
+ return invalidValue(context, `${where}/value`, value);
40
+ }
41
+ }
42
+ return { text, attributes };
43
+ }
@@ -0,0 +1,54 @@
1
+ // Offset paging (`start`/`count`) with Rest.li links and a UTF-8 byte budget per page.
2
+ import { fail, invalidValue } from "./errors.mjs";
3
+ import { jsonBytes } from "./text.mjs";
4
+
5
+ export const PAGE_BYTE_BUDGET = 900000;
6
+
7
+ export function pageArgs(context, input, { defaultCount = 10, maxCount = 100 } = {}) {
8
+ const start = input.start ?? 0;
9
+ const count = input.count ?? defaultCount;
10
+ if (!Number.isInteger(start) || start < 0 || start > 1000000) invalidValue(context, "start", start);
11
+ if (!Number.isInteger(count) || count < 1 || count > maxCount) invalidValue(context, "count", count);
12
+ return { start, count };
13
+ }
14
+
15
+ function encode(value) {
16
+ try {
17
+ return encodeURIComponent(value);
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ /** Query string from ordered [name, value] pairs (values null/undefined skipped). */
24
+ export function queryString(pairs) {
25
+ return pairs.filter(([, value]) => value !== null && value !== undefined).map(([name, value]) => `${name}=${encode(String(value))}`).join("&");
26
+ }
27
+
28
+ /**
29
+ * Builds `{ paging, elements }` from the full ordered `items`, rendering with `render` from `start` while the page has room
30
+ * in `count` and in the byte budget. A byte-limited page ends early with a `next` link at the first omitted item.
31
+ */
32
+ export function buildPage(context, items, render, { start, count, path, query = [], withTotal = true }) {
33
+ const elements = [];
34
+ let bytes = 200;
35
+ for (let i = start; i < items.length && elements.length < count; i += 1) {
36
+ const element = render(items[i]);
37
+ const size = jsonBytes(element) + 1;
38
+ if (bytes + size > PAGE_BYTE_BUDGET) {
39
+ if (elements.length === 0) fail(context, "INTERNAL_SERVER_ERROR", "a single element exceeds the response size budget");
40
+ break;
41
+ }
42
+ bytes += size;
43
+ elements.push(element);
44
+ }
45
+ const links = [];
46
+ const next = start + elements.length;
47
+ if (start > 0) {
48
+ links.push({ rel: "prev", href: `${path}?${queryString([...query, ["start", Math.max(0, start - count)], ["count", count]])}`, type: "application/json" });
49
+ }
50
+ if (next < items.length) {
51
+ links.push({ rel: "next", href: `${path}?${queryString([...query, ["start", next], ["count", count]])}`, type: "application/json" });
52
+ }
53
+ return { paging: { start, count, links, ...(withTotal ? { total: items.length } : {}) }, elements };
54
+ }
@@ -0,0 +1,47 @@
1
+ // Validation of post fields shared by create and partial update.
2
+ import { blank, invalidValue, tooLong } from "./errors.mjs";
3
+ import { templateHashtags } from "./text.mjs";
4
+
5
+ export const MAX_COMMENTARY = 3000;
6
+ export const VISIBILITIES = new Set(["PUBLIC", "CONNECTIONS", "LOGGED_IN"]);
7
+ export const FEED_DISTRIBUTIONS = new Set(["MAIN_FEED", "NONE"]);
8
+
9
+ /** Returns templated commentary ("" when absent). Blank checks are the caller's (they depend on article/reshare). */
10
+ export function commentaryArg(context, value) {
11
+ if (value === undefined || value === null) return "";
12
+ if (typeof value !== "string") return invalidValue(context, "commentary", value);
13
+ if (value.length > MAX_COMMENTARY) return tooLong(context, "commentary", value.length, MAX_COMMENTARY);
14
+ const templated = templateHashtags(value);
15
+ if (templated.length > MAX_COMMENTARY) return tooLong(context, "commentary", templated.length, MAX_COMMENTARY);
16
+ return value.trim() === "" ? "" : templated;
17
+ }
18
+
19
+ /** Validates `content`; only `content.article` is simulated. Returns the stored article or null. */
20
+ export function contentArg(context, content) {
21
+ if (content === undefined || content === null) return null;
22
+ if (typeof content !== "object" || Array.isArray(content)) return invalidValue(context, "content", content);
23
+ for (const key of Object.keys(content)) {
24
+ if (key !== "article") return invalidValue(context, `content/${key}`, "an unsupported content type");
25
+ }
26
+ const article = content.article;
27
+ if (article === undefined) return invalidValue(context, "content", content);
28
+ if (article === null || typeof article !== "object" || Array.isArray(article)) return invalidValue(context, "content/article", article);
29
+ const { source, title, description } = article;
30
+ if (typeof source !== "string" || !/^https:\/\/[^\s/]+/.test(source)) return invalidValue(context, "content/article/source", source);
31
+ if (source.length > 2000) return tooLong(context, "content/article/source", source.length, 2000);
32
+ if (typeof title !== "string" || title.trim() === "") return blank(context, "content/article/title");
33
+ if (title.length > 400) return tooLong(context, "content/article/title", title.length, 400);
34
+ if (description !== undefined && description !== null && typeof description !== "string") {
35
+ return invalidValue(context, "content/article/description", description);
36
+ }
37
+ if (typeof description === "string" && description.length > 4000) {
38
+ return tooLong(context, "content/article/description", description.length, 4000);
39
+ }
40
+ return { source, title, description: typeof description === "string" && description !== "" ? description : null };
41
+ }
42
+
43
+ /** Compares two 1–19 digit decimal id strings numerically. */
44
+ export function compareIds(a, b) {
45
+ if (a.length !== b.length) return a.length - b.length;
46
+ return a < b ? -1 : a > b ? 1 : 0;
47
+ }