@quatronic/sdk 0.2.1 → 0.2.2-canary-20260917103453
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 +16 -0
- package/dist/chunk-2HE2D4SY.js +43 -0
- package/dist/chunk-2HE2D4SY.js.map +1 -0
- package/dist/{chunk-HKL5OGAF.js → chunk-4YNIBRDA.js} +14 -3
- package/dist/chunk-4YNIBRDA.js.map +1 -0
- package/dist/{chunk-JIB5TDR6.js → chunk-DAZOIUP6.js} +1 -1
- package/dist/chunk-DAZOIUP6.js.map +1 -0
- package/dist/chunk-FUPTRHT7.js +67 -0
- package/dist/chunk-FUPTRHT7.js.map +1 -0
- package/dist/{chunk-3VQW3FDC.js → chunk-UFYZP2E7.js} +34 -4
- package/dist/chunk-UFYZP2E7.js.map +1 -0
- package/dist/chunk-X6D3TBNS.js +20 -0
- package/dist/chunk-X6D3TBNS.js.map +1 -0
- package/dist/eslint/index.d.ts +28 -0
- package/dist/eslint/index.js +248 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/next/auth/index.d.ts +11 -1
- package/dist/next/auth/index.js +30 -22
- package/dist/next/auth/index.js.map +1 -1
- package/dist/next/config/index.js +19 -0
- package/dist/next/config/index.js.map +1 -1
- package/dist/next/connections/index.d.ts +1 -1
- package/dist/next/connections/index.js +2 -2
- package/dist/next/observability/register.js +1 -1
- package/dist/next/security/index.d.ts +152 -0
- package/dist/next/security/index.js +168 -0
- package/dist/next/security/index.js.map +1 -0
- package/dist/next/security/testing.d.ts +57 -0
- package/dist/next/security/testing.js +126 -0
- package/dist/next/security/testing.js.map +1 -0
- package/dist/node/connections/index.js +2 -2
- package/dist/node/fspath/index.d.ts +69 -0
- package/dist/node/fspath/index.js +181 -0
- package/dist/node/fspath/index.js.map +1 -0
- package/dist/node/identity/index.js +1 -1
- package/dist/node/net/index.d.ts +82 -0
- package/dist/node/net/index.js +401 -0
- package/dist/node/net/index.js.map +1 -0
- package/dist/node/observability/register.js +1 -1
- package/dist/node/parse/index.d.ts +27 -0
- package/dist/node/parse/index.js +11 -0
- package/dist/node/parse/index.js.map +1 -0
- package/package.json +33 -3
- package/semgrep/qdc-python.yaml +169 -0
- package/semgrep/qdc-web.yaml +512 -0
- package/dist/chunk-3VQW3FDC.js.map +0 -1
- package/dist/chunk-HKL5OGAF.js.map +0 -1
- package/dist/chunk-JIB5TDR6.js.map +0 -1
package/README.md
CHANGED
|
@@ -7,6 +7,22 @@ Single-package SDK for QDC tenant apps. Exposes:
|
|
|
7
7
|
|
|
8
8
|
See `docs/superpowers/specs/2026-05-26-per-app-bff-architecture-design.md`.
|
|
9
9
|
|
|
10
|
+
## Security primitives
|
|
11
|
+
|
|
12
|
+
`safeRedirect`, `withSecurityHeaders`, `safeFetch`, `safeJoin`, `safeFilename`,
|
|
13
|
+
`assertUpload` and `parseJsonBounded` cover the operations that produce most web
|
|
14
|
+
vulnerabilities — each one has a short unsafe version that looks correct. What
|
|
15
|
+
each does, and what it deliberately does not do:
|
|
16
|
+
[`docs/security-primitives.md`](docs/security-primitives.md).
|
|
17
|
+
|
|
18
|
+
An app's whole security wiring also lives here, so a fix reaches every app with
|
|
19
|
+
a version bump instead of a patch per repo: `withSecurityHeaders` and
|
|
20
|
+
`createCspReportRoute` for the proxy and the violation endpoint,
|
|
21
|
+
`@quatronic/sdk/next/security/testing`'s `securityHeaderChecks` for the test the
|
|
22
|
+
app runs against its own proxy, and `@quatronic/sdk/eslint` for the twelve
|
|
23
|
+
`qdc/*` lint rules —
|
|
24
|
+
[`docs/security-rules.md`](docs/security-rules.md).
|
|
25
|
+
|
|
10
26
|
## Permissions
|
|
11
27
|
|
|
12
28
|
The SDK ships a hybrid role/permission layer. Roles come from the Keycloak JWT;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/security-core/headers.ts
|
|
2
|
+
var STATIC_SECURITY_HEADERS = [
|
|
3
|
+
// Two years, subdomains included, eligible for the preload list. Long enough
|
|
4
|
+
// to be worth preloading; the app is HTTPS-only behind Northflank anyway.
|
|
5
|
+
["Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload"],
|
|
6
|
+
// Refuse to execute a response whose declared type does not match its use —
|
|
7
|
+
// closes the "upload a .txt that sniffs as HTML" path.
|
|
8
|
+
["X-Content-Type-Options", "nosniff"],
|
|
9
|
+
// Send the full URL only same-origin; cross-origin gets the bare origin, and
|
|
10
|
+
// HTTPS→HTTP gets nothing.
|
|
11
|
+
["Referrer-Policy", "strict-origin-when-cross-origin"],
|
|
12
|
+
// Clickjacking. `frame-ancestors` in the CSP is the modern control; this is
|
|
13
|
+
// the fallback for anything that still only reads the legacy header.
|
|
14
|
+
["X-Frame-Options", "DENY"],
|
|
15
|
+
// Powerful features off by default. An app that needs one opts back in.
|
|
16
|
+
//
|
|
17
|
+
// `payment=()` is deliberately not in this list. It disables the Payment
|
|
18
|
+
// Request API, which is what Apple Pay and Google Pay use in the browser, and
|
|
19
|
+
// an app taking payments is an ordinary case rather than an exotic one. A
|
|
20
|
+
// default that has to be overridden on a common path teaches people to
|
|
21
|
+
// override the header, which costs more than the directive buys.
|
|
22
|
+
["Permissions-Policy", "camera=(), microphone=(), geolocation=(), usb=()"],
|
|
23
|
+
// Stop another origin getting a scriptable handle on this window.
|
|
24
|
+
//
|
|
25
|
+
// `-allow-popups` rather than plain `same-origin`: both settings block that,
|
|
26
|
+
// but plain `same-origin` also severs the opener reference for popups *we*
|
|
27
|
+
// open, which breaks every "popup authorises, then postMessages the result
|
|
28
|
+
// back" flow — connect-your-Google-Drive is the everyday example. What it
|
|
29
|
+
// buys in exchange is `crossOriginIsolated`, i.e. SharedArrayBuffer and
|
|
30
|
+
// high-resolution timers, which a business app has no use for.
|
|
31
|
+
//
|
|
32
|
+
// Neither setting touches redirects: COOP governs window references, not
|
|
33
|
+
// navigation, so an ordinary OAuth redirect flow is unaffected either way.
|
|
34
|
+
["Cross-Origin-Opener-Policy", "same-origin-allow-popups"],
|
|
35
|
+
// Refuse to be loaded as a subresource by another origin (Spectre-class
|
|
36
|
+
// side-channel hardening).
|
|
37
|
+
["Cross-Origin-Resource-Policy", "same-origin"]
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
STATIC_SECURITY_HEADERS
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=chunk-2HE2D4SY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/security-core/headers.ts"],"sourcesContent":["// The response headers a QDC app sends regardless of the request. Defined once\n// here because two places emit them: `withQdcConfig().headers()` at build time\n// (every route Next serves) and `withSecurityHeaders` at request time (also the\n// redirects a proxy issues, which never reach a Next route and so never pick up\n// the build-time set).\n//\n// `X-XSS-Protection` is deliberately absent. It is deprecated, every current\n// browser ignores it, and its legacy filter introduced vulnerabilities of its\n// own — sending it is worse than sending nothing.\nexport const STATIC_SECURITY_HEADERS: ReadonlyArray<readonly [string, string]> = [\n // Two years, subdomains included, eligible for the preload list. Long enough\n // to be worth preloading; the app is HTTPS-only behind Northflank anyway.\n [\"Strict-Transport-Security\", \"max-age=63072000; includeSubDomains; preload\"],\n // Refuse to execute a response whose declared type does not match its use —\n // closes the \"upload a .txt that sniffs as HTML\" path.\n [\"X-Content-Type-Options\", \"nosniff\"],\n // Send the full URL only same-origin; cross-origin gets the bare origin, and\n // HTTPS→HTTP gets nothing.\n [\"Referrer-Policy\", \"strict-origin-when-cross-origin\"],\n // Clickjacking. `frame-ancestors` in the CSP is the modern control; this is\n // the fallback for anything that still only reads the legacy header.\n [\"X-Frame-Options\", \"DENY\"],\n // Powerful features off by default. An app that needs one opts back in.\n //\n // `payment=()` is deliberately not in this list. It disables the Payment\n // Request API, which is what Apple Pay and Google Pay use in the browser, and\n // an app taking payments is an ordinary case rather than an exotic one. A\n // default that has to be overridden on a common path teaches people to\n // override the header, which costs more than the directive buys.\n [\"Permissions-Policy\", \"camera=(), microphone=(), geolocation=(), usb=()\"],\n // Stop another origin getting a scriptable handle on this window.\n //\n // `-allow-popups` rather than plain `same-origin`: both settings block that,\n // but plain `same-origin` also severs the opener reference for popups *we*\n // open, which breaks every \"popup authorises, then postMessages the result\n // back\" flow — connect-your-Google-Drive is the everyday example. What it\n // buys in exchange is `crossOriginIsolated`, i.e. SharedArrayBuffer and\n // high-resolution timers, which a business app has no use for.\n //\n // Neither setting touches redirects: COOP governs window references, not\n // navigation, so an ordinary OAuth redirect flow is unaffected either way.\n [\"Cross-Origin-Opener-Policy\", \"same-origin-allow-popups\"],\n // Refuse to be loaded as a subresource by another origin (Spectre-class\n // side-channel hardening).\n [\"Cross-Origin-Resource-Policy\", \"same-origin\"],\n]\n"],"mappings":";AASO,IAAM,0BAAoE;AAAA;AAAA;AAAA,EAG/E,CAAC,6BAA6B,8CAA8C;AAAA;AAAA;AAAA,EAG5E,CAAC,0BAA0B,SAAS;AAAA;AAAA;AAAA,EAGpC,CAAC,mBAAmB,iCAAiC;AAAA;AAAA;AAAA,EAGrD,CAAC,mBAAmB,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQ1B,CAAC,sBAAsB,kDAAkD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYzE,CAAC,8BAA8B,0BAA0B;AAAA;AAAA;AAAA,EAGzD,CAAC,gCAAgC,aAAa;AAChD;","names":[]}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
connKey,
|
|
7
7
|
defaultEnv,
|
|
8
8
|
readConn
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-DAZOIUP6.js";
|
|
10
10
|
|
|
11
11
|
// src/connections-core/connection.ts
|
|
12
12
|
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
|
@@ -28,7 +28,18 @@ function connection(alias, opts = {}) {
|
|
|
28
28
|
const base = /^https?:\/\//i.test(rawBase) ? rawBase : `http://${rawBase}`;
|
|
29
29
|
const token = readConn(env, alias, "TOKEN");
|
|
30
30
|
const { timeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS, signal: caller, headers, ...rest } = init;
|
|
31
|
-
const
|
|
31
|
+
const absolute = /^https?:\/\//i.test(path);
|
|
32
|
+
if (absolute) {
|
|
33
|
+
console.warn(
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
level: "warn",
|
|
36
|
+
event: "qdc.connection.absolute_url",
|
|
37
|
+
alias,
|
|
38
|
+
message: "absolute URL passed to connection().fetch; this will throw in the next minor release"
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const url = absolute ? path : base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
|
|
32
43
|
const controller = new AbortController();
|
|
33
44
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
34
45
|
if (caller) {
|
|
@@ -126,4 +137,4 @@ export {
|
|
|
126
137
|
assertConnections,
|
|
127
138
|
assertInboundConnection
|
|
128
139
|
};
|
|
129
|
-
//# sourceMappingURL=chunk-
|
|
140
|
+
//# sourceMappingURL=chunk-4YNIBRDA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/connections-core/connection.ts","../src/connections-core/assertConnection.ts"],"sourcesContent":["import { SpanStatusCode, trace } from \"@opentelemetry/api\"\nimport { ConnectionError } from \"./errors\"\nimport { CONNECTION_TOKEN_HEADER, DEFAULT_CONNECTION_TIMEOUT_MS } from \"./constants\"\nimport { connKey, readConn, defaultEnv, type Env } from \"./env\"\n\nexport interface PostgresMaterial {\n uri: string\n host?: string\n port?: string\n user?: string\n password?: string\n db?: string\n}\nexport interface RedisMaterial {\n url: string\n}\nexport interface S3Material {\n endpoint: string\n bucket: string\n accessKey: string\n secretKey: string\n}\n\nexport interface ConnectionHandle {\n fetch(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<Response>\n postgres(): PostgresMaterial\n redis(): RedisMaterial\n s3(): S3Material\n}\n\nexport function connection(alias: string, opts: { env?: Env } = {}): ConnectionHandle {\n const env = opts.env ?? defaultEnv()\n\n function required(suffix: string, code: \"not_wired\" | \"missing_material\"): string {\n const value = readConn(env, alias, suffix)\n if (value === undefined || value === \"\") {\n throw new ConnectionError(code, {\n message: `Connection \"${alias}\" is missing ${connKey(alias, suffix)}`,\n status: 500,\n })\n }\n return value\n }\n\n return {\n async fetch(path, init = {}) {\n const rawBase = required(\"URL\", \"not_wired\")\n // Module-service connections carry a bare host:port (NF internal DNS,\n // always plain HTTP) — portal-api never prefixes a scheme.\n const base = /^https?:\\/\\//i.test(rawBase) ? rawBase : `http://${rawBase}`\n const token = readConn(env, alias, \"TOKEN\")\n const { timeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS, signal: caller, headers, ...rest } = init\n\n // An absolute `path` ignores the wired base entirely, so a caller-supplied\n // one turns connection().fetch into an arbitrary-request primitive that\n // carries the connection token to whatever host it names. A\n // protocol-relative \"//evil.example\" is not affected: it does not match\n // here, and the join below strips its leading slashes.\n //\n // Warned in this release, refused in the next minor — see \"absolute_url\"\n // in errors.ts. Passing a whole URL is a thing working code does today,\n // so throwing without a release of warning would break apps at an upgrade\n // they had no reason to read the notes for.\n const absolute = /^https?:\\/\\//i.test(path)\n if (absolute) {\n // Edge-safe structured log, same shape as requireConnection's rejection.\n console.warn(\n JSON.stringify({\n level: \"warn\",\n event: \"qdc.connection.absolute_url\",\n alias,\n message:\n \"absolute URL passed to connection().fetch; this will throw in the next minor release\",\n }),\n )\n }\n\n const url = absolute ? path : base.replace(/\\/+$/, \"\") + \"/\" + path.replace(/^\\/+/, \"\")\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n if (caller) {\n if (caller.aborted) controller.abort()\n else caller.addEventListener(\"abort\", () => controller.abort(), { once: true })\n }\n\n const mergedHeaders = new Headers(headers)\n if (token) mergedHeaders.set(CONNECTION_TOKEN_HEADER, token)\n\n const tracer = trace.getTracer(\"@quatronic/sdk\")\n // Deterministic portal naming: the portal maps qdc.connection.alias to the\n // provider module/resource instead of reverse-guessing NF hostnames. Auto\n // http/undici child spans nest under this one and fold into it at render.\n return tracer.startActiveSpan(\n `→ ${alias}`,\n { attributes: { \"qdc.connection.alias\": alias, \"qdc.span.type\": \"outbound_call\" } },\n async (span) => {\n try {\n const res = await fetch(url, { ...rest, headers: mergedHeaders, signal: controller.signal })\n if (res.status >= 500) {\n span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${res.status}` })\n }\n return res\n } catch (err) {\n span.recordException(err as Error)\n span.setStatus({ code: SpanStatusCode.ERROR })\n throw err\n } finally {\n span.end()\n clearTimeout(timer)\n }\n },\n )\n },\n\n postgres() {\n const material: PostgresMaterial = { uri: required(\"URI\", \"missing_material\") }\n const host = readConn(env, alias, \"HOST\")\n if (host) material.host = host\n const port = readConn(env, alias, \"PORT\")\n if (port) material.port = port\n const user = readConn(env, alias, \"USER\")\n if (user) material.user = user\n const password = readConn(env, alias, \"PASSWORD\")\n if (password) material.password = password\n const db = readConn(env, alias, \"DB\")\n if (db) material.db = db\n return material\n },\n\n redis() {\n return { url: required(\"URL\", \"missing_material\") }\n },\n\n s3() {\n return {\n endpoint: required(\"ENDPOINT\", \"missing_material\"),\n bucket: required(\"BUCKET\", \"missing_material\"),\n accessKey: required(\"ACCESS_KEY\", \"missing_material\"),\n secretKey: required(\"SECRET_KEY\", \"missing_material\"),\n }\n },\n }\n}\n","import { ConnectionError } from \"./errors\"\nimport { collectConnectionTokens, connKey, readConn, defaultEnv, type Env } from \"./env\"\n\nexport type ConnectionKind = \"service\" | \"postgres\" | \"redis\" | \"s3\"\n\nconst REQUIRED_SUFFIXES: Record<ConnectionKind, string[]> = {\n service: [\"URL\"],\n postgres: [\"URI\"],\n redis: [\"URL\"],\n s3: [\"ENDPOINT\", \"BUCKET\", \"ACCESS_KEY\", \"SECRET_KEY\"],\n}\n\n/**\n * Fail-fast at app startup: a module that declares a connection but is missing\n * its CONN_<ALIAS>_* env crashes with a clear message instead of a mystery 500\n * on first use.\n */\nexport function assertConnection(\n alias: string,\n kind: ConnectionKind,\n opts: { env?: Env } = {},\n): void {\n const env = opts.env ?? defaultEnv()\n const missing = REQUIRED_SUFFIXES[kind]\n .filter((suffix) => {\n const value = readConn(env, alias, suffix)\n return value === undefined || value === \"\"\n })\n .map((suffix) => connKey(alias, suffix))\n\n if (missing.length > 0) {\n throw new ConnectionError(\"missing_material\", {\n message: `Connection \"${alias}\" (${kind}) is not wired: missing ${missing.join(\", \")}`,\n status: 500,\n })\n }\n}\n\n/** Batch form — validate several declared connections at startup. */\nexport function assertConnections(\n specs: Array<{ alias: string; kind: ConnectionKind }>,\n opts: { env?: Env } = {},\n): void {\n for (const { alias, kind } of specs) assertConnection(alias, kind, opts)\n}\n\n/**\n * Provider-side fail-fast at startup: a module that guards incoming requests\n * with `requireConnection` needs at least one `CONN_*_TOKEN` injected. Unlike a\n * consumer it does not know its callers' aliases, so `assertConnection` (which\n * is alias-specific) does not apply. This checks — alias-agnostically — that\n * *some* inbound token is wired.\n *\n * Use this instead of probing `requireConnection` with a tokenless `Request`:\n * that probe emits a misleading `qdc.connection.rejected` / `missing_token`\n * warning on every healthy boot (the guard always logs before it throws),\n * whereas this reads the env directly and stays silent unless nothing is wired.\n */\nexport function assertInboundConnection(opts: { env?: Env } = {}): void {\n const env = opts.env ?? defaultEnv()\n if (collectConnectionTokens(env).length === 0) {\n throw new ConnectionError(\"not_wired\", {\n message: \"No CONN_*_TOKEN configured — no connection is wired to this module.\",\n status: 500,\n })\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/connections-core/errors.ts","../src/connections-core/constants.ts","../src/connections-core/requireConnection.ts","../src/connections-core/env.ts"],"sourcesContent":["/** Why a connection guard / client / assertion rejected. */\nexport type ConnectionErrorCode =\n | \"not_wired\" // no CONN_*_TOKEN in env (guard) or missing CONN_<ALIAS>_URL (client)\n | \"missing_token\" // X-QDC-Connection-Token header absent (guard)\n | \"invalid_token\" // incoming token matched no candidate (guard)\n | \"missing_material\" // a required CONN_<ALIAS>_* resource key is absent\n | \"absolute_url\" // connection().fetch was given an absolute URL, bypassing the wired base\n\n/**\n * Single error shape for the connections subpath, mirroring AuthError in\n * next/auth. `status` defaults to 401 (guard rejections, which a route maps to\n * a 401); config-shaped errors pass `status: 500`.\n */\nexport class ConnectionError extends Error {\n readonly code: ConnectionErrorCode\n readonly status: number\n\n constructor(code: ConnectionErrorCode, opts?: { message?: string; status?: number }) {\n super(opts?.message ?? code)\n this.name = \"ConnectionError\"\n this.code = code\n this.status = opts?.status ?? 401\n }\n}\n","/** Header carrying the per-connection shared-secret token. Fixed in v1. */\nexport const CONNECTION_TOKEN_HEADER = \"X-QDC-Connection-Token\"\n\n/** Default request timeout for connection().fetch, overridable per call. */\nexport const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000\n\n/** OTel span attribute set to `true` when the guard rejects a request. */\nexport const CONNECTION_REJECTED_ATTR = \"qdc.connection.rejected\"\n\n/** OTel span attribute carrying the rejection reason code. */\nexport const CONNECTION_REJECT_REASON_ATTR = \"qdc.connection.reject_reason\"\n","import { trace } from \"@opentelemetry/api\"\nimport { ConnectionError, type ConnectionErrorCode } from \"./errors\"\nimport {\n CONNECTION_TOKEN_HEADER,\n CONNECTION_REJECTED_ATTR,\n CONNECTION_REJECT_REASON_ATTR,\n} from \"./constants\"\nimport { collectConnectionTokens, defaultEnv, type Env } from \"./env\"\n\n/**\n * Provider-side guard. Reads CONN_*_TOKEN from its own env and validates the\n * incoming X-QDC-Connection-Token against all of them in constant time.\n * Returns the matched <ALIAS>; throws ConnectionError otherwise.\n *\n * Edge-capable: pure Web Crypto + header/env reads. No Node-only crypto, no\n * Node logger. Async because crypto.subtle is async.\n */\nexport async function requireConnection(\n req: Request,\n opts: { env?: Env } = {},\n): Promise<string> {\n const env = opts.env ?? defaultEnv()\n const candidates = collectConnectionTokens(env)\n const incoming = req.headers.get(CONNECTION_TOKEN_HEADER) ?? undefined\n\n if (candidates.length === 0) reject(\"not_wired\")\n if (incoming === undefined || incoming === \"\") reject(\"missing_token\")\n\n const alias = await matchAny(incoming as string, candidates)\n if (alias === null) reject(\"invalid_token\")\n return alias as string\n}\n\nfunction reject(code: ConnectionErrorCode): never {\n const span = trace.getActiveSpan()\n if (span) {\n span.setAttribute(CONNECTION_REJECTED_ATTR, true)\n span.setAttribute(CONNECTION_REJECT_REASON_ATTR, code)\n }\n // Edge-safe structured log — node logger is Node-only and must not be imported here.\n console.warn(JSON.stringify({ level: \"warn\", event: \"qdc.connection.rejected\", code }))\n throw new ConnectionError(code)\n}\n\n/**\n * Constant-time match of `incoming` against every candidate token, no early\n * exit. Uses a per-call random HMAC key (double-HMAC pattern) so the byte\n * comparison runs over fixed-length (32-byte) digests regardless of input\n * length. Empty set → null (caller fails closed before reaching here).\n */\nasync function matchAny(\n incoming: string,\n candidates: Array<{ alias: string; token: string }>,\n): Promise<string | null> {\n if (candidates.length === 0) return null\n const enc = new TextEncoder()\n const key = await crypto.subtle.generateKey({ name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"])\n const incomingDigest = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, enc.encode(incoming)))\n\n let matched: string | null = null\n for (const { alias, token } of candidates) {\n const candDigest = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, enc.encode(token)))\n let diff = incomingDigest.length ^ candDigest.length\n for (let i = 0; i < incomingDigest.length; i++) {\n diff |= (incomingDigest[i] ?? 0) ^ (candDigest[i] ?? 0)\n }\n if (diff === 0) matched = alias // no break — constant pass over the whole set\n }\n return matched\n}\n","export type Env = Record<string, string | undefined>\n\nconst TOKEN_KEY_RE = /^CONN_(.+)_TOKEN$/\n\n/**\n * Mirror of portal-api connection_material.alias_for_provider:\n * uppercase, every run of non-[A-Z0-9] → single \"_\", strip leading/trailing \"_\".\n */\nexport function normalizeAlias(alias: string): string {\n return alias\n .toUpperCase()\n .replace(/[^A-Z0-9]+/g, \"_\")\n .replace(/^_+|_+$/g, \"\")\n}\n\nexport function connKey(alias: string, suffix: string): string {\n return `CONN_${normalizeAlias(alias)}_${suffix}`\n}\n\nexport function readConn(env: Env, alias: string, suffix: string): string | undefined {\n return env[connKey(alias, suffix)]\n}\n\n/**\n * Every CONN_*_TOKEN in env, with its <ALIAS> (already normalized — it comes\n * straight out of the env key). Empty values are ignored.\n */\nexport function collectConnectionTokens(env: Env): Array<{ alias: string; token: string }> {\n const out: Array<{ alias: string; token: string }> = []\n for (const [key, value] of Object.entries(env)) {\n if (value == null || value === \"\") continue\n const m = TOKEN_KEY_RE.exec(key)\n if (m && m[1]) out.push({ alias: m[1], token: value })\n }\n return out\n}\n\n/** process.env, narrowed. Used when the caller does not inject opts.env. */\nexport function defaultEnv(): Env {\n return process.env as Env\n}\n"],"mappings":";AAaO,IAAM,kBAAN,cAA8B,MAAM;AAAA,EAChC;AAAA,EACA;AAAA,EAET,YAAY,MAA2B,MAA8C;AACnF,UAAM,MAAM,WAAW,IAAI;AAC3B,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,SAAS,MAAM,UAAU;AAAA,EAChC;AACF;;;ACtBO,IAAM,0BAA0B;AAGhC,IAAM,gCAAgC;AAGtC,IAAM,2BAA2B;AAGjC,IAAM,gCAAgC;;;ACV7C,SAAS,aAAa;;;ACEtB,IAAM,eAAe;AAMd,SAAS,eAAe,OAAuB;AACpD,SAAO,MACJ,YAAY,EACZ,QAAQ,eAAe,GAAG,EAC1B,QAAQ,YAAY,EAAE;AAC3B;AAEO,SAAS,QAAQ,OAAe,QAAwB;AAC7D,SAAO,QAAQ,eAAe,KAAK,CAAC,IAAI,MAAM;AAChD;AAEO,SAAS,SAAS,KAAU,OAAe,QAAoC;AACpF,SAAO,IAAI,QAAQ,OAAO,MAAM,CAAC;AACnC;AAMO,SAAS,wBAAwB,KAAmD;AACzF,QAAM,MAA+C,CAAC;AACtD,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC9C,QAAI,SAAS,QAAQ,UAAU,GAAI;AACnC,UAAM,IAAI,aAAa,KAAK,GAAG;AAC/B,QAAI,KAAK,EAAE,CAAC,EAAG,KAAI,KAAK,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,MAAM,CAAC;AAAA,EACvD;AACA,SAAO;AACT;AAGO,SAAS,aAAkB;AAChC,SAAO,QAAQ;AACjB;;;ADvBA,eAAsB,kBACpB,KACA,OAAsB,CAAC,GACN;AACjB,QAAM,MAAM,KAAK,OAAO,WAAW;AACnC,QAAM,aAAa,wBAAwB,GAAG;AAC9C,QAAM,WAAW,IAAI,QAAQ,IAAI,uBAAuB,KAAK;AAE7D,MAAI,WAAW,WAAW,EAAG,QAAO,WAAW;AAC/C,MAAI,aAAa,UAAa,aAAa,GAAI,QAAO,eAAe;AAErE,QAAM,QAAQ,MAAM,SAAS,UAAoB,UAAU;AAC3D,MAAI,UAAU,KAAM,QAAO,eAAe;AAC1C,SAAO;AACT;AAEA,SAAS,OAAO,MAAkC;AAChD,QAAM,OAAO,MAAM,cAAc;AACjC,MAAI,MAAM;AACR,SAAK,aAAa,0BAA0B,IAAI;AAChD,SAAK,aAAa,+BAA+B,IAAI;AAAA,EACvD;AAEA,UAAQ,KAAK,KAAK,UAAU,EAAE,OAAO,QAAQ,OAAO,2BAA2B,KAAK,CAAC,CAAC;AACtF,QAAM,IAAI,gBAAgB,IAAI;AAChC;AAQA,eAAe,SACb,UACA,YACwB;AACxB,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,MAAM,MAAM,OAAO,OAAO,YAAY,EAAE,MAAM,QAAQ,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;AAC9F,QAAM,iBAAiB,IAAI,WAAW,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,QAAQ,CAAC,CAAC;AAEjG,MAAI,UAAyB;AAC7B,aAAW,EAAE,OAAO,MAAM,KAAK,YAAY;AACzC,UAAM,aAAa,IAAI,WAAW,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC1F,QAAI,OAAO,eAAe,SAAS,WAAW;AAC9C,aAAS,IAAI,GAAG,IAAI,eAAe,QAAQ,KAAK;AAC9C,eAAS,eAAe,CAAC,KAAK,MAAM,WAAW,CAAC,KAAK;AAAA,IACvD;AACA,QAAI,SAAS,EAAG,WAAU;AAAA,EAC5B;AACA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/security-core/parse.ts
|
|
2
|
+
var BoundedJsonError = class extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(reason, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "BoundedJsonError";
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var DEFAULT_JSON_BOUNDS = {
|
|
11
|
+
maxBytes: 1024 * 1024,
|
|
12
|
+
maxDepth: 32
|
|
13
|
+
};
|
|
14
|
+
function utf8Length(text) {
|
|
15
|
+
return new TextEncoder().encode(text).length;
|
|
16
|
+
}
|
|
17
|
+
function assertDepth(text, maxDepth) {
|
|
18
|
+
let depth = 0;
|
|
19
|
+
let inString = false;
|
|
20
|
+
let escaped = false;
|
|
21
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
22
|
+
const char = text[index];
|
|
23
|
+
if (inString) {
|
|
24
|
+
if (escaped) escaped = false;
|
|
25
|
+
else if (char === "\\") escaped = true;
|
|
26
|
+
else if (char === '"') inString = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (char === '"') {
|
|
30
|
+
inString = true;
|
|
31
|
+
} else if (char === "{" || char === "[") {
|
|
32
|
+
depth += 1;
|
|
33
|
+
if (depth > maxDepth) {
|
|
34
|
+
throw new BoundedJsonError(
|
|
35
|
+
"too_deep",
|
|
36
|
+
`json nests deeper than ${maxDepth} levels at offset ${index}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
} else if (char === "}" || char === "]") {
|
|
40
|
+
depth -= 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function parseJsonBounded(text, bounds = {}) {
|
|
45
|
+
const maxBytes = bounds.maxBytes ?? DEFAULT_JSON_BOUNDS.maxBytes;
|
|
46
|
+
const maxDepth = bounds.maxDepth ?? DEFAULT_JSON_BOUNDS.maxDepth;
|
|
47
|
+
if (text.length > maxBytes) {
|
|
48
|
+
throw new BoundedJsonError("too_large", `json exceeds ${maxBytes} bytes`);
|
|
49
|
+
}
|
|
50
|
+
const bytes = utf8Length(text);
|
|
51
|
+
if (bytes > maxBytes) {
|
|
52
|
+
throw new BoundedJsonError("too_large", `json is ${bytes} bytes, over the ${maxBytes} cap`);
|
|
53
|
+
}
|
|
54
|
+
assertDepth(text, maxDepth);
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(text);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new BoundedJsonError("malformed", `json did not parse: ${String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
BoundedJsonError,
|
|
64
|
+
DEFAULT_JSON_BOUNDS,
|
|
65
|
+
parseJsonBounded
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=chunk-FUPTRHT7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/security-core/parse.ts"],"sourcesContent":["export type BoundedJsonReason = \"too_large\" | \"too_deep\" | \"malformed\"\n\n/**\n * A refusal to parse, whether because the payload was outside the bounds or\n * because it was not JSON at all. One error type so a route handler has one\n * `catch` to write.\n */\nexport class BoundedJsonError extends Error {\n readonly reason: BoundedJsonReason\n\n constructor(reason: BoundedJsonReason, message: string) {\n super(message)\n this.name = \"BoundedJsonError\"\n this.reason = reason\n }\n}\n\nexport interface JsonBounds {\n /** UTF-8 bytes of input to accept. Default: 1 MiB. */\n maxBytes?: number\n /** Nesting of objects and arrays to accept. Default: 32. */\n maxDepth?: number\n}\n\nexport const DEFAULT_JSON_BOUNDS: Required<JsonBounds> = {\n maxBytes: 1024 * 1024,\n maxDepth: 32,\n}\n\nfunction utf8Length(text: string): number {\n // One UTF-8 byte per UTF-16 code unit is the floor, so a string longer than\n // the cap in code units is already over it — checked by the caller before\n // this runs, which keeps the encode bounded no matter what arrives.\n return new TextEncoder().encode(text).length\n}\n\n/**\n * Measure nesting without building anything.\n *\n * This runs *before* `JSON.parse`, which is the whole point: by the time the\n * parser has materialised a 100,000-deep array the memory is already spent,\n * and whatever walks the result recursively is already the next victim.\n */\nfunction assertDepth(text: string, maxDepth: number): void {\n let depth = 0\n let inString = false\n let escaped = false\n\n for (let index = 0; index < text.length; index += 1) {\n const char = text[index]\n\n if (inString) {\n if (escaped) escaped = false\n else if (char === \"\\\\\") escaped = true\n else if (char === '\"') inString = false\n continue\n }\n\n if (char === '\"') {\n inString = true\n } else if (char === \"{\" || char === \"[\") {\n depth += 1\n if (depth > maxDepth) {\n throw new BoundedJsonError(\n \"too_deep\",\n `json nests deeper than ${maxDepth} levels at offset ${index}`,\n )\n }\n } else if (char === \"}\" || char === \"]\") {\n // A closer too many is malformed input; leave saying so to JSON.parse,\n // which reports the offset far better than this scanner could.\n depth -= 1\n }\n }\n}\n\n/**\n * `JSON.parse` with the two bounds an untrusted payload needs.\n *\n * The result is `unknown` on purpose. A generic parameter here would let a\n * caller name a type the bytes never had to match, which is how \"validated\n * input\" turns into a lie; validate the result with your schema of choice.\n */\nexport function parseJsonBounded(text: string, bounds: JsonBounds = {}): unknown {\n const maxBytes = bounds.maxBytes ?? DEFAULT_JSON_BOUNDS.maxBytes\n const maxDepth = bounds.maxDepth ?? DEFAULT_JSON_BOUNDS.maxDepth\n\n if (text.length > maxBytes) {\n throw new BoundedJsonError(\"too_large\", `json exceeds ${maxBytes} bytes`)\n }\n const bytes = utf8Length(text)\n if (bytes > maxBytes) {\n throw new BoundedJsonError(\"too_large\", `json is ${bytes} bytes, over the ${maxBytes} cap`)\n }\n\n assertDepth(text, maxDepth)\n\n try {\n return JSON.parse(text) as unknown\n } catch (error) {\n throw new BoundedJsonError(\"malformed\", `json did not parse: ${String(error)}`)\n }\n}\n"],"mappings":";AAOO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EACjC;AAAA,EAET,YAAY,QAA2B,SAAiB;AACtD,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AASO,IAAM,sBAA4C;AAAA,EACvD,UAAU,OAAO;AAAA,EACjB,UAAU;AACZ;AAEA,SAAS,WAAW,MAAsB;AAIxC,SAAO,IAAI,YAAY,EAAE,OAAO,IAAI,EAAE;AACxC;AASA,SAAS,YAAY,MAAc,UAAwB;AACzD,MAAI,QAAQ;AACZ,MAAI,WAAW;AACf,MAAI,UAAU;AAEd,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAAG;AACnD,UAAM,OAAO,KAAK,KAAK;AAEvB,QAAI,UAAU;AACZ,UAAI,QAAS,WAAU;AAAA,eACd,SAAS,KAAM,WAAU;AAAA,eACzB,SAAS,IAAK,YAAW;AAClC;AAAA,IACF;AAEA,QAAI,SAAS,KAAK;AAChB,iBAAW;AAAA,IACb,WAAW,SAAS,OAAO,SAAS,KAAK;AACvC,eAAS;AACT,UAAI,QAAQ,UAAU;AACpB,cAAM,IAAI;AAAA,UACR;AAAA,UACA,0BAA0B,QAAQ,qBAAqB,KAAK;AAAA,QAC9D;AAAA,MACF;AAAA,IACF,WAAW,SAAS,OAAO,SAAS,KAAK;AAGvC,eAAS;AAAA,IACX;AAAA,EACF;AACF;AASO,SAAS,iBAAiB,MAAc,SAAqB,CAAC,GAAY;AAC/E,QAAM,WAAW,OAAO,YAAY,oBAAoB;AACxD,QAAM,WAAW,OAAO,YAAY,oBAAoB;AAExD,MAAI,KAAK,SAAS,UAAU;AAC1B,UAAM,IAAI,iBAAiB,aAAa,gBAAgB,QAAQ,QAAQ;AAAA,EAC1E;AACA,QAAM,QAAQ,WAAW,IAAI;AAC7B,MAAI,QAAQ,UAAU;AACpB,UAAM,IAAI,iBAAiB,aAAa,WAAW,KAAK,oBAAoB,QAAQ,MAAM;AAAA,EAC5F;AAEA,cAAY,MAAM,QAAQ;AAE1B,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,SAAS,OAAO;AACd,UAAM,IAAI,iBAAiB,aAAa,uBAAuB,OAAO,KAAK,CAAC,EAAE;AAAA,EAChF;AACF;","names":[]}
|
|
@@ -29,7 +29,7 @@ import { hostname } from "os";
|
|
|
29
29
|
// package.json
|
|
30
30
|
var package_default = {
|
|
31
31
|
name: "@quatronic/sdk",
|
|
32
|
-
version: "0.2.
|
|
32
|
+
version: "0.2.2-canary-20260917103453",
|
|
33
33
|
type: "module",
|
|
34
34
|
private: false,
|
|
35
35
|
publishConfig: {
|
|
@@ -37,7 +37,8 @@ var package_default = {
|
|
|
37
37
|
access: "public"
|
|
38
38
|
},
|
|
39
39
|
files: [
|
|
40
|
-
"dist"
|
|
40
|
+
"dist",
|
|
41
|
+
"semgrep"
|
|
41
42
|
],
|
|
42
43
|
bin: {
|
|
43
44
|
"qdc-setup-env": "./dist/cli/setup-env.js"
|
|
@@ -79,6 +80,18 @@ var package_default = {
|
|
|
79
80
|
types: "./dist/next/config/index.d.ts",
|
|
80
81
|
import: "./dist/next/config/index.js"
|
|
81
82
|
},
|
|
83
|
+
"./next/security": {
|
|
84
|
+
types: "./dist/next/security/index.d.ts",
|
|
85
|
+
import: "./dist/next/security/index.js"
|
|
86
|
+
},
|
|
87
|
+
"./next/security/testing": {
|
|
88
|
+
types: "./dist/next/security/testing.d.ts",
|
|
89
|
+
import: "./dist/next/security/testing.js"
|
|
90
|
+
},
|
|
91
|
+
"./eslint": {
|
|
92
|
+
types: "./dist/eslint/index.d.ts",
|
|
93
|
+
import: "./dist/eslint/index.js"
|
|
94
|
+
},
|
|
82
95
|
"./next/connections": {
|
|
83
96
|
types: "./dist/next/connections/index.d.ts",
|
|
84
97
|
import: "./dist/next/connections/index.js"
|
|
@@ -87,6 +100,18 @@ var package_default = {
|
|
|
87
100
|
types: "./dist/node/connections/index.d.ts",
|
|
88
101
|
import: "./dist/node/connections/index.js"
|
|
89
102
|
},
|
|
103
|
+
"./node/net": {
|
|
104
|
+
types: "./dist/node/net/index.d.ts",
|
|
105
|
+
import: "./dist/node/net/index.js"
|
|
106
|
+
},
|
|
107
|
+
"./node/fspath": {
|
|
108
|
+
types: "./dist/node/fspath/index.d.ts",
|
|
109
|
+
import: "./dist/node/fspath/index.js"
|
|
110
|
+
},
|
|
111
|
+
"./node/parse": {
|
|
112
|
+
types: "./dist/node/parse/index.d.ts",
|
|
113
|
+
import: "./dist/node/parse/index.js"
|
|
114
|
+
},
|
|
90
115
|
"./node/identity": {
|
|
91
116
|
types: "./dist/node/identity/index.d.ts",
|
|
92
117
|
import: "./dist/node/identity/index.js"
|
|
@@ -108,7 +133,8 @@ var package_default = {
|
|
|
108
133
|
test: "vitest run",
|
|
109
134
|
"test:watch": "vitest",
|
|
110
135
|
typecheck: "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
111
|
-
lint: "eslint src tests"
|
|
136
|
+
lint: "eslint src tests",
|
|
137
|
+
calibrate: "node scripts/calibrate.mjs"
|
|
112
138
|
},
|
|
113
139
|
dependencies: {
|
|
114
140
|
"@opentelemetry/api": "^1.9.1",
|
|
@@ -136,12 +162,16 @@ var package_default = {
|
|
|
136
162
|
"@better-auth/core": ">=1.6",
|
|
137
163
|
"better-auth": ">=1.6",
|
|
138
164
|
"drizzle-orm": ">=0.36",
|
|
165
|
+
eslint: ">=9",
|
|
139
166
|
next: ">=15",
|
|
140
167
|
pg: ">=8.13",
|
|
141
168
|
react: ">=18",
|
|
142
169
|
"react-dom": ">=18"
|
|
143
170
|
},
|
|
144
171
|
peerDependenciesMeta: {
|
|
172
|
+
eslint: {
|
|
173
|
+
optional: true
|
|
174
|
+
},
|
|
145
175
|
react: {
|
|
146
176
|
optional: true
|
|
147
177
|
},
|
|
@@ -409,4 +439,4 @@ function startNodeSdk(opts) {
|
|
|
409
439
|
export {
|
|
410
440
|
startNodeSdk
|
|
411
441
|
};
|
|
412
|
-
//# sourceMappingURL=chunk-
|
|
442
|
+
//# sourceMappingURL=chunk-UFYZP2E7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/observability-core/start-node-sdk.ts","../src/observability-core/resource.ts","../package.json","../src/observability-core/noise-filter.ts","../src/observability-core/http-sanitize.ts","../src/observability-core/span-scrub.ts","../src/observability-core/scrub-span-processor.ts","../src/observability-core/format-fatal.ts"],"sourcesContent":["// Shared NodeSDK bootstrap for every server runtime (Next and plain Node). The\n// only per-runtime difference is the noise-prefix list. Importing this module's\n// caller (a register entry) must happen before pino/node:http are first imported\n// so the instrumentations can patch them.\nimport { NodeSDK } from \"@opentelemetry/sdk-node\"\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-http\"\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-http\"\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-http\"\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\"\nimport { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\"\nimport { BatchSpanProcessor } from \"@opentelemetry/sdk-trace-base\"\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\"\nimport { UndiciInstrumentation } from \"@opentelemetry/instrumentation-undici\"\nimport { PinoInstrumentation } from \"@opentelemetry/instrumentation-pino\"\nimport { PgInstrumentation } from \"@opentelemetry/instrumentation-pg\"\nimport { buildResource } from \"./resource\"\nimport { qdcSanitizeHttp, qdcTracesSampleRatio, qdcEnhancedDbReporting, qdcHealthPath } from \"./env\"\nimport { shouldIgnoreIncoming, shouldIgnoreOutgoing } from \"./noise-filter\"\nimport { sanitizeHttpSpanAttributes } from \"./http-sanitize\"\nimport { ScrubSpanProcessor } from \"./scrub-span-processor\"\nimport { formatFatal } from \"./format-fatal\"\n\nexport interface StartNodeSdkOptions {\n noisePrefixes: string[]\n}\n\nexport function startNodeSdk(opts: StartNodeSdkOptions): NodeSDK {\n const { noisePrefixes } = opts\n\n // Sampling defaults before NodeSDK reads env. Default 100% in all envs; an\n // explicit OTEL_TRACES_SAMPLER always wins.\n if (!process.env.OTEL_TRACES_SAMPLER) process.env.OTEL_TRACES_SAMPLER = \"parentbased_traceidratio\"\n if (!process.env.OTEL_TRACES_SAMPLER_ARG) process.env.OTEL_TRACES_SAMPLER_ARG = qdcTracesSampleRatio()\n\n const healthPath = qdcHealthPath()\n\n const httpInstrumentation = new HttpInstrumentation({\n ignoreIncomingRequestHook: (req) => shouldIgnoreIncoming(req, { healthPath, noisePrefixes }),\n ignoreOutgoingRequestHook: (req) => shouldIgnoreOutgoing({ path: req.path ?? undefined }, { healthPath, noisePrefixes }),\n ...(qdcSanitizeHttp()\n ? { requestHook: (span, request) => sanitizeHttpSpanAttributes(span, request as { url?: string; path?: string }) }\n : {}),\n })\n\n // Cross-module / cross-service propagation: global fetch (undici) is the natural\n // way a Next server and a Node backend call CONN_<ALIAS>_URL. instrumentation-http\n // does NOT see undici, so without this the traceparent is never injected and the\n // hop starts a new trace. Injection is unconditional on origin (server-to-server,\n // no CORS) — that is what continues the trace across modules.\n const undiciInstrumentation = new UndiciInstrumentation({\n ignoreRequestHook: (request) =>\n shouldIgnoreOutgoing({ path: (request as { path?: string }).path }, { healthPath, noisePrefixes }),\n ...(qdcSanitizeHttp()\n ? { requestHook: (span, request) =>\n sanitizeHttpSpanAttributes(span, { path: (request as { path?: string }).path }, \"stable\") }\n : {}),\n })\n\n // DB spans. Full SQL + values only off-prod (synthetic data); parametrized only\n // in prod-like envs. `requireParentSpan` drops queries that run with no active\n // parent span — connection-pool warmup, keepalive pings, startup/migration — so\n // each no longer surfaces as its own root trace. Queries inside an HTTP request\n // (or an `observe()` wrapper) still nest under that span and are kept.\n const pgInstrumentation = new PgInstrumentation({\n enhancedDatabaseReporting: qdcEnhancedDbReporting(),\n requireParentSpan: true,\n })\n\n let sdk: NodeSDK\n try {\n sdk = new NodeSDK({\n resource: buildResource(),\n spanProcessors: [new ScrubSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()))],\n metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),\n logRecordProcessor: new BatchLogRecordProcessor(new OTLPLogExporter()),\n instrumentations: [httpInstrumentation, undiciInstrumentation, pgInstrumentation, new PinoInstrumentation()],\n })\n } catch (err: unknown) {\n process.stderr.write(`[qdc-sdk] initialization failed: ${err}\\n`)\n process.exit(1)\n }\n\n try {\n sdk.start()\n } catch (err: unknown) {\n process.stderr.write(`[qdc-sdk] SDK start failed: ${err}\\n`)\n process.exit(1)\n }\n\n const gracefulShutdown = async (signal: string): Promise<void> => {\n process.stderr.write(`[qdc-sdk] ${signal} received, shutting down\\n`)\n try { await sdk.shutdown() } catch { /* ignore */ }\n process.exit(0)\n }\n process.on(\"SIGTERM\", () => void gracefulShutdown(\"SIGTERM\"))\n process.on(\"SIGINT\", () => void gracefulShutdown(\"SIGINT\"))\n process.on(\"uncaughtException\", async (err) => {\n process.stderr.write(`[qdc-sdk] fatal: ${formatFatal(err)}\\n`)\n try { await sdk.shutdown() } finally { process.exit(1) }\n })\n process.on(\"unhandledRejection\", async (reason) => {\n process.stderr.write(`[qdc-sdk] fatal: ${formatFatal(reason)}\\n`)\n try { await sdk.shutdown() } finally { process.exit(1) }\n })\n\n return sdk\n}\n","import { type Resource, resourceFromAttributes } from \"@opentelemetry/resources\"\nimport { hostname } from \"node:os\"\nimport { qdcAppId, qdcCustomerId, qdcEnv, qdcModule } from \"./env\"\nimport pkg from \"../../package.json\" with { type: \"json\" }\n\nconst SDK_VERSION = (pkg as { version: string }).version\n\n/**\n * Maps the QDC identity env vars to the OpenTelemetry resource attributes used\n * by the SDK's server-side observability. The NodeSDK carries this resource on\n * every span and log record (the latter via the in-process PinoInstrumentation),\n * so it is the single source of truth for tenant labels.\n *\n * Identity is read from NEXT_PUBLIC_QDC_* with a fallback to legacy QDC_* (see\n * ./env). Throws if the customer or app id are missing — required to land logs\n * and traces under the correct tenant namespace.\n */\nexport function buildResourceAttributes(): Record<string, string> {\n const customerId = qdcCustomerId()\n if (!customerId)\n throw new Error(\"NEXT_PUBLIC_QDC_CUSTOMER_ID (or legacy QDC_CUSTOMER_ID) env var is required\")\n\n const appId = qdcAppId()\n if (!appId)\n throw new Error(\"NEXT_PUBLIC_QDC_APP_ID (or legacy QDC_APP_ID) env var is required\")\n\n const env = qdcEnv()\n\n const attrs: Record<string, string> = {\n \"service.name\": appId,\n \"service.namespace\": customerId,\n \"deployment.environment\": env,\n \"host.name\": hostname(),\n \"qdc.customer_id\": customerId,\n \"qdc.app_id\": appId,\n \"qdc.sdk.version\": SDK_VERSION,\n }\n const mod = qdcModule()\n if (mod) attrs[\"qdc.module\"] = mod\n return attrs\n}\n\nexport function buildResource(): Resource {\n return resourceFromAttributes(buildResourceAttributes())\n}\n","{\n \"name\": \"@quatronic/sdk\",\n \"version\": \"0.2.2-canary-20260917103453\",\n \"type\": \"module\",\n \"private\": false,\n \"publishConfig\": {\n \"registry\": \"https://registry.npmjs.org\",\n \"access\": \"public\"\n },\n \"files\": [\n \"dist\",\n \"semgrep\"\n ],\n \"bin\": {\n \"qdc-setup-env\": \"./dist/cli/setup-env.js\"\n },\n \"exports\": {\n \"./auth\": {\n \"types\": \"./dist/auth/index.d.ts\",\n \"import\": \"./dist/auth/index.js\"\n },\n \"./next/auth\": {\n \"types\": \"./dist/next/auth/index.d.ts\",\n \"import\": \"./dist/next/auth/index.js\"\n },\n \"./next/better-auth\": {\n \"types\": \"./dist/next/better-auth/index.d.ts\",\n \"import\": \"./dist/next/better-auth/index.js\"\n },\n \"./observability\": {\n \"types\": \"./dist/observability/index.d.ts\",\n \"import\": \"./dist/observability/index.js\"\n },\n \"./next/observability\": {\n \"types\": \"./dist/next/observability/index.d.ts\",\n \"import\": \"./dist/next/observability/index.js\"\n },\n \"./next/observability/register\": {\n \"types\": \"./dist/next/observability/register.d.ts\",\n \"import\": \"./dist/next/observability/register.js\"\n },\n \"./node/observability\": {\n \"types\": \"./dist/node/observability/index.d.ts\",\n \"import\": \"./dist/node/observability/index.js\"\n },\n \"./node/observability/register\": {\n \"types\": \"./dist/node/observability/register.d.ts\",\n \"import\": \"./dist/node/observability/register.js\"\n },\n \"./next/config\": {\n \"types\": \"./dist/next/config/index.d.ts\",\n \"import\": \"./dist/next/config/index.js\"\n },\n \"./next/security\": {\n \"types\": \"./dist/next/security/index.d.ts\",\n \"import\": \"./dist/next/security/index.js\"\n },\n \"./next/security/testing\": {\n \"types\": \"./dist/next/security/testing.d.ts\",\n \"import\": \"./dist/next/security/testing.js\"\n },\n \"./eslint\": {\n \"types\": \"./dist/eslint/index.d.ts\",\n \"import\": \"./dist/eslint/index.js\"\n },\n \"./next/connections\": {\n \"types\": \"./dist/next/connections/index.d.ts\",\n \"import\": \"./dist/next/connections/index.js\"\n },\n \"./node/connections\": {\n \"types\": \"./dist/node/connections/index.d.ts\",\n \"import\": \"./dist/node/connections/index.js\"\n },\n \"./node/net\": {\n \"types\": \"./dist/node/net/index.d.ts\",\n \"import\": \"./dist/node/net/index.js\"\n },\n \"./node/fspath\": {\n \"types\": \"./dist/node/fspath/index.d.ts\",\n \"import\": \"./dist/node/fspath/index.js\"\n },\n \"./node/parse\": {\n \"types\": \"./dist/node/parse/index.d.ts\",\n \"import\": \"./dist/node/parse/index.js\"\n },\n \"./node/identity\": {\n \"types\": \"./dist/node/identity/index.d.ts\",\n \"import\": \"./dist/node/identity/index.js\"\n },\n \"./node/db\": {\n \"types\": \"./dist/node/db/index.d.ts\",\n \"import\": \"./dist/node/db/index.js\"\n },\n \"./next/db\": {\n \"types\": \"./dist/next/db/index.d.ts\",\n \"import\": \"./dist/next/db/index.js\"\n }\n },\n \"scripts\": {\n \"build\": \"tsup\",\n \"surface\": \"node scripts/api-surface.mjs\",\n \"surface:check\": \"node scripts/api-surface.mjs --check\",\n \"prepublishOnly\": \"pnpm build\",\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.test.json\",\n \"lint\": \"eslint src tests\",\n \"calibrate\": \"node scripts/calibrate.mjs\"\n },\n \"dependencies\": {\n \"@opentelemetry/api\": \"^1.9.1\",\n \"@opentelemetry/context-zone\": \"2.7.1\",\n \"@opentelemetry/exporter-logs-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/exporter-metrics-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/exporter-trace-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/instrumentation\": \"^0.218.0\",\n \"@opentelemetry/instrumentation-document-load\": \"^0.63.0\",\n \"@opentelemetry/instrumentation-fetch\": \"^0.218.0\",\n \"@opentelemetry/instrumentation-http\": \"^0.218.0\",\n \"@opentelemetry/instrumentation-pg\": \"^0.71.0\",\n \"@opentelemetry/instrumentation-pino\": \"^0.64.0\",\n \"@opentelemetry/instrumentation-undici\": \"^0.28.0\",\n \"@opentelemetry/resources\": \"^2.7.1\",\n \"@opentelemetry/sdk-logs\": \"^0.218.0\",\n \"@opentelemetry/sdk-metrics\": \"^2.7.1\",\n \"@opentelemetry/sdk-node\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-base\": \"2.7.1\",\n \"@opentelemetry/sdk-trace-web\": \"2.7.1\",\n \"jose\": \"^5.9.6\",\n \"pino\": \"^10.3.1\"\n },\n \"peerDependencies\": {\n \"@better-auth/core\": \">=1.6\",\n \"better-auth\": \">=1.6\",\n \"drizzle-orm\": \">=0.36\",\n \"eslint\": \">=9\",\n \"next\": \">=15\",\n \"pg\": \">=8.13\",\n \"react\": \">=18\",\n \"react-dom\": \">=18\"\n },\n \"peerDependenciesMeta\": {\n \"eslint\": {\n \"optional\": true\n },\n \"react\": {\n \"optional\": true\n },\n \"react-dom\": {\n \"optional\": true\n },\n \"next\": {\n \"optional\": true\n },\n \"drizzle-orm\": {\n \"optional\": true\n },\n \"pg\": {\n \"optional\": true\n },\n \"better-auth\": {\n \"optional\": true\n },\n \"@better-auth/core\": {\n \"optional\": true\n }\n },\n \"devDependencies\": {\n \"@better-auth/core\": \"^1.6.24\",\n \"@eslint/js\": \"^10.0.1\",\n \"@opentelemetry/instrumentation\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-base\": \"^2.7.0\",\n \"@testcontainers/postgresql\": \"^12.0.0\",\n \"@testing-library/react\": \"^16.1.0\",\n \"@types/node\": \"^22.10.2\",\n \"@types/pg\": \"^8.11.10\",\n \"@types/react\": \"^18.3.17\",\n \"@types/react-dom\": \"^18.3.5\",\n \"@vitest/coverage-v8\": \"^2.1.9\",\n \"better-auth\": \"^1.6.24\",\n \"drizzle-kit\": \"^0.30.1\",\n \"drizzle-orm\": \"^0.38.2\",\n \"eslint\": \"^10.5.0\",\n \"jsdom\": \"^25.0.1\",\n \"msw\": \"^2.7.0\",\n \"next\": \"^15.1.0\",\n \"pg\": \"^8.13.1\",\n \"react\": \"^18.3.1\",\n \"react-dom\": \"^18.3.1\",\n \"testcontainers\": \"^10.16.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"typescript-eslint\": \"^8.61.1\",\n \"vitest\": \"^2.1.8\"\n }\n}\n","// Pure predicate deciding whether an HTTP request is infrastructural noise that\n// should never become a span. Framework-agnostic rules are built in: favicon,\n// source maps, OPTIONS, RSC navigations/prefetches (`?_rsc=` query + RSC headers),\n// and Next's `/_next/*` internals. Callers pass any additional environment-specific\n// prefixes (and an optional health path) via NoiseFilterOptions.\n\nexport interface NoiseFilterOptions {\n healthPath?: string\n noisePrefixes?: string[]\n}\n\n// `/api/auth/me` is the SDK's own session poll (PortalProvider fires it on every\n// page mount) — a heartbeat, not user activity. The meaningful auth flows keep\n// their labeled spans (Log in, Complete login, Log out, Refresh session);\n// dropping the inbound span here also drops its pg children (requireParentSpan).\nconst NOISE_EXACT = new Set([\"/favicon.ico\", \"/api/auth/me\"])\n\n// React Server Components navigations/prefetches/data-fetches. Next signals them\n// with a `?_rsc=` query param and/or an `RSC` / `Next-Router-Prefetch` request\n// header. They are real-looking routes, so a path-prefix filter misses them.\n// NOTE: `Next-Router-State-Tree` is intentionally NOT in this list — Next.js\n// sends it on server actions too (POST + Next-Action), not only on RSC navigations,\n// so it is not a safe RSC discriminator.\nconst RSC_QUERY_RE = /[?&]_rsc=/\nconst RSC_HEADERS = [\"rsc\", \"next-router-prefetch\"]\n\nexport function isNoisePath(rawUrl: string, opts: NoiseFilterOptions = {}): boolean {\n if (RSC_QUERY_RE.test(rawUrl)) return true\n const path = (rawUrl.split(\"?\")[0] ?? \"\").trim()\n if (!path) return false\n if (NOISE_EXACT.has(path)) return true\n if (path.endsWith(\".map\")) return true\n if (path.startsWith(\"/_next/\")) return true\n if ((opts.noisePrefixes ?? []).some((p) => path.startsWith(p))) return true\n if (opts.healthPath && path === opts.healthPath) return true\n return false\n}\n\nexport function shouldIgnoreIncoming(\n req: { url?: string; method?: string; headers?: Record<string, unknown> },\n opts: NoiseFilterOptions = {},\n): boolean {\n if ((req.method ?? \"\").toUpperCase() === \"OPTIONS\") return true\n const headers = req.headers ?? {}\n // A Next.js server action is real work — never noise — even though it also\n // carries router headers. Exempt it explicitly so its inbound span (and the\n // DB child gated by pg requireParentSpan) is preserved.\n if (headers[\"next-action\"] != null) return false\n if (RSC_HEADERS.some((h) => headers[h] != null)) return true\n return isNoisePath(req.url ?? \"\", opts)\n}\n\nexport function shouldIgnoreOutgoing(\n req: { path?: string },\n opts: NoiseFilterOptions = {},\n): boolean {\n return isNoisePath(req.path ?? \"\", opts)\n}\n","// Pure HTTP-span PII sanitizer. No OTel SDK import so it is unit-testable in\n// isolation. register.ts wires sanitizeHttpSpanAttributes into the\n// HttpInstrumentation requestHook when QDC_OTEL_SANITIZE_HTTP is on.\n\nconst REDACTED = \"[redacted]\"\n\nexport type SemconvMode = \"legacy\" | \"stable\" | \"dup\"\n\n// instrumentation-http emits legacy attribute names by default and the stable\n// names (url.full/url.query/url.path) only when OTEL_SEMCONV_STABILITY_OPT_IN\n// selects \"http\" (stable only) or \"http/dup\" (both).\nexport function httpSemconvMode(\n optIn: string | undefined = process.env.OTEL_SEMCONV_STABILITY_OPT_IN,\n): SemconvMode {\n const tokens = (optIn ?? \"\").split(\",\").map((t) => t.trim().toLowerCase())\n if (tokens.includes(\"http/dup\")) return \"dup\"\n if (tokens.includes(\"http\")) return \"stable\"\n return \"legacy\"\n}\n\ninterface SpanLike {\n setAttribute(key: string, value: string): unknown\n}\n\ninterface RequestLike {\n url?: string // IncomingMessage (server spans): path + query\n path?: string // ClientRequest (outgoing spans): path + query\n}\n\nexport function sanitizeHttpSpanAttributes(\n span: SpanLike,\n request: RequestLike,\n mode: SemconvMode = httpSemconvMode(),\n): void {\n const raw = request.url ?? request.path ?? \"\"\n const queryStart = raw.indexOf(\"?\")\n const hasQuery = queryStart !== -1\n const path = hasQuery ? raw.slice(0, queryStart) : raw\n\n // Legacy semconv: redact http.url (once the query is gone it only duplicates\n // http.host + http.target); http.target keeps the path. Skip http.target when\n // we have no path info, so we don't clobber the instrumentation's \"/\" default.\n if (mode === \"legacy\" || mode === \"dup\") {\n if (path) span.setAttribute(\"http.target\", path)\n span.setAttribute(\"http.url\", REDACTED)\n }\n\n // Stable semconv: the query lives in url.full / url.query; url.path is already\n // query-less, so leave it intact. Only redact url.query when a query was\n // actually present — the instrumentation omits it otherwise, and writing it\n // unconditionally would falsely signal a redacted query on clean paths.\n if (mode === \"stable\" || mode === \"dup\") {\n span.setAttribute(\"url.full\", REDACTED)\n if (hasQuery) span.setAttribute(\"url.query\", REDACTED)\n }\n}\n","// Leaf-noise predicate + attribute redaction, pure so they unit-test without the\n// OTel SDK. Wired into export by scrub-span-processor.ts. Contract:\n// docs (QDC spec 2026-07-02): the SDK drops ONLY leaf spans — spans that never\n// have children — so parent/child chains can never break. Structural framework\n// spans (BaseServer.handleRequest, AppRender.fetch, undici) stay; the portal\n// folds those at render time.\n\nexport const REDACTED = \"●●●\"\n\n// next.span_type values that are pure leaves and never carry information a\n// developer needs: sub-millisecond router/renderer bookkeeping.\nconst NEXT_LEAF_NOISE = new Set([\n \"Middleware.execute\",\n \"NextNodeServer.findPageComponents\",\n \"NextNodeServer.startResponse\",\n])\n\n// pg connection-pool bookkeeping; the actual query span (pg.query:*) stays.\nconst NOISE_SPAN_NAMES = new Set([\"pg-pool.connect\", \"pg.connect\"])\n\nexport function isNoiseLeafSpan(name: string, attributes: Record<string, unknown>): boolean {\n const nextType = attributes[\"next.span_type\"]\n if (typeof nextType === \"string\" && NEXT_LEAF_NOISE.has(nextType)) return true\n return NOISE_SPAN_NAMES.has(name)\n}\n\nconst REDACT_KEYS = new Set([\"db.connection_string\", \"db.user\"])\nconst REDACT_PREFIXES = [\"net.peer.\", \"net.sock.\", \"net.host.\", \"network.peer.\", \"network.local.\"]\n\nexport function redactSpanAttributes(attributes: Record<string, unknown>): void {\n for (const key of Object.keys(attributes)) {\n if (REDACT_KEYS.has(key) || REDACT_PREFIXES.some((p) => key.startsWith(p))) {\n attributes[key] = REDACTED\n }\n }\n}\n","import type { Context } from \"@opentelemetry/api\"\nimport type { ReadableSpan, Span, SpanProcessor } from \"@opentelemetry/sdk-trace-base\"\nimport { isNoiseLeafSpan, redactSpanAttributes } from \"./span-scrub\"\n\n/** Wraps the export processor: drops leaf-noise spans and redacts sensitive\n * attributes on everything else. Runs in ALL envs — connection strings and\n * internal IPs never belong in the telemetry backend. */\nexport class ScrubSpanProcessor implements SpanProcessor {\n constructor(private readonly inner: SpanProcessor) {}\n\n onStart(span: Span, parentContext: Context): void {\n this.inner.onStart(span, parentContext)\n }\n\n onEnd(span: ReadableSpan): void {\n if (isNoiseLeafSpan(span.name, span.attributes as Record<string, unknown>)) return\n redactSpanAttributes(span.attributes as Record<string, unknown>)\n this.inner.onEnd(span)\n }\n\n forceFlush(): Promise<void> {\n return this.inner.forceFlush()\n }\n\n shutdown(): Promise<void> {\n return this.inner.shutdown()\n }\n}\n","// A crash that reaches the SDK's last-resort handlers is often the only thing an\n// operator ever sees about it: the process dies before any log exporter flushes,\n// so all that survives is this one stderr line in the platform's container log.\n// `${err}` would reduce it to \"Error: self-signed certificate\" — no stack, no\n// cause, no error code, i.e. no way to tell which of the app's boot steps threw.\nexport function formatFatal(err: unknown): string {\n if (!(err instanceof Error)) return String(err)\n\n let out = err.stack ?? `${err.name}: ${err.message}`\n\n const code = (err as { code?: unknown }).code\n if (typeof code === \"string\" || typeof code === \"number\") out += `\\n code: ${code}`\n\n // Fetch/undici and pg wrap the real failure in `cause`; without it a boot\n // crash reads as a generic \"TypeError: fetch failed\".\n let cause: unknown = (err as { cause?: unknown }).cause\n let depth = 0\n while (cause !== undefined && depth < 3) {\n const rendered = cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause)\n out += `\\n caused by: ${rendered}`\n cause = cause instanceof Error ? (cause as { cause?: unknown }).cause : undefined\n depth += 1\n }\n\n return out\n}\n"],"mappings":"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","names":["REDACTED"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/security-core/proxy.ts
|
|
2
|
+
var ALWAYS_EXCLUDED = ["_next/static", "_next/image", "favicon.ico"];
|
|
3
|
+
var SAFE_EXCLUSION = /^[A-Za-z0-9._/-]+$/;
|
|
4
|
+
function proxyMatcher(alsoExclude = []) {
|
|
5
|
+
for (const value of alsoExclude) {
|
|
6
|
+
if (!SAFE_EXCLUSION.test(value)) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`Invalid proxy matcher exclusion ${JSON.stringify(value)}: path segments only, [A-Za-z0-9._/-]`
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const excluded = [.../* @__PURE__ */ new Set([...alsoExclude, ...ALWAYS_EXCLUDED])];
|
|
13
|
+
return [`/((?!${excluded.join("|")}).*)`];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
ALWAYS_EXCLUDED,
|
|
18
|
+
proxyMatcher
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=chunk-X6D3TBNS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/security-core/proxy.ts"],"sourcesContent":["// The proxy matcher. Pure string work, no `next` import.\n\n/**\n * Paths the QDC proxy never runs on: build output and the image optimizer,\n * which serve no HTML and therefore carry no nonce.\n */\nexport const ALWAYS_EXCLUDED = [\"_next/static\", \"_next/image\", \"favicon.ico\"]\n\n// An exclusion is interpolated into a regular expression, so a value carrying\n// metacharacters would rewrite the matcher rather than extend it — and a\n// matcher that quietly matches nothing is a security policy that quietly does\n// not run. Path segments only.\nconst SAFE_EXCLUSION = /^[A-Za-z0-9._/-]+$/\n\n/**\n * Builds the `config.matcher` pattern for a QDC app's `src/proxy.ts`.\n *\n * **Do not call this inside `export const config`.** Next does not execute the\n * proxy module to read that object — it extracts the value statically from the\n * AST, and a call expression is not a value it can extract. `next dev` then\n * throws `E1013` and `next build` exits 1 with \"Invalid segment configuration\n * export detected\". The matcher has to be a literal array in the app, the same\n * way `src/proxy.ts` itself has to sit at a fixed repo path.\n *\n * So this is the definition of what that literal should say, not a substitute\n * for writing it: the template renders its proxies from this function's output,\n * and `proxyMatcher.test.ts` pins the three recipe matchers against it, so a\n * change here is caught rather than silently diverging.\n *\n * `alsoExclude` is for routes an app reaches without a session — an auth\n * module's login and password-reset pages, and `api`, which answers with JSON\n * rather than HTML. Exclusions are listed before the always-excluded paths, so\n * the rendered pattern reads in the order it was written.\n */\nexport function proxyMatcher(alsoExclude: readonly string[] = []): string[] {\n for (const value of alsoExclude) {\n if (!SAFE_EXCLUSION.test(value)) {\n throw new Error(\n `Invalid proxy matcher exclusion ${JSON.stringify(value)}: path segments only, [A-Za-z0-9._/-]`,\n )\n }\n }\n const excluded = [...new Set([...alsoExclude, ...ALWAYS_EXCLUDED])]\n return [`/((?!${excluded.join(\"|\")}).*)`]\n}\n"],"mappings":";AAMO,IAAM,kBAAkB,CAAC,gBAAgB,eAAe,aAAa;AAM5E,IAAM,iBAAiB;AAsBhB,SAAS,aAAa,cAAiC,CAAC,GAAa;AAC1E,aAAW,SAAS,aAAa;AAC/B,QAAI,CAAC,eAAe,KAAK,KAAK,GAAG;AAC/B,YAAM,IAAI;AAAA,QACR,mCAAmC,KAAK,UAAU,KAAK,CAAC;AAAA,MAC1D;AAAA,IACF;AAAA,EACF;AACA,QAAM,WAAW,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAG,aAAa,GAAG,eAAe,CAAC,CAAC;AAClE,SAAO,CAAC,QAAQ,SAAS,KAAK,GAAG,CAAC,MAAM;AAC1C;","names":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Rule, Linter } from 'eslint';
|
|
2
|
+
|
|
3
|
+
declare const rules: Record<string, Rule.RuleModule>;
|
|
4
|
+
/**
|
|
5
|
+
* Rule name to QDC id, for docs and for anything that wants to assert the set
|
|
6
|
+
* without re-deriving it from the messages.
|
|
7
|
+
*/
|
|
8
|
+
declare const RULE_IDS: Record<string, string>;
|
|
9
|
+
|
|
10
|
+
interface QdcSecurityConfigs {
|
|
11
|
+
recommended: Linter.Config[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The plugin object, typed explicitly: an inferred type here reaches into
|
|
15
|
+
* `@eslint/core` and TypeScript refuses to name it in a `.d.ts`.
|
|
16
|
+
*/
|
|
17
|
+
interface QdcSecurityPlugin {
|
|
18
|
+
meta: {
|
|
19
|
+
name: string;
|
|
20
|
+
};
|
|
21
|
+
rules: Record<string, Rule.RuleModule>;
|
|
22
|
+
configs: QdcSecurityConfigs;
|
|
23
|
+
RULE_IDS: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
declare const configs: QdcSecurityConfigs;
|
|
26
|
+
declare const qdcSecurity: QdcSecurityPlugin;
|
|
27
|
+
|
|
28
|
+
export { type QdcSecurityConfigs, type QdcSecurityPlugin, RULE_IDS, configs, qdcSecurity as default, rules };
|