@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,95 @@
1
+ // Renders stored rows as LinkedIn JSON objects.
2
+ import { postUrnOf } from "./urn.mjs";
3
+
4
+ export function liteProfile(person) {
5
+ const locale = `${person.locale.language}_${person.locale.country}`;
6
+ const localized = (value) => ({ localized: { [locale]: value }, preferredLocale: { country: person.locale.country, language: person.locale.language } });
7
+ return {
8
+ id: person.id,
9
+ localizedFirstName: person.firstName,
10
+ localizedLastName: person.lastName,
11
+ ...(person.headline === null ? {} : { localizedHeadline: person.headline }),
12
+ vanityName: person.vanityName,
13
+ firstName: localized(person.firstName),
14
+ lastName: localized(person.lastName),
15
+ ...(person.pictureUrl === null ? {} : { profilePicture: { displayImage: `urn:li:digitalmediaAsset:${person.id}-profile` } }),
16
+ };
17
+ }
18
+
19
+ export const fullName = (person) => `${person.firstName} ${person.lastName}`;
20
+
21
+ export function renderPost(post) {
22
+ return {
23
+ id: postUrnOf(post),
24
+ author: post.author,
25
+ commentary: post.commentary,
26
+ visibility: post.visibility,
27
+ distribution: { feedDistribution: post.feedDistribution, targetEntities: [], thirdPartyDistributionChannels: [] },
28
+ lifecycleState: post.lifecycleState,
29
+ lifecycleStateInfo: { isEditedByAuthor: post.isEditedByAuthor },
30
+ isReshareDisabledByAuthor: post.isReshareDisabledByAuthor,
31
+ createdAt: post.createdAtMs,
32
+ lastModifiedAt: post.lastModifiedAtMs,
33
+ ...(post.publishedAtMs === null ? {} : { publishedAt: post.publishedAtMs }),
34
+ ...(post.article === null ? {} : {
35
+ content: { article: { source: post.article.source, title: post.article.title,
36
+ ...(post.article.description === null ? {} : { description: post.article.description }) } },
37
+ }),
38
+ ...(post.reshareParent === null ? {} : { reshareContext: { parent: post.reshareParent, root: post.reshareRoot ?? post.reshareParent } }),
39
+ };
40
+ }
41
+
42
+ const auditStamp = (actor, time, impersonator) => ({
43
+ actor, time, ...(impersonator === null || impersonator === undefined ? {} : { impersonator: `urn:li:person:${impersonator}` }),
44
+ });
45
+
46
+ export function renderReaction(reaction) {
47
+ return {
48
+ id: `urn:li:reaction:(${reaction.actor},${reaction.entity})`,
49
+ root: reaction.entity,
50
+ reactionType: reaction.reactionType,
51
+ created: auditStamp(reaction.actor, reaction.createdAtMs, reaction.impersonator),
52
+ lastModified: auditStamp(reaction.actor, reaction.lastModifiedAtMs, reaction.impersonator),
53
+ };
54
+ }
55
+
56
+ /** Comment object; `likes` are the reactions on it and `replies` its reply rows (top-level comments only). */
57
+ export function renderComment(comment, likes, replies, viewerUrn) {
58
+ const urn = `urn:li:comment:(urn:li:activity:${comment.activityId},${comment.id})`;
59
+ const stamp = (time) => auditStamp(comment.actor, time, comment.agent);
60
+ return {
61
+ id: comment.id,
62
+ commentUrn: urn,
63
+ actor: comment.actor,
64
+ ...(comment.agent === null ? {} : { agent: `urn:li:person:${comment.agent}` }),
65
+ created: stamp(comment.createdAtMs),
66
+ lastModified: stamp(comment.lastModifiedAtMs),
67
+ message: {
68
+ text: comment.text,
69
+ attributes: comment.attributes.map((attribute) => ({
70
+ start: attribute.start,
71
+ length: attribute.length,
72
+ value: attribute.person !== null
73
+ ? { person: { person: `urn:li:person:${attribute.person}` } }
74
+ : { organization: { organization: `urn:li:organization:${attribute.organization}` } },
75
+ })),
76
+ },
77
+ object: `urn:li:activity:${comment.activityId}`,
78
+ ...(comment.parentCommentId === null ? {} : {
79
+ parentComment: `urn:li:comment:(urn:li:activity:${comment.activityId},${comment.parentCommentId})`,
80
+ }),
81
+ likesSummary: {
82
+ totalLikes: likes.length,
83
+ aggregatedTotalLikes: likes.length,
84
+ likedByCurrentUser: likes.some((like) => like.actor === viewerUrn),
85
+ selectedLikes: likes.slice(0, 10).map((like) => like.actor),
86
+ },
87
+ ...(comment.parentCommentId !== null ? {} : {
88
+ commentsSummary: {
89
+ totalFirstLevelComments: replies.length,
90
+ aggregatedTotalComments: replies.length,
91
+ selectedComments: replies.slice(0, 10).map((reply) => `urn:li:comment:(urn:li:activity:${reply.activityId},${reply.id})`),
92
+ },
93
+ }),
94
+ };
95
+ }
@@ -0,0 +1,219 @@
1
+ // Route decoders: LinkedIn path keys, query parameters, headers and bodies into operation arguments.
2
+ import { parseComplexKey } from "./urn.mjs";
3
+ import { begin, copyFields, finish, isObject, jsonBody, pathValue, queryInt, queryValue } from "./wire.mjs";
4
+
5
+ const SYNTAX = "Syntax exception in path variables";
6
+ const set = (state, name, value) => {
7
+ if (value !== undefined) state.args[name] = value;
8
+ };
9
+ const defined = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
10
+ const paging = (state) => {
11
+ set(state, "start", queryInt(state, "start"));
12
+ set(state, "count", queryInt(state, "count"));
13
+ };
14
+
15
+ /** Builds a decoder: `rest` routes carry the version/method checks; `fill(state, request)` adds route arguments. */
16
+ export const route = (options, fill) => (request) => {
17
+ const state = begin(request, options);
18
+ fill(state, request);
19
+ return finish(state);
20
+ };
21
+
22
+ export const plain = () => ({ arguments: {} });
23
+
24
+ export const getPerson = route({ rest: false }, (state, request) => {
25
+ const key = parseComplexKey(pathValue(request, "personKey"));
26
+ if (key === null || key.size !== 1 || !key.has("id")) state.wire.error = SYNTAX;
27
+ else state.args.personId = key.get("id");
28
+ });
29
+
30
+ export const createPost = route({ rest: true }, (state, request) => {
31
+ copyFields(state, jsonBody(state, request), ["author", "commentary", "visibility", "distribution", "lifecycleState", "isReshareDisabledByAuthor",
32
+ "content", "reshareContext"]);
33
+ });
34
+
35
+ export const createUgcPost = route({ rest: false }, (state, request) => {
36
+ const body = jsonBody(state, request);
37
+ if (body === null) return;
38
+ const args = state.args;
39
+ args.kind = "ugcPost";
40
+ args.distribution = { feedDistribution: "MAIN_FEED" };
41
+ if (body.author !== undefined) args.author = body.author;
42
+ if (body.lifecycleState !== undefined) args.lifecycleState = body.lifecycleState;
43
+ if (isObject(body.visibility)) set(state, "visibility", body.visibility["com.linkedin.ugc.MemberNetworkVisibility"]);
44
+ const share = isObject(body.specificContent) ? body.specificContent["com.linkedin.ugc.ShareContent"] : undefined;
45
+ if (!isObject(share)) return;
46
+ if (isObject(share.shareCommentary)) set(state, "commentary", share.shareCommentary.text);
47
+ const category = share.shareMediaCategory ?? "NONE";
48
+ if (category === "ARTICLE") {
49
+ const media = Array.isArray(share.media) && isObject(share.media[0]) ? share.media[0] : {};
50
+ const text = (value) => (isObject(value) ? value.text : undefined);
51
+ args.content = { article: defined({ source: media.originalUrl, title: text(media.title), description: text(media.description) }) };
52
+ } else if (category !== "NONE") {
53
+ args.content = { media: { category: String(category).slice(0, 64) } };
54
+ }
55
+ });
56
+
57
+ export const getPost = route({ rest: true }, (state, request) => {
58
+ state.args.postUrn = pathValue(request, "postUrn");
59
+ set(state, "viewContext", queryValue(state, "viewContext"));
60
+ });
61
+
62
+ export const findPosts = route({ rest: true }, (state) => {
63
+ if (queryValue(state, "q") !== "author") state.wire.error = "Invalid query parameters passed to request: q";
64
+ set(state, "author", queryValue(state, "author"));
65
+ set(state, "viewContext", queryValue(state, "viewContext"));
66
+ set(state, "sortBy", queryValue(state, "sortBy"));
67
+ paging(state);
68
+ });
69
+
70
+ const POST_PATCH = ["commentary", "lifecycleState", "contentCallToActionLabel", "contentLandingPage"];
71
+
72
+ export const updatePost = route({ rest: true, requireMethod: true }, (state, request) => {
73
+ state.args.postUrn = pathValue(request, "postUrn");
74
+ const body = jsonBody(state, request);
75
+ if (body === null) return;
76
+ for (const key of Object.keys(body)) if (key !== "patch") state.wire.fieldError = key;
77
+ const patch = body.patch;
78
+ if (!isObject(patch)) {
79
+ state.wire.fieldError ??= "patch";
80
+ return;
81
+ }
82
+ for (const key of Object.keys(patch)) if (key !== "$set") state.wire.fieldError = `patch/${key}`;
83
+ const changes = patch.$set ?? {};
84
+ if (!isObject(changes)) {
85
+ state.wire.fieldError ??= "patch/$set";
86
+ return;
87
+ }
88
+ for (const key of Object.keys(changes)) {
89
+ if (POST_PATCH.includes(key)) state.args[key] = changes[key];
90
+ else state.wire.fieldError = `patch/$set/${key}`;
91
+ }
92
+ });
93
+
94
+ export const deletePost = (name, rest) => route({ rest }, (state, request) => {
95
+ state.args.postUrn = pathValue(request, name);
96
+ });
97
+
98
+ const target = (state, request) => {
99
+ state.args.target = pathValue(request, "target");
100
+ };
101
+ const commentKey = (state, request) => {
102
+ target(state, request);
103
+ state.args.commentId = pathValue(request, "commentId");
104
+ };
105
+
106
+ export const createComment = route({ rest: true }, (state, request) => {
107
+ target(state, request);
108
+ copyFields(state, jsonBody(state, request), ["actor", "object", "message", "parentComment", "content"]);
109
+ });
110
+
111
+ export const listComments = route({ rest: true }, (state, request) => {
112
+ target(state, request);
113
+ paging(state);
114
+ });
115
+
116
+ export const getComment = route({ rest: true }, commentKey);
117
+
118
+ export const updateComment = route({ rest: true, requireMethod: true }, (state, request) => {
119
+ commentKey(state, request);
120
+ set(state, "actor", queryValue(state, "actor"));
121
+ const body = jsonBody(state, request);
122
+ if (body === null) return;
123
+ const changes = isObject(body.patch) && isObject(body.patch.message) ? body.patch.message.$set : undefined;
124
+ if (!isObject(changes) || Object.keys(body).length !== 1 || Object.keys(body.patch).length !== 1
125
+ || Object.keys(body.patch.message).length !== 1) {
126
+ state.wire.fieldError = "patch";
127
+ return;
128
+ }
129
+ for (const key of Object.keys(changes)) if (key !== "text" && key !== "attributes") state.wire.fieldError = `patch/message/$set/${key}`;
130
+ state.args.message = defined({ text: changes.text, attributes: changes.attributes });
131
+ });
132
+
133
+ export const deleteComment = route({ rest: true }, (state, request) => {
134
+ commentKey(state, request);
135
+ set(state, "actor", queryValue(state, "actor"));
136
+ });
137
+
138
+ export const createReaction = route({ rest: true }, (state, request) => {
139
+ set(state, "actor", queryValue(state, "actor"));
140
+ copyFields(state, jsonBody(state, request), ["root", "reactionType"]);
141
+ });
142
+
143
+ export const getReactions = route({ rest: true }, (state, request) => {
144
+ const key = parseComplexKey(pathValue(request, "reactionKey"));
145
+ if (key === null || !key.has("entity") || key.size > 2 || (key.size === 2 && !key.has("actor"))) {
146
+ state.wire.error = SYNTAX;
147
+ state.args.entity = "";
148
+ return;
149
+ }
150
+ state.args.entity = key.get("entity");
151
+ if (key.has("actor")) {
152
+ state.args.single = true;
153
+ state.args.actor = key.get("actor");
154
+ return;
155
+ }
156
+ if (queryValue(state, "q") !== "entity") state.wire.error = "Invalid query parameters passed to request: q";
157
+ const sort = queryValue(state, "sort");
158
+ if (sort !== undefined) {
159
+ const parsed = parseComplexKey(sort);
160
+ if (parsed === null || parsed.size !== 1 || !parsed.has("value")) state.wire.error = "Invalid query parameters passed to request: sort";
161
+ else state.args.sort = parsed.get("value");
162
+ }
163
+ paging(state);
164
+ });
165
+
166
+ export const deleteReaction = route({ rest: true }, (state, request) => {
167
+ const key = parseComplexKey(pathValue(request, "reactionKey"));
168
+ if (key === null || key.size !== 2 || !key.has("actor") || !key.has("entity")) {
169
+ state.wire.error = SYNTAX;
170
+ state.args.actor = "";
171
+ state.args.entity = "";
172
+ return;
173
+ }
174
+ state.args.actor = key.get("actor");
175
+ state.args.entity = key.get("entity");
176
+ });
177
+
178
+ export const getSocialMetadata = route({ rest: true }, (state, request) => {
179
+ state.args.entity = pathValue(request, "entity");
180
+ });
181
+
182
+ export const updateSocialMetadata = route({ rest: true }, (state, request) => {
183
+ state.args.entity = pathValue(request, "entity");
184
+ set(state, "actor", queryValue(state, "actor"));
185
+ const body = jsonBody(state, request);
186
+ if (body === null) return;
187
+ const changes = isObject(body.patch) ? body.patch.$set : undefined;
188
+ if (!isObject(changes) || Object.keys(body).length !== 1 || Object.keys(body.patch).length !== 1) {
189
+ state.wire.fieldError = "patch";
190
+ return;
191
+ }
192
+ for (const key of Object.keys(changes)) {
193
+ if (key === "commentsState") state.args.commentsState = changes.commentsState;
194
+ else state.wire.fieldError = `patch/$set/${key}`;
195
+ }
196
+ });
197
+
198
+ export const getOrganization = route({ rest: true }, (state, request) => {
199
+ state.args.organizationId = pathValue(request, "organizationId");
200
+ });
201
+
202
+ export const findOrganizations = route({ rest: true }, (state) => {
203
+ if (queryValue(state, "q") !== "vanityName") state.wire.error = "Invalid query parameters passed to request: q";
204
+ state.args.vanityName = queryValue(state, "vanityName") ?? "";
205
+ });
206
+
207
+ export const findAcls = route({ rest: true }, (state) => {
208
+ for (const name of ["q", "organization", "role", "state"]) set(state, name, queryValue(state, name));
209
+ paging(state);
210
+ });
211
+
212
+ export const getNetworkSize = route({ rest: true }, (state, request) => {
213
+ state.args.entity = pathValue(request, "entity");
214
+ set(state, "edgeType", queryValue(state, "edgeType"));
215
+ });
216
+
217
+ export const getConnectionSize = route({ rest: false }, (state, request) => {
218
+ state.args.entity = pathValue(request, "personUrn");
219
+ });
@@ -0,0 +1,25 @@
1
+ // Reaction and comment summaries shared by social metadata and the home feed.
2
+ import { reactionsOn, threadComments } from "./cascade.mjs";
3
+ import { activityUrn, commentUrn } from "./urn.mjs";
4
+
5
+ const TYPES = ["LIKE", "PRAISE", "APPRECIATION", "EMPATHY", "INTEREST", "ENTERTAINMENT"];
6
+
7
+ export function reactionSummaries(reactions) {
8
+ const counts = new Map();
9
+ for (const reaction of reactions) counts.set(reaction.reactionType, (counts.get(reaction.reactionType) ?? 0) + 1);
10
+ const summaries = {};
11
+ for (const type of TYPES) if (counts.has(type)) summaries[type] = { reactionType: type, count: counts.get(type) };
12
+ return summaries;
13
+ }
14
+
15
+ /** Summaries of a post (comment null) or of one comment (its replies and reactions). */
16
+ export function summarize(context, post, comment) {
17
+ const rows = threadComments(context, post.activityId);
18
+ if (comment !== null) {
19
+ const replies = rows.filter((row) => row.parentCommentId === comment.id).length;
20
+ const reactions = reactionsOn(context, commentUrn(comment.activityId, comment.id));
21
+ return { commentSummary: { count: replies, topLevelCount: replies }, reactions };
22
+ }
23
+ const topLevel = rows.filter((row) => row.parentCommentId === null).length;
24
+ return { commentSummary: { count: rows.length, topLevelCount: topLevel }, reactions: reactionsOn(context, activityUrn(post.activityId)) };
25
+ }
@@ -0,0 +1,54 @@
1
+ // Bounded state access: prefix scans that fail instead of truncating, virtual time and LinkedIn-style ids.
2
+ import { fail } from "./errors.mjs";
3
+
4
+ const BATCH = 1000;
5
+ const DEFAULT_MAX = 5000;
6
+
7
+ export function maxScanRows(context) {
8
+ const limits = context.state.get("meta", "limits");
9
+ const value = limits?.maxScanRows;
10
+ return Number.isInteger(value) && value >= 1 && value <= 10000 ? value : DEFAULT_MAX;
11
+ }
12
+
13
+ function overBound(context, max) {
14
+ fail(context, "INTERNAL_SERVER_ERROR", `state exceeds the supported bound of ${max} rows`);
15
+ }
16
+
17
+ /** All rows of a namespace whose row id starts with `prefix` (empty prefix = whole namespace), in row-id order. */
18
+ export function scanPrefix(context, namespace, prefix = "") {
19
+ const max = maxScanRows(context);
20
+ const rows = [];
21
+ let after = prefix === "" ? undefined : prefix;
22
+ for (;;) {
23
+ const batch = context.state.scan(namespace, after === undefined ? { limit: BATCH } : { afterRowId: after, limit: BATCH });
24
+ const records = Array.isArray(batch) ? batch : (batch?.records ?? []);
25
+ for (const record of records) {
26
+ if (prefix !== "" && !record.rowId.startsWith(prefix)) return rows;
27
+ rows.push(record);
28
+ if (rows.length > max) overBound(context, max);
29
+ }
30
+ if (records.length < BATCH) return rows;
31
+ after = records[records.length - 1].rowId;
32
+ }
33
+ }
34
+
35
+ export const scanValues = (context, namespace, prefix = "") => scanPrefix(context, namespace, prefix).map((record) => record.value);
36
+
37
+ export const nowMs = (context) => Math.floor(context.clock.nowUs() / 1000);
38
+
39
+ /** Next LinkedIn-style id: creation time in the high bits, a per-world sequence in the low 22 bits. */
40
+ export function nextId(context) {
41
+ const counters = context.state.get("meta", "counters") ?? { seq: 0 };
42
+ const seq = (Number.isInteger(counters.seq) ? counters.seq : 0) + 1;
43
+ context.state.put("meta", "counters", { seq });
44
+ const id = (BigInt(Math.max(0, nowMs(context))) << 22n) | BigInt(seq & 0x3fffff);
45
+ return id.toString();
46
+ }
47
+
48
+ /** Writes only when the stored value would change (replays do not spend the mutation budget). */
49
+ export function putIfChanged(context, namespace, rowId, value) {
50
+ const current = context.state.get(namespace, rowId);
51
+ if (current !== null && JSON.stringify(current) === JSON.stringify(value)) return false;
52
+ context.state.put(namespace, rowId, value);
53
+ return true;
54
+ }
@@ -0,0 +1,55 @@
1
+ // Little text format helpers and UTF-8 byte accounting (no Buffer in behavior modules).
2
+
3
+ const WORD = /[\p{L}\p{N}_]/u;
4
+ const MAX_TAG = 100;
5
+
6
+ /**
7
+ * Converts `#word` hashtags into LinkedIn's `{hashtag|\#|word}` template in one linear pass. A `#` preceded by a word
8
+ * character or a backslash (already templated text) is left alone, as are hashtags longer than 100 characters.
9
+ */
10
+ export function templateHashtags(text) {
11
+ const chars = Array.from(text);
12
+ let out = "";
13
+ let i = 0;
14
+ while (i < chars.length) {
15
+ const ch = chars[i];
16
+ const prev = i > 0 ? chars[i - 1] : "";
17
+ if (ch === "#" && prev !== "\\" && !(prev !== "" && WORD.test(prev))) {
18
+ let j = i + 1;
19
+ while (j < chars.length && j - i - 1 <= MAX_TAG && WORD.test(chars[j])) j += 1;
20
+ const length = j - i - 1;
21
+ if (length >= 1 && length <= MAX_TAG) {
22
+ out += `{hashtag|\\#|${chars.slice(i + 1, j).join("")}}`;
23
+ i = j;
24
+ continue;
25
+ }
26
+ }
27
+ out += ch;
28
+ i += 1;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** UTF-8 byte length of a string, computed from code points (lone surrogates count as the 3-byte replacement). */
34
+ export function utf8Length(text) {
35
+ let bytes = 0;
36
+ for (let i = 0; i < text.length; i += 1) {
37
+ const code = text.charCodeAt(i);
38
+ if (code < 0x80) bytes += 1;
39
+ else if (code < 0x800) bytes += 2;
40
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
41
+ const next = text.charCodeAt(i + 1);
42
+ if (next >= 0xdc00 && next <= 0xdfff) {
43
+ bytes += 4;
44
+ i += 1;
45
+ } else bytes += 3;
46
+ } else bytes += 3;
47
+ }
48
+ return bytes;
49
+ }
50
+
51
+ /** Encoded JSON size in UTF-8 bytes. */
52
+ export const jsonBytes = (value) => utf8Length(JSON.stringify(value));
53
+
54
+ /** Case-insensitive fold used by connection search (locale-independent). */
55
+ export const fold = (text) => text.toLowerCase();
@@ -0,0 +1,93 @@
1
+ // Linear, non-backtracking parsers for the URNs and Rest.li complex keys this Tool accepts from callers.
2
+
3
+ const MAX_URN = 200;
4
+ const PERSON_ID = /^[A-Za-z0-9_-]{10}$/;
5
+ const DIGITS = /^[0-9]{1,19}$/;
6
+ const ORG_DIGITS = /^[1-9][0-9]{0,11}$/;
7
+
8
+ /**
9
+ * Parses a URN. Returns `{ type, id, urn }` (comment URNs add `activityId` and `commentId`) or null when the text is not
10
+ * a well-formed URN of a type this Tool knows.
11
+ */
12
+ export function parseUrn(value) {
13
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_URN) return null;
14
+ if (!value.startsWith("urn:li:")) return null;
15
+ const rest = value.slice(7);
16
+ const colon = rest.indexOf(":");
17
+ if (colon <= 0) return null;
18
+ const type = rest.slice(0, colon);
19
+ const id = rest.slice(colon + 1);
20
+ switch (type) {
21
+ case "person":
22
+ return PERSON_ID.test(id) ? { type, id, urn: value } : null;
23
+ case "organization":
24
+ return ORG_DIGITS.test(id) ? { type, id, urn: value } : null;
25
+ case "share":
26
+ case "ugcPost":
27
+ case "activity":
28
+ return DIGITS.test(id) ? { type, id, urn: value } : null;
29
+ case "comment": {
30
+ if (!id.startsWith("(urn:li:activity:") || !id.endsWith(")")) return null;
31
+ const inner = id.slice(17, -1);
32
+ const comma = inner.indexOf(",");
33
+ if (comma <= 0) return null;
34
+ const activityId = inner.slice(0, comma);
35
+ const commentId = inner.slice(comma + 1);
36
+ if (!DIGITS.test(activityId) || !DIGITS.test(commentId)) return null;
37
+ return { type, id: commentId, activityId, commentId, urn: value };
38
+ }
39
+ default:
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Looks like an URN (prefix present) even when the id part is malformed: decides INVALID_URN_ID vs INVALID_URN_TYPE. */
45
+ export function urnType(value) {
46
+ if (typeof value !== "string" || !value.startsWith("urn:li:") || value.length > MAX_URN) return null;
47
+ const rest = value.slice(7);
48
+ const colon = rest.indexOf(":");
49
+ return colon > 0 ? rest.slice(0, colon) : null;
50
+ }
51
+
52
+ export const personUrn = (personId) => `urn:li:person:${personId}`;
53
+ export const organizationUrn = (organizationId) => `urn:li:organization:${organizationId}`;
54
+ export const activityUrn = (activityId) => `urn:li:activity:${activityId}`;
55
+ export const postUrnOf = (post) => `urn:li:${post.kind}:${post.id}`;
56
+ export const commentUrn = (activityId, commentId) => `urn:li:comment:(urn:li:activity:${activityId},${commentId})`;
57
+ export const reactionId = (actor, entity) => `urn:li:reaction:(${actor},${entity})`;
58
+
59
+ /**
60
+ * Parses a Rest.li complex key `(name:value,name:value)`, splitting only on top-level commas so nested comment URNs survive.
61
+ * Returns a Map of name → value, or null when the text is not a well-formed key. Names are lowercase letters only.
62
+ */
63
+ export function parseComplexKey(text) {
64
+ if (typeof text !== "string" || text.length < 3 || text.length > 512) return null;
65
+ if (text[0] !== "(" || text[text.length - 1] !== ")") return null;
66
+ const body = text.slice(1, -1);
67
+ const parts = [];
68
+ let depth = 0;
69
+ let start = 0;
70
+ for (let i = 0; i < body.length; i += 1) {
71
+ const ch = body[i];
72
+ if (ch === "(") depth += 1;
73
+ else if (ch === ")") {
74
+ depth -= 1;
75
+ if (depth < 0) return null;
76
+ } else if (ch === "," && depth === 0) {
77
+ parts.push(body.slice(start, i));
78
+ start = i + 1;
79
+ }
80
+ }
81
+ if (depth !== 0) return null;
82
+ parts.push(body.slice(start));
83
+ if (parts.length > 4) return null;
84
+ const map = new Map();
85
+ for (const part of parts) {
86
+ const colon = part.indexOf(":");
87
+ if (colon <= 0) return null;
88
+ const name = part.slice(0, colon);
89
+ if (!/^[a-z]{1,20}$/i.test(name) || map.has(name)) return null;
90
+ map.set(name, part.slice(colon + 1));
91
+ }
92
+ return map;
93
+ }
@@ -0,0 +1,125 @@
1
+ // Pure HTTP helpers: request inspection that never throws, and LinkedIn success/error responses.
2
+ // Problems a decoder finds travel to the handler as `wire.error` (→ BAD_REQUEST) or `wire.fieldError` (→ INVALID_VALUE_FOR_FIELD),
3
+ // so every refusal is answered in LinkedIn's envelope.
4
+ import { ERRORS } from "./errors.mjs";
5
+
6
+ const MAX_DEPTH = 512;
7
+ const MAX_REPEAT = 20;
8
+ const BANNED = new Set(["__proto__", "constructor", "prototype"]);
9
+ const PROTOCOL = { "x-restli-protocol-version": "2.0.0" };
10
+
11
+ export const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
12
+ const last = (values) => (Array.isArray(values) && values.length > 0 ? values[values.length - 1] : undefined);
13
+ const clip = (value) => (typeof value === "string" ? value.slice(0, 64) : null);
14
+
15
+ /** Iterative depth and reserved-key check for a JSON body. */
16
+ export function shapeProblem(value) {
17
+ const stack = [[value, 1]];
18
+ while (stack.length > 0) {
19
+ const [node, depth] = stack.pop();
20
+ if (depth > MAX_DEPTH) return "Request body is nested too deeply";
21
+ if (node === null || typeof node !== "object") continue;
22
+ if (!Array.isArray(node)) for (const key of Object.keys(node)) if (BANNED.has(key)) return `Unpermitted fields present in REQUEST_BODY: [/${key}]`;
23
+ for (const child of Array.isArray(node) ? node : Object.values(node)) if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Starts a request decode: the `wire` block plus query-level checks shared by every route. */
29
+ export function begin(request, { rest, requireMethod = false }) {
30
+ const headers = isObject(request.headers) ? request.headers : {};
31
+ const query = isObject(request.query) ? request.query : {};
32
+ const wire = {
33
+ route: String(request.routeId ?? "").slice(0, 64), rest, linkedinVersion: clip(last(headers["linkedin-version"])),
34
+ restliMethod: clip(last(headers["x-restli-method"])), requireMethod, error: null, fieldError: null,
35
+ };
36
+ for (const name of Object.keys(query)) {
37
+ const values = query[name];
38
+ if (Array.isArray(values) && values.length > MAX_REPEAT) wire.error = "Invalid query parameters passed to request";
39
+ if (name === "ids") wire.error = "Batch get is not supported: Invalid query parameters passed to request";
40
+ }
41
+ return { wire, args: {}, query };
42
+ }
43
+
44
+ /** Reads one query value; U+FFFD (mangled percent-encoding) marks the request invalid. */
45
+ export function queryValue(state, name) {
46
+ const value = last(state.query[name]);
47
+ if (value === undefined) return undefined;
48
+ if (typeof value !== "string" || value.includes("�")) {
49
+ state.wire.error = "Invalid query parameters passed to request";
50
+ return undefined;
51
+ }
52
+ return value;
53
+ }
54
+
55
+ /** Decimal integer query value (`start`, `count`). */
56
+ export function queryInt(state, name) {
57
+ const value = queryValue(state, name);
58
+ if (value === undefined) return undefined;
59
+ if (!/^[0-9]{1,7}$/.test(value)) {
60
+ state.wire.error = `Invalid query parameters passed to request: ${name}`;
61
+ return undefined;
62
+ }
63
+ return Number(value);
64
+ }
65
+
66
+ /** The JSON object body, or null after recording a refusal (absent bodies read as an empty object). */
67
+ export function jsonBody(state, request) {
68
+ const body = request.body;
69
+ if (!isObject(body) || body.kind === "none") return {};
70
+ if (body.kind !== "json" || !isObject(body.value)) {
71
+ state.wire.error = "Request body must be a JSON object";
72
+ return null;
73
+ }
74
+ const problem = shapeProblem(body.value);
75
+ if (problem !== null) {
76
+ state.wire.error = problem;
77
+ return null;
78
+ }
79
+ return body.value;
80
+ }
81
+
82
+ /** Copies allowed body fields; any other field is refused like LinkedIn's unpermitted-field check. */
83
+ export function copyFields(state, body, allowed) {
84
+ if (body === null) return;
85
+ for (const key of Object.keys(body)) {
86
+ if (allowed.includes(key)) state.args[key] = body[key];
87
+ else if (state.wire.error === null) state.wire.error = `Unpermitted fields present in REQUEST_BODY: [/${key}]`;
88
+ }
89
+ }
90
+
91
+ export const pathValue = (request, name) => (isObject(request.path) && typeof request.path[name] === "string" ? request.path[name] : "");
92
+
93
+ export const finish = (state) => ({ arguments: { ...state.args, wire: state.wire } });
94
+
95
+ function errorResponse({ invocation, outcome }, v2) {
96
+ const error = isObject(outcome?.error) ? outcome.error : {};
97
+ let code = String(error.code ?? "");
98
+ code = code.slice(code.lastIndexOf(".") + 1);
99
+ let message = typeof error.message === "string" && error.message !== "" ? error.message : "Request could not be processed";
100
+ if (outcome?.status === "invalid") {
101
+ code = "BAD_REQUEST";
102
+ const issue = Array.isArray(error.issues) && error.issues.length > 0 ? error.issues[0] : null;
103
+ const where = issue !== null && Array.isArray(issue.path) ? issue.path.join("/") : "";
104
+ message = issue === null ? "Invalid request" : `Invalid value for field ${where}: ${String(issue.message ?? "invalid value")}`;
105
+ } else if (outcome?.status === "denied") {
106
+ code = "ACCESS_DENIED";
107
+ message = "Not enough permissions to access this resource";
108
+ } else if (outcome?.status === "unsupported") {
109
+ code = "NOT_FOUND";
110
+ }
111
+ const [status, serviceErrorCode] = ERRORS.get(code) ?? [500, 0];
112
+ if (!ERRORS.has(code)) code = "INTERNAL_SERVER_ERROR";
113
+ void invocation;
114
+ const body = v2 ? { status, serviceErrorCode, message } : { status, serviceErrorCode, code, message };
115
+ return { headers: PROTOCOL, body: { kind: "json", value: body } };
116
+ }
117
+
118
+ /** Encoder factory: `shape(value)` → `{ headers?, body? }` for the success case; `v2` selects the envelope without `code`. */
119
+ export function encoder(shape, v2 = false) {
120
+ return (result) => {
121
+ if (result.outcome?.status !== "ok") return errorResponse(result, v2);
122
+ const { headers = {}, body } = shape(result.outcome.value);
123
+ return { headers: { ...PROTOCOL, ...headers }, body: body === undefined ? { kind: "empty" } : { kind: "json", value: body } };
124
+ };
125
+ }