@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/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node — public entry point.
3
+ // ────────────────────────────────────────────────────────────────────────────
4
+
5
+ export { Outposted, type OutpostedOptions } from './client';
6
+
7
+ export {
8
+ OutpostedError,
9
+ OutpostedAuthError,
10
+ OutpostedValidationError,
11
+ OutpostedForbiddenError,
12
+ OutpostedNotFoundError,
13
+ OutpostedConflictError,
14
+ OutpostedRateLimitError,
15
+ OutpostedServerError,
16
+ OutpostedNetworkError,
17
+ type OutpostedErrorOptions,
18
+ type OutpostedErrorBody,
19
+ type ValidationIssue,
20
+ type OutpostedValidationErrorOptions,
21
+ type OutpostedRateLimitErrorOptions,
22
+ } from './errors';
23
+
24
+ export { HttpClient, type HttpClientOptions, type RequestOptions, type FetchLike } from './transport';
25
+
26
+ export { WorkspacesResource } from './resources/workspaces';
27
+ export { BrandsResource } from './resources/brands';
28
+ export { ApiKeysResource } from './resources/api-keys';
29
+ export { ContentsResource } from './resources/contents';
30
+ export { ConnectionsResource } from './resources/connections';
31
+ export { WebhooksResource } from './resources/webhooks';
32
+ export { DeliveriesResource } from './resources/deliveries';
33
+ export { OAuthResource } from './resources/oauth';
34
+
35
+ // Re-export webhook signing helpers so consumers don't have to install a
36
+ // second package to verify incoming Outposted webhooks.
37
+ export { signPayload, verifyPayload } from '@outposted/webhooks';
@@ -0,0 +1,157 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/resources/api-keys — workspace API key management.
3
+ //
4
+ // Two operations, both scoped to a workspace:
5
+ //
6
+ // - list(workspaceId) GET /v1/workspaces/:wsId/api-keys
7
+ // - rotate(workspaceId, body?) POST /v1/workspaces/:wsId/api-keys
8
+ //
9
+ // The server NEVER returns plaintext on list — only metadata (id, name, prefix,
10
+ // timestamps). Plaintext is returned exactly ONCE, by `rotate()`, on the same
11
+ // 201 response that creates the new key. There is no second chance: if the
12
+ // caller drops the response, the key is unrecoverable.
13
+ //
14
+ // `revoke_others: true` is the proper rotation hygiene: create a new key, then
15
+ // in the SAME atomic operation revoke every other active key in the workspace.
16
+ // Use this on credential leak or scheduled rotation.
17
+ // ────────────────────────────────────────────────────────────────────────────
18
+
19
+ import type { HttpClient } from '../transport';
20
+
21
+ /**
22
+ * Metadata for an API key as returned by `list()`. Contains NO secret material —
23
+ * neither the plaintext key nor its hash. `prefix` is the short, non-secret
24
+ * identifier (e.g. `opst_live_AbCd...`) safe to display in dashboards and logs.
25
+ */
26
+ export interface ApiKey {
27
+ /** Stable identifier (e.g. `key_01HXY...`). */
28
+ id: string;
29
+ /** Human label set at creation time. Defaults to `"rotated"` when not provided. */
30
+ name: string;
31
+ /** Non-secret prefix safe to display (e.g. `opst_live_AbCd...`). */
32
+ prefix: string;
33
+ /** ISO-8601 timestamp the key was created. */
34
+ created_at: string;
35
+ /** ISO-8601 timestamp of the most recent authenticated request, or `null`. */
36
+ last_used_at: string | null;
37
+ /** ISO-8601 timestamp the key was revoked, or `null` if still active. */
38
+ revoked_at: string | null;
39
+ /** ISO-8601 expiry, or `null` if non-expiring. */
40
+ expires_at: string | null;
41
+ }
42
+
43
+ /**
44
+ * Response from `rotate()`. Carries the freshly-minted plaintext `api_key` —
45
+ * the one and ONLY time the SDK will ever expose it. Persist it immediately;
46
+ * the server cannot return it again.
47
+ */
48
+ export interface RotatedApiKey {
49
+ /** Stable identifier of the new key. */
50
+ id: string;
51
+ /** Human label (the `name` arg, or `"rotated"` when omitted). */
52
+ name: string;
53
+ /** Non-secret prefix safe to display (e.g. `opst_live_AbCd...`). */
54
+ prefix: string;
55
+ /** **Plaintext key. Returned ONCE. Persist immediately — it cannot be retrieved later.** */
56
+ api_key: string;
57
+ /** ISO-8601 timestamp the key was created. */
58
+ created_at: string;
59
+ /** `true` when `revoke_others: true` was honoured and all other active keys were revoked. */
60
+ revoked_others: boolean;
61
+ }
62
+
63
+ /** Body for `rotate()`. Both fields are optional; an empty body is valid. */
64
+ export interface RotateApiKeyInput {
65
+ /** Label for the new key (1-100 chars). Defaults server-side to `"rotated"`. */
66
+ name?: string;
67
+ /** When `true`, revoke every OTHER active key in the workspace atomically. */
68
+ revoke_others?: boolean;
69
+ }
70
+
71
+ interface ListResponse {
72
+ data: ApiKey[];
73
+ }
74
+
75
+ /**
76
+ * API key resource — list metadata and rotate (create + optionally revoke
77
+ * others) keys for a given workspace. The Bearer key used by the SDK MUST
78
+ * already belong to the workspace it's operating on; the server enforces this.
79
+ */
80
+ export class ApiKeysResource {
81
+ constructor(private readonly http: HttpClient) {}
82
+
83
+ /**
84
+ * List every API key in a workspace — metadata only. Plaintext and hash are
85
+ * NEVER included. Useful for displaying key inventories in dashboards or
86
+ * pruning stale credentials.
87
+ *
88
+ * The response is unwrapped: the SDK returns the `ApiKey[]` array directly,
89
+ * not the `{ data: [...] }` envelope the wire format uses.
90
+ *
91
+ * @example
92
+ * ```bash
93
+ * curl https://api.outposted.one/v1/workspaces/ws_123/api-keys \
94
+ * -H "Authorization: Bearer opst_live_..."
95
+ * ```
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const keys = await client.apiKeys.list('ws_123');
100
+ * for (const k of keys) {
101
+ * console.log(k.prefix, k.last_used_at);
102
+ * }
103
+ * ```
104
+ */
105
+ async list(workspaceId: string): Promise<ApiKey[]> {
106
+ const response = await this.http.request<ListResponse>({
107
+ method: 'GET',
108
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/api-keys`,
109
+ });
110
+ return response.data ?? [];
111
+ }
112
+
113
+ /**
114
+ * Rotate (create) a new API key for a workspace. When `revoke_others: true`,
115
+ * every other active key in the workspace is revoked atomically in the same
116
+ * operation — the canonical rotation flow.
117
+ *
118
+ * **CRITICAL — read the response carefully:** the returned `api_key` field
119
+ * is the PLAINTEXT key, and the server returns it exactly ONCE on this call.
120
+ * The plaintext is never persisted server-side and CANNOT be retrieved
121
+ * again. Persist `result.api_key` to your secret store IMMEDIATELY — before
122
+ * any other operation that could throw — or you'll have to rotate again to
123
+ * recover.
124
+ *
125
+ * @example
126
+ * ```bash
127
+ * # Simple rotation — keeps existing keys alive.
128
+ * curl -X POST https://api.outposted.one/v1/workspaces/ws_123/api-keys \
129
+ * -H "Authorization: Bearer opst_live_..." \
130
+ * -H "Content-Type: application/json" \
131
+ * -d '{"name":"ci-pipeline"}'
132
+ *
133
+ * # Full rotation — new key + revoke every other active key.
134
+ * curl -X POST https://api.outposted.one/v1/workspaces/ws_123/api-keys \
135
+ * -H "Authorization: Bearer opst_live_..." \
136
+ * -H "Content-Type: application/json" \
137
+ * -d '{"name":"post-leak","revoke_others":true}'
138
+ * ```
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * const result = await client.apiKeys.rotate('ws_123', {
143
+ * name: 'post-leak',
144
+ * revoke_others: true,
145
+ * });
146
+ * // ⚠️ result.api_key is the plaintext — store it NOW.
147
+ * await secretStore.put('OUTPOSTED_API_KEY', result.api_key);
148
+ * ```
149
+ */
150
+ async rotate(workspaceId: string, input: RotateApiKeyInput = {}): Promise<RotatedApiKey> {
151
+ return this.http.request<RotatedApiKey>({
152
+ method: 'POST',
153
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/api-keys`,
154
+ body: input,
155
+ });
156
+ }
157
+ }
@@ -0,0 +1,127 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/resources/brands — Brand publishing identities.
3
+ //
4
+ // A Brand is a publishing identity inside a workspace. One brand can connect
5
+ // multiple platforms (e.g. 1 IG handle + 1 FB Page + 1 Threads). The slug is
6
+ // unique per workspace.
7
+ //
8
+ // Endpoints proxied here (see apps/api/src/app/api/v1/workspaces/[wsId]/brands):
9
+ // - GET /v1/workspaces/:wsId/brands → list
10
+ // - POST /v1/workspaces/:wsId/brands → create
11
+ // - GET /v1/workspaces/:wsId/brands/:brandId → fetch one (with connections)
12
+ //
13
+ // All methods unwrap the API envelope (`{ brand }` / `{ brands }`) so the
14
+ // caller gets the domain entity directly. Errors are surfaced as the typed
15
+ // OutpostedError subclasses produced by the transport layer.
16
+ // ────────────────────────────────────────────────────────────────────────────
17
+
18
+ import type { Brand, BrandCreateInput } from '@outposted/domain';
19
+ import { OutpostedNotFoundError } from '../errors';
20
+ import type { HttpClient } from '../transport';
21
+
22
+ /**
23
+ * Payload accepted by `BrandsResource.create`. Mirrors the POST body the API
24
+ * expects — `workspace_id` is taken from the URL, so we omit it from the body
25
+ * shape exposed by the SDK.
26
+ */
27
+ export type BrandCreateBody = Omit<BrandCreateInput, 'workspace_id'>;
28
+
29
+ /**
30
+ * SDK accessor for the `/v1/workspaces/:wsId/brands` collection.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
35
+ * const brands = await outposted.brands.list('ws_123');
36
+ * ```
37
+ */
38
+ export class BrandsResource {
39
+ constructor(private readonly http: HttpClient) {}
40
+
41
+ /**
42
+ * List every brand in a workspace, newest first.
43
+ *
44
+ * @example curl
45
+ * ```bash
46
+ * curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
47
+ * https://api.outposted.one/v1/workspaces/ws_123/brands
48
+ * ```
49
+ *
50
+ * @example SDK
51
+ * ```ts
52
+ * const brands = await outposted.brands.list('ws_123');
53
+ * console.log(brands.map((b) => b.slug));
54
+ * ```
55
+ */
56
+ async list(workspaceId: string): Promise<Brand[]> {
57
+ const { brands } = await this.http.request<{ brands: Brand[] }>({
58
+ method: 'GET',
59
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/brands`,
60
+ });
61
+ return brands;
62
+ }
63
+
64
+ /**
65
+ * Create a brand inside a workspace. Slugs are unique per workspace; a
66
+ * conflicting slug surfaces as `OutpostedConflictError` (HTTP 409).
67
+ *
68
+ * @example curl
69
+ * ```bash
70
+ * curl -X POST -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
71
+ * -H "Content-Type: application/json" \
72
+ * -d '{"name":"Coke","slug":"coke","brand_type":"product"}' \
73
+ * https://api.outposted.one/v1/workspaces/ws_123/brands
74
+ * ```
75
+ *
76
+ * @example SDK
77
+ * ```ts
78
+ * const brand = await outposted.brands.create('ws_123', {
79
+ * name: 'Coke',
80
+ * slug: 'coke',
81
+ * brand_type: 'product',
82
+ * });
83
+ * ```
84
+ */
85
+ async create(workspaceId: string, input: BrandCreateBody): Promise<Brand> {
86
+ const { brand } = await this.http.request<{ brand: Brand }>({
87
+ method: 'POST',
88
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/brands`,
89
+ body: input,
90
+ });
91
+ return brand;
92
+ }
93
+
94
+ /**
95
+ * Fetch a single brand by id. The API responds with the brand row plus its
96
+ * nested `connected_accounts` — the SDK returns the unwrapped object.
97
+ *
98
+ * Throws `OutpostedNotFoundError` (HTTP 404) when the brand does not exist
99
+ * or does not belong to the authenticated workspace.
100
+ *
101
+ * @example curl
102
+ * ```bash
103
+ * curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
104
+ * https://api.outposted.one/v1/workspaces/ws_123/brands/brand_abc
105
+ * ```
106
+ *
107
+ * @example SDK
108
+ * ```ts
109
+ * const brand = await outposted.brands.get('ws_123', 'brand_abc');
110
+ * ```
111
+ */
112
+ async get(workspaceId: string, brandId: string): Promise<Brand> {
113
+ const { brand } = await this.http.request<{ brand: Brand | null }>({
114
+ method: 'GET',
115
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/brands/${encodeURIComponent(brandId)}`,
116
+ });
117
+ if (!brand) {
118
+ // Defense in depth — the API itself returns 404 for missing brands, but
119
+ // if it ever returns 200 with a null body we still want a typed error.
120
+ throw new OutpostedNotFoundError(`brand ${brandId} not found in workspace ${workspaceId}`, {
121
+ status: 404,
122
+ code: 'brand_not_found',
123
+ });
124
+ }
125
+ return brand;
126
+ }
127
+ }
@@ -0,0 +1,115 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/resources/connections — ConnectedAccount resource.
3
+ //
4
+ // A `connected_account` ties a brand to one external account on one platform
5
+ // (e.g. brand `coke` → instagram → ig_user_id 1234). They're created
6
+ // automatically by the OAuth flow: the user is redirected to the platform,
7
+ // authorizes, and the callback handler persists the encrypted token + handle
8
+ // before bouncing back to the app. The SDK exposes the entry point of that
9
+ // flow (`getOAuthStartUrl`) so server-side integrations can mint redirect
10
+ // URLs without going through a UI.
11
+ //
12
+ // `list()` is a stub today — the V0 API surface doesn't include a list
13
+ // endpoint for connected accounts. When one lands (planned route:
14
+ // `GET /api/v1/workspaces/:wsId/brands/:brandId/connections`), wire it up
15
+ // here. Until then, calling `list()` throws so consumers fail loud instead
16
+ // of silently getting an empty array.
17
+ //
18
+ // TODO(api): wire `list()` to `GET /api/v1/workspaces/:wsId/brands/:brandId/connections`
19
+ // once that route exists in `apps/api/src/app/api/v1/workspaces/[wsId]/brands/[brandId]/connections/route.ts`.
20
+ // ────────────────────────────────────────────────────────────────────────────
21
+
22
+ import type { ConnectedAccount, AccountType } from '@outposted/domain';
23
+ import type { HttpClient } from '../transport';
24
+
25
+ /** Platforms the OAuth start endpoint accepts. Mirrors `StartSchema.platform` in `connections/start/route.ts`. */
26
+ export type ConnectionPlatform =
27
+ | 'instagram'
28
+ | 'facebook'
29
+ | 'threads'
30
+ | 'youtube'
31
+ | 'linkedin';
32
+
33
+ export interface GetOAuthStartUrlOptions {
34
+ /**
35
+ * Optional URL the OAuth callback will redirect the user to after the
36
+ * connection is persisted. Only honored if it's an absolute URL pointing
37
+ * to a host the backend trusts; otherwise the backend uses the default
38
+ * post-connect destination.
39
+ */
40
+ returnTo?: string;
41
+ }
42
+
43
+ export class ConnectionsResource {
44
+ constructor(private readonly http: HttpClient) {}
45
+
46
+ /**
47
+ * List the connected accounts attached to a brand.
48
+ *
49
+ * **Not implemented in this API version.** The V0 API exposes the OAuth
50
+ * start endpoint (see `getOAuthStartUrl`) and the callback handler that
51
+ * creates rows, but does not yet ship a read endpoint. This method exists
52
+ * so the resource shape is forward-compatible — calling it today throws.
53
+ *
54
+ * Equivalent (future) curl:
55
+ * ```bash
56
+ * curl https://api.outposted.one/api/v1/workspaces/$WS_ID/brands/$BRAND_ID/connections \
57
+ * -H "Authorization: Bearer $OUTPOSTED_API_KEY"
58
+ * ```
59
+ *
60
+ * Throws `Error('Not implemented in this API version — use the OAuth start URL flow to add connections.')`.
61
+ */
62
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
63
+ async list(workspaceId: string, brandId: string): Promise<ConnectedAccount[]> {
64
+ throw new Error(
65
+ 'Not implemented in this API version — use the OAuth start URL flow to add connections.',
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Build the URL the end user should be redirected to in order to authorize
71
+ * a connection. Does NOT make an HTTP call — pure URL construction.
72
+ *
73
+ * Flow:
74
+ * 1. Your server redirects the end user to this URL.
75
+ * 2. They authorize on the platform (Instagram / Facebook / etc.).
76
+ * 3. The platform redirects to Outposted's OAuth callback, which exchanges
77
+ * the code for a token, encrypts it, and persists a `ConnectedAccount`
78
+ * row automatically.
79
+ * 4. The user bounces back to your app (default destination, or the URL
80
+ * passed via `returnTo` if the backend trusts it).
81
+ *
82
+ * SDK call:
83
+ * ```ts
84
+ * const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
85
+ * const url = outposted.connections.getOAuthStartUrl('ws_123', 'brand_456', 'instagram', {
86
+ * returnTo: 'https://app.example.com/settings/connections',
87
+ * });
88
+ * // redirect the end user there; they authorize on the platform; the
89
+ * // OAuth callback creates the ConnectedAccount automatically.
90
+ * res.redirect(url);
91
+ * ```
92
+ *
93
+ * @param workspaceId Workspace that owns the brand.
94
+ * @param brandId Brand the new connection will be attached to.
95
+ * @param platform Target platform (`instagram` | `facebook` | `threads` | `youtube` | `linkedin`).
96
+ * @param opts Optional flags — currently just `returnTo`.
97
+ */
98
+ getOAuthStartUrl(
99
+ workspaceId: string,
100
+ brandId: string,
101
+ platform: ConnectionPlatform,
102
+ opts?: GetOAuthStartUrlOptions,
103
+ ): string {
104
+ const url = new URL(`${this.http.baseUrl}/api/v1/oauth/${platform}/start`);
105
+ url.searchParams.set('brand_id', brandId);
106
+ url.searchParams.set('workspace_id', workspaceId);
107
+ if (opts?.returnTo) {
108
+ url.searchParams.set('return_to', opts.returnTo);
109
+ }
110
+ return url.toString();
111
+ }
112
+ }
113
+
114
+ // Re-export the domain types so consumers don't need a second import.
115
+ export type { ConnectedAccount, AccountType };