@outposted/node 0.1.0 → 0.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 — 2026-06-03
4
+ - Inline webhook signing + types (no workspace: dependencies). Package is now self-contained and installable by external consumers.
5
+
3
6
  ## 0.1.0 — 2026-06-03
4
7
 
5
8
  - Initial release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outposted/node",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "description": "Outposted Node.js client — publish to Instagram, Facebook, YouTube, LinkedIn, Threads from one API call.",
6
6
  "main": "src/index.ts",
@@ -43,11 +43,10 @@
43
43
  "typecheck": "tsc --noEmit",
44
44
  "lint": "eslint src __tests__ --max-warnings 0"
45
45
  },
46
- "dependencies": {
47
- "@outposted/domain": "workspace:*",
48
- "@outposted/webhooks": "workspace:*"
49
- },
46
+ "dependencies": {},
50
47
  "devDependencies": {
48
+ "@outposted/domain": "workspace:*",
49
+ "@outposted/webhooks": "workspace:*",
51
50
  "@types/node": "^22",
52
51
  "typescript": "^5",
53
52
  "vitest": "^2",
package/src/index.ts CHANGED
@@ -34,4 +34,4 @@ export { OAuthResource } from './resources/oauth';
34
34
 
35
35
  // Re-export webhook signing helpers so consumers don't have to install a
36
36
  // second package to verify incoming Outposted webhooks.
37
- export { signPayload, verifyPayload } from '@outposted/webhooks';
37
+ export { signPayload, verifyPayload } from './webhook-signing';
@@ -15,7 +15,7 @@
15
15
  // OutpostedError subclasses produced by the transport layer.
16
16
  // ────────────────────────────────────────────────────────────────────────────
17
17
 
18
- import type { Brand, BrandCreateInput } from '@outposted/domain';
18
+ import type { Brand, BrandCreateInput } from '../types';
19
19
  import { OutpostedNotFoundError } from '../errors';
20
20
  import type { HttpClient } from '../transport';
21
21
 
@@ -19,7 +19,7 @@
19
19
  // once that route exists in `apps/api/src/app/api/v1/workspaces/[wsId]/brands/[brandId]/connections/route.ts`.
20
20
  // ────────────────────────────────────────────────────────────────────────────
21
21
 
22
- import type { ConnectedAccount, AccountType } from '@outposted/domain';
22
+ import type { ConnectedAccount, AccountType } from '../types';
23
23
  import type { HttpClient } from '../transport';
24
24
 
25
25
  /** Platforms the OAuth start endpoint accepts. Mirrors `StartSchema.platform` in `connections/start/route.ts`. */
@@ -30,7 +30,7 @@ import type {
30
30
  ContentPayload,
31
31
  ContentStatus,
32
32
  ContentType,
33
- } from '@outposted/domain';
33
+ } from '../types';
34
34
  import type { HttpClient } from '../transport';
35
35
 
36
36
  /**
@@ -22,7 +22,7 @@
22
22
  // transport layer (e.g. OutpostedNotFoundError on 404).
23
23
  // ────────────────────────────────────────────────────────────────────────────
24
24
 
25
- import type { WebhookDelivery, WebhookDeliveryStatus } from '@outposted/domain';
25
+ import type { WebhookDelivery, WebhookDeliveryStatus } from '../types';
26
26
  import type { HttpClient } from '../transport';
27
27
 
28
28
  /** Query params accepted by `DeliveriesResource.list`. */
@@ -19,7 +19,7 @@
19
19
  // can reuse it for inbound verification without installing a second package.
20
20
  // ────────────────────────────────────────────────────────────────────────────
21
21
 
22
- import { verifyPayload, type VerifyResult } from '@outposted/webhooks';
22
+ import { verifyPayload, type VerifyResult } from '../webhook-signing';
23
23
  import type { HttpClient } from '../transport';
24
24
 
25
25
  export type { VerifyResult };
package/src/types.ts ADDED
@@ -0,0 +1,178 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/types — inline domain types.
3
+ //
4
+ // These types are copied verbatim (type-only) from @outposted/domain to keep
5
+ // the SDK self-contained and installable by external consumers (the package
6
+ // is not in the workspace, so `workspace:*` deps are unusable).
7
+ //
8
+ // Keep these in sync with `packages/domain/src/{brand,connection,content,webhook}.ts`.
9
+ // Runtime values (zod schemas, helper functions) are NOT mirrored here — the
10
+ // SDK only needs type shapes.
11
+ // ────────────────────────────────────────────────────────────────────────────
12
+
13
+ // ── Brand ──────────────────────────────────────────────────────────────────
14
+
15
+ export interface Brand {
16
+ id: string;
17
+ workspace_id: string;
18
+ slug: string;
19
+ name: string;
20
+ brand_type: string;
21
+ description: string | null;
22
+ metadata: Record<string, unknown>;
23
+ created_at: string;
24
+ }
25
+
26
+ export interface BrandCreateInput {
27
+ workspace_id: string;
28
+ slug: string;
29
+ name: string;
30
+ brand_type: string;
31
+ description?: string;
32
+ metadata?: Record<string, unknown>;
33
+ }
34
+
35
+ // ── Connection ─────────────────────────────────────────────────────────────
36
+
37
+ export type ConnectionStatus = 'connected' | 'expired' | 'revoked' | 'error';
38
+
39
+ export type AccountType =
40
+ | 'personal'
41
+ | 'page'
42
+ | 'organization'
43
+ | 'ig_business'
44
+ | 'channel'
45
+ | 'profile'
46
+ | 'unknown';
47
+
48
+ export interface ConnectedAccount {
49
+ id: string;
50
+ brand_id: string;
51
+ platform_slug: string;
52
+ provider_slug: string;
53
+ external_account_id: string;
54
+ external_handle: string | null;
55
+ display_label: string | null;
56
+ status: ConnectionStatus;
57
+ /**
58
+ * pgcrypto-encrypted token bundle (hex-encoded bytea). The worker calls
59
+ * `decryptToken(token_encrypted)` just before invoking the adapter. Domain
60
+ * code MUST NEVER touch this — it's effectively opaque infrastructure.
61
+ */
62
+ token_encrypted: string | null;
63
+ metadata: Record<string, unknown>;
64
+ connected_at: string;
65
+ }
66
+
67
+ // ── Content ────────────────────────────────────────────────────────────────
68
+
69
+ export type ContentStatus =
70
+ | 'awaiting_media'
71
+ | 'queued'
72
+ | 'scheduled'
73
+ | 'publishing'
74
+ | 'processing_remote'
75
+ | 'published'
76
+ | 'partial'
77
+ | 'failed'
78
+ | 'cancelled';
79
+
80
+ export type JobStatus =
81
+ | 'queued'
82
+ | 'scheduled'
83
+ | 'processing'
84
+ | 'processing_remote'
85
+ | 'published'
86
+ | 'failed'
87
+ | 'cancelled';
88
+
89
+ export type ContentType =
90
+ | 'post'
91
+ | 'carousel'
92
+ | 'reel'
93
+ | 'story'
94
+ | 'newsletter'
95
+ | 'thread'
96
+ | 'text_only'
97
+ | 'link_share'
98
+ | 'video'
99
+ | 'short';
100
+
101
+ export interface MediaItem {
102
+ type: 'image' | 'video';
103
+ url?: string;
104
+ /** Multipart binary reference, e.g. "file_0". Resolved to `url` at ingest. */
105
+ ref?: string;
106
+ /** Large-media direct upload intent (E1.11). Server hands back a presigned PUT URL. */
107
+ upload?: true;
108
+ cover_url?: string;
109
+ alt_text?: string;
110
+ // Server-injected at ingest (#95). NOT accepted from clients.
111
+ content_type?: string;
112
+ size?: number;
113
+ checksum_sha256?: string;
114
+ transcoded_from?: string;
115
+ }
116
+
117
+ export interface ThreadChainPost {
118
+ text: string;
119
+ media?: MediaItem;
120
+ }
121
+
122
+ export interface ContentPayload {
123
+ text?: string;
124
+ caption?: string;
125
+ title?: string;
126
+ body_html?: string;
127
+ link?: string;
128
+ media?: MediaItem[];
129
+ /** Legacy image-only shape; upstreams maps to `media[]` with type='image'. */
130
+ media_urls?: string[];
131
+ hashtags?: string[];
132
+ posts?: ThreadChainPost[];
133
+ /**
134
+ * Per-publish visibility. YouTube-specific in V0 (video/short): controls
135
+ * snippet privacyStatus. Other platforms ignore it. Defaults to 'public'
136
+ * downstream when omitted.
137
+ */
138
+ privacy_status?: 'public' | 'unlisted' | 'private';
139
+ }
140
+
141
+ export interface Content {
142
+ id: string;
143
+ workspace_id: string;
144
+ brand_id: string;
145
+ content_type: ContentType;
146
+ target_platforms: string[];
147
+ target_connections: string[] | null;
148
+ payload: ContentPayload;
149
+ status: ContentStatus;
150
+ /** ISO-8601 timestamp when status is `scheduled`. Null otherwise. */
151
+ scheduled_at: string | null;
152
+ created_at: string;
153
+ updated_at: string;
154
+ created_by_api_key_id: string | null;
155
+ idempotency_key: string | null;
156
+ }
157
+
158
+ // ── Webhook ────────────────────────────────────────────────────────────────
159
+
160
+ export type WebhookDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'dlq';
161
+
162
+ export interface WebhookDelivery {
163
+ id: string;
164
+ endpoint_id: string;
165
+ content_id: string | null;
166
+ job_id: string | null;
167
+ event_type: string;
168
+ status: WebhookDeliveryStatus;
169
+ attempt_count: number;
170
+ next_retry_at: string | null;
171
+ payload: Record<string, unknown> | null;
172
+ last_response_status: number | null;
173
+ last_response_body: string | null;
174
+ last_error: string | null;
175
+ delivered_at: string | null;
176
+ created_at: string;
177
+ updated_at: string;
178
+ }
@@ -0,0 +1,144 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/webhook-signing — inline HMAC-SHA256 signing + verification.
3
+ //
4
+ // Copied verbatim from `@outposted/webhooks/sign` (and a slice of `secret`)
5
+ // so the SDK is self-contained and installable by external consumers without
6
+ // the workspace-only `@outposted/webhooks` dependency.
7
+ //
8
+ // Single source of truth for the wire format lives in
9
+ // `packages/webhooks/src/sign.ts` — keep these in sync.
10
+ //
11
+ // Format (Stripe-style): `X-Outposted-Signature: t=<unix_seconds>,v1=<hex_hmac>`
12
+ //
13
+ // - `t` = timestamp seconds (replay protection — receiver checks tolerance)
14
+ // - `v1` = HMAC-SHA256( `${t}.${rawBody}`, secret ) in lowercase hex
15
+ //
16
+ // node:crypto works in both Node and Cloudflare Workers (via nodejs_compat).
17
+ // ────────────────────────────────────────────────────────────────────────────
18
+
19
+ import { createHmac, timingSafeEqual } from 'node:crypto';
20
+
21
+ const SIGNATURE_HEADER = 'X-Outposted-Signature';
22
+ const SIGNATURE_VERSION = 'v1';
23
+
24
+ /** Default replay tolerance: 5 minutes, matching Stripe's default. */
25
+ export const DEFAULT_TOLERANCE_SECONDS = 5 * 60;
26
+
27
+ export interface SignedSignature {
28
+ /** Header value (e.g. `t=1717209600,v1=abc123...`). Pass as-is into X-Outposted-Signature. */
29
+ header: string;
30
+ /** Timestamp used in the signature, unix seconds. */
31
+ timestamp: number;
32
+ /** Raw HMAC-SHA256 hex digest of `${timestamp}.${rawBody}`. */
33
+ signature: string;
34
+ }
35
+
36
+ export interface SignPayloadOptions {
37
+ /** Override the timestamp (mostly for tests). Defaults to `Math.floor(Date.now() / 1000)`. */
38
+ timestamp?: number;
39
+ }
40
+
41
+ /**
42
+ * Sign a raw body with the workspace's whsec secret. Returns the header value
43
+ * the sender writes into `X-Outposted-Signature`.
44
+ *
45
+ * `rawBody` MUST be the exact bytes sent on the wire — any whitespace,
46
+ * key-ordering, or encoding difference between sign-time and verify-time
47
+ * breaks the signature. Pass `JSON.stringify(payload)` once and reuse the
48
+ * same string for both the signature and the HTTP body.
49
+ */
50
+ export function signPayload(
51
+ rawBody: string,
52
+ secret: string,
53
+ options: SignPayloadOptions = {},
54
+ ): SignedSignature {
55
+ if (!secret) throw new Error('signPayload: secret is required');
56
+ const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000);
57
+ const signature = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
58
+ return {
59
+ header: `t=${timestamp},${SIGNATURE_VERSION}=${signature}`,
60
+ timestamp,
61
+ signature,
62
+ };
63
+ }
64
+
65
+ export type VerifyResult =
66
+ | { ok: true; timestamp: number }
67
+ | { ok: false; code: 'missing'; detail: string }
68
+ | { ok: false; code: 'malformed'; detail: string }
69
+ | { ok: false; code: 'unsupported_version'; detail: string }
70
+ | { ok: false; code: 'timestamp_out_of_tolerance'; detail: string }
71
+ | { ok: false; code: 'signature_mismatch'; detail: string };
72
+
73
+ export interface VerifyPayloadOptions {
74
+ /** Replay tolerance window in seconds (default: 5 min, Stripe-compatible). */
75
+ toleranceSeconds?: number;
76
+ /** Override the "now" reference for tests. Unix seconds. */
77
+ nowSeconds?: number;
78
+ }
79
+
80
+ /**
81
+ * Verify an inbound signed payload — for receivers (the customer side, and
82
+ * also our own E2E tests). Returns a discriminated union so callers branch on
83
+ * the specific failure mode (e.g. respond 400 for malformed, 401 for
84
+ * signature_mismatch, 408 for timestamp_out_of_tolerance).
85
+ *
86
+ * Constant-time compare is used on the signature itself to prevent the
87
+ * trivial timing-based oracle attack.
88
+ */
89
+ export function verifyPayload(
90
+ rawBody: string,
91
+ headerValue: string | null | undefined,
92
+ secret: string,
93
+ options: VerifyPayloadOptions = {},
94
+ ): VerifyResult {
95
+ if (!headerValue) {
96
+ return { ok: false, code: 'missing', detail: `${SIGNATURE_HEADER} header not present` };
97
+ }
98
+ const parts = headerValue.split(',').map((s) => s.trim());
99
+ const kv = new Map<string, string>();
100
+ for (const part of parts) {
101
+ const eq = part.indexOf('=');
102
+ if (eq < 1) {
103
+ return { ok: false, code: 'malformed', detail: `bad pair: '${part}'` };
104
+ }
105
+ kv.set(part.slice(0, eq), part.slice(eq + 1));
106
+ }
107
+
108
+ const t = kv.get('t');
109
+ const sig = kv.get(SIGNATURE_VERSION);
110
+ if (!t || !sig) {
111
+ return {
112
+ ok: false,
113
+ code: kv.size === 0 ? 'malformed' : 'unsupported_version',
114
+ detail: `missing t/${SIGNATURE_VERSION} in '${headerValue}'`,
115
+ };
116
+ }
117
+ const timestamp = Number(t);
118
+ if (!Number.isFinite(timestamp) || timestamp <= 0) {
119
+ return { ok: false, code: 'malformed', detail: `bad timestamp: '${t}'` };
120
+ }
121
+
122
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
123
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
124
+ if (Math.abs(now - timestamp) > tolerance) {
125
+ return {
126
+ ok: false,
127
+ code: 'timestamp_out_of_tolerance',
128
+ detail: `timestamp ${timestamp} is ${Math.abs(now - timestamp)}s from now=${now}, tolerance=${tolerance}s`,
129
+ };
130
+ }
131
+
132
+ const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
133
+ if (sig.length !== expected.length) {
134
+ return { ok: false, code: 'signature_mismatch', detail: 'length mismatch' };
135
+ }
136
+ const a = Buffer.from(sig, 'utf8');
137
+ const b = Buffer.from(expected, 'utf8');
138
+ if (!timingSafeEqual(a, b)) {
139
+ return { ok: false, code: 'signature_mismatch', detail: 'HMAC does not match' };
140
+ }
141
+ return { ok: true, timestamp };
142
+ }
143
+
144
+ export { SIGNATURE_HEADER };