@appstrate/afps-shared 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # `@appstrate/afps-shared`
2
+
3
+ Zero-internal-dependency leaf package holding the primitives that both
4
+ [`@appstrate/core`](https://www.npmjs.com/package/@appstrate/core) (platform side)
5
+ and the AFPS runtime (in-sandbox side) need to agree on byte-for-byte.
6
+
7
+ It exists so those two layers cannot drift: an integrity hash, an SSRF verdict or
8
+ a capability-token signature computed on one side must validate on the other.
9
+ If you are building on Appstrate, you almost certainly want `@appstrate/core`
10
+ instead — this package is its foundation, published separately because the
11
+ runtime cannot depend on the platform.
12
+
13
+ ```sh
14
+ npm install @appstrate/afps-shared
15
+ ```
16
+
17
+ **Requires Bun ≥ 1.3.9.** The package ships raw TypeScript sources rather than a
18
+ compiled bundle, so Node cannot import it directly. There is no barrel export —
19
+ import each module by subpath.
20
+
21
+ ## Exports
22
+
23
+ | Subpath | What it does |
24
+ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `./guarded-fetch` | The single outbound-request primitive for any URL whose host comes from a less-trusted input (manifest URLs, OAuth endpoints). Wraps the SSRF checks below. |
26
+ | `./ssrf` · `./ssrf-dns` | SSRF host/address validation, including DNS resolution so a hostname cannot rebind to a private range between check and connect. |
27
+ | `./signed-token` | Keyring-HMAC capability tokens — the one codec behind every short-lived, URL-carried capability (upload URLs, document previews, hosted connect sessions). |
28
+ | `./unzip-bounded` | Memory-bounded ZIP decompression for untrusted archives (AFPS bundles, package ZIPs, integration bundles). |
29
+ | `./integrity` | Package integrity digests. |
30
+ | `./companion-files` | Companion-file enforcement, shared between the platform ZIP-import path and the runtime bundle loader. |
31
+ | `./credential-template` | Credential-template placeholder substitution. |
32
+ | `./delivery-http` | Shared HTTP delivery contract. |
33
+ | `./semver-resolve` | Version-range resolution against a published version list. |
34
+ | `./api-tool-naming` · `./mcp-naming` | Deterministic tool and MCP-server naming. |
35
+ | `./file-field` | File-field parsing helpers. |
36
+ | `./token-usage` | Token-usage accounting shapes. |
37
+ | `./backoff` | Retry backoff computation. |
38
+
39
+ ```ts
40
+ import { guardedFetch } from "@appstrate/afps-shared/guarded-fetch";
41
+
42
+ // Refuses private ranges, link-local addresses and DNS-rebinding attempts.
43
+ const res = await guardedFetch(untrustedUrlFromManifest);
44
+ ```
45
+
46
+ ## Versioning
47
+
48
+ `@appstrate/core` depends on this package by caret range, so **a release here
49
+ must be published before any `@appstrate/core` release that bumps its
50
+ range** — otherwise `npm install @appstrate/core` cannot resolve.
51
+
52
+ Publishing is triggered by pushing an `afps-shared@X.Y.Z` git tag; CI handles the
53
+ rest (`.github/workflows/publish-afps-shared.yml`).
54
+
55
+ ## License
56
+
57
+ Apache-2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/afps-shared",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,21 +49,19 @@
49
49
  "./guarded-fetch": "./src/guarded-fetch.ts",
50
50
  "./signed-token": "./src/signed-token.ts",
51
51
  "./unzip-bounded": "./src/unzip-bounded.ts",
52
- "./backoff": "./src/backoff.ts"
52
+ "./backoff": "./src/backoff.ts",
53
+ "./mime": "./src/mime.ts"
53
54
  },
54
55
  "dependencies": {
56
+ "@types/semver": "^7.8.0",
55
57
  "fflate": "^0.8.3",
56
58
  "semver": "^7.8.4"
57
59
  },
58
- "devDependencies": {
59
- "@types/semver": "^7.7.1"
60
- },
61
60
  "peerDependencies": {
62
- "typescript": "^5"
61
+ "typescript": ">=5 <8"
63
62
  },
64
63
  "scripts": {
65
64
  "typecheck": "tsc --noEmit",
66
- "check": "tsc --noEmit",
67
65
  "test": "bun test"
68
66
  }
69
67
  }
@@ -7,9 +7,14 @@
7
7
  * (`@appstrate/afps-runtime/bundle/build:extractRootFromAfps`).
8
8
  *
9
9
  * This is the SINGLE source of truth for the §3.3 / §3.4 companion-file
10
- * invariants. Both `@appstrate/core/companion-files` and
11
- * `@appstrate/afps-runtime/bundle/companion-files` re-export from here, so
12
- * the two call sites can never drift.
10
+ * invariants. Both call sites import THIS module directly — there is no
11
+ * intermediate re-export to drift against:
12
+ * - `packages/core/src/zip.ts` calls `checkCompanionFiles` +
13
+ * `companionFilesFromRecord` inline (core no longer publishes a
14
+ * `./companion-files` subpath — removed in core 6.0.0).
15
+ * - `packages/afps-runtime/src/bundle/companion-files.ts` is a thin
16
+ * internal adapter (Map-accepting, throws `BundleError`) consumed by
17
+ * `bundle/validate-bundle.ts`; it is not a package subpath either.
13
18
  */
14
19
 
15
20
  /**
@@ -3,7 +3,11 @@
3
3
 
4
4
  /**
5
5
  * Canonical `{$credential.<field>}` value-template renderer — the SINGLE
6
- * source of truth, re-exported by `@appstrate/core/credential-template`.
6
+ * source of truth. Consumers import this module directly; core no longer
7
+ * publishes a `./credential-template` subpath (removed in core 6.0.0). The
8
+ * only importer today is `apps/api/src/services/integration-manifest-helpers.ts`,
9
+ * which re-exports it pre-bound to `emptyAs: "null"` for
10
+ * `integration-spawn-resolver.ts`.
7
11
  *
8
12
  * AFPS `delivery.http` / `delivery.env` / `delivery.files` value templates
9
13
  * reference an auth's decrypted credential bag via the `{$credential.<field>}`
package/src/mime.ts ADDED
@@ -0,0 +1,134 @@
1
+ // Copyright 2025-2026 Appstrate
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * MIME classification primitives — the ONE place that answers "is this media
6
+ * type a text payload or an opaque binary container?".
7
+ *
8
+ * Lives in the zero-dependency shared package because the question is asked at
9
+ * four layers that cannot import each other:
10
+ *
11
+ * - **Platform API** (`apps/api/src/services/mime-policy.ts`) — sniff
12
+ * enforcement on uploads, MCP `resources/read` inlining.
13
+ * - **AFPS runtime** (`packages/afps-runtime` → `http-call-core.ts`) — decides
14
+ * whether an `http_call` response body is decoded as text or base64'd.
15
+ * - **Sidecar** (`runtime-pi/sidecar/mcp.ts`) — decides whether an `api_call`
16
+ * response is inlined as text or spilled to the blob store as bytes.
17
+ * - **Core** (`@appstrate/core/mime`) — re-exports this module verbatim, so
18
+ * the platform surface is unchanged.
19
+ *
20
+ * Every one of those had its own hand-rolled list, and the lists drifted:
21
+ *
22
+ * - The MCP copy did not know about the YAML family, so a YAML document was
23
+ * base64-blobbed instead of being handed to the model as readable text.
24
+ * - The sidecar matched `contentType.includes("xml")`, which classifies
25
+ * `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (an
26
+ * XLSX — a ZIP binary) as text. The UTF-8 decode replaced invalid bytes with
27
+ * U+FFFD and the re-encode wrote that corruption to disk, destroying every
28
+ * OOXML file downloaded through `responseMode.toFile`.
29
+ *
30
+ * Both bugs are the same bug: an ad-hoc list, matched by substring instead of
31
+ * by media type. Add a format HERE, not at a call site.
32
+ *
33
+ * WHY this module sits in `@appstrate/afps-shared` rather than in core:
34
+ * `@appstrate/afps-runtime` deliberately carries no runtime dependency on core
35
+ * (it ships as a portable bundle runner and a standalone `afps` CLI; core sits
36
+ * beside it in the dependency graph, not below it). For that reason the set
37
+ * used to be hand-copied into `http-call-core.ts` with a parity test guarding
38
+ * the copy — and it drifted three times anyway, once classifying XLSX as XML.
39
+ * afps-shared is a `workspace:*` dependency of afps-runtime AND a published
40
+ * dependency of core, so a single definition now reaches both without either
41
+ * importing the other. That is the same "canonical source, core re-exports
42
+ * verbatim" arrangement used by `ssrf.ts`, `credential-template.ts` and
43
+ * `guarded-fetch.ts`.
44
+ */
45
+
46
+ /**
47
+ * Strip charset / boundary / other parameters from a MIME string and lowercase
48
+ * it, so `text/csv; charset=utf-8` compares equal to `text/csv`.
49
+ *
50
+ * Every predicate in this module expects an already-normalized value —
51
+ * classification and normalization stay separate so a caller that already holds
52
+ * a bare media type does not pay for the split twice.
53
+ */
54
+ export function normalizeMime(mime: string | null | undefined): string {
55
+ if (!mime) return "";
56
+ return mime.split(";", 1)[0]?.trim().toLowerCase() ?? "";
57
+ }
58
+
59
+ /**
60
+ * Media types whose payload is text, matched EXACTLY. A substring test would
61
+ * classify `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
62
+ * as XML — see the module doc.
63
+ */
64
+ export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
65
+ // JSON family
66
+ "application/json",
67
+ "application/ld+json",
68
+ "application/x-ndjson",
69
+ "application/jsonl",
70
+ "application/json-seq",
71
+ // XML family
72
+ "application/xml",
73
+ "application/xml-dtd",
74
+ "application/xml-external-parsed-entity", // RFC 7303
75
+ "image/svg+xml", // XML-based, file-type never matches it
76
+ // YAML family
77
+ "application/yaml",
78
+ "application/x-yaml",
79
+ // Scripting / tabular / form encodings with no magic signature
80
+ "application/javascript",
81
+ "application/x-javascript",
82
+ "application/ecmascript",
83
+ "application/csv",
84
+ "application/x-sh",
85
+ "application/x-httpd-php",
86
+ "application/x-www-form-urlencoded",
87
+ ]);
88
+
89
+ /**
90
+ * Is this MIME text-shaped — i.e. does the format carry its payload as text
91
+ * (plain text, JSON, CSV, XML source, YAML, JS, …) rather than as a binary
92
+ * container?
93
+ *
94
+ * Expects a NORMALIZED media type ({@link normalizeMime}); a value carrying
95
+ * `; charset=…` never matches the exact sets below.
96
+ *
97
+ * Four consumers ask this same question and must answer it identically:
98
+ *
99
+ * - **Sniff enforcement** (`shouldEnforceSniffedMime`): text-shaped formats
100
+ * have no magic bytes, so `file-type` can never confirm them — the strict
101
+ * declared-vs-sniffed check is skipped and the declared mime trusted.
102
+ * Callers needing strict binary validation should declare a concrete binary
103
+ * MIME (application/pdf, image/*, …) which `file-type` can identify.
104
+ * - **MCP `resources/read`**: text-shaped bytes are inlined as a `text` block;
105
+ * anything else goes out as a base64 `blob`.
106
+ * - **HTTP response classification** (`api_call` / `http_call`): text-shaped
107
+ * bodies are UTF-8 decoded; anything else stays raw bytes. A false positive
108
+ * here is data loss, not a cosmetic mislabel — the decode is lossy
109
+ * (`fatal: false` → U+FFFD) and irreversible once re-encoded.
110
+ *
111
+ * `application/octet-stream` is deliberately absent: it is the explicit "opaque
112
+ * blob" marker and MUST stay on the binary path even when its bytes happen to
113
+ * be ASCII.
114
+ */
115
+ export function isTextShapedMime(mime: string): boolean {
116
+ if (mime.startsWith("text/")) return true;
117
+ if (TEXT_SHAPED_MEDIA_TYPES.has(mime)) return true;
118
+ // Structured-syntax suffixes (RFC 6839) — `+json`, `+xml`, `+yaml`.
119
+ // Anything in these families is text-shaped and cannot be magic-sniffed.
120
+ return mime.endsWith("+json") || mime.endsWith("+xml") || mime.endsWith("+yaml");
121
+ }
122
+
123
+ /**
124
+ * Convenience wrapper for the common HTTP shape: classify a raw `Content-Type`
125
+ * header value (parameters included) in one call.
126
+ *
127
+ * An absent or empty header is NOT text — a caller that wants to treat a
128
+ * missing Content-Type as text must say so explicitly at its own call site,
129
+ * because the safe default for unknown bytes is the binary path.
130
+ */
131
+ export function isTextShapedContentType(contentType: string | null | undefined): boolean {
132
+ const mime = normalizeMime(contentType);
133
+ return mime !== "" && isTextShapedMime(mime);
134
+ }