@sprigr/apps-app-sdk 0.2.0 → 0.4.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
@@ -11,6 +11,8 @@ npm install @sprigr/apps-app-sdk
11
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
12
 
13
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
+ - `createMarketplaceEmitter(integrationType, defaults?)` — pre-bind an app's integration type so call sites pass only what varies; `sourceIntegration` is built per call from `env.INSTALL_ID`.
15
+ - `canEmit(env)` — whether an emit could reach the platform by either transport. Gate work that only exists to feed an emit on this, not on `env.SPRIGR?.emit`.
14
16
  - `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
17
  - `resolveInstallBridge(env)` / `installTokenPost(bridge, path, body, opts?)` — build your own `/internal/wfp/*` fallback (collections, files, inbox) with the auth and error extraction handled.
16
18
  - `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`.
package/dist/index.d.ts CHANGED
@@ -234,6 +234,72 @@ declare function resolvePlatformWebhookBase(env: PlatformHostEnv, override?: str
234
234
  * form should pre-translate before calling.
235
235
  */
236
236
  declare function buildMarketplaceWebhookUrl(env: PlatformHostEnv, installId: string, topicPath: string, override?: string | null): string;
237
+ /**
238
+ * Is this URL (or bare hostname) served by a STAGING host?
239
+ *
240
+ * Every Sprigr staging host is `staging-<something>.sprigr.com`:
241
+ * `staging-apps` (per-install app sites), `staging-sites` (the
242
+ * preview-token host from `get_website_preview`), `staging-team` (the
243
+ * portal), `staging-webhooks` (the platform base). No prod host carries
244
+ * a `staging-` label.
245
+ *
246
+ * Tests the HOSTNAME, not a substring of the whole URL. Eight apps
247
+ * previously hand-rolled
248
+ *
249
+ * reqUrl.includes('staging-apps.sprigr.com') ||
250
+ * reqUrl.includes('staging-team.sprigr.com')
251
+ *
252
+ * which was wrong in BOTH directions. It missed `staging-sites`, so a
253
+ * staging install served from the preview host built an authorize URL
254
+ * pointing at the PRODUCTION bouncer — which cannot resolve a staging
255
+ * install id, so the connect died at the bouncer with a confusing error
256
+ * (observed live 2026-07-25 by reading the Location header off a
257
+ * deployed /oauth/start). And because it matched the whole URL, a PROD
258
+ * install hit with `?return_to=https://staging-team.sprigr.com/x` was
259
+ * classified as staging. Parsing the hostname closes both.
260
+ *
261
+ * Unparseable input resolves to `false` (prod), so a caller can never
262
+ * accidentally point production traffic at staging.
263
+ */
264
+ declare function isStagingHost(urlOrHostname: string | null | undefined): boolean;
265
+ /**
266
+ * Signals an app can offer to decide which bouncer to use. Pass whatever
267
+ * the call site actually has:
268
+ *
269
+ * - `reqUrl` an inbound browser request URL (the /oauth/start route).
270
+ * - `platformBase` the platform-stamped `SPRIGR_PLATFORM_BASE`, for tool
271
+ * handlers dispatched on /__sprigr/tool/* where there is
272
+ * no browser origin. Staging value is
273
+ * `https://staging-webhooks.sprigr.com`.
274
+ * - `override` an explicit per-install redirect URI / bouncer base.
275
+ * Always wins; used for local dev and canaries.
276
+ *
277
+ * Staging wins if ANY supplied signal says staging.
278
+ */
279
+ interface OAuthBouncerSignals {
280
+ reqUrl?: string | null;
281
+ platformBase?: string | null;
282
+ override?: string | null;
283
+ }
284
+ /**
285
+ * The bouncer ORIGIN for the environment serving this request, e.g.
286
+ * `https://staging-oauth-bouncer.sprigr.com`. Never has a trailing slash.
287
+ *
288
+ * `override` short-circuits everything (trailing slash stripped).
289
+ */
290
+ declare function resolveOAuthBouncerBase(signals?: OAuthBouncerSignals): string;
291
+ /**
292
+ * The full bouncer CALLBACK URL for a provider, which is the value that
293
+ * goes in an authorize URL's `redirect_uri` and must be registered as a
294
+ * redirect URI on the provider's developer app:
295
+ *
296
+ * resolveOAuthBouncerCallbackUrl('simpro', { reqUrl: req.url })
297
+ * // -> "https://staging-oauth-bouncer.sprigr.com/simpro/oauth/callback"
298
+ *
299
+ * `override` is returned verbatim (no provider path appended) — an app
300
+ * pointing at a local bouncer supplies the complete callback URL.
301
+ */
302
+ declare function resolveOAuthBouncerCallbackUrl(provider: string, signals?: OAuthBouncerSignals): string;
237
303
 
238
304
  /**
239
305
  * Talking to the platform from BOTH execution contexts.
@@ -364,6 +430,26 @@ declare function installTokenPost(bridge: InstallBridge, path: string, body: unk
364
430
  * await audit(env, 'emit', JSON.stringify(r)); // records `via`
365
431
  */
366
432
  declare function emitMarketplaceEvent(env: WfpBridgeEnv, event: string, payload: unknown, opts?: EmitMarketplaceEventOptions): Promise<EmitResult>;
433
+ /**
434
+ * Whether an emit could reach the platform at all, by either transport.
435
+ *
436
+ * Use it to skip work whose only purpose is to feed an emit. Gate on this
437
+ * rather than on `env.SPRIGR?.emit`: the binding is absent on every inline
438
+ * route, so the narrower check skips the work in exactly the context where the
439
+ * HTTP bridge would have carried it.
440
+ */
441
+ declare function canEmit(env: WfpBridgeEnv): boolean;
442
+ /**
443
+ * Pre-bind an app's `integrationType` so call sites pass only what varies.
444
+ * `sourceIntegration` is built per call from `env.INSTALL_ID`, and omitted
445
+ * entirely when that is unbound (the platform 400s on a partial one).
446
+ *
447
+ * const emit = createMarketplaceEmitter('procore');
448
+ * const r = await emit(env, 'procore.rfi.updated', payload);
449
+ */
450
+ declare function createMarketplaceEmitter<E extends WfpBridgeEnv = WfpBridgeEnv>(integrationType: string, defaults?: {
451
+ timeoutMs?: number;
452
+ }): (env: E, eventName: string, payload: unknown) => Promise<EmitResult>;
367
453
  /**
368
454
  * Return an env whose `SPRIGR.emit` works even on an inline route, leaving
369
455
  * existing `env.SPRIGR.emit(...)` call sites untouched. Use this when an app
@@ -853,4 +939,4 @@ interface ExecutionContextLike {
853
939
  }
854
940
  type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
855
941
 
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 };
942
+ 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 OAuthBouncerSignals, 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, canEmit, constantTimeEqual, createMarketplaceEmitter, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, installTokenPost, isStagingHost, listAppFiles, overlaySprigr, parseActor, putAppFile, putAppFileStream, randomHex, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, withSprigrEmitFallback };
package/dist/index.js CHANGED
@@ -165,6 +165,30 @@ function buildMarketplaceWebhookUrl(env, installId, topicPath, override) {
165
165
  const base = resolvePlatformWebhookBase(env, override);
166
166
  return `${base}/webhook/marketplace/${installId}/${topicPath}`;
167
167
  }
168
+ var OAUTH_BOUNCER_PROD = "https://oauth-bouncer.sprigr.com";
169
+ var OAUTH_BOUNCER_STAGING = "https://staging-oauth-bouncer.sprigr.com";
170
+ function isStagingHost(urlOrHostname) {
171
+ const raw = (urlOrHostname ?? "").trim();
172
+ if (!raw) return false;
173
+ let hostname;
174
+ try {
175
+ hostname = new URL(raw).hostname;
176
+ } catch {
177
+ hostname = /^[a-z0-9.-]+$/i.test(raw) ? raw : "";
178
+ }
179
+ return /(^|\.)staging-/.test(hostname);
180
+ }
181
+ function resolveOAuthBouncerBase(signals = {}) {
182
+ const override = (signals.override ?? "").trim();
183
+ if (override) return override.replace(/\/$/, "");
184
+ const isStaging = isStagingHost(signals.reqUrl) || isStagingHost(signals.platformBase);
185
+ return isStaging ? OAUTH_BOUNCER_STAGING : OAUTH_BOUNCER_PROD;
186
+ }
187
+ function resolveOAuthBouncerCallbackUrl(provider, signals = {}) {
188
+ const override = (signals.override ?? "").trim();
189
+ if (override) return override;
190
+ return `${resolveOAuthBouncerBase(signals)}/${provider}/oauth/callback`;
191
+ }
168
192
 
169
193
  // src/wfp-bridge.ts
170
194
  function resolveInstallBridge(env) {
@@ -262,6 +286,18 @@ async function emitMarketplaceEvent(env, event, payload, opts) {
262
286
  return { emitted: false, via: "http", error: err instanceof Error ? err.message : String(err) };
263
287
  }
264
288
  }
289
+ function canEmit(env) {
290
+ return bindingEmit(env) !== null || resolveInstallBridge(env) !== null;
291
+ }
292
+ function createMarketplaceEmitter(integrationType, defaults = {}) {
293
+ return (env, eventName, payload) => {
294
+ const installId = typeof env.INSTALL_ID === "string" ? env.INSTALL_ID : "";
295
+ return emitMarketplaceEvent(env, eventName, payload, {
296
+ ...defaults,
297
+ ...installId ? { sourceIntegration: { integrationId: installId, integrationType } } : {}
298
+ });
299
+ };
300
+ }
265
301
  function withSprigrEmitFallback(env) {
266
302
  if (bindingEmit(env)) return env;
267
303
  const bridge = resolveInstallBridge(env);
@@ -391,7 +427,9 @@ export {
391
427
  base64ToBytes,
392
428
  buildMarketplaceWebhookUrl,
393
429
  bytesToBase64,
430
+ canEmit,
394
431
  constantTimeEqual,
432
+ createMarketplaceEmitter,
395
433
  decodeState,
396
434
  deleteAppFile,
397
435
  describeMissingBridge,
@@ -403,6 +441,7 @@ export {
403
441
  getAppFile,
404
442
  hmacSha256Hex,
405
443
  installTokenPost,
444
+ isStagingHost,
406
445
  listAppFiles,
407
446
  overlaySprigr,
408
447
  parseActor,
@@ -410,6 +449,8 @@ export {
410
449
  putAppFileStream,
411
450
  randomHex,
412
451
  resolveInstallBridge,
452
+ resolveOAuthBouncerBase,
453
+ resolveOAuthBouncerCallbackUrl,
413
454
  resolvePlatformWebhookBase,
414
455
  withSprigrEmitFallback
415
456
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprigr/apps-app-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",