@sprigr/apps-app-sdk 0.8.8 → 0.10.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
@@ -69,6 +69,7 @@ Three platform tiers, and they are not substitutes for each other. Full guide: [
69
69
 
70
70
  - `requireApproval(handlers, specs, opts)` wraps the named tools so each returns an `_approval` card instead of writing, then on the granted pass captures a before-image through the **pinned** connection and offers `_undo`. It resolves the connection inside itself, before the label lookup, the hash and the capture: a gate that sits outside the app's own pin names the wrong store, hashes the wrong store and captures the wrong store's copy. Register the result after the originals: `Object.assign(registry, requireApproval(registry, SPECS, opts))`.
71
71
  - `dispatcherApproval(specs, opts)`: the same gate for a one-tool-many-actions dispatcher. Build it beside the action registry and call `gate.run(action, args, env, () => def.execute(state, parsed))` from the dispatcher; params are read from `opts.inputField` (default `input`), the action name is hashed in, and `pinEnv` may return a per-actor token state rather than the env.
72
+ - Every resolver on the gate is a **default, overridable per spec**: `ApprovalSpec.resolveConnection`, `.describeTarget` and `.stampConnection` win over the gate's for that spec alone. Reach for it when one app has two families of write that reach a connection differently (creates that POST under an account, deletes that address a global object id on a path carrying no account). One gate-level rule has to pick one of them, and for the other it prints a false connection on the card a person taps and mixes it into the grant hash. A spec that hits no connection returns `''`.
72
73
  - `approvalHash(rawId, connection, ...parts)`, `set(values)`, `seq(values)`: a stable operation identity so a tap survives the model reordering a list or dropping an optional argument on the retry.
73
74
  - `undoEnvelope({...})` throws on a blank field. The platform drops the whole envelope on a blank and mints nothing, silently.
74
75
  - `refuseWithoutForce(name, handler, opts)` / `archiveOfferRefusal(...)`: a delete refuses once and offers the vendor's native archive unless `force: true`.
package/dist/index.d.ts CHANGED
@@ -582,6 +582,168 @@ interface InboxOwner {
582
582
  */
583
583
  declare function ownerFromActorKey(key: string | null | undefined): InboxOwner | null;
584
584
 
585
+ /**
586
+ * Who is looking at this app's UI right now.
587
+ *
588
+ * A marketplace app UI is embedded in the portal iframe with only
589
+ * `#install_id=...` in its src, so nothing about the human viewer arrives in
590
+ * the URL. It arrives in HTTP headers the platform's `website-serve` worker
591
+ * stamps on every dispatch, and this module is the ONE place that reads them.
592
+ *
593
+ * ## Use this, do not hand-roll `src/lib/viewer.ts`
594
+ *
595
+ * Twelve apps in `sprigr-apps` each carried their own copy of this logic as
596
+ * of 2026-09-03, all textually different. Five apps had a query-param
597
+ * fallback that outranked the header, which is a connection-hijack bug (any
598
+ * tenant member could bind their own provider account to a colleague's actor
599
+ * row): `sprigr-apps` #1487-#1491, and `sprigr-apps#474` before them. That is
600
+ * why this lives in the SDK.
601
+ *
602
+ * **Never take a viewer identity from a query param, a form field, a cookie
603
+ * your app set, or a `postMessage` payload — not even as a fallback for when
604
+ * the header is missing.** A missing header means "no viewer"; it does not
605
+ * mean "believe the client".
606
+ *
607
+ * ## Two channels, one preferred
608
+ *
609
+ * - **`X-Sprigr-Viewer`** (preferred, platform issue #3210): a signed token
610
+ * naming the viewer, bound to YOUR install and expiring in ~2 minutes.
611
+ * Everything in it is inside an HMAC your install alone can check, using
612
+ * the `SPRIGR_VIEWER_SECRET` binding the platform derives per install. It
613
+ * is the only channel that carries a display name.
614
+ * - **`x-sprigr-platform-user-id`** (legacy): the viewer's OIDC sub as a
615
+ * plain header. Not signed. It rests on transport integrity: your Worker
616
+ * is reachable only through the platform's WFP dispatch, and the platform
617
+ * overwrites the header unconditionally on every forward, so a browser
618
+ * cannot smuggle its own value through. That is a real guarantee, but you
619
+ * cannot verify it yourself.
620
+ *
621
+ * `resolveViewerContext` prefers the signed channel and returns
622
+ * `trust: 'verified'`. The legacy channel is used ONLY when you pass
623
+ * `allowTransportFallback: true`, and then reports `trust: 'transport'`. The
624
+ * fallback is opt-in per call site on purpose: it is needed while an install
625
+ * predates its `SPRIGR_VIEWER_SECRET` binding (the platform binds it on the
626
+ * install's next build or rebind), and it should be dropped once every
627
+ * install has been rebound.
628
+ */
629
+ /** Header carrying the signed viewer context. */
630
+ declare const VIEWER_CONTEXT_HEADER = "x-sprigr-viewer";
631
+ /** Legacy transport-attested header carrying the viewer's OIDC sub. */
632
+ declare const VIEWER_PLATFORM_USER_ID_HEADER = "x-sprigr-platform-user-id";
633
+ /** Legacy transport-attested header carrying the sprigr-team `users.id`. */
634
+ declare const VIEWER_USER_ID_HEADER = "x-sprigr-user-id";
635
+ /** Legacy transport-attested header carrying the viewer's company role. */
636
+ declare const VIEWER_ROLE_HEADER = "x-sprigr-role";
637
+ /** Sentinel both id headers carry when nobody is signed in. */
638
+ declare const VIEWER_ANONYMOUS = "anonymous";
639
+ /** Raw wire claims. snake_case because they are the signed payload. */
640
+ interface WireClaims {
641
+ install_id: string;
642
+ company_id: string;
643
+ user_id: string;
644
+ platform_user_id: string;
645
+ role: string;
646
+ display_name?: string;
647
+ email?: string;
648
+ issued_at: number;
649
+ expires_at: number;
650
+ }
651
+ /** The resolved viewer. */
652
+ interface ViewerContext {
653
+ /**
654
+ * How this was established.
655
+ *
656
+ * - `'verified'` — the signed `X-Sprigr-Viewer` token checked out against
657
+ * your install's own key. Authorise on this.
658
+ * - `'transport'` — the legacy unsigned header, accepted because the call
659
+ * site passed `allowTransportFallback`. Same practical guarantee the
660
+ * platform has always given, but nothing was cryptographically checked
661
+ * and `displayName` / `email` are never available.
662
+ */
663
+ trust: 'verified' | 'transport';
664
+ /** OIDC sub (`usr_...`) of the viewer, or null when signed out. This is
665
+ * what per-actor state keys on. */
666
+ platformUserId: string | null;
667
+ /** sprigr-team `users.id` row id, or null when signed out. */
668
+ userId: string | null;
669
+ /** Company that owns the install. Null on the legacy channel when the
670
+ * header is absent. */
671
+ companyId: string | null;
672
+ /** The viewer's role at the company, or null when signed out. */
673
+ role: string | null;
674
+ /** Display name. Only ever present on a verified context. DISPLAY ONLY —
675
+ * never branch authorisation on it. */
676
+ displayName: string | null;
677
+ /** Email. Only ever present on a verified context. DISPLAY ONLY. */
678
+ email: string | null;
679
+ /** The install the context was minted for. Null on the legacy channel. */
680
+ installId: string | null;
681
+ }
682
+ interface ViewerEnv {
683
+ /** Per-install key the platform derives and binds. Absent on an install
684
+ * that has not been rebuilt since the platform started binding it. */
685
+ SPRIGR_VIEWER_SECRET?: string;
686
+ /** This install's id, bound by the platform. Used to refuse a context
687
+ * minted for a different install. */
688
+ INSTALL_ID?: string;
689
+ }
690
+ interface ResolveViewerOptions {
691
+ /**
692
+ * Accept the legacy unsigned `x-sprigr-platform-user-id` header when no
693
+ * verified token is available. Defaults to FALSE: a call site that wants
694
+ * the weaker guarantee has to say so, so the weaker path is visible in
695
+ * review instead of being the silent default.
696
+ */
697
+ allowTransportFallback?: boolean;
698
+ /** Override the clock, for tests. Epoch seconds. */
699
+ nowSeconds?: number;
700
+ /** Override the future-issued tolerance, in seconds. */
701
+ clockSkewSeconds?: number;
702
+ }
703
+ type ViewerContextFailure = 'missing' | 'no_secret' | 'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid' | 'install_mismatch';
704
+ type ViewerContextVerification = {
705
+ ok: true;
706
+ claims: WireClaims;
707
+ } | {
708
+ ok: false;
709
+ reason: ViewerContextFailure;
710
+ };
711
+ /**
712
+ * Verify a raw `X-Sprigr-Viewer` token.
713
+ *
714
+ * Most apps want `resolveViewerContext` instead; reach for this when you
715
+ * need to log or branch on the specific failure reason. Do NOT surface the
716
+ * reason to the browser: collapse every failure into the same "no viewer"
717
+ * outcome so a caller learns nothing from probing.
718
+ *
719
+ * The signature is checked before any claim, so nothing about an unsigned
720
+ * payload is observable through the failure reason.
721
+ */
722
+ declare function verifyViewerToken(token: string | null | undefined, env: ViewerEnv, options?: ResolveViewerOptions): Promise<ViewerContextVerification>;
723
+ /**
724
+ * Resolve the viewer from a request's headers.
725
+ *
726
+ * const viewer = await resolveViewerContext(request.headers, env);
727
+ * if (!viewer?.platformUserId) return jsonError(401, 'sign in to continue');
728
+ * // authorise on viewer.platformUserId, render viewer.displayName
729
+ *
730
+ * Returns null when there is no viewer at all: no token (or one that did not
731
+ * verify) and either no legacy header or no `allowTransportFallback`. A
732
+ * signed-out visitor on a verified context comes back non-null with every id
733
+ * field null, so an app can tell "nobody is signed in" from "I cannot tell".
734
+ */
735
+ declare function resolveViewerContext(headers: Headers, env: ViewerEnv, options?: ResolveViewerOptions): Promise<ViewerContext | null>;
736
+ /**
737
+ * The viewer's platform user id, or null.
738
+ *
739
+ * The narrow shape the twelve hand-rolled `viewer.ts` copies exposed, so an
740
+ * app can migrate onto the SDK in one import change and adopt the richer
741
+ * context later. `allowTransportFallback` defaults to TRUE here and only
742
+ * here, because that is exactly what those copies did — an app that wants
743
+ * the stronger guarantee should call `resolveViewerContext` directly.
744
+ */
745
+ declare function resolveViewerUserId(headers: Headers, env: ViewerEnv, options?: ResolveViewerOptions): Promise<string | null>;
746
+
585
747
  /**
586
748
  * Adapters between the marketplace wrapper's calling convention and an app's
587
749
  * inner handler signatures, with fail-closed per-actor authorization built in.
@@ -1088,12 +1250,25 @@ declare function refuseWithoutForce<E>(deleteAction: string, inner: ToolHandler<
1088
1250
  * `_undo` ON A NULL CAPTURE. A failed capture does not fail the write: the
1089
1251
  * user asked for it and a human approved it. It degrades to no token.
1090
1252
  *
1253
+ * Every resolver here is gate-level by default and overridable per spec:
1254
+ * `ApprovalSpec.resolveConnection` / `.describeTarget` / `.stampConnection`
1255
+ * win over the gate's for that spec alone. One rule cannot describe two
1256
+ * families of write truthfully, and both consumers of the resolved value are
1257
+ * safety surfaces (the card a person reads, and the grant hash).
1258
+ *
1091
1259
  * Generalised from the Shopify app's `require-approval.ts`; the per-tool
1092
1260
  * questions stay in the app, because only the app knows what cannot be walked
1093
1261
  * back.
1094
1262
  */
1095
- /** Per-tool (or per-action) spec: how the card reads, and (optionally) how to journal a before-image. */
1096
- interface ApprovalSpec<P = unknown> {
1263
+ /**
1264
+ * Per-tool (or per-action) spec: how the card reads, and (optionally) how to
1265
+ * journal a before-image.
1266
+ *
1267
+ * `P` is the pinned connection handed to `capture` / `describeTarget`; `E` is
1268
+ * the env a spec-level `resolveConnection` receives. `E` defaults to `unknown`
1269
+ * so `ApprovalSpec<MyState>` keeps working unchanged.
1270
+ */
1271
+ interface ApprovalSpec<P = unknown, E = unknown> {
1097
1272
  /**
1098
1273
  * Arg names that may carry the target id, most specific first. ONE list, used
1099
1274
  * for both the card label and the grant hash, so the two cannot drift apart
@@ -1122,6 +1297,27 @@ interface ApprovalSpec<P = unknown> {
1122
1297
  * can ever cover it, which is the safe side but not the useful one.
1123
1298
  */
1124
1299
  count?: (args: ToolArgs) => number;
1300
+ /**
1301
+ * Overrides the gate's `resolveConnection` for THIS spec only; the gate's
1302
+ * value stays the default for every spec that omits it.
1303
+ *
1304
+ * One gate serves every spec, so a gate-level resolver has to pick one rule.
1305
+ * That is wrong the moment an app has two families of write that reach a
1306
+ * connection differently: in meta-ads (sprigr-apps#1462) six creates POST
1307
+ * under an ad account and three address a Graph object by id on a path that
1308
+ * carries no account at all, so the account resolved for the creates was
1309
+ * printed on the delete's card and mixed into its hash while being false.
1310
+ * A resolver returning '' here is the honest answer for such a spec.
1311
+ */
1312
+ resolveConnection?: (env: E, args: ToolArgs) => Promise<string>;
1313
+ /** Overrides the gate's `describeTarget` for this spec only. */
1314
+ describeTarget?: (pinned: P, id: string, name: string) => Promise<string>;
1315
+ /**
1316
+ * Overrides the gate's `stampConnection` for this spec only. The stamp
1317
+ * repeats the card's claim in the agent's "done" report, so a spec that
1318
+ * resolves its own connection usually wants to stamp it its own way too.
1319
+ */
1320
+ stampConnection?: (result: unknown, connection: string) => unknown;
1125
1321
  /** Present only for tools whose write can be (partly) reversed. */
1126
1322
  undo?: UndoCaptureSpec<P>;
1127
1323
  }
@@ -1160,6 +1356,10 @@ interface ApprovalGateOptions<E, P = E> {
1160
1356
  * arguments and the install, canonicalised (a label like "EU store" becomes
1161
1357
  * the real domain). Return '' for a single-connection app. Never read it off
1162
1358
  * the arguments alone: on the retry the model usually omits it.
1359
+ *
1360
+ * This is the DEFAULT for every spec. A spec whose write reaches a
1361
+ * connection by another rule overrides it with
1362
+ * `ApprovalSpec.resolveConnection`.
1163
1363
  */
1164
1364
  resolveConnection: (env: E, args: ToolArgs) => Promise<string>;
1165
1365
  /**
@@ -1172,7 +1372,8 @@ interface ApprovalGateOptions<E, P = E> {
1172
1372
  * back to the id on any miss; never guess, a confidently wrong card is worse
1173
1373
  * than a terse one. Omit to always show the raw id. `name` is the tool, or
1174
1374
  * for a dispatcher the action, so a lookup can route by resource type when
1175
- * the id alone does not say what it is (an Asana gid).
1375
+ * the id alone does not say what it is (an Asana gid). Overridable per
1376
+ * spec.
1176
1377
  */
1177
1378
  describeTarget?: (pinned: P, id: string, name: string) => Promise<string>;
1178
1379
  /**
@@ -1183,7 +1384,8 @@ interface ApprovalGateOptions<E, P = E> {
1183
1384
  journal?: (env: E) => CaptureJournal;
1184
1385
  /**
1185
1386
  * Stamp the resolved connection onto a successful result (e.g. `{ store }`)
1186
- * so an agent reporting "deleted it" can name where. Optional.
1387
+ * so an agent reporting "deleted it" can name where. Optional, and
1388
+ * overridable per spec.
1187
1389
  */
1188
1390
  stampConnection?: (result: unknown, connection: string) => unknown;
1189
1391
  }
@@ -1220,7 +1422,7 @@ interface DispatcherApprovalGate<E> {
1220
1422
  * rename that quietly ungated a permanent delete is exactly the failure this
1221
1423
  * guard exists to prevent, and it would look identical to working.
1222
1424
  */
1223
- declare function requireApproval<E>(handlers: Record<string, ToolHandler<E>>, specs: Record<string, ApprovalSpec<E>>, opts: RequireApprovalOptions<E>): Record<string, ToolHandler<E>>;
1425
+ declare function requireApproval<E>(handlers: Record<string, ToolHandler<E>>, specs: Record<string, ApprovalSpec<E, E>>, opts: RequireApprovalOptions<E>): Record<string, ToolHandler<E>>;
1224
1426
  /**
1225
1427
  * The same gate for a dispatcher tool (one tool, many actions selected by an
1226
1428
  * input field). Build it once beside the action registry and call `run` from
@@ -1234,7 +1436,7 @@ declare function requireApproval<E>(handlers: Record<string, ToolHandler<E>>, sp
1234
1436
  * params are read from `opts.inputField` (default `input`), or from the
1235
1437
  * top-level args when that field is absent (the flat-verb envelope).
1236
1438
  */
1237
- declare function dispatcherApproval<E, P = E>(specs: Record<string, ApprovalSpec<P>>, opts: DispatcherApprovalOptions<E, P>): DispatcherApprovalGate<E>;
1439
+ declare function dispatcherApproval<E, P = E>(specs: Record<string, ApprovalSpec<P, E>>, opts: DispatcherApprovalOptions<E, P>): DispatcherApprovalGate<E>;
1238
1440
 
1239
1441
  /**
1240
1442
  * The reversal entry point every adopting app declares as `undo.reverse_tool`.
@@ -1957,4 +2159,4 @@ interface ExecutionContextLike {
1957
2159
  }
1958
2160
  type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
1959
2161
 
1960
- export { APPROVAL_GRANTED_KEY, type Actor, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppUndoEnvelope, type ApplyPoliciesOptions, type ApprovalGateOptions, type ApprovalSpec, type ArchiveOfferOptions, type ArchiveOfferRefusal, type CaptureJournal, type ConfirmCondition, type ConfirmRule, type ConfirmationPolicy, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, DEFAULT_MONEY_FIELDS, DEFAULT_WRITE_PREFIXES, type DerivePolicyOptions, type DerivedPolicy, type DispatcherApprovalGate, type DispatcherApprovalOptions, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InboxOwner, type InstallBridge, InvalidUndoEnvelopeError, type JournalRowLike, type ManifestDispatchBlock, type ManifestLike, type ManifestToolLike, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type OfferUndoArgs, type PlatformHostEnv, type PolicyCheckInput, type PolicySource, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type RequireApprovalOptions, type RestoreResult, type RestoreSpec, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataIndexOpts, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type ToolArgs, type ToolHandler, type ToolResult, type ToolWrapperOptions, UNIT_SEP, type UndoApplyOptions, type UndoApplyResult, type UndoCaptureSpec, type UndoFidelity, type VerbRule, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, applyConfirmationPolicies, approvalHash, archiveOfferRefusal, base64ToBytes, buildConfirmationPolicy, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, checkConfirmationPolicy, commonDestructiveVerbs, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, deriveConfirmationPolicy, describeMissingBridge, dispatcherApproval, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, fullWarning, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, manifestIsFresh, offerUndo, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, recreatedWarning, refuseWithoutForce, requireApproval, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, runUndoApply, safeCapture, seq, serializeManifest, set, tool, undoEnvelope, withSprigrEmitFallback };
2162
+ export { APPROVAL_GRANTED_KEY, type Actor, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppUndoEnvelope, type ApplyPoliciesOptions, type ApprovalGateOptions, type ApprovalSpec, type ArchiveOfferOptions, type ArchiveOfferRefusal, type CaptureJournal, type ConfirmCondition, type ConfirmRule, type ConfirmationPolicy, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, DEFAULT_MONEY_FIELDS, DEFAULT_WRITE_PREFIXES, type DerivePolicyOptions, type DerivedPolicy, type DispatcherApprovalGate, type DispatcherApprovalOptions, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InboxOwner, type InstallBridge, InvalidUndoEnvelopeError, type JournalRowLike, type ManifestDispatchBlock, type ManifestLike, type ManifestToolLike, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type OfferUndoArgs, type PlatformHostEnv, type PolicyCheckInput, type PolicySource, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type RequireApprovalOptions, type ResolveViewerOptions, type RestoreResult, type RestoreSpec, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataIndexOpts, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type ToolArgs, type ToolHandler, type ToolResult, type ToolWrapperOptions, UNIT_SEP, type UndoApplyOptions, type UndoApplyResult, type UndoCaptureSpec, type UndoFidelity, VIEWER_ANONYMOUS, VIEWER_CONTEXT_HEADER, VIEWER_PLATFORM_USER_ID_HEADER, VIEWER_ROLE_HEADER, VIEWER_USER_ID_HEADER, type VerbRule, type ViewerContext, type ViewerContextFailure, type ViewerContextVerification, type ViewerEnv, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, applyConfirmationPolicies, approvalHash, archiveOfferRefusal, base64ToBytes, buildConfirmationPolicy, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, checkConfirmationPolicy, commonDestructiveVerbs, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, deriveConfirmationPolicy, describeMissingBridge, dispatcherApproval, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, fullWarning, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, manifestIsFresh, offerUndo, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, recreatedWarning, refuseWithoutForce, requireApproval, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, resolveViewerContext, resolveViewerUserId, runUndoApply, safeCapture, seq, serializeManifest, set, tool, undoEnvelope, verifyViewerToken, withSprigrEmitFallback };
package/dist/index.js CHANGED
@@ -339,6 +339,123 @@ function ownerFromActorKey(key) {
339
339
  return null;
340
340
  }
341
341
 
342
+ // src/viewer.ts
343
+ var VIEWER_CONTEXT_HEADER = "x-sprigr-viewer";
344
+ var VIEWER_PLATFORM_USER_ID_HEADER = "x-sprigr-platform-user-id";
345
+ var VIEWER_USER_ID_HEADER = "x-sprigr-user-id";
346
+ var VIEWER_ROLE_HEADER = "x-sprigr-role";
347
+ var VIEWER_ANONYMOUS = "anonymous";
348
+ var TOKEN_VERSION = "svc1";
349
+ var DEFAULT_CLOCK_SKEW_SECONDS = 60;
350
+ function base64urlDecode2(s) {
351
+ try {
352
+ let b64 = s.replace(/-/g, "+").replace(/_/g, "/");
353
+ while (b64.length % 4 !== 0) b64 += "=";
354
+ const bin = atob(b64);
355
+ const out = new Uint8Array(bin.length);
356
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
357
+ return out;
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+ async function hmacBytes(secret, message) {
363
+ const key = await crypto.subtle.importKey(
364
+ "raw",
365
+ new TextEncoder().encode(secret),
366
+ { name: "HMAC", hash: "SHA-256" },
367
+ false,
368
+ ["sign"]
369
+ );
370
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)));
371
+ }
372
+ function constantTimeEqualBytes(a, b) {
373
+ if (a.byteLength !== b.byteLength) return false;
374
+ let diff = 0;
375
+ for (let i = 0; i < a.byteLength; i++) diff |= a[i] ^ b[i];
376
+ return diff === 0;
377
+ }
378
+ async function verifyViewerToken(token, env, options = {}) {
379
+ if (!token) return { ok: false, reason: "missing" };
380
+ const secret = env.SPRIGR_VIEWER_SECRET;
381
+ if (!secret) return { ok: false, reason: "no_secret" };
382
+ const parts = token.split(".");
383
+ if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return { ok: false, reason: "malformed" };
384
+ const payloadB64 = parts[1];
385
+ const macB64 = parts[2];
386
+ const provided = base64urlDecode2(macB64);
387
+ if (!provided) return { ok: false, reason: "malformed" };
388
+ const expected = await hmacBytes(secret, `${TOKEN_VERSION}.${payloadB64}`);
389
+ if (!constantTimeEqualBytes(provided, expected)) return { ok: false, reason: "bad_signature" };
390
+ const payloadBytes = base64urlDecode2(payloadB64);
391
+ if (!payloadBytes) return { ok: false, reason: "malformed" };
392
+ let parsed;
393
+ try {
394
+ parsed = JSON.parse(new TextDecoder().decode(payloadBytes));
395
+ } catch {
396
+ return { ok: false, reason: "malformed" };
397
+ }
398
+ const p = parsed;
399
+ if (!p || typeof p !== "object" || typeof p.install_id !== "string" || !p.install_id || typeof p.company_id !== "string" || !p.company_id || typeof p.user_id !== "string" || !p.user_id || typeof p.platform_user_id !== "string" || !p.platform_user_id || typeof p.role !== "string" || !p.role || typeof p.issued_at !== "number" || !Number.isFinite(p.issued_at) || typeof p.expires_at !== "number" || !Number.isFinite(p.expires_at) || p.display_name !== void 0 && typeof p.display_name !== "string" || p.email !== void 0 && typeof p.email !== "string") {
400
+ return { ok: false, reason: "malformed" };
401
+ }
402
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1e3);
403
+ const skew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
404
+ if (now >= p.expires_at) return { ok: false, reason: "expired" };
405
+ if (p.issued_at - now > skew) return { ok: false, reason: "not_yet_valid" };
406
+ if (env.INSTALL_ID && p.install_id !== env.INSTALL_ID) {
407
+ return { ok: false, reason: "install_mismatch" };
408
+ }
409
+ return { ok: true, claims: p };
410
+ }
411
+ function anonymousOrValue(raw) {
412
+ const v = raw?.trim();
413
+ if (!v || v === VIEWER_ANONYMOUS) return null;
414
+ return v;
415
+ }
416
+ async function resolveViewerContext(headers, env, options = {}) {
417
+ const verified = await verifyViewerToken(headers.get(VIEWER_CONTEXT_HEADER), env, options);
418
+ if (verified.ok) {
419
+ const c = verified.claims;
420
+ const platformUserId2 = anonymousOrValue(c.platform_user_id);
421
+ return {
422
+ trust: "verified",
423
+ platformUserId: platformUserId2,
424
+ userId: anonymousOrValue(c.user_id),
425
+ companyId: c.company_id,
426
+ role: anonymousOrValue(c.role),
427
+ // Display fields are ignored for a signed-out viewer even if a future
428
+ // platform version were to send them: a name with no identity behind
429
+ // it has nobody to belong to.
430
+ displayName: platformUserId2 ? c.display_name ?? null : null,
431
+ email: platformUserId2 ? c.email ?? null : null,
432
+ installId: c.install_id
433
+ };
434
+ }
435
+ if (!options.allowTransportFallback) return null;
436
+ const platformUserId = anonymousOrValue(headers.get(VIEWER_PLATFORM_USER_ID_HEADER));
437
+ const userId = anonymousOrValue(headers.get(VIEWER_USER_ID_HEADER));
438
+ if (!platformUserId && !userId) return null;
439
+ return {
440
+ trust: "transport",
441
+ platformUserId,
442
+ userId,
443
+ companyId: null,
444
+ role: anonymousOrValue(headers.get(VIEWER_ROLE_HEADER)),
445
+ // The legacy channel never carries these.
446
+ displayName: null,
447
+ email: null,
448
+ installId: null
449
+ };
450
+ }
451
+ async function resolveViewerUserId(headers, env, options = {}) {
452
+ const viewer = await resolveViewerContext(headers, env, {
453
+ allowTransportFallback: true,
454
+ ...options
455
+ });
456
+ return viewer?.platformUserId ?? null;
457
+ }
458
+
342
459
  // src/tool-wrappers.ts
343
460
  var NotConnectedError = class extends Error {
344
461
  constructor(message = "The calling actor has no connection with this app.") {
@@ -641,10 +758,11 @@ async function pin(opts, env, connection) {
641
758
  return opts.pinEnv(env, connection);
642
759
  }
643
760
  async function askPass(name, spec, params, env, opts, hashPrefix) {
644
- const connection = await opts.resolveConnection(env, params);
761
+ const connection = await (spec.resolveConnection ?? opts.resolveConnection)(env, params);
645
762
  const rawId = rawIdOf(params, spec.keys);
646
763
  const pinned = await pin(opts, env, connection);
647
- const target = rawId && opts.describeTarget ? await safeLabel(opts.describeTarget, pinned, rawId, name) : rawId || "(unknown id)";
764
+ const describeTarget = spec.describeTarget ?? opts.describeTarget;
765
+ const target = rawId && describeTarget ? await safeLabel(describeTarget, pinned, rawId, name) : rawId || "(unknown id)";
648
766
  const extra = spec.hash ? spec.hash(params) : [];
649
767
  const count = countOf(spec, params, rawId);
650
768
  return {
@@ -657,8 +775,9 @@ async function askPass(name, spec, params, env, opts, hashPrefix) {
657
775
  };
658
776
  }
659
777
  async function grantedPass(name, spec, params, env, opts, write) {
660
- const connection = await opts.resolveConnection(env, params);
661
- const stamp = (r) => connection && opts.stampConnection ? opts.stampConnection(r, connection) : r;
778
+ const connection = await (spec.resolveConnection ?? opts.resolveConnection)(env, params);
779
+ const stampConnection = spec.stampConnection ?? opts.stampConnection;
780
+ const stamp = (r) => connection && stampConnection ? stampConnection(r, connection) : r;
662
781
  const undo = spec.undo;
663
782
  const id = rawIdOf(params, spec.keys);
664
783
  if (!undo || !id) return stamp(await write());
@@ -1054,6 +1173,11 @@ export {
1054
1173
  InvalidUndoEnvelopeError,
1055
1174
  NotConnectedError,
1056
1175
  UNIT_SEP,
1176
+ VIEWER_ANONYMOUS,
1177
+ VIEWER_CONTEXT_HEADER,
1178
+ VIEWER_PLATFORM_USER_ID_HEADER,
1179
+ VIEWER_ROLE_HEADER,
1180
+ VIEWER_USER_ID_HEADER,
1057
1181
  actorKey,
1058
1182
  actorTool,
1059
1183
  appFileUrl,
@@ -1102,6 +1226,8 @@ export {
1102
1226
  resolveOAuthBouncerBase,
1103
1227
  resolveOAuthBouncerCallbackUrl,
1104
1228
  resolvePlatformWebhookBase,
1229
+ resolveViewerContext,
1230
+ resolveViewerUserId,
1105
1231
  runUndoApply,
1106
1232
  safeCapture,
1107
1233
  seq,
@@ -1109,5 +1235,6 @@ export {
1109
1235
  set,
1110
1236
  tool,
1111
1237
  undoEnvelope,
1238
+ verifyViewerToken,
1112
1239
  withSprigrEmitFallback
1113
1240
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprigr/apps-app-sdk",
3
- "version": "0.8.8",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",