@appstrate/afps-shared 0.3.1 → 0.5.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.5.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,20 @@
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
+ "./archive-prefix": "./src/archive-prefix.ts",
53
+ "./backoff": "./src/backoff.ts",
54
+ "./mime": "./src/mime.ts"
53
55
  },
54
56
  "dependencies": {
57
+ "@types/semver": "^7.8.0",
55
58
  "fflate": "^0.8.3",
56
59
  "semver": "^7.8.4"
57
60
  },
58
- "devDependencies": {
59
- "@types/semver": "^7.7.1"
60
- },
61
61
  "peerDependencies": {
62
- "typescript": "^5"
62
+ "typescript": ">=5 <8"
63
63
  },
64
64
  "scripts": {
65
65
  "typecheck": "tsc --noEmit",
66
- "check": "tsc --noEmit",
67
66
  "test": "bun test"
68
67
  }
69
68
  }
@@ -0,0 +1,81 @@
1
+ // Copyright 2026 Appstrate
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Wrapper-folder stripping for AFPS archives — the ONE place that answers
6
+ * "did whoever zipped this wrap everything in a top-level directory, and if so
7
+ * what is it?".
8
+ *
9
+ * ZIPs created by macOS Finder or `zip -r folder/` wrap all entries under a
10
+ * single top-level directory, so a lookup like `files["manifest.json"]` misses.
11
+ * Only strip when EVERY entry shares one first-level prefix and none sits at
12
+ * the root; anything else is ambiguous and the collection comes back untouched,
13
+ * by identity.
14
+ *
15
+ * Lives in the zero-dependency shared package because two packages that cannot
16
+ * import each other both have to strip the same prefix the same way:
17
+ *
18
+ * - **Core** (`@appstrate/core/zip`) — the platform's package-ZIP parser, over
19
+ * fflate's `Record<string, Uint8Array>` shape, plus the sanitized `Map`
20
+ * shape the bundle path hands it.
21
+ * - **AFPS runtime** (`@appstrate/afps-runtime` → `bundle/archive-utils.ts`) —
22
+ * the `.afps` ingestion path, over the sanitized `Map` shape.
23
+ *
24
+ * The runtime used to keep a hand-copy of core's `Map` branch under a comment
25
+ * asking a human to "keep the two algorithms in sync", with no parity test to
26
+ * police it. That is exactly the arrangement `@appstrate/afps-shared/mime`
27
+ * replaced for the media-type set — where the policing parity test existed and
28
+ * the copies drifted three times anyway, once corrupting every OOXML download.
29
+ * `@appstrate/afps-runtime` deliberately carries no runtime dependency on core
30
+ * (it ships as a portable bundle runner and a standalone `afps` CLI), and
31
+ * afps-shared is a dependency of both, so one definition reaches both without
32
+ * either taking on the other.
33
+ *
34
+ * Change the rule HERE, not at a call site.
35
+ */
36
+
37
+ /**
38
+ * Detect and strip a single common wrapper folder from archive entries.
39
+ *
40
+ * Accepts either a `Record<string, Uint8Array>` (fflate's default ZIP shape) or
41
+ * a `Map<string, Uint8Array>` (the sanitized bundle shape) and returns the same
42
+ * type as the input — the ORIGINAL object, by identity, when there is nothing
43
+ * to strip.
44
+ */
45
+ export function stripWrapperPrefix(files: Record<string, Uint8Array>): Record<string, Uint8Array>;
46
+ export function stripWrapperPrefix(files: Map<string, Uint8Array>): Map<string, Uint8Array>;
47
+ export function stripWrapperPrefix(
48
+ files: Record<string, Uint8Array> | Map<string, Uint8Array>,
49
+ ): Record<string, Uint8Array> | Map<string, Uint8Array> {
50
+ const keys = files instanceof Map ? [...files.keys()] : Object.keys(files);
51
+ const prefix = commonWrapperPrefix(keys);
52
+ if (prefix === null) return files;
53
+
54
+ if (files instanceof Map) {
55
+ const stripped = new Map<string, Uint8Array>();
56
+ for (const [key, value] of files) stripped.set(key.slice(prefix.length), value);
57
+ return stripped;
58
+ }
59
+ const stripped: Record<string, Uint8Array> = {};
60
+ for (const [key, value] of Object.entries(files)) {
61
+ stripped[key.slice(prefix.length)] = value;
62
+ }
63
+ return stripped;
64
+ }
65
+
66
+ /**
67
+ * The single wrapper prefix (WITH its trailing slash) every key shares, or
68
+ * `null` when there is none to strip — no entries at all, an entry at the root
69
+ * level, or more than one top-level folder (ambiguous).
70
+ */
71
+ function commonWrapperPrefix(keys: readonly string[]): string | null {
72
+ if (keys.length === 0) return null;
73
+ const prefixes = new Set<string>();
74
+ for (const key of keys) {
75
+ const slashIdx = key.indexOf("/");
76
+ if (slashIdx === -1) return null; // root-level file → no stripping
77
+ prefixes.add(key.slice(0, slashIdx));
78
+ }
79
+ if (prefixes.size !== 1) return null; // multiple top-level folders → ambiguous
80
+ return `${[...prefixes][0]}/`;
81
+ }
@@ -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,179 @@
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
+ * Membership criterion, applied to every entry below:
65
+ *
66
+ * 1. the format's specification defines the payload as a character sequence,
67
+ * so a UTF-8 decode is lossless and a base64 round-trip is pure noise to
68
+ * the model;
69
+ * 2. it plausibly arrives as an HTTP response body (request-only grammars
70
+ * such as `application/sparql-query` or `application/jsonpath` are out);
71
+ * 3. it is not a container that may embed raw bytes. `application/rtf`
72
+ * (`\bin` blocks), `application/postscript` (binary-token PostScript) and
73
+ * `application/http` (a message whose body may be anything) all fail this
74
+ * and stay on the byte path even though their common form is ASCII.
75
+ *
76
+ * Completeness matters more than it used to. `isTextLikeMimeType` in
77
+ * `@appstrate/afps-runtime` used to treat any `; charset=…` parameter as a
78
+ * declaration of textness ahead of this set; it now consults that parameter
79
+ * only for an ambiguous base type, so a text format MISSING from this set is
80
+ * base64'd to the model even when the upstream declared a charset.
81
+ */
82
+ export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
83
+ // JSON family
84
+ "application/json",
85
+ "application/ld+json",
86
+ "application/x-ndjson",
87
+ "application/jsonl",
88
+ "application/json-seq",
89
+ "application/json5",
90
+ "application/hjson",
91
+ // XML / SGML family
92
+ "application/xml",
93
+ "application/xml-dtd",
94
+ "application/xml-external-parsed-entity", // RFC 7303
95
+ "application/sgml",
96
+ "image/svg+xml", // XML-based, file-type never matches it
97
+ // YAML family
98
+ "application/yaml",
99
+ "application/x-yaml",
100
+ // TOML
101
+ "application/toml",
102
+ // Markdown — `text/markdown` is the registered spelling and already matches
103
+ // on the `text/` prefix; these two are the widespread unregistered ones.
104
+ "application/markdown",
105
+ "application/x-markdown",
106
+ // Query / schema languages served as source text
107
+ "application/sql", // RFC 6922
108
+ "application/graphql",
109
+ // RDF text serialisations. The `+json` / `+xml` RDF spellings already match
110
+ // on their structured suffix, and Turtle is registered as `text/turtle`.
111
+ "application/n-triples",
112
+ "application/n-quads",
113
+ "application/trig",
114
+ // Scripting / tabular / form encodings with no magic signature
115
+ "application/javascript",
116
+ "application/x-javascript",
117
+ "application/ecmascript",
118
+ "application/node",
119
+ "application/typescript",
120
+ "application/x-typescript",
121
+ "application/dart",
122
+ "application/csv",
123
+ "application/x-sh",
124
+ "application/x-shellscript",
125
+ "application/x-httpd-php",
126
+ "application/x-www-form-urlencoded",
127
+ // ASCII-armored / PEM blocks — base64 wrapped in text framing. Re-encoding
128
+ // them as base64 hides the framing the model needs to read.
129
+ "application/pem-certificate-chain", // RFC 8555
130
+ "application/pgp-keys", // RFC 3156 — the armored form
131
+ "application/pgp-signature", // RFC 3156 — the armored form
132
+ ]);
133
+
134
+ /**
135
+ * Is this MIME text-shaped — i.e. does the format carry its payload as text
136
+ * (plain text, JSON, CSV, XML source, YAML, JS, …) rather than as a binary
137
+ * container?
138
+ *
139
+ * Expects a NORMALIZED media type ({@link normalizeMime}); a value carrying
140
+ * `; charset=…` never matches the exact sets below.
141
+ *
142
+ * Four consumers ask this same question and must answer it identically:
143
+ *
144
+ * - **Sniff enforcement** (`shouldEnforceSniffedMime`): text-shaped formats
145
+ * have no magic bytes, so `file-type` can never confirm them — the strict
146
+ * declared-vs-sniffed check is skipped and the declared mime trusted.
147
+ * Callers needing strict binary validation should declare a concrete binary
148
+ * MIME (application/pdf, image/*, …) which `file-type` can identify.
149
+ * - **MCP `resources/read`**: text-shaped bytes are inlined as a `text` block;
150
+ * anything else goes out as a base64 `blob`.
151
+ * - **HTTP response classification** (`api_call` / `http_call`): text-shaped
152
+ * bodies are UTF-8 decoded; anything else stays raw bytes. A false positive
153
+ * here is data loss, not a cosmetic mislabel — the decode is lossy
154
+ * (`fatal: false` → U+FFFD) and irreversible once re-encoded.
155
+ *
156
+ * `application/octet-stream` is deliberately absent: it is the explicit "opaque
157
+ * blob" marker and MUST stay on the binary path even when its bytes happen to
158
+ * be ASCII.
159
+ */
160
+ export function isTextShapedMime(mime: string): boolean {
161
+ if (mime.startsWith("text/")) return true;
162
+ if (TEXT_SHAPED_MEDIA_TYPES.has(mime)) return true;
163
+ // Structured-syntax suffixes (RFC 6839) — `+json`, `+xml`, `+yaml`.
164
+ // Anything in these families is text-shaped and cannot be magic-sniffed.
165
+ return mime.endsWith("+json") || mime.endsWith("+xml") || mime.endsWith("+yaml");
166
+ }
167
+
168
+ /**
169
+ * Convenience wrapper for the common HTTP shape: classify a raw `Content-Type`
170
+ * header value (parameters included) in one call.
171
+ *
172
+ * An absent or empty header is NOT text — a caller that wants to treat a
173
+ * missing Content-Type as text must say so explicitly at its own call site,
174
+ * because the safe default for unknown bytes is the binary path.
175
+ */
176
+ export function isTextShapedContentType(contentType: string | null | undefined): boolean {
177
+ const mime = normalizeMime(contentType);
178
+ return mime !== "" && isTextShapedMime(mime);
179
+ }