@thomasfosterau/effect-web 0.0.0-stage → 0.0.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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -2
  3. package/package.json +72 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Foster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,167 @@
1
- # Temporary Holding Version
1
+ # @thomasfosterau/effect-web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-neutral web request/response utilities on `effect/http`: cache-control and security headers, Vary, the cross-origin guard, client IP, an SSRF-guarded fetch, structured logging and an allow-listed span tracer, with no SvelteKit, Alchemy or Cloudflare dependency, so it serves a SvelteKit hook, an `HttpApi` middleware or a bare Worker equally.
4
+
5
+ Invariant ids in doc comments and below (`CACHE-AUTH-003`, `SEC-SSRF-003`, …) name the rule an export enforces; [`SECURITY.md`](https://github.com/thomasfosterau/effect-web/blob/main/SECURITY.md#invariant-ids) states each rule and the exports that enforce it.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @thomasfosterau/effect-web effect
11
+ ```
12
+
13
+ `effect` (>= 4.0.0-rc.118) is a peer dependency. `@thomasfosterau/effect-jsonapi` (>= 0.16.1, the first release built on rc.118) is an **optional** peer, needed only by the `./negotiate` entry.
14
+
15
+ | Entry | What it is | Peers |
16
+ | --- | --- | --- |
17
+ | `.` | Response-header policies (security baseline, stage `noindex`, `Vary` discipline, the `Cache-Control` safety net and the viewer-aware policy), the cross-origin mutation guard, the trusted client address, the degraded-session report, the JSON:API guard responses a front door emits at its seam, and the Fetch adapters. | `effect` |
18
+ | `./net` | The SSRF host block list, the URL safety check (as a `Result`, as a throw) and the redirect-revalidating fetch (as an `Effect` with tagged failures, as a `Promise`). | `effect` |
19
+ | `./observability` | The structured JSON-line sink on `effect/Logger`, the allow-listed span tracer on `effect/Tracer`, the `logEvent` trio, `describeError`, and the `read-path.auth` signal. | `effect` |
20
+ | `./negotiate` | The content-negotiation dispatch verdict (HTML / JSON:API / Markdown) for a URL space shared between pages and a JSON:API, over effect-jsonapi's §5/§6 rules. | `effect`, `@thomasfosterau/effect-jsonapi` |
21
+
22
+ Every entry is `sideEffects: false`: no top-level statements in `dist/`.
23
+
24
+ ## Usage
25
+
26
+ ### A SvelteKit hook
27
+
28
+ The helpers are written over `effect/http`'s `Headers`, `HttpServerRequest` and `HttpServerResponse`. Three adapters bridge to the Fetch types a SvelteKit `Handle` deals in: `HttpServerRequest.fromWeb` (Effect's own) for a request, `HttpServerResponse.toWeb` (Effect's own) for a whole response, and this package's `updateWebHeaders` / `applyToWebHeaders` for a header policy on a Fetch `Response`. The `*Web` functions (`crossOriginMutationRejectionWeb`, `jsonApiErrorResponseWeb`, `negotiateWebRequest`, `requestForRouterWeb`) are those adapters pre-applied.
29
+
30
+ ```ts
31
+ // hooks.server.ts
32
+ import type { Handle, RequestEvent } from "@sveltejs/kit";
33
+ import { sequence } from "@sveltejs/kit/hooks";
34
+ import * as Option from "effect/Option";
35
+ import {
36
+ applyToWebHeaders,
37
+ cacheControlForViewer,
38
+ classifyRoute,
39
+ crossOriginMutationRejectionWeb,
40
+ ensureCacheControl,
41
+ responseVary,
42
+ securityHeaders,
43
+ stageNoindex,
44
+ updateWebHeaders,
45
+ } from "@thomasfosterau/effect-web";
46
+ import {
47
+ chooseNegotiatedResponse,
48
+ endpointPaths,
49
+ makeApiPathMatcher,
50
+ makeMarkdownPathMatcher,
51
+ negotiateWebRequest,
52
+ } from "@thomasfosterau/effect-web/negotiate";
53
+
54
+ /** What the application supplies. */
55
+ interface App {
56
+ readonly apiGroups: Parameters<typeof endpointPaths>[0]; // the served HttpApi groups
57
+ readonly markdownSegments: ReadonlyArray<string>; // e.g. ["posts", "people"]
58
+ readonly indexable: boolean; // true on the production stage only
59
+ readonly authentication: Handle; // resolves `event.locals.user`
60
+ readonly forwardJsonApi: (request: Request) => Promise<Response>;
61
+ readonly renderMarkdown: (event: RequestEvent) => Promise<Option.Option<Response>>;
62
+ }
63
+
64
+ export const makeHandle = (app: App): Handle => {
65
+ const isApiPath = makeApiPathMatcher(endpointPaths(app.apiGroups));
66
+ const isMarkdownPath = makeMarkdownPathMatcher(app.markdownSegments);
67
+
68
+ // Outermost: the post-`resolve` header stamp lands on the final response.
69
+ const applyHeaders: Handle = async ({ event, resolve }) => {
70
+ const response = await resolve(event);
71
+ applyToWebHeaders(response.headers, (headers) =>
72
+ responseVary(
73
+ stageNoindex(securityHeaders(headers), { indexable: app.indexable }),
74
+ classifyRoute(event.url.pathname),
75
+ ),
76
+ );
77
+ return response;
78
+ };
79
+
80
+ // The JSON:API forward, outside the render middleware: a cross-origin
81
+ // mutation is refused (already `private, no-store`); anything else gets the
82
+ // CACHE-AUTH-003 safety net, since `cacheControl` below never sees it.
83
+ const forward: Handle = async ({ event, resolve }) => {
84
+ if (!negotiateWebRequest(event.request, { isApiPath }).dispatch) return resolve(event);
85
+ const rejected = crossOriginMutationRejectionWeb(event.request);
86
+ if (Option.isSome(rejected)) return rejected.value;
87
+ return updateWebHeaders(await app.forwardJsonApi(event.request), ensureCacheControl);
88
+ };
89
+
90
+ // After `authentication` (reads the resolved viewer), before `negotiate`
91
+ // (stamps whichever representation is returned): CACHE-AUTH-003.
92
+ const cacheControl: Handle = async ({ event, resolve }) =>
93
+ updateWebHeaders(await resolve(event), (headers) =>
94
+ cacheControlForViewer(headers, { viewerResolved: event.locals.user !== null }),
95
+ );
96
+
97
+ // Innermost: the Markdown representation, or SvelteKit's own render.
98
+ const negotiate: Handle = async ({ event, resolve }) => {
99
+ const verdict = negotiateWebRequest(event.request, { isApiPath, isMarkdownPath });
100
+ if (verdict.markdown === true) {
101
+ return chooseNegotiatedResponse(await app.renderMarkdown(event), () => resolve(event));
102
+ }
103
+ return resolve(event);
104
+ };
105
+
106
+ return sequence(applyHeaders, forward, app.authentication, cacheControl, negotiate);
107
+ };
108
+ ```
109
+
110
+ A response that bypasses the middleware (an auth short-circuit) still gets the safety net: `updateWebHeaders(response, ensureCacheControl)`. A verdict with an `error` (406 / 415) is answered with `jsonApiErrorResponseWeb(verdict.error)`.
111
+
112
+ ## API
113
+
114
+ ### `.` — headers, guards, adapters
115
+
116
+ #### Header policies
117
+
118
+ Pure `(headers: Headers.Headers) => Headers.Headers` functions; compose them and apply once with `updateHeaders` (an `HttpServerResponse`), `updateWebHeaders` (a Fetch `Response`, cloned only when something changed) or `applyToWebHeaders` (a mutable Fetch `Headers`, in place).
119
+
120
+ - `securityHeaders(headers)` — the baseline (`SECURITY_HEADER_DEFAULTS`: `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, `Cross-Origin-Opener-Policy`), each only when the route did not set it (SEC-HDR-001).
121
+ - `stageNoindex(headers, { indexable })` — `X-Robots-Tag: noindex` on a non-indexable stage (CANON-003). The stage decision is yours; this package reads no environment.
122
+ - `appendVary(headers, names)` — idempotent, case-insensitive; the single place that mutates `Vary`.
123
+ - `responseVary(headers, routeClass)` / `VARY_BY_ROUTE_CLASS` / `RouteVaryClass` — no `Vary` for a `"locale-invariant"` asset, `Accept` alone for a `"format-negotiated"` one, `Cookie, Accept-Language, Accept` for the `"negotiated"` surface (PERF-CACHE-001).
124
+ - `classifyRoute(pathname, classifier?)` — narrowest class first, so a locale-invariant asset never gets a locale `Vary` (PERF-CACHE-001). `classifier.isLocaleInvariantAsset` defaults to `isLocaleInvariantAsset`; `classifier.isFormatNegotiatedAsset` is application-specific (for example `GET /images/<uuid>`) and defaults to none.
125
+ - `LOCALE_INVARIANT_ASSET_PATHS`, `isOpenGraphCard`, `isLocaleInvariantAsset`, `makeLocaleInvariantAssetMatcher(paths, ...predicates)` — the default set (icons, the default share card, the public edge-cached documents), the Open Graph card predicate, and the way to build your own matcher. The set and `isOpenGraphCard` keep share images free of a locale `Vary` (OG-IMG-001).
126
+ - `ensureCacheControl(headers)` — `Cache-Control: private, no-store` (`PRIVATE_NO_STORE`) when nothing declared one: the CACHE-AUTH-003 safety net for a response that bypasses the viewer-aware policy.
127
+ - `cacheControlForViewer(headers, { viewerResolved })` — the viewer-aware policy (CACHE-AUTH-003): a resolved viewer's response is always forced to `private, no-store` (Workers Caching runs ahead of the Worker on a hit, and `Set-Cookie` cannot be relied on as its bypass); an anonymous one falls back to `ensureCacheControl`.
128
+
129
+ #### Guards and resolvers
130
+
131
+ - `isCrossOrigin(origin: Option<string>, expectedOrigin)`, `isCrossOriginRejected(request, expectedOrigin)` — the predicate an API applies per route, one for every route (a CSRF guard that differs between routes can't be audited): a present `Origin` that is not the Worker's own is refused; absent passes (a server-to-server caller). `isCrossOriginRejected` is the SEC-CSRF-001 check over a request; `expectedOrigin` is explicit because an `HttpServerRequest`'s `url` is its path.
132
+ - `crossOriginMutationRejection(request, expectedOrigin): Option<HttpServerResponse>` — the front door's SEC-CSRF-001 guard for a dispatched JSON:API request: `Some(403 CROSS_ORIGIN_REJECTED, already private, no-store)` for a cross-origin mutation, `None` to proceed. `crossOriginMutationRejectionWeb(request, expectedOrigin = new URL(request.url).origin)` over Fetch types.
133
+ - `resolveClientIp(getClientAddress): Option<string>` — the platform-resolved address or `None`, never the spoofable inbound `x-forwarded-for` (SEC-SSRF-005).
134
+ - `describeSessionDegraded(error)` / `SESSION_DEGRADED_LOG_MESSAGE` — what a fail-open session resolution reports: the fixed message plus the error for the Worker log, the error verbatim for the tracker.
135
+ - `isMutatingMethod(method)` — `POST` / `PUT` / `PATCH` / `DELETE`.
136
+
137
+ #### JSON:API seam errors
138
+
139
+ - `NegotiationError` (`{ status, code, title, detail }`), `CROSS_ORIGIN_REJECTED` (403 `auth.forbidden`), `SERVICE_UNAVAILABLE` (503 `unavailable`), `JSONAPI_MEDIA_TYPE`.
140
+ - `jsonApiErrorDocument(error)`, `jsonApiErrorResponse(error): HttpServerResponse`, `jsonApiErrorResponseWeb(error): Response` — the `{ errors: [...], jsonapi: { version: "1.1" } }` document.
141
+
142
+ ### `./net` — the SSRF guard
143
+
144
+ - `isBlockedHost(hostname)` — loopback / internal names (trailing root dot normalised), private / link-local / CGNAT IPv4, loopback / ULA / link-local IPv6, IPv4-mapped and NAT64 literals routed through the IPv4 list. A malformed dotted quad is blocked, never allowed.
145
+ - `checkUrl(url): Result<URL, UnsafeUrl>` — http(s) only, no embedded credentials, host not blocked (SEC-SSRF-003). `UnsafeUrl` is a tagged error with `reason: "invalid" | "scheme" | "credentials" | "blocked-host"` and fixed message wording, kept stable because callers' tests pin it. `assertAllowedUrl(url): URL` is the same check for plain callers: it throws a plain `Error` carrying the message, whose `cause` is the `UnsafeUrl`.
146
+ - `followRedirects(url, init, { maxHops?, fetch? }): Effect<FollowedResponse, UnsafeUrl | RedirectRejected | FetchFailed>` — fetches with `redirect: "manual"`, re-checking the initial URL, every `Location` hop and the final URL (SEC-SSRF-003). `maxHops: 0` refuses any redirect (the shape for a POST). Pass a shared `signal` in `init` so the chain shares one deadline. `fetchFollowingRedirects(url, init, maxHops?, fetch?): Promise<FollowedResponse>` is the same for plain callers, rejecting with a plain `Error` carrying the failure's message, whose `cause` is the tagged `UnsafeUrl` / `RedirectRejected` (a `fetch` failure rejects with the original `fetch` cause instead).
147
+
148
+ ### `./observability` — the read-path signals on `effect/Logger` and `effect/Tracer`
149
+
150
+ - `logStructured(payload)` — one flat JSON line through `effect/Logger`, envelope-free (`{"event":…}`, never Effect's logger envelope, so a dashboard query on the flat shape keeps working), unconditional whatever the ambient minimum log level. Safe to call from plain code: it never suspends.
151
+ - `spanTracer(trackedSpanNames): Tracer.Tracer` — logs `{ event: "span", name, durationMs, traceId, spanId, ok }` per completed span whose name is in the set, nothing for any other. **The allow-list is a required parameter, never a constant**: it replaces the tracer for the whole per-request layer, where `Effect.withSpan` has hundreds of call sites. Build it once at module scope.
152
+ - `logEvent` / `logWarningEvent` / `logErrorEvent(event, fields?)` — `Effect.logInfo` / `logWarning` / `logError` with `{ event, ...fields }` as log annotations. `describeError(error)` renders a failure as `Tag: message`, `Tag`, `message` or `String(error)`.
153
+ - `deriveAuthOutcome(facts): AuthOutcome` (`"skipped" | "resolved" | "anonymous" | "degraded"`; `"skipped"` is the credential-less fast path, PERF-CACHE-003) and `logAuthOutcome(durationMs, outcome)` — the `read-path.auth` line.
154
+
155
+ ### `./negotiate` — the dispatch verdict
156
+
157
+ - `negotiateRepresentation(request: HttpServerRequest, options): Negotiation` / `negotiateWebRequest(request: Request, options)` — reads negotiate on `Accept`, trying `text/markdown` first on a Markdown path so the page's own URL serves it (DISC-009); mutating methods on `Content-Type`, anything else is HTML; a disallowed media-type parameter answers 406 / 415 (JSONAPI-NEG-001 / JSONAPI-NEG-002). The verdict: `representation` (`"text/html" | "application/vnd.api+json" | "text/markdown"`, the normalized cache key, never the raw `Accept`: PERF-CACHE-005), `dispatch`, `markdown?`, `error?` (`NOT_ACCEPTABLE` 406 / `UNSUPPORTED_MEDIA_TYPE` 415).
158
+ - `NegotiationOptions`: `isApiPath` (required — build it with `makeApiPathMatcher(endpointPaths(groups))` from the served `HttpApi` groups, so the matcher never drifts from the surface), `isMarkdownPath?` (`makeMarkdownPathMatcher(segments)`: a `<segment>/<id>` page whose segment is listed, DISC-009), `extensions?` (the JSON:API `ext` URIs the api declares).
159
+ - `requestForRouter(request)` / `requestForRouterWeb(request)` — relabels a dispatched write's `Content-Type` to `application/json` for a router that registers JSON payloads under the default encoding.
160
+ - `chooseNegotiatedResponse(markdown: Option<A>, onHtml: () => B)` — the Markdown response or the framework's render, which runs only on `None`.
161
+ - `MARKDOWN_MEDIA_TYPE`, `Representation`, `Negotiation`.
162
+
163
+ What is adopted from `@thomasfosterau/effect-jsonapi` and what is not: the JSON:API §5/§6 media-type-parameter rule (`ext` / `profile` only; every `ext` URI must be declared) runs through `Middleware.negotiate` for a write and `Middleware.contentTypeIsAcceptable` per `Accept` instance. The **ranking** — whether the JSON:API or Markdown instance outranks every other media type the client offered (`q`, ties win, an unparsable `q` reads as `1`) — is local, because a JSON:API-only server never chooses between representations. Two consequences: an undeclared `ext` is a 406 / 415 (spec-correct), and the §6 error `code`s are effect-jsonapi's (`not_acceptable`, `unsupported_media_type`).
164
+
165
+ ## License
166
+
167
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
+ "$schema": "https://www.schemastore.org/package",
2
3
  "name": "@thomasfosterau/effect-web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
4
+ "version": "0.0.0",
5
+ "description": "Framework-neutral web request/response utilities on effect/http: cache-control and security headers, Vary, the cross-origin guard, client IP, an SSRF-guarded fetch, structured logging and an allow-listed span tracer.",
6
+ "keywords": [
7
+ "cloudflare-workers",
8
+ "content-negotiation",
9
+ "effect",
10
+ "fetch",
11
+ "http",
12
+ "ssrf",
13
+ "sveltekit",
14
+ "typescript"
15
+ ],
16
+ "homepage": "https://github.com/thomasfosterau/effect-web#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/thomasfosterau/effect-web/issues"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Thomas Foster <me@thomasfoster.au>",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/thomasfosterau/effect-web.git",
25
+ "directory": "packages/web"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "type": "module",
33
+ "sideEffects": false,
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ },
40
+ "./net": {
41
+ "types": "./dist/net.d.ts",
42
+ "default": "./dist/net.js"
43
+ },
44
+ "./observability": {
45
+ "types": "./dist/observability.d.ts",
46
+ "default": "./dist/observability.js"
47
+ },
48
+ "./negotiate": {
49
+ "types": "./dist/negotiate.d.ts",
50
+ "default": "./dist/negotiate.js"
51
+ }
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "devDependencies": {
57
+ "@effect/language-service": "^0.87.4",
58
+ "@thomasfosterau/effect-jsonapi": "^0.16.3",
59
+ "@types/node": "^26.6.4",
60
+ "@vitest/coverage-istanbul": "4.1.11",
61
+ "effect": "4.0.3"
62
+ },
63
+ "peerDependencies": {
64
+ "@thomasfosterau/effect-jsonapi": ">=0.16.1",
65
+ "effect": ">=4.0.0-rc.118"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@thomasfosterau/effect-jsonapi": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "engines": {
73
+ "node": ">=22"
74
+ }
6
75
  }