@sprigr/apps-app-sdk 0.4.0 → 0.6.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
@@ -17,6 +17,31 @@ The runtime injects the `env.SPRIGR` host object **only on `/__sprigr/*` dispatc
17
17
  - `resolveInstallBridge(env)` / `installTokenPost(bridge, path, body, opts?)` — build your own `/internal/wfp/*` fallback (collections, files, inbox) with the auth and error extraction handled.
18
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`.
19
19
 
20
+ ## Per-actor tool wrappers
21
+
22
+ An agent-facing tool must resolve the **calling** identity's own credential, or refuse. It must never fall back to another connected account, not even for a read: an install is company-wide, but access is not.
23
+
24
+ ```ts
25
+ // src/handlers/wrap.ts: bind the options once
26
+ import { createToolWrappers, NotConnectedError } from '@sprigr/apps-app-sdk';
27
+
28
+ export const { tool, actorTool } = createToolWrappers<MyEnv>({
29
+ notConnectedHint: 'Call my_connect and send the user the link.',
30
+ mapError: (err) =>
31
+ err instanceof MyApiError ? { error: err.message, status: err.status } : null,
32
+ });
33
+
34
+ // src/handlers/things.ts
35
+ export default { list_things: actorTool(async (env, actor, args) => { /* ... */ }) };
36
+ ```
37
+
38
+ - `actorTool(fn)`: reads the platform-stamped `args.actor`, returns `412 no_caller_identity` when it is absent, and maps a thrown `NotConnectedError` to `412 not_connected` with your connect hint. Identity comes only from the nested `args.actor`; flat body fields are agent-supplied and spoofable.
39
+ - `tool(fn)`: for handlers with genuinely no caller (schedules, webhooks, platform mirrors). Those need a *designated service connection*, which is not licence for an agent-facing handler to borrow one.
40
+ - `NotConnectedError`: throw from your token resolver when the **caller** has no connection. Don't throw it for "the install has none", which is a different fault with a different fix.
41
+ - `isNotConnectedError(err)`: matches by class *and* by `name`, so it still works when the vendor pattern puts two copies of this module in one isolate and `instanceof` would silently fail.
42
+
43
+ **Why this is in the SDK:** every app hand-rolled this, and one shipped a version that fell back to "the first connected actor on the install" when the caller had none. One person's consent then exposed their account to every agent on that install, in production.
44
+
20
45
  ## Webhook callback URLs
21
46
 
22
47
  - `resolvePlatformWebhookBase(env)` / `buildMarketplaceWebhookUrl(env, installId, topicPath)` — env-correct platform host, so a staging install never registers prod-pointing subscriptions.
@@ -26,7 +51,7 @@ The runtime injects the `env.SPRIGR` host object **only on `/__sprigr/*` dispatc
26
51
  - `fetchWithRetry` — rate-limit-header-aware, jittered retry
27
52
  - `constantTimeEqual(a, b)` — bearer-secret verification
28
53
  - `encodeState` / `decodeState` — OAuth state base64url
29
- - `parseActor` / `actorKey` — per-actor token scoping
54
+ - `parseActor` / `actorKey` / `ownerFromActorKey` — per-actor token scoping
30
55
  - `putAppFile` / `putAppFileStream` / `appFileUrl` / `getAppFile` / `listAppFiles` / `deleteAppFile` — durable app-scoped file storage from outside the injected bridge
31
56
  - `fetchFileBytes` / `fetchFileAsBase64` / `bytesToBase64` / `base64ToBytes` — file byte helpers
32
57
 
package/dist/index.d.ts CHANGED
@@ -528,6 +528,191 @@ declare function parseActor(args: unknown): Actor | undefined;
528
528
  * treat that as "no caller identity" and fail closed.
529
529
  */
530
530
  declare function actorKey(actor: Actor | undefined): string | null;
531
+ /**
532
+ * Mailbox-owner attribution for one `env.SPRIGR.inbox.append` /
533
+ * `env.SPRIGR.inbox.folders` batch.
534
+ *
535
+ * Exactly ONE of the two fields must be set:
536
+ *
537
+ * - `platformUserId` — OIDC sub of the user whose mailbox the batch was
538
+ * synced from (a personal mailbox connection).
539
+ * - `agentId` — agent id for an agent-keyed connection (a shared /
540
+ * departmental mailbox connected through a shared agent rather than a
541
+ * person).
542
+ *
543
+ * Why this exists: a mail app install is shared by the whole company, and
544
+ * several users can each connect their own mailbox to it. The platform files
545
+ * every appended message under the ONE integration behind the install, so
546
+ * without attribution it cannot tell whose mailbox a batch came from and has
547
+ * historically granted thread visibility to every connected user. Passing
548
+ * `owner` pins each synced thread's visibility to exactly the mailbox owner:
549
+ * user connections stay private to that user, agent-keyed connections stay
550
+ * visible to that agent's audience.
551
+ *
552
+ * This is the OWNER OF THE MAILBOX (the connection identity the app keyed
553
+ * the sync on), NOT the dispatch caller — a scheduled poll has no caller at
554
+ * all yet must still attribute every batch. Populate it from the per-actor
555
+ * connection row the sync loop is iterating, e.g.:
556
+ *
557
+ * const owner = row.sprigr_user_id
558
+ * ? { platformUserId: row.sprigr_user_id }
559
+ * : { agentId: row.sprigr_agent_id };
560
+ * await env.SPRIGR.inbox.append({ channel, messages, owner });
561
+ *
562
+ * Mail apps must send it on EVERY append/folders call (sync poll, push
563
+ * delivery, backfill, folder reports). Batches without it fall back to the
564
+ * platform's legacy integration-level resolution, which on a multi-user
565
+ * install pins nobody (fail closed) rather than everyone.
566
+ *
567
+ * Hosts older than the attribution surface ignore the field entirely, so
568
+ * sending it is always safe.
569
+ */
570
+ interface InboxOwner {
571
+ /** OIDC sub (platform user id) of the mailbox's owner. */
572
+ platformUserId?: string;
573
+ /** Agent id for an agent-keyed (shared-mailbox) connection. */
574
+ agentId?: string;
575
+ }
576
+ /**
577
+ * Inverse of `actorKey`: turn a stored `u:<platformUserId>` / `a:<agentId>`
578
+ * key back into an `InboxOwner`. For apps that persist the connection
579
+ * identity as an actor-key string and need to attribute inbox batches from
580
+ * it. Returns null for null/empty/unrecognised input — callers should treat
581
+ * that as "owner unknown" and omit the field rather than guess.
582
+ */
583
+ declare function ownerFromActorKey(key: string | null | undefined): InboxOwner | null;
584
+
585
+ /**
586
+ * Adapters between the marketplace wrapper's calling convention and an app's
587
+ * inner handler signatures, with fail-closed per-actor authorization built in.
588
+ *
589
+ * The wrapper calls `handler(args, env, ctx)`, args first. Inner handlers are
590
+ * usually written `(env, args)` or `(env, actor, args)`, so something has to
591
+ * flip them and shape thrown errors into a result an agent can read.
592
+ *
593
+ * tool(fn) actor-less handlers: schedules, webhooks, internal work.
594
+ * actorTool(fn) per-actor handlers: reads the platform-stamped
595
+ * `args.actor`, fails closed with 412 when it is absent, and
596
+ * maps NotConnectedError to a 412 `not_connected`.
597
+ *
598
+ * WHY THIS IS IN THE SDK. Every app hand-rolled this wrapper, and the gorgias
599
+ * app got it wrong in the way that matters: when the caller had no connection
600
+ * it fell through to "the first connected actor on the install", so one
601
+ * person's consent made their whole helpdesk readable and writable by every
602
+ * agent on a company-wide install. That reached production. The rule is not
603
+ * hard, but it is easy to write a plausible-looking fallback for, so the
604
+ * correct version belongs in one place that apps import rather than copy.
605
+ *
606
+ * THE RULE: an agent-facing tool resolves the CALLING identity's own
607
+ * credential, or refuses. Never another actor's, not even to be helpful, and
608
+ * not even for a read. Background paths (schedules, webhooks, platform
609
+ * mirrors) genuinely have no caller and need a designated service connection;
610
+ * that is what `tool()` is for, and it is not licence for an agent-facing
611
+ * handler to borrow one.
612
+ */
613
+
614
+ /** What every wrapped handler returns. `ok:false` never throws past the wrapper. */
615
+ type ToolResult<R> = {
616
+ ok: true;
617
+ result: R;
618
+ } | {
619
+ ok: false;
620
+ error: string;
621
+ hint?: string;
622
+ status?: number;
623
+ };
624
+ /**
625
+ * Throw this from a token resolver when the CALLING actor has no connection
626
+ * of their own. `actorTool` turns it into a 412 `not_connected` carrying the
627
+ * app's connect hint, which is the agent's cue to mint an authorize link
628
+ * rather than retry.
629
+ *
630
+ * Do not throw it for "the install has no connection at all" on a background
631
+ * path: that is a different failure with a different fix (an admin
632
+ * designating a service connection), and collapsing the two sends the agent
633
+ * chasing a consent link it cannot complete.
634
+ */
635
+ declare class NotConnectedError extends Error {
636
+ constructor(message?: string);
637
+ }
638
+ /**
639
+ * True for a NotConnectedError, INCLUDING one thrown by a different copy of
640
+ * this module.
641
+ *
642
+ * `instanceof` alone is wrong here and would fail silently. Apps reach this
643
+ * package two ways: a normal npm dependency, and the legacy `sprigrVendor`
644
+ * mirror that copies the source into `src/lib/vendor/`. An app that vendors
645
+ * the SDK while a dependency resolves its own npm copy has TWO distinct
646
+ * `NotConnectedError` classes at runtime, and `instanceof` across them is
647
+ * false. The failure mode is the bad one: the error falls through to the
648
+ * generic branch, the agent gets an opaque message instead of `not_connected`,
649
+ * and never learns to offer a connect link. Matching on `name` as well costs
650
+ * nothing and survives the boundary.
651
+ */
652
+ declare function isNotConnectedError(err: unknown): boolean;
653
+ interface ToolWrapperOptions {
654
+ /**
655
+ * Hint returned alongside `not_connected`. Name the app's connect tool and
656
+ * frame it as a one-click authorization rather than an error, because this
657
+ * string is what the agent repeats to the user.
658
+ */
659
+ notConnectedHint?: string;
660
+ /**
661
+ * Hint returned alongside `no_caller_identity`.
662
+ */
663
+ noCallerIdentityHint?: string;
664
+ /**
665
+ * Shape an app-specific error (a provider API error carrying an HTTP
666
+ * status) into a result. Return null to fall through to the generic
667
+ * `{ error: message }`. Runs AFTER the not_connected check.
668
+ *
669
+ * mapError: (err) => err instanceof GraphApiError
670
+ * ? { error: err.message, status: err.status }
671
+ * : null,
672
+ */
673
+ mapError?: (err: unknown) => {
674
+ error: string;
675
+ status?: number;
676
+ hint?: string;
677
+ } | null;
678
+ }
679
+ /**
680
+ * Wrap an actor-less handler. Catches throws into `{ ok: false, error }` so an
681
+ * agent caller never sees a bare exception.
682
+ */
683
+ declare function tool<Env, A, R>(fn: (env: Env, args: A) => Promise<R>, opts?: ToolWrapperOptions): (args: A, env: Env) => Promise<ToolResult<R>>;
684
+ /**
685
+ * Wrap a per-actor handler. Fails closed twice over: no stamped identity is
686
+ * `no_caller_identity`, and a caller with no connection of their own is
687
+ * `not_connected`. Both 412, both distinct on purpose: the first is a
688
+ * plumbing fault the agent should retry differently, the second the agent can
689
+ * resolve itself by sending the user a link.
690
+ *
691
+ * Identity comes ONLY from the nested, platform-stamped `args.actor`.
692
+ * `parseActor` deliberately ignores flat body fields, which are agent-supplied
693
+ * and spoofable; honouring one would let a caller name any colleague and act
694
+ * as them.
695
+ */
696
+ declare function actorTool<Env, A, R>(fn: (env: Env, actor: Actor, args: A) => Promise<R>, opts?: ToolWrapperOptions): (args: A, env: Env) => Promise<ToolResult<R>>;
697
+ /**
698
+ * Bind the options once and get both wrappers typed to the app's env, so
699
+ * handler files call `actorTool(fn)` with no per-call ceremony:
700
+ *
701
+ * // src/handlers/wrap.ts
702
+ * export const { tool, actorTool } = createToolWrappers<Ms365Env>({
703
+ * notConnectedHint: 'Call ms_connect and send the user the link…',
704
+ * mapError: (err) =>
705
+ * err instanceof GraphApiError ? { error: err.message, status: err.status } : null,
706
+ * });
707
+ *
708
+ * Options bound once is the point: an app that repeats them at each of fifty
709
+ * call sites will drift, and the one that drifts is the hint telling a user
710
+ * how to connect.
711
+ */
712
+ declare function createToolWrappers<Env>(opts?: ToolWrapperOptions): {
713
+ tool: <A, R>(fn: (env: Env, args: A) => Promise<R>) => (args: A, env: Env) => Promise<ToolResult<R>>;
714
+ actorTool: <A, R>(fn: (env: Env, actor: Actor, args: A) => Promise<R>) => (args: A, env: Env) => Promise<ToolResult<R>>;
715
+ };
531
716
 
532
717
  /**
533
718
  * App-scoped R2 file storage (platform feature, 2026-06).
@@ -939,4 +1124,4 @@ interface ExecutionContextLike {
939
1124
  }
940
1125
  type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
941
1126
 
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 };
1127
+ 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 InboxOwner, type InstallBridge, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, 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 ToolResult, type ToolWrapperOptions, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, base64ToBytes, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, tool, withSprigrEmitFallback };
package/dist/index.js CHANGED
@@ -332,6 +332,72 @@ function actorKey(actor) {
332
332
  if (actor.agentId) return `a:${actor.agentId}`;
333
333
  return null;
334
334
  }
335
+ function ownerFromActorKey(key) {
336
+ if (!key) return null;
337
+ if (key.startsWith("u:") && key.length > 2) return { platformUserId: key.slice(2) };
338
+ if (key.startsWith("a:") && key.length > 2) return { agentId: key.slice(2) };
339
+ return null;
340
+ }
341
+
342
+ // src/tool-wrappers.ts
343
+ var NotConnectedError = class extends Error {
344
+ constructor(message = "The calling actor has no connection with this app.") {
345
+ super(message);
346
+ this.name = "NotConnectedError";
347
+ }
348
+ };
349
+ function isNotConnectedError(err) {
350
+ if (err instanceof NotConnectedError) return true;
351
+ return err instanceof Error && err.name === "NotConnectedError";
352
+ }
353
+ var DEFAULT_NO_CALLER_HINT = "The platform did not stamp caller identity on this call. Retry from an agent context.";
354
+ var DEFAULT_NOT_CONNECTED_HINT = "You have not connected your own account with this app yet, and this tool only ever acts as the person calling it. Use the app's connect tool to get an authorize link.";
355
+ function shapeError(err, opts) {
356
+ const mapped = opts.mapError?.(err);
357
+ if (mapped) return { ok: false, ...mapped };
358
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
359
+ }
360
+ function tool(fn, opts = {}) {
361
+ return async (args, env) => {
362
+ try {
363
+ return { ok: true, result: await fn(env, args ?? {}) };
364
+ } catch (err) {
365
+ return shapeError(err, opts);
366
+ }
367
+ };
368
+ }
369
+ function actorTool(fn, opts = {}) {
370
+ return async (args, env) => {
371
+ const actor = parseActor(args);
372
+ if (!actor || !actor.platformUserId && !actor.agentId) {
373
+ return {
374
+ ok: false,
375
+ error: "no_caller_identity",
376
+ hint: opts.noCallerIdentityHint ?? DEFAULT_NO_CALLER_HINT,
377
+ status: 412
378
+ };
379
+ }
380
+ try {
381
+ return { ok: true, result: await fn(env, actor, args ?? {}) };
382
+ } catch (err) {
383
+ if (isNotConnectedError(err)) {
384
+ return {
385
+ ok: false,
386
+ error: "not_connected",
387
+ hint: opts.notConnectedHint ?? DEFAULT_NOT_CONNECTED_HINT,
388
+ status: 412
389
+ };
390
+ }
391
+ return shapeError(err, opts);
392
+ }
393
+ };
394
+ }
395
+ function createToolWrappers(opts = {}) {
396
+ return {
397
+ tool: (fn) => tool(fn, opts),
398
+ actorTool: (fn) => actorTool(fn, opts)
399
+ };
400
+ }
335
401
 
336
402
  // src/app-files.ts
337
403
  var DEFAULT_PLATFORM_BASE = "https://webhooks.sprigr.com";
@@ -422,7 +488,9 @@ function deleteAppFile(env, key) {
422
488
  export {
423
489
  DEFAULT_EMIT_TIMEOUT_MS,
424
490
  DEFAULT_MAX_FILE_BYTES,
491
+ NotConnectedError,
425
492
  actorKey,
493
+ actorTool,
426
494
  appFileUrl,
427
495
  base64ToBytes,
428
496
  buildMarketplaceWebhookUrl,
@@ -430,6 +498,7 @@ export {
430
498
  canEmit,
431
499
  constantTimeEqual,
432
500
  createMarketplaceEmitter,
501
+ createToolWrappers,
433
502
  decodeState,
434
503
  deleteAppFile,
435
504
  describeMissingBridge,
@@ -441,9 +510,11 @@ export {
441
510
  getAppFile,
442
511
  hmacSha256Hex,
443
512
  installTokenPost,
513
+ isNotConnectedError,
444
514
  isStagingHost,
445
515
  listAppFiles,
446
516
  overlaySprigr,
517
+ ownerFromActorKey,
447
518
  parseActor,
448
519
  putAppFile,
449
520
  putAppFileStream,
@@ -452,5 +523,6 @@ export {
452
523
  resolveOAuthBouncerBase,
453
524
  resolveOAuthBouncerCallbackUrl,
454
525
  resolvePlatformWebhookBase,
526
+ tool,
455
527
  withSprigrEmitFallback
456
528
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprigr/apps-app-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",