@opencoredev/social-sdk 0.1.2 → 0.2.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.
- package/README.md +6 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +234 -0
- package/dist/cloud/common.d.ts +25 -0
- package/dist/cloud/common.js +334 -0
- package/dist/cloud/lifecycle.d.ts +10 -0
- package/dist/cloud/lifecycle.js +112 -0
- package/dist/cloud/media.d.ts +23 -0
- package/dist/cloud/media.js +100 -0
- package/dist/cloud/outcomes.d.ts +9 -0
- package/dist/cloud/outcomes.js +195 -0
- package/dist/cloud/post-for-me.d.ts +69 -0
- package/dist/cloud/post-for-me.js +396 -0
- package/dist/cloud/zernio.d.ts +111 -0
- package/dist/cloud/zernio.js +632 -0
- package/dist/core/adapter.d.ts +158 -0
- package/dist/core/adapter.js +3 -0
- package/dist/core/client.d.ts +141 -0
- package/dist/core/client.js +1285 -0
- package/dist/core/concurrency.d.ts +9 -0
- package/dist/core/concurrency.js +79 -0
- package/dist/core/errors.d.ts +44 -0
- package/dist/core/errors.js +50 -0
- package/dist/core/idempotency.d.ts +33 -0
- package/dist/core/idempotency.js +58 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +6 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +81 -0
- package/dist/core/types.d.ts +396 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platforms/bluesky.d.ts +261 -0
- package/dist/platforms/bluesky.js +1776 -0
- package/dist/platforms/instagram.d.ts +129 -0
- package/dist/platforms/instagram.js +1031 -0
- package/dist/platforms/linkedin.d.ts +108 -0
- package/dist/platforms/linkedin.js +890 -0
- package/dist/platforms/threads.d.ts +134 -0
- package/dist/platforms/threads.js +945 -0
- package/dist/platforms/tiktok.d.ts +31 -0
- package/dist/platforms/tiktok.js +593 -0
- package/dist/platforms/x-engagement.d.ts +16 -0
- package/dist/platforms/x-engagement.js +50 -0
- package/dist/platforms/x-text.d.ts +2 -0
- package/dist/platforms/x-text.js +123 -0
- package/dist/platforms/x-tlds.d.ts +1 -0
- package/dist/platforms/x-tlds.js +2 -0
- package/dist/platforms/x.d.ts +299 -0
- package/dist/platforms/x.js +1287 -0
- package/dist/platforms/youtube-upload.d.ts +32 -0
- package/dist/platforms/youtube-upload.js +296 -0
- package/dist/platforms/youtube.d.ts +108 -0
- package/dist/platforms/youtube.js +1100 -0
- package/dist/server/connections.d.ts +123 -0
- package/dist/server/connections.js +335 -0
- package/dist/server/credentials.d.ts +51 -0
- package/dist/server/credentials.js +108 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/oauth.d.ts +52 -0
- package/dist/server/oauth.js +553 -0
- package/dist/server/webhooks.d.ts +57 -0
- package/dist/server/webhooks.js +204 -0
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.js +564 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/transport/binary.d.ts +2 -0
- package/dist/transport/binary.js +29 -0
- package/dist/transport/budget.d.ts +3 -0
- package/dist/transport/budget.js +26 -0
- package/dist/transport/http.d.ts +44 -0
- package/dist/transport/http.js +259 -0
- package/dist/transport/json.d.ts +8 -0
- package/dist/transport/json.js +16 -0
- package/dist/transport/upload.d.ts +25 -0
- package/dist/transport/upload.js +153 -0
- package/dist/transport/validation.d.ts +5 -0
- package/dist/transport/validation.js +24 -0
- package/package.json +2 -7
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ConnectedAccountRef, Platform } from "../core/types.js";
|
|
2
|
+
export interface ConnectionAttempt {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly backend: string;
|
|
5
|
+
readonly tenantId: string;
|
|
6
|
+
readonly principalId: string;
|
|
7
|
+
readonly platforms: readonly Platform[];
|
|
8
|
+
readonly capabilities: readonly string[];
|
|
9
|
+
readonly redirectUri: string;
|
|
10
|
+
readonly state: string;
|
|
11
|
+
readonly codeVerifier: string;
|
|
12
|
+
readonly providerState?: string;
|
|
13
|
+
readonly createdAt: string;
|
|
14
|
+
readonly expiresAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ConnectionStart {
|
|
17
|
+
readonly authorizationUrl: string;
|
|
18
|
+
readonly attempt: Omit<ConnectionAttempt, "state" | "codeVerifier"> & {
|
|
19
|
+
readonly state: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface ConnectionAccount {
|
|
23
|
+
readonly ref: ConnectedAccountRef;
|
|
24
|
+
readonly displayName: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ConnectionProvider {
|
|
27
|
+
start(input: {
|
|
28
|
+
readonly platforms: readonly Platform[];
|
|
29
|
+
readonly capabilities: readonly string[];
|
|
30
|
+
readonly redirectUri: string;
|
|
31
|
+
readonly state: string;
|
|
32
|
+
readonly codeChallenge: string;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
readonly authorizationUrl: string;
|
|
35
|
+
readonly providerState?: string;
|
|
36
|
+
}>;
|
|
37
|
+
complete(input: {
|
|
38
|
+
readonly callbackUrl: string;
|
|
39
|
+
readonly attempt: ConnectionAttempt;
|
|
40
|
+
}): Promise<readonly ConnectionAccount[]>;
|
|
41
|
+
}
|
|
42
|
+
export interface ConnectionStore {
|
|
43
|
+
save(attempt: ConnectionAttempt): Promise<void>;
|
|
44
|
+
get(attemptId: string): Promise<ConnectionAttempt | undefined>;
|
|
45
|
+
/** Atomically claim a one-use code exchange. Never expire or release this claim automatically. */
|
|
46
|
+
claimDiscovery(attemptId: string): Promise<boolean>;
|
|
47
|
+
/** Persist discovered accounts before presenting the account picker. */
|
|
48
|
+
saveDiscoveredAccounts(attemptId: string, accounts: readonly ConnectionAccount[]): Promise<void>;
|
|
49
|
+
getDiscoveredAccounts(attemptId: string): Promise<readonly ConnectionAccount[] | undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Implementations must consume an attempt and insert all selected grants in
|
|
52
|
+
* one durable transaction. If the transaction fails, the attempt must remain
|
|
53
|
+
* retryable (or expose an explicit recovery marker); never commit a consumed
|
|
54
|
+
* attempt with only a subset of its grants.
|
|
55
|
+
*/
|
|
56
|
+
complete(input: {
|
|
57
|
+
readonly attemptId: string;
|
|
58
|
+
readonly state: string;
|
|
59
|
+
readonly accounts: readonly ConnectionAccount[];
|
|
60
|
+
readonly selectedAccountIds: readonly string[];
|
|
61
|
+
}): Promise<readonly ConnectionGrant[]>;
|
|
62
|
+
}
|
|
63
|
+
export interface ConnectionGrant {
|
|
64
|
+
readonly grantId: string;
|
|
65
|
+
readonly tenantId: string;
|
|
66
|
+
readonly principalId: string;
|
|
67
|
+
readonly account: ConnectedAccountRef;
|
|
68
|
+
readonly capabilities: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
export interface ConnectionManagerOptions {
|
|
71
|
+
readonly store: ConnectionStore;
|
|
72
|
+
readonly now?: () => Date;
|
|
73
|
+
readonly randomBytes?: (length: number) => Uint8Array;
|
|
74
|
+
readonly ttlMs?: number;
|
|
75
|
+
}
|
|
76
|
+
export declare class ConnectionManager {
|
|
77
|
+
#private;
|
|
78
|
+
constructor(options: ConnectionManagerOptions);
|
|
79
|
+
begin(input: {
|
|
80
|
+
readonly backend: string;
|
|
81
|
+
readonly tenantId: string;
|
|
82
|
+
readonly principalId: string;
|
|
83
|
+
readonly platforms: readonly Platform[];
|
|
84
|
+
readonly capabilities?: readonly string[];
|
|
85
|
+
readonly redirectUri: string;
|
|
86
|
+
readonly allowedRedirectUris: readonly string[];
|
|
87
|
+
readonly provider: ConnectionProvider;
|
|
88
|
+
}): Promise<ConnectionStart>;
|
|
89
|
+
discover(input: {
|
|
90
|
+
readonly attemptId: string;
|
|
91
|
+
readonly tenantId: string;
|
|
92
|
+
readonly principalId: string;
|
|
93
|
+
readonly callbackUrl: string;
|
|
94
|
+
readonly returnedState: string;
|
|
95
|
+
readonly allowedRedirectUris: readonly string[];
|
|
96
|
+
readonly provider: ConnectionProvider;
|
|
97
|
+
}): Promise<readonly ConnectionAccount[]>;
|
|
98
|
+
select(input: {
|
|
99
|
+
readonly attemptId: string;
|
|
100
|
+
readonly tenantId: string;
|
|
101
|
+
readonly principalId: string;
|
|
102
|
+
readonly selectedAccountIds: readonly string[];
|
|
103
|
+
}): Promise<readonly ConnectionGrant[]>;
|
|
104
|
+
/** Convenience for callers that already know which discovered accounts to select. */
|
|
105
|
+
complete(input: Parameters<ConnectionManager["discover"]>[0] & {
|
|
106
|
+
readonly selectedAccountIds: readonly string[];
|
|
107
|
+
}): Promise<readonly ConnectionGrant[]>;
|
|
108
|
+
}
|
|
109
|
+
export declare class MemoryConnectionStore implements ConnectionStore {
|
|
110
|
+
#private;
|
|
111
|
+
save(attempt: ConnectionAttempt): Promise<void>;
|
|
112
|
+
get(attemptId: string): Promise<ConnectionAttempt | undefined>;
|
|
113
|
+
claimDiscovery(attemptId: string): Promise<boolean>;
|
|
114
|
+
saveDiscoveredAccounts(attemptId: string, accounts: readonly ConnectionAccount[]): Promise<void>;
|
|
115
|
+
getDiscoveredAccounts(attemptId: string): Promise<readonly ConnectionAccount[] | undefined>;
|
|
116
|
+
complete(input: {
|
|
117
|
+
readonly attemptId: string;
|
|
118
|
+
readonly state: string;
|
|
119
|
+
readonly accounts: readonly ConnectionAccount[];
|
|
120
|
+
readonly selectedAccountIds: readonly string[];
|
|
121
|
+
}): Promise<readonly ConnectionGrant[]>;
|
|
122
|
+
grants(): readonly ConnectionGrant[];
|
|
123
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { SocialError } from "../core/errors.js";
|
|
2
|
+
function randomBytes(length) {
|
|
3
|
+
const result = new Uint8Array(length);
|
|
4
|
+
crypto.getRandomValues(result);
|
|
5
|
+
return result;
|
|
6
|
+
}
|
|
7
|
+
function base64Url(bytes) {
|
|
8
|
+
let binary = "";
|
|
9
|
+
for (const byte of bytes)
|
|
10
|
+
binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
12
|
+
}
|
|
13
|
+
async function challenge(verifier) {
|
|
14
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
15
|
+
return base64Url(new Uint8Array(digest));
|
|
16
|
+
}
|
|
17
|
+
function allowedRedirect(value, allowlist, callback = false) {
|
|
18
|
+
let candidate;
|
|
19
|
+
try {
|
|
20
|
+
candidate = new URL(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (candidate.username ||
|
|
26
|
+
candidate.password ||
|
|
27
|
+
candidate.hash ||
|
|
28
|
+
(candidate.protocol !== "https:" &&
|
|
29
|
+
!(candidate.protocol === "http:" &&
|
|
30
|
+
["localhost", "127.0.0.1", "[::1]"].includes(candidate.hostname))))
|
|
31
|
+
return false;
|
|
32
|
+
return allowlist.some((entry) => {
|
|
33
|
+
try {
|
|
34
|
+
const allowed = new URL(entry);
|
|
35
|
+
if (!callback)
|
|
36
|
+
return candidate.href === allowed.href;
|
|
37
|
+
if (candidate.origin !== allowed.origin || candidate.pathname !== allowed.pathname)
|
|
38
|
+
return false;
|
|
39
|
+
for (const [key, value] of allowed.searchParams)
|
|
40
|
+
if (candidate.searchParams.getAll(key).length !== 1 ||
|
|
41
|
+
candidate.searchParams.get(key) !== value)
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function ensureState(expected, actual) {
|
|
51
|
+
const expectedBytes = new TextEncoder().encode(expected);
|
|
52
|
+
const actualBytes = new TextEncoder().encode(actual);
|
|
53
|
+
let difference = expectedBytes.length ^ actualBytes.length;
|
|
54
|
+
const length = Math.max(expectedBytes.length, actualBytes.length);
|
|
55
|
+
for (let index = 0; index < length; index++)
|
|
56
|
+
difference |= (expectedBytes[index] ?? 0) ^ (actualBytes[index] ?? 0);
|
|
57
|
+
if (difference !== 0) {
|
|
58
|
+
throw new SocialError({
|
|
59
|
+
code: "unauthorized",
|
|
60
|
+
operation: "connections.complete",
|
|
61
|
+
message: "Connection state did not match this authenticated attempt",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class ConnectionManager {
|
|
66
|
+
#options;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
const ttlMs = options.ttlMs ?? 10 * 60 * 1000;
|
|
69
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
|
|
70
|
+
throw new SocialError({
|
|
71
|
+
code: "invalid_config",
|
|
72
|
+
operation: "connections.constructor",
|
|
73
|
+
message: "ttlMs must be a positive safe integer",
|
|
74
|
+
});
|
|
75
|
+
this.#options = {
|
|
76
|
+
store: options.store,
|
|
77
|
+
now: options.now ?? (() => new Date()),
|
|
78
|
+
randomBytes: options.randomBytes ?? randomBytes,
|
|
79
|
+
ttlMs,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async begin(input) {
|
|
83
|
+
if (!input.tenantId || !input.principalId)
|
|
84
|
+
throw new SocialError({
|
|
85
|
+
code: "unauthorized",
|
|
86
|
+
operation: "connections.begin",
|
|
87
|
+
message: "An authenticated tenant and principal are required",
|
|
88
|
+
});
|
|
89
|
+
if (!allowedRedirect(input.redirectUri, input.allowedRedirectUris))
|
|
90
|
+
throw new SocialError({
|
|
91
|
+
code: "invalid_input",
|
|
92
|
+
operation: "connections.begin",
|
|
93
|
+
message: "redirectUri is not on the exact callback allowlist",
|
|
94
|
+
});
|
|
95
|
+
if (input.platforms.length === 0)
|
|
96
|
+
throw new SocialError({
|
|
97
|
+
code: "invalid_input",
|
|
98
|
+
operation: "connections.begin",
|
|
99
|
+
message: "At least one platform is required",
|
|
100
|
+
});
|
|
101
|
+
const now = this.#options.now();
|
|
102
|
+
const state = base64Url(this.#options.randomBytes(32));
|
|
103
|
+
const codeVerifier = base64Url(this.#options.randomBytes(48));
|
|
104
|
+
const attemptId = base64Url(this.#options.randomBytes(18));
|
|
105
|
+
const expiresAt = new Date(now.getTime() + this.#options.ttlMs).toISOString();
|
|
106
|
+
const started = await input.provider.start({
|
|
107
|
+
platforms: input.platforms,
|
|
108
|
+
capabilities: input.capabilities ?? [],
|
|
109
|
+
redirectUri: input.redirectUri,
|
|
110
|
+
state,
|
|
111
|
+
codeChallenge: await challenge(codeVerifier),
|
|
112
|
+
});
|
|
113
|
+
const attemptBase = {
|
|
114
|
+
id: attemptId,
|
|
115
|
+
backend: input.backend,
|
|
116
|
+
tenantId: input.tenantId,
|
|
117
|
+
principalId: input.principalId,
|
|
118
|
+
platforms: [...input.platforms],
|
|
119
|
+
capabilities: [...(input.capabilities ?? [])],
|
|
120
|
+
redirectUri: input.redirectUri,
|
|
121
|
+
state,
|
|
122
|
+
codeVerifier,
|
|
123
|
+
createdAt: now.toISOString(),
|
|
124
|
+
expiresAt,
|
|
125
|
+
};
|
|
126
|
+
const attempt = started.providerState === undefined
|
|
127
|
+
? attemptBase
|
|
128
|
+
: { ...attemptBase, providerState: started.providerState };
|
|
129
|
+
await this.#options.store.save(attempt);
|
|
130
|
+
const { state: publicState, codeVerifier: _privateVerifier, ...publicAttempt } = attempt;
|
|
131
|
+
return {
|
|
132
|
+
authorizationUrl: started.authorizationUrl,
|
|
133
|
+
attempt: { ...publicAttempt, state: publicState },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async discover(input) {
|
|
137
|
+
const attempt = await this.#options.store.get(input.attemptId);
|
|
138
|
+
if (attempt === undefined)
|
|
139
|
+
throw new SocialError({
|
|
140
|
+
code: "invalid_input",
|
|
141
|
+
operation: "connections.complete",
|
|
142
|
+
message: "Connection attempt was not found",
|
|
143
|
+
});
|
|
144
|
+
if (attempt.tenantId !== input.tenantId || attempt.principalId !== input.principalId)
|
|
145
|
+
throw new SocialError({
|
|
146
|
+
code: "unauthorized",
|
|
147
|
+
operation: "connections.complete",
|
|
148
|
+
message: "Connection attempt belongs to a different authenticated principal",
|
|
149
|
+
});
|
|
150
|
+
if (!allowedRedirect(input.callbackUrl, input.allowedRedirectUris, true) ||
|
|
151
|
+
!allowedRedirect(input.callbackUrl, [attempt.redirectUri], true))
|
|
152
|
+
throw new SocialError({
|
|
153
|
+
code: "invalid_input",
|
|
154
|
+
operation: "connections.complete",
|
|
155
|
+
message: "Callback URL is not the exact registered redirect",
|
|
156
|
+
});
|
|
157
|
+
ensureState(attempt.state, input.returnedState);
|
|
158
|
+
const states = new URL(input.callbackUrl).searchParams.getAll("state");
|
|
159
|
+
// Callers may pass a callback URL after extracting its state parameter; when
|
|
160
|
+
// present, reject duplicates or a value that differs from the authenticated state.
|
|
161
|
+
if (states.length > 1 || (states.length === 1 && states[0] !== input.returnedState))
|
|
162
|
+
throw new SocialError({
|
|
163
|
+
code: "unauthorized",
|
|
164
|
+
operation: "connections.complete",
|
|
165
|
+
message: "Callback state differs from the authenticated callback parameter",
|
|
166
|
+
});
|
|
167
|
+
if (this.#options.now().getTime() >= Date.parse(attempt.expiresAt))
|
|
168
|
+
throw new SocialError({
|
|
169
|
+
code: "timeout",
|
|
170
|
+
operation: "connections.complete",
|
|
171
|
+
message: "Connection attempt expired",
|
|
172
|
+
});
|
|
173
|
+
const saved = await this.#options.store.getDiscoveredAccounts(attempt.id);
|
|
174
|
+
if (saved)
|
|
175
|
+
return saved;
|
|
176
|
+
if (!(await this.#options.store.claimDiscovery(attempt.id))) {
|
|
177
|
+
const completed = await this.#options.store.getDiscoveredAccounts(attempt.id);
|
|
178
|
+
if (completed)
|
|
179
|
+
return completed;
|
|
180
|
+
throw new SocialError({
|
|
181
|
+
code: "reconnect_required",
|
|
182
|
+
operation: "connections.discover",
|
|
183
|
+
message: "Code exchange is already in progress or its result was lost. Wait for discovery or begin a new connection; the code will not be exchanged again.",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const accounts = await input.provider.complete({ callbackUrl: input.callbackUrl, attempt });
|
|
187
|
+
this.#validateAccounts(attempt, accounts);
|
|
188
|
+
await this.#options.store.saveDiscoveredAccounts(attempt.id, accounts);
|
|
189
|
+
return structuredClone(accounts);
|
|
190
|
+
}
|
|
191
|
+
#validateAccounts(attempt, accounts) {
|
|
192
|
+
if (accounts.length === 0)
|
|
193
|
+
throw new SocialError({
|
|
194
|
+
code: "invalid_input",
|
|
195
|
+
operation: "connections.discover",
|
|
196
|
+
message: "The provider returned no connected accounts",
|
|
197
|
+
});
|
|
198
|
+
const known = new Set(accounts.map((account) => account.ref.accountId));
|
|
199
|
+
if (known.size !== accounts.length ||
|
|
200
|
+
accounts.some(({ ref }) => ref.kind !== "connected-account" ||
|
|
201
|
+
ref.version !== 1 ||
|
|
202
|
+
ref.backend !== attempt.backend ||
|
|
203
|
+
!attempt.platforms.includes(ref.platform) ||
|
|
204
|
+
!ref.accountId))
|
|
205
|
+
throw new SocialError({
|
|
206
|
+
code: "unauthorized",
|
|
207
|
+
operation: "connections.discover",
|
|
208
|
+
message: "Provider returned ambiguous accounts or references outside this connection attempt",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async select(input) {
|
|
212
|
+
const attempt = await this.#options.store.get(input.attemptId);
|
|
213
|
+
if (!attempt ||
|
|
214
|
+
attempt.tenantId !== input.tenantId ||
|
|
215
|
+
attempt.principalId !== input.principalId)
|
|
216
|
+
throw new SocialError({
|
|
217
|
+
code: "unauthorized",
|
|
218
|
+
operation: "connections.select",
|
|
219
|
+
message: "Connection attempt was not found for this authenticated principal",
|
|
220
|
+
});
|
|
221
|
+
if (this.#options.now().getTime() >= Date.parse(attempt.expiresAt))
|
|
222
|
+
throw new SocialError({
|
|
223
|
+
code: "timeout",
|
|
224
|
+
operation: "connections.select",
|
|
225
|
+
message: "Connection attempt expired",
|
|
226
|
+
});
|
|
227
|
+
const accounts = await this.#options.store.getDiscoveredAccounts(attempt.id);
|
|
228
|
+
if (!accounts)
|
|
229
|
+
throw new SocialError({
|
|
230
|
+
code: "invalid_input",
|
|
231
|
+
operation: "connections.select",
|
|
232
|
+
message: "Complete account discovery before selecting accounts",
|
|
233
|
+
});
|
|
234
|
+
this.#validateAccounts(attempt, accounts);
|
|
235
|
+
const selected = new Set(input.selectedAccountIds);
|
|
236
|
+
if (!selected.size || selected.size !== input.selectedAccountIds.length)
|
|
237
|
+
throw new SocialError({
|
|
238
|
+
code: "invalid_input",
|
|
239
|
+
operation: "connections.select",
|
|
240
|
+
message: "Select one or more distinct discovered accounts",
|
|
241
|
+
});
|
|
242
|
+
const known = new Set(accounts.map(({ ref }) => ref.accountId));
|
|
243
|
+
if ([...selected].some((id) => !known.has(id)))
|
|
244
|
+
throw new SocialError({
|
|
245
|
+
code: "unauthorized",
|
|
246
|
+
operation: "connections.select",
|
|
247
|
+
message: "Selected account was not returned by the provider",
|
|
248
|
+
});
|
|
249
|
+
return this.#options.store.complete({
|
|
250
|
+
attemptId: attempt.id,
|
|
251
|
+
state: attempt.state,
|
|
252
|
+
accounts,
|
|
253
|
+
selectedAccountIds: input.selectedAccountIds,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** Convenience for callers that already know which discovered accounts to select. */
|
|
257
|
+
async complete(input) {
|
|
258
|
+
await this.discover(input);
|
|
259
|
+
return this.select(input);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export class MemoryConnectionStore {
|
|
263
|
+
#attempts = new Map();
|
|
264
|
+
#grants = [];
|
|
265
|
+
#sequence = 0;
|
|
266
|
+
async save(attempt) {
|
|
267
|
+
if (this.#attempts.has(attempt.id))
|
|
268
|
+
throw new SocialError({
|
|
269
|
+
code: "upstream_failure",
|
|
270
|
+
operation: "connections.begin",
|
|
271
|
+
message: "Connection attempt ID collision",
|
|
272
|
+
});
|
|
273
|
+
this.#attempts.set(attempt.id, {
|
|
274
|
+
attempt: structuredClone(attempt),
|
|
275
|
+
consumed: false,
|
|
276
|
+
discoveryClaimed: false,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async get(attemptId) {
|
|
280
|
+
const stored = this.#attempts.get(attemptId);
|
|
281
|
+
return stored && !stored.consumed ? structuredClone(stored.attempt) : undefined;
|
|
282
|
+
}
|
|
283
|
+
async claimDiscovery(attemptId) {
|
|
284
|
+
const stored = this.#attempts.get(attemptId);
|
|
285
|
+
if (!stored || stored.consumed || stored.discoveryClaimed)
|
|
286
|
+
return false;
|
|
287
|
+
stored.discoveryClaimed = true;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
async saveDiscoveredAccounts(attemptId, accounts) {
|
|
291
|
+
const stored = this.#attempts.get(attemptId);
|
|
292
|
+
if (!stored || stored.consumed || !stored.discoveryClaimed || stored.accounts)
|
|
293
|
+
throw new SocialError({
|
|
294
|
+
code: "unauthorized",
|
|
295
|
+
operation: "connections.discover",
|
|
296
|
+
message: "Discovery cannot be saved for this attempt",
|
|
297
|
+
});
|
|
298
|
+
stored.accounts = structuredClone(accounts);
|
|
299
|
+
}
|
|
300
|
+
async getDiscoveredAccounts(attemptId) {
|
|
301
|
+
const stored = this.#attempts.get(attemptId);
|
|
302
|
+
return stored?.accounts && !stored.consumed ? structuredClone(stored.accounts) : undefined;
|
|
303
|
+
}
|
|
304
|
+
async complete(input) {
|
|
305
|
+
const stored = this.#attempts.get(input.attemptId);
|
|
306
|
+
if (stored === undefined || stored.consumed || stored.attempt.state !== input.state)
|
|
307
|
+
throw new SocialError({
|
|
308
|
+
code: "unauthorized",
|
|
309
|
+
operation: "connections.complete",
|
|
310
|
+
message: "Connection callback is invalid or already consumed",
|
|
311
|
+
});
|
|
312
|
+
if (!stored.accounts || JSON.stringify(stored.accounts) !== JSON.stringify(input.accounts))
|
|
313
|
+
throw new SocialError({
|
|
314
|
+
code: "unauthorized",
|
|
315
|
+
operation: "connections.select",
|
|
316
|
+
message: "Grants must match persisted discovery",
|
|
317
|
+
});
|
|
318
|
+
const selected = new Set(input.selectedAccountIds);
|
|
319
|
+
const grants = input.accounts
|
|
320
|
+
.filter((account) => selected.has(account.ref.accountId))
|
|
321
|
+
.map((account) => ({
|
|
322
|
+
grantId: `grant-${++this.#sequence}`,
|
|
323
|
+
tenantId: stored.attempt.tenantId,
|
|
324
|
+
principalId: stored.attempt.principalId,
|
|
325
|
+
account: account.ref,
|
|
326
|
+
capabilities: stored.attempt.capabilities,
|
|
327
|
+
}));
|
|
328
|
+
this.#grants.push(...structuredClone(grants));
|
|
329
|
+
stored.consumed = true;
|
|
330
|
+
return structuredClone(grants);
|
|
331
|
+
}
|
|
332
|
+
grants() {
|
|
333
|
+
return structuredClone(this.#grants);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CredentialStore } from "../core/adapter.js";
|
|
2
|
+
export interface CredentialLease {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly token: string;
|
|
5
|
+
readonly expiresAt: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CredentialLock {
|
|
8
|
+
acquire(key: string, ttlMs: number): Promise<CredentialLease | undefined>;
|
|
9
|
+
release(lease: CredentialLease): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface StoredCredential {
|
|
12
|
+
readonly accessToken: string;
|
|
13
|
+
readonly refreshToken?: string;
|
|
14
|
+
readonly expiresAt?: string;
|
|
15
|
+
readonly scopes?: readonly string[];
|
|
16
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
17
|
+
}
|
|
18
|
+
export declare class MemoryCredentialStore implements CredentialStore<StoredCredential> {
|
|
19
|
+
#private;
|
|
20
|
+
get(key: string): Promise<{
|
|
21
|
+
readonly value: StoredCredential;
|
|
22
|
+
readonly revision: string;
|
|
23
|
+
} | undefined>;
|
|
24
|
+
compareAndSet(input: {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly expectedRevision: string | undefined;
|
|
27
|
+
readonly value: StoredCredential;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
readonly updated: boolean;
|
|
30
|
+
readonly revision?: string;
|
|
31
|
+
}>;
|
|
32
|
+
delete(key: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare class MemoryCredentialLock implements CredentialLock {
|
|
35
|
+
#private;
|
|
36
|
+
acquire(key: string, ttlMs: number): Promise<CredentialLease | undefined>;
|
|
37
|
+
release(lease: CredentialLease): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare class CredentialManager {
|
|
40
|
+
private readonly store;
|
|
41
|
+
private readonly lock;
|
|
42
|
+
private readonly lockTtlMs;
|
|
43
|
+
constructor(store: CredentialStore<StoredCredential>, lock: CredentialLock, lockTtlMs?: number);
|
|
44
|
+
get(key: string): Promise<{
|
|
45
|
+
readonly value: StoredCredential;
|
|
46
|
+
readonly revision: string;
|
|
47
|
+
} | undefined>;
|
|
48
|
+
save(key: string, value: StoredCredential, expectedRevision?: string): Promise<string>;
|
|
49
|
+
rotate(key: string, refresh: (current: StoredCredential) => Promise<StoredCredential>): Promise<StoredCredential>;
|
|
50
|
+
delete(key: string): Promise<void>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { SocialError } from "../core/errors.js";
|
|
2
|
+
export class MemoryCredentialStore {
|
|
3
|
+
#values = new Map();
|
|
4
|
+
#sequence = 0;
|
|
5
|
+
async get(key) {
|
|
6
|
+
const found = this.#values.get(key);
|
|
7
|
+
if (found === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
const value = { ...found.value };
|
|
10
|
+
if (found.value.scopes !== undefined)
|
|
11
|
+
value.scopes = [...found.value.scopes];
|
|
12
|
+
return { value, revision: found.revision };
|
|
13
|
+
}
|
|
14
|
+
async compareAndSet(input) {
|
|
15
|
+
const current = this.#values.get(input.key);
|
|
16
|
+
if ((current?.revision ?? undefined) !== input.expectedRevision)
|
|
17
|
+
return { updated: false };
|
|
18
|
+
const revision = `credential-${++this.#sequence}`;
|
|
19
|
+
const value = { ...input.value };
|
|
20
|
+
if (input.value.scopes !== undefined)
|
|
21
|
+
value.scopes = [...input.value.scopes];
|
|
22
|
+
this.#values.set(input.key, { value, revision });
|
|
23
|
+
return { updated: true, revision };
|
|
24
|
+
}
|
|
25
|
+
async delete(key) {
|
|
26
|
+
this.#values.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class MemoryCredentialLock {
|
|
30
|
+
#leases = new Map();
|
|
31
|
+
#sequence = 0;
|
|
32
|
+
async acquire(key, ttlMs) {
|
|
33
|
+
const current = this.#leases.get(key);
|
|
34
|
+
if (current !== undefined && Date.parse(current.expiresAt) > Date.now())
|
|
35
|
+
return undefined;
|
|
36
|
+
const lease = {
|
|
37
|
+
key,
|
|
38
|
+
token: `lease-${++this.#sequence}`,
|
|
39
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
40
|
+
};
|
|
41
|
+
this.#leases.set(key, lease);
|
|
42
|
+
return lease;
|
|
43
|
+
}
|
|
44
|
+
async release(lease) {
|
|
45
|
+
if (this.#leases.get(lease.key)?.token === lease.token)
|
|
46
|
+
this.#leases.delete(lease.key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class CredentialManager {
|
|
50
|
+
store;
|
|
51
|
+
lock;
|
|
52
|
+
lockTtlMs;
|
|
53
|
+
constructor(store, lock, lockTtlMs = 30_000) {
|
|
54
|
+
this.store = store;
|
|
55
|
+
this.lock = lock;
|
|
56
|
+
this.lockTtlMs = lockTtlMs;
|
|
57
|
+
}
|
|
58
|
+
get(key) {
|
|
59
|
+
return this.store.get(key);
|
|
60
|
+
}
|
|
61
|
+
async save(key, value, expectedRevision) {
|
|
62
|
+
const result = await this.store.compareAndSet({ key, expectedRevision, value });
|
|
63
|
+
if (!result.updated || result.revision === undefined)
|
|
64
|
+
throw new SocialError({
|
|
65
|
+
code: "upstream_failure",
|
|
66
|
+
operation: "credentials.save",
|
|
67
|
+
message: "Credential revision changed; reload before saving",
|
|
68
|
+
});
|
|
69
|
+
return result.revision;
|
|
70
|
+
}
|
|
71
|
+
async rotate(key, refresh) {
|
|
72
|
+
const lease = await this.lock.acquire(key, this.lockTtlMs);
|
|
73
|
+
if (lease === undefined)
|
|
74
|
+
throw new SocialError({
|
|
75
|
+
code: "upstream_failure",
|
|
76
|
+
operation: "credentials.rotate",
|
|
77
|
+
message: "Another worker is already refreshing this credential",
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
const current = await this.store.get(key);
|
|
81
|
+
if (current === undefined)
|
|
82
|
+
throw new SocialError({
|
|
83
|
+
code: "reconnect_required",
|
|
84
|
+
operation: "credentials.rotate",
|
|
85
|
+
message: "No credential is stored for this account",
|
|
86
|
+
});
|
|
87
|
+
const next = await refresh(current.value);
|
|
88
|
+
const saved = await this.store.compareAndSet({
|
|
89
|
+
key,
|
|
90
|
+
expectedRevision: current.revision,
|
|
91
|
+
value: next,
|
|
92
|
+
});
|
|
93
|
+
if (!saved.updated)
|
|
94
|
+
throw new SocialError({
|
|
95
|
+
code: "upstream_failure",
|
|
96
|
+
operation: "credentials.rotate",
|
|
97
|
+
message: "Credential changed while refreshing; do not repeat the provider request blindly",
|
|
98
|
+
});
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await this.lock.release(lease);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
delete(key) {
|
|
106
|
+
return this.store.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ConnectionAccount, ConnectionAttempt, ConnectionProvider } from "./connections.js";
|
|
2
|
+
export interface OAuthTokenSet {
|
|
3
|
+
readonly accessToken: string;
|
|
4
|
+
readonly refreshToken?: string;
|
|
5
|
+
readonly expiresAt?: string;
|
|
6
|
+
readonly scopes?: readonly string[];
|
|
7
|
+
readonly tokenType?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface OAuthCredentialSink {
|
|
10
|
+
save(input: {
|
|
11
|
+
readonly account: ConnectionAccount;
|
|
12
|
+
readonly token: OAuthTokenSet;
|
|
13
|
+
readonly attempt: ConnectionAttempt;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface OAuthProviderOptions {
|
|
17
|
+
readonly clientId: string;
|
|
18
|
+
readonly clientSecret?: string;
|
|
19
|
+
readonly redirectUri?: string;
|
|
20
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
21
|
+
readonly credentialSink?: OAuthCredentialSink;
|
|
22
|
+
/**
|
|
23
|
+
* Selects which validated discovered accounts receive persisted credentials.
|
|
24
|
+
* When omitted, credentials are persisted for every discovered account.
|
|
25
|
+
*/
|
|
26
|
+
readonly selectAccounts?: (accounts: readonly ConnectionAccount[], attempt: ConnectionAttempt) => readonly string[] | Promise<readonly string[]>;
|
|
27
|
+
/** Optional label for callers; the attempt's backend is always authoritative. */
|
|
28
|
+
readonly backend?: string;
|
|
29
|
+
readonly scopes?: readonly string[];
|
|
30
|
+
/** Explicit LinkedIn Marketing API version (YYYYMM). Required for LinkedIn OAuth. */
|
|
31
|
+
readonly linkedinApiVersion?: string;
|
|
32
|
+
/** Graph API version for Threads account discovery. */
|
|
33
|
+
readonly threadsApiVersion?: string;
|
|
34
|
+
/** Graph API version for Instagram Login account discovery. */
|
|
35
|
+
readonly instagramApiVersion?: string;
|
|
36
|
+
/** Maximum time for a single OAuth request. Defaults to ten seconds. */
|
|
37
|
+
readonly timeoutMs?: number;
|
|
38
|
+
/** Maximum response body size. Defaults to one MiB. */
|
|
39
|
+
readonly maxResponseBytes?: number;
|
|
40
|
+
}
|
|
41
|
+
type ProviderKind = "youtube" | "x" | "threads" | "tiktok" | "instagram" | "linkedin";
|
|
42
|
+
export declare function oauthProvider(kind: ProviderKind, options: OAuthProviderOptions): ConnectionProvider;
|
|
43
|
+
/** Exchange a short-lived Threads or Instagram Login token for a long-lived token. */
|
|
44
|
+
export declare function exchangeLongLivedOAuthToken(kind: "threads" | "instagram", options: OAuthProviderOptions, current: OAuthTokenSet): Promise<OAuthTokenSet>;
|
|
45
|
+
export declare const youtubeOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
46
|
+
export declare const xOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
47
|
+
export declare const threadsOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
48
|
+
export declare const tiktokOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
49
|
+
export declare const instagramOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
50
|
+
export declare const linkedinOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
51
|
+
export declare function refreshOAuthToken(kind: ProviderKind, options: OAuthProviderOptions, current: OAuthTokenSet): Promise<OAuthTokenSet>;
|
|
52
|
+
export {};
|