flowflex 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/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # @fantacode/flowflex
2
+
3
+ Official Node/browser SDK for firing **FlowFlex custom-integration events** and
4
+ attaching **private files** to them — without ever dealing with presigned URLs,
5
+ Basic-auth headers, or the multi-step upload dance yourself.
6
+
7
+ ```bash
8
+ npm install @fantacode/flowflex
9
+ ```
10
+
11
+ Requires Node 18+ (for the built-in `fetch`) or any modern browser.
12
+
13
+ ---
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { FlowFlex } from "@fantacode/flowflex";
19
+
20
+ const ff = new FlowFlex({
21
+ apiKey: "cik_xxx", // from your custom integration
22
+ apiSecret: "yyy",
23
+ integrationCode: "ic_abc123", // the code in your event URL: /v1/events/<code>
24
+ baseUrl: "https://app.flowflex.com",
25
+ });
26
+
27
+ // Plain event, no file
28
+ await ff.sendEvent("order.placed", {
29
+ payload: { name: "Ada", order_id: "ord_42" },
30
+ });
31
+ ```
32
+
33
+ In your flow builder, the values land under `trigger` — e.g. `{{trigger.name}}`,
34
+ `{{trigger.order_id}}`.
35
+
36
+ ---
37
+
38
+ ## Attaching a private file
39
+
40
+ Wrap any file in `ff.file(...)` and drop it into the payload. The SDK uploads it
41
+ through a presigned URL and replaces it with its opaque `assetId` before the
42
+ event is sent. **The file bytes go straight to storage — they never pass through
43
+ the FlowFlex app server.**
44
+
45
+ ```ts
46
+ await ff.sendEvent("invoice.created", {
47
+ payload: {
48
+ assetId: ff.file("./invoice.pdf"), // ← becomes "asset_..." on the wire
49
+ customer_id: "cust_123",
50
+ },
51
+ });
52
+ ```
53
+
54
+ Then in your flow's **Media Message** node:
55
+
56
+ 1. Set **File source** → `Private file (assetId)`
57
+ 2. Set the **Asset ID** field → `{{trigger.assetId}}`
58
+
59
+ The key you use in the payload is the key you reference in the flow. If you send
60
+ `{ invoice: ff.file(...) }`, reference it as `{{trigger.invoice}}`.
61
+
62
+ ### Multiple files
63
+
64
+ A flow can have as many media nodes as you like — put a `file()` anywhere in the
65
+ payload (top-level, nested, or in arrays) and the SDK uploads them **all in
66
+ parallel** and swaps each for its `assetId`. The shape is entirely up to you;
67
+ reference each one by its path in the flow builder.
68
+
69
+ ```ts
70
+ await ff.sendEvent("order.shipped", {
71
+ payload: {
72
+ invoice: ff.file("./invoice.pdf"), // {{trigger.invoice}}
73
+ label: ff.file("./label.png"), // {{trigger.label}}
74
+ gallery: [ff.file("./a.jpg"), ff.file("./b.jpg")], // {{trigger.gallery[0]}}, {{trigger.gallery[1]}}
75
+ order: { receipt: ff.file("./receipt.pdf") }, // {{trigger.order.receipt}}
76
+ note: "non-file values pass through untouched",
77
+ },
78
+ });
79
+ // → { invoice: "asset_a", label: "asset_b",
80
+ // gallery: ["asset_c", "asset_d"],
81
+ // order: { receipt: "asset_e" }, note: "..." }
82
+ ```
83
+
84
+ `result.uploadedAssets` maps each payload path to its assetId, e.g.
85
+ `{ "invoice": "asset_a", "gallery[0]": "asset_c", "order.receipt": "asset_e" }`.
86
+
87
+ **Reusing one file across nodes:** if you pass the *same* `file()` instance in
88
+ multiple places, it's uploaded only once and the same `assetId` is used
89
+ everywhere:
90
+
91
+ ```ts
92
+ const banner = ff.file("./banner.png");
93
+ await ff.sendEvent("promo.sent", {
94
+ payload: { header: banner, footer: banner }, // one upload, same assetId in both
95
+ });
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Supplying file bytes other ways
101
+
102
+ `file()` accepts a path (Node), a `Buffer`/`Uint8Array`, or a `Blob`/`File`
103
+ (browser). When the type can't be inferred, pass `filename` and `mime`:
104
+
105
+ ```ts
106
+ // Buffer / Uint8Array
107
+ ff.file(pdfBuffer, { filename: "invoice.pdf", mime: "application/pdf" });
108
+
109
+ // Browser File input
110
+ const f = document.querySelector("input[type=file]").files[0];
111
+ ff.file(f); // name + type read from the File
112
+
113
+ // Override the stored filename
114
+ ff.file("./tmp-7f3a.pdf", { filename: "Invoice-2026.pdf" });
115
+ ```
116
+
117
+ **Allowed types:** PDF, DOC(X), XLS(X), PPT(X), TXT, JPEG, PNG, MP4, 3GPP, AAC,
118
+ AMR, MP3, OGG. **Max size:** 25 MB.
119
+
120
+ ---
121
+
122
+ ## API
123
+
124
+ ### `new FlowFlex(options)`
125
+
126
+ | Option | Type | Required | Notes |
127
+ | ------------------------- | -------- | -------- | --------------------------------------------------------------------- |
128
+ | `apiKey` | string | yes | Custom-integration key (`cik_…`). |
129
+ | `apiSecret` | string | yes | Custom-integration secret. |
130
+ | `integrationCode` | string | yes | The `<code>` in `/v1/events/<code>`. |
131
+ | `baseUrl` | string | yes | FlowFlex host. Must be `https` (except `localhost`). Trailing `/api` stripped. |
132
+ | `timeoutMs` | number | no | Per-request timeout. Default `30000`. |
133
+ | `maxFileBytes` | number | no | Client-side size cap. Default `26214400` (25 MB). |
134
+ | `fetch` | function | no | Custom fetch for runtimes without a global one. |
135
+
136
+ > **Server-only.** There is no option to run this in a browser — it throws if constructed
137
+ > in one, and browser bundlers resolve it to a stub that throws on import. See **Security**.
138
+
139
+ ### `ff.sendEvent(event, { payload?, idempotencyKey? })`
140
+
141
+ Uploads any `file()` in `payload`, then POSTs the event. Returns:
142
+
143
+ ```ts
144
+ {
145
+ response: unknown, // raw body from the events endpoint
146
+ uploadedAssets: Record<string, string>, // payload path → assetId
147
+ }
148
+ ```
149
+
150
+ An `idempotencyKey` is auto-generated (UUID) if you don't pass one — safe to
151
+ retry the same call.
152
+
153
+ ### `ff.file(source, { filename?, mime?, size? })`
154
+
155
+ Returns a lazy `FileRef`. Bytes are read only when the event is sent.
156
+
157
+ ### Lower-level helpers
158
+
159
+ ```ts
160
+ const { assetId, uploadUrl } = await ff.createUploadUrl({ filename, mime });
161
+ const assetId = await ff.uploadFile(ff.file("./x.pdf")); // upload, get assetId
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Errors
167
+
168
+ All errors extend `FlowFlexError` (`{ message, status?, code?, details? }`):
169
+
170
+ - `FlowFlexConfigError` — bad/missing constructor options.
171
+ - `FlowFlexUploadError` — a file couldn't be read or storage rejected it.
172
+ - `FlowFlexError` — API or network failure (`code` is the backend error code,
173
+ e.g. `MIME_NOT_ALLOWED`, `ASSET_FILE_MISSING`).
174
+
175
+ ```ts
176
+ import { FlowFlexError } from "@fantacode/flowflex";
177
+
178
+ try {
179
+ await ff.sendEvent("invoice.created", { payload: { assetId: ff.file("./big.pdf") } });
180
+ } catch (err) {
181
+ if (err instanceof FlowFlexError) {
182
+ console.error(err.code, err.status, err.message);
183
+ }
184
+ }
185
+ ```
186
+
187
+ ---
188
+
189
+ ## File lifetime & storage cleanup
190
+
191
+ > **Important — read before using file attachments in production.**
192
+
193
+ When you call `ff.file(...)`, the file is uploaded to **private Supabase storage**
194
+ that only the FlowFlex backend can read. It is **not** a public URL and the caller
195
+ cannot access it after upload.
196
+
197
+ ### How long does the file stay?
198
+
199
+ | Phase | Duration |
200
+ | ----- | -------- |
201
+ | Presigned upload URL valid | **2 hours** from `createUploadUrl` |
202
+ | File kept in storage | **48 hours** from upload |
203
+ | After 48 hours | File **deleted from storage** + record removed |
204
+
205
+ After the flow delivers the WhatsApp message the file is no longer needed. The
206
+ backend runs an automatic cleanup job every hour that:
207
+
208
+ 1. Finds assets whose 48-hour window has expired
209
+ 2. **Deletes the file from Supabase storage first**
210
+ 3. Then removes the database record
211
+
212
+ This means storage never accumulates — every file is cleaned up within ~1 hour
213
+ of its expiry.
214
+
215
+ ### What this means for you
216
+
217
+ - **Do not store `assetId` long-term** expecting to reuse it. It expires in 48h.
218
+ - **Each event send should get a fresh `assetId`** by calling `ff.sendEvent` with
219
+ a new `ff.file(...)`. The SDK handles the upload automatically.
220
+ - If you fire the event more than 48h after uploading, the file will be gone and
221
+ the flow will fail with `ASSET_FILE_MISSING`. Keep your event send close to
222
+ the upload.
223
+ - **Re-sending the same message** to a different recipient after 48h requires a
224
+ fresh upload — call `sendEvent` again with the file, don't reuse the old assetId.
225
+
226
+ ### Typical correct pattern
227
+
228
+ ```ts
229
+ // ✅ Upload + fire in the same operation — always fresh
230
+ await ff.sendEvent("invoice.created", {
231
+ payload: { assetId: ff.file("./invoice.pdf") },
232
+ });
233
+
234
+ // ❌ Don't store assetId and reuse it hours later
235
+ const { uploadedAssets } = await ff.sendEvent(...);
236
+ // ... 50 hours later ...
237
+ // uploadedAssets["assetId"] is now expired and deleted
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Security
243
+
244
+ **This SDK is server-only.** Your `apiKey`/`apiSecret` are integration-wide
245
+ credentials. Bundling them into front-end code exposes them to anyone who opens
246
+ devtools — they could then fire events and upload files as you. The browser is
247
+ hard-blocked, with no opt-out:
248
+
249
+ - **Runtime** — the constructor **always throws** if it detects a browser.
250
+ - **Bundle time** — browser bundlers (webpack / Vite / Next) resolve the package to
251
+ a stub (via the `"browser"` export condition) that **throws on import**, so the SDK
252
+ can't be built into client code in the first place.
253
+
254
+ Other protections built in:
255
+
256
+ - **HTTPS enforced.** `baseUrl` must be `https://` (only `localhost` may use
257
+ `http`), so Basic-auth credentials are never sent in cleartext.
258
+ - **No credential leakage.** The `Authorization` header is never included in
259
+ error messages, logs, or `FlowFlexError.details`.
260
+ - **Header-injection safe.** The `event` name and `idempotencyKey` are rejected
261
+ if they contain control characters (CRLF).
262
+ - **Path-injection safe.** `integrationCode` is URL-encoded into the request path.
263
+ - **Prototype-pollution safe.** Payload keys like `__proto__` can't pollute
264
+ `Object.prototype` during the file-swap walk.
265
+ - **Per-request timeouts** (default 30 s) on every network call.
266
+ - **Client-side size cap** (default 25 MB) so oversized files fail before upload.
267
+
268
+ If you must call FlowFlex from a browser, proxy through your own backend: the
269
+ browser talks to your server, your server holds the secret and calls this SDK.
270
+
271
+ ## How it works
272
+
273
+ ```
274
+ ff.sendEvent("invoice.created", { payload: { assetId: file("invoice.pdf") } })
275
+
276
+ ├─ 1. POST /v1/assets/upload-url → { assetId, uploadUrl }
277
+ ├─ 2. PUT <uploadUrl> (file bytes straight to private storage)
278
+ └─ 3. POST /v1/events/<code> with { assetId: "asset_…" }
279
+
280
+ └─ flow runs; media node resolves assetId → WhatsApp media at send time
281
+ ```
282
+
283
+ The caller only ever holds the opaque `assetId`. There is no readable link to
284
+ the file — the backend resolves it server-side when the flow delivers the message.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Browser build of `flowflex` — intentionally non-functional.
3
+ *
4
+ * Bundlers that target the browser resolve the package to this module via the
5
+ * `"browser"` export condition (and legacy `"browser"` field) in package.json.
6
+ *
7
+ * The SDK is **server-only**: it authenticates with your integration
8
+ * `apiKey`/`apiSecret` over HTTP Basic auth, and those credentials must never
9
+ * reach client code. Importing or using it in the browser throws immediately.
10
+ * Call the SDK from your backend (e.g. a Next.js route handler / API route) and
11
+ * have the browser talk to your own server endpoint instead.
12
+ */
13
+ export declare class FlowFlexError extends Error {
14
+ constructor(message?: string);
15
+ }
16
+ export declare class FlowFlexConfigError extends FlowFlexError {
17
+ }
18
+ export declare class FlowFlexUploadError extends FlowFlexError {
19
+ }
20
+ export declare class FlowFlex {
21
+ constructor();
22
+ }
23
+ export declare class FileRef {
24
+ constructor();
25
+ }
26
+ export declare function file(): never;
27
+ export default FlowFlex;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ /**
3
+ * Browser build of `flowflex` — intentionally non-functional.
4
+ *
5
+ * Bundlers that target the browser resolve the package to this module via the
6
+ * `"browser"` export condition (and legacy `"browser"` field) in package.json.
7
+ *
8
+ * The SDK is **server-only**: it authenticates with your integration
9
+ * `apiKey`/`apiSecret` over HTTP Basic auth, and those credentials must never
10
+ * reach client code. Importing or using it in the browser throws immediately.
11
+ * Call the SDK from your backend (e.g. a Next.js route handler / API route) and
12
+ * have the browser talk to your own server endpoint instead.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.FileRef = exports.FlowFlex = exports.FlowFlexUploadError = exports.FlowFlexConfigError = exports.FlowFlexError = void 0;
16
+ exports.file = file;
17
+ const MESSAGE = "flowflex is server-only and must not be bundled into browser/client code. " +
18
+ "It uses your integration apiKey/apiSecret over HTTP Basic auth — exposing those in the " +
19
+ "browser lets anyone fire events as you. Call the SDK from your server (e.g. a Next.js " +
20
+ "route handler / API route) and have the browser talk to your own backend endpoint.";
21
+ class FlowFlexError extends Error {
22
+ constructor(message = MESSAGE) {
23
+ super(message);
24
+ this.name = "FlowFlexError";
25
+ }
26
+ }
27
+ exports.FlowFlexError = FlowFlexError;
28
+ class FlowFlexConfigError extends FlowFlexError {
29
+ }
30
+ exports.FlowFlexConfigError = FlowFlexConfigError;
31
+ class FlowFlexUploadError extends FlowFlexError {
32
+ }
33
+ exports.FlowFlexUploadError = FlowFlexUploadError;
34
+ class FlowFlex {
35
+ constructor() {
36
+ throw new FlowFlexConfigError(MESSAGE);
37
+ }
38
+ }
39
+ exports.FlowFlex = FlowFlex;
40
+ class FileRef {
41
+ constructor() {
42
+ throw new FlowFlexConfigError(MESSAGE);
43
+ }
44
+ }
45
+ exports.FileRef = FileRef;
46
+ function file() {
47
+ throw new FlowFlexConfigError(MESSAGE);
48
+ }
49
+ exports.default = FlowFlex;
50
+ // Fail loudly the moment this server-only module is evaluated in a browser
51
+ // bundle — before any FlowFlex is even constructed.
52
+ throw new FlowFlexConfigError(MESSAGE);
@@ -0,0 +1,55 @@
1
+ import { FlowFlexOptions, SendEventOptions, SendEventResult, UploadUrlResponse, FileOptions } from "./types.js";
2
+ import { FileRef, FileSource } from "./file.js";
3
+ export declare class FlowFlex {
4
+ private readonly apiKey;
5
+ private readonly apiSecret;
6
+ private readonly integrationCode;
7
+ private readonly baseUrl;
8
+ private readonly timeoutMs;
9
+ private readonly fetchImpl;
10
+ private readonly maxFileBytes;
11
+ constructor(options: FlowFlexOptions);
12
+ /** Convenience re-export so callers can do `ff.file(...)`. */
13
+ file(source: FileSource, options?: FileOptions): FileRef;
14
+ private authHeader;
15
+ /** fetch wrapper with timeout + JSON parsing + error normalization. */
16
+ private request;
17
+ /**
18
+ * Request a presigned upload URL. Most callers don't need this directly —
19
+ * use `file()` inside an event payload and let `sendEvent` handle uploads.
20
+ */
21
+ createUploadUrl(input: {
22
+ filename: string;
23
+ mime: string;
24
+ size?: number;
25
+ }): Promise<UploadUrlResponse>;
26
+ /** Upload one FileRef and return its assetId. */
27
+ uploadFile(ref: FileRef): Promise<string>;
28
+ /**
29
+ * Deep-clone a payload, collecting every FileRef anywhere inside it (nested
30
+ * objects, arrays, mixed) alongside its parent container + key so we can
31
+ * patch the assetId back in after upload. The container always exists at
32
+ * push time, so there's no backfill to get wrong.
33
+ */
34
+ private cloneAndCollect;
35
+ /**
36
+ * Upload every FileRef in a payload and return a deep copy with each replaced
37
+ * by its assetId string, plus a map of path → assetId. Uploads run in
38
+ * parallel; the SAME FileRef instance reused in multiple places is uploaded
39
+ * only once and its assetId reused everywhere.
40
+ */
41
+ private resolveFiles;
42
+ /**
43
+ * Fire a custom-integration event into FlowFlex. Any `file()` in the payload
44
+ * is uploaded first and replaced with its assetId, so a flow's media node can
45
+ * reference it as e.g. `{{trigger.assetId}}`.
46
+ *
47
+ * @example
48
+ * await ff.sendEvent("invoice.created", {
49
+ * payload: { assetId: ff.file("./invoice.pdf"), name: "Ada" },
50
+ * });
51
+ */
52
+ sendEvent(event: string, body?: {
53
+ payload?: Record<string, unknown>;
54
+ } & SendEventOptions): Promise<SendEventResult>;
55
+ }