@outposted/node 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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/package.json +58 -0
- package/src/client.ts +91 -0
- package/src/errors.ts +214 -0
- package/src/index.ts +37 -0
- package/src/resources/api-keys.ts +157 -0
- package/src/resources/brands.ts +127 -0
- package/src/resources/connections.ts +115 -0
- package/src/resources/contents.ts +424 -0
- package/src/resources/deliveries.ts +186 -0
- package/src/resources/oauth.ts +165 -0
- package/src/resources/webhooks.ts +301 -0
- package/src/resources/workspaces.ts +70 -0
- package/src/transport.ts +198 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-06-03
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Resources: workspaces, brands, apiKeys, contents, connections, webhooks, deliveries, oauth
|
|
7
|
+
- HMAC-SHA256 signed webhook verification (Stripe-style)
|
|
8
|
+
- Automatic retry on 5xx and network errors
|
|
9
|
+
- TypeScript types throughout
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RC Negocios Digitais Ltda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# @outposted/node
|
|
2
|
+
|
|
3
|
+
Outposted Node.js client. Publish content to Instagram, Facebook, YouTube, LinkedIn, Threads from a single API call. Resilient HMAC-SHA256 signed webhooks with built-in verification.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @outposted/node
|
|
9
|
+
# or
|
|
10
|
+
npm install @outposted/node
|
|
11
|
+
# or
|
|
12
|
+
yarn add @outposted/node
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js >= 18 (uses native `fetch` and `node:crypto`).
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Outposted } from '@outposted/node';
|
|
21
|
+
|
|
22
|
+
const client = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
|
|
23
|
+
|
|
24
|
+
// Publish an Instagram image post
|
|
25
|
+
const result = await client.contents.create('ws_123', {
|
|
26
|
+
brand_id: 'brand_abc',
|
|
27
|
+
content_type: 'post',
|
|
28
|
+
target_platforms: ['instagram'],
|
|
29
|
+
content: {
|
|
30
|
+
caption: 'Hello from the SDK',
|
|
31
|
+
media: [{ type: 'image', url: 'https://cdn.example.com/cover.jpg' }],
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (result.status !== 'awaiting_media') {
|
|
36
|
+
console.log(`queued ${result.jobs.length} job(s)`);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The client validates the `opst_` prefix at construction. Resources are instantiated lazily — first property access builds the resource class, subsequent accesses return the cached instance.
|
|
41
|
+
|
|
42
|
+
### Options
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
new Outposted({
|
|
46
|
+
apiKey: 'opst_live_...', // required, must start with opst_
|
|
47
|
+
baseUrl: 'https://api.outposted.one', // optional override
|
|
48
|
+
fetch: customFetch, // optional fetch polyfill / interceptor
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The transport layer retries transient failures (5xx + network errors) up to 3 attempts with exponential backoff (250ms, 500ms, 1000ms). 4xx errors are never retried — they surface as typed errors immediately.
|
|
53
|
+
|
|
54
|
+
## Resources
|
|
55
|
+
|
|
56
|
+
### `workspaces`
|
|
57
|
+
|
|
58
|
+
The "who am I?" probe for the authenticated API key.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const workspace = await client.workspaces.me();
|
|
62
|
+
console.log(workspace.id, workspace.slug);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `brands`
|
|
66
|
+
|
|
67
|
+
A brand is a publishing identity inside a workspace. One brand can connect multiple platforms (e.g. 1 IG handle + 1 FB Page + 1 Threads).
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const brands = await client.brands.list('ws_123');
|
|
71
|
+
const brand = await client.brands.get('ws_123', 'brand_abc');
|
|
72
|
+
const fresh = await client.brands.create('ws_123', {
|
|
73
|
+
name: 'Coke',
|
|
74
|
+
slug: 'coke',
|
|
75
|
+
brand_type: 'product',
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `contents`
|
|
80
|
+
|
|
81
|
+
The core publishing surface. Create, list, fetch, cancel, and retry contents.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// Create + dispatch
|
|
85
|
+
await client.contents.create('ws_123', {
|
|
86
|
+
brand_id: 'brand_abc',
|
|
87
|
+
content_type: 'post',
|
|
88
|
+
target_platforms: ['instagram'],
|
|
89
|
+
content: { caption: '…', media: [{ type: 'image', url: '…' }] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Multi-connection disambiguation (brand has ≥2 LinkedIn connections)
|
|
93
|
+
await client.contents.create('ws_123', {
|
|
94
|
+
brand_id: 'brand_abc',
|
|
95
|
+
content_type: 'post',
|
|
96
|
+
target_platforms: ['linkedin'],
|
|
97
|
+
target_connections: ['conn_personal', 'conn_company'],
|
|
98
|
+
content: { text: 'Cross-posted to both surfaces' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const { items, total } = await client.contents.list('ws_123', { status: 'published', limit: 20 });
|
|
102
|
+
const detail = await client.contents.get('ws_123', 'cnt_abc');
|
|
103
|
+
await client.contents.cancel('ws_123', 'cnt_abc');
|
|
104
|
+
await client.contents.retry('ws_123', 'cnt_abc');
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `connections`
|
|
108
|
+
|
|
109
|
+
Build OAuth start URLs that the end user is redirected to in order to authorize a platform connection. The callback handler inside Outposted creates the `ConnectedAccount` row automatically.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const url = client.connections.getOAuthStartUrl('ws_123', 'brand_abc', 'instagram', {
|
|
113
|
+
returnTo: 'https://app.example.com/settings/connections',
|
|
114
|
+
});
|
|
115
|
+
res.redirect(url);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`list(workspaceId, brandId)` exists but throws today — the V0 API does not yet expose a list endpoint for connected accounts. Use `client.brands.get(...)` and inspect `brand.connected_accounts` in the meantime.
|
|
119
|
+
|
|
120
|
+
### `webhooks`
|
|
121
|
+
|
|
122
|
+
Register, list, update, delete webhook endpoints. **Capture `result.secret` immediately on create — the plaintext is shown only once.**
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const endpoint = await client.webhooks.create('ws_123', {
|
|
126
|
+
url: 'https://api.example.com/outposted-webhook',
|
|
127
|
+
enabled_events: ['content.published', 'content.failed'],
|
|
128
|
+
});
|
|
129
|
+
await secretManager.store('OUTPOSTED_WHSEC', endpoint.secret); // whsec_..., shown ONCE
|
|
130
|
+
|
|
131
|
+
const endpoints = await client.webhooks.list('ws_123');
|
|
132
|
+
await client.webhooks.update('ws_123', endpoint.id, { active: false });
|
|
133
|
+
await client.webhooks.delete('ws_123', endpoint.id);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`webhooks.verify(rawBody, header, secret)` is the inbound verifier — see the dedicated section below.
|
|
137
|
+
|
|
138
|
+
### `deliveries`
|
|
139
|
+
|
|
140
|
+
Inspect webhook delivery history and manually retry stuck deliveries.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const page = await client.deliveries.list('ws_123', 'wh_456', {
|
|
144
|
+
status: 'failed',
|
|
145
|
+
since: '2026-06-01T00:00:00.000Z',
|
|
146
|
+
limit: 50,
|
|
147
|
+
});
|
|
148
|
+
for (const delivery of page.items) {
|
|
149
|
+
console.log(delivery.id, delivery.last_response_status);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await client.deliveries.redeliver('ws_123', 'del_789');
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `oauth`
|
|
156
|
+
|
|
157
|
+
Pure URL builders — no HTTP I/O. The customer redirects the end user's browser to the returned URL; the platform handshake completes server-side and persists the `ConnectedAccount`.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const ig = client.oauth.startInstagram({ brandId: 'brand_abc' });
|
|
161
|
+
const fb = client.oauth.startFacebook({
|
|
162
|
+
brandId: 'brand_abc',
|
|
163
|
+
returnTo: 'https://app.example.com/connected',
|
|
164
|
+
});
|
|
165
|
+
const yt = client.oauth.startYouTube({ brandId: 'brand_abc' });
|
|
166
|
+
const th = client.oauth.startThreads({ brandId: 'brand_abc' });
|
|
167
|
+
const li = client.oauth.startLinkedIn({ brandId: 'brand_abc', accountType: 'page' });
|
|
168
|
+
|
|
169
|
+
res.redirect(ig);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `apiKeys`
|
|
173
|
+
|
|
174
|
+
List API key metadata and rotate (create + optionally revoke others). The plaintext key is returned exactly once on `rotate()`.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const keys = await client.apiKeys.list('ws_123');
|
|
178
|
+
|
|
179
|
+
// Full rotation hygiene: create a fresh key + revoke every other active key atomically
|
|
180
|
+
const result = await client.apiKeys.rotate('ws_123', {
|
|
181
|
+
name: 'post-leak',
|
|
182
|
+
revoke_others: true,
|
|
183
|
+
});
|
|
184
|
+
await secretStore.put('OUTPOSTED_API_KEY', result.api_key); // plaintext, shown ONCE
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Webhook verification
|
|
188
|
+
|
|
189
|
+
The SDK ships `webhooks.verify()` so consumers don't need a second package. It's byte-identical to the signing path used by the API + worker.
|
|
190
|
+
|
|
191
|
+
`rawBody` MUST be the exact bytes of the request body — any reformatting (key re-ordering, whitespace normalization) breaks the HMAC. Capture the raw stream BEFORE any JSON parser touches it.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import express from 'express';
|
|
195
|
+
import { Outposted } from '@outposted/node';
|
|
196
|
+
|
|
197
|
+
const client = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
|
|
198
|
+
const secret = process.env.OUTPOSTED_WHSEC!; // saved from webhooks.create()
|
|
199
|
+
|
|
200
|
+
const app = express();
|
|
201
|
+
|
|
202
|
+
app.post('/outposted-webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
203
|
+
const rawBody = req.body.toString('utf8');
|
|
204
|
+
const sig = req.header('x-outposted-signature');
|
|
205
|
+
const result = client.webhooks.verify(rawBody, sig, secret);
|
|
206
|
+
|
|
207
|
+
if (!result.ok) {
|
|
208
|
+
const status =
|
|
209
|
+
result.code === 'signature_mismatch'
|
|
210
|
+
? 401
|
|
211
|
+
: result.code === 'timestamp_out_of_tolerance'
|
|
212
|
+
? 408
|
|
213
|
+
: 400;
|
|
214
|
+
return res.status(status).json({ error: result.code, detail: result.detail });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const event = JSON.parse(rawBody);
|
|
218
|
+
// … react to event.type, event.data …
|
|
219
|
+
res.status(200).end();
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`verify()` returns a discriminated union:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
type VerifyResult =
|
|
227
|
+
| { ok: true; timestamp: number }
|
|
228
|
+
| { ok: false; code: 'missing'; detail: string }
|
|
229
|
+
| { ok: false; code: 'malformed'; detail: string }
|
|
230
|
+
| { ok: false; code: 'unsupported_version'; detail: string }
|
|
231
|
+
| { ok: false; code: 'timestamp_out_of_tolerance'; detail: string }
|
|
232
|
+
| { ok: false; code: 'signature_mismatch'; detail: string };
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| `code` | HTTP suggestion |
|
|
236
|
+
| ---------------------------- | --------------- |
|
|
237
|
+
| `ok: true` | 200 OK |
|
|
238
|
+
| `missing` / `malformed` | 400 |
|
|
239
|
+
| `unsupported_version` | 400 |
|
|
240
|
+
| `timestamp_out_of_tolerance` | 408 |
|
|
241
|
+
| `signature_mismatch` | 401 |
|
|
242
|
+
|
|
243
|
+
Default replay tolerance is 300s (Stripe-compatible). Override with `client.webhooks.verify(body, sig, secret, { toleranceSeconds: 600 })`.
|
|
244
|
+
|
|
245
|
+
## Errors
|
|
246
|
+
|
|
247
|
+
Every HTTP error surfaces as a typed `OutpostedError` subclass. Branch with `instanceof`.
|
|
248
|
+
|
|
249
|
+
| Class | Fires on |
|
|
250
|
+
| -------------------------- | --------------------------------------------------------------- |
|
|
251
|
+
| `OutpostedAuthError` | 401 — API key missing, invalid, or revoked. |
|
|
252
|
+
| `OutpostedForbiddenError` | 403 — key is valid but lacks permission for the resource. |
|
|
253
|
+
| `OutpostedNotFoundError` | 404 — resource does not exist or belongs to another tenant. |
|
|
254
|
+
| `OutpostedConflictError` | 409 — slug clash, idempotency replay, `cancel_too_late`, etc. |
|
|
255
|
+
| `OutpostedValidationError` | 400 / 422 — payload shape invalid; carries `issues[]`. |
|
|
256
|
+
| `OutpostedRateLimitError` | 429 — carries `retryAfter` (seconds) parsed from `Retry-After`. |
|
|
257
|
+
| `OutpostedServerError` | 5xx — automatically retried 2× before surfacing. |
|
|
258
|
+
| `OutpostedNetworkError` | DNS failure, connection refused, abort, timeout — no response. |
|
|
259
|
+
| `OutpostedError` | Base class — catches any of the above. |
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import {
|
|
263
|
+
Outposted,
|
|
264
|
+
OutpostedAuthError,
|
|
265
|
+
OutpostedRateLimitError,
|
|
266
|
+
OutpostedValidationError,
|
|
267
|
+
} from '@outposted/node';
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await client.contents.create('ws_123', input);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err instanceof OutpostedAuthError) {
|
|
273
|
+
// rotate the key
|
|
274
|
+
} else if (err instanceof OutpostedRateLimitError) {
|
|
275
|
+
console.log(`rate limited — retry in ${err.retryAfter}s`);
|
|
276
|
+
} else if (err instanceof OutpostedValidationError) {
|
|
277
|
+
for (const issue of err.issues) {
|
|
278
|
+
console.error(issue.path, issue.message);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Every error carries `status`, `code`, `requestId` (X-Outposted-Request-ID, for support correlation), `body`, and `headers`.
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
MIT
|
|
291
|
+
|
|
292
|
+
## Reference
|
|
293
|
+
|
|
294
|
+
Full API docs: <https://docs.outposted.one>
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@outposted/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Outposted Node.js client — publish to Instagram, Facebook, YouTube, LinkedIn, Threads from one API call.",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"outposted",
|
|
16
|
+
"social-media",
|
|
17
|
+
"instagram",
|
|
18
|
+
"facebook",
|
|
19
|
+
"youtube",
|
|
20
|
+
"linkedin",
|
|
21
|
+
"threads",
|
|
22
|
+
"webhooks",
|
|
23
|
+
"api",
|
|
24
|
+
"sdk",
|
|
25
|
+
"publishing"
|
|
26
|
+
],
|
|
27
|
+
"homepage": "https://docs.outposted.one",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Studio-1103/outposted.git",
|
|
31
|
+
"directory": "packages/sdk-node"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:watch": "vitest",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"lint": "eslint src __tests__ --max-warnings 0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@outposted/domain": "workspace:*",
|
|
48
|
+
"@outposted/webhooks": "workspace:*"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22",
|
|
52
|
+
"typescript": "^5",
|
|
53
|
+
"vitest": "^2",
|
|
54
|
+
"eslint": "^9",
|
|
55
|
+
"typescript-eslint": "^8",
|
|
56
|
+
"@eslint/js": "^9"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// @outposted/node/client — Stripe-style multi-resource client.
|
|
3
|
+
//
|
|
4
|
+
// import { Outposted } from '@outposted/node';
|
|
5
|
+
// const client = new Outposted({ apiKey: 'opst_live_...' });
|
|
6
|
+
// await client.contents.create(workspaceId, { ... });
|
|
7
|
+
//
|
|
8
|
+
// API key prefix is validated at construction (opst_*). Resources are
|
|
9
|
+
// instantiated lazily — the first property access builds the resource class,
|
|
10
|
+
// subsequent accesses return the cached instance. This keeps cold start cheap
|
|
11
|
+
// for callers that touch only one resource.
|
|
12
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
import { HttpClient, type FetchLike } from './transport';
|
|
15
|
+
import { ApiKeysResource } from './resources/api-keys';
|
|
16
|
+
import { BrandsResource } from './resources/brands';
|
|
17
|
+
import { ConnectionsResource } from './resources/connections';
|
|
18
|
+
import { ContentsResource } from './resources/contents';
|
|
19
|
+
import { DeliveriesResource } from './resources/deliveries';
|
|
20
|
+
import { OAuthResource } from './resources/oauth';
|
|
21
|
+
import { WebhooksResource } from './resources/webhooks';
|
|
22
|
+
import { WorkspacesResource } from './resources/workspaces';
|
|
23
|
+
|
|
24
|
+
export interface OutpostedOptions {
|
|
25
|
+
apiKey: string;
|
|
26
|
+
baseUrl?: string;
|
|
27
|
+
fetch?: FetchLike;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const API_KEY_PREFIX = 'opst_';
|
|
31
|
+
|
|
32
|
+
export class Outposted {
|
|
33
|
+
private readonly http: HttpClient;
|
|
34
|
+
|
|
35
|
+
private _workspaces?: WorkspacesResource;
|
|
36
|
+
private _brands?: BrandsResource;
|
|
37
|
+
private _apiKeys?: ApiKeysResource;
|
|
38
|
+
private _contents?: ContentsResource;
|
|
39
|
+
private _connections?: ConnectionsResource;
|
|
40
|
+
private _webhooks?: WebhooksResource;
|
|
41
|
+
private _deliveries?: DeliveriesResource;
|
|
42
|
+
private _oauth?: OAuthResource;
|
|
43
|
+
|
|
44
|
+
constructor(options: OutpostedOptions) {
|
|
45
|
+
if (!options || typeof options.apiKey !== 'string' || options.apiKey.trim() === '') {
|
|
46
|
+
throw new Error('Outposted: apiKey is required.');
|
|
47
|
+
}
|
|
48
|
+
if (!options.apiKey.startsWith(API_KEY_PREFIX)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Outposted: apiKey must start with "${API_KEY_PREFIX}" (got "${options.apiKey.slice(0, 8)}...").`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
this.http = new HttpClient({
|
|
54
|
+
apiKey: options.apiKey,
|
|
55
|
+
baseUrl: options.baseUrl,
|
|
56
|
+
fetch: options.fetch,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get workspaces(): WorkspacesResource {
|
|
61
|
+
return (this._workspaces ??= new WorkspacesResource(this.http));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get brands(): BrandsResource {
|
|
65
|
+
return (this._brands ??= new BrandsResource(this.http));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get apiKeys(): ApiKeysResource {
|
|
69
|
+
return (this._apiKeys ??= new ApiKeysResource(this.http));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get contents(): ContentsResource {
|
|
73
|
+
return (this._contents ??= new ContentsResource(this.http));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get connections(): ConnectionsResource {
|
|
77
|
+
return (this._connections ??= new ConnectionsResource(this.http));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get webhooks(): WebhooksResource {
|
|
81
|
+
return (this._webhooks ??= new WebhooksResource(this.http));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get deliveries(): DeliveriesResource {
|
|
85
|
+
return (this._deliveries ??= new DeliveriesResource(this.http));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get oauth(): OAuthResource {
|
|
89
|
+
return (this._oauth ??= new OAuthResource(this.http));
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// @outposted/node/errors — error hierarchy for SDK consumers.
|
|
3
|
+
//
|
|
4
|
+
// Shape mirrors Stripe SDK: one base class (OutpostedError) with discriminated
|
|
5
|
+
// subclasses per HTTP semantic. Consumers `instanceof` to branch.
|
|
6
|
+
//
|
|
7
|
+
// Static factory `OutpostedError.fromResponse(status, body, headers, requestId)`
|
|
8
|
+
// is the canonical mapping point from raw HTTP responses to the right subclass.
|
|
9
|
+
// Transport calls it on any 4xx/5xx; consumers don't.
|
|
10
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface OutpostedErrorBody {
|
|
13
|
+
error?: {
|
|
14
|
+
code?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
issues?: unknown;
|
|
17
|
+
};
|
|
18
|
+
message?: string;
|
|
19
|
+
code?: string;
|
|
20
|
+
issues?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OutpostedErrorOptions {
|
|
24
|
+
status?: number;
|
|
25
|
+
code?: string;
|
|
26
|
+
requestId?: string;
|
|
27
|
+
body?: OutpostedErrorBody | null;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Base class for every error thrown by the Outposted SDK. Carries the HTTP
|
|
33
|
+
* status (when there was a response), our internal error code, the parsed
|
|
34
|
+
* body, and the X-Outposted-Request-ID header for support correlation.
|
|
35
|
+
*/
|
|
36
|
+
export class OutpostedError extends Error {
|
|
37
|
+
public readonly status?: number;
|
|
38
|
+
public readonly code?: string;
|
|
39
|
+
public readonly requestId?: string;
|
|
40
|
+
public readonly body?: OutpostedErrorBody | null;
|
|
41
|
+
public readonly headers?: Record<string, string>;
|
|
42
|
+
|
|
43
|
+
constructor(message: string, options: OutpostedErrorOptions = {}) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'OutpostedError';
|
|
46
|
+
this.status = options.status;
|
|
47
|
+
this.code = options.code;
|
|
48
|
+
this.requestId = options.requestId;
|
|
49
|
+
this.body = options.body ?? null;
|
|
50
|
+
this.headers = options.headers;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map an HTTP response onto the most specific OutpostedError subclass.
|
|
55
|
+
* Called by the transport layer on every non-2xx response.
|
|
56
|
+
*/
|
|
57
|
+
static fromResponse(
|
|
58
|
+
status: number,
|
|
59
|
+
body: OutpostedErrorBody | null,
|
|
60
|
+
headers: Record<string, string>,
|
|
61
|
+
requestId: string | undefined,
|
|
62
|
+
): OutpostedError {
|
|
63
|
+
const message = extractMessage(body) ?? `Outposted API error (HTTP ${status})`;
|
|
64
|
+
const code = extractCode(body);
|
|
65
|
+
const base = { status, code, requestId, body, headers };
|
|
66
|
+
|
|
67
|
+
if (status === 401) {
|
|
68
|
+
return new OutpostedAuthError(message, base);
|
|
69
|
+
}
|
|
70
|
+
if (status === 403) {
|
|
71
|
+
return new OutpostedForbiddenError(message, base);
|
|
72
|
+
}
|
|
73
|
+
if (status === 404) {
|
|
74
|
+
return new OutpostedNotFoundError(message, base);
|
|
75
|
+
}
|
|
76
|
+
if (status === 409) {
|
|
77
|
+
return new OutpostedConflictError(message, base);
|
|
78
|
+
}
|
|
79
|
+
if (status === 422 || status === 400) {
|
|
80
|
+
const issues = extractIssues(body);
|
|
81
|
+
return new OutpostedValidationError(message, { ...base, issues });
|
|
82
|
+
}
|
|
83
|
+
if (status === 429) {
|
|
84
|
+
const retryAfter = parseRetryAfter(headers);
|
|
85
|
+
return new OutpostedRateLimitError(message, { ...base, retryAfter });
|
|
86
|
+
}
|
|
87
|
+
if (status >= 500) {
|
|
88
|
+
return new OutpostedServerError(message, base);
|
|
89
|
+
}
|
|
90
|
+
return new OutpostedError(message, base);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class OutpostedAuthError extends OutpostedError {
|
|
95
|
+
constructor(message: string, options: OutpostedErrorOptions = {}) {
|
|
96
|
+
super(message, options);
|
|
97
|
+
this.name = 'OutpostedAuthError';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class OutpostedForbiddenError extends OutpostedError {
|
|
102
|
+
constructor(message: string, options: OutpostedErrorOptions = {}) {
|
|
103
|
+
super(message, options);
|
|
104
|
+
this.name = 'OutpostedForbiddenError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class OutpostedNotFoundError extends OutpostedError {
|
|
109
|
+
constructor(message: string, options: OutpostedErrorOptions = {}) {
|
|
110
|
+
super(message, options);
|
|
111
|
+
this.name = 'OutpostedNotFoundError';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class OutpostedConflictError extends OutpostedError {
|
|
116
|
+
constructor(message: string, options: OutpostedErrorOptions = {}) {
|
|
117
|
+
super(message, options);
|
|
118
|
+
this.name = 'OutpostedConflictError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ValidationIssue {
|
|
123
|
+
path?: (string | number)[];
|
|
124
|
+
message?: string;
|
|
125
|
+
code?: string;
|
|
126
|
+
[k: string]: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface OutpostedValidationErrorOptions extends OutpostedErrorOptions {
|
|
130
|
+
issues?: ValidationIssue[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class OutpostedValidationError extends OutpostedError {
|
|
134
|
+
public readonly issues: ValidationIssue[];
|
|
135
|
+
|
|
136
|
+
constructor(message: string, options: OutpostedValidationErrorOptions = {}) {
|
|
137
|
+
super(message, options);
|
|
138
|
+
this.name = 'OutpostedValidationError';
|
|
139
|
+
this.issues = options.issues ?? [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface OutpostedRateLimitErrorOptions extends OutpostedErrorOptions {
|
|
144
|
+
retryAfter?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class OutpostedRateLimitError extends OutpostedError {
|
|
148
|
+
/** Seconds to wait before retrying, parsed from Retry-After header. */
|
|
149
|
+
public readonly retryAfter?: number;
|
|
150
|
+
|
|
151
|
+
constructor(message: string, options: OutpostedRateLimitErrorOptions = {}) {
|
|
152
|
+
super(message, options);
|
|
153
|
+
this.name = 'OutpostedRateLimitError';
|
|
154
|
+
this.retryAfter = options.retryAfter;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class OutpostedServerError extends OutpostedError {
|
|
159
|
+
constructor(message: string, options: OutpostedErrorOptions = {}) {
|
|
160
|
+
super(message, options);
|
|
161
|
+
this.name = 'OutpostedServerError';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Thrown when the request never produced an HTTP response — DNS failure,
|
|
167
|
+
* connection refused, fetch timeout, abort, etc. No status, no body.
|
|
168
|
+
*/
|
|
169
|
+
export class OutpostedNetworkError extends OutpostedError {
|
|
170
|
+
public readonly cause?: unknown;
|
|
171
|
+
|
|
172
|
+
constructor(message: string, cause?: unknown) {
|
|
173
|
+
super(message);
|
|
174
|
+
this.name = 'OutpostedNetworkError';
|
|
175
|
+
this.cause = cause;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function extractMessage(body: OutpostedErrorBody | null): string | undefined {
|
|
182
|
+
if (!body) return undefined;
|
|
183
|
+
if (body.error?.message) return body.error.message;
|
|
184
|
+
if (body.message) return body.message;
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractCode(body: OutpostedErrorBody | null): string | undefined {
|
|
189
|
+
if (!body) return undefined;
|
|
190
|
+
if (body.error?.code) return body.error.code;
|
|
191
|
+
if (body.code) return body.code;
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractIssues(body: OutpostedErrorBody | null): ValidationIssue[] {
|
|
196
|
+
if (!body) return [];
|
|
197
|
+
const raw = body.error?.issues ?? body.issues;
|
|
198
|
+
if (!Array.isArray(raw)) return [];
|
|
199
|
+
return raw.filter((x): x is ValidationIssue => typeof x === 'object' && x !== null);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseRetryAfter(headers: Record<string, string>): number | undefined {
|
|
203
|
+
const raw = headers['retry-after'];
|
|
204
|
+
if (!raw) return undefined;
|
|
205
|
+
const asInt = Number(raw);
|
|
206
|
+
if (Number.isFinite(asInt) && asInt >= 0) return asInt;
|
|
207
|
+
// RFC 7231 also allows HTTP-date — convert to seconds-from-now.
|
|
208
|
+
const asDate = Date.parse(raw);
|
|
209
|
+
if (Number.isFinite(asDate)) {
|
|
210
|
+
const delta = Math.ceil((asDate - Date.now()) / 1000);
|
|
211
|
+
return delta > 0 ? delta : 0;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|