@sprigr/apps-app-sdk 0.20.0 → 0.21.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
@@ -79,6 +79,7 @@ export default {
79
79
  - `putAppFile` / `putAppFileStream` / `appFileUrl` / `getAppFile` / `listAppFiles` / `deleteAppFile` — durable app-scoped file storage from outside the injected bridge
80
80
  - `fetchFileBytes` / `fetchFileAsBase64` / `bytesToBase64` / `base64ToBytes` — file byte helpers
81
81
  - `readableStoredKey(appKey, downloadUrl)` / `agentFileKeyFromUrl` — turn the install-relative key your app stored a file under into the absolute key the platform's `read_file` tool opens (read off the signed URL `appFileUrl` minted). Return it as a tool's `file_key` so an agent can open the file. `appKeyFromCallerKey` / `callerKeyRefusal` go the other way, for a key an agent hands back to your tool (0.17.0)
82
+ - `readFileRef(env, { file_key } | { file_url }, { maxBytes })` / `resolveFileRef`: accept a file an agent hands your tool. `resolveFileRef` only validates (run it before an approval card): a key in this install's store in any form, or an https link on `files.sprigr.com` (a link into this install is read as its key; a company link must be signed and in this company; workspace keys, other installs and every other host are refused with a `FileRefError` whose message says what to pass instead). `readFileRef` then reads the bytes with a size cap, a 20 s time cap and no redirects; `openFileRef` does the same as a stream, for a large attachment passed on without buffering. `FILE_REF_SOURCES_HELP` is the agent-facing list of accepted sources, for a tool's input description (0.21.0)
82
83
 
83
84
  Full platform semantics: [`docs/platform-reference.md`](../../docs/platform-reference.md).
84
85
 
package/dist/index.d.ts CHANGED
@@ -164,6 +164,256 @@ declare function fetchFileAsBase64(url: string, opts?: {
164
164
  size: number;
165
165
  }>;
166
166
 
167
+ /**
168
+ * App-scoped R2 file storage (platform feature, 2026-06).
169
+ *
170
+ * Marketplace apps get a contained file store: every key is confined
171
+ * server-side to `_apps/{installId}/...` so an app can only ever touch
172
+ * its own files. The app never holds a raw R2 binding — these helpers
173
+ * call the platform over the same per-install token channel as
174
+ * `env.SPRIGR.emit` (provisioning `/internal/wfp/file/*`).
175
+ *
176
+ * Auth: `Authorization: Bearer <SPRIGR_INSTALL_TOKEN>` against
177
+ * `<SPRIGR_PLATFORM_BASE>/internal/wfp/file/<op>`. Both bindings are
178
+ * stamped onto every per-install WFP upload.
179
+ *
180
+ * Keys are app-relative (`simpro-files/123.jpg`); the platform adds and
181
+ * strips the `_apps/{installId}/` prefix, so the app stays unaware of it
182
+ * and physically cannot escape its namespace.
183
+ */
184
+ interface AppFilesEnv {
185
+ SPRIGR_INSTALL_TOKEN?: string;
186
+ SPRIGR_PLATFORM_BASE?: string;
187
+ }
188
+ interface PutAppFileArgs {
189
+ /** App-relative key, e.g. "simpro-files/job-1234/55.jpg". */
190
+ key: string;
191
+ /** File bytes, base64-encoded. */
192
+ base64: string;
193
+ /** MIME type stored as the object's content-type. */
194
+ contentType?: string;
195
+ /** Optional display filename. */
196
+ filename?: string;
197
+ }
198
+ interface PutAppFileResult {
199
+ ok: true;
200
+ key: string;
201
+ bytes: number;
202
+ contentType: string;
203
+ }
204
+ interface PutAppFileStreamArgs {
205
+ /** App-relative key, e.g. "simpro-files/job-1234/55.jpg". */
206
+ key: string;
207
+ /**
208
+ * Raw file bytes. A `ReadableStream` is piped straight through to R2 so
209
+ * the bytes never fully materialise in the app isolate — use it for large
210
+ * blobs (full-res photos, PDFs). An `ArrayBuffer`/`Uint8Array` is also
211
+ * accepted when the bytes are already in hand.
212
+ */
213
+ body: ReadableStream<Uint8Array> | ArrayBuffer | Uint8Array;
214
+ /** MIME type stored as the object's content-type. */
215
+ contentType?: string;
216
+ /** Optional display filename. */
217
+ filename?: string;
218
+ /**
219
+ * Declared byte length. Forward it when known (e.g. the source's
220
+ * Content-Length) so the platform can enforce its size cap before opening
221
+ * the R2 write. Streamed bodies carry no Content-Length, so this is the
222
+ * only length signal the platform gets. Omit for unknown-length streams.
223
+ */
224
+ contentLength?: number;
225
+ }
226
+ interface AppFileUrlArgs {
227
+ key: string;
228
+ /** Seconds the signed URL stays valid (default 24h, min 60s, max ~10y, clamped server-side). */
229
+ expiresIn?: number;
230
+ }
231
+ interface AppFileUrlResult {
232
+ ok: true;
233
+ url: string;
234
+ expires_at: number;
235
+ key: string;
236
+ }
237
+ interface GetAppFileResult {
238
+ ok: true;
239
+ key: string;
240
+ base64: string;
241
+ contentType: string;
242
+ filename?: string;
243
+ bytes: number;
244
+ }
245
+ interface AppFileListItem {
246
+ key: string;
247
+ bytes: number;
248
+ uploaded?: string;
249
+ filename?: string;
250
+ contentType?: string;
251
+ }
252
+ /** Store bytes in the app's contained R2 namespace (base64 JSON). */
253
+ declare function putAppFile(env: AppFilesEnv, args: PutAppFileArgs): Promise<PutAppFileResult>;
254
+ /**
255
+ * Store bytes by streaming the raw body straight into the app's contained
256
+ * R2 namespace. Unlike `putAppFile`, this never base64-encodes the bytes
257
+ * and (with a `ReadableStream` body) never buffers them whole in the
258
+ * isolate — pipe a source `Response.body` directly through for multi-MB
259
+ * blobs. Metadata travels in headers; the body is opaque bytes.
260
+ *
261
+ * The platform's put-stream route cannot retry a transient R2 fault itself
262
+ * (its body is spent once read), so a resendable body — `ArrayBuffer` /
263
+ * `Uint8Array` already in hand, not a `ReadableStream` — gets a bounded
264
+ * retry here instead. The key is deterministic, so a retry is a full
265
+ * overwrite, exactly the argument the platform's buffered `put` route makes
266
+ * for its own retry (wfp-files.ts, sprigr-team).
267
+ */
268
+ declare function putAppFileStream(env: AppFilesEnv, args: PutAppFileStreamArgs): Promise<PutAppFileResult>;
269
+ /** Mint a signed, time-limited download URL for an app file. */
270
+ declare function appFileUrl(env: AppFilesEnv, args: AppFileUrlArgs): Promise<AppFileUrlResult>;
271
+ /** Read an app file back as base64. */
272
+ declare function getAppFile(env: AppFilesEnv, key: string): Promise<GetAppFileResult>;
273
+ /** List app files under an optional app-relative prefix. */
274
+ declare function listAppFiles(env: AppFilesEnv, prefix?: string): Promise<{
275
+ ok: true;
276
+ files: AppFileListItem[];
277
+ truncated: boolean;
278
+ }>;
279
+ /** Delete an app file. */
280
+ declare function deleteAppFile(env: AppFilesEnv, key: string): Promise<{
281
+ ok: true;
282
+ key: string;
283
+ }>;
284
+
285
+ /**
286
+ * A file an agent hands an app tool: `{ file_key }` or `{ file_url }`
287
+ * (sprigr/sprigr-app-kit#120).
288
+ *
289
+ * The platform resolves no file argument for an app. An agent passes a plain
290
+ * string and the app turns it into bytes. Three apps had each written that
291
+ * (trello `readInstallFile`, dropbox `resolveSource`, skusavvy `readRef`), and
292
+ * the copies had drifted: only dropbox checked that a company link was signed
293
+ * and in this company, only skusavvy capped time and refused redirects. This
294
+ * module is the one version, with the strictest rules of each.
295
+ *
296
+ * Two steps, usable apart:
297
+ *
298
+ * resolveFileRef validation only, no I/O. Run it before an approval card or
299
+ * any other side effect, so a malformed call fails first.
300
+ * openFileRef resolve, then open the bytes as a stream with a size cap,
301
+ * a time cap and no redirects, so a large attachment is
302
+ * passed on without being buffered first.
303
+ * readFileRef openFileRef, read into memory, for files an app buffers.
304
+ *
305
+ * What a reference may be:
306
+ * - file_key: a key in THIS install's file store, in any form a tool hands
307
+ * out (app-relative, absolute `_apps/<installId>/...`, or its signed link),
308
+ * via appKeyFromCallerKey. A key in the company's own storage (an agent's
309
+ * workspace file) cannot be opened by an app: refused, with how to pass it.
310
+ * - file_url: an https link on a Sprigr file host only. A link into this
311
+ * install's storage is read as a key (so it opens the caller's own copy);
312
+ * a link into the company's storage must be signed and in THIS company.
313
+ * Nothing else is fetched, so an agent cannot point an app at an arbitrary
314
+ * host, and every host it can reach is the platform's own.
315
+ */
316
+
317
+ /** Hosts the platform serves signed file links from (the file proxy, prod and staging). */
318
+ declare const SPRIGR_FILE_HOSTS: ReadonlySet<string>;
319
+ /** Default time to read one file before giving up. */
320
+ declare const DEFAULT_FILE_READ_TIMEOUT_MS = 20000;
321
+ /** How an agent gets a workspace file to an app: a short-lived signed link. */
322
+ declare const WORKSPACE_FILE_HOW_TO = "Apps cannot open a file in your own Sprigr storage by key. Mint a short-lived download link for it (the files tool's generate_url; a few minutes is enough) and pass that link as file_url: the app fetches it itself, so the link never leaves Sprigr.";
323
+ /**
324
+ * The sources a file argument accepts, written for the agent. Put it in a
325
+ * tool's input description so the agent knows what to pass before it calls;
326
+ * the not_one and not_sprigr_host refusals repeat it.
327
+ */
328
+ declare const FILE_REF_SOURCES_HELP = "Pass exactly one of: file_key, a key this app's own tools returned; or file_url, an https Sprigr file link (on files.sprigr.com), either the download_url another app's tool returned or, for a file in your own Sprigr storage, a short-lived link from the files tool's generate_url.";
329
+ type FileRefErrorCode = 'not_one' | 'bad_url' | 'not_sprigr_host' | 'unsigned_link' | 'other_company' | 'workspace_key' | 'other_install' | 'malformed_key' | 'internal_key' | 'no_file_store' | 'not_found' | 'too_large' | 'timeout' | 'read_failed';
330
+ /** A refusal or read failure, with a message written for the agent that made the call. */
331
+ declare class FileRefError extends Error {
332
+ readonly code: FileRefErrorCode;
333
+ constructor(code: FileRefErrorCode, message: string);
334
+ }
335
+ interface FileRef {
336
+ file_key?: unknown;
337
+ file_url?: unknown;
338
+ }
339
+ interface ResolveFileRefOptions {
340
+ /** env.INSTALL_ID: refuses an absolute key or link for another install. */
341
+ installId?: string | null;
342
+ /** env.COMPANY_ID: a company link must be in this company; also spots workspace keys. */
343
+ companyId?: string | null;
344
+ /** App-relative prefixes of the app's own working storage, never a tool's output. */
345
+ internalPrefixes?: readonly string[];
346
+ /** Field name used in messages (default `file`), e.g. `image` or `images[1]`. */
347
+ label?: string;
348
+ }
349
+ type ResolvedFileRef = {
350
+ kind: 'stored';
351
+ appKey: string;
352
+ filename: string;
353
+ } | {
354
+ kind: 'url';
355
+ url: string;
356
+ filename: string;
357
+ };
358
+ /**
359
+ * Check a `{ file_key } | { file_url }` reference and say what to read.
360
+ * Pure: no request is made. Throws FileRefError.
361
+ */
362
+ declare function resolveFileRef(ref: FileRef | null | undefined, opts?: ResolveFileRefOptions): ResolvedFileRef;
363
+ interface FileRefEnv extends Partial<AppFilesEnv> {
364
+ INSTALL_ID?: string;
365
+ COMPANY_ID?: string;
366
+ /** Present on dispatched handlers: env.SPRIGR.files.url mints a read link. */
367
+ SPRIGR?: unknown;
368
+ }
369
+ interface ReadFileRefOptions extends Omit<ResolveFileRefOptions, 'installId' | 'companyId'> {
370
+ /** Byte ceiling for one file (required: pick the downstream API's limit). */
371
+ maxBytes: number;
372
+ /**
373
+ * Time for the whole read, headers and body, default
374
+ * {@link DEFAULT_FILE_READ_TIMEOUT_MS}. A streaming caller sending a large
375
+ * file onward should raise it to cover the upload too.
376
+ */
377
+ timeoutMs?: number;
378
+ }
379
+ /** How the bytes were read: a minted link to a stored key, the install-token fallback, or the caller's link. */
380
+ type FileRefVia = 'bridge' | 'install_token' | 'url';
381
+ interface FileRefContent {
382
+ bytes: Uint8Array;
383
+ size: number;
384
+ contentType: string;
385
+ filename: string;
386
+ via: FileRefVia;
387
+ }
388
+ interface FileRefStream {
389
+ /**
390
+ * The file's bytes. Errors with a FileRefError (`too_large`, `timeout`,
391
+ * `read_failed`) if the cap or the time runs out mid-read; cancel it if you
392
+ * stop early.
393
+ */
394
+ body: ReadableStream<Uint8Array>;
395
+ /** The declared length when the source sent one, else null. */
396
+ size: number | null;
397
+ contentType: string;
398
+ filename: string;
399
+ via: FileRefVia;
400
+ }
401
+ /**
402
+ * Resolve a reference (with the env's install and company) and open it as a
403
+ * stream, so a large attachment is passed on without being buffered first.
404
+ * Every fetch is time-capped, size-capped (on the declared length up front and
405
+ * on the bytes as they arrive), and refuses redirects, so a Sprigr link cannot
406
+ * bounce the read to another host. The install-token fallback returns its
407
+ * bytes in one chunk (that transport is base64 JSON). Throws FileRefError.
408
+ */
409
+ declare function openFileRef(env: FileRefEnv, ref: FileRef | null | undefined, opts: ReadFileRefOptions): Promise<FileRefStream>;
410
+ /**
411
+ * {@link openFileRef}, read into memory. For files an app sends on in one
412
+ * request body; use openFileRef to pass a large file on as a stream.
413
+ * Throws FileRefError.
414
+ */
415
+ declare function readFileRef(env: FileRefEnv, ref: FileRef | null | undefined, opts: ReadFileRefOptions): Promise<FileRefContent>;
416
+
167
417
  /**
168
418
  * Per-env resolver for the platform webhook host that marketplace
169
419
  * apps use as the callback URL when registering provider webhooks.
@@ -1292,124 +1542,6 @@ declare function parseActorRetiredArgs(args: unknown): ActorRetired;
1292
1542
  */
1293
1543
  declare function actorRetiredHook<Env>(fn: (env: Env, retired: ActorRetired) => Promise<ActorRetiredResult>): (args: unknown, env: Env) => Promise<ActorRetiredResult>;
1294
1544
 
1295
- /**
1296
- * App-scoped R2 file storage (platform feature, 2026-06).
1297
- *
1298
- * Marketplace apps get a contained file store: every key is confined
1299
- * server-side to `_apps/{installId}/...` so an app can only ever touch
1300
- * its own files. The app never holds a raw R2 binding — these helpers
1301
- * call the platform over the same per-install token channel as
1302
- * `env.SPRIGR.emit` (provisioning `/internal/wfp/file/*`).
1303
- *
1304
- * Auth: `Authorization: Bearer <SPRIGR_INSTALL_TOKEN>` against
1305
- * `<SPRIGR_PLATFORM_BASE>/internal/wfp/file/<op>`. Both bindings are
1306
- * stamped onto every per-install WFP upload.
1307
- *
1308
- * Keys are app-relative (`simpro-files/123.jpg`); the platform adds and
1309
- * strips the `_apps/{installId}/` prefix, so the app stays unaware of it
1310
- * and physically cannot escape its namespace.
1311
- */
1312
- interface AppFilesEnv {
1313
- SPRIGR_INSTALL_TOKEN?: string;
1314
- SPRIGR_PLATFORM_BASE?: string;
1315
- }
1316
- interface PutAppFileArgs {
1317
- /** App-relative key, e.g. "simpro-files/job-1234/55.jpg". */
1318
- key: string;
1319
- /** File bytes, base64-encoded. */
1320
- base64: string;
1321
- /** MIME type stored as the object's content-type. */
1322
- contentType?: string;
1323
- /** Optional display filename. */
1324
- filename?: string;
1325
- }
1326
- interface PutAppFileResult {
1327
- ok: true;
1328
- key: string;
1329
- bytes: number;
1330
- contentType: string;
1331
- }
1332
- interface PutAppFileStreamArgs {
1333
- /** App-relative key, e.g. "simpro-files/job-1234/55.jpg". */
1334
- key: string;
1335
- /**
1336
- * Raw file bytes. A `ReadableStream` is piped straight through to R2 so
1337
- * the bytes never fully materialise in the app isolate — use it for large
1338
- * blobs (full-res photos, PDFs). An `ArrayBuffer`/`Uint8Array` is also
1339
- * accepted when the bytes are already in hand.
1340
- */
1341
- body: ReadableStream<Uint8Array> | ArrayBuffer | Uint8Array;
1342
- /** MIME type stored as the object's content-type. */
1343
- contentType?: string;
1344
- /** Optional display filename. */
1345
- filename?: string;
1346
- /**
1347
- * Declared byte length. Forward it when known (e.g. the source's
1348
- * Content-Length) so the platform can enforce its size cap before opening
1349
- * the R2 write. Streamed bodies carry no Content-Length, so this is the
1350
- * only length signal the platform gets. Omit for unknown-length streams.
1351
- */
1352
- contentLength?: number;
1353
- }
1354
- interface AppFileUrlArgs {
1355
- key: string;
1356
- /** Seconds the signed URL stays valid (default 24h, min 60s, max ~10y, clamped server-side). */
1357
- expiresIn?: number;
1358
- }
1359
- interface AppFileUrlResult {
1360
- ok: true;
1361
- url: string;
1362
- expires_at: number;
1363
- key: string;
1364
- }
1365
- interface GetAppFileResult {
1366
- ok: true;
1367
- key: string;
1368
- base64: string;
1369
- contentType: string;
1370
- filename?: string;
1371
- bytes: number;
1372
- }
1373
- interface AppFileListItem {
1374
- key: string;
1375
- bytes: number;
1376
- uploaded?: string;
1377
- filename?: string;
1378
- contentType?: string;
1379
- }
1380
- /** Store bytes in the app's contained R2 namespace (base64 JSON). */
1381
- declare function putAppFile(env: AppFilesEnv, args: PutAppFileArgs): Promise<PutAppFileResult>;
1382
- /**
1383
- * Store bytes by streaming the raw body straight into the app's contained
1384
- * R2 namespace. Unlike `putAppFile`, this never base64-encodes the bytes
1385
- * and (with a `ReadableStream` body) never buffers them whole in the
1386
- * isolate — pipe a source `Response.body` directly through for multi-MB
1387
- * blobs. Metadata travels in headers; the body is opaque bytes.
1388
- *
1389
- * The platform's put-stream route cannot retry a transient R2 fault itself
1390
- * (its body is spent once read), so a resendable body — `ArrayBuffer` /
1391
- * `Uint8Array` already in hand, not a `ReadableStream` — gets a bounded
1392
- * retry here instead. The key is deterministic, so a retry is a full
1393
- * overwrite, exactly the argument the platform's buffered `put` route makes
1394
- * for its own retry (wfp-files.ts, sprigr-team).
1395
- */
1396
- declare function putAppFileStream(env: AppFilesEnv, args: PutAppFileStreamArgs): Promise<PutAppFileResult>;
1397
- /** Mint a signed, time-limited download URL for an app file. */
1398
- declare function appFileUrl(env: AppFilesEnv, args: AppFileUrlArgs): Promise<AppFileUrlResult>;
1399
- /** Read an app file back as base64. */
1400
- declare function getAppFile(env: AppFilesEnv, key: string): Promise<GetAppFileResult>;
1401
- /** List app files under an optional app-relative prefix. */
1402
- declare function listAppFiles(env: AppFilesEnv, prefix?: string): Promise<{
1403
- ok: true;
1404
- files: AppFileListItem[];
1405
- truncated: boolean;
1406
- }>;
1407
- /** Delete an app file. */
1408
- declare function deleteAppFile(env: AppFilesEnv, key: string): Promise<{
1409
- ok: true;
1410
- key: string;
1411
- }>;
1412
-
1413
1545
  /**
1414
1546
  * Write-protection types, mirrored from the Sprigr platform.
1415
1547
  *
@@ -3207,4 +3339,4 @@ interface ExecutionContextLike {
3207
3339
  }
3208
3340
  type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
3209
3341
 
3210
- export { ACTOR_RETIRED_HOOK_TOOL, ACTOR_RETIRE_REASONS, APPROVAL_GRANTED_KEY, APPROVAL_PREVIEW_MAX, APP_FILES_ROOT, APP_OWNER_SEGMENT, type Actor, type ActorRetireReason, type ActorRetired, type ActorRetiredHookBody, type ActorRetiredResult, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppToolEffectsDeclaration, type AppUndoEnvelope, type ApplyPoliciesOptions, type ApprovalCoverage, type ApprovalGateOptions, type ApprovalSpec, type ArchiveOfferOptions, type ArchiveOfferRefusal, type CallerKeyResolution, type CaptureJournal, type ConfirmCondition, type ConfirmRule, type ConfirmationPolicy, type ConnectCompleteRequest, type ConnectCompleteResult, type D1Like, type D1PreparedStatementLike, DATA_PARTIAL_UPDATE_PATH, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_LOG_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, INBOX_SEND_CONTRACT_V1, type InboxAddress, type InboxAppendRequest, type InboxAppendResult, type InboxChannelDeclaration, type InboxFoldersRequest, type InboxFoldersResult, type InboxOwner, type InboxSendConnection, type InboxSendHandlerImpl, InboxSendRefusal, type InboxSendSupports, type InboxSendV1Args, type InboxSendV1Result, type InstallBridge, InvalidUndoEnvelopeError, type JournalRowLike, type LogToPlatformOptions, type ManifestDispatchBlock, type ManifestLike, type ManifestToolLike, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type OfferUndoArgs, type PartialUpdateDataOptions, type PlatformHostEnv, type PolicyCheckInput, type PolicySource, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type RequireApprovalOptions, type ResolveViewerOptions, type RestoreResult, type RestoreSpec, SPRIGR_DATA_MAX_OBJECTS_PER_CALL, SPRIGR_LOG_LEVELS, SPRIGR_LOG_LIMITS, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataImportOpts, type SprigrDataIndexOpts, type SprigrDataPartialUpdateBody, type SprigrDataPartialUpdateOpts, type SprigrDataPartialUpdateResult, type SprigrDataPatch, type SprigrDataSearchOpts, type SprigrDataSearchResult, SprigrDataValidationError, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type SprigrFilesListItem, type SprigrFilesListResult, type SprigrInboxApi, type SprigrLogEntry, type SprigrLogFn, type SprigrLogLevel, type SprigrLogResult, SprigrLogValidationError, type SyncedAttachment, type SyncedMessage, 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, actorRetiredHook, actorTool, agentFileKeyFromUrl, appFileUrl, appKeyFromCallerKey, applyConfirmationPolicies, approvalHash, approvalPreview, approvalRecipients, archiveOfferRefusal, attendedFindings, base64ToBytes, buildConfirmationPolicy, buildMarketplaceWebhookUrl, buildPartialUpdateBody, bytesToBase64, callerKeyRefusal, canEmit, canLog, canPartialUpdate, checkConfirmationPolicy, commonDestructiveVerbs, connectComplete, connectDisconnect, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, defineInboxSendHandler, deleteAppFile, deriveConfirmationPolicy, describeMissingBridge, dispatcherApproval, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, fullWarning, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, logToPlatform, manifestIsFresh, offerUndo, overlaySprigr, ownerFromActorKey, parseActor, parseActorRetiredArgs, partialUpdateData, putAppFile, putAppFileStream, randomHex, readableStoredKey, recreatedWarning, refuseWithoutForce, requireApproval, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, resolveViewerContext, resolveViewerUserId, runUndoApply, safeCapture, seq, serializeManifest, set, tool, undoEnvelope, validateLogEntries, validateLogEntry, verifyViewerToken, withSprigrEmitFallback, withSprigrInboxFallback, withSprigrLogFallback };
3342
+ export { ACTOR_RETIRED_HOOK_TOOL, ACTOR_RETIRE_REASONS, APPROVAL_GRANTED_KEY, APPROVAL_PREVIEW_MAX, APP_FILES_ROOT, APP_OWNER_SEGMENT, type Actor, type ActorRetireReason, type ActorRetired, type ActorRetiredHookBody, type ActorRetiredResult, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppToolEffectsDeclaration, type AppUndoEnvelope, type ApplyPoliciesOptions, type ApprovalCoverage, type ApprovalGateOptions, type ApprovalSpec, type ArchiveOfferOptions, type ArchiveOfferRefusal, type CallerKeyResolution, type CaptureJournal, type ConfirmCondition, type ConfirmRule, type ConfirmationPolicy, type ConnectCompleteRequest, type ConnectCompleteResult, type D1Like, type D1PreparedStatementLike, DATA_PARTIAL_UPDATE_PATH, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_FILE_READ_TIMEOUT_MS, DEFAULT_LOG_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, FILE_REF_SOURCES_HELP, type FetchFileResult, type FetchWithRetryOptions, type FileRef, type FileRefContent, type FileRefEnv, FileRefError, type FileRefErrorCode, type FileRefStream, type FileRefVia, type GetAppFileResult, type HandlerFn, INBOX_SEND_CONTRACT_V1, type InboxAddress, type InboxAppendRequest, type InboxAppendResult, type InboxChannelDeclaration, type InboxFoldersRequest, type InboxFoldersResult, type InboxOwner, type InboxSendConnection, type InboxSendHandlerImpl, InboxSendRefusal, type InboxSendSupports, type InboxSendV1Args, type InboxSendV1Result, type InstallBridge, InvalidUndoEnvelopeError, type JournalRowLike, type LogToPlatformOptions, type ManifestDispatchBlock, type ManifestLike, type ManifestToolLike, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type OfferUndoArgs, type PartialUpdateDataOptions, type PlatformHostEnv, type PolicyCheckInput, type PolicySource, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type ReadFileRefOptions, type RequireApprovalOptions, type ResolveFileRefOptions, type ResolveViewerOptions, type ResolvedFileRef, type RestoreResult, type RestoreSpec, SPRIGR_DATA_MAX_OBJECTS_PER_CALL, SPRIGR_FILE_HOSTS, SPRIGR_LOG_LEVELS, SPRIGR_LOG_LIMITS, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataImportOpts, type SprigrDataIndexOpts, type SprigrDataPartialUpdateBody, type SprigrDataPartialUpdateOpts, type SprigrDataPartialUpdateResult, type SprigrDataPatch, type SprigrDataSearchOpts, type SprigrDataSearchResult, SprigrDataValidationError, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type SprigrFilesListItem, type SprigrFilesListResult, type SprigrInboxApi, type SprigrLogEntry, type SprigrLogFn, type SprigrLogLevel, type SprigrLogResult, SprigrLogValidationError, type SyncedAttachment, type SyncedMessage, 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, WORKSPACE_FILE_HOW_TO, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorRetiredHook, actorTool, agentFileKeyFromUrl, appFileUrl, appKeyFromCallerKey, applyConfirmationPolicies, approvalHash, approvalPreview, approvalRecipients, archiveOfferRefusal, attendedFindings, base64ToBytes, buildConfirmationPolicy, buildMarketplaceWebhookUrl, buildPartialUpdateBody, bytesToBase64, callerKeyRefusal, canEmit, canLog, canPartialUpdate, checkConfirmationPolicy, commonDestructiveVerbs, connectComplete, connectDisconnect, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, defineInboxSendHandler, deleteAppFile, deriveConfirmationPolicy, describeMissingBridge, dispatcherApproval, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, fullWarning, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, logToPlatform, manifestIsFresh, offerUndo, openFileRef, overlaySprigr, ownerFromActorKey, parseActor, parseActorRetiredArgs, partialUpdateData, putAppFile, putAppFileStream, randomHex, readFileRef, readableStoredKey, recreatedWarning, refuseWithoutForce, requireApproval, resolveFileRef, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, resolveViewerContext, resolveViewerUserId, runUndoApply, safeCapture, seq, serializeManifest, set, tool, undoEnvelope, validateLogEntries, validateLogEntry, verifyViewerToken, withSprigrEmitFallback, withSprigrInboxFallback, withSprigrLogFallback };
package/dist/index.js CHANGED
@@ -155,6 +155,409 @@ async function fetchFileAsBase64(url, opts = {}) {
155
155
  return { base64: bytesToBase64(bytes), contentType, size };
156
156
  }
157
157
 
158
+ // src/app-files.ts
159
+ var DEFAULT_PLATFORM_BASE = "https://webhooks.sprigr.com";
160
+ function platformBase(env) {
161
+ return (env.SPRIGR_PLATFORM_BASE ?? DEFAULT_PLATFORM_BASE).trim().replace(/\/+$/, "");
162
+ }
163
+ var AppFilesError = class extends Error {
164
+ status;
165
+ constructor(message, status) {
166
+ super(message);
167
+ this.name = "AppFilesError";
168
+ this.status = status;
169
+ }
170
+ };
171
+ function requireToken(env) {
172
+ const token = env.SPRIGR_INSTALL_TOKEN;
173
+ if (!token) {
174
+ throw new AppFilesError("app file storage unavailable: SPRIGR_INSTALL_TOKEN unset", 500);
175
+ }
176
+ return token;
177
+ }
178
+ async function parseResult(res, op) {
179
+ const text = await res.text();
180
+ let parsed = void 0;
181
+ try {
182
+ parsed = text ? JSON.parse(text) : void 0;
183
+ } catch {
184
+ }
185
+ if (!res.ok) {
186
+ const detail = parsed && typeof parsed === "object" ? parsed.hint ?? parsed.detail ?? parsed.error : text.slice(0, 200);
187
+ throw new AppFilesError(`app file ${op} failed (${res.status}): ${String(detail)}`, res.status);
188
+ }
189
+ return parsed;
190
+ }
191
+ async function call(env, op, body) {
192
+ const token = requireToken(env);
193
+ const res = await fetch(`${platformBase(env)}/internal/wfp/file/${op}`, {
194
+ method: "POST",
195
+ headers: {
196
+ authorization: `Bearer ${token}`,
197
+ "content-type": "application/json"
198
+ },
199
+ body: JSON.stringify(body ?? {})
200
+ });
201
+ return parseResult(res, op);
202
+ }
203
+ function putAppFile(env, args) {
204
+ return call(env, "put", {
205
+ key: args.key,
206
+ base64: args.base64,
207
+ contentType: args.contentType,
208
+ filename: args.filename
209
+ });
210
+ }
211
+ var STREAM_PUT_MAX_ATTEMPTS = 3;
212
+ var STREAM_PUT_BASE_DELAY_MS = 500;
213
+ function isTransientPutStreamFailure(status, message) {
214
+ if (status === 503) return true;
215
+ if (status !== 502) return false;
216
+ const msg = message.toLowerCase();
217
+ return msg.includes("(10001)") || msg.includes("(10043)");
218
+ }
219
+ function sleep2(ms) {
220
+ return new Promise((resolve) => setTimeout(resolve, ms));
221
+ }
222
+ async function putAppFileStream(env, args) {
223
+ const token = requireToken(env);
224
+ const headers = {
225
+ authorization: `Bearer ${token}`,
226
+ "content-type": args.contentType || "application/octet-stream",
227
+ "x-app-file-key": args.key,
228
+ "x-app-file-content-type": args.contentType || "application/octet-stream"
229
+ };
230
+ if (args.filename) headers["x-app-file-name"] = encodeURIComponent(args.filename);
231
+ if (typeof args.contentLength === "number" && Number.isFinite(args.contentLength)) {
232
+ headers["x-app-file-length"] = String(args.contentLength);
233
+ }
234
+ const resendable = !(args.body instanceof ReadableStream);
235
+ const attempts = resendable ? STREAM_PUT_MAX_ATTEMPTS : 1;
236
+ let lastErr;
237
+ for (let attempt = 1; attempt <= attempts; attempt++) {
238
+ const res = await fetch(`${platformBase(env)}/internal/wfp/file/put-stream`, {
239
+ method: "POST",
240
+ headers,
241
+ body: args.body,
242
+ // Required by the Workers runtime when sending a streaming request body.
243
+ ...args.body instanceof ReadableStream ? { duplex: "half" } : {}
244
+ });
245
+ try {
246
+ return await parseResult(res, "put-stream");
247
+ } catch (err) {
248
+ lastErr = err;
249
+ if (!resendable || attempt === attempts || !(err instanceof AppFilesError) || !isTransientPutStreamFailure(err.status, err.message)) {
250
+ throw err;
251
+ }
252
+ await sleep2(STREAM_PUT_BASE_DELAY_MS * 2 ** (attempt - 1));
253
+ }
254
+ }
255
+ throw lastErr;
256
+ }
257
+ function appFileUrl(env, args) {
258
+ return call(env, "url", { key: args.key, expiresIn: args.expiresIn });
259
+ }
260
+ function getAppFile(env, key) {
261
+ return call(env, "get", { key });
262
+ }
263
+ function listAppFiles(env, prefix) {
264
+ return call(env, "list", { prefix });
265
+ }
266
+ function deleteAppFile(env, key) {
267
+ return call(env, "delete", { key });
268
+ }
269
+
270
+ // src/stored-file-keys.ts
271
+ var APP_FILES_ROOT = "_apps/";
272
+ var APP_OWNER_SEGMENT = "~u";
273
+ function isSegment(s) {
274
+ return typeof s === "string" && s.length > 0 && !s.includes("/");
275
+ }
276
+ function trimLeadingSlashes(key) {
277
+ return key.replace(/^\/+/, "");
278
+ }
279
+ function parseAbsolute(key) {
280
+ if (!key.startsWith(APP_FILES_ROOT)) return null;
281
+ const parts = key.slice(APP_FILES_ROOT.length).split("/");
282
+ const installId = parts[0];
283
+ if (!isSegment(installId)) return null;
284
+ let i = 1;
285
+ let ownerHash = null;
286
+ if (parts[1] === APP_OWNER_SEGMENT) {
287
+ if (!isSegment(parts[2])) return null;
288
+ ownerHash = parts[2];
289
+ i = 3;
290
+ }
291
+ const rest = parts.slice(i).join("/");
292
+ if (!rest || rest.split("/").some((seg) => seg === "")) return null;
293
+ return { installId, ownerHash, rest };
294
+ }
295
+ function urlPathKey(url) {
296
+ try {
297
+ return trimLeadingSlashes(decodeURIComponent(new URL(url).pathname));
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ function agentFileKeyFromUrl(downloadUrl, appKey) {
303
+ const rel = trimLeadingSlashes(appKey);
304
+ if (!rel) return null;
305
+ const path = urlPathKey(downloadUrl);
306
+ if (!path) return null;
307
+ const parsed = parseAbsolute(path);
308
+ if (!parsed || parsed.rest !== rel) return null;
309
+ return path;
310
+ }
311
+ function readableStoredKey(appKey, downloadUrl) {
312
+ return agentFileKeyFromUrl(downloadUrl, appKey) ?? appKey;
313
+ }
314
+ function appKeyFromCallerKey(key, ownInstallId) {
315
+ let candidate = key.trim();
316
+ let wasAbsolute = false;
317
+ if (/^https?:\/\//i.test(candidate)) {
318
+ const path = urlPathKey(candidate);
319
+ if (!path || !path.startsWith(APP_FILES_ROOT)) return { ok: false, reason: "malformed" };
320
+ candidate = path;
321
+ }
322
+ candidate = trimLeadingSlashes(candidate);
323
+ if (candidate.startsWith(APP_FILES_ROOT)) {
324
+ const parsed = parseAbsolute(candidate);
325
+ if (!parsed) return { ok: false, reason: "malformed" };
326
+ if (ownInstallId && parsed.installId !== ownInstallId) {
327
+ return { ok: false, reason: "other_install", installId: parsed.installId };
328
+ }
329
+ return { ok: true, appKey: parsed.rest, wasAbsolute: true };
330
+ }
331
+ const parts = candidate.split("/");
332
+ if (parts[0] === APP_OWNER_SEGMENT) {
333
+ const rest = parts.slice(2).join("/");
334
+ if (!isSegment(parts[1]) || !rest) return { ok: false, reason: "malformed" };
335
+ return { ok: true, appKey: rest, wasAbsolute: true };
336
+ }
337
+ return { ok: true, appKey: candidate, wasAbsolute };
338
+ }
339
+ function callerKeyRefusal(key, r) {
340
+ if (r.reason === "other_install") {
341
+ return `"${key}" is a stored file of a different app install (${r.installId}), not this one. Pass a file_key this app returned to you.`;
342
+ }
343
+ return `"${key}" is not a stored file key: it has no file path after the app storage prefix.`;
344
+ }
345
+
346
+ // src/file-ref.ts
347
+ var SPRIGR_FILE_HOSTS = /* @__PURE__ */ new Set(["files.sprigr.com", "staging-files.sprigr.com"]);
348
+ var DEFAULT_FILE_READ_TIMEOUT_MS = 2e4;
349
+ var STORED_READ_URL_TTL_SECONDS = 300;
350
+ var WORKSPACE_FILE_HOW_TO = "Apps cannot open a file in your own Sprigr storage by key. Mint a short-lived download link for it (the files tool's generate_url; a few minutes is enough) and pass that link as file_url: the app fetches it itself, so the link never leaves Sprigr.";
351
+ var FILE_REF_SOURCES_HELP = "Pass exactly one of: file_key, a key this app's own tools returned; or file_url, an https Sprigr file link (on files.sprigr.com), either the download_url another app's tool returned or, for a file in your own Sprigr storage, a short-lived link from the files tool's generate_url.";
352
+ var FileRefError = class extends Error {
353
+ code;
354
+ constructor(code, message) {
355
+ super(message);
356
+ this.name = "FileRefError";
357
+ this.code = code;
358
+ }
359
+ };
360
+ function str(v) {
361
+ return typeof v === "string" && v.trim() ? v.trim() : null;
362
+ }
363
+ function lastSegment(path, fallback) {
364
+ const tail = path.split("/").filter(Boolean).pop();
365
+ if (!tail) return fallback;
366
+ try {
367
+ return decodeURIComponent(tail);
368
+ } catch {
369
+ return tail;
370
+ }
371
+ }
372
+ function isWorkspaceKey(key, companyId) {
373
+ const k = key.replace(/^\/+/, "");
374
+ if (k.startsWith(APP_FILES_ROOT)) return false;
375
+ if (companyId && k.startsWith(`${companyId}/`)) return true;
376
+ return /^(?:comp_|agt_)[^/]+\//.test(k);
377
+ }
378
+ function stored(appKey, original, opts) {
379
+ if ((opts.internalPrefixes ?? []).some((p) => appKey.startsWith(p))) {
380
+ throw new FileRefError("internal_key", `"${original}" is this app's internal working storage, not a file a tool returned. Pass a file_key a tool gave you.`);
381
+ }
382
+ return { kind: "stored", appKey, filename: lastSegment(appKey, "file") };
383
+ }
384
+ function resolveFileRef(ref, opts = {}) {
385
+ const label = opts.label ?? "file";
386
+ const key = str(ref?.file_key);
387
+ const url = str(ref?.file_url);
388
+ if (key === null === (url === null)) {
389
+ throw new FileRefError("not_one", `${label} takes exactly one of file_key or file_url. ${FILE_REF_SOURCES_HELP}`);
390
+ }
391
+ if (url !== null) {
392
+ let u;
393
+ let path;
394
+ try {
395
+ u = new URL(url);
396
+ path = decodeURIComponent(u.pathname).replace(/^\/+/, "");
397
+ } catch {
398
+ throw new FileRefError("bad_url", `${label}.file_url is not a valid URL.`);
399
+ }
400
+ if (u.protocol !== "https:" || !SPRIGR_FILE_HOSTS.has(u.hostname.toLowerCase())) {
401
+ throw new FileRefError(
402
+ "not_sprigr_host",
403
+ `${label}.file_url is not a Sprigr file link, and other web addresses are not fetched. ${FILE_REF_SOURCES_HELP}`
404
+ );
405
+ }
406
+ if (path.startsWith(APP_FILES_ROOT)) {
407
+ const own = appKeyFromCallerKey(url, opts.installId);
408
+ if (own.ok) return stored(own.appKey, url, opts);
409
+ return { kind: "url", url, filename: lastSegment(path, "file") };
410
+ }
411
+ if (!opts.companyId || !path.startsWith(`${opts.companyId}/`)) {
412
+ throw new FileRefError("other_company", `${label}.file_url is not a file in this workspace. ${WORKSPACE_FILE_HOW_TO}`);
413
+ }
414
+ if (!u.searchParams.get("token") || !u.searchParams.get("expires")) {
415
+ throw new FileRefError("unsigned_link", `${label}.file_url must be a signed download link (with its token). ${WORKSPACE_FILE_HOW_TO}`);
416
+ }
417
+ return { kind: "url", url, filename: lastSegment(path, "file") };
418
+ }
419
+ if (isWorkspaceKey(key, opts.companyId)) {
420
+ throw new FileRefError("workspace_key", `"${key}" is a file in your own Sprigr storage. ${WORKSPACE_FILE_HOW_TO}`);
421
+ }
422
+ const resolved = appKeyFromCallerKey(key, opts.installId);
423
+ if (!resolved.ok) {
424
+ if (resolved.reason === "other_install") {
425
+ throw new FileRefError(
426
+ "other_install",
427
+ `"${key}" is a file of another app. Pass that app's download_url for it as file_url instead; a file_key only opens inside the app that stored it.`
428
+ );
429
+ }
430
+ throw new FileRefError("malformed_key", callerKeyRefusal(key, resolved));
431
+ }
432
+ return stored(resolved.appKey, key, opts);
433
+ }
434
+ var round1 = (n) => Math.round(n * 10) / 10;
435
+ var human = (n) => n >= 1024 * 1024 ? `${round1(n / (1024 * 1024))} MB` : n >= 1024 ? `${round1(n / 1024)} KB` : `${n} bytes`;
436
+ async function openFileRef(env, ref, opts) {
437
+ const label = opts.label ?? "file";
438
+ const resolved = resolveFileRef(ref, { ...opts, installId: env.INSTALL_ID, companyId: env.COMPANY_ID });
439
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_FILE_READ_TIMEOUT_MS;
440
+ const tooLarge = (size) => new FileRefError(
441
+ "too_large",
442
+ size === null ? `${label} is over the ${human(opts.maxBytes)} limit, so it was not read.` : `${label} is ${human(size)}, over the ${human(opts.maxBytes)} limit, so it was not read.`
443
+ );
444
+ const failure = (err) => {
445
+ if (err instanceof FileRefError) return err;
446
+ const name = err instanceof Error ? err.name : "";
447
+ const message = err instanceof Error ? err.message : String(err);
448
+ if (name === "TimeoutError" || name === "AbortError") {
449
+ return new FileRefError("timeout", `Reading ${label} took longer than ${Math.round(timeoutMs / 1e3)}s, so it was not read.`);
450
+ }
451
+ return new FileRefError("read_failed", `Could not read ${label}: ${message}`);
452
+ };
453
+ const openUrl = async (url, via, filename) => {
454
+ let res;
455
+ try {
456
+ res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "error" });
457
+ } catch (err) {
458
+ throw failure(err);
459
+ }
460
+ if (!res.ok) {
461
+ await res.body?.cancel().catch(() => {
462
+ });
463
+ if (res.status === 404) throw new FileRefError("not_found", `There is no file at ${label} (the link or key may have expired or been deleted).`);
464
+ throw new FileRefError("read_failed", `Could not read ${label}: the file host answered HTTP ${res.status}.`);
465
+ }
466
+ const declared = Number(res.headers.get("content-length") || "");
467
+ const size = Number.isFinite(declared) && declared >= 0 && res.headers.has("content-length") ? declared : null;
468
+ if (size !== null && size > opts.maxBytes) {
469
+ await res.body?.cancel().catch(() => {
470
+ });
471
+ throw tooLarge(size);
472
+ }
473
+ const contentType = res.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
474
+ const reader = (res.body ?? new ReadableStream({ start: (c) => c.close() })).getReader();
475
+ let total = 0;
476
+ const body = new ReadableStream({
477
+ async pull(ctrl) {
478
+ let chunk;
479
+ try {
480
+ chunk = await reader.read();
481
+ } catch (err) {
482
+ ctrl.error(failure(err));
483
+ return;
484
+ }
485
+ if (chunk.done) {
486
+ ctrl.close();
487
+ return;
488
+ }
489
+ total += chunk.value.byteLength;
490
+ if (total > opts.maxBytes) {
491
+ await reader.cancel().catch(() => {
492
+ });
493
+ ctrl.error(tooLarge(null));
494
+ return;
495
+ }
496
+ ctrl.enqueue(chunk.value);
497
+ },
498
+ cancel(reason) {
499
+ return reader.cancel(reason);
500
+ }
501
+ });
502
+ return { body, size, contentType, filename, via };
503
+ };
504
+ if (resolved.kind === "url") return openUrl(resolved.url, "url", resolved.filename);
505
+ const files = env.SPRIGR?.files;
506
+ if (typeof files?.url === "function") {
507
+ const minted = await files.url(resolved.appKey, { expiresIn: STORED_READ_URL_TTL_SECONDS });
508
+ if (!minted?.url) throw new FileRefError("not_found", `This app's file store has no file at "${resolved.appKey}".`);
509
+ return openUrl(minted.url, "bridge", resolved.filename);
510
+ }
511
+ if (env.SPRIGR_INSTALL_TOKEN && env.SPRIGR_PLATFORM_BASE) {
512
+ let got;
513
+ try {
514
+ got = await getAppFile(env, resolved.appKey);
515
+ } catch (err) {
516
+ throw failure(err);
517
+ }
518
+ if (got.bytes > opts.maxBytes) throw tooLarge(got.bytes);
519
+ const bytes = base64ToBytes(got.base64 ?? "");
520
+ if (bytes.byteLength > opts.maxBytes) throw tooLarge(bytes.byteLength);
521
+ return {
522
+ body: new ReadableStream({
523
+ start(ctrl) {
524
+ ctrl.enqueue(bytes);
525
+ ctrl.close();
526
+ }
527
+ }),
528
+ size: bytes.byteLength,
529
+ contentType: got.contentType || "application/octet-stream",
530
+ filename: got.filename || resolved.filename,
531
+ via: "install_token"
532
+ };
533
+ }
534
+ throw new FileRefError(
535
+ "no_file_store",
536
+ `This runtime has no file store to read ${label}.file_key from (no env.SPRIGR.files and no install token). Pass a Sprigr file link as file_url instead.`
537
+ );
538
+ }
539
+ async function readFileRef(env, ref, opts) {
540
+ const opened = await openFileRef(env, ref, opts);
541
+ const chunks = [];
542
+ let size = 0;
543
+ const reader = opened.body.getReader();
544
+ for (; ; ) {
545
+ const { done, value } = await reader.read();
546
+ if (done) break;
547
+ chunks.push(value);
548
+ size += value.byteLength;
549
+ }
550
+ const bytes = chunks.length === 1 ? chunks[0] : new Uint8Array(size);
551
+ if (chunks.length !== 1) {
552
+ let at = 0;
553
+ for (const c of chunks) {
554
+ bytes.set(c, at);
555
+ at += c.byteLength;
556
+ }
557
+ }
558
+ return { bytes, size, contentType: opened.contentType, filename: opened.filename, via: opened.via };
559
+ }
560
+
158
561
  // src/platform-host.ts
159
562
  var FALLBACK_PLATFORM_BASE = "https://webhooks.sprigr.com";
160
563
  function resolvePlatformWebhookBase(env, override) {
@@ -864,14 +1267,14 @@ function parseActorRetiredArgs(args) {
864
1267
  throw new Error("on_actor_retired: refused, the call carries an actor; only the platform hook may call this");
865
1268
  }
866
1269
  if (b.trigger !== "actor_retired") throw new Error(`on_actor_retired: trigger must be actor_retired, got ${String(b.trigger)}`);
867
- const str = (k) => {
1270
+ const str2 = (k) => {
868
1271
  const v = b[k];
869
1272
  if (typeof v !== "string" || v === "") throw new Error(`on_actor_retired: ${k} is required`);
870
1273
  return v;
871
1274
  };
872
- const installId = str("install_id");
873
- const companyId = str("company_id");
874
- const key = str("actor_key");
1275
+ const installId = str2("install_id");
1276
+ const companyId = str2("company_id");
1277
+ const key = str2("actor_key");
875
1278
  const m = KEY_RE.exec(key);
876
1279
  if (!m) throw new Error(`on_actor_retired: actor_key must be u:<id> or a:<id>, got ${key.slice(0, 80)}`);
877
1280
  const actor = m[1] === "u" ? { platformUserId: m[2] } : { agentId: m[2] };
@@ -886,9 +1289,9 @@ function parseActorRetiredArgs(args) {
886
1289
  if (typeof reason !== "string" || !ACTOR_RETIRE_REASONS.includes(reason)) {
887
1290
  throw new Error(`on_actor_retired: unknown reason ${String(reason)}`);
888
1291
  }
889
- const storageOwnerRef = str("storage_owner_ref");
1292
+ const storageOwnerRef = str2("storage_owner_ref");
890
1293
  if (!OWNER_REF_RE.test(storageOwnerRef)) throw new Error("on_actor_retired: storage_owner_ref must be 32 lowercase hex characters");
891
- const requestedAt = Date.parse(str("requested_at"));
1294
+ const requestedAt = Date.parse(str2("requested_at"));
892
1295
  if (!Number.isFinite(requestedAt)) throw new Error("on_actor_retired: requested_at is not a date");
893
1296
  const attempt = typeof b.attempt === "number" && b.attempt >= 1 ? b.attempt : 1;
894
1297
  return {
@@ -915,118 +1318,6 @@ function actorRetiredHook(fn) {
915
1318
  };
916
1319
  }
917
1320
 
918
- // src/app-files.ts
919
- var DEFAULT_PLATFORM_BASE = "https://webhooks.sprigr.com";
920
- function platformBase(env) {
921
- return (env.SPRIGR_PLATFORM_BASE ?? DEFAULT_PLATFORM_BASE).trim().replace(/\/+$/, "");
922
- }
923
- var AppFilesError = class extends Error {
924
- status;
925
- constructor(message, status) {
926
- super(message);
927
- this.name = "AppFilesError";
928
- this.status = status;
929
- }
930
- };
931
- function requireToken(env) {
932
- const token = env.SPRIGR_INSTALL_TOKEN;
933
- if (!token) {
934
- throw new AppFilesError("app file storage unavailable: SPRIGR_INSTALL_TOKEN unset", 500);
935
- }
936
- return token;
937
- }
938
- async function parseResult(res, op) {
939
- const text = await res.text();
940
- let parsed = void 0;
941
- try {
942
- parsed = text ? JSON.parse(text) : void 0;
943
- } catch {
944
- }
945
- if (!res.ok) {
946
- const detail = parsed && typeof parsed === "object" ? parsed.hint ?? parsed.detail ?? parsed.error : text.slice(0, 200);
947
- throw new AppFilesError(`app file ${op} failed (${res.status}): ${String(detail)}`, res.status);
948
- }
949
- return parsed;
950
- }
951
- async function call(env, op, body) {
952
- const token = requireToken(env);
953
- const res = await fetch(`${platformBase(env)}/internal/wfp/file/${op}`, {
954
- method: "POST",
955
- headers: {
956
- authorization: `Bearer ${token}`,
957
- "content-type": "application/json"
958
- },
959
- body: JSON.stringify(body ?? {})
960
- });
961
- return parseResult(res, op);
962
- }
963
- function putAppFile(env, args) {
964
- return call(env, "put", {
965
- key: args.key,
966
- base64: args.base64,
967
- contentType: args.contentType,
968
- filename: args.filename
969
- });
970
- }
971
- var STREAM_PUT_MAX_ATTEMPTS = 3;
972
- var STREAM_PUT_BASE_DELAY_MS = 500;
973
- function isTransientPutStreamFailure(status, message) {
974
- if (status === 503) return true;
975
- if (status !== 502) return false;
976
- const msg = message.toLowerCase();
977
- return msg.includes("(10001)") || msg.includes("(10043)");
978
- }
979
- function sleep2(ms) {
980
- return new Promise((resolve) => setTimeout(resolve, ms));
981
- }
982
- async function putAppFileStream(env, args) {
983
- const token = requireToken(env);
984
- const headers = {
985
- authorization: `Bearer ${token}`,
986
- "content-type": args.contentType || "application/octet-stream",
987
- "x-app-file-key": args.key,
988
- "x-app-file-content-type": args.contentType || "application/octet-stream"
989
- };
990
- if (args.filename) headers["x-app-file-name"] = encodeURIComponent(args.filename);
991
- if (typeof args.contentLength === "number" && Number.isFinite(args.contentLength)) {
992
- headers["x-app-file-length"] = String(args.contentLength);
993
- }
994
- const resendable = !(args.body instanceof ReadableStream);
995
- const attempts = resendable ? STREAM_PUT_MAX_ATTEMPTS : 1;
996
- let lastErr;
997
- for (let attempt = 1; attempt <= attempts; attempt++) {
998
- const res = await fetch(`${platformBase(env)}/internal/wfp/file/put-stream`, {
999
- method: "POST",
1000
- headers,
1001
- body: args.body,
1002
- // Required by the Workers runtime when sending a streaming request body.
1003
- ...args.body instanceof ReadableStream ? { duplex: "half" } : {}
1004
- });
1005
- try {
1006
- return await parseResult(res, "put-stream");
1007
- } catch (err) {
1008
- lastErr = err;
1009
- if (!resendable || attempt === attempts || !(err instanceof AppFilesError) || !isTransientPutStreamFailure(err.status, err.message)) {
1010
- throw err;
1011
- }
1012
- await sleep2(STREAM_PUT_BASE_DELAY_MS * 2 ** (attempt - 1));
1013
- }
1014
- }
1015
- throw lastErr;
1016
- }
1017
- function appFileUrl(env, args) {
1018
- return call(env, "url", { key: args.key, expiresIn: args.expiresIn });
1019
- }
1020
- function getAppFile(env, key) {
1021
- return call(env, "get", { key });
1022
- }
1023
- function listAppFiles(env, prefix) {
1024
- return call(env, "list", { prefix });
1025
- }
1026
- function deleteAppFile(env, key) {
1027
- return call(env, "delete", { key });
1028
- }
1029
-
1030
1321
  // src/write-protection/types.ts
1031
1322
  var APPROVAL_GRANTED_KEY = "_approval_granted";
1032
1323
 
@@ -1750,82 +2041,6 @@ async function connectDisconnect(env, req) {
1750
2041
  if (!bridge) throw new Error("connectDisconnect: SPRIGR_PLATFORM_BASE / SPRIGR_INSTALL_TOKEN are not bound");
1751
2042
  return await installTokenPost(bridge, "/internal/wfp/connect/disconnect", req, { label: "connect disconnect" });
1752
2043
  }
1753
-
1754
- // src/stored-file-keys.ts
1755
- var APP_FILES_ROOT = "_apps/";
1756
- var APP_OWNER_SEGMENT = "~u";
1757
- function isSegment(s) {
1758
- return typeof s === "string" && s.length > 0 && !s.includes("/");
1759
- }
1760
- function trimLeadingSlashes(key) {
1761
- return key.replace(/^\/+/, "");
1762
- }
1763
- function parseAbsolute(key) {
1764
- if (!key.startsWith(APP_FILES_ROOT)) return null;
1765
- const parts = key.slice(APP_FILES_ROOT.length).split("/");
1766
- const installId = parts[0];
1767
- if (!isSegment(installId)) return null;
1768
- let i = 1;
1769
- let ownerHash = null;
1770
- if (parts[1] === APP_OWNER_SEGMENT) {
1771
- if (!isSegment(parts[2])) return null;
1772
- ownerHash = parts[2];
1773
- i = 3;
1774
- }
1775
- const rest = parts.slice(i).join("/");
1776
- if (!rest || rest.split("/").some((seg) => seg === "")) return null;
1777
- return { installId, ownerHash, rest };
1778
- }
1779
- function urlPathKey(url) {
1780
- try {
1781
- return trimLeadingSlashes(decodeURIComponent(new URL(url).pathname));
1782
- } catch {
1783
- return null;
1784
- }
1785
- }
1786
- function agentFileKeyFromUrl(downloadUrl, appKey) {
1787
- const rel = trimLeadingSlashes(appKey);
1788
- if (!rel) return null;
1789
- const path = urlPathKey(downloadUrl);
1790
- if (!path) return null;
1791
- const parsed = parseAbsolute(path);
1792
- if (!parsed || parsed.rest !== rel) return null;
1793
- return path;
1794
- }
1795
- function readableStoredKey(appKey, downloadUrl) {
1796
- return agentFileKeyFromUrl(downloadUrl, appKey) ?? appKey;
1797
- }
1798
- function appKeyFromCallerKey(key, ownInstallId) {
1799
- let candidate = key.trim();
1800
- let wasAbsolute = false;
1801
- if (/^https?:\/\//i.test(candidate)) {
1802
- const path = urlPathKey(candidate);
1803
- if (!path || !path.startsWith(APP_FILES_ROOT)) return { ok: false, reason: "malformed" };
1804
- candidate = path;
1805
- }
1806
- candidate = trimLeadingSlashes(candidate);
1807
- if (candidate.startsWith(APP_FILES_ROOT)) {
1808
- const parsed = parseAbsolute(candidate);
1809
- if (!parsed) return { ok: false, reason: "malformed" };
1810
- if (ownInstallId && parsed.installId !== ownInstallId) {
1811
- return { ok: false, reason: "other_install", installId: parsed.installId };
1812
- }
1813
- return { ok: true, appKey: parsed.rest, wasAbsolute: true };
1814
- }
1815
- const parts = candidate.split("/");
1816
- if (parts[0] === APP_OWNER_SEGMENT) {
1817
- const rest = parts.slice(2).join("/");
1818
- if (!isSegment(parts[1]) || !rest) return { ok: false, reason: "malformed" };
1819
- return { ok: true, appKey: rest, wasAbsolute: true };
1820
- }
1821
- return { ok: true, appKey: candidate, wasAbsolute };
1822
- }
1823
- function callerKeyRefusal(key, r) {
1824
- if (r.reason === "other_install") {
1825
- return `"${key}" is a stored file of a different app install (${r.installId}), not this one. Pass a file_key this app returned to you.`;
1826
- }
1827
- return `"${key}" is not a stored file key: it has no file path after the app storage prefix.`;
1828
- }
1829
2044
  export {
1830
2045
  ACTOR_RETIRED_HOOK_TOOL,
1831
2046
  ACTOR_RETIRE_REASONS,
@@ -1835,15 +2050,19 @@ export {
1835
2050
  APP_OWNER_SEGMENT,
1836
2051
  DATA_PARTIAL_UPDATE_PATH,
1837
2052
  DEFAULT_EMIT_TIMEOUT_MS,
2053
+ DEFAULT_FILE_READ_TIMEOUT_MS,
1838
2054
  DEFAULT_LOG_TIMEOUT_MS,
1839
2055
  DEFAULT_MAX_FILE_BYTES,
1840
2056
  DEFAULT_MONEY_FIELDS,
1841
2057
  DEFAULT_WRITE_PREFIXES,
2058
+ FILE_REF_SOURCES_HELP,
2059
+ FileRefError,
1842
2060
  INBOX_SEND_CONTRACT_V1,
1843
2061
  InboxSendRefusal,
1844
2062
  InvalidUndoEnvelopeError,
1845
2063
  NotConnectedError,
1846
2064
  SPRIGR_DATA_MAX_OBJECTS_PER_CALL,
2065
+ SPRIGR_FILE_HOSTS,
1847
2066
  SPRIGR_LOG_LEVELS,
1848
2067
  SPRIGR_LOG_LIMITS,
1849
2068
  SprigrDataValidationError,
@@ -1854,6 +2073,7 @@ export {
1854
2073
  VIEWER_PLATFORM_USER_ID_HEADER,
1855
2074
  VIEWER_ROLE_HEADER,
1856
2075
  VIEWER_USER_ID_HEADER,
2076
+ WORKSPACE_FILE_HOW_TO,
1857
2077
  actorKey,
1858
2078
  actorRetiredHook,
1859
2079
  actorTool,
@@ -1903,6 +2123,7 @@ export {
1903
2123
  logToPlatform,
1904
2124
  manifestIsFresh,
1905
2125
  offerUndo,
2126
+ openFileRef,
1906
2127
  overlaySprigr,
1907
2128
  ownerFromActorKey,
1908
2129
  parseActor,
@@ -1911,10 +2132,12 @@ export {
1911
2132
  putAppFile,
1912
2133
  putAppFileStream,
1913
2134
  randomHex,
2135
+ readFileRef,
1914
2136
  readableStoredKey,
1915
2137
  recreatedWarning,
1916
2138
  refuseWithoutForce,
1917
2139
  requireApproval,
2140
+ resolveFileRef,
1918
2141
  resolveInstallBridge,
1919
2142
  resolveOAuthBouncerBase,
1920
2143
  resolveOAuthBouncerCallbackUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprigr/apps-app-sdk",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",