@appstrate/afps-shared 0.4.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/afps-shared",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,6 +49,7 @@
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
+ "./archive-prefix": "./src/archive-prefix.ts",
52
53
  "./backoff": "./src/backoff.ts",
53
54
  "./mime": "./src/mime.ts"
54
55
  },
@@ -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
+ }
@@ -96,3 +96,35 @@ export function projectHttpDeliveryConfig(
96
96
  }
97
97
  return cfg;
98
98
  }
99
+
100
+ /**
101
+ * Header names whose value is an RFC 9110 `credentials` production — an auth
102
+ * scheme token, one SP, then the credentials. Only in these positions is a
103
+ * bare token prefix a defect; anywhere else (`Cookie: session=`) it is an
104
+ * ordinary literal.
105
+ */
106
+ const AUTH_SCHEME_HEADERS = new Set(["authorization", "proxy-authorization"]);
107
+
108
+ /** RFC 9110 `token` grammar — matches a prefix that is nothing but a scheme. */
109
+ const BARE_AUTH_SCHEME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
110
+
111
+ /**
112
+ * True when `prefix` is nothing but an auth-scheme token AND `headerName` is a
113
+ * position whose value is an RFC 9110 `credentials` production.
114
+ *
115
+ * `prefix` is a LITERAL prepended to the rendered credential (AFPS §7.6), so a
116
+ * bare `"Bearer"` renders `Authorization: BearerTOKEN` — a malformed credential
117
+ * every upstream answers with a 401 that names nothing. The injector
118
+ * (`@appstrate/afps-runtime`'s `planHttpDeliveryInjection`) concatenates
119
+ * verbatim and repairs nothing, so every path that accepts an author-written
120
+ * prefix must refuse the bare form up front instead. This is the one grammar
121
+ * those gates share: the integration manifest validator
122
+ * (`@appstrate/core/integration`, install time) and the portable runtime's
123
+ * local creds file (`resolvers/integration-api-call.ts`, load time).
124
+ *
125
+ * Callers pass the EFFECTIVE header name — the one that will actually be sent,
126
+ * after their own defaulting has been applied.
127
+ */
128
+ export function isBareAuthSchemePrefix(headerName: string, prefix: string): boolean {
129
+ return AUTH_SCHEME_HEADERS.has(headerName.toLowerCase()) && BARE_AUTH_SCHEME.test(prefix);
130
+ }
@@ -80,7 +80,7 @@ export interface GuardedFetchOptions {
80
80
  fetchImpl?: typeof fetch;
81
81
  /**
82
82
  * Opt-in predicate for hosts the OPERATOR has explicitly trusted (e.g. an
83
- * internal IdP on a private address via `OAUTH_ALLOWED_INTERNAL_IDP_HOSTS`).
83
+ * internal IdP on a private address via `EGRESS_ALLOW_INTERNAL_HOSTS`).
84
84
  * When it returns true the host blocklist is skipped for that hop, but the
85
85
  * manual-redirect discipline (cross-origin body/credential stripping) still
86
86
  * applies — so a trusted host that open-redirects cannot forward the secret.
package/src/mime.ts CHANGED
@@ -60,6 +60,24 @@ export function normalizeMime(mime: string | null | undefined): string {
60
60
  * Media types whose payload is text, matched EXACTLY. A substring test would
61
61
  * classify `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
62
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.
63
81
  */
64
82
  export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
65
83
  // JSON family
@@ -68,22 +86,49 @@ export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
68
86
  "application/x-ndjson",
69
87
  "application/jsonl",
70
88
  "application/json-seq",
71
- // XML family
89
+ "application/json5",
90
+ "application/hjson",
91
+ // XML / SGML family
72
92
  "application/xml",
73
93
  "application/xml-dtd",
74
94
  "application/xml-external-parsed-entity", // RFC 7303
95
+ "application/sgml",
75
96
  "image/svg+xml", // XML-based, file-type never matches it
76
97
  // YAML family
77
98
  "application/yaml",
78
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",
79
114
  // Scripting / tabular / form encodings with no magic signature
80
115
  "application/javascript",
81
116
  "application/x-javascript",
82
117
  "application/ecmascript",
118
+ "application/node",
119
+ "application/typescript",
120
+ "application/x-typescript",
121
+ "application/dart",
83
122
  "application/csv",
84
123
  "application/x-sh",
124
+ "application/x-shellscript",
85
125
  "application/x-httpd-php",
86
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
87
132
  ]);
88
133
 
89
134
  /**