@getstrata/core 0.5.60 → 0.5.61
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/core/http/contentSecurityPolicy.d.ts +27 -0
- package/dist/core/http/requestMetaContext.d.ts +1 -0
- package/dist/core/http/safeInternalPath.d.ts +4 -0
- package/dist/core/http/securedRouteModelBinding.d.ts +2 -0
- package/dist/core/http/securityHeadersMiddleware.d.ts +5 -2
- package/dist/core/http/webErrorResponse.d.ts +1 -1
- package/dist/core/view/etaViewEngine.d.ts +3 -2
- package/dist/core/view/htmlResponse.d.ts +1 -2
- package/dist/core/view/index.d.ts +4 -1
- package/dist/core/view/viewEngine.d.ts +1 -0
- package/dist/core/view/webErrorView.d.ts +21 -0
- package/dist/entries/http/contentSecurityPolicy.js +1 -0
- package/dist/entries/http/requireWebAuthMiddleware.js +34 -2
- package/dist/entries/http/response.js +127 -555
- package/dist/entries/http/safeInternalPath.js +38 -0
- package/dist/entries/http/securedRouteModelBinding.js +27 -5
- package/dist/entries/http/securityHeadersMiddleware.js +33 -60
- package/dist/entries/http/webErrorResponse.js +126 -554
- package/dist/entries/logging/requestLoggingMiddleware.js +2 -1
- package/dist/entries/view.js +88 -8
- package/dist/framework/public-api.d.ts +4 -1
- package/dist/index.js +589 -340
- package/package.json +12 -2
- package/dist/config/contentSecurityPolicy.d.ts +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @getstrata/core changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.61
|
|
4
|
+
|
|
5
|
+
HTMX HTML kernel gaps that sibling apps could not work around without weakening CSP or duplicating the framework.
|
|
6
|
+
|
|
7
|
+
- `createSecurityHeadersMiddleware({ csp | htmlCsp | directives })` and `configureContentSecurityPolicy()` extend the HTMX baseline. Defaults now allow YouTube/Vimeo embeds, `https:` media, `data:`/`https:` images, the HTMX 2.0.4 indicator style hash, and a per-request nonce on `script-src`/`style-src`. API JSON stays `default-src 'none'`. Do not add `'unsafe-inline'` to `script-src`.
|
|
8
|
+
- `configureWebErrorView` / `notFoundHtmlResponse()` render styled HTML 404/403/500 (app `errors/*.eta` or kernel chrome with `/assets/app.css`). Production 5xx messages do not leak stacks.
|
|
9
|
+
- `EtaViewEngine` passes `Request` into `resolveLayoutData`. `configureWebLayoutData({ loadUser })` receives that request as the second argument. Layout data includes `cspNonce`.
|
|
10
|
+
- `securedBindRouteModelByKey` treats a null model as `NotFoundError`. GET ETags are skipped for HTML and composite objects unless `etag: true`.
|
|
11
|
+
- Login redirects keep `pathname + search` after a same-origin safe-path check (`/forum?page=2` → `/login?redirect=%2Fforum%3Fpage%3D2`).
|
|
12
|
+
|
|
3
13
|
## 0.5.60
|
|
4
14
|
|
|
5
15
|
- `@getstrata/core/database/boundConnection` publishes `getBoundDatabaseConnection` and `resetBoundDatabaseConnection` on the JS entry (not only the types file). Tests can reset the bound pool after `closeDatabase()` without a postinstall patch.
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ import type { Migration } from "@getstrata/core/database/migrations/types";
|
|
|
82
82
|
Package name: **`@getstrata/core`** (npm org [`@getstrata`](https://www.npmjs.com/org/getstrata)).
|
|
83
83
|
|
|
84
84
|
1. Add `NPM_TOKEN` to GitHub repository secrets.
|
|
85
|
-
2. Tag a release: `git tag v0.5.
|
|
85
|
+
2. Tag a release: `git tag v0.5.62 && git push origin v0.5.62`
|
|
86
86
|
3. [Release workflow](../../.github/workflows/release.yml) builds and runs `npm publish --access public`.
|
|
87
87
|
|
|
88
88
|
Previously published as `@eyk-workhub/framework@0.1.0`, deprecated in favor of this package.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type CspDirectiveValue = string | string[] | false;
|
|
2
|
+
interface ContentSecurityPolicyDirectives {
|
|
3
|
+
[directive: string]: CspDirectiveValue;
|
|
4
|
+
}
|
|
5
|
+
interface ContentSecurityPolicyOptions {
|
|
6
|
+
/** Full HTML Content-Security-Policy. Replaces the HTMX/SPA baseline. */
|
|
7
|
+
csp?: string;
|
|
8
|
+
/** Alias for `csp`. */
|
|
9
|
+
htmlCsp?: string;
|
|
10
|
+
/** Merge extra sources into the HTML baseline. `false` removes a directive. */
|
|
11
|
+
directives?: ContentSecurityPolicyDirectives;
|
|
12
|
+
}
|
|
13
|
+
interface ResolveContentSecurityPolicyOptions extends ContentSecurityPolicyOptions {
|
|
14
|
+
nonce?: string;
|
|
15
|
+
}
|
|
16
|
+
/** HTMX 2.0.4 default indicator CSS (line-continued string inside insertIndicatorStyles). */
|
|
17
|
+
declare const HTMX_2_0_4_INDICATOR_STYLE_HASH = "'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='";
|
|
18
|
+
declare function generateCspNonce(): string;
|
|
19
|
+
declare function configureContentSecurityPolicy(options: ContentSecurityPolicyOptions): void;
|
|
20
|
+
declare function resetContentSecurityPolicyForTests(): void;
|
|
21
|
+
declare function strictApiContentSecurityPolicy(): string;
|
|
22
|
+
declare function serverHtmxContentSecurityPolicy(nonce?: string): string;
|
|
23
|
+
declare function spaContentSecurityPolicy(nonce?: string): string;
|
|
24
|
+
declare function resolveHtmlContentSecurityPolicy(options?: ResolveContentSecurityPolicyOptions): string;
|
|
25
|
+
declare function resolveContentSecurityPolicy(response: Response, options?: ResolveContentSecurityPolicyOptions): string;
|
|
26
|
+
export type { ContentSecurityPolicyDirectives, ContentSecurityPolicyOptions };
|
|
27
|
+
export { configureContentSecurityPolicy, generateCspNonce, HTMX_2_0_4_INDICATOR_STYLE_HASH, resetContentSecurityPolicyForTests, resolveContentSecurityPolicy, resolveHtmlContentSecurityPolicy, serverHtmxContentSecurityPolicy, spaContentSecurityPolicy, strictApiContentSecurityPolicy, };
|
|
@@ -7,6 +7,7 @@ type RequestMeta = {
|
|
|
7
7
|
message: string;
|
|
8
8
|
} | null;
|
|
9
9
|
csrfToken?: string;
|
|
10
|
+
cspNonce?: string;
|
|
10
11
|
};
|
|
11
12
|
declare const requestMetaContext: import("node:async_hooks").AsyncLocalStorage<RequestMeta>;
|
|
12
13
|
declare function runWithRequestMeta<T>(meta: RequestMeta, callback: () => T | Promise<T>): T | Promise<T>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
declare function sanitizeInternalPath(raw: string, fallback?: string): string;
|
|
2
|
+
declare function safeInternalRedirectPath(request: Request, fallback?: string): string;
|
|
3
|
+
declare function loginRedirectLocation(request: Request): string;
|
|
4
|
+
export { loginRedirectLocation, safeInternalRedirectPath, sanitizeInternalPath };
|
|
@@ -4,6 +4,8 @@ interface RouteModelAuthorization {
|
|
|
4
4
|
resource: string;
|
|
5
5
|
action: keyof Policy;
|
|
6
6
|
requireIfMatch?: boolean;
|
|
7
|
+
/** Opt in (`true`) or out (`false`) of GET ETags. HTML and composites are off by default. */
|
|
8
|
+
etag?: boolean;
|
|
7
9
|
}
|
|
8
10
|
declare function securedBindRouteModel<TParams extends Record<string, string>, TModel, TParam extends keyof TParams & string>(param: TParam, resolver: (id: number, request: RouteRequest<TParams>) => Promise<TModel>, authorization: RouteModelAuthorization, handler: (request: RouteRequest<TParams>, model: TModel) => Response | Promise<Response>): (request: RouteRequest<TParams>) => Promise<Response>;
|
|
9
11
|
declare function securedBindRouteModelByKey<TParams extends Record<string, string>, TModel, TParam extends keyof TParams & string>(param: TParam, resolver: (key: string, request: RouteRequest<TParams>) => Promise<TModel>, authorization: RouteModelAuthorization, handler: (request: RouteRequest<TParams>, model: TModel) => Response | Promise<Response>): (request: RouteRequest<TParams>) => Promise<Response>;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ContentSecurityPolicyOptions } from "@getstrata/core/http/contentSecurityPolicy";
|
|
2
|
+
import { configureContentSecurityPolicy } from "@getstrata/core/http/contentSecurityPolicy";
|
|
1
3
|
import type { Middleware } from "./middleware";
|
|
2
|
-
declare function createSecurityHeadersMiddleware(): Middleware;
|
|
3
|
-
export {
|
|
4
|
+
declare function createSecurityHeadersMiddleware(options?: ContentSecurityPolicyOptions): Middleware;
|
|
5
|
+
export type { ContentSecurityPolicyOptions };
|
|
6
|
+
export { configureContentSecurityPolicy, createSecurityHeadersMiddleware };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
type FieldErrors = Record<string, string[]>;
|
|
2
2
|
declare function normalizeFieldErrors(details: unknown): FieldErrors;
|
|
3
|
-
declare function webErrorResponse(error: unknown, request?: Request): Response | null
|
|
3
|
+
declare function webErrorResponse(error: unknown, request?: Request): Promise<Response | null>;
|
|
4
4
|
export type { FieldErrors };
|
|
5
5
|
export { normalizeFieldErrors, webErrorResponse };
|
|
@@ -3,8 +3,9 @@ declare const DEFAULT_VIEWS_DIRECTORY: string;
|
|
|
3
3
|
declare const DEFAULT_LAYOUT = "layouts/app.eta";
|
|
4
4
|
interface RenderOptions {
|
|
5
5
|
layout?: string | false;
|
|
6
|
+
request?: Request;
|
|
6
7
|
}
|
|
7
|
-
type LayoutDataResolver = () => Promise<Record<string, unknown>>;
|
|
8
|
+
type LayoutDataResolver = (request?: Request) => Promise<Record<string, unknown>>;
|
|
8
9
|
/**
|
|
9
10
|
* Renders `.eta` files as HTML + Eta tags (`<% %>`, `<%= %>`, `<%~ include() %>`).
|
|
10
11
|
* Pug class/attribute shorthand is rejected at render time.
|
|
@@ -15,5 +16,5 @@ declare class EtaViewEngine implements ViewEngine {
|
|
|
15
16
|
constructor(viewsDirectory?: string, resolveLayoutData?: LayoutDataResolver);
|
|
16
17
|
render(name: string, data?: Record<string, unknown>, options?: RenderOptions): Promise<string>;
|
|
17
18
|
}
|
|
18
|
-
export type { RenderOptions };
|
|
19
|
+
export type { LayoutDataResolver, RenderOptions };
|
|
19
20
|
export { DEFAULT_LAYOUT, DEFAULT_VIEWS_DIRECTORY, EtaViewEngine };
|
|
@@ -4,7 +4,6 @@ declare function htmlResponse(html: string, init?: {
|
|
|
4
4
|
}): Response;
|
|
5
5
|
declare function isHtmxRequest(request: Request): boolean;
|
|
6
6
|
declare function redirectResponse(location: string, status?: number): Response;
|
|
7
|
-
declare function notFoundHtmlResponse(body?: string): Response;
|
|
8
7
|
declare function textResponse(body: string, init?: {
|
|
9
8
|
status?: number;
|
|
10
9
|
}): Response;
|
|
@@ -15,4 +14,4 @@ declare function xmlResponse(body: string, init?: {
|
|
|
15
14
|
declare function rssResponse(body: string, init?: {
|
|
16
15
|
status?: number;
|
|
17
16
|
}): Response;
|
|
18
|
-
export { htmlResponse, isHtmxRequest,
|
|
17
|
+
export { htmlResponse, isHtmxRequest, redirectResponse, rssResponse, textResponse, xmlResponse };
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
export type { LayoutDataResolver } from "./etaViewEngine";
|
|
1
2
|
export { DEFAULT_VIEWS_DIRECTORY, EtaViewEngine } from "./etaViewEngine";
|
|
2
|
-
export { htmlResponse, isHtmxRequest,
|
|
3
|
+
export { htmlResponse, isHtmxRequest, redirectResponse, rssResponse, textResponse, xmlResponse, } from "./htmlResponse";
|
|
3
4
|
export type { ViewEngine } from "./viewEngine";
|
|
5
|
+
export type { WebErrorViewInput, WebErrorViewOptions } from "./webErrorView";
|
|
6
|
+
export { configureWebErrorView, errorTemplateName, htmlErrorResponse, notFoundHtmlResponse, renderKernelErrorChrome, renderWebErrorHtml, } from "./webErrorView";
|
|
4
7
|
export type { WebLayoutAuthUser, WebLayoutData, WebLayoutDataOptions, WebLayoutUserKey, } from "./webLayoutData";
|
|
5
8
|
export { configureWebLayoutData, resolveWebLayoutData } from "./webLayoutData";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface WebErrorViewInput {
|
|
2
|
+
status: number;
|
|
3
|
+
title: string;
|
|
4
|
+
message: string;
|
|
5
|
+
request?: Request;
|
|
6
|
+
errors?: Record<string, string[]>;
|
|
7
|
+
}
|
|
8
|
+
interface WebErrorViewOptions {
|
|
9
|
+
render?: (input: WebErrorViewInput) => Promise<string> | string;
|
|
10
|
+
}
|
|
11
|
+
declare function configureWebErrorView(options: WebErrorViewOptions): void;
|
|
12
|
+
declare function resetWebErrorViewForTests(): void;
|
|
13
|
+
declare function renderKernelErrorChrome(input: WebErrorViewInput): string;
|
|
14
|
+
declare function errorTemplateName(status: number): string;
|
|
15
|
+
declare function renderWebErrorHtml(input: WebErrorViewInput): Promise<string>;
|
|
16
|
+
declare function htmlErrorResponse(input: WebErrorViewInput & {
|
|
17
|
+
status: number;
|
|
18
|
+
}): Promise<Response>;
|
|
19
|
+
declare function notFoundHtmlResponse(body?: string): Promise<Response>;
|
|
20
|
+
export type { WebErrorViewInput, WebErrorViewOptions };
|
|
21
|
+
export { configureWebErrorView, errorTemplateName, htmlErrorResponse, notFoundHtmlResponse, renderKernelErrorChrome, renderWebErrorHtml, resetWebErrorViewForTests, };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../../index.js";
|
|
@@ -25,6 +25,39 @@ function requestPrefersJson(request) {
|
|
|
25
25
|
return pathname.startsWith("/api/");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ../../src/core/http/safeInternalPath.ts
|
|
29
|
+
function looksLikeExternalTarget(value) {
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const decoded = decodeURIComponent(trimmed);
|
|
39
|
+
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function sanitizeInternalPath(raw, fallback = "/") {
|
|
48
|
+
if (looksLikeExternalTarget(raw)) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
function safeInternalRedirectPath(request, fallback = "/") {
|
|
54
|
+
const url = new URL(request.url);
|
|
55
|
+
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
56
|
+
}
|
|
57
|
+
function loginRedirectLocation(request) {
|
|
58
|
+
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
// ../../src/core/http/requireWebAuthMiddleware.ts
|
|
29
62
|
function createRequireWebAuthMiddleware(auth) {
|
|
30
63
|
return async (request, next) => {
|
|
@@ -35,8 +68,7 @@ function createRequireWebAuthMiddleware(auth) {
|
|
|
35
68
|
if (requestPrefersJson(request)) {
|
|
36
69
|
throw new UnauthorizedError;
|
|
37
70
|
}
|
|
38
|
-
|
|
39
|
-
return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
|
|
71
|
+
return Response.redirect(loginRedirectLocation(request), 302);
|
|
40
72
|
};
|
|
41
73
|
}
|
|
42
74
|
export {
|