@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 +57 -0
- package/package.json +6 -7
- package/src/companion-files.ts +8 -3
- package/src/credential-template.ts +5 -1
- package/src/mime.ts +134 -0
- package/src/signed-token.ts +106 -0
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
|
+
"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": "
|
|
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
|
}
|
package/src/companion-files.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
+
}
|