node-red-contrib-letmepost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 letmepost.dev
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # node-red-contrib-letmepost
2
+
3
+ Node-RED nodes for [letmepost.dev](https://letmepost.dev) — the open-source
4
+ social-media publishing API. Publish or schedule to Bluesky, X/Twitter,
5
+ LinkedIn, Instagram, Threads, Facebook, Pinterest and TikTok through one API,
6
+ and trigger flows from signed webhook deliveries.
7
+
8
+ ## Nodes
9
+
10
+ | Node | What it does |
11
+ | --- | --- |
12
+ | `letmepost-config` | Config node holding your API key and base URL. |
13
+ | `letmepost-publish` | Publish or schedule a post (`POST /v1/posts`). |
14
+ | `letmepost-get-post` | Fetch a post and its attempts (`GET /v1/posts/{id}`). |
15
+ | `letmepost-list-accounts` | List connected accounts (`GET /v1/accounts`). |
16
+ | `letmepost-webhook` | Trigger a flow on a signature-verified webhook event. |
17
+
18
+ ## Install
19
+
20
+ From your Node-RED user directory (`~/.node-red`):
21
+
22
+ ```bash
23
+ npm install node-red-contrib-letmepost
24
+ ```
25
+
26
+ Or in the editor: **Menu → Manage palette → Install** and search for
27
+ `node-red-contrib-letmepost`. Restart Node-RED.
28
+
29
+ Requires Node.js 18+ (uses the global `fetch`) and Node-RED 3.0+.
30
+
31
+ ## Configure credentials
32
+
33
+ 1. Mint an API key in the letmepost dashboard under **Settings → API Keys**
34
+ (`lmp_live_…` for production, `lmp_test_…` for the test environment).
35
+ 2. Drop any letmepost node onto a flow, open it, and next to **Connection**
36
+ click the pencil to add a new `letmepost-config`.
37
+ 3. Paste the key into **API Key**. Leave **Base URL** as
38
+ `https://api.letmepost.dev` unless you self-host.
39
+
40
+ The key is stored encrypted in Node-RED's credentials store, never in the
41
+ flow JSON.
42
+
43
+ ## Nodes in detail
44
+
45
+ ### letmepost-publish
46
+
47
+ Publishes or schedules a post. Each node field can be overridden by the matching
48
+ `msg` property (`msg` wins; otherwise the node config is used).
49
+
50
+ - **Account IDs** — comma-separated IDs, or an array on `msg.accountIds`. Each
51
+ becomes a target `{ accountId }`.
52
+ - **Platform** — used only when no account IDs are given; auto-resolves to the
53
+ single connected account for that platform (e.g. `bluesky`).
54
+ - **Text** — applied to every target.
55
+ - **First Comment** — auto-posted reply where the platform supports it.
56
+ - **Schedule At** — ISO-8601 timestamp in the future; queues the batch (HTTP
57
+ 202, status `queued`).
58
+ - **Profile ID**, **Idempotency Key** — optional. The idempotency key is sent as
59
+ the `Idempotency-Key` header so retries never double-post.
60
+ - `msg.media` — optional array of MediaInput objects, passed through verbatim
61
+ (e.g. `[{ "kind": "image", "mediaId": "med_…" }]`).
62
+ - `msg.payload.targets` — advanced: if `msg.payload` already contains a
63
+ `targets` array, the whole payload is sent as the request body and the fields
64
+ above are ignored. Use this to build arbitrary multi-target / per-target
65
+ override shapes.
66
+
67
+ Output `msg.payload` is the `CreatePostResponse` envelope:
68
+ `{ id, status, createdAt, results[] }`. The node status reflects the batch
69
+ status (`published` / `partial_failed` / `failed` / `queued`).
70
+
71
+ ### letmepost-get-post
72
+
73
+ `GET /v1/posts/{id}`. Set **Post ID** on the node or `msg.postId`. Output
74
+ `msg.payload` is the `PostDetail` (the post row plus `attempts[]`).
75
+
76
+ ### letmepost-list-accounts
77
+
78
+ `GET /v1/accounts`. Optional **Profile ID** filter (or `msg.profileId`). Output
79
+ `msg.payload` is the array of `Account` objects. Feed an account's `id` into the
80
+ publish node's Account IDs.
81
+
82
+ ### letmepost-webhook
83
+
84
+ Triggers a flow when letmepost delivers an event. It registers an HTTP `POST`
85
+ route at the configured **URL Path**, verifies the signature, and emits the
86
+ event only when valid.
87
+
88
+ This node does **not** register itself with the API. Webhook endpoints are
89
+ created from the letmepost dashboard — the registration API is a
90
+ dashboard-session operation and does not accept an `lmp_live_` API key. Register
91
+ the URL by hand:
92
+
93
+ **Setup**
94
+
95
+ 1. Pick a **URL Path** (default `/letmepost-webhook`). Your public URL is the
96
+ Node-RED base URL plus this path,
97
+ e.g. `https://your-host:1880/letmepost-webhook`. Node-RED has to be reachable
98
+ from the public internet for letmepost to deliver to it.
99
+ 2. In the letmepost dashboard, go to **Settings → Webhooks**, add an endpoint,
100
+ and paste that public URL. Choose the event types you want
101
+ (`post.published`, `post.failed`, `token.expiring`, …) or subscribe to all.
102
+ 3. Copy the `signingSecret` (`whsec_…`) the dashboard shows once at creation
103
+ into the node's **Signing Secret** field.
104
+
105
+ **Verification.** Each delivery carries
106
+ `X-Letmepost-Signature: sha256=<hex>`, an HMAC-SHA256 of the raw request body
107
+ keyed by the signing secret (no timestamp). The node recomputes it over the
108
+ exact received bytes and compares in constant time. Deliveries that fail get a
109
+ `401` and are dropped; malformed JSON gets a `400`.
110
+
111
+ Output: `msg.payload` is the parsed event, `msg.topic` is the event `type`.
112
+
113
+ ## Errors
114
+
115
+ Action nodes report API failures via `node.error(err, msg)`, so a **Catch** node
116
+ downstream can handle them. The message surfaces the letmepost error code, the
117
+ failed `rule`, the `remediation`, and the `requestId`.
118
+
119
+ ## License
120
+
121
+ [Apache-2.0](./LICENSE)
@@ -0,0 +1,49 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('letmepost-config', {
3
+ category: 'config',
4
+ defaults: {
5
+ name: { value: '' },
6
+ baseUrl: { value: 'https://api.letmepost.dev', required: true },
7
+ },
8
+ credentials: {
9
+ apiKey: { type: 'password' },
10
+ },
11
+ label: function () {
12
+ return this.name || 'letmepost';
13
+ },
14
+ });
15
+ </script>
16
+
17
+ <script type="text/html" data-template-name="letmepost-config">
18
+ <div class="form-row">
19
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
20
+ <input type="text" id="node-config-input-name" placeholder="letmepost" />
21
+ </div>
22
+ <div class="form-row">
23
+ <label for="node-config-input-apiKey"><i class="fa fa-key"></i> API Key</label>
24
+ <input type="password" id="node-config-input-apiKey" placeholder="lmp_live_…" />
25
+ </div>
26
+ <div class="form-row">
27
+ <label for="node-config-input-baseUrl"><i class="fa fa-globe"></i> Base URL</label>
28
+ <input type="text" id="node-config-input-baseUrl" placeholder="https://api.letmepost.dev" />
29
+ </div>
30
+ </script>
31
+
32
+ <script type="text/html" data-help-name="letmepost-config">
33
+ <p>Holds the credentials shared by every letmepost node.</p>
34
+ <h3>Fields</h3>
35
+ <dl class="message-properties">
36
+ <dt>API Key <span class="property-type">password</span></dt>
37
+ <dd>
38
+ Your letmepost.dev API key. Create one in the dashboard under
39
+ Settings &rarr; API Keys. Use an <code>lmp_live_</code> key for
40
+ production or <code>lmp_test_</code> for the test environment. Stored
41
+ encrypted in Node-RED's credentials store, never in the flow file.
42
+ </dd>
43
+ <dt>Base URL <span class="property-type">string</span></dt>
44
+ <dd>
45
+ The API base URL. Defaults to <code>https://api.letmepost.dev</code>.
46
+ Change only if you run a self-hosted instance.
47
+ </dd>
48
+ </dl>
49
+ </script>
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const { DEFAULT_BASE_URL } = require('./lib/client');
4
+
5
+ module.exports = function (RED) {
6
+ function LetmepostConfigNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ this.name = config.name;
9
+ this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).trim();
10
+ // this.credentials.apiKey is populated by Node-RED from the encrypted
11
+ // credentials store declared below.
12
+ }
13
+
14
+ RED.nodes.registerType('letmepost-config', LetmepostConfigNode, {
15
+ credentials: {
16
+ apiKey: { type: 'password' },
17
+ },
18
+ });
19
+ };
@@ -0,0 +1,52 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('letmepost-get-post', {
3
+ category: 'letmepost',
4
+ color: '#1f6feb',
5
+ defaults: {
6
+ name: { value: '' },
7
+ letmepostConfig: { value: '', type: 'letmepost-config', required: true },
8
+ postId: { value: '' },
9
+ },
10
+ inputs: 1,
11
+ outputs: 1,
12
+ icon: 'font-awesome/fa-search',
13
+ label: function () {
14
+ return this.name || 'letmepost get post';
15
+ },
16
+ });
17
+ </script>
18
+
19
+ <script type="text/html" data-template-name="letmepost-get-post">
20
+ <div class="form-row">
21
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
22
+ <input type="text" id="node-input-name" placeholder="letmepost get post" />
23
+ </div>
24
+ <div class="form-row">
25
+ <label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
26
+ <input type="text" id="node-input-letmepostConfig" />
27
+ </div>
28
+ <div class="form-row">
29
+ <label for="node-input-postId"><i class="fa fa-hashtag"></i> Post ID</label>
30
+ <input type="text" id="node-input-postId" placeholder="post ID (or set msg.postId)" />
31
+ </div>
32
+ </script>
33
+
34
+ <script type="text/html" data-help-name="letmepost-get-post">
35
+ <p>Fetches a single post and its publish attempts via
36
+ <code>GET /v1/posts/{id}</code>.</p>
37
+
38
+ <h3>Inputs</h3>
39
+ <dl class="message-properties">
40
+ <dt class="optional">postId <span class="property-type">string</span></dt>
41
+ <dd>The post ID to retrieve. Overrides the node's Post ID field. Use the
42
+ <code>postId</code> returned inside a publish node's
43
+ <code>results[]</code>.</dd>
44
+ </dl>
45
+
46
+ <h3>Output</h3>
47
+ <dl class="message-properties">
48
+ <dt>payload <span class="property-type">object</span></dt>
49
+ <dd>The <code>PostDetail</code> object: the post row plus an
50
+ <code>attempts[]</code> array.</dd>
51
+ </dl>
52
+ </script>
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { letmepostRequest } = require('./lib/client');
4
+
5
+ module.exports = function (RED) {
6
+ function LetmepostGetPostNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+ node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
10
+
11
+ node.on('input', async function (msg, send, done) {
12
+ send = send || function () { node.send.apply(node, arguments); };
13
+ done = done || function (err) { if (err) node.error(err, msg); };
14
+
15
+ try {
16
+ const postId =
17
+ msg.postId !== undefined && msg.postId !== null && msg.postId !== ''
18
+ ? msg.postId
19
+ : config.postId;
20
+ if (!postId) {
21
+ throw new Error('No post ID. Set a Post ID on the node or provide msg.postId.');
22
+ }
23
+
24
+ node.status({ fill: 'blue', shape: 'dot', text: 'fetching' });
25
+ const response = await letmepostRequest(node.letmepostConfig, {
26
+ method: 'GET',
27
+ endpoint: `/v1/posts/${encodeURIComponent(String(postId))}`,
28
+ });
29
+
30
+ node.status({ fill: 'green', shape: 'dot', text: response.status || 'ok' });
31
+ msg.payload = response;
32
+ send(msg);
33
+ done();
34
+ } catch (err) {
35
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
36
+ done(err);
37
+ }
38
+ });
39
+ }
40
+
41
+ RED.nodes.registerType('letmepost-get-post', LetmepostGetPostNode);
42
+ };
@@ -0,0 +1,53 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('letmepost-list-accounts', {
3
+ category: 'letmepost',
4
+ color: '#1f6feb',
5
+ defaults: {
6
+ name: { value: '' },
7
+ letmepostConfig: { value: '', type: 'letmepost-config', required: true },
8
+ profileId: { value: '' },
9
+ },
10
+ inputs: 1,
11
+ outputs: 1,
12
+ icon: 'font-awesome/fa-users',
13
+ label: function () {
14
+ return this.name || 'letmepost list accounts';
15
+ },
16
+ });
17
+ </script>
18
+
19
+ <script type="text/html" data-template-name="letmepost-list-accounts">
20
+ <div class="form-row">
21
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
22
+ <input type="text" id="node-input-name" placeholder="letmepost list accounts" />
23
+ </div>
24
+ <div class="form-row">
25
+ <label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
26
+ <input type="text" id="node-input-letmepostConfig" />
27
+ </div>
28
+ <div class="form-row">
29
+ <label for="node-input-profileId"><i class="fa fa-id-card-o"></i> Profile ID</label>
30
+ <input type="text" id="node-input-profileId" placeholder="optional profile UUID" />
31
+ </div>
32
+ </script>
33
+
34
+ <script type="text/html" data-help-name="letmepost-list-accounts">
35
+ <p>Lists the social accounts connected to your organization via
36
+ <code>GET /v1/accounts</code>.</p>
37
+
38
+ <h3>Inputs</h3>
39
+ <dl class="message-properties">
40
+ <dt class="optional">profileId <span class="property-type">string</span></dt>
41
+ <dd>Only return accounts under this profile. Overrides the node field.</dd>
42
+ </dl>
43
+
44
+ <h3>Output</h3>
45
+ <dl class="message-properties">
46
+ <dt>payload <span class="property-type">array</span></dt>
47
+ <dd>An array of <code>Account</code> objects (the API response's
48
+ <code>data</code> array). Each carries <code>id</code>,
49
+ <code>platform</code>, <code>platformAccountId</code> and
50
+ <code>displayName</code>. Feed an account's <code>id</code> into the
51
+ publish node's Account IDs.</dd>
52
+ </dl>
53
+ </script>
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const { letmepostRequest } = require('./lib/client');
4
+
5
+ module.exports = function (RED) {
6
+ function LetmepostListAccountsNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+ node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
10
+
11
+ node.on('input', async function (msg, send, done) {
12
+ send = send || function () { node.send.apply(node, arguments); };
13
+ done = done || function (err) { if (err) node.error(err, msg); };
14
+
15
+ try {
16
+ const query = {};
17
+ const profileId =
18
+ msg.profileId !== undefined && msg.profileId !== null && msg.profileId !== ''
19
+ ? msg.profileId
20
+ : config.profileId;
21
+ if (profileId) query.profileId = String(profileId);
22
+
23
+ node.status({ fill: 'blue', shape: 'dot', text: 'fetching' });
24
+ const response = await letmepostRequest(node.letmepostConfig, {
25
+ method: 'GET',
26
+ endpoint: '/v1/accounts',
27
+ query,
28
+ });
29
+
30
+ const accounts = Array.isArray(response.data) ? response.data : [];
31
+ node.status({ fill: 'green', shape: 'dot', text: `${accounts.length} accounts` });
32
+ msg.payload = accounts;
33
+ send(msg);
34
+ done();
35
+ } catch (err) {
36
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
37
+ done(err);
38
+ }
39
+ });
40
+ }
41
+
42
+ RED.nodes.registerType('letmepost-list-accounts', LetmepostListAccountsNode);
43
+ };
@@ -0,0 +1,115 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('letmepost-publish', {
3
+ category: 'letmepost',
4
+ color: '#1f6feb',
5
+ defaults: {
6
+ name: { value: '' },
7
+ letmepostConfig: { value: '', type: 'letmepost-config', required: true },
8
+ accountIds: { value: '' },
9
+ platform: { value: '' },
10
+ text: { value: '' },
11
+ firstComment: { value: '' },
12
+ scheduledAt: { value: '' },
13
+ profileId: { value: '' },
14
+ idempotencyKey: { value: '' },
15
+ },
16
+ inputs: 1,
17
+ outputs: 1,
18
+ icon: 'font-awesome/fa-paper-plane',
19
+ label: function () {
20
+ return this.name || 'letmepost publish';
21
+ },
22
+ });
23
+ </script>
24
+
25
+ <script type="text/html" data-template-name="letmepost-publish">
26
+ <div class="form-row">
27
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
28
+ <input type="text" id="node-input-name" placeholder="letmepost publish" />
29
+ </div>
30
+ <div class="form-row">
31
+ <label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
32
+ <input type="text" id="node-input-letmepostConfig" />
33
+ </div>
34
+ <div class="form-row">
35
+ <label for="node-input-accountIds"><i class="fa fa-users"></i> Account IDs</label>
36
+ <input type="text" id="node-input-accountIds" placeholder="comma-separated account IDs" />
37
+ </div>
38
+ <div class="form-row">
39
+ <label for="node-input-platform"><i class="fa fa-share-alt"></i> Platform</label>
40
+ <input type="text" id="node-input-platform" placeholder="e.g. bluesky (used only when no Account IDs)" />
41
+ </div>
42
+ <div class="form-row">
43
+ <label for="node-input-text"><i class="fa fa-pencil"></i> Text</label>
44
+ <input type="text" id="node-input-text" placeholder="The post text" />
45
+ </div>
46
+ <div class="form-row">
47
+ <label for="node-input-firstComment"><i class="fa fa-comment"></i> First Comment</label>
48
+ <input type="text" id="node-input-firstComment" placeholder="optional first comment" />
49
+ </div>
50
+ <div class="form-row">
51
+ <label for="node-input-scheduledAt"><i class="fa fa-clock-o"></i> Schedule At</label>
52
+ <input type="text" id="node-input-scheduledAt" placeholder="ISO-8601, e.g. 2026-06-01T12:00:00.000Z" />
53
+ </div>
54
+ <div class="form-row">
55
+ <label for="node-input-profileId"><i class="fa fa-id-card-o"></i> Profile ID</label>
56
+ <input type="text" id="node-input-profileId" placeholder="optional profile UUID" />
57
+ </div>
58
+ <div class="form-row">
59
+ <label for="node-input-idempotencyKey"><i class="fa fa-shield"></i> Idempotency Key</label>
60
+ <input type="text" id="node-input-idempotencyKey" placeholder="optional, retries never double-post" />
61
+ </div>
62
+ </script>
63
+
64
+ <script type="text/html" data-help-name="letmepost-publish">
65
+ <p>Publishes or schedules a post to one or more connected accounts via
66
+ <code>POST /v1/posts</code>.</p>
67
+
68
+ <h3>Inputs</h3>
69
+ <p>Every config field can be overridden by the matching <code>msg</code>
70
+ property. The fallback order is <b>msg property &rarr; node config</b>.</p>
71
+ <dl class="message-properties">
72
+ <dt class="optional">accountIds <span class="property-type">string | array</span></dt>
73
+ <dd>One or more connected account IDs. A comma-separated string or an
74
+ array. Each becomes a target <code>{ accountId }</code>.</dd>
75
+ <dt class="optional">platform <span class="property-type">string</span></dt>
76
+ <dd>Used only when no account IDs are given: auto-resolves to the single
77
+ connected account for that platform (e.g. <code>bluesky</code>,
78
+ <code>twitter</code>, <code>linkedin</code>).</dd>
79
+ <dt class="optional">text <span class="property-type">string</span></dt>
80
+ <dd>The post text, applied to every target that has no per-target text.</dd>
81
+ <dt class="optional">firstComment <span class="property-type">string</span></dt>
82
+ <dd>Auto-posted reply after publishing, where the platform supports it.</dd>
83
+ <dt class="optional">scheduledAt <span class="property-type">string</span></dt>
84
+ <dd>ISO-8601 timestamp at least 1 second in the future. When set, the batch
85
+ is queued and the response is <code>202</code> with status
86
+ <code>queued</code>.</dd>
87
+ <dt class="optional">profileId <span class="property-type">string</span></dt>
88
+ <dd>Profile scope for this batch.</dd>
89
+ <dt class="optional">idempotencyKey <span class="property-type">string</span></dt>
90
+ <dd>Sent as the <code>Idempotency-Key</code> header. Replays within 24
91
+ hours return the original response.</dd>
92
+ <dt class="optional">media <span class="property-type">array</span></dt>
93
+ <dd>Optional array of MediaInput objects (e.g.
94
+ <code>[{ kind: "image", mediaId: "med_…" }]</code>) passed through verbatim.</dd>
95
+ <dt class="optional">payload.targets <span class="property-type">array</span></dt>
96
+ <dd>Advanced: if <code>msg.payload</code> already contains a
97
+ <code>targets</code> array, the whole payload is sent as the request body
98
+ and the fields above are ignored. Use this to build any multi-target or
99
+ per-target-override shape the API accepts.</dd>
100
+ </dl>
101
+
102
+ <h3>Output</h3>
103
+ <dl class="message-properties">
104
+ <dt>payload <span class="property-type">object</span></dt>
105
+ <dd>The <code>CreatePostResponse</code> batch envelope:
106
+ <code>{ id, status, createdAt, results[] }</code>. The node status shows
107
+ the batch <code>status</code> (published / partial_failed / failed /
108
+ queued).</dd>
109
+ </dl>
110
+
111
+ <h3>Errors</h3>
112
+ <p>API failures are reported via <code>node.error(err, msg)</code> so a Catch
113
+ node downstream can handle them. The message includes the letmepost error
114
+ code, <code>rule</code>, remediation and request ID.</p>
115
+ </script>
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const { letmepostRequest } = require('./lib/client');
4
+
5
+ module.exports = function (RED) {
6
+ function LetmepostPublishNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+ node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
10
+
11
+ node.on('input', async function (msg, send, done) {
12
+ send = send || function () { node.send.apply(node, arguments); };
13
+ done = done || function (err) { if (err) node.error(err, msg); };
14
+
15
+ try {
16
+ const body = buildBody(config, msg);
17
+ const requestOptions = { method: 'POST', endpoint: '/v1/posts', body };
18
+
19
+ const idempotencyKey = pick(msg.idempotencyKey, config.idempotencyKey);
20
+ if (idempotencyKey) {
21
+ requestOptions.headers = { 'Idempotency-Key': String(idempotencyKey) };
22
+ }
23
+
24
+ node.status({ fill: 'blue', shape: 'dot', text: 'publishing' });
25
+ const response = await letmepostRequest(node.letmepostConfig, requestOptions);
26
+
27
+ const status = response && response.status ? response.status : 'done';
28
+ const shape = status === 'published' || status === 'queued' ? 'dot' : 'ring';
29
+ const fill = status === 'failed' ? 'red' : status === 'partial_failed' ? 'yellow' : 'green';
30
+ node.status({ fill, shape, text: status });
31
+
32
+ msg.payload = response;
33
+ send(msg);
34
+ done();
35
+ } catch (err) {
36
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
37
+ done(err);
38
+ }
39
+ });
40
+ }
41
+
42
+ RED.nodes.registerType('letmepost-publish', LetmepostPublishNode);
43
+ };
44
+
45
+ // Resolve a value from msg first, falling back to the node config. Empty strings
46
+ // and empty arrays count as "not set" so a configured default still wins.
47
+ function pick(fromMsg, fromConfig) {
48
+ if (fromMsg !== undefined && fromMsg !== null && fromMsg !== '') return fromMsg;
49
+ return fromConfig;
50
+ }
51
+
52
+ function parseAccountIds(value) {
53
+ if (Array.isArray(value)) return value.filter(Boolean).map(String);
54
+ if (typeof value === 'string') {
55
+ return value
56
+ .split(',')
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ }
60
+ return [];
61
+ }
62
+
63
+ function buildBody(config, msg) {
64
+ // A fully formed body on msg.payload.targets wins outright — lets advanced
65
+ // users build any multi-target / per-target-override shape the API accepts.
66
+ if (msg.payload && typeof msg.payload === 'object' && Array.isArray(msg.payload.targets)) {
67
+ return msg.payload;
68
+ }
69
+
70
+ const accountIds = parseAccountIds(pick(msg.accountIds, config.accountIds));
71
+ const platform = pick(msg.platform, config.platform);
72
+
73
+ let targets;
74
+ if (accountIds.length > 0) {
75
+ targets = accountIds.map((id) => ({ accountId: id }));
76
+ } else if (platform) {
77
+ // Auto-resolve hint: the org must have exactly one connected account for
78
+ // this platform (see CreatePostRequest in the OpenAPI spec).
79
+ targets = [{ platform: String(platform) }];
80
+ } else {
81
+ throw new Error(
82
+ 'No targets. Set Account IDs or a Platform on the node, or provide msg.payload.targets.',
83
+ );
84
+ }
85
+
86
+ const body = { targets };
87
+
88
+ const text = pick(msg.text, config.text);
89
+ if (text) body.text = String(text);
90
+
91
+ const firstComment = pick(msg.firstComment, config.firstComment);
92
+ if (firstComment) body.firstComment = { text: String(firstComment) };
93
+
94
+ const profileId = pick(msg.profileId, config.profileId);
95
+ if (profileId) body.profileId = String(profileId);
96
+
97
+ const scheduledAt = pick(msg.scheduledAt, config.scheduledAt);
98
+ if (scheduledAt) {
99
+ body.scheduledAt = String(scheduledAt);
100
+ body.publishNow = false;
101
+ } else {
102
+ body.publishNow = true;
103
+ }
104
+
105
+ // Optional media passthrough: accept a ready-made MediaInput[] on msg.media.
106
+ if (Array.isArray(msg.media) && msg.media.length > 0) {
107
+ body.media = msg.media;
108
+ }
109
+
110
+ return body;
111
+ }
@@ -0,0 +1,91 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('letmepost-webhook', {
3
+ category: 'letmepost',
4
+ color: '#1f6feb',
5
+ defaults: {
6
+ name: { value: '' },
7
+ letmepostConfig: { value: '', type: 'letmepost-config', required: false },
8
+ path: { value: '/letmepost-webhook', required: true },
9
+ },
10
+ credentials: {
11
+ signingSecret: { type: 'password' },
12
+ },
13
+ inputs: 0,
14
+ outputs: 1,
15
+ icon: 'font-awesome/fa-bolt',
16
+ label: function () {
17
+ return this.name || 'letmepost webhook';
18
+ },
19
+ oneditprepare: function () {
20
+ // nothing dynamic; placeholder for future use
21
+ },
22
+ });
23
+ </script>
24
+
25
+ <script type="text/html" data-template-name="letmepost-webhook">
26
+ <div class="form-row">
27
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
28
+ <input type="text" id="node-input-name" placeholder="letmepost webhook" />
29
+ </div>
30
+ <div class="form-row">
31
+ <label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
32
+ <input type="text" id="node-input-letmepostConfig" />
33
+ </div>
34
+ <div class="form-row">
35
+ <label for="node-input-path"><i class="fa fa-globe"></i> URL Path</label>
36
+ <input type="text" id="node-input-path" placeholder="/letmepost-webhook" />
37
+ </div>
38
+ <div class="form-row">
39
+ <label for="node-input-signingSecret"><i class="fa fa-shield"></i> Signing Secret</label>
40
+ <input type="password" id="node-input-signingSecret" placeholder="whsec_…" />
41
+ </div>
42
+ <div class="form-tips">
43
+ The full public URL is your Node-RED host + this path, e.g.
44
+ <code>https://your-host:1880/letmepost-webhook</code>. Register that URL
45
+ in the letmepost <b>dashboard</b> (Settings &rarr; Webhooks), then paste
46
+ the <code>signingSecret</code> it shows once into the field above.
47
+ </div>
48
+ </script>
49
+
50
+ <script type="text/html" data-help-name="letmepost-webhook">
51
+ <p>Starts a flow when letmepost.dev delivers a webhook event. The node
52
+ registers an HTTP <code>POST</code> route at the configured path, verifies the
53
+ <code>X-Letmepost-Signature</code> HMAC-SHA256 over the raw body, and emits the
54
+ event only when the signature is valid.</p>
55
+
56
+ <h3>Setup</h3>
57
+ <p>This node does <b>not</b> register itself with the API. Webhook endpoints
58
+ are created from the letmepost dashboard (the registration API is a
59
+ dashboard-session operation and does not accept an <code>lmp_live_</code> key).
60
+ Register the URL manually:</p>
61
+ <ol>
62
+ <li>Choose a <b>URL Path</b> (default <code>/letmepost-webhook</code>). The
63
+ public URL is your Node-RED base URL plus this path, e.g.
64
+ <code>https://your-host:1880/letmepost-webhook</code>. Node-RED must be
65
+ reachable from the public internet for letmepost to deliver to it.</li>
66
+ <li>In the letmepost dashboard go to <b>Settings &rarr; Webhooks</b>, add a
67
+ new endpoint, and paste that public URL. Pick the event types you want (or
68
+ all of them).</li>
69
+ <li>Copy the <code>signingSecret</code> (<code>whsec_…</code>) the dashboard
70
+ shows once at creation, and paste it into <b>Signing Secret</b> above.</li>
71
+ </ol>
72
+
73
+ <h3>Verification</h3>
74
+ <p>Each delivery carries <code>X-Letmepost-Signature: sha256=&lt;hex&gt;</code>,
75
+ an HMAC-SHA256 of the raw request body keyed by the signing secret (no
76
+ timestamp). The node recomputes it over the exact received bytes and compares
77
+ in constant time. Deliveries that fail get a <code>401</code> and are dropped;
78
+ malformed JSON gets a <code>400</code>.</p>
79
+
80
+ <h3>Output</h3>
81
+ <dl class="message-properties">
82
+ <dt>payload <span class="property-type">object</span></dt>
83
+ <dd>The parsed webhook event body.</dd>
84
+ <dt>topic <span class="property-type">string</span></dt>
85
+ <dd>The event <code>type</code> (e.g. <code>post.published</code>).</dd>
86
+ </dl>
87
+
88
+ <h3>Notes</h3>
89
+ <p>The Connection (config node) is optional here and is not used to fetch the
90
+ secret — letmepost never returns it again. Store the secret on this node.</p>
91
+ </script>
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const SIGNATURE_HEADER = 'x-letmepost-signature';
6
+ const SIGNATURE_PREFIX = 'sha256=';
7
+
8
+ module.exports = function (RED) {
9
+ function LetmepostWebhookNode(config) {
10
+ RED.nodes.createNode(this, config);
11
+ const node = this;
12
+ node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
13
+
14
+ // The signing secret is the whsec_… value returned ONCE when the endpoint
15
+ // was created via POST /v1/webhook-endpoints. It lives in the config
16
+ // node's credentials (preferred) or, as a fallback, on this node's own
17
+ // credentials. We never fetch it from the API — it is not retrievable.
18
+ const signingSecret = resolveSecret(node, config);
19
+
20
+ let path = (config.path || '').trim();
21
+ if (!path) {
22
+ node.error('letmepost webhook: no path configured; node disabled.');
23
+ node.status({ fill: 'red', shape: 'ring', text: 'no path' });
24
+ return;
25
+ }
26
+ if (path[0] !== '/') path = `/${path}`;
27
+
28
+ node.status({ fill: 'grey', shape: 'ring', text: 'listening' });
29
+
30
+ // Capture the RAW request body for HMAC. Node-RED mounts a global JSON
31
+ // body-parser, but parsed JSON is re-serialized differently from the bytes
32
+ // that were signed, so HMAC over it would not match. We register our own
33
+ // body-parser on THIS route with a verify hook that stashes the exact
34
+ // received bytes on req.rawBody before any parsing happens.
35
+ const bodyParser = require('body-parser');
36
+ const captureRawBody = function (req, res, buf) {
37
+ req.rawBody = buf;
38
+ };
39
+ const rawTextParser = bodyParser.text({
40
+ type: '*/*',
41
+ limit: '5mb',
42
+ verify: captureRawBody,
43
+ });
44
+
45
+ const handler = function (req, res) {
46
+ const presented = req.headers[SIGNATURE_HEADER];
47
+ const rawBody = req.rawBody;
48
+
49
+ if (!signingSecret) {
50
+ node.warn('letmepost webhook: no signing secret set; rejecting delivery.');
51
+ node.status({ fill: 'red', shape: 'ring', text: 'no secret' });
52
+ res.status(401).json({ message: 'Webhook has no signing secret configured.' });
53
+ return;
54
+ }
55
+ if (rawBody === undefined) {
56
+ res.status(400).json({ message: 'Missing raw request body for signature verification.' });
57
+ return;
58
+ }
59
+ if (!verifySignature(rawBody, presented, signingSecret)) {
60
+ node.status({ fill: 'red', shape: 'ring', text: 'bad signature' });
61
+ res.status(401).json({ message: 'Signature verification failed.' });
62
+ return;
63
+ }
64
+
65
+ const bodyText = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody);
66
+ let event;
67
+ try {
68
+ event = JSON.parse(bodyText);
69
+ } catch (e) {
70
+ res.status(400).json({ message: 'Webhook body was not valid JSON.' });
71
+ return;
72
+ }
73
+
74
+ // Ack immediately so the sender does not retry, then emit the event.
75
+ res.status(200).json({ received: true });
76
+
77
+ const eventType = (event && event.type) || 'event';
78
+ node.status({ fill: 'green', shape: 'dot', text: eventType });
79
+ node.send({ payload: event, topic: eventType, req: { headers: req.headers } });
80
+ };
81
+
82
+ // Register POST on the user-configured path against Node-RED's HTTP node
83
+ // app, with our raw-body parser in front of the handler.
84
+ RED.httpNode.post(path, rawTextParser, handler);
85
+
86
+ node.on('close', function (done) {
87
+ removeRoute(RED.httpNode, 'post', path);
88
+ node.status({});
89
+ done();
90
+ });
91
+ }
92
+
93
+ RED.nodes.registerType('letmepost-webhook', LetmepostWebhookNode, {
94
+ credentials: {
95
+ signingSecret: { type: 'password' },
96
+ },
97
+ });
98
+ };
99
+
100
+ function resolveSecret(node, config) {
101
+ if (node.credentials && node.credentials.signingSecret) {
102
+ return node.credentials.signingSecret;
103
+ }
104
+ if (
105
+ node.letmepostConfig &&
106
+ node.letmepostConfig.credentials &&
107
+ node.letmepostConfig.credentials.signingSecret
108
+ ) {
109
+ return node.letmepostConfig.credentials.signingSecret;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ // Verify an X-Letmepost-Signature header: HMAC-SHA256 over the RAW body, hex
115
+ // encoded, prefixed with "sha256=". No timestamp is involved.
116
+ function verifySignature(rawBody, presentedHeader, secret) {
117
+ if (typeof presentedHeader !== 'string' || presentedHeader.length === 0) return false;
118
+ if (typeof secret !== 'string' || secret.length === 0) return false;
119
+
120
+ const bodyBytes = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
121
+ const digest = crypto.createHmac('sha256', secret).update(bodyBytes).digest('hex');
122
+ const expected = `${SIGNATURE_PREFIX}${digest}`;
123
+ const presented = presentedHeader.startsWith(SIGNATURE_PREFIX)
124
+ ? presentedHeader
125
+ : `${SIGNATURE_PREFIX}${presentedHeader}`;
126
+
127
+ const expectedBuf = Buffer.from(expected);
128
+ const presentedBuf = Buffer.from(presented);
129
+ if (expectedBuf.length !== presentedBuf.length) return false;
130
+ try {
131
+ return crypto.timingSafeEqual(expectedBuf, presentedBuf);
132
+ } catch (e) {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ // Remove the route we added so a redeploy does not stack duplicate handlers on
138
+ // the shared Express app. Express keeps registered routes on the router stack.
139
+ function removeRoute(app, method, path) {
140
+ const router = app && app._router;
141
+ if (!router || !Array.isArray(router.stack)) return;
142
+ router.stack = router.stack.filter(function (layer) {
143
+ if (!layer.route) return true;
144
+ if (layer.route.path !== path) return true;
145
+ return !(layer.route.methods && layer.route.methods[method]);
146
+ });
147
+ }
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_BASE_URL = 'https://api.letmepost.dev';
4
+
5
+ const CODE_TITLES = {
6
+ validation_failed: 'Validation failed',
7
+ preflight_failed: 'Preflight check failed',
8
+ platform_auth_failed: 'Platform authentication failed',
9
+ platform_rejected: 'Platform rejected the post',
10
+ platform_unavailable: 'Platform temporarily unavailable',
11
+ platform_not_enabled: 'Platform not enabled',
12
+ internal_error: 'Internal error',
13
+ unauthenticated: 'Authentication failed',
14
+ unauthorized: 'Not authorized',
15
+ not_found: 'Not found',
16
+ idempotency_conflict: 'Idempotency conflict',
17
+ rate_limited: 'Rate limited',
18
+ };
19
+
20
+ function normalizeBaseUrl(baseUrl) {
21
+ return (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
22
+ }
23
+
24
+ function buildQueryString(query) {
25
+ const parts = [];
26
+ for (const key of Object.keys(query || {})) {
27
+ const value = query[key];
28
+ if (value === undefined || value === null || value === '') continue;
29
+ if (Array.isArray(value)) {
30
+ for (const entry of value) {
31
+ if (entry === undefined || entry === null || entry === '') continue;
32
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(entry))}`);
33
+ }
34
+ } else {
35
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
36
+ }
37
+ }
38
+ return parts.length > 0 ? `?${parts.join('&')}` : '';
39
+ }
40
+
41
+ function extractEnvelope(body) {
42
+ let parsed = body;
43
+ if (typeof body === 'string') {
44
+ try {
45
+ parsed = JSON.parse(body);
46
+ } catch (e) {
47
+ return null;
48
+ }
49
+ }
50
+ if (!parsed || typeof parsed !== 'object') return null;
51
+ const maybe = parsed.error;
52
+ if (!maybe || typeof maybe !== 'object') return null;
53
+ if (typeof maybe.code !== 'string' || typeof maybe.message !== 'string') return null;
54
+ return maybe;
55
+ }
56
+
57
+ // An Error subclass carrying the parsed letmepost envelope so action nodes can
58
+ // surface rule + remediation + requestId in node.error / node.status.
59
+ class LetmepostError extends Error {
60
+ constructor(message, statusCode, envelope, body) {
61
+ super(message);
62
+ this.name = 'LetmepostError';
63
+ this.statusCode = statusCode;
64
+ this.envelope = envelope || null;
65
+ this.body = body;
66
+ }
67
+ }
68
+
69
+ function describeEnvelope(envelope) {
70
+ const title = CODE_TITLES[envelope.code] || 'Letmepost API error';
71
+ let message = `${title} (${envelope.code}): ${envelope.message}`;
72
+ const extra = [];
73
+ if (envelope.rule) extra.push(`Rule: ${envelope.rule}`);
74
+ if (envelope.remediation) extra.push(envelope.remediation);
75
+ if (envelope.docUrl) extra.push(`See ${envelope.docUrl}`);
76
+ if (envelope.requestId) extra.push(`Request ID: ${envelope.requestId}`);
77
+ if (extra.length > 0) message += ` — ${extra.join(' — ')}`;
78
+ return message;
79
+ }
80
+
81
+ /**
82
+ * Authenticated request against the letmepost.dev API. Reads the apiKey
83
+ * credential and baseUrl off the referenced config node and injects the
84
+ * Authorization: Bearer header. Resolves the parsed JSON body on 2xx, or throws
85
+ * a LetmepostError carrying the transparent error envelope (rule + remediation).
86
+ *
87
+ * Prefers the global fetch (Node 18+). Falls back to node-fetch only if global
88
+ * fetch is unavailable.
89
+ */
90
+ async function letmepostRequest(config, options) {
91
+ if (!config) {
92
+ throw new Error('No letmepost configuration node is set. Pick a config node on this node.');
93
+ }
94
+ const apiKey = config.credentials && config.credentials.apiKey;
95
+ if (!apiKey) {
96
+ throw new Error('The letmepost configuration node has no API key set.');
97
+ }
98
+
99
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
100
+ const method = options.method || 'GET';
101
+ const query = buildQueryString(options.query);
102
+ const url = `${baseUrl}${options.endpoint}${query}`;
103
+
104
+ const headers = Object.assign(
105
+ {
106
+ Authorization: `Bearer ${apiKey}`,
107
+ Accept: 'application/json',
108
+ },
109
+ options.headers || {},
110
+ );
111
+
112
+ const init = { method, headers };
113
+ if (options.body !== undefined) {
114
+ headers['Content-Type'] = 'application/json';
115
+ init.body = JSON.stringify(options.body);
116
+ }
117
+
118
+ const fetchFn = await resolveFetch();
119
+ const response = await fetchFn(url, init);
120
+
121
+ const text = await response.text();
122
+ let parsed;
123
+ if (text) {
124
+ try {
125
+ parsed = JSON.parse(text);
126
+ } catch (e) {
127
+ parsed = text;
128
+ }
129
+ } else {
130
+ parsed = {};
131
+ }
132
+
133
+ if (!response.ok) {
134
+ const envelope = extractEnvelope(parsed);
135
+ const message = envelope
136
+ ? describeEnvelope(envelope)
137
+ : `letmepost request failed with HTTP ${response.status}`;
138
+ throw new LetmepostError(message, response.status, envelope, parsed);
139
+ }
140
+
141
+ return parsed;
142
+ }
143
+
144
+ let cachedFetch;
145
+ async function resolveFetch() {
146
+ if (typeof fetch === 'function') return fetch;
147
+ if (cachedFetch) return cachedFetch;
148
+ const mod = await import('node-fetch');
149
+ cachedFetch = mod.default;
150
+ return cachedFetch;
151
+ }
152
+
153
+ module.exports = {
154
+ DEFAULT_BASE_URL,
155
+ LetmepostError,
156
+ letmepostRequest,
157
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "node-red-contrib-letmepost",
3
+ "version": "0.1.0",
4
+ "description": "Node-RED nodes for letmepost.dev — publish to Bluesky, X/Twitter, LinkedIn, Instagram, Threads, Facebook, Pinterest and TikTok through one API, plus a signed webhook trigger.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://letmepost.dev",
7
+ "keywords": [
8
+ "node-red",
9
+ "letmepost",
10
+ "social-media",
11
+ "publishing",
12
+ "webhook",
13
+ "bluesky",
14
+ "twitter",
15
+ "linkedin",
16
+ "instagram",
17
+ "threads",
18
+ "pinterest",
19
+ "tiktok"
20
+ ],
21
+ "author": {
22
+ "name": "letmepost.dev",
23
+ "email": "kamal@letmepost.dev"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/letmepost/node-red-contrib-letmepost.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/letmepost/node-red-contrib-letmepost/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "dependencies": {
36
+ "body-parser": "^1.20.2"
37
+ },
38
+ "node-red": {
39
+ "version": ">=3.0.0",
40
+ "nodes": {
41
+ "letmepost-config": "letmepost/letmepost-config.js",
42
+ "letmepost-publish": "letmepost/letmepost-publish.js",
43
+ "letmepost-get-post": "letmepost/letmepost-get-post.js",
44
+ "letmepost-list-accounts": "letmepost/letmepost-list-accounts.js",
45
+ "letmepost-webhook": "letmepost/letmepost-webhook.js"
46
+ }
47
+ },
48
+ "files": [
49
+ "letmepost",
50
+ "README.md",
51
+ "LICENSE"
52
+ ]
53
+ }