@getstrata/core 0.5.59 → 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 +17 -0
- package/README.md +2 -2
- package/dist/core/database/bindConnection.d.ts +1 -3
- package/dist/core/database/bunSql.d.ts +13 -0
- package/dist/core/http/contentSecurityPolicy.d.ts +27 -0
- package/dist/core/http/memoryThrottleMiddleware.d.ts +2 -1
- 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 +5 -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/database/bunSql.js +1 -0
- package/dist/entries/http/contentSecurityPolicy.js +1 -0
- package/dist/entries/http/memoryThrottleMiddleware.js +10 -2
- package/dist/entries/http/requireWebAuthMiddleware.js +34 -2
- package/dist/entries/http/response.js +133 -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 +132 -554
- package/dist/entries/logging/requestLoggingMiddleware.js +2 -1
- package/dist/entries/view.js +96 -9
- package/dist/framework/public-api.d.ts +7 -3
- package/dist/index.js +665 -384
- package/package.json +17 -2
- package/dist/config/contentSecurityPolicy.d.ts +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.5.60
|
|
14
|
+
|
|
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.
|
|
16
|
+
- `bindBunSql` / `createBunSqlPool` on `@getstrata/core/database/bunSql` create a Bun `SQL` pool and register it as the bound connection plus default pool.
|
|
17
|
+
- `xmlResponse` accepts `{ contentType }`. `rssResponse` sends `application/rss+xml`.
|
|
18
|
+
- Memory throttle buckets are per middleware instance. `resetMemoryThrottleForTests()` clears every instance map so nested HTTP tests do not leak.
|
|
19
|
+
|
|
3
20
|
## 0.5.59
|
|
4
21
|
|
|
5
22
|
Sibling HTMX apps can consume published packages without WorkHub-only glue.
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ import { EtaViewEngine } from "@getstrata/core/view";
|
|
|
28
28
|
|
|
29
29
|
`.eta` files are **HTML + Eta tags** (`<% %>`, `<%= %>`, `<%~ include() %>`), not Pug. Class/attribute shorthand such as `section.section` or `a href=` fails at render time with the template name.
|
|
30
30
|
|
|
31
|
-
**Dependency:** `eta` is bundled as a direct dependency of `@getstrata/core`. Apps do not need to list it separately. The database driver is your app's choice. WorkHub and getstrata use **Bun's built-in `Bun.sql`** client; bind it with `bindDatabaseConnection()
|
|
31
|
+
**Dependency:** `eta` is bundled as a direct dependency of `@getstrata/core`. Apps do not need to list it separately. The database driver is your app's choice. WorkHub and getstrata use **Bun's built-in `Bun.sql`** client; create and bind it with `createBunSqlPool()` / `bindBunSql()`, or call `bindDatabaseConnection()` yourself.
|
|
32
32
|
|
|
33
33
|
`orderBy` accepts explicit `{ column, direction }` objects or column shorthand such as `{ published_at: "desc" }`.
|
|
34
34
|
|
|
@@ -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.
|
|
@@ -1,3 +1 @@
|
|
|
1
|
-
|
|
2
|
-
declare function bindDatabaseConnection(connection: DatabaseConnection): void;
|
|
3
|
-
export { bindDatabaseConnection };
|
|
1
|
+
export { bindDatabaseConnection, getBoundDatabaseConnection, resetBoundDatabaseConnection, } from "./boundConnection";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import type { DatabaseConnection } from "./baseRepository";
|
|
3
|
+
interface CreateBunSqlPoolOptions {
|
|
4
|
+
url: string;
|
|
5
|
+
max?: number;
|
|
6
|
+
idleTimeout?: number;
|
|
7
|
+
maxLifetime?: number;
|
|
8
|
+
connectionTimeout?: number;
|
|
9
|
+
}
|
|
10
|
+
declare function createBunSqlPool(options: CreateBunSqlPoolOptions): InstanceType<typeof SQL>;
|
|
11
|
+
declare function bindBunSql(sql: DatabaseConnection): DatabaseConnection;
|
|
12
|
+
export type { CreateBunSqlPoolOptions };
|
|
13
|
+
export { bindBunSql, createBunSqlPool };
|
|
@@ -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, };
|
|
@@ -5,5 +5,6 @@ interface MemoryThrottleOptions {
|
|
|
5
5
|
keyPrefix?: string;
|
|
6
6
|
}
|
|
7
7
|
declare function createMemoryThrottleMiddleware(options: MemoryThrottleOptions): Middleware;
|
|
8
|
+
declare function resetMemoryThrottleForTests(): void;
|
|
8
9
|
export type { MemoryThrottleOptions };
|
|
9
|
-
export { createMemoryThrottleMiddleware };
|
|
10
|
+
export { createMemoryThrottleMiddleware, resetMemoryThrottleForTests };
|
|
@@ -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,11 +4,14 @@ 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;
|
|
11
10
|
declare function xmlResponse(body: string, init?: {
|
|
12
11
|
status?: number;
|
|
12
|
+
contentType?: string;
|
|
13
13
|
}): Response;
|
|
14
|
-
|
|
14
|
+
declare function rssResponse(body: string, init?: {
|
|
15
|
+
status?: number;
|
|
16
|
+
}): Response;
|
|
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";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../../index.js";
|
|
@@ -15,9 +15,11 @@ function readClientIp(request, env = process.env) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// ../../src/core/http/memoryThrottleMiddleware.ts
|
|
18
|
-
var
|
|
18
|
+
var throttleBucketRegistries = new Set;
|
|
19
19
|
function createMemoryThrottleMiddleware(options) {
|
|
20
20
|
const prefix = options.keyPrefix ?? "workhub:memory-throttle:";
|
|
21
|
+
const buckets = new Map;
|
|
22
|
+
throttleBucketRegistries.add(buckets);
|
|
21
23
|
return async (request, next) => {
|
|
22
24
|
const path = new URL(request.url).pathname;
|
|
23
25
|
const identity = readClientIp(request) ?? request.headers.get("authorization")?.slice(0, 32) ?? "unknown";
|
|
@@ -40,6 +42,12 @@ function createMemoryThrottleMiddleware(options) {
|
|
|
40
42
|
return await next();
|
|
41
43
|
};
|
|
42
44
|
}
|
|
45
|
+
function resetMemoryThrottleForTests() {
|
|
46
|
+
for (const buckets of throttleBucketRegistries) {
|
|
47
|
+
buckets.clear();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
43
50
|
export {
|
|
44
|
-
createMemoryThrottleMiddleware
|
|
51
|
+
createMemoryThrottleMiddleware,
|
|
52
|
+
resetMemoryThrottleForTests
|
|
45
53
|
};
|
|
@@ -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 {
|