@appstrate/afps-shared 0.3.0 → 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.0",
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",
@@ -47,22 +47,21 @@
47
47
  "./token-usage": "./src/token-usage.ts",
48
48
  "./ssrf-dns": "./src/ssrf-dns.ts",
49
49
  "./guarded-fetch": "./src/guarded-fetch.ts",
50
+ "./signed-token": "./src/signed-token.ts",
50
51
  "./unzip-bounded": "./src/unzip-bounded.ts",
51
- "./backoff": "./src/backoff.ts"
52
+ "./backoff": "./src/backoff.ts",
53
+ "./mime": "./src/mime.ts"
52
54
  },
53
55
  "dependencies": {
56
+ "@types/semver": "^7.8.0",
54
57
  "fflate": "^0.8.3",
55
58
  "semver": "^7.8.4"
56
59
  },
57
- "devDependencies": {
58
- "@types/semver": "^7.7.1"
59
- },
60
60
  "peerDependencies": {
61
- "typescript": "^5"
61
+ "typescript": ">=5 <8"
62
62
  },
63
63
  "scripts": {
64
64
  "typecheck": "tsc --noEmit",
65
- "check": "tsc --noEmit",
66
65
  "test": "bun test"
67
66
  }
68
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
+ }
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Keyring-HMAC capability tokens — the ONE codec behind every short-lived,
5
+ * URL-carried capability the platform mints (filesystem/proxy upload URLs,
6
+ * document previews, hosted connect sessions).
7
+ *
8
+ * Wire format: `base64url(JSON payload).base64url(HMAC-SHA256)`.
9
+ *
10
+ * Two properties are load-bearing and were previously re-implemented (and
11
+ * drifted) per token type:
12
+ *
13
+ * - **Keyring rotation.** A secret is a comma-separated list (or an array):
14
+ * the FIRST key signs new tokens, ALL keys verify, so a rotation never
15
+ * invalidates tokens already in flight. Individual keys must therefore not
16
+ * contain commas.
17
+ * - **Domain separation.** {@link signKeyringToken} takes the domain as its
18
+ * FIRST, REQUIRED argument and mixes it into the signed content, so a token
19
+ * minted for one purpose can never be verified as another — including when
20
+ * two token types share a signing secret (upload URLs and document previews
21
+ * both key off `UPLOAD_SIGNING_SECRET`). Making the parameter mandatory is
22
+ * the point: an optional domain is a domain someone forgets, and the
23
+ * resulting protection is one-directional — exactly the asymmetry this
24
+ * module replaces.
25
+ *
26
+ * Deliberately NOT part of the codec: expiry and claim validation. Every token
27
+ * type names its expiry field differently and enforces its own required
28
+ * claims, so {@link verifyKeyringToken} returns the decoded payload after the
29
+ * signature check and leaves semantics to the caller.
30
+ *
31
+ * Zero-dependency leaf so `@appstrate/core` (storage), the platform API
32
+ * (document previews) and `@appstrate/connect` (hosted connect sessions) can
33
+ * all sit above it without a cycle.
34
+ */
35
+
36
+ import { createHmac, timingSafeEqual } from "node:crypto";
37
+
38
+ /**
39
+ * Normalize a signing secret into a keyring. A plain string is split on commas
40
+ * (rotation: prepend the new key); empty segments are dropped.
41
+ */
42
+ export function toKeyring(secret: string | readonly string[]): string[] {
43
+ const keys = typeof secret === "string" ? secret.split(",") : [...secret];
44
+ return keys.filter((k) => k.length > 0);
45
+ }
46
+
47
+ /**
48
+ * Encode + HMAC-sign a payload with the FIRST key of the keyring, binding the
49
+ * signature to `domain`. Throws when the keyring holds no usable key.
50
+ *
51
+ * `domain` is a short, stable, versioned literal (`"doc-preview.v1."`) — change
52
+ * it and every token already in flight stops verifying.
53
+ */
54
+ export function signKeyringToken(
55
+ domain: string,
56
+ payload: unknown,
57
+ secret: string | readonly string[],
58
+ ): string {
59
+ const [activeKey] = toKeyring(secret);
60
+ if (!activeKey) throw new Error("signKeyringToken requires at least one signing key");
61
+ const body = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64url");
62
+ const sig = createHmac("sha256", activeKey)
63
+ .update(domain + body)
64
+ .digest("base64url");
65
+ return `${body}.${sig}`;
66
+ }
67
+
68
+ /**
69
+ * Verify a token against `domain` and decode its payload. Returns null on any
70
+ * failure (malformed shape, wrong/absent signature, non-JSON body) — never
71
+ * throws. Verifies against EVERY key of the keyring (constant-time comparison
72
+ * per key) so tokens signed before a rotation stay valid.
73
+ *
74
+ * The returned value is the raw decoded JSON cast to `T`: the signature proves
75
+ * WE minted it, not that its fields are the ones the caller expects. Callers
76
+ * validate expiry + required claims themselves.
77
+ */
78
+ export function verifyKeyringToken<T>(
79
+ domain: string,
80
+ token: string,
81
+ secret: string | readonly string[],
82
+ ): T | null {
83
+ const dot = token.indexOf(".");
84
+ if (dot <= 0) return null;
85
+ const body = token.slice(0, dot);
86
+ const sig = token.slice(dot + 1);
87
+ const a = Buffer.from(sig);
88
+ let valid = false;
89
+ for (const key of toKeyring(secret)) {
90
+ const b = Buffer.from(
91
+ createHmac("sha256", key)
92
+ .update(domain + body)
93
+ .digest("base64url"),
94
+ );
95
+ if (a.length === b.length && timingSafeEqual(a, b)) {
96
+ valid = true;
97
+ break;
98
+ }
99
+ }
100
+ if (!valid) return null;
101
+ try {
102
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf-8")) as T;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }