@sprigr/apps-app-sdk 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 +10 -0
- package/dist/index.d.ts +629 -0
- package/dist/index.js +298 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @sprigr/apps-app-sdk
|
|
2
|
+
|
|
3
|
+
Shared handler types + helpers for apps in this repo.
|
|
4
|
+
|
|
5
|
+
Scaffold today: just the type contracts the Sprigr wrapper expects. P1 adds:
|
|
6
|
+
|
|
7
|
+
- `fetchWithRetry` (rate-limit-header-aware, jittered retry)
|
|
8
|
+
- `constantTimeEqual(a, b)` for bearer-secret verification
|
|
9
|
+
- `encodeState` / `decodeState` for OAuth state base64url
|
|
10
|
+
- Anything else extracted from `apps/procore` once it stabilises
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto helpers for marketplace-app handlers.
|
|
3
|
+
*
|
|
4
|
+
* All operations use SubtleCrypto from the Web Crypto API — available in
|
|
5
|
+
* Cloudflare Workers and Node 20+. No node:crypto imports.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Constant-time string compare. Use for bearer-secret / signature
|
|
9
|
+
* verification — never `a === b` with secrets, that leaks length via
|
|
10
|
+
* early exit.
|
|
11
|
+
*/
|
|
12
|
+
declare function constantTimeEqual(a: string, b: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* HMAC-SHA256 → lowercase hex string. Useful for verifying signed
|
|
15
|
+
* payloads from providers that DO sign (Shopify, Slack, etc.). Procore
|
|
16
|
+
* does NOT sign webhook bodies — for Procore we mint our own bearer
|
|
17
|
+
* secret and constant-time-compare.
|
|
18
|
+
*/
|
|
19
|
+
declare function hmacSha256Hex(secret: string, message: string): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Cryptographically random hex string. Used for minting webhook
|
|
22
|
+
* secrets, OAuth state CSRF tokens, etc.
|
|
23
|
+
*/
|
|
24
|
+
declare function randomHex(bytes?: number): string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* OAuth `state` parameter encode / decode.
|
|
28
|
+
*
|
|
29
|
+
* Carries a small bag of context (install_id, csrf, target environment,
|
|
30
|
+
* return URL) through the provider's authorize → callback round trip.
|
|
31
|
+
* Base64url-encoded JSON so it survives URL encoding without escaping.
|
|
32
|
+
*
|
|
33
|
+
* IMPORTANT: state is observable by the user — never include secrets,
|
|
34
|
+
* tokens, or PII. The CSRF token is the only "secret" and is checked
|
|
35
|
+
* against the install's pending-flow store, not used to authorize
|
|
36
|
+
* anything directly.
|
|
37
|
+
*/
|
|
38
|
+
interface OAuthState {
|
|
39
|
+
/** Sprigr install ID this OAuth flow belongs to. */
|
|
40
|
+
installId: string;
|
|
41
|
+
/** Random CSRF token; the callback compares it against the install's stored pending value. */
|
|
42
|
+
csrf: string;
|
|
43
|
+
/** Procore environment to use ('prod' | 'sandbox' | 'monthly'). */
|
|
44
|
+
environment?: string;
|
|
45
|
+
/** Optional return URL after the flow completes. */
|
|
46
|
+
returnTo?: string;
|
|
47
|
+
/** Issued-at — useful for expiring stale state. */
|
|
48
|
+
iat: number;
|
|
49
|
+
/**
|
|
50
|
+
* Sprigr actor initiating the flow — the OIDC sub of the user clicking
|
|
51
|
+
* "Connect". Used by apps with per-actor OAuth (simPRO) so the callback
|
|
52
|
+
* knows which actor's token row to write. Absent for apps with one
|
|
53
|
+
* connection per install (procore, shopify).
|
|
54
|
+
*/
|
|
55
|
+
actorPlatformUserId?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Agent id when the flow was initiated by an unbound agent (no platform
|
|
58
|
+
* user). Apps that key per-actor state fall back to this when
|
|
59
|
+
* actorPlatformUserId is absent.
|
|
60
|
+
*/
|
|
61
|
+
actorAgentId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Per-tenant provider host (e.g. simPRO's `https://acme.simprosuite.com`).
|
|
64
|
+
* Carried in state so the callback can re-use the same host the start
|
|
65
|
+
* route validated, without re-querying the user.
|
|
66
|
+
*/
|
|
67
|
+
buildUrl?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Auth method discriminator for providers that support multiple flows
|
|
70
|
+
* (simPRO: oauth / client_credentials / api_key). Absent for single-
|
|
71
|
+
* method providers.
|
|
72
|
+
*/
|
|
73
|
+
authMethod?: string;
|
|
74
|
+
}
|
|
75
|
+
declare function encodeState(state: OAuthState): string;
|
|
76
|
+
declare function decodeState(encoded: string): OAuthState;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* fetchWithRetry — rate-limit-header-aware retry wrapper around fetch.
|
|
80
|
+
*
|
|
81
|
+
* Retries on:
|
|
82
|
+
* - 429 Too Many Requests — honors `Retry-After` (seconds) and
|
|
83
|
+
* `X-RateLimit-Reset` (epoch seconds) when present, else
|
|
84
|
+
* exponential-backoff with jitter.
|
|
85
|
+
* - 5xx — exponential backoff.
|
|
86
|
+
* - Network errors (TypeError thrown by fetch) — exponential backoff.
|
|
87
|
+
*
|
|
88
|
+
* Does NOT retry: 4xx other than 429 (caller's bug), redirects, 401
|
|
89
|
+
* (auth — caller decides whether to refresh + retry).
|
|
90
|
+
*/
|
|
91
|
+
interface FetchWithRetryOptions {
|
|
92
|
+
maxAttempts?: number;
|
|
93
|
+
/** Base backoff in ms; doubled each attempt and jittered ±30%. */
|
|
94
|
+
baseBackoffMs?: number;
|
|
95
|
+
/** Hard cap on total wait across all retries (ms). */
|
|
96
|
+
maxTotalWaitMs?: number;
|
|
97
|
+
/** Called with each attempt's failure metadata; useful for logging. */
|
|
98
|
+
onRetry?: (info: {
|
|
99
|
+
attempt: number;
|
|
100
|
+
status: number | null;
|
|
101
|
+
waitMs: number;
|
|
102
|
+
reason: string;
|
|
103
|
+
}) => void;
|
|
104
|
+
}
|
|
105
|
+
declare function fetchWithRetry(input: RequestInfo | URL, init?: RequestInit, opts?: FetchWithRetryOptions): Promise<Response>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* File helpers for marketplace apps that move binary blobs (attachment
|
|
109
|
+
* upload/download, document fetch). Centralises two foot-guns that bit the
|
|
110
|
+
* platform's own agent runtime before they were caught:
|
|
111
|
+
*
|
|
112
|
+
* 1. Encoding bytes to base64 with `String.fromCharCode(...bytes)` (throws
|
|
113
|
+
* `RangeError` past ~64 KB) or a byte-at-a-time `binary += ...` loop
|
|
114
|
+
* (O(n^2) — a 15 MB file pegged the isolate and OOM'd it). `bytesToBase64`
|
|
115
|
+
* builds the binary string in 32 KB chunks: linear and arity-safe.
|
|
116
|
+
*
|
|
117
|
+
* 2. Fetching a caller-supplied URL with no size limit and holding the whole
|
|
118
|
+
* body in memory. `fetchFileBytes` enforces a byte ceiling (checks the
|
|
119
|
+
* Content-Length header up front AND the materialised body) so an oversized
|
|
120
|
+
* file fails fast with a clear error instead of OOMing the worker.
|
|
121
|
+
*
|
|
122
|
+
* App workers run under Worker-for-Platforms dispatch with the same ~128 MB
|
|
123
|
+
* memory ceiling and a ~25-30 s wall as the agent isolate, so the same
|
|
124
|
+
* discipline applies: never base64 with a byte-at-a-time loop, never pull an
|
|
125
|
+
* unbounded remote file fully into memory. For files larger than a few MB,
|
|
126
|
+
* prefer a Sprigr file-proxy URL reference over inline bytes.
|
|
127
|
+
*/
|
|
128
|
+
/** Encode raw bytes as a standard base64 string. */
|
|
129
|
+
declare function bytesToBase64(input: ArrayBuffer | Uint8Array): string;
|
|
130
|
+
/** Decode a base64 string back to bytes. Tolerates embedded whitespace. */
|
|
131
|
+
declare function base64ToBytes(b64: string): Uint8Array;
|
|
132
|
+
/**
|
|
133
|
+
* Default ceiling for a single file an app pulls into memory. simPRO/Procore
|
|
134
|
+
* non-segmented uploads are practical only up to a few MB anyway; this leaves
|
|
135
|
+
* headroom while still refusing the multi-tens-of-MB file that would OOM the
|
|
136
|
+
* dispatch worker.
|
|
137
|
+
*/
|
|
138
|
+
declare const DEFAULT_MAX_FILE_BYTES: number;
|
|
139
|
+
interface FetchFileResult {
|
|
140
|
+
bytes: Uint8Array;
|
|
141
|
+
contentType: string;
|
|
142
|
+
size: number;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Fetch a URL into memory with a hard byte ceiling. Throws (with a message the
|
|
146
|
+
* agent can read) when the file is over `maxBytes`, checking the declared
|
|
147
|
+
* Content-Length first to avoid even downloading an oversized body when the
|
|
148
|
+
* server reports its size.
|
|
149
|
+
*
|
|
150
|
+
* Use this for any "the app fetches a caller-supplied URL" path — e.g. a
|
|
151
|
+
* Sprigr file-proxy signed URL passed in lieu of inline base64.
|
|
152
|
+
*/
|
|
153
|
+
declare function fetchFileBytes(url: string, opts?: {
|
|
154
|
+
maxBytes?: number;
|
|
155
|
+
init?: RequestInit;
|
|
156
|
+
}): Promise<FetchFileResult>;
|
|
157
|
+
/** Convenience: {@link fetchFileBytes} then base64-encode the result. */
|
|
158
|
+
declare function fetchFileAsBase64(url: string, opts?: {
|
|
159
|
+
maxBytes?: number;
|
|
160
|
+
init?: RequestInit;
|
|
161
|
+
}): Promise<{
|
|
162
|
+
base64: string;
|
|
163
|
+
contentType: string;
|
|
164
|
+
size: number;
|
|
165
|
+
}>;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Per-env resolver for the platform webhook host that marketplace
|
|
169
|
+
* apps use as the callback URL when registering provider webhooks.
|
|
170
|
+
*
|
|
171
|
+
* Why this exists:
|
|
172
|
+
* Every app that registers webhooks with a third-party provider at
|
|
173
|
+
* OAuth-completion time has to tell the provider "POST inbound events
|
|
174
|
+
* to <some URL>". For the apps that route through the marketplace
|
|
175
|
+
* runtime's dispatcher (e.g. shopify -> /webhook/marketplace/{installId}/{topic}),
|
|
176
|
+
* that URL has to point at the env-correct platform host:
|
|
177
|
+
*
|
|
178
|
+
* - prod : https://webhooks.sprigr.com
|
|
179
|
+
* - staging : https://staging-webhooks.sprigr.com
|
|
180
|
+
*
|
|
181
|
+
* The marketplace build-runner already injects this value as
|
|
182
|
+
* `env.SPRIGR_PLATFORM_BASE` on every per-install WFP upload (platform
|
|
183
|
+
* build-runner). Apps
|
|
184
|
+
* just need to read it.
|
|
185
|
+
*
|
|
186
|
+
* The bug this prevents:
|
|
187
|
+
* Hardcoding `'https://webhooks.sprigr.com'` as the default makes a
|
|
188
|
+
* staging install register prod-pointing webhook subscriptions with
|
|
189
|
+
* the provider. The provider duly fires events at prod; prod's
|
|
190
|
+
* platform doesn't know the staging install id, so the webhooks
|
|
191
|
+
* silently fail (404s land in the *prod* tail, invisible to the
|
|
192
|
+
* staging operator). The staging chain stays dead until someone
|
|
193
|
+
* notices the empty inbound queue and traces it back.
|
|
194
|
+
*
|
|
195
|
+
* When NOT to use this:
|
|
196
|
+
* Apps that receive webhooks on their OWN per-install URL (e.g.
|
|
197
|
+
* procore POSTs to `<procore-XXX.staging-apps.sprigr.com>/api/webhook/procore`)
|
|
198
|
+
* don't go through the marketplace runtime dispatcher. Those apps
|
|
199
|
+
* derive the callback URL from the inbound request's `Host` header
|
|
200
|
+
* instead, which is naturally env-correct. This helper is only for
|
|
201
|
+
* the marketplace-dispatcher pattern.
|
|
202
|
+
*/
|
|
203
|
+
/**
|
|
204
|
+
* Minimal env shape this helper reads. Apps' ShopifyEnv /
|
|
205
|
+
* ProcoreEnv / etc. all extend this; passing the full env is fine.
|
|
206
|
+
*/
|
|
207
|
+
interface PlatformHostEnv {
|
|
208
|
+
SPRIGR_PLATFORM_BASE?: string;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Returns the marketplace platform's webhook ingest host for the
|
|
212
|
+
* current env. Trailing slash is always stripped so callers can
|
|
213
|
+
* concatenate path segments directly.
|
|
214
|
+
*
|
|
215
|
+
* const base = resolvePlatformWebhookBase(env);
|
|
216
|
+
* // staging: "https://staging-webhooks.sprigr.com"
|
|
217
|
+
* // prod: "https://webhooks.sprigr.com"
|
|
218
|
+
* const callbackUrl = `${base}/webhook/marketplace/${installId}/${topicPath}`;
|
|
219
|
+
*
|
|
220
|
+
* Caller-supplied `override` wins over the env var, useful for
|
|
221
|
+
* one-off migration scripts and tests.
|
|
222
|
+
*/
|
|
223
|
+
declare function resolvePlatformWebhookBase(env: PlatformHostEnv, override?: string | null): string;
|
|
224
|
+
/**
|
|
225
|
+
* Convenience wrapper for the most common shape: build the full
|
|
226
|
+
* marketplace-dispatcher webhook URL for a given install + topic
|
|
227
|
+
* path.
|
|
228
|
+
*
|
|
229
|
+
* buildMarketplaceWebhookUrl(env, 'inst_abc', 'orders-create')
|
|
230
|
+
* // -> "https://staging-webhooks.sprigr.com/webhook/marketplace/inst_abc/orders-create"
|
|
231
|
+
*
|
|
232
|
+
* The `topicPath` argument is the manifest path segment (e.g.
|
|
233
|
+
* `orders-create`, not `orders/create`). Apps that need the slash
|
|
234
|
+
* form should pre-translate before calling.
|
|
235
|
+
*/
|
|
236
|
+
declare function buildMarketplaceWebhookUrl(env: PlatformHostEnv, installId: string, topicPath: string, override?: string | null): string;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Caller identity stamped by the platform on agent-initiated WFP dispatches.
|
|
240
|
+
*
|
|
241
|
+
* Three platform-controlled headers carry the actor on every dispatch:
|
|
242
|
+
*
|
|
243
|
+
* x-sprigr-actor-agent-id — calling agent's id (always present
|
|
244
|
+
* when an agent is the caller)
|
|
245
|
+
* x-sprigr-actor-platform-user-id — OIDC sub of the user the agent is
|
|
246
|
+
* bound to (absent for unbound agents)
|
|
247
|
+
* x-sprigr-actor-role — bound user's display role at the
|
|
248
|
+
* company; informational only
|
|
249
|
+
*
|
|
250
|
+
* The sprigr-wrapper parses these and surfaces an `Actor` via `args.actor`
|
|
251
|
+
* to handler code. Apps that need per-user scoping (per-actor OAuth tokens,
|
|
252
|
+
* per-user audit) must read identity from `args.actor` only — body fields
|
|
253
|
+
* are agent-supplied and spoofable.
|
|
254
|
+
*
|
|
255
|
+
* Wired in the platform dispatch wrapper.
|
|
256
|
+
*/
|
|
257
|
+
interface Actor {
|
|
258
|
+
/** Calling agent's id. Always present when dispatch is agent-initiated. */
|
|
259
|
+
agentId?: string;
|
|
260
|
+
/** OIDC sub of the user the agent is bound to. Apps key per-user state on
|
|
261
|
+
* this when set; falls back to agentId when the agent has no bound user. */
|
|
262
|
+
platformUserId?: string;
|
|
263
|
+
/** Bound user's display role at the company. Display + audit only — apps
|
|
264
|
+
* must not gate authorisation on this without server-side verification. */
|
|
265
|
+
role?: string;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Parse the platform-stamped actor headers off an incoming request. Returns
|
|
269
|
+
* undefined when no actor headers are present (webhook receivers, schedule
|
|
270
|
+
* firers, event consumers — no per-call user). Returns a partial actor when
|
|
271
|
+
* at least one header is set.
|
|
272
|
+
*
|
|
273
|
+
* Apps that require caller identity must treat `undefined` as a hard fail
|
|
274
|
+
* (return 412 with a clear hint) rather than fall back to install-wide
|
|
275
|
+
* tokens — see plan risk R13.
|
|
276
|
+
*
|
|
277
|
+
* const actor = parseActor(args);
|
|
278
|
+
* if (!actor?.platformUserId && !actor?.agentId) {
|
|
279
|
+
* return jsonError(412, 'no_caller_identity');
|
|
280
|
+
* }
|
|
281
|
+
*
|
|
282
|
+
* Usage: pass the wrapper's `args` object directly. The wrapper writes
|
|
283
|
+
* `args.actor` from the headers; this helper just narrows the type and
|
|
284
|
+
* normalises the shape.
|
|
285
|
+
*/
|
|
286
|
+
declare function parseActor(args: unknown): Actor | undefined;
|
|
287
|
+
/**
|
|
288
|
+
* Stable key for per-actor state in per-install D1. Prefers platformUserId
|
|
289
|
+
* (durable across agent rebinds) and falls back to agentId for unbound
|
|
290
|
+
* agents (orchestrator, system).
|
|
291
|
+
*
|
|
292
|
+
* const tokens = await db
|
|
293
|
+
* .prepare('SELECT * FROM simpro_actor_tokens WHERE actor_key = ?')
|
|
294
|
+
* .bind(actorKey(actor))
|
|
295
|
+
* .first();
|
|
296
|
+
*
|
|
297
|
+
* Returns null when the actor has neither field set — callers should
|
|
298
|
+
* treat that as "no caller identity" and fail closed.
|
|
299
|
+
*/
|
|
300
|
+
declare function actorKey(actor: Actor | undefined): string | null;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* App-scoped R2 file storage (platform feature, 2026-06).
|
|
304
|
+
*
|
|
305
|
+
* Marketplace apps get a contained file store: every key is confined
|
|
306
|
+
* server-side to `_apps/{installId}/...` so an app can only ever touch
|
|
307
|
+
* its own files. The app never holds a raw R2 binding — these helpers
|
|
308
|
+
* call the platform over the same per-install token channel as
|
|
309
|
+
* `env.SPRIGR.emit` (provisioning `/internal/wfp/file/*`).
|
|
310
|
+
*
|
|
311
|
+
* Auth: `Authorization: Bearer <SPRIGR_INSTALL_TOKEN>` against
|
|
312
|
+
* `<SPRIGR_PLATFORM_BASE>/internal/wfp/file/<op>`. Both bindings are
|
|
313
|
+
* stamped onto every per-install WFP upload.
|
|
314
|
+
*
|
|
315
|
+
* Keys are app-relative (`simpro-files/123.jpg`); the platform adds and
|
|
316
|
+
* strips the `_apps/{installId}/` prefix, so the app stays unaware of it
|
|
317
|
+
* and physically cannot escape its namespace.
|
|
318
|
+
*/
|
|
319
|
+
interface AppFilesEnv {
|
|
320
|
+
SPRIGR_INSTALL_TOKEN?: string;
|
|
321
|
+
SPRIGR_PLATFORM_BASE?: string;
|
|
322
|
+
}
|
|
323
|
+
interface PutAppFileArgs {
|
|
324
|
+
/** App-relative key, e.g. "simpro-files/job-1234/55.jpg". */
|
|
325
|
+
key: string;
|
|
326
|
+
/** File bytes, base64-encoded. */
|
|
327
|
+
base64: string;
|
|
328
|
+
/** MIME type stored as the object's content-type. */
|
|
329
|
+
contentType?: string;
|
|
330
|
+
/** Optional display filename. */
|
|
331
|
+
filename?: string;
|
|
332
|
+
}
|
|
333
|
+
interface PutAppFileResult {
|
|
334
|
+
ok: true;
|
|
335
|
+
key: string;
|
|
336
|
+
bytes: number;
|
|
337
|
+
contentType: string;
|
|
338
|
+
}
|
|
339
|
+
interface PutAppFileStreamArgs {
|
|
340
|
+
/** App-relative key, e.g. "simpro-files/job-1234/55.jpg". */
|
|
341
|
+
key: string;
|
|
342
|
+
/**
|
|
343
|
+
* Raw file bytes. A `ReadableStream` is piped straight through to R2 so
|
|
344
|
+
* the bytes never fully materialise in the app isolate — use it for large
|
|
345
|
+
* blobs (full-res photos, PDFs). An `ArrayBuffer`/`Uint8Array` is also
|
|
346
|
+
* accepted when the bytes are already in hand.
|
|
347
|
+
*/
|
|
348
|
+
body: ReadableStream<Uint8Array> | ArrayBuffer | Uint8Array;
|
|
349
|
+
/** MIME type stored as the object's content-type. */
|
|
350
|
+
contentType?: string;
|
|
351
|
+
/** Optional display filename. */
|
|
352
|
+
filename?: string;
|
|
353
|
+
/**
|
|
354
|
+
* Declared byte length. Forward it when known (e.g. the source's
|
|
355
|
+
* Content-Length) so the platform can enforce its size cap before opening
|
|
356
|
+
* the R2 write. Streamed bodies carry no Content-Length, so this is the
|
|
357
|
+
* only length signal the platform gets. Omit for unknown-length streams.
|
|
358
|
+
*/
|
|
359
|
+
contentLength?: number;
|
|
360
|
+
}
|
|
361
|
+
interface AppFileUrlArgs {
|
|
362
|
+
key: string;
|
|
363
|
+
/** Seconds the signed URL stays valid (default 24h, min 60s, max ~10y, clamped server-side). */
|
|
364
|
+
expiresIn?: number;
|
|
365
|
+
}
|
|
366
|
+
interface AppFileUrlResult {
|
|
367
|
+
ok: true;
|
|
368
|
+
url: string;
|
|
369
|
+
expires_at: number;
|
|
370
|
+
key: string;
|
|
371
|
+
}
|
|
372
|
+
interface GetAppFileResult {
|
|
373
|
+
ok: true;
|
|
374
|
+
key: string;
|
|
375
|
+
base64: string;
|
|
376
|
+
contentType: string;
|
|
377
|
+
filename?: string;
|
|
378
|
+
bytes: number;
|
|
379
|
+
}
|
|
380
|
+
interface AppFileListItem {
|
|
381
|
+
key: string;
|
|
382
|
+
bytes: number;
|
|
383
|
+
uploaded?: string;
|
|
384
|
+
filename?: string;
|
|
385
|
+
contentType?: string;
|
|
386
|
+
}
|
|
387
|
+
/** Store bytes in the app's contained R2 namespace (base64 JSON). */
|
|
388
|
+
declare function putAppFile(env: AppFilesEnv, args: PutAppFileArgs): Promise<PutAppFileResult>;
|
|
389
|
+
/**
|
|
390
|
+
* Store bytes by streaming the raw body straight into the app's contained
|
|
391
|
+
* R2 namespace. Unlike `putAppFile`, this never base64-encodes the bytes
|
|
392
|
+
* and (with a `ReadableStream` body) never buffers them whole in the
|
|
393
|
+
* isolate — pipe a source `Response.body` directly through for multi-MB
|
|
394
|
+
* blobs. Metadata travels in headers; the body is opaque bytes.
|
|
395
|
+
*/
|
|
396
|
+
declare function putAppFileStream(env: AppFilesEnv, args: PutAppFileStreamArgs): Promise<PutAppFileResult>;
|
|
397
|
+
/** Mint a signed, time-limited download URL for an app file. */
|
|
398
|
+
declare function appFileUrl(env: AppFilesEnv, args: AppFileUrlArgs): Promise<AppFileUrlResult>;
|
|
399
|
+
/** Read an app file back as base64. */
|
|
400
|
+
declare function getAppFile(env: AppFilesEnv, key: string): Promise<GetAppFileResult>;
|
|
401
|
+
/** List app files under an optional app-relative prefix. */
|
|
402
|
+
declare function listAppFiles(env: AppFilesEnv, prefix?: string): Promise<{
|
|
403
|
+
ok: true;
|
|
404
|
+
files: AppFileListItem[];
|
|
405
|
+
truncated: boolean;
|
|
406
|
+
}>;
|
|
407
|
+
/** Delete an app file. */
|
|
408
|
+
declare function deleteAppFile(env: AppFilesEnv, key: string): Promise<{
|
|
409
|
+
ok: true;
|
|
410
|
+
key: string;
|
|
411
|
+
}>;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* @sprigr/apps-app-sdk
|
|
415
|
+
*
|
|
416
|
+
* Shared types and helpers for marketplace-app handlers in this repo.
|
|
417
|
+
*/
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Identifies the upstream third-party integration that sourced a
|
|
421
|
+
* bridged marketplace event. Apps that wrap a provider webhook
|
|
422
|
+
* (Shopify orders, Procore RFI updates, ...) stamp this on each
|
|
423
|
+
* emit so subscribers can call write-backs through the platform's
|
|
424
|
+
* per-integration forwarder.
|
|
425
|
+
*
|
|
426
|
+
* Mirrors the platform-side `MarketplaceEventSourceIntegration`
|
|
427
|
+
* type in the Sprigr platform source. Keep
|
|
428
|
+
* the field set + nullability in sync — the queue-consumer's
|
|
429
|
+
* marketplace-event-handler reads these unconditionally on the
|
|
430
|
+
* dispatch path, so a missing `integrationId` or `integrationType`
|
|
431
|
+
* is a hard error (400 from /internal/wfp/emit).
|
|
432
|
+
*/
|
|
433
|
+
interface MarketplaceEventSourceIntegration {
|
|
434
|
+
/** Stable identifier for the source integration. For marketplace-
|
|
435
|
+
* bridged events, this is typically the source app's INSTALL_ID. */
|
|
436
|
+
integrationId: string;
|
|
437
|
+
/** Integration type / slug (e.g. 'shopify'). Matches the manifest's
|
|
438
|
+
* `integration_dependencies[].integration_type` so the platform
|
|
439
|
+
* forwarder can route write-backs by type. */
|
|
440
|
+
integrationType: string;
|
|
441
|
+
/** Optional human-friendly identifier (e.g. Shopify shop domain
|
|
442
|
+
* `brand-a.myshopify.com`). Useful for app-side logging / display;
|
|
443
|
+
* use `integrationId` for routing. */
|
|
444
|
+
shopDomain?: string;
|
|
445
|
+
/** Opaque service key the marketplace app supplied when it
|
|
446
|
+
* registered a FulfillmentService (or equivalent per-resource
|
|
447
|
+
* partition). Set ONLY when the event is scoped to one
|
|
448
|
+
* registration; absent for shop-wide events. */
|
|
449
|
+
fulfillmentServiceKey?: string;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Optional metadata forwarded with `env.SPRIGR.emit`. The platform-
|
|
453
|
+
* side `/internal/wfp/emit` endpoint passes each field through to
|
|
454
|
+
* `emitMarketplaceEvent`.
|
|
455
|
+
*/
|
|
456
|
+
interface MarketplaceEventEmitOpts {
|
|
457
|
+
/** Stable identifier for the source integration; see
|
|
458
|
+
* MarketplaceEventSourceIntegration. */
|
|
459
|
+
sourceIntegration?: MarketplaceEventSourceIntegration;
|
|
460
|
+
/** Pin delivery to a single install in the receiving tenant.
|
|
461
|
+
* Omit for broadcast (the default). */
|
|
462
|
+
targetAppInstallationId?: string;
|
|
463
|
+
/** Reserved for future at-least-once dedup. Apps can set it now;
|
|
464
|
+
* the platform stores it but doesn't enforce dedup yet. */
|
|
465
|
+
dedupId?: string;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Options for `env.SPRIGR.data.search`. Mirrors the platform's
|
|
469
|
+
* /internal/wfp/data/search body (the platform provisioning worker). All fields optional; an
|
|
470
|
+
* empty call returns the first page of everything.
|
|
471
|
+
*/
|
|
472
|
+
interface SprigrDataSearchOpts {
|
|
473
|
+
/** Full-text query. Combine with `semantic: true` for hybrid search. */
|
|
474
|
+
query?: string;
|
|
475
|
+
/** Facet filter string, e.g. 'status:active,type:customer'. */
|
|
476
|
+
filters?: string;
|
|
477
|
+
/** Hybrid keyword + vector search. Best for fuzzy human references. */
|
|
478
|
+
semantic?: boolean;
|
|
479
|
+
/** Sort expression, e.g. 'updatedAt:desc'. */
|
|
480
|
+
sortBy?: string;
|
|
481
|
+
/** Facet names to count in the response. */
|
|
482
|
+
facets?: string[];
|
|
483
|
+
/** Restrict returned fields to keep responses lean. */
|
|
484
|
+
attributesToRetrieve?: string[];
|
|
485
|
+
/** Zero-based page. Default 0. */
|
|
486
|
+
page?: number;
|
|
487
|
+
/** Results per page. Default 20, platform-clamped to 100. */
|
|
488
|
+
hitsPerPage?: number;
|
|
489
|
+
}
|
|
490
|
+
interface SprigrDataSearchResult {
|
|
491
|
+
ok: boolean;
|
|
492
|
+
hits: Array<{
|
|
493
|
+
objectID: string;
|
|
494
|
+
[key: string]: unknown;
|
|
495
|
+
}>;
|
|
496
|
+
nbHits: number;
|
|
497
|
+
page: number;
|
|
498
|
+
facetCounts?: Record<string, Record<string, number>>;
|
|
499
|
+
/** The private index that was queried (informational). */
|
|
500
|
+
index: string;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The marketplace install's PRIVATE per-company datastore: a Sprigr
|
|
504
|
+
* index named `<companyId>-app-<slug>`, reachable only through these
|
|
505
|
+
* methods (agents have no direct index access). Injected on
|
|
506
|
+
* `env.SPRIGR` by the platform wrapper for /__sprigr/* dispatch.
|
|
507
|
+
*
|
|
508
|
+
* Intended pattern: the app syncs lean provider objects via `import`,
|
|
509
|
+
* its tool handlers call `search` to resolve a fuzzy human reference
|
|
510
|
+
* ("customer Joe Bloggs") to an entity ID, then fetch live detail from
|
|
511
|
+
* the provider REST API by that ID. The index is a resolver, not a
|
|
512
|
+
* cache of record.
|
|
513
|
+
*/
|
|
514
|
+
interface SprigrDataApi {
|
|
515
|
+
/**
|
|
516
|
+
* Upsert objects into the private index. Each object MUST carry a
|
|
517
|
+
* unique string `objectID`. The index is auto-created on first
|
|
518
|
+
* import from the manifest's `data_index` settings; re-imports
|
|
519
|
+
* upsert by objectID. Max 1000 objects per call.
|
|
520
|
+
*/
|
|
521
|
+
import(objects: Array<{
|
|
522
|
+
objectID: string;
|
|
523
|
+
[key: string]: unknown;
|
|
524
|
+
}>): Promise<{
|
|
525
|
+
ok: boolean;
|
|
526
|
+
indexed: number;
|
|
527
|
+
index: string;
|
|
528
|
+
}>;
|
|
529
|
+
/**
|
|
530
|
+
* Search the private index. A never-imported index yields an empty
|
|
531
|
+
* hit list, not an error.
|
|
532
|
+
*/
|
|
533
|
+
search(opts?: SprigrDataSearchOpts): Promise<SprigrDataSearchResult>;
|
|
534
|
+
/** Fetch one object by objectID. `object` is null when absent. */
|
|
535
|
+
get(objectID: string): Promise<{
|
|
536
|
+
ok: boolean;
|
|
537
|
+
object: Record<string, unknown> | null;
|
|
538
|
+
index: string;
|
|
539
|
+
}>;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Durable app file storage exposed on `env.SPRIGR.files` by the platform
|
|
543
|
+
* wrapper (the platform build-runner). Persists bytes into the install's
|
|
544
|
+
* contained R2 namespace (every key is confined server-side to
|
|
545
|
+
* `_apps/{installId}/...`, so an app can only ever touch its own files) and
|
|
546
|
+
* mints signed, time-limited download URLs.
|
|
547
|
+
*
|
|
548
|
+
* Use it to re-host an ephemeral third-party asset (e.g. a provider render
|
|
549
|
+
* URL the upstream deletes after a short retention window) so the URL your
|
|
550
|
+
* app hands back keeps resolving.
|
|
551
|
+
*
|
|
552
|
+
* This is the in-isolate bridge surface, injected on `env.SPRIGR` for
|
|
553
|
+
* `/__sprigr/*` tool / webhook / event handlers. The standalone
|
|
554
|
+
* `putAppFileStream` / `appFileUrl` helpers in this package call the same
|
|
555
|
+
* `/internal/wfp/file/*` endpoints over HTTP for code that runs outside the
|
|
556
|
+
* injected bridge (e.g. inline Next.js route handlers).
|
|
557
|
+
*/
|
|
558
|
+
interface SprigrFilesApi {
|
|
559
|
+
/**
|
|
560
|
+
* Stream bytes into the app's store under `key` (app-relative; the platform
|
|
561
|
+
* adds/strips the `_apps/{installId}/` prefix). A `ReadableStream` is piped
|
|
562
|
+
* straight through to R2 without buffering whole in the isolate, so use it
|
|
563
|
+
* for multi-MB blobs (videos, full-res photos, PDFs). Size cap: 50 MB.
|
|
564
|
+
* `opts.length` declares the byte length so the platform can enforce the cap
|
|
565
|
+
* before opening the write; forward it for streamed bodies, which carry no
|
|
566
|
+
* Content-Length (`bytes` comes back `null` when it is omitted).
|
|
567
|
+
*/
|
|
568
|
+
putStream(key: string, body: ReadableStream | ArrayBuffer | Uint8Array | string, opts?: {
|
|
569
|
+
contentType?: string;
|
|
570
|
+
length?: number;
|
|
571
|
+
filename?: string;
|
|
572
|
+
}): Promise<{
|
|
573
|
+
ok: boolean;
|
|
574
|
+
key: string;
|
|
575
|
+
bytes: number | null;
|
|
576
|
+
contentType: string;
|
|
577
|
+
}>;
|
|
578
|
+
/**
|
|
579
|
+
* Mint a signed, time-limited download URL for a previously-stored `key`.
|
|
580
|
+
* `opts.expiresIn` is seconds, clamped server-side (default 24h, min 60s,
|
|
581
|
+
* max ~10y). The URL expires, so re-mint on demand rather than caching it.
|
|
582
|
+
*/
|
|
583
|
+
url(key: string, opts?: {
|
|
584
|
+
expiresIn?: number;
|
|
585
|
+
}): Promise<{
|
|
586
|
+
ok: boolean;
|
|
587
|
+
url: string;
|
|
588
|
+
expires_at: number;
|
|
589
|
+
key: string;
|
|
590
|
+
}>;
|
|
591
|
+
}
|
|
592
|
+
/** Narrow structural type for the per-install D1 binding. */
|
|
593
|
+
interface D1Like {
|
|
594
|
+
prepare(sql: string): D1PreparedStatementLike;
|
|
595
|
+
}
|
|
596
|
+
interface D1PreparedStatementLike {
|
|
597
|
+
bind(...args: unknown[]): D1PreparedStatementLike;
|
|
598
|
+
run(): Promise<unknown>;
|
|
599
|
+
first<T = unknown>(): Promise<T | null>;
|
|
600
|
+
all<T = unknown>(): Promise<{
|
|
601
|
+
results: T[];
|
|
602
|
+
}>;
|
|
603
|
+
}
|
|
604
|
+
interface WebhookArgs {
|
|
605
|
+
body: string;
|
|
606
|
+
signature?: string;
|
|
607
|
+
headers?: Record<string, string>;
|
|
608
|
+
}
|
|
609
|
+
interface ScheduleArgs {
|
|
610
|
+
name: string;
|
|
611
|
+
scheduled_at?: string;
|
|
612
|
+
}
|
|
613
|
+
interface EventArgs {
|
|
614
|
+
event: string;
|
|
615
|
+
payload: unknown;
|
|
616
|
+
eventId: string;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* ExecutionContext shape narrowed to what most handlers need
|
|
620
|
+
* (`waitUntil` for fire-and-forget tasks). Keeps the type usable
|
|
621
|
+
* without depending on @cloudflare/workers-types here.
|
|
622
|
+
*/
|
|
623
|
+
interface ExecutionContextLike {
|
|
624
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
625
|
+
passThroughOnException?(): void;
|
|
626
|
+
}
|
|
627
|
+
type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
|
|
628
|
+
|
|
629
|
+
export { type Actor, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type D1Like, type D1PreparedStatementLike, DEFAULT_MAX_FILE_BYTES, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, type OAuthState, type PlatformHostEnv, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type ScheduleArgs, type SprigrDataApi, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type WebhookArgs, actorKey, appFileUrl, base64ToBytes, buildMarketplaceWebhookUrl, bytesToBase64, constantTimeEqual, decodeState, deleteAppFile, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, listAppFiles, parseActor, putAppFile, putAppFileStream, randomHex, resolvePlatformWebhookBase };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// src/crypto.ts
|
|
2
|
+
function constantTimeEqual(a, b) {
|
|
3
|
+
if (a.length !== b.length) return false;
|
|
4
|
+
let mismatch = 0;
|
|
5
|
+
for (let i = 0; i < a.length; i++) {
|
|
6
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
7
|
+
}
|
|
8
|
+
return mismatch === 0;
|
|
9
|
+
}
|
|
10
|
+
async function hmacSha256Hex(secret, message) {
|
|
11
|
+
const keyData = new TextEncoder().encode(secret);
|
|
12
|
+
const msgData = new TextEncoder().encode(message);
|
|
13
|
+
const key = await crypto.subtle.importKey(
|
|
14
|
+
"raw",
|
|
15
|
+
keyData,
|
|
16
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
17
|
+
false,
|
|
18
|
+
["sign"]
|
|
19
|
+
);
|
|
20
|
+
const sigBuf = await crypto.subtle.sign("HMAC", key, msgData);
|
|
21
|
+
return [...new Uint8Array(sigBuf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
}
|
|
23
|
+
function randomHex(bytes = 32) {
|
|
24
|
+
const arr = new Uint8Array(bytes);
|
|
25
|
+
crypto.getRandomValues(arr);
|
|
26
|
+
return [...arr].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/state.ts
|
|
30
|
+
function base64urlEncode(bytes) {
|
|
31
|
+
let s = "";
|
|
32
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
33
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function base64urlDecode(input) {
|
|
36
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((input.length + 3) % 4);
|
|
37
|
+
const bin = atob(padded);
|
|
38
|
+
const out = new Uint8Array(bin.length);
|
|
39
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function encodeState(state) {
|
|
43
|
+
const json = JSON.stringify(state);
|
|
44
|
+
return base64urlEncode(new TextEncoder().encode(json));
|
|
45
|
+
}
|
|
46
|
+
function decodeState(encoded) {
|
|
47
|
+
const bytes = base64urlDecode(encoded);
|
|
48
|
+
const json = new TextDecoder().decode(bytes);
|
|
49
|
+
return JSON.parse(json);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/fetch.ts
|
|
53
|
+
var DEFAULT_OPTS = {
|
|
54
|
+
maxAttempts: 4,
|
|
55
|
+
baseBackoffMs: 500,
|
|
56
|
+
maxTotalWaitMs: 3e4
|
|
57
|
+
};
|
|
58
|
+
async function fetchWithRetry(input, init, opts = {}) {
|
|
59
|
+
const cfg = { ...DEFAULT_OPTS, ...opts };
|
|
60
|
+
let totalWait = 0;
|
|
61
|
+
let lastError = null;
|
|
62
|
+
for (let attempt = 1; attempt <= cfg.maxAttempts; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
const resp = await fetch(input, init);
|
|
65
|
+
if (resp.status === 429 || resp.status >= 500) {
|
|
66
|
+
if (attempt === cfg.maxAttempts) return resp;
|
|
67
|
+
const wait = waitForResponse(resp, attempt, cfg);
|
|
68
|
+
if (totalWait + wait > cfg.maxTotalWaitMs) return resp;
|
|
69
|
+
opts.onRetry?.({ attempt, status: resp.status, waitMs: wait, reason: resp.status === 429 ? "rate_limited" : "server_error" });
|
|
70
|
+
await sleep(wait);
|
|
71
|
+
totalWait += wait;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return resp;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
lastError = err;
|
|
77
|
+
if (attempt === cfg.maxAttempts) throw err;
|
|
78
|
+
const wait = expBackoff(attempt, cfg.baseBackoffMs);
|
|
79
|
+
if (totalWait + wait > cfg.maxTotalWaitMs) throw err;
|
|
80
|
+
opts.onRetry?.({ attempt, status: null, waitMs: wait, reason: "network_error" });
|
|
81
|
+
await sleep(wait);
|
|
82
|
+
totalWait += wait;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw lastError ?? new Error("fetchWithRetry exhausted");
|
|
86
|
+
}
|
|
87
|
+
function waitForResponse(resp, attempt, cfg) {
|
|
88
|
+
const retryAfter = resp.headers.get("retry-after");
|
|
89
|
+
if (retryAfter) {
|
|
90
|
+
const seconds = parseInt(retryAfter, 10);
|
|
91
|
+
if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1e3;
|
|
92
|
+
}
|
|
93
|
+
const reset = resp.headers.get("x-ratelimit-reset");
|
|
94
|
+
if (reset) {
|
|
95
|
+
const resetEpoch = parseInt(reset, 10);
|
|
96
|
+
if (!Number.isNaN(resetEpoch)) {
|
|
97
|
+
const waitMs = resetEpoch * 1e3 - Date.now();
|
|
98
|
+
if (waitMs > 0 && waitMs < 6e4) return waitMs;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return expBackoff(attempt, cfg.baseBackoffMs);
|
|
102
|
+
}
|
|
103
|
+
function expBackoff(attempt, base) {
|
|
104
|
+
const expo = base * Math.pow(2, attempt - 1);
|
|
105
|
+
const jitter = 0.7 + Math.random() * 0.6;
|
|
106
|
+
return Math.floor(expo * jitter);
|
|
107
|
+
}
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/file.ts
|
|
113
|
+
var CHUNK = 32768;
|
|
114
|
+
function bytesToBase64(input) {
|
|
115
|
+
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
116
|
+
let binary = "";
|
|
117
|
+
for (let i = 0; i < u8.length; i += CHUNK) {
|
|
118
|
+
binary += String.fromCharCode.apply(
|
|
119
|
+
null,
|
|
120
|
+
u8.subarray(i, i + CHUNK)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return btoa(binary);
|
|
124
|
+
}
|
|
125
|
+
function base64ToBytes(b64) {
|
|
126
|
+
const binary = atob(b64.replace(/\s/g, ""));
|
|
127
|
+
const bytes = new Uint8Array(binary.length);
|
|
128
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
129
|
+
return bytes;
|
|
130
|
+
}
|
|
131
|
+
var DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
132
|
+
async function fetchFileBytes(url, opts = {}) {
|
|
133
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
134
|
+
const res = await fetch(url, opts.init);
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new Error(`fetchFileBytes: HTTP ${res.status} fetching file URL`);
|
|
137
|
+
}
|
|
138
|
+
const declared = Number(res.headers.get("content-length") || "");
|
|
139
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`fetchFileBytes: file is ${declared} bytes, over the ${maxBytes}-byte cap \u2014 use a smaller file or a segmented upload`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const buf = await res.arrayBuffer();
|
|
145
|
+
if (buf.byteLength > maxBytes) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`fetchFileBytes: file is ${buf.byteLength} bytes, over the ${maxBytes}-byte cap \u2014 use a smaller file or a segmented upload`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const contentType = res.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
|
|
151
|
+
return { bytes: new Uint8Array(buf), contentType, size: buf.byteLength };
|
|
152
|
+
}
|
|
153
|
+
async function fetchFileAsBase64(url, opts = {}) {
|
|
154
|
+
const { bytes, contentType, size } = await fetchFileBytes(url, opts);
|
|
155
|
+
return { base64: bytesToBase64(bytes), contentType, size };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/platform-host.ts
|
|
159
|
+
var FALLBACK_PLATFORM_BASE = "https://webhooks.sprigr.com";
|
|
160
|
+
function resolvePlatformWebhookBase(env, override) {
|
|
161
|
+
const raw = (override ?? env.SPRIGR_PLATFORM_BASE ?? FALLBACK_PLATFORM_BASE).trim();
|
|
162
|
+
return raw.replace(/\/$/, "");
|
|
163
|
+
}
|
|
164
|
+
function buildMarketplaceWebhookUrl(env, installId, topicPath, override) {
|
|
165
|
+
const base = resolvePlatformWebhookBase(env, override);
|
|
166
|
+
return `${base}/webhook/marketplace/${installId}/${topicPath}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/actor.ts
|
|
170
|
+
function parseActor(args) {
|
|
171
|
+
if (!args || typeof args !== "object") return void 0;
|
|
172
|
+
const a = args.actor;
|
|
173
|
+
if (!a || typeof a !== "object") return void 0;
|
|
174
|
+
const obj = a;
|
|
175
|
+
const out = {};
|
|
176
|
+
if (typeof obj.agentId === "string" && obj.agentId.length > 0) out.agentId = obj.agentId;
|
|
177
|
+
if (typeof obj.platformUserId === "string" && obj.platformUserId.length > 0) {
|
|
178
|
+
out.platformUserId = obj.platformUserId;
|
|
179
|
+
}
|
|
180
|
+
if (typeof obj.role === "string" && obj.role.length > 0) out.role = obj.role;
|
|
181
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
182
|
+
}
|
|
183
|
+
function actorKey(actor) {
|
|
184
|
+
if (!actor) return null;
|
|
185
|
+
if (actor.platformUserId) return `u:${actor.platformUserId}`;
|
|
186
|
+
if (actor.agentId) return `a:${actor.agentId}`;
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/app-files.ts
|
|
191
|
+
var DEFAULT_PLATFORM_BASE = "https://webhooks.sprigr.com";
|
|
192
|
+
function platformBase(env) {
|
|
193
|
+
return (env.SPRIGR_PLATFORM_BASE ?? DEFAULT_PLATFORM_BASE).trim().replace(/\/+$/, "");
|
|
194
|
+
}
|
|
195
|
+
var AppFilesError = class extends Error {
|
|
196
|
+
status;
|
|
197
|
+
constructor(message, status) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "AppFilesError";
|
|
200
|
+
this.status = status;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
function requireToken(env) {
|
|
204
|
+
const token = env.SPRIGR_INSTALL_TOKEN;
|
|
205
|
+
if (!token) {
|
|
206
|
+
throw new AppFilesError("app file storage unavailable: SPRIGR_INSTALL_TOKEN unset", 500);
|
|
207
|
+
}
|
|
208
|
+
return token;
|
|
209
|
+
}
|
|
210
|
+
async function parseResult(res, op) {
|
|
211
|
+
const text = await res.text();
|
|
212
|
+
let parsed = void 0;
|
|
213
|
+
try {
|
|
214
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
const detail = parsed && typeof parsed === "object" ? parsed.hint ?? parsed.detail ?? parsed.error : text.slice(0, 200);
|
|
219
|
+
throw new AppFilesError(`app file ${op} failed (${res.status}): ${String(detail)}`, res.status);
|
|
220
|
+
}
|
|
221
|
+
return parsed;
|
|
222
|
+
}
|
|
223
|
+
async function call(env, op, body) {
|
|
224
|
+
const token = requireToken(env);
|
|
225
|
+
const res = await fetch(`${platformBase(env)}/internal/wfp/file/${op}`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
authorization: `Bearer ${token}`,
|
|
229
|
+
"content-type": "application/json"
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify(body ?? {})
|
|
232
|
+
});
|
|
233
|
+
return parseResult(res, op);
|
|
234
|
+
}
|
|
235
|
+
function putAppFile(env, args) {
|
|
236
|
+
return call(env, "put", {
|
|
237
|
+
key: args.key,
|
|
238
|
+
base64: args.base64,
|
|
239
|
+
contentType: args.contentType,
|
|
240
|
+
filename: args.filename
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async function putAppFileStream(env, args) {
|
|
244
|
+
const token = requireToken(env);
|
|
245
|
+
const headers = {
|
|
246
|
+
authorization: `Bearer ${token}`,
|
|
247
|
+
"content-type": args.contentType || "application/octet-stream",
|
|
248
|
+
"x-app-file-key": args.key,
|
|
249
|
+
"x-app-file-content-type": args.contentType || "application/octet-stream"
|
|
250
|
+
};
|
|
251
|
+
if (args.filename) headers["x-app-file-name"] = encodeURIComponent(args.filename);
|
|
252
|
+
if (typeof args.contentLength === "number" && Number.isFinite(args.contentLength)) {
|
|
253
|
+
headers["x-app-file-length"] = String(args.contentLength);
|
|
254
|
+
}
|
|
255
|
+
const res = await fetch(`${platformBase(env)}/internal/wfp/file/put-stream`, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers,
|
|
258
|
+
body: args.body,
|
|
259
|
+
// Required by the Workers runtime when sending a streaming request body.
|
|
260
|
+
...args.body instanceof ReadableStream ? { duplex: "half" } : {}
|
|
261
|
+
});
|
|
262
|
+
return parseResult(res, "put-stream");
|
|
263
|
+
}
|
|
264
|
+
function appFileUrl(env, args) {
|
|
265
|
+
return call(env, "url", { key: args.key, expiresIn: args.expiresIn });
|
|
266
|
+
}
|
|
267
|
+
function getAppFile(env, key) {
|
|
268
|
+
return call(env, "get", { key });
|
|
269
|
+
}
|
|
270
|
+
function listAppFiles(env, prefix) {
|
|
271
|
+
return call(env, "list", { prefix });
|
|
272
|
+
}
|
|
273
|
+
function deleteAppFile(env, key) {
|
|
274
|
+
return call(env, "delete", { key });
|
|
275
|
+
}
|
|
276
|
+
export {
|
|
277
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
278
|
+
actorKey,
|
|
279
|
+
appFileUrl,
|
|
280
|
+
base64ToBytes,
|
|
281
|
+
buildMarketplaceWebhookUrl,
|
|
282
|
+
bytesToBase64,
|
|
283
|
+
constantTimeEqual,
|
|
284
|
+
decodeState,
|
|
285
|
+
deleteAppFile,
|
|
286
|
+
encodeState,
|
|
287
|
+
fetchFileAsBase64,
|
|
288
|
+
fetchFileBytes,
|
|
289
|
+
fetchWithRetry,
|
|
290
|
+
getAppFile,
|
|
291
|
+
hmacSha256Hex,
|
|
292
|
+
listAppFiles,
|
|
293
|
+
parseActor,
|
|
294
|
+
putAppFile,
|
|
295
|
+
putAppFileStream,
|
|
296
|
+
randomHex,
|
|
297
|
+
resolvePlatformWebhookBase
|
|
298
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sprigr/apps-app-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest",
|
|
17
|
+
"build": "tsup src/index.ts --format esm --dts --out-dir dist --clean --tsconfig tsconfig.build.json",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@cloudflare/workers-types": "^4.20240909.0",
|
|
22
|
+
"typescript": "^5.6.0",
|
|
23
|
+
"vitest": "^2.0.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/sprigr/sprigr-app-kit.git",
|
|
29
|
+
"directory": "packages/app-sdk"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
]
|
|
35
|
+
}
|