@opencoredev/social-sdk 0.3.0 → 0.5.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/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +24 -21
- package/dist/cloud/common.d.ts +13 -8
- package/dist/cloud/common.js +40 -60
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +6 -2
- package/dist/cloud/media.js +50 -37
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/postfast.d.ts +54 -0
- package/dist/cloud/postfast.js +578 -0
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +72 -0
- package/dist/platforms/x.js +328 -119
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +6 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PublishRequest } from "./core/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A JSON publish request the CLI cannot hand to `prepare`. The message names the
|
|
4
|
+
* offending path and never repeats the submitted value.
|
|
5
|
+
*/
|
|
6
|
+
export declare class PublishRequestInputError extends Error {
|
|
7
|
+
readonly name = "PublishRequestInputError";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse and decode CLI input text into a `PublishRequest`. This checks the
|
|
11
|
+
* request's structure; `prepare` still reports semantic problems such as an
|
|
12
|
+
* unknown backend, an empty target list, or a past schedule as diagnostics.
|
|
13
|
+
* Text that is not JSON throws the `SyntaxError` from `JSON.parse`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function decodePublishRequest(text: string): PublishRequest;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { definedFields } from "./core/fields.js";
|
|
2
|
+
import { isJsonValue } from "./transport/json.js";
|
|
3
|
+
import { isFiniteNumber, isJsonArray, isJsonObject, isString, } from "./transport/validation.js";
|
|
4
|
+
/**
|
|
5
|
+
* A JSON publish request the CLI cannot hand to `prepare`. The message names the
|
|
6
|
+
* offending path and never repeats the submitted value.
|
|
7
|
+
*/
|
|
8
|
+
export class PublishRequestInputError extends Error {
|
|
9
|
+
name = "PublishRequestInputError";
|
|
10
|
+
}
|
|
11
|
+
/** JSON diagnostics deliberately accept only portable URLs, never executable streams or Blob handles. */
|
|
12
|
+
const httpsMediaOnlyMessage = "CLI media validation accepts HTTPS URL inputs only. Validate Blob/stream/media handles through the SDK.";
|
|
13
|
+
function fail(path, expectation) {
|
|
14
|
+
throw new PublishRequestInputError(`Invalid publish request: ${path} must be ${expectation}.`);
|
|
15
|
+
}
|
|
16
|
+
function objectAt(value, path) {
|
|
17
|
+
if (!isJsonObject(value))
|
|
18
|
+
fail(path, "an object");
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function stringAt(value, path) {
|
|
22
|
+
if (!isString(value))
|
|
23
|
+
fail(path, "a string");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function optionalStringAt(value, path) {
|
|
27
|
+
return value === undefined ? undefined : stringAt(value, path);
|
|
28
|
+
}
|
|
29
|
+
function optionalNumberAt(value, path) {
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (!isFiniteNumber(value))
|
|
33
|
+
fail(path, "a finite number");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function versionAt(value, path) {
|
|
37
|
+
if (value !== 1)
|
|
38
|
+
fail(path, "1");
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function connectedAccount(value, path) {
|
|
42
|
+
const input = objectAt(value, path);
|
|
43
|
+
if (input["kind"] !== "connected-account")
|
|
44
|
+
fail(`${path}.kind`, '"connected-account"');
|
|
45
|
+
return {
|
|
46
|
+
kind: input["kind"],
|
|
47
|
+
version: versionAt(input["version"], `${path}.version`),
|
|
48
|
+
backend: stringAt(input["backend"], `${path}.backend`),
|
|
49
|
+
platform: stringAt(input["platform"], `${path}.platform`),
|
|
50
|
+
accountId: stringAt(input["accountId"], `${path}.accountId`),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function mediaRef(input, path) {
|
|
54
|
+
if (input["kind"] !== "media")
|
|
55
|
+
fail(`${path}.kind`, '"media"');
|
|
56
|
+
return {
|
|
57
|
+
kind: input["kind"],
|
|
58
|
+
version: versionAt(input["version"], `${path}.version`),
|
|
59
|
+
backend: stringAt(input["backend"], `${path}.backend`),
|
|
60
|
+
mediaId: stringAt(input["mediaId"], `${path}.mediaId`),
|
|
61
|
+
platform: stringAt(input["platform"], `${path}.platform`),
|
|
62
|
+
accountId: stringAt(input["accountId"], `${path}.accountId`),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function replyReference(value, path) {
|
|
66
|
+
const input = objectAt(value, path);
|
|
67
|
+
const kind = input["kind"];
|
|
68
|
+
const base = {
|
|
69
|
+
version: versionAt(input["version"], `${path}.version`),
|
|
70
|
+
backend: stringAt(input["backend"], `${path}.backend`),
|
|
71
|
+
platform: stringAt(input["platform"], `${path}.platform`),
|
|
72
|
+
accountId: stringAt(input["accountId"], `${path}.accountId`),
|
|
73
|
+
postId: stringAt(input["postId"], `${path}.postId`),
|
|
74
|
+
};
|
|
75
|
+
if (kind === "comment")
|
|
76
|
+
return { kind, ...base, commentId: stringAt(input["commentId"], `${path}.commentId`) };
|
|
77
|
+
if (kind !== "platform-post")
|
|
78
|
+
fail(`${path}.kind`, '"platform-post" or "comment"');
|
|
79
|
+
const native = input["native"];
|
|
80
|
+
return {
|
|
81
|
+
kind,
|
|
82
|
+
...base,
|
|
83
|
+
...definedFields({
|
|
84
|
+
native: native === undefined ? undefined : objectAt(native, `${path}.native`),
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function mediaSource(value, path) {
|
|
89
|
+
if (!isJsonObject(value) || value["kind"] !== "https-url")
|
|
90
|
+
throw new PublishRequestInputError(httpsMediaOnlyMessage);
|
|
91
|
+
return { kind: value["kind"], url: stringAt(value["url"], `${path}.url`) };
|
|
92
|
+
}
|
|
93
|
+
/** Thumbnails were never limited to URLs by the CLI, so JSON media references stay accepted. */
|
|
94
|
+
function thumbnailSource(value, path) {
|
|
95
|
+
const input = objectAt(value, path);
|
|
96
|
+
if (input["kind"] === "https-url")
|
|
97
|
+
return { kind: input["kind"], url: stringAt(input["url"], `${path}.url`) };
|
|
98
|
+
if (input["kind"] === "media-ref")
|
|
99
|
+
return {
|
|
100
|
+
kind: input["kind"],
|
|
101
|
+
ref: mediaRef(objectAt(input["ref"], `${path}.ref`), `${path}.ref`),
|
|
102
|
+
};
|
|
103
|
+
return fail(`${path}.kind`, '"https-url" or "media-ref"');
|
|
104
|
+
}
|
|
105
|
+
function mediaAttachment(value, path) {
|
|
106
|
+
const input = objectAt(value, path);
|
|
107
|
+
const kind = input["kind"];
|
|
108
|
+
if (kind !== "image" && kind !== "video")
|
|
109
|
+
fail(`${path}.kind`, '"image" or "video"');
|
|
110
|
+
const thumbnail = input["thumbnail"];
|
|
111
|
+
return {
|
|
112
|
+
kind,
|
|
113
|
+
source: mediaSource(input["source"], `${path}.source`),
|
|
114
|
+
...definedFields({
|
|
115
|
+
mimeType: optionalStringAt(input["mimeType"], `${path}.mimeType`),
|
|
116
|
+
filename: optionalStringAt(input["filename"], `${path}.filename`),
|
|
117
|
+
byteSize: optionalNumberAt(input["byteSize"], `${path}.byteSize`),
|
|
118
|
+
width: optionalNumberAt(input["width"], `${path}.width`),
|
|
119
|
+
height: optionalNumberAt(input["height"], `${path}.height`),
|
|
120
|
+
durationSeconds: optionalNumberAt(input["durationSeconds"], `${path}.durationSeconds`),
|
|
121
|
+
altText: optionalStringAt(input["altText"], `${path}.altText`),
|
|
122
|
+
caption: optionalStringAt(input["caption"], `${path}.caption`),
|
|
123
|
+
thumbnail: thumbnail === undefined ? undefined : thumbnailSource(thumbnail, `${path}.thumbnail`),
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function link(value, path) {
|
|
128
|
+
const input = objectAt(value, path);
|
|
129
|
+
return {
|
|
130
|
+
url: stringAt(input["url"], `${path}.url`),
|
|
131
|
+
...definedFields({
|
|
132
|
+
title: optionalStringAt(input["title"], `${path}.title`),
|
|
133
|
+
description: optionalStringAt(input["description"], `${path}.description`),
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function content(value, path) {
|
|
138
|
+
const input = objectAt(value, path);
|
|
139
|
+
const media = input["media"];
|
|
140
|
+
const linkInput = input["link"];
|
|
141
|
+
if (media !== undefined && !isJsonArray(media))
|
|
142
|
+
fail(`${path}.media`, "an array");
|
|
143
|
+
return definedFields({
|
|
144
|
+
text: optionalStringAt(input["text"], `${path}.text`),
|
|
145
|
+
media: media?.map((item, index) => mediaAttachment(item, `${path}.media[${index}]`)),
|
|
146
|
+
link: linkInput === undefined ? undefined : link(linkInput, `${path}.link`),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function target(value, path) {
|
|
150
|
+
const input = objectAt(value, path);
|
|
151
|
+
const override = input["content"];
|
|
152
|
+
const replyTo = input["replyTo"];
|
|
153
|
+
const options = input["options"];
|
|
154
|
+
return {
|
|
155
|
+
account: connectedAccount(input["account"], `${path}.account`),
|
|
156
|
+
...definedFields({
|
|
157
|
+
content: override === undefined ? undefined : content(override, `${path}.content`),
|
|
158
|
+
replyTo: replyTo === undefined ? undefined : replyReference(replyTo, `${path}.replyTo`),
|
|
159
|
+
// Each adapter validates its own option fields during `prepare`.
|
|
160
|
+
options: options === undefined ? undefined : objectAt(options, `${path}.options`),
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function schedule(value, path) {
|
|
165
|
+
const input = objectAt(value, path);
|
|
166
|
+
return {
|
|
167
|
+
at: stringAt(input["at"], `${path}.at`),
|
|
168
|
+
...definedFields({ timeZone: optionalStringAt(input["timeZone"], `${path}.timeZone`) }),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parse and decode CLI input text into a `PublishRequest`. This checks the
|
|
173
|
+
* request's structure; `prepare` still reports semantic problems such as an
|
|
174
|
+
* unknown backend, an empty target list, or a past schedule as diagnostics.
|
|
175
|
+
* Text that is not JSON throws the `SyntaxError` from `JSON.parse`.
|
|
176
|
+
*/
|
|
177
|
+
export function decodePublishRequest(text) {
|
|
178
|
+
const parsed = JSON.parse(text);
|
|
179
|
+
if (!isJsonValue(parsed) || !isJsonObject(parsed) || !isJsonArray(parsed["targets"]))
|
|
180
|
+
throw new PublishRequestInputError("Expected a JSON publish request with a targets array.");
|
|
181
|
+
const scheduleInput = parsed["schedule"];
|
|
182
|
+
const replyTo = parsed["replyTo"];
|
|
183
|
+
return {
|
|
184
|
+
targets: parsed["targets"].map((item, index) => target(item, `targets[${index}]`)),
|
|
185
|
+
content: content(parsed["content"], "content"),
|
|
186
|
+
...definedFields({
|
|
187
|
+
idempotencyKey: optionalStringAt(parsed["idempotencyKey"], "idempotencyKey"),
|
|
188
|
+
correlationId: optionalStringAt(parsed["correlationId"], "correlationId"),
|
|
189
|
+
schedule: scheduleInput === undefined ? undefined : schedule(scheduleInput, "schedule"),
|
|
190
|
+
replyTo: replyTo === undefined ? undefined : replyReference(replyTo, "replyTo"),
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import type { SocialAdapter } from "./core/index.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** Adapters the diagnostic CLI can construct offline. */
|
|
4
|
+
export declare const adapterNames: readonly ["mock", "zernio", "post-for-me", "postfast", "bluesky", "x", "threads", "youtube", "tiktok", "instagram", "linkedin"];
|
|
5
|
+
export type AdapterName = (typeof adapterNames)[number];
|
|
6
|
+
export declare function isAdapterName(value: string): value is AdapterName;
|
|
5
7
|
export declare function createDiagnosticAdapter(name: AdapterName, accountId?: string): SocialAdapter<unknown>;
|
|
6
8
|
export interface CliIO {
|
|
7
9
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
@@ -10,4 +12,3 @@ export interface CliIO {
|
|
|
10
12
|
}
|
|
11
13
|
/** All commands are offline and return 0 (valid), 1 (diagnostic failure), or 2 (usage/input error). */
|
|
12
14
|
export declare function runCli(args: readonly string[], io: CliIO): Promise<number>;
|
|
13
|
-
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/require-safety-comment-for-type-assertion -- CLI JSON is parsed and validated at its input boundary. */
|
|
3
2
|
import { realpathSync } from "node:fs";
|
|
4
3
|
import { readFile, stat } from "node:fs/promises";
|
|
5
4
|
import { pathToFileURL } from "node:url";
|
|
6
5
|
import { createSocial } from "./core/client.js";
|
|
6
|
+
import { decodePublishRequest, PublishRequestInputError } from "./cli-request.js";
|
|
7
7
|
import { mockBackend } from "./testing/index.js";
|
|
8
8
|
import { zernio } from "./cloud/zernio.js";
|
|
9
9
|
import { postForMe } from "./cloud/post-for-me.js";
|
|
10
|
+
import { postfast } from "./cloud/postfast.js";
|
|
10
11
|
import { bluesky } from "./platforms/bluesky.js";
|
|
11
12
|
import { x } from "./platforms/x.js";
|
|
12
13
|
import { threads } from "./platforms/threads.js";
|
|
@@ -14,10 +15,12 @@ import { youtube } from "./platforms/youtube.js";
|
|
|
14
15
|
import { tiktok } from "./platforms/tiktok.js";
|
|
15
16
|
import { instagram } from "./platforms/instagram.js";
|
|
16
17
|
import { linkedin } from "./platforms/linkedin.js";
|
|
17
|
-
|
|
18
|
+
/** Adapters the diagnostic CLI can construct offline. */
|
|
19
|
+
export const adapterNames = [
|
|
18
20
|
"mock",
|
|
19
21
|
"zernio",
|
|
20
22
|
"post-for-me",
|
|
23
|
+
"postfast",
|
|
21
24
|
"bluesky",
|
|
22
25
|
"x",
|
|
23
26
|
"threads",
|
|
@@ -26,10 +29,17 @@ const names = [
|
|
|
26
29
|
"instagram",
|
|
27
30
|
"linkedin",
|
|
28
31
|
];
|
|
32
|
+
export function isAdapterName(value) {
|
|
33
|
+
return adapterNames.some((name) => name === value);
|
|
34
|
+
}
|
|
35
|
+
function isLinkedInAuthorUrn(value) {
|
|
36
|
+
return value.startsWith("urn:li:person:") || value.startsWith("urn:li:organization:");
|
|
37
|
+
}
|
|
29
38
|
const environmentNames = {
|
|
30
39
|
mock: [],
|
|
31
40
|
zernio: ["ZERNIO_API_KEY"],
|
|
32
41
|
"post-for-me": ["POST_FOR_ME_API_KEY"],
|
|
42
|
+
postfast: ["POSTFAST_API_KEY"],
|
|
33
43
|
bluesky: ["BLUESKY_SERVICE", "BLUESKY_DID", "BLUESKY_ACCESS_JWT"],
|
|
34
44
|
x: ["X_USER_ID", "X_ACCESS_TOKEN"],
|
|
35
45
|
threads: ["THREADS_USER_ID", "THREADS_ACCESS_TOKEN"],
|
|
@@ -49,6 +59,8 @@ export function createDiagnosticAdapter(name, accountId = "diagnostic-account")
|
|
|
49
59
|
return zernio({ apiKey: "offline-placeholder", fetch: noNetwork });
|
|
50
60
|
case "post-for-me":
|
|
51
61
|
return postForMe({ apiKey: "offline-placeholder", fetch: noNetwork });
|
|
62
|
+
case "postfast":
|
|
63
|
+
return postfast({ apiKey: "offline-placeholder", fetch: noNetwork });
|
|
52
64
|
case "bluesky":
|
|
53
65
|
return bluesky({
|
|
54
66
|
auth: { service: "https://bsky.social", did: accountId, accessJwt: "offline-placeholder" },
|
|
@@ -83,9 +95,7 @@ export function createDiagnosticAdapter(name, accountId = "diagnostic-account")
|
|
|
83
95
|
case "linkedin":
|
|
84
96
|
return linkedin({
|
|
85
97
|
auth: {
|
|
86
|
-
author: accountId
|
|
87
|
-
? accountId
|
|
88
|
-
: "urn:li:person:diagnostic",
|
|
98
|
+
author: isLinkedInAuthorUrn(accountId) ? accountId : "urn:li:person:diagnostic",
|
|
89
99
|
accessToken: "offline-placeholder",
|
|
90
100
|
},
|
|
91
101
|
apiVersion: "202609",
|
|
@@ -97,6 +107,7 @@ export function createDiagnosticAdapter(name, accountId = "diagnostic-account")
|
|
|
97
107
|
export async function runCli(args, io) {
|
|
98
108
|
const command = args[0] ?? "help";
|
|
99
109
|
const json = args.includes("--json");
|
|
110
|
+
// `data` is any report shape this command builds; it is only passed to JSON.stringify.
|
|
100
111
|
const finish = (code, data) => {
|
|
101
112
|
const result = { schemaVersion: 1, command, ok: code === 0, data };
|
|
102
113
|
io.write(json ? JSON.stringify(result) + "\n" : JSON.stringify(result, null, 2) + "\n");
|
|
@@ -123,12 +134,12 @@ export async function runCli(args, io) {
|
|
|
123
134
|
exitCodes: { success: 0, diagnosticFailure: 1, invalidInput: 2 },
|
|
124
135
|
});
|
|
125
136
|
const selected = options.get("--adapter") ?? "mock";
|
|
126
|
-
if (!
|
|
127
|
-
return finish(2, { error: "Unknown adapter.", adapters:
|
|
137
|
+
if (!isAdapterName(selected))
|
|
138
|
+
return finish(2, { error: "Unknown adapter.", adapters: adapterNames });
|
|
128
139
|
const name = selected;
|
|
129
140
|
if (command === "adapters")
|
|
130
141
|
return finish(0, {
|
|
131
|
-
adapters:
|
|
142
|
+
adapters: adapterNames,
|
|
132
143
|
verification: "Local contract tests; no live checks are performed by this CLI.",
|
|
133
144
|
});
|
|
134
145
|
if (command === "doctor") {
|
|
@@ -176,18 +187,7 @@ export async function runCli(args, io) {
|
|
|
176
187
|
const raw = await io.readInput(file);
|
|
177
188
|
if (new TextEncoder().encode(raw).byteLength > 1024 * 1024)
|
|
178
189
|
return finish(2, { error: "Input exceeds the 1 MiB diagnostic limit." });
|
|
179
|
-
const
|
|
180
|
-
if (!request ||
|
|
181
|
-
typeof request !== "object" ||
|
|
182
|
-
!Array.isArray(request["targets"]))
|
|
183
|
-
return finish(2, { error: "Expected a JSON publish request with a targets array." });
|
|
184
|
-
const input = request;
|
|
185
|
-
// JSON diagnostics deliberately accept only portable URLs, never executable streams or Blob handles.
|
|
186
|
-
const contents = [input.content, ...input.targets.map((target) => target.content)];
|
|
187
|
-
if (contents.some((content) => content?.media?.some((media) => media.source?.kind !== "https-url")))
|
|
188
|
-
return finish(2, {
|
|
189
|
-
error: "CLI media validation accepts HTTPS URL inputs only. Validate Blob/stream/media handles through the SDK.",
|
|
190
|
-
});
|
|
190
|
+
const input = decodePublishRequest(raw);
|
|
191
191
|
const social = createSocial({
|
|
192
192
|
backend: createDiagnosticAdapter(name, input.targets[0]?.account?.accountId),
|
|
193
193
|
});
|
|
@@ -204,7 +204,10 @@ export async function runCli(args, io) {
|
|
|
204
204
|
})),
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
|
-
catch {
|
|
207
|
+
catch (error) {
|
|
208
|
+
// Decoder messages name a path or rule and never repeat submitted values.
|
|
209
|
+
if (error instanceof PublishRequestInputError)
|
|
210
|
+
return finish(2, { error: error.message });
|
|
208
211
|
return finish(2, {
|
|
209
212
|
error: "Unable to read or validate the JSON publish request. Check its shape and file permissions.",
|
|
210
213
|
});
|
package/dist/cloud/common.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ManagedMediaStore } from "./media.js";
|
|
2
|
-
import type { AdapterOperationContext, CapabilityManifest, ConnectedAccountRef, JsonObject, MediaAttachment, Platform, PreparationIssue, PreparedPublishTarget } from "../core/types.js";
|
|
2
|
+
import type { AdapterOperationContext, CapabilityDeclaration, CapabilityManifest, ConnectedAccountRef, JsonObject, JsonValue, MediaAttachment, Platform, PreparationIssue, PreparedPublishTarget } from "../core/types.js";
|
|
3
3
|
import { type HttpOptions } from "../transport/http.js";
|
|
4
|
+
import { type JsonField } from "../transport/validation.js";
|
|
4
5
|
export interface ManagedOptions extends HttpOptions {
|
|
5
6
|
readonly apiKey: string;
|
|
6
7
|
readonly mediaStore?: ManagedMediaStore;
|
|
@@ -9,17 +10,21 @@ export interface ManagedOptions extends HttpOptions {
|
|
|
9
10
|
readonly uploadHostAllowed?: (hostname: string) => boolean;
|
|
10
11
|
}
|
|
11
12
|
export declare const selectedPlatforms: readonly ["x", "threads", "bluesky", "youtube", "tiktok", "instagram", "linkedin", "facebook"];
|
|
12
|
-
export declare function platform(value:
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
export declare function
|
|
13
|
+
export declare function platform(value: JsonField): Platform;
|
|
14
|
+
/** How a provider expects its server-side API key. Defaults to a bearer token. */
|
|
15
|
+
export type ManagedAuthHeader = (apiKey: string) => readonly [name: string, value: string];
|
|
16
|
+
export declare function managedHttp(origin: string, options: ManagedOptions, authHeader?: ManagedAuthHeader): (path: string, context: AdapterOperationContext, body?: JsonObject, query?: Record<string, string>, method?: "GET" | "POST" | "PUT" | "DELETE") => Promise<JsonValue>;
|
|
17
|
+
export type PublishFormats = (platform: (typeof selectedPlatforms)[number]) => CapabilityDeclaration["formats"];
|
|
18
|
+
export declare const publishFormats: PublishFormats;
|
|
19
|
+
export declare function capabilityManifest(backend: string, apiRevision: string, operations: readonly string[], formats?: PublishFormats): CapabilityManifest;
|
|
20
|
+
export declare function optionsObject(target: PreparedPublishTarget): JsonObject;
|
|
16
21
|
/** Every accepted normalized option has an intentional provider mapping. */
|
|
17
|
-
export declare function managedOptionIssues(target: PreparedPublishTarget, provider: "zernio" | "post-for-me"): PreparationIssue[];
|
|
22
|
+
export declare function managedOptionIssues(target: PreparedPublishTarget, provider: "zernio" | "post-for-me" | "postfast"): PreparationIssue[];
|
|
18
23
|
export declare function managedPreparation(target: PreparedPublishTarget): PreparationIssue[];
|
|
19
|
-
export declare function uploadManagedMedia(item: MediaAttachment, presign: (body: JsonObject) => Promise<
|
|
24
|
+
export declare function uploadManagedMedia(item: MediaAttachment, presign: (body: JsonObject) => Promise<JsonValue>, config: {
|
|
20
25
|
options: ManagedOptions;
|
|
21
26
|
provider: "zernio" | "post-for-me";
|
|
22
27
|
context: AdapterOperationContext;
|
|
23
28
|
}): Promise<string>;
|
|
24
29
|
export declare function accountMatches(ref: Pick<ConnectedAccountRef, "backend" | "platform" | "accountId">, context: AdapterOperationContext): void;
|
|
25
|
-
export declare function publicFields(value:
|
|
30
|
+
export declare function publicFields(value: JsonField, fields: readonly string[]): JsonObject;
|
package/dist/cloud/common.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { remainingBudget } from "../transport/budget.js";
|
|
2
2
|
import { SocialError } from "../core/errors.js";
|
|
3
3
|
import { createHttp, HttpError } from "../transport/http.js";
|
|
4
|
+
import { isJsonValue } from "../transport/json.js";
|
|
4
5
|
import { httpsUrl, upload } from "../transport/upload.js";
|
|
5
|
-
import {
|
|
6
|
+
import { definedFields } from "../core/fields.js";
|
|
7
|
+
import { isBoolean, isJsonArray, isJsonObject, isString, object, string, } from "../transport/validation.js";
|
|
6
8
|
export const selectedPlatforms = [
|
|
7
9
|
"x",
|
|
8
10
|
"threads",
|
|
@@ -13,11 +15,9 @@ export const selectedPlatforms = [
|
|
|
13
15
|
"linkedin",
|
|
14
16
|
"facebook",
|
|
15
17
|
];
|
|
16
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- provider payload is validated at this adapter boundary.
|
|
17
18
|
export function platform(value) {
|
|
18
19
|
const slug = value === "twitter" ? "x" : value;
|
|
19
|
-
|
|
20
|
-
if (typeof slug !== "string" || !selectedPlatforms.some((item) => item === slug))
|
|
20
|
+
if (!isString(slug) || !selectedPlatforms.some((item) => item === slug))
|
|
21
21
|
throw new SocialError({
|
|
22
22
|
code: "unsupported_capability",
|
|
23
23
|
operation: "accounts.read",
|
|
@@ -25,7 +25,8 @@ export function platform(value) {
|
|
|
25
25
|
});
|
|
26
26
|
return slug;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
const bearer = (apiKey) => ["Authorization", `Bearer ${apiKey}`];
|
|
29
|
+
export function managedHttp(origin, options, authHeader = bearer) {
|
|
29
30
|
if (!options.apiKey.trim())
|
|
30
31
|
throw new SocialError({
|
|
31
32
|
code: "invalid_config",
|
|
@@ -37,10 +38,8 @@ export function managedHttp(origin, options) {
|
|
|
37
38
|
const url = new URL(origin + path);
|
|
38
39
|
for (const [key, value] of Object.entries(query))
|
|
39
40
|
url.searchParams.set(key, value);
|
|
40
|
-
const headers = new Headers({
|
|
41
|
-
|
|
42
|
-
"Content-Type": "application/json",
|
|
43
|
-
});
|
|
41
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
42
|
+
headers.set(...authHeader(options.apiKey));
|
|
44
43
|
if (context.targetIdempotencyKey && origin.includes("zernio.com"))
|
|
45
44
|
headers.set(path === "/v1/posts" ? "x-request-id" : "Idempotency-Key", context.targetIdempotencyKey);
|
|
46
45
|
try {
|
|
@@ -49,10 +48,10 @@ export function managedHttp(origin, options) {
|
|
|
49
48
|
headers,
|
|
50
49
|
timeoutMs: remainingBudget(context),
|
|
51
50
|
method,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
...definedFields({
|
|
52
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
53
|
+
signal: context.signal,
|
|
54
|
+
}),
|
|
56
55
|
maxAttempts: method === "GET" ? Math.min(5, context.retryBudget.maxAttempts) : 1,
|
|
57
56
|
});
|
|
58
57
|
}
|
|
@@ -88,8 +87,7 @@ export function managedHttp(origin, options) {
|
|
|
88
87
|
backend: context.backendInstance,
|
|
89
88
|
correlationId: context.correlationId,
|
|
90
89
|
message: error.message,
|
|
91
|
-
|
|
92
|
-
...(error.status === undefined ? {} : { upstreamStatus: error.status }),
|
|
90
|
+
...definedFields({ upstreamStatus: error.status }),
|
|
93
91
|
retryDisposition: ambiguous
|
|
94
92
|
? { kind: "reconcile-first" }
|
|
95
93
|
: error.status === 401
|
|
@@ -101,7 +99,14 @@ export function managedHttp(origin, options) {
|
|
|
101
99
|
}
|
|
102
100
|
};
|
|
103
101
|
}
|
|
104
|
-
export
|
|
102
|
+
export const publishFormats = (platform) => {
|
|
103
|
+
if (platform === "youtube")
|
|
104
|
+
return ["video"];
|
|
105
|
+
if (platform === "instagram" || platform === "tiktok")
|
|
106
|
+
return ["image", "video", "carousel"];
|
|
107
|
+
return ["text", "image", "video", "carousel"];
|
|
108
|
+
};
|
|
109
|
+
export function capabilityManifest(backend, apiRevision, operations, formats = publishFormats) {
|
|
105
110
|
return {
|
|
106
111
|
schemaVersion: 1,
|
|
107
112
|
backend,
|
|
@@ -111,29 +116,25 @@ export function capabilityManifest(backend, apiRevision, operations) {
|
|
|
111
116
|
platform,
|
|
112
117
|
operation,
|
|
113
118
|
availability: "available",
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- provider payload is validated at this adapter boundary.
|
|
118
|
-
formats: (platform === "youtube"
|
|
119
|
-
? ["video"]
|
|
120
|
-
: platform === "instagram" || platform === "tiktok"
|
|
121
|
-
? ["image", "video", "carousel"]
|
|
122
|
-
: ["text", "image", "video", "carousel"]),
|
|
123
|
-
}
|
|
124
|
-
: {}),
|
|
119
|
+
...definedFields({
|
|
120
|
+
formats: operation === "posts.publish" ? formats(platform) : undefined,
|
|
121
|
+
}),
|
|
125
122
|
notes: "Contract implementation; live account verification and provider/platform eligibility are separate.",
|
|
126
123
|
}))),
|
|
127
124
|
};
|
|
128
125
|
}
|
|
129
|
-
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- provider payload is validated at this adapter boundary.
|
|
130
126
|
export function optionsObject(target) {
|
|
131
|
-
|
|
127
|
+
const options = target.options;
|
|
128
|
+
if (options === undefined)
|
|
129
|
+
return {};
|
|
130
|
+
// Reject exactly as `object` does, including options with members JSON cannot represent.
|
|
131
|
+
if (!isJsonValue(options))
|
|
132
|
+
throw new HttpError("Upstream response must be an object.", "invalid-response", true);
|
|
133
|
+
return object(options);
|
|
132
134
|
}
|
|
133
135
|
/** Every accepted normalized option has an intentional provider mapping. */
|
|
134
136
|
export function managedOptionIssues(target, provider) {
|
|
135
137
|
const config = optionsObject(target);
|
|
136
|
-
// oxlint-disable-next-line anti-slop/no-known-value-widening -- provider payload is validated at this adapter boundary.
|
|
137
138
|
const keys = {
|
|
138
139
|
youtube: ["title", "visibility", "madeForKids"],
|
|
139
140
|
instagram: ["shareToFeed"],
|
|
@@ -167,8 +168,7 @@ export function managedOptionIssues(target, provider) {
|
|
|
167
168
|
"aiGenerated",
|
|
168
169
|
"draft",
|
|
169
170
|
]) {
|
|
170
|
-
|
|
171
|
-
if (config[key] !== undefined && typeof config[key] !== "boolean")
|
|
171
|
+
if (config[key] !== undefined && !isBoolean(config[key]))
|
|
172
172
|
fail("options.boolean", "Consent, audience, interaction and disclosure choices must be booleans.");
|
|
173
173
|
}
|
|
174
174
|
if (config["replySettings"] !== undefined &&
|
|
@@ -184,8 +184,7 @@ export function managedOptionIssues(target, provider) {
|
|
|
184
184
|
"aiGenerated",
|
|
185
185
|
"draft",
|
|
186
186
|
])
|
|
187
|
-
|
|
188
|
-
if (typeof config[key] !== "boolean")
|
|
187
|
+
if (!isBoolean(config[key]))
|
|
189
188
|
fail("tiktok.explicit_choice", "Select every interaction, disclosure, AI-content and draft/direct-post choice before submission.");
|
|
190
189
|
if (config["brandedContent"] === true && config["privacy"] === "SELF_ONLY")
|
|
191
190
|
fail("tiktok.branded_privacy", "TikTok branded content cannot use private visibility.");
|
|
@@ -241,13 +240,11 @@ export function managedPreparation(target) {
|
|
|
241
240
|
const options = optionsObject(target);
|
|
242
241
|
if (media.length !== 1 || media[0]?.kind !== "video")
|
|
243
242
|
fail("youtube.video", "YouTube requires exactly one video.");
|
|
244
|
-
|
|
245
|
-
if (typeof options["title"] !== "string" || !options["title"])
|
|
243
|
+
if (!isString(options["title"]) || !options["title"])
|
|
246
244
|
fail("youtube.title", "Select a YouTube title explicitly.");
|
|
247
245
|
if (!["public", "unlisted", "private"].includes(String(options["visibility"])))
|
|
248
246
|
fail("youtube.visibility", "Select public, unlisted, or private visibility explicitly.");
|
|
249
|
-
|
|
250
|
-
if (typeof options["madeForKids"] !== "boolean")
|
|
247
|
+
if (!isBoolean(options["madeForKids"]))
|
|
251
248
|
fail("youtube.audience", "Declare whether the video is made for kids.");
|
|
252
249
|
}
|
|
253
250
|
if (["instagram", "tiktok"].includes(target.account.platform) && media.length === 0)
|
|
@@ -261,9 +258,7 @@ export function managedPreparation(target) {
|
|
|
261
258
|
}
|
|
262
259
|
return issues;
|
|
263
260
|
}
|
|
264
|
-
export async function uploadManagedMedia(item,
|
|
265
|
-
// oxlint-disable-next-line anti-slop/no-unknown-returns -- provider payload is validated at this adapter boundary.
|
|
266
|
-
presign, config) {
|
|
261
|
+
export async function uploadManagedMedia(item, presign, config) {
|
|
267
262
|
if (item.source.kind === "https-url")
|
|
268
263
|
return httpsUrl(item.source.url).href;
|
|
269
264
|
if (item.source.kind === "media-ref")
|
|
@@ -277,8 +272,7 @@ presign, config) {
|
|
|
277
272
|
const source = item.source;
|
|
278
273
|
const size = item.byteSize ?? (source.kind === "blob" ? source.blob.size : undefined);
|
|
279
274
|
const data = object(await presign(config.provider === "zernio"
|
|
280
|
-
?
|
|
281
|
-
{ filename, contentType: mimeType, ...(size === undefined ? {} : { size }) }
|
|
275
|
+
? { filename, contentType: mimeType, ...definedFields({ size }) }
|
|
282
276
|
: {}));
|
|
283
277
|
const uploadUrl = string(data[config.provider === "zernio" ? "uploadUrl" : "upload_url"]);
|
|
284
278
|
const publicUrl = string(data[config.provider === "zernio" ? "publicUrl" : "media_url"]);
|
|
@@ -290,19 +284,13 @@ presign, config) {
|
|
|
290
284
|
url: uploadUrl,
|
|
291
285
|
source: {
|
|
292
286
|
mimeType,
|
|
293
|
-
|
|
294
|
-
...(size === undefined ? {} : { size }),
|
|
295
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- Blob bodies preserve known-size uploads for presigned storage.
|
|
296
|
-
...(source.kind === "blob" ? { body: source.blob } : {}),
|
|
287
|
+
...definedFields({ size, body: source.kind === "blob" ? source.blob : undefined }),
|
|
297
288
|
open: source.kind === "blob" ? () => source.blob.stream() : source.open,
|
|
298
289
|
},
|
|
299
290
|
allowHost,
|
|
300
291
|
maxBytes: 5 * 1024 * 1024 * 1024,
|
|
301
292
|
timeoutMs: remainingBudget(config.context),
|
|
302
|
-
|
|
303
|
-
...(config.options.fetch ? { fetch: config.options.fetch } : {}),
|
|
304
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- provider payload is validated at this adapter boundary.
|
|
305
|
-
...(config.context.signal ? { signal: config.context.signal } : {}),
|
|
293
|
+
...definedFields({ fetch: config.options.fetch, signal: config.context.signal }),
|
|
306
294
|
});
|
|
307
295
|
return httpsUrl(publicUrl).href;
|
|
308
296
|
}
|
|
@@ -314,20 +302,12 @@ export function accountMatches(ref, context) {
|
|
|
314
302
|
message: "Account reference belongs to another backend instance.",
|
|
315
303
|
});
|
|
316
304
|
}
|
|
317
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- provider payload is validated at this adapter boundary.
|
|
318
305
|
export function publicFields(value, fields) {
|
|
319
306
|
const data = object(value);
|
|
320
307
|
const result = {};
|
|
321
308
|
for (const field of fields) {
|
|
322
309
|
const value = data[field];
|
|
323
|
-
if (
|
|
324
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- provider payload is validated at this adapter boundary.
|
|
325
|
-
typeof value === "string" ||
|
|
326
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- provider payload is validated at this adapter boundary.
|
|
327
|
-
typeof value === "boolean" ||
|
|
328
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- provider payload is validated at this adapter boundary.
|
|
329
|
-
typeof value === "number" ||
|
|
330
|
-
value === null)
|
|
310
|
+
if (value !== undefined && !isJsonObject(value) && !isJsonArray(value))
|
|
331
311
|
result[field] = value;
|
|
332
312
|
}
|
|
333
313
|
return result;
|