@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.
@@ -0,0 +1,165 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/resources/oauth — OAuth start URL builder.
3
+ //
4
+ // This resource is intentionally **pure**: every method returns a URL string
5
+ // and performs NO HTTP I/O. The customer redirects the end user's browser to
6
+ // the returned URL; the user authorizes on the platform's site; the OAuth
7
+ // callback inside Outposted API (see apps/api/src/app/api/v1/oauth/*/callback)
8
+ // completes the handshake and persists the ConnectedAccount.
9
+ //
10
+ // Shape (per platform):
11
+ // `${baseUrl}/api/v1/oauth/{platform}/start?brand_id=...&[other params]`
12
+ //
13
+ // Notes:
14
+ // - `brand_id` is always required — a ConnectedAccount belongs to a brand.
15
+ // - `workspace_id` is optional. When omitted, the API resolves it from the
16
+ // authenticated session/API key.
17
+ // - `return_to` is optional. After a successful callback, Outposted will
18
+ // redirect the browser to this URL. Caller passes a raw URL string; the
19
+ // SDK URL-encodes it.
20
+ // - LinkedIn supports `account_type` (`personal` | `page`) to pre-select the
21
+ // identity flow on the LinkedIn authorize screen.
22
+ //
23
+ // Why a URL builder and not a `POST /start` wrapper:
24
+ // The OAuth start endpoints expose GET semantics — the call IS the redirect.
25
+ // Wrapping them in HTTP would mean fetching the redirect ourselves and
26
+ // re-emitting it, which doesn't work for browser-driven OAuth flows.
27
+ // ────────────────────────────────────────────────────────────────────────────
28
+
29
+ import type { HttpClient } from '../transport';
30
+
31
+ /** Common args shared across every `start*` method. */
32
+ interface OAuthStartArgs {
33
+ /** Brand the resulting ConnectedAccount will belong to. Required. */
34
+ brandId: string;
35
+ /** Workspace scope. Optional — the API resolves it from the API key when omitted. */
36
+ workspaceId?: string;
37
+ /** Absolute URL the browser is redirected to after a successful callback. */
38
+ returnTo?: string;
39
+ }
40
+
41
+ /** LinkedIn-specific args: pre-select personal vs page identity. */
42
+ export interface OAuthLinkedInStartArgs extends OAuthStartArgs {
43
+ /**
44
+ * Pre-select the LinkedIn identity type at the authorize screen.
45
+ * - `personal` — connect as the authenticated member.
46
+ * - `page` — connect as a Company Page the member administers.
47
+ */
48
+ accountType?: 'personal' | 'page';
49
+ }
50
+
51
+ /**
52
+ * Builds OAuth start URLs for every supported platform.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import express from 'express';
57
+ * import { Outposted } from '@outposted/node';
58
+ *
59
+ * const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
60
+ * const app = express();
61
+ *
62
+ * app.get('/connect/instagram', (req, res) => {
63
+ * const url = outposted.oauth.startInstagram({ brandId: 'brand_abc' });
64
+ * res.redirect(url);
65
+ * });
66
+ * ```
67
+ */
68
+ export class OAuthResource {
69
+ constructor(private readonly http: HttpClient) {}
70
+
71
+ /**
72
+ * Build the Instagram OAuth start URL.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const url = client.oauth.startInstagram({ brandId: 'brand_abc' });
77
+ * res.redirect(url);
78
+ * ```
79
+ */
80
+ startInstagram(args: OAuthStartArgs): string {
81
+ return this.build('instagram', args);
82
+ }
83
+
84
+ /**
85
+ * Build the Facebook OAuth start URL.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const url = client.oauth.startFacebook({
90
+ * brandId: 'brand_abc',
91
+ * returnTo: 'https://app.example.com/connected',
92
+ * });
93
+ * res.redirect(url);
94
+ * ```
95
+ */
96
+ startFacebook(args: OAuthStartArgs): string {
97
+ return this.build('facebook', args);
98
+ }
99
+
100
+ /**
101
+ * Build the Threads OAuth start URL.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const url = client.oauth.startThreads({ brandId: 'brand_abc' });
106
+ * res.redirect(url);
107
+ * ```
108
+ */
109
+ startThreads(args: OAuthStartArgs): string {
110
+ return this.build('threads', args);
111
+ }
112
+
113
+ /**
114
+ * Build the YouTube OAuth start URL.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * const url = client.oauth.startYouTube({ brandId: 'brand_abc' });
119
+ * res.redirect(url);
120
+ * ```
121
+ */
122
+ startYouTube(args: OAuthStartArgs): string {
123
+ return this.build('youtube', args);
124
+ }
125
+
126
+ /**
127
+ * Build the LinkedIn OAuth start URL. Pass `accountType` to pre-select the
128
+ * personal vs page authorize flow.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * const url = client.oauth.startLinkedIn({
133
+ * brandId: 'brand_abc',
134
+ * accountType: 'page',
135
+ * });
136
+ * res.redirect(url);
137
+ * ```
138
+ */
139
+ startLinkedIn(args: OAuthLinkedInStartArgs): string {
140
+ const url = new URL(`${this.http.baseUrl}/api/v1/oauth/linkedin/start`);
141
+ appendCommon(url, args);
142
+ if (args.accountType !== undefined) {
143
+ url.searchParams.set('account_type', args.accountType);
144
+ }
145
+ return url.toString();
146
+ }
147
+
148
+ // ── internal ─────────────────────────────────────────────────────────────
149
+
150
+ private build(platform: 'instagram' | 'facebook' | 'threads' | 'youtube', args: OAuthStartArgs): string {
151
+ const url = new URL(`${this.http.baseUrl}/api/v1/oauth/${platform}/start`);
152
+ appendCommon(url, args);
153
+ return url.toString();
154
+ }
155
+ }
156
+
157
+ function appendCommon(url: URL, args: OAuthStartArgs): void {
158
+ url.searchParams.set('brand_id', args.brandId);
159
+ if (args.workspaceId !== undefined) {
160
+ url.searchParams.set('workspace_id', args.workspaceId);
161
+ }
162
+ if (args.returnTo !== undefined) {
163
+ url.searchParams.set('return_to', args.returnTo);
164
+ }
165
+ }
@@ -0,0 +1,301 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/resources/webhooks — outbound webhook endpoints (E2).
3
+ //
4
+ // A webhook endpoint is a HTTPS URL registered against a workspace that
5
+ // receives signed POSTs when domain events fire (content.published,
6
+ // content.failed, …). The workspace's `whsec_*` secret is generated server-side
7
+ // at create-time and returned ONCE in the response body — losing it means
8
+ // rotating to a new endpoint. The DB only stores the ciphertext.
9
+ //
10
+ // Endpoints proxied here (see apps/api/src/app/api/v1/workspaces/[wsId]/webhooks):
11
+ // - POST /v1/workspaces/:wsId/webhooks → register endpoint
12
+ // - GET /v1/workspaces/:wsId/webhooks → list endpoints
13
+ // - GET /v1/workspaces/:wsId/webhooks/:id → fetch one
14
+ // - PATCH /v1/workspaces/:wsId/webhooks/:id → toggle/update
15
+ // - DELETE /v1/workspaces/:wsId/webhooks/:id → hard delete
16
+ //
17
+ // `verify()` delegates straight to `@outposted/webhooks/verifyPayload` — it's
18
+ // the same code path the API + worker use to sign on the way out, so customers
19
+ // can reuse it for inbound verification without installing a second package.
20
+ // ────────────────────────────────────────────────────────────────────────────
21
+
22
+ import { verifyPayload, type VerifyResult } from '@outposted/webhooks';
23
+ import type { HttpClient } from '../transport';
24
+
25
+ export type { VerifyResult };
26
+
27
+ /**
28
+ * Payload accepted by `WebhooksResource.create`. Only `url` is required —
29
+ * `description` is a free-form label and `enabled_events` is the subscription
30
+ * filter (omit/null = receive every event type).
31
+ */
32
+ export interface CreateWebhookInput {
33
+ /** Absolute HTTPS URL the worker should POST to (HTTP allowed in dev only). */
34
+ url: string;
35
+ /** Optional human-readable label (max 200 chars). */
36
+ description?: string | null;
37
+ /**
38
+ * Event types the endpoint subscribes to (e.g. `['content.published']`).
39
+ * Omit or pass null to receive every event type the workspace emits.
40
+ */
41
+ enabled_events?: string[] | null;
42
+ }
43
+
44
+ /**
45
+ * Webhook endpoint as returned by list/get/update. Does NOT contain the
46
+ * plaintext secret — the API never returns it after creation.
47
+ */
48
+ export interface Webhook {
49
+ id: string;
50
+ workspace_id: string;
51
+ url: string;
52
+ description: string | null;
53
+ enabled_events: string[] | null;
54
+ active: boolean;
55
+ last_delivery_at?: string | null;
56
+ last_delivery_status?: string | null;
57
+ created_at: string;
58
+ }
59
+
60
+ /**
61
+ * Response from `WebhooksResource.create`. Identical to `Webhook` but with the
62
+ * plaintext `secret` field populated — capture it immediately, it will never
63
+ * appear in any other response.
64
+ */
65
+ export interface CreatedWebhook extends Webhook {
66
+ /**
67
+ * Plaintext signing secret in `whsec_...` shape. Shown exactly ONCE at
68
+ * creation — the API stores only the encrypted form. Save it to your secret
69
+ * manager before discarding the response object.
70
+ */
71
+ secret: string;
72
+ }
73
+
74
+ /**
75
+ * Patch body accepted by `WebhooksResource.update`. Every field is optional;
76
+ * at least one must be provided or the API responds 400.
77
+ */
78
+ export interface UpdateWebhookInput {
79
+ url?: string;
80
+ description?: string | null;
81
+ enabled_events?: string[] | null;
82
+ active?: boolean;
83
+ }
84
+
85
+ /**
86
+ * Options for `WebhooksResource.verify`. Forwarded straight to
87
+ * `verifyPayload` from `@outposted/webhooks`.
88
+ */
89
+ export interface VerifyOptions {
90
+ /** Replay window in seconds (default 300 = 5 min, Stripe-compatible). */
91
+ toleranceSeconds?: number;
92
+ }
93
+
94
+ /**
95
+ * SDK accessor for the `/v1/workspaces/:wsId/webhooks` collection.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
100
+ * const endpoint = await outposted.webhooks.create('ws_123', {
101
+ * url: 'https://api.example.com/outposted-webhook',
102
+ * enabled_events: ['content.published', 'content.failed'],
103
+ * });
104
+ * console.log(endpoint.secret); // 'whsec_...' — store this NOW
105
+ * ```
106
+ */
107
+ export class WebhooksResource {
108
+ constructor(private readonly http: HttpClient) {}
109
+
110
+ /**
111
+ * Register a webhook endpoint inside a workspace. The API generates a fresh
112
+ * `whsec_*` secret server-side and returns it in the response body —
113
+ * **this is the only time the plaintext appears**, so capture it before
114
+ * discarding the response. Subsequent list/get calls never expose it.
115
+ *
116
+ * Throws `OutpostedValidationError` (400) on bad URL or unknown event types,
117
+ * and `OutpostedConflictError` (409) once the workspace hits the 25-endpoint
118
+ * cap.
119
+ *
120
+ * @example curl
121
+ * ```bash
122
+ * curl -X POST -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
123
+ * -H "Content-Type: application/json" \
124
+ * -d '{"url":"https://example.com/hook","enabled_events":["content.published"]}' \
125
+ * https://api.outposted.one/api/v1/workspaces/ws_123/webhooks
126
+ * ```
127
+ *
128
+ * @example SDK
129
+ * ```ts
130
+ * const endpoint = await outposted.webhooks.create('ws_123', {
131
+ * url: 'https://example.com/hook',
132
+ * enabled_events: ['content.published'],
133
+ * });
134
+ * await secretManager.store('OUTPOSTED_WHSEC', endpoint.secret); // ONLY chance
135
+ * ```
136
+ */
137
+ async create(workspaceId: string, input: CreateWebhookInput): Promise<CreatedWebhook> {
138
+ return this.http.request<CreatedWebhook>({
139
+ method: 'POST',
140
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/webhooks`,
141
+ body: input,
142
+ });
143
+ }
144
+
145
+ /**
146
+ * List every webhook endpoint registered against the workspace. The plaintext
147
+ * secret is NEVER included — it only exists in the `create` response.
148
+ *
149
+ * @example curl
150
+ * ```bash
151
+ * curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
152
+ * https://api.outposted.one/api/v1/workspaces/ws_123/webhooks
153
+ * ```
154
+ *
155
+ * @example SDK
156
+ * ```ts
157
+ * const endpoints = await outposted.webhooks.list('ws_123');
158
+ * for (const ep of endpoints) {
159
+ * console.log(ep.url, ep.active);
160
+ * }
161
+ * ```
162
+ */
163
+ async list(workspaceId: string): Promise<Webhook[]> {
164
+ const { data } = await this.http.request<{ data: Webhook[] }>({
165
+ method: 'GET',
166
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/webhooks`,
167
+ });
168
+ return data;
169
+ }
170
+
171
+ /**
172
+ * Fetch a single webhook endpoint by id. Throws `OutpostedNotFoundError`
173
+ * (404) when the endpoint doesn't exist or belongs to a different workspace.
174
+ *
175
+ * @example curl
176
+ * ```bash
177
+ * curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
178
+ * https://api.outposted.one/api/v1/workspaces/ws_123/webhooks/whep_abc
179
+ * ```
180
+ *
181
+ * @example SDK
182
+ * ```ts
183
+ * const endpoint = await outposted.webhooks.get('ws_123', 'whep_abc');
184
+ * ```
185
+ */
186
+ async get(workspaceId: string, webhookId: string): Promise<Webhook> {
187
+ return this.http.request<Webhook>({
188
+ method: 'GET',
189
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/webhooks/${encodeURIComponent(webhookId)}`,
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Patch an existing endpoint. At least one field must be present in `patch`
195
+ * — sending an empty object responds 400. Toggling `active: false` pauses
196
+ * deliveries without losing the endpoint row.
197
+ *
198
+ * @example curl
199
+ * ```bash
200
+ * curl -X PATCH -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
201
+ * -H "Content-Type: application/json" \
202
+ * -d '{"active":false}' \
203
+ * https://api.outposted.one/api/v1/workspaces/ws_123/webhooks/whep_abc
204
+ * ```
205
+ *
206
+ * @example SDK
207
+ * ```ts
208
+ * await outposted.webhooks.update('ws_123', 'whep_abc', { active: false });
209
+ * ```
210
+ */
211
+ async update(workspaceId: string, webhookId: string, patch: UpdateWebhookInput): Promise<Webhook> {
212
+ return this.http.request<Webhook>({
213
+ method: 'PATCH',
214
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/webhooks/${encodeURIComponent(webhookId)}`,
215
+ body: patch,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Hard-delete an endpoint. Idempotent from the caller's perspective —
221
+ * deleting an already-gone endpoint responds 404 (surfaced as
222
+ * `OutpostedNotFoundError`).
223
+ *
224
+ * @example curl
225
+ * ```bash
226
+ * curl -X DELETE -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
227
+ * https://api.outposted.one/api/v1/workspaces/ws_123/webhooks/whep_abc
228
+ * ```
229
+ *
230
+ * @example SDK
231
+ * ```ts
232
+ * await outposted.webhooks.delete('ws_123', 'whep_abc');
233
+ * ```
234
+ */
235
+ async delete(workspaceId: string, webhookId: string): Promise<void> {
236
+ await this.http.request<undefined>({
237
+ method: 'DELETE',
238
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/webhooks/${encodeURIComponent(webhookId)}`,
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Verify an inbound signed delivery using the workspace's `whsec_*` secret
244
+ * (the one returned ONCE by `create`). Delegates to
245
+ * `verifyPayload` from `@outposted/webhooks` so the verification code is
246
+ * byte-identical to the API + worker signing path.
247
+ *
248
+ * Returns a discriminated union — branch on `result.ok` and `result.code` to
249
+ * respond with the right HTTP status:
250
+ * - `ok: true` → 200 OK, process the event
251
+ * - `code: 'missing' | 'malformed'` → 400 Bad Request
252
+ * - `code: 'unsupported_version'` → 400 Bad Request
253
+ * - `code: 'timestamp_out_of_tolerance'` → 408 Request Timeout
254
+ * - `code: 'signature_mismatch'` → 401 Unauthorized
255
+ *
256
+ * `rawBody` MUST be the exact bytes of the request body — any reformatting
257
+ * (key re-ordering, whitespace normalization) breaks the HMAC. Capture the
258
+ * raw stream BEFORE any JSON parser touches it.
259
+ *
260
+ * @example Express middleware
261
+ * ```ts
262
+ * import express from 'express';
263
+ * import { Outposted } from '@outposted/node';
264
+ *
265
+ * const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
266
+ * const secret = process.env.OUTPOSTED_WHSEC!; // saved from webhooks.create()
267
+ *
268
+ * const app = express();
269
+ *
270
+ * // raw body capture — this MUST come before any JSON middleware
271
+ * app.post(
272
+ * '/outposted-webhook',
273
+ * express.raw({ type: 'application/json' }),
274
+ * (req, res) => {
275
+ * const rawBody = req.body.toString('utf8');
276
+ * const sig = req.header('x-outposted-signature');
277
+ * const result = outposted.webhooks.verify(rawBody, sig, secret);
278
+ *
279
+ * if (!result.ok) {
280
+ * const status =
281
+ * result.code === 'signature_mismatch' ? 401 :
282
+ * result.code === 'timestamp_out_of_tolerance' ? 408 : 400;
283
+ * return res.status(status).json({ error: result.code, detail: result.detail });
284
+ * }
285
+ *
286
+ * const event = JSON.parse(rawBody);
287
+ * // … process event …
288
+ * res.status(200).end();
289
+ * },
290
+ * );
291
+ * ```
292
+ */
293
+ verify(
294
+ rawBody: string,
295
+ headerValue: string | null | undefined,
296
+ secret: string,
297
+ options: VerifyOptions = {},
298
+ ): VerifyResult {
299
+ return verifyPayload(rawBody, headerValue, secret, options);
300
+ }
301
+ }
@@ -0,0 +1,70 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/resources/workspaces — Workspace resource.
3
+ //
4
+ // Wraps `GET /api/v1/workspaces/me`: returns the workspace tied to the
5
+ // authenticated API key. This is the canonical "who am I?" probe — handy as
6
+ // a startup health check that the SDK is configured with a valid key.
7
+ //
8
+ // The API envelope is `{ workspace, api_key_id, rate_limit }`; this resource
9
+ // unwraps it so callers get a clean `Workspace` object. The other envelope
10
+ // fields (api_key_id, rate_limit) are reachable via the standard rate-limit
11
+ // headers and are not surfaced here to keep the resource minimal.
12
+ // ────────────────────────────────────────────────────────────────────────────
13
+
14
+ import type { HttpClient } from '../transport';
15
+
16
+ /**
17
+ * Workspace tied to an API key. Shape mirrors the columns returned by
18
+ * `GET /api/v1/workspaces/me`.
19
+ */
20
+ export interface Workspace {
21
+ id: string;
22
+ slug: string;
23
+ name: string;
24
+ stripe_customer_id: string | null;
25
+ currency: string;
26
+ created_at: string;
27
+ trial_ends_at: string | null;
28
+ }
29
+
30
+ interface WorkspaceMeResponse {
31
+ workspace: Workspace;
32
+ api_key_id: string;
33
+ rate_limit: {
34
+ limit: number;
35
+ remaining: number;
36
+ reset_at: string;
37
+ };
38
+ }
39
+
40
+ export class WorkspacesResource {
41
+ constructor(private readonly http: HttpClient) {}
42
+
43
+ /**
44
+ * Fetch the workspace bound to the current API key.
45
+ *
46
+ * Equivalent curl:
47
+ * ```bash
48
+ * curl https://api.outposted.one/api/v1/workspaces/me \
49
+ * -H "Authorization: Bearer $OUTPOSTED_API_KEY"
50
+ * ```
51
+ *
52
+ * SDK call:
53
+ * ```ts
54
+ * const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
55
+ * const workspace = await outposted.workspaces.me();
56
+ * console.log(workspace.slug); // "acme"
57
+ * ```
58
+ *
59
+ * Throws `OutpostedAuthError` (401) if the key is invalid, or
60
+ * `OutpostedNotFoundError` (404) if the workspace row was deleted while the
61
+ * key still exists.
62
+ */
63
+ async me(): Promise<Workspace> {
64
+ const response = await this.http.request<WorkspaceMeResponse>({
65
+ method: 'GET',
66
+ path: '/api/v1/workspaces/me',
67
+ });
68
+ return response.workspace;
69
+ }
70
+ }