@sprigr/apps-app-sdk 0.1.1 → 0.2.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 CHANGED
@@ -1,10 +1,31 @@
1
1
  # @sprigr/apps-app-sdk
2
2
 
3
- Shared handler types + helpers for apps in this repo.
3
+ Shared handler types + helpers for Sprigr marketplace apps.
4
4
 
5
- Scaffold today: just the type contracts the Sprigr wrapper expects. P1 adds:
5
+ ```bash
6
+ npm install @sprigr/apps-app-sdk
7
+ ```
6
8
 
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
9
+ ## Talking to the platform
10
+
11
+ The runtime injects the `env.SPRIGR` host object **only on `/__sprigr/*` dispatch paths** (tool, schedule, event and platform-webhook handlers). Inline Next.js route handlers never get it, so an app that receives its provider webhook on an inline route has no working `env.SPRIGR` on the path that matters most.
12
+
13
+ - `emitMarketplaceEvent(env, event, payload, opts?)` — emit from either context. Uses the injected binding when present, the install-token bridge (`POST ${SPRIGR_PLATFORM_BASE}/internal/wfp/emit`) otherwise. Never throws, so a webhook ack is never at risk; times out after 5s. Returns `{ emitted, via: 'binding' | 'http' | 'none', eventId?, error? }` — record `via` in your audit row.
14
+ - `withSprigrEmitFallback(env)` — repair `env.SPRIGR.emit` once and leave existing call sites untouched. Matches the host object's contract (resolves `{ ok, eventId, queued }`, throws on non-2xx).
15
+ - `resolveInstallBridge(env)` / `installTokenPost(bridge, path, body, opts?)` — build your own `/internal/wfp/*` fallback (collections, files, inbox) with the auth and error extraction handled.
16
+ - `overlaySprigr(env, sprigr)` — overlay a patched `SPRIGR` via `Object.create`. Never rebuild a dispatch-path env by spread: the real bindings live on the prototype and `SPRIGR` is non-enumerable, so `{ ...env }` yields an env whose `DB` is `undefined`.
17
+
18
+ ## Webhook callback URLs
19
+
20
+ - `resolvePlatformWebhookBase(env)` / `buildMarketplaceWebhookUrl(env, installId, topicPath)` — env-correct platform host, so a staging install never registers prod-pointing subscriptions.
21
+
22
+ ## Misc
23
+
24
+ - `fetchWithRetry` — rate-limit-header-aware, jittered retry
25
+ - `constantTimeEqual(a, b)` — bearer-secret verification
26
+ - `encodeState` / `decodeState` — OAuth state base64url
27
+ - `parseActor` / `actorKey` — per-actor token scoping
28
+ - `putAppFile` / `putAppFileStream` / `appFileUrl` / `getAppFile` / `listAppFiles` / `deleteAppFile` — durable app-scoped file storage from outside the injected bridge
29
+ - `fetchFileBytes` / `fetchFileAsBase64` / `bytesToBase64` / `base64ToBytes` — file byte helpers
30
+
31
+ Full platform semantics: [`docs/platform-reference.md`](../../docs/platform-reference.md).
package/dist/index.d.ts CHANGED
@@ -235,6 +235,150 @@ declare function resolvePlatformWebhookBase(env: PlatformHostEnv, override?: str
235
235
  */
236
236
  declare function buildMarketplaceWebhookUrl(env: PlatformHostEnv, installId: string, topicPath: string, override?: string | null): string;
237
237
 
238
+ /**
239
+ * Talking to the platform from BOTH execution contexts.
240
+ *
241
+ * The marketplace runtime injects the `SPRIGR` host object **only on
242
+ * `/__sprigr/*` dispatch paths** (tool, schedule, event and platform-webhook
243
+ * handlers). Inline Next.js route handlers (`app/api/.../route.ts`) never
244
+ * receive it. Any app whose provider webhook lands on an inline route (because
245
+ * the provider doesn't HMAC bodies, so the marketplace dispatcher can't verify
246
+ * the delivery) therefore has NO working `env.SPRIGR` on the one path that
247
+ * matters most.
248
+ *
249
+ * An app that only tries `env.SPRIGR.emit` from there emits nothing at all,
250
+ * silently, while still acking the provider 200. That has now shipped four
251
+ * separate times (shopify #478, procore, starshipit, cin7-core), so the fix
252
+ * lives here instead of being re-derived per app.
253
+ *
254
+ * The escape hatch: `SPRIGR_PLATFORM_BASE` and `SPRIGR_INSTALL_TOKEN` are plain
255
+ * script vars stamped on every per-install WFP upload, so unlike `SPRIGR` they
256
+ * ARE readable from an inline route. With them you can call the same
257
+ * `/internal/wfp/*` endpoints the host object calls under the covers:
258
+ *
259
+ * POST ${SPRIGR_PLATFORM_BASE}/internal/wfp/emit
260
+ * Authorization: Bearer ${SPRIGR_INSTALL_TOKEN}
261
+ * { event, payload, sourceIntegration?, targetAppInstallationId?, dedupId? }
262
+ *
263
+ * Use `emitMarketplaceEvent` for a single call site, or `withSprigrEmitFallback`
264
+ * to repair `env.SPRIGR.emit` once and leave existing call sites untouched.
265
+ */
266
+
267
+ /** Resolved per-install bridge credentials. */
268
+ interface InstallBridge {
269
+ /** Platform base, trailing slash stripped. */
270
+ base: string;
271
+ /** Per-install bearer, `<installId>.<base64url-hmac>`. */
272
+ token: string;
273
+ }
274
+ /**
275
+ * Minimal env shape these helpers read. Every app's own env interface
276
+ * (ProcoreEnv, ShopifyEnv, ...) already satisfies it structurally, so passing
277
+ * the full env is fine.
278
+ */
279
+ interface WfpBridgeEnv {
280
+ SPRIGR?: unknown;
281
+ SPRIGR_PLATFORM_BASE?: string;
282
+ SPRIGR_INSTALL_TOKEN?: string;
283
+ INSTALL_ID?: string;
284
+ [key: string]: unknown;
285
+ }
286
+ /**
287
+ * Pull the install-token bridge off env, or null when either half is missing.
288
+ *
289
+ * Deliberately has NO production default for the base: an app that guessed
290
+ * `https://webhooks.sprigr.com` would have a *staging* install firing events
291
+ * into *prod*, where the install id is unknown and the call 404s into a tail
292
+ * nobody watching staging ever reads. Failing closed is louder.
293
+ */
294
+ declare function resolveInstallBridge(env: WfpBridgeEnv): InstallBridge | null;
295
+ /**
296
+ * Human-readable note about which half of the bridge is missing. Which binding
297
+ * is absent is the entire diagnosis, so never collapse this to a bare
298
+ * "not configured".
299
+ */
300
+ declare function describeMissingBridge(env: WfpBridgeEnv): string;
301
+ /**
302
+ * Overlay a replacement `SPRIGR` onto env WITHOUT rebuilding it by spread.
303
+ *
304
+ * On the `/__sprigr/*` dispatch path the platform hands handlers
305
+ * `Object.create(bindings)` with `SPRIGR` as a NON-ENUMERABLE own property: the
306
+ * real bindings (`DB`, secrets, the install token itself) live on the
307
+ * prototype. `{ ...env }` copies none of that — it yields an env whose `DB` is
308
+ * undefined. That is exactly how every scheduled `ms_index_files` run died for
309
+ * 24h on staging (microsoft-365 v0.12.0, fixed in v0.14.2, #758). Chaining off
310
+ * env via the prototype preserves whatever shape the caller had, plain or
311
+ * wrapped.
312
+ */
313
+ declare function overlaySprigr<E extends object>(env: E, sprigr: unknown): E;
314
+ /** Reply shape of `/internal/wfp/emit`. */
315
+ interface WfpEmitReply {
316
+ ok?: boolean;
317
+ eventId?: string;
318
+ /** False when the platform accepted the call but the enqueue itself failed. */
319
+ queued?: boolean;
320
+ error?: string;
321
+ }
322
+ /** How the emit reached the platform. `none` means it never left. */
323
+ type EmitTransport = 'binding' | 'http' | 'none';
324
+ interface EmitResult {
325
+ emitted: boolean;
326
+ eventId?: string;
327
+ /** Which transport carried it. Worth recording in an audit row: it makes the
328
+ * binding silently disappearing again visible without a redeploy. */
329
+ via: EmitTransport;
330
+ error?: string;
331
+ }
332
+ /** Default ceiling on the platform call. A webhook ack matters more than the
333
+ * event: providers retry a failed delivery, and a slow emit must never turn
334
+ * the ack into a non-2xx. */
335
+ declare const DEFAULT_EMIT_TIMEOUT_MS = 5000;
336
+ interface EmitMarketplaceEventOptions extends MarketplaceEventEmitOpts {
337
+ /** Override the 5s ceiling on the HTTP fallback. */
338
+ timeoutMs?: number;
339
+ }
340
+ /**
341
+ * POST a JSON body to an `/internal/wfp/*` endpoint with the install-token
342
+ * bearer. Returns the parsed reply and throws on a non-2xx, matching what the
343
+ * injected host object does, so callers can treat both paths identically.
344
+ *
345
+ * Shared by every install-token fallback (emit, inbox, collections, files) so
346
+ * the auth header and error-detail extraction live in exactly one place.
347
+ */
348
+ declare function installTokenPost(bridge: InstallBridge, path: string, body: unknown, opts?: {
349
+ label?: string;
350
+ timeoutMs?: number;
351
+ signal?: AbortSignal;
352
+ }): Promise<Record<string, unknown>>;
353
+ /**
354
+ * Emit a marketplace event from any execution context, never throwing.
355
+ *
356
+ * Prefers the injected `env.SPRIGR.emit`; falls back to the install-token
357
+ * bridge when it is absent (i.e. on an inline Next route). Safe to call from a
358
+ * webhook receiver: every failure is reported in the result rather than raised,
359
+ * so the provider ack is never at risk.
360
+ *
361
+ * const r = await emitMarketplaceEvent(env, 'procore.rfi.updated', payload, {
362
+ * sourceIntegration: { integrationId: env.INSTALL_ID, integrationType: 'procore' },
363
+ * });
364
+ * await audit(env, 'emit', JSON.stringify(r)); // records `via`
365
+ */
366
+ declare function emitMarketplaceEvent(env: WfpBridgeEnv, event: string, payload: unknown, opts?: EmitMarketplaceEventOptions): Promise<EmitResult>;
367
+ /**
368
+ * Return an env whose `SPRIGR.emit` works even on an inline route, leaving
369
+ * existing `env.SPRIGR.emit(...)` call sites untouched. Use this when an app
370
+ * emits from many places; use `emitMarketplaceEvent` for a single call site.
371
+ *
372
+ * The installed `emit` matches the injected host object's contract: it resolves
373
+ * `{ ok, eventId, queued }` and THROWS on a non-2xx, so existing
374
+ * `queued === false` checks and try/catch blocks keep working unchanged.
375
+ *
376
+ * When the bindings are absent the env is returned as-is (`SPRIGR` stays
377
+ * undefined) so callers fail the same way they already do rather than
378
+ * discovering a new shape.
379
+ */
380
+ declare function withSprigrEmitFallback<E extends WfpBridgeEnv & object>(env: E): E;
381
+
238
382
  /**
239
383
  * Caller identity stamped by the platform on agent-initiated WFP dispatches.
240
384
  *
@@ -601,12 +745,17 @@ interface SprigrFilesEditInput {
601
745
  /** App-relative key of the source file already in app storage. */
602
746
  file_key: string;
603
747
  /** pdf runs the PyMuPDF ops engine (list_fields / fill_form / replace_text);
604
- * docx/xlsx run python-docx / openpyxl. */
605
- format: 'docx' | 'xlsx' | 'pdf';
748
+ * docx/xlsx run python-docx / openpyxl; xer/pmxml/mspdi run the platform's
749
+ * pure-TS schedule engine in-worker (Primavera P6 / MS Project XML, no
750
+ * sandbox cold-start exposure). */
751
+ format: 'docx' | 'xlsx' | 'pdf' | 'xer' | 'pmxml' | 'mspdi';
606
752
  operations: Array<Record<string, unknown>>;
607
753
  output_filename?: string;
608
754
  output_key?: string;
609
755
  allow_lossy?: boolean;
756
+ /** Schedule formats only: write even when the FK-integrity gate finds
757
+ * errors (the validation result is still returned). */
758
+ skip_validation?: boolean;
610
759
  /** Deterministic idempotency token: the platform persists the pipeline's
611
760
  * terminal result under it, so a retry of the same logical edit after a
612
761
  * dispatch timeout replays the finished result instead of re-running the
@@ -622,6 +771,10 @@ interface SprigrFilesEditResult {
622
771
  failed_operations?: Array<Record<string, unknown>>;
623
772
  warning?: string;
624
773
  error?: string;
774
+ /** Schedule formats only: the FK-integrity result ({errorCount, warningCount, issues}). */
775
+ validation?: Record<string, unknown>;
776
+ /** Schedule formats only: one-line engine summary incl. matched counts. */
777
+ summary?: string;
625
778
  }
626
779
  /** Input to `env.SPRIGR.files.create` (build a NEW docx/xlsx from a spec). */
627
780
  interface SprigrFilesCreateInput {
@@ -700,4 +853,4 @@ interface ExecutionContextLike {
700
853
  }
701
854
  type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
702
855
 
703
- 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 SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type WebhookArgs, actorKey, appFileUrl, base64ToBytes, buildMarketplaceWebhookUrl, bytesToBase64, constantTimeEqual, decodeState, deleteAppFile, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, listAppFiles, parseActor, putAppFile, putAppFileStream, randomHex, resolvePlatformWebhookBase };
856
+ export { type Actor, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InstallBridge, 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 SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, appFileUrl, base64ToBytes, buildMarketplaceWebhookUrl, bytesToBase64, constantTimeEqual, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, installTokenPost, listAppFiles, overlaySprigr, parseActor, putAppFile, putAppFileStream, randomHex, resolveInstallBridge, resolvePlatformWebhookBase, withSprigrEmitFallback };
package/dist/index.js CHANGED
@@ -166,6 +166,116 @@ function buildMarketplaceWebhookUrl(env, installId, topicPath, override) {
166
166
  return `${base}/webhook/marketplace/${installId}/${topicPath}`;
167
167
  }
168
168
 
169
+ // src/wfp-bridge.ts
170
+ function resolveInstallBridge(env) {
171
+ const token = typeof env.SPRIGR_INSTALL_TOKEN === "string" ? env.SPRIGR_INSTALL_TOKEN : "";
172
+ const base = typeof env.SPRIGR_PLATFORM_BASE === "string" ? env.SPRIGR_PLATFORM_BASE.replace(/\/+$/, "") : "";
173
+ if (!token || !base) return null;
174
+ return { base, token };
175
+ }
176
+ function describeMissingBridge(env) {
177
+ const base = typeof env.SPRIGR_PLATFORM_BASE === "string" && env.SPRIGR_PLATFORM_BASE ? "set" : "unset";
178
+ const token = typeof env.SPRIGR_INSTALL_TOKEN === "string" && env.SPRIGR_INSTALL_TOKEN ? "set" : "unset";
179
+ return `no_emit_path (SPRIGR unbound, SPRIGR_PLATFORM_BASE=${base}, SPRIGR_INSTALL_TOKEN=${token})`;
180
+ }
181
+ function overlaySprigr(env, sprigr) {
182
+ const out = Object.create(env);
183
+ Object.defineProperty(out, "SPRIGR", { value: sprigr, enumerable: false, configurable: false });
184
+ return out;
185
+ }
186
+ var DEFAULT_EMIT_TIMEOUT_MS = 5e3;
187
+ function compactOpts(opts) {
188
+ const out = {};
189
+ if (!opts) return out;
190
+ if (opts.sourceIntegration !== void 0) out.sourceIntegration = opts.sourceIntegration;
191
+ if (opts.targetAppInstallationId !== void 0) out.targetAppInstallationId = opts.targetAppInstallationId;
192
+ if (opts.dedupId !== void 0) out.dedupId = opts.dedupId;
193
+ return out;
194
+ }
195
+ function bindingEmit(env) {
196
+ const sprigr = env.SPRIGR;
197
+ return typeof sprigr?.emit === "function" ? sprigr.emit.bind(
198
+ sprigr
199
+ ) : null;
200
+ }
201
+ async function installTokenPost(bridge, path, body, opts) {
202
+ const label = opts?.label ?? `POST ${path}`;
203
+ const controller = new AbortController();
204
+ const timer = opts?.timeoutMs != null ? setTimeout(() => controller.abort(), opts.timeoutMs) : void 0;
205
+ if (opts?.signal) {
206
+ if (opts.signal.aborted) controller.abort();
207
+ else opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
208
+ }
209
+ try {
210
+ const resp = await fetch(`${bridge.base}${path}`, {
211
+ method: "POST",
212
+ headers: { authorization: `Bearer ${bridge.token}`, "content-type": "application/json" },
213
+ body: JSON.stringify(body),
214
+ signal: controller.signal
215
+ });
216
+ const text = await resp.text();
217
+ let parsed = null;
218
+ try {
219
+ parsed = text ? JSON.parse(text) : null;
220
+ } catch {
221
+ parsed = null;
222
+ }
223
+ if (!resp.ok) {
224
+ const detail = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : text.slice(0, 200);
225
+ throw new Error(`${label} failed: ${resp.status} ${detail}`);
226
+ }
227
+ return parsed ?? {};
228
+ } finally {
229
+ if (timer !== void 0) clearTimeout(timer);
230
+ }
231
+ }
232
+ async function emitMarketplaceEvent(env, event, payload, opts) {
233
+ const { timeoutMs, ...emitOpts } = opts ?? {};
234
+ const compact = compactOpts(emitOpts);
235
+ const passThrough = Object.keys(compact).length > 0 ? compact : void 0;
236
+ const injected = bindingEmit(env);
237
+ if (injected) {
238
+ try {
239
+ const r = await injected(event, payload, passThrough);
240
+ if (r && r.queued === false) {
241
+ return { emitted: false, via: "binding", eventId: r.eventId, error: r.error ?? "not_queued" };
242
+ }
243
+ return { emitted: r?.ok !== false, via: "binding", eventId: r?.eventId };
244
+ } catch (err) {
245
+ return { emitted: false, via: "binding", error: err instanceof Error ? err.message : String(err) };
246
+ }
247
+ }
248
+ const bridge = resolveInstallBridge(env);
249
+ if (!bridge) return { emitted: false, via: "none", error: describeMissingBridge(env) };
250
+ try {
251
+ const reply = await installTokenPost(
252
+ bridge,
253
+ "/internal/wfp/emit",
254
+ { event, payload: payload === void 0 ? null : payload, ...compact },
255
+ { label: "emit", timeoutMs: timeoutMs ?? DEFAULT_EMIT_TIMEOUT_MS }
256
+ );
257
+ if (reply.ok === false || reply.queued === false) {
258
+ return { emitted: false, via: "http", eventId: reply.eventId, error: reply.error ?? "not_queued" };
259
+ }
260
+ return { emitted: true, via: "http", eventId: reply.eventId };
261
+ } catch (err) {
262
+ return { emitted: false, via: "http", error: err instanceof Error ? err.message : String(err) };
263
+ }
264
+ }
265
+ function withSprigrEmitFallback(env) {
266
+ if (bindingEmit(env)) return env;
267
+ const bridge = resolveInstallBridge(env);
268
+ if (!bridge) return env;
269
+ const emit = async (event, payload, opts) => await installTokenPost(
270
+ bridge,
271
+ "/internal/wfp/emit",
272
+ { event, payload: payload === void 0 ? null : payload, ...compactOpts(opts) },
273
+ { label: "emit", timeoutMs: DEFAULT_EMIT_TIMEOUT_MS }
274
+ );
275
+ const existing = env.SPRIGR ?? {};
276
+ return overlaySprigr(env, { ...existing, emit });
277
+ }
278
+
169
279
  // src/actor.ts
170
280
  function parseActor(args) {
171
281
  if (!args || typeof args !== "object") return void 0;
@@ -274,6 +384,7 @@ function deleteAppFile(env, key) {
274
384
  return call(env, "delete", { key });
275
385
  }
276
386
  export {
387
+ DEFAULT_EMIT_TIMEOUT_MS,
277
388
  DEFAULT_MAX_FILE_BYTES,
278
389
  actorKey,
279
390
  appFileUrl,
@@ -283,16 +394,22 @@ export {
283
394
  constantTimeEqual,
284
395
  decodeState,
285
396
  deleteAppFile,
397
+ describeMissingBridge,
398
+ emitMarketplaceEvent,
286
399
  encodeState,
287
400
  fetchFileAsBase64,
288
401
  fetchFileBytes,
289
402
  fetchWithRetry,
290
403
  getAppFile,
291
404
  hmacSha256Hex,
405
+ installTokenPost,
292
406
  listAppFiles,
407
+ overlaySprigr,
293
408
  parseActor,
294
409
  putAppFile,
295
410
  putAppFileStream,
296
411
  randomHex,
297
- resolvePlatformWebhookBase
412
+ resolveInstallBridge,
413
+ resolvePlatformWebhookBase,
414
+ withSprigrEmitFallback
298
415
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprigr/apps-app-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",