diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml
deleted file mode 100644
index ec1324a..0000000
--- a/.github/workflows/conformance.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: Conformance
-
-on: [push, pull_request]
-
-# ๐ Restrict GITHUB_TOKEN permissions (least privilege)
-permissions:
- contents: read
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with: { node-version: '20' }
- - run: npm ci
- - run: npm run test
- - uses: actions/upload-artifact@v4
- with:
- name: conformance-json
- path: docs/conformance.json
diff --git a/CLAUDE.md b/CLAUDE.md
index 4f65c01..73bbb2b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,10 +100,10 @@ packages//
- supertest for Express middleware integration tests
### current status
-Tests are not yet implemented across packages. Priority order for adding tests:
-1. proxyabl-core (security-critical: SSRF, auth modes, proxy execution)
-2. transformabl-core (correctness-critical: PII regex patterns)
-3. validatabl-core (security-critical: policy enforcement)
-4. identifiabl-core (security-critical: JWT verification)
-5. limitabl-core (correctness-critical: rate limiting, budget tracking)
-6. request-context, explicabl, middleware wrappers
+Every `-core` package and the middleware wrappers have vitest suites under
+`packages//__tests__/` (note: `__tests__/`, not `tests/`). `npm test` at
+the repo root runs the whole suite; `npm run build` must pass first (the tests
+import built workspace packages). Security-critical coverage is real:
+proxyabl-core SSRF, transformabl-core PII regex + redaction, validatabl-core
+policy enforcement, identifiabl-core JWT verification, limitabl-core
+rate/budget/agent-guard.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 842f6ae..173be9d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,7 +54,7 @@ Run the full test suite:
npm test
```
-This runs Vitest plus the conformance report writer. See `docs/testing.md` for:
+This runs the Vitest suite. See `docs/testing.md` for:
- `/__test__/echo` routes
- Scope/RBAC parity checks
- Proxy + echo server validation
diff --git a/README.md b/README.md
index d4b42b4..3a45cd8 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,6 @@
-
-
-
See, price, and control every tool call your AI agents make.
@@ -44,6 +38,16 @@ Hermes Agent uses a native Python plugin instead ([why](https://agenticcontrolpl
pip install hermes-acp && hermes plugins enable acp
```
+**Prefer to see the guarantees work first โ zero config, no IdP, no backend?**
+
+```bash
+git clone https://github.com/agentic-control-plane/GatewayStack
+cd GatewayStack/examples/quickstart && npm install && npm start
+```
+
+Prints three real decisions โ a deny-by-default policy, PII redaction, and a
+rate limit โ as pure local library code ([`examples/quickstart`](examples/quickstart)).
+
Your own framework code โ drop in a package:
```bash
@@ -88,9 +92,11 @@ Each layer ships as a framework-agnostic `-core` package plus an Express middlew
| `@gatewaystack/transformabl` | [](https://www.npmjs.com/package/@gatewaystack/transformabl) | PII detection, redaction, safety classification |
| `@gatewaystack/validatabl` | [](https://www.npmjs.com/package/@gatewaystack/validatabl) | Deny-by-default policy engine, scope/permission enforcement |
| `@gatewaystack/limitabl` | [](https://www.npmjs.com/package/@gatewaystack/limitabl) | Rate limits, budget tracking, agent guard |
-| `@gatewaystack/proxyabl` | [](https://www.npmjs.com/package/@gatewaystack/proxyabl) | Auth mode routing, SSRF protection, identity-aware proxy |
+| `@gatewaystack/proxyabl` | [](https://www.npmjs.com/package/@gatewaystack/proxyabl) | Auth-mode routing, identity-aware proxy to a configured backend (path-allowlisted). SSRF-safe fetch for arbitrary URLs is a `proxyabl-core` primitive โ see note below. |
| `@gatewaystack/explicabl` | [](https://www.npmjs.com/package/@gatewaystack/explicabl) | Structured audit logging, health endpoints |
+> **SSRF note (proxyabl).** The `proxyabl` Express router forwards to an operator-**configured** backend, with tool-name sanitization, path allowlisting, and host/protocol pinning to that backend โ the target host is not caller-controlled. The DNS-resolving, private-IP-blocking SSRF engine (`assertUrlSafe` / `executeProxyRequest`) lives in `@gatewaystack/proxyabl-core` and is the primitive to use when you proxy **arbitrary, caller-influenced URLs**; the bundled router does not currently route through it (its threat model is different โ a configured backend may legitimately be internal). See [`docs/packages.md`](docs/packages.md#proxyabl).
+
## Full stack example
Wire all six layers together. Each is optional โ use only what you need.
@@ -175,7 +181,7 @@ AI apps have three actors โ user, LLM, backend โ and no shared identity laye
| `demos/` | MCP issuer + ChatGPT Apps SDK connectors that mint demo JWTs |
| `tools/` | Echo server, mock tool backend, Cloud Run deploy helper |
| `tests/` | Vitest smoke tests |
-| `docs/` | Auth0 walkthroughs, conformance output, endpoint references, troubleshooting |
+| `docs/` | Auth0 walkthroughs, endpoint references, troubleshooting |
## Testing
diff --git a/docs/conformance.json b/docs/conformance.json
deleted file mode 100644
index 963d627..0000000
--- a/docs/conformance.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "version": "0.1.0",
- "spec": "Apps SDK OAuth 2.1 + MCP Authorization (subset)",
- "categories": {
- "pkce": "pass",
- "jwt_verify": "pass",
- "scope_enforcement": "pass",
- "allowlist": "pass",
- "expiry": "pass"
- },
- "timestamp": "2026-02-07T07:46:57.508Z"
-}
\ No newline at end of file
diff --git a/docs/deployment.md b/docs/deployment.md
index aaac788..edb6691 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -71,11 +71,11 @@ docker run -p 8080:8080 \
### CI/CD
-`.github/workflows/conformance.yml` runs `npm test` and updates `docs/conformance.json` on every push to main.
+`.github/workflows/build.yml` builds the monorepo and runs `npm test` on every push and pull request to main.
**To add deployment:**
```yaml
-# Add to conformance.yml after tests pass
+# Add to build.yml after tests pass
- name: Deploy to Cloud Run
if: github.ref == 'refs/heads/main'
run: ./tools/deploy/cloud-run.sh apps/gateway-server
diff --git a/docs/operations.md b/docs/operations.md
index 81d3363..57d7b9a 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -107,7 +107,7 @@ Run the full test suite:
npm test
```
-This runs Vitest plus the conformance report writer that updates `docs/conformance.json`.
+This runs the Vitest suite across the gateway and core packages.
For detailed testing workflows, see:
- `docs/testing.md` โ `/__test__/echo` routes, scope checks, proxy validation
@@ -126,7 +126,7 @@ For detailed testing workflows, see:
Toggles worth noting:
- `DEMO_MODE=true` swaps in `OAUTH_*_DEMO` overrides so demos can mint JWTs locally.
-- `ENABLE_TEST_ROUTES=true` + `TOOL_SCOPE_ALLOWLIST_JSON` expose `/__test__/echo` for conformance runs.
+- `ENABLE_TEST_ROUTES=true` + `TOOL_SCOPE_ALLOWLIST_JSON` expose `/__test__/echo` for scope/proxy validation runs.
- `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX` tune limitabl without editing TypeScript.
- `.env.example` plus `apps/gateway-server/.env.example` enumerate every knob.
diff --git a/docs/packages.md b/docs/packages.md
index c636227..3621f5e 100644
--- a/docs/packages.md
+++ b/docs/packages.md
@@ -30,7 +30,13 @@ GatewayStack ships as composable npm packages. Each governance layer has a `-cor
`@gatewaystack/proxyabl-core` / `@gatewaystack/proxyabl`
-**Execution Control & Identity-Aware Routing.** Five auth modes (API key, forward bearer, service OAuth, user OAuth, none), SSRF protection (host allowlist, private IP blocking), HTTP proxy with timeout/redirect/size controls, multi-provider registry. Express middleware serves PRM/OIDC metadata, enforces scope-to-tool mappings, and injects verified identity into downstream headers.
+**Execution Control & Identity-Aware Routing.** Five auth modes (API key, forward bearer, service OAuth, user OAuth, none) and a multi-provider registry.
+
+**`proxyabl-core` โ SSRF-safe fetch primitive.** `assertUrlSafe` / `executeProxyRequest` provide host-allowlisting, DNS-resolving private-IP blocking (IPv4 + IPv6, incl. IPv4-mapped IPv6), protocol enforcement, redirect blocking (`redirect: "manual"`), and timeout/response-size caps. Use these directly when you proxy **arbitrary, caller-influenced URLs** โ that is the case SSRF protection is for.
+
+**`proxyabl` โ Express gateway router.** Forwards to an operator-**configured** backend (`functionsBase` / proxy target). The caller controls the tool name / path, not the host: the router sanitizes the tool name, enforces a path allowlist, and pins the resolved URL's host + protocol to the configured backend. It is a pass-through proxy and **does not currently route through `proxyabl-core`'s `assertUrlSafe` engine** โ the two have different threat models (a configured backend may legitimately be an internal address, which the private-IP-blocking engine would reject). The Express middleware also serves PRM/OIDC metadata, enforces scope-to-tool mappings, and injects verified identity into downstream headers.
+
+> Converging these โ one canonical `proxyabl-core` engine consumed by both the OSS router and the production gateway (which forked an equivalent) โ is tracked in the proxyabl convergence work. Until then, treat `assertUrlSafe` / `executeProxyRequest` as an available primitive, not something the bundled router runs for you.
## explicabl
diff --git a/docs/testing.md b/docs/testing.md
index d526506..65edcca 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -3,7 +3,7 @@
This document covers:
- How to enable internal `/__test__` routes
- Scope/RBAC parity checks
-- Vitest + conformance report
+- Running the Vitest suite
---
@@ -21,7 +21,7 @@ These routes are guarded by:
- `TOOL_SCOPE_ALLOWLIST_JSON` (allowed scopes per test route)
- The `X-Required-Scope` header (per-request scope requirement you pass in curl)
-They are for validation and conformance testing only, not for production traffic.
+They are for validation and scope/proxy testing only, not for production traffic.
---
@@ -95,12 +95,10 @@ npm test
This runs:
- Vitest against the gateway and core packages (see `vitest.config.mts`)
-- The conformance report writer, which emits a summary of MCP/Auth OAuth behavior into `docs/conformance.json`
**Key files:**
- `package.json` โ test script
- `vitest.config.mts` โ shared test config
- `tests/smoke.test.ts` โ placeholder smoke test
-- `packages/explicabl-core/src/reporting/saveReport.ts` โ writes the conformance report artifact
---
\ No newline at end of file
diff --git a/docs/testing/conformance.md b/docs/testing/conformance.md
deleted file mode 100644
index e69de29..0000000
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..d9d30f0
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,12 @@
+# Examples
+
+Runnable GatewayStack examples.
+
+| Example | What it shows | Setup |
+|---------|---------------|-------|
+| [`quickstart/`](./quickstart) | Deny-by-default policy, PII redaction, and rate limiting โ three real decisions printed to your console | **None.** `npm install && npm start` |
+
+The quickstart uses only the framework-agnostic `-core` packages, so it runs
+anywhere Node runs, with zero external services. Wiring the same layers as
+Express middleware (which does need an IdP for the identity layer) is covered in
+the [root README](../README.md#full-stack-example).
diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md
new file mode 100644
index 0000000..0c43835
--- /dev/null
+++ b/examples/quickstart/README.md
@@ -0,0 +1,32 @@
+# Quickstart โ governance in 30 seconds
+
+Three real GatewayStack decisions running on your machine. **No IdP, no backend,
+no config.**
+
+```bash
+npm install
+npm start
+```
+
+You'll see:
+
+```
+1 ยท Deny-by-default policy (validatabl)
+ โ
ALLOW read_file โ Matched rule: allow-reads (allow)
+ ๐ DENY delete_database โ No rules matched; default: deny
+
+2 ยท PII redaction (transformabl)
+ in : Email me at john@acme.com or call about SSN 123-45-6789.
+ out: Email me at [EMAIL] or call about SSN [SSN].
+ detected: email, ssn
+
+3 ยท Rate limit + budget guard (limitabl)
+ call 1: โ
ALLOW
+ ...
+ call 4: ๐ DENY โ Rate limited. Retry after 60s
+```
+
+Everything here is pure `-core` library code โ the same logic the Express
+middleware wraps. See [`index.ts`](./index.ts); it's ~50 lines. To wire these as
+HTTP middleware in front of your own tools/models, see the
+[full-stack example](../../README.md#full-stack-example).
diff --git a/examples/quickstart/index.ts b/examples/quickstart/index.ts
new file mode 100644
index 0000000..8568c57
--- /dev/null
+++ b/examples/quickstart/index.ts
@@ -0,0 +1,69 @@
+/**
+ * GatewayStack โ 30-second quickstart.
+ *
+ * Three real governance decisions, running entirely on your machine. No IdP, no
+ * backend, no config. Run it:
+ *
+ * npm install && npm start
+ *
+ * Everything here is pure library code from the `-core` packages โ the same
+ * logic the Express middleware wraps. If you can see these decisions happen,
+ * you can drop them into your own gateway.
+ */
+
+import { applyPolicies, type PolicySet } from "@gatewaystack/validatabl-core";
+import { transformContent } from "@gatewaystack/transformabl-core";
+import { LimitablEngine } from "@gatewaystack/limitabl-core";
+
+const ALLOW = "\x1b[32mโ
ALLOW\x1b[0m";
+const DENY = "\x1b[31m๐ DENY \x1b[0m";
+const h = (s: string) => console.log(`\n\x1b[1m${s}\x1b[0m`);
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// 1. Deny-by-default policy โ a tool call is refused unless a rule allows it.
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+h("1 ยท Deny-by-default policy (validatabl)");
+
+const policy: PolicySet = {
+ defaultEffect: "deny", // nothing is allowed unless a rule says so
+ rules: [
+ {
+ id: "allow-reads",
+ effect: "allow",
+ conditions: [{ field: "tool", operator: "in", value: ["read_file", "list_files"] }],
+ },
+ ],
+};
+
+for (const tool of ["read_file", "delete_database"]) {
+ const d = applyPolicies(policy, { identity: { sub: "agent-1" }, tool });
+ console.log(` ${d.allowed ? ALLOW : DENY} ${tool.padEnd(16)} โ ${d.reason}`);
+}
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// 2. PII redaction โ sensitive values are stripped before they leave.
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+h("2 ยท PII redaction (transformabl)");
+
+const raw = "Email me at john@acme.com or call about SSN 123-45-6789.";
+const result = transformContent(raw, { redaction: { mode: "placeholder" } });
+
+console.log(` in : ${raw}`);
+console.log(` out: ${result.content}`);
+console.log(` detected: ${result.piiMatches.map((m) => m.type).join(", ") || "none"}`);
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// 3. Rate limit โ the 4th call in the window is refused.
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+h("3 ยท Rate limit + budget guard (limitabl)");
+
+const engine = new LimitablEngine({ rateLimit: { windowMs: 60_000, maxRequests: 3 } });
+const key = { sub: "agent-1" };
+
+for (let i = 1; i <= 5; i++) {
+ const r = engine.preflight(key);
+ console.log(` call ${i}: ${r.allowed ? ALLOW : DENY} ${r.allowed ? "" : "โ " + r.reason}`);
+}
+
+h("Next โ wire these as Express middleware: see ../../README.md#full-stack-example");
+console.log();
diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json
new file mode 100644
index 0000000..70abf75
--- /dev/null
+++ b/examples/quickstart/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "gatewaystack-quickstart",
+ "private": true,
+ "type": "module",
+ "description": "30-second, zero-config GatewayStack governance demo",
+ "scripts": {
+ "start": "tsx index.ts"
+ },
+ "dependencies": {
+ "@gatewaystack/validatabl-core": "^0.1.0",
+ "@gatewaystack/transformabl-core": "^0.4.0",
+ "@gatewaystack/limitabl-core": "^0.1.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.2"
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 0bf0456..d11ca39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6601,7 +6601,7 @@
},
"packages/explicabl": {
"name": "@gatewaystack/explicabl",
- "version": "0.0.7",
+ "version": "0.0.9",
"license": "MIT",
"dependencies": {
"express": "^4.22.0",
@@ -6611,11 +6611,17 @@
"@types/express": "^4.17.21",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"packages/explicabl-core": {
"name": "@gatewaystack/observability-core",
- "version": "0.0.1"
+ "version": "0.0.1",
+ "engines": {
+ "node": ">=18"
+ }
},
"packages/identifiabl": {
"name": "@gatewaystack/identifiabl",
@@ -6623,8 +6629,10 @@
"license": "MIT",
"dependencies": {
"@gatewaystack/identifiabl-core": "^0.1.0",
- "@gatewaystack/request-context": "^0.0.6",
- "jose": "^5.0.0"
+ "@gatewaystack/request-context": "^0.0.6"
+ },
+ "engines": {
+ "node": ">=18"
},
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0"
@@ -6636,6 +6644,9 @@
"license": "MIT",
"dependencies": {
"jose": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"packages/identifiabl-core/node_modules/jose": {
@@ -6656,6 +6667,9 @@
"@types/express": "^4.17.21",
"typescript": "^5.6.3"
},
+ "engines": {
+ "node": ">=18"
+ },
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0"
}
@@ -6666,30 +6680,62 @@
"license": "MIT",
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"packages/proxyabl": {
"name": "@gatewaystack/proxyabl",
- "version": "0.0.12",
+ "version": "0.0.13",
"license": "MIT",
"dependencies": {
- "@gatewaystack/proxyabl-core": "^0.1.0",
- "@gatewaystack/request-context": "0.0.6",
+ "@gatewaystack/proxyabl-core": "^0.1.1",
+ "@gatewaystack/request-context": "^0.0.6",
"express": "^4.22.0",
"express-rate-limit": "^8.2.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
+ "jose": "^6.1.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"packages/proxyabl-core": {
"name": "@gatewaystack/proxyabl-core",
- "version": "0.1.0",
+ "version": "0.1.1",
"license": "MIT",
+ "dependencies": {
+ "jose": "^6.1.0"
+ },
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/proxyabl-core/node_modules/jose": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
+ "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "packages/proxyabl/node_modules/jose": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
+ "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
}
},
"packages/request-context": {
@@ -6698,28 +6744,37 @@
"license": "MIT",
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"packages/transformabl": {
"name": "@gatewaystack/transformabl",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
- "@gatewaystack/transformabl-core": "^0.3.0"
+ "@gatewaystack/transformabl-core": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.6.3"
},
+ "engines": {
+ "node": ">=18"
+ },
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0"
}
},
"packages/transformabl-core": {
"name": "@gatewaystack/transformabl-core",
- "version": "0.3.0",
+ "version": "0.4.1",
"license": "MIT",
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"packages/validatabl": {
@@ -6733,6 +6788,9 @@
"@types/express": "^4.17.21",
"typescript": "^5.6.3"
},
+ "engines": {
+ "node": ">=18"
+ },
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0"
}
@@ -6743,6 +6801,9 @@
"license": "MIT",
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"tools/echo-server": {
diff --git a/package.json b/package.json
index c7779d1..c3cfc46 100644
--- a/package.json
+++ b/package.json
@@ -20,9 +20,8 @@
"build:admin": "npm --workspace apps/admin-ui run build",
"start:server": "npm --workspace apps/gateway-server run start",
"start:admin": "npm --workspace apps/admin-ui run preview",
- "test": "vitest run --config ./vitest.config.mts --reporter=verbose && npm run conformance:report",
+ "test": "vitest run --config ./vitest.config.mts --reporter=verbose",
"test:watch": "vitest --config ./vitest.config.mts",
- "conformance:report": "tsx packages/explicabl-core/src/reporting/saveReport.ts",
"demo:mcp": "npm-run-all -p demo:issuer demo:gateway demo:mcp-server",
"demo:issuer": "npm run -w @gatewaystack/demo-mcp-server dev",
"demo:mcp-server": "npm run -w @gatewaystack/demo-mcp-server dev",
diff --git a/packages/explicabl-core/LICENSE b/packages/explicabl-core/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/explicabl-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/explicabl-core/package.json b/packages/explicabl-core/package.json
index da1df19..8cdfed5 100644
--- a/packages/explicabl-core/package.json
+++ b/packages/explicabl-core/package.json
@@ -2,5 +2,8 @@
"name": "@gatewaystack/observability-core",
"version": "0.0.1",
"main": "dist/index.js",
- "types": "dist/index.d.ts"
+ "types": "dist/index.d.ts",
+ "engines": {
+ "node": ">=18"
+ }
}
diff --git a/packages/explicabl-core/src/reporting/saveReport.js b/packages/explicabl-core/src/reporting/saveReport.js
deleted file mode 100644
index 3e90f4a..0000000
--- a/packages/explicabl-core/src/reporting/saveReport.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import fs from "node:fs";
-const out = {
- version: "0.1.0",
- spec: "Apps SDK OAuth 2.1 + MCP Authorization (subset)",
- categories: {
- pkce: "pass",
- jwt_verify: "pass",
- scope_enforcement: "pass",
- allowlist: "pass",
- expiry: "pass"
- },
- timestamp: new Date().toISOString()
-};
-fs.mkdirSync("docs", { recursive: true });
-fs.writeFileSync("docs/conformance.json", JSON.stringify(out, null, 2));
-console.log("[conformance] wrote docs/conformance.json");
diff --git a/packages/explicabl-core/src/reporting/saveReport.ts b/packages/explicabl-core/src/reporting/saveReport.ts
deleted file mode 100644
index 7161784..0000000
--- a/packages/explicabl-core/src/reporting/saveReport.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as fs from "node:fs";
-const out = {
- version: "0.1.0",
- spec: "Apps SDK OAuth 2.1 + MCP Authorization (subset)",
- categories: {
- pkce: "pass",
- jwt_verify: "pass",
- scope_enforcement: "pass",
- allowlist: "pass",
- expiry: "pass"
- },
- timestamp: new Date().toISOString()
-};
-fs.mkdirSync("docs", { recursive: true });
-fs.writeFileSync("docs/conformance.json", JSON.stringify(out, null, 2));
-console.log("[conformance] wrote docs/conformance.json");
diff --git a/packages/explicabl/LICENSE b/packages/explicabl/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/explicabl/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/explicabl/package.json b/packages/explicabl/package.json
index 4a360ce..2fbd85d 100644
--- a/packages/explicabl/package.json
+++ b/packages/explicabl/package.json
@@ -1,6 +1,6 @@
{
"name": "@gatewaystack/explicabl",
- "version": "0.0.8",
+ "version": "0.0.9",
"private": false,
"license": "MIT",
"type": "module",
@@ -18,6 +18,9 @@
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
+ "engines": {
+ "node": ">=18"
+ },
"dependencies": {
"express": "^4.22.0",
"express-rate-limit": "^8.2.2"
@@ -26,5 +29,8 @@
"@types/express": "^4.17.21",
"typescript": "^5.6.3",
"tsx": "^4.19.2"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/explicabl/src/health.ts b/packages/explicabl/src/health.ts
index e4a4654..91aee1d 100644
--- a/packages/explicabl/src/health.ts
+++ b/packages/explicabl/src/health.ts
@@ -1,5 +1,4 @@
import { Router, type Request, type RequestHandler } from "express";
-import fetch from "node-fetch";
import rateLimit from "express-rate-limit";
import { timingSafeEqual } from "node:crypto";
diff --git a/packages/explicabl/tsconfig.json b/packages/explicabl/tsconfig.json
index f5bc8df..8cb43ee 100644
--- a/packages/explicabl/tsconfig.json
+++ b/packages/explicabl/tsconfig.json
@@ -13,7 +13,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"]
}
diff --git a/packages/identifiabl-core/LICENSE b/packages/identifiabl-core/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/identifiabl-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/identifiabl-core/package.json b/packages/identifiabl-core/package.json
index 75588f5..7e9acc9 100644
--- a/packages/identifiabl-core/package.json
+++ b/packages/identifiabl-core/package.json
@@ -13,7 +13,9 @@
"directory": "packages/identifiabl-core"
},
"files": [
- "dist"
+ "dist",
+ "LICENSE",
+ "README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json",
@@ -21,5 +23,8 @@
},
"dependencies": {
"jose": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/identifiabl-core/tsconfig.json b/packages/identifiabl-core/tsconfig.json
index 5c317ea..e4c820a 100644
--- a/packages/identifiabl-core/tsconfig.json
+++ b/packages/identifiabl-core/tsconfig.json
@@ -4,7 +4,7 @@
"composite": true,
"rootDir": "src",
"outDir": "dist",
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"],
"references": [
diff --git a/packages/identifiabl/LICENSE b/packages/identifiabl/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/identifiabl/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/identifiabl/README.md b/packages/identifiabl/README.md
index 6b401c3..80ef011 100644
--- a/packages/identifiabl/README.md
+++ b/packages/identifiabl/README.md
@@ -30,12 +30,12 @@ npm install @gatewaystack/identifiabl
```typescript
import express from 'express';
-import { createIdentifiablMiddleware } from '@gatewaystack/identifiabl';
+import { identifiabl } from '@gatewaystack/identifiabl';
const app = express();
// Add identity verification to all routes
-app.use(createIdentifiablMiddleware({
+app.use(identifiabl({
issuer: 'https://your-tenant.auth0.com/',
audience: 'https://gateway.local/api',
jwksUri: 'https://your-tenant.auth0.com/.well-known/jwks.json'
@@ -59,7 +59,7 @@ app.get('/api/data', (req, res) => {
app.get('/health', (req, res) => res.json({ ok: true }));
// Protected routes (auth required)
-app.use('/protected', createIdentifiablMiddleware({
+app.use('/protected', identifiabl({
issuer: process.env.OAUTH_ISSUER,
audience: process.env.OAUTH_AUDIENCE,
jwksUri: process.env.OAUTH_JWKS_URI
@@ -400,7 +400,7 @@ identifiabl is optimized for production:
### Custom Claims Extraction
```typescript
-app.use(createIdentifiablMiddleware({
+app.use(identifiabl({
issuer: process.env.OAUTH_ISSUER,
audience: process.env.OAUTH_AUDIENCE,
jwksUri: process.env.OAUTH_JWKS_URI,
@@ -418,7 +418,7 @@ app.use(createIdentifiablMiddleware({
```typescript
// Enforce tenant ID in token
-app.use(createIdentifiablMiddleware({
+app.use(identifiabl({
issuer: process.env.OAUTH_ISSUER,
audience: process.env.OAUTH_AUDIENCE,
jwksUri: process.env.OAUTH_JWKS_URI,
diff --git a/packages/identifiabl/package.json b/packages/identifiabl/package.json
index a3a33f6..2a8b24c 100644
--- a/packages/identifiabl/package.json
+++ b/packages/identifiabl/package.json
@@ -15,7 +15,6 @@
"LICENSE"
],
"dependencies": {
- "jose": "^5.0.0",
"@gatewaystack/identifiabl-core": "^0.1.0",
"@gatewaystack/request-context": "^0.0.6"
},
@@ -25,5 +24,8 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/limitabl-core/LICENSE b/packages/limitabl-core/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/limitabl-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/limitabl-core/package.json b/packages/limitabl-core/package.json
index 83b7fff..c60c68d 100644
--- a/packages/limitabl-core/package.json
+++ b/packages/limitabl-core/package.json
@@ -20,5 +20,8 @@
},
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/limitabl-core/tsconfig.json b/packages/limitabl-core/tsconfig.json
index f3f2f18..6566a49 100644
--- a/packages/limitabl-core/tsconfig.json
+++ b/packages/limitabl-core/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"]
}
diff --git a/packages/limitabl/LICENSE b/packages/limitabl/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/limitabl/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/limitabl/package.json b/packages/limitabl/package.json
index 7ce7aaa..0bc2f8d 100644
--- a/packages/limitabl/package.json
+++ b/packages/limitabl/package.json
@@ -27,5 +27,8 @@
"devDependencies": {
"@types/express": "^4.17.21",
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/limitabl/tsconfig.json b/packages/limitabl/tsconfig.json
index 7208f0f..0678654 100644
--- a/packages/limitabl/tsconfig.json
+++ b/packages/limitabl/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"],
"references": [
diff --git a/packages/proxyabl-core/LICENSE b/packages/proxyabl-core/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/proxyabl-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/proxyabl-core/README.md b/packages/proxyabl-core/README.md
index d3e567f..d078b43 100644
--- a/packages/proxyabl-core/README.md
+++ b/packages/proxyabl-core/README.md
@@ -98,6 +98,8 @@ resolveAuth(config: AuthModeConfig, context: AuthContext): ResolvedAuth
### SSRF Protection
+> **Scope.** `assertUrlSafe` / `executeProxyRequest` are the primitive for proxying **arbitrary, caller-influenced URLs** โ call them directly from your handler. The Express router in [`@gatewaystack/proxyabl`](https://www.npmjs.com/package/@gatewaystack/proxyabl) is a different thing: it forwards to an operator-**configured** backend and does not route through this engine (a configured backend may legitimately be internal, which private-IP blocking would reject). Importing the router does not give you this SSRF check automatically.
+
```ts
assertUrlSafe(opts: UrlSafetyOptions): Promise
```
diff --git a/packages/proxyabl-core/package.json b/packages/proxyabl-core/package.json
index 5cb5be1..12b37d5 100644
--- a/packages/proxyabl-core/package.json
+++ b/packages/proxyabl-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@gatewaystack/proxyabl-core",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": false,
"license": "MIT",
"type": "module",
@@ -18,7 +18,13 @@
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
+ "dependencies": {
+ "jose": "^6.1.0"
+ },
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/proxyabl-core/tsconfig.json b/packages/proxyabl-core/tsconfig.json
index f3f2f18..6566a49 100644
--- a/packages/proxyabl-core/tsconfig.json
+++ b/packages/proxyabl-core/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"]
}
diff --git a/packages/proxyabl/LICENSE b/packages/proxyabl/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/proxyabl/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/proxyabl/package.json b/packages/proxyabl/package.json
index 4235dd8..302e7a2 100644
--- a/packages/proxyabl/package.json
+++ b/packages/proxyabl/package.json
@@ -1,6 +1,6 @@
{
"name": "@gatewaystack/proxyabl",
- "version": "0.0.12",
+ "version": "0.0.13",
"private": false,
"license": "MIT",
"type": "module",
@@ -21,12 +21,16 @@
"dependencies": {
"express": "^4.22.0",
"express-rate-limit": "^8.2.2",
- "@gatewaystack/proxyabl-core": "^0.1.0",
- "@gatewaystack/request-context": "0.0.6"
+ "@gatewaystack/proxyabl-core": "^0.1.1",
+ "@gatewaystack/request-context": "^0.0.6"
},
"devDependencies": {
"@types/express": "^4.17.21",
+ "jose": "^6.1.0",
"typescript": "^5.6.3",
"tsx": "^4.19.2"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/proxyabl/tsconfig.json b/packages/proxyabl/tsconfig.json
index ae64fc4..962c896 100644
--- a/packages/proxyabl/tsconfig.json
+++ b/packages/proxyabl/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"],
"references": [
diff --git a/packages/request-context/LICENSE b/packages/request-context/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/request-context/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/request-context/package.json b/packages/request-context/package.json
index ac22aea..3e9b840 100644
--- a/packages/request-context/package.json
+++ b/packages/request-context/package.json
@@ -15,10 +15,14 @@
"LICENSE"
],
"scripts": {
- "build": "tsc -p tsconfig.json"
+ "build": "tsc -p tsconfig.json",
+ "prepublishOnly": "npm run build"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
-}
\ No newline at end of file
+}
diff --git a/packages/transformabl-core/LICENSE b/packages/transformabl-core/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/transformabl-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/transformabl-core/__tests__/detect.test.ts b/packages/transformabl-core/__tests__/detect.test.ts
index 7986fff..f770b74 100644
--- a/packages/transformabl-core/__tests__/detect.test.ts
+++ b/packages/transformabl-core/__tests__/detect.test.ts
@@ -145,6 +145,162 @@ describe("detectPii", () => {
});
});
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // Presidio-parity recognizers (v0.4.0)
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe("us_bank_number", () => {
+ it("detects 10-digit bank account", () => {
+ const matches = detectPii("account 9845302145 for deposit");
+ expect(matches.some((m) => m.type === "us_bank_number")).toBe(true);
+ });
+
+ it("detects 17-digit routing/account combos", () => {
+ const matches = detectPii("wire to 12345678901234567");
+ expect(matches.some((m) => m.type === "us_bank_number")).toBe(true);
+ });
+
+ it("does NOT match 7-digit values (too short)", () => {
+ const matches = detectPii("code 1234567");
+ expect(matches.some((m) => m.type === "us_bank_number")).toBe(false);
+ });
+ });
+
+ describe("us_itin", () => {
+ it("detects ITIN format 9XX-7X-XXXX", () => {
+ const matches = detectPii("ITIN: 912-78-5551");
+ expect(matches.some((m) => m.type === "us_itin")).toBe(true);
+ });
+
+ it("detects ITIN format 9XX-8X-XXXX", () => {
+ const matches = detectPii("taxpayer 988-83-0099");
+ expect(matches.some((m) => m.type === "us_itin")).toBe(true);
+ });
+
+ it("regular SSN 123-45-6789 does not match ITIN pattern", () => {
+ const matches = detectPii("ssn 123-45-6789");
+ expect(matches.some((m) => m.type === "us_itin")).toBe(false);
+ });
+ });
+
+ describe("us_passport", () => {
+ it("detects 9-digit passport", () => {
+ const matches = detectPii("passport 123456789 expires");
+ expect(matches.some((m) => m.type === "us_passport")).toBe(true);
+ });
+
+ it("detects 1-letter + 8-digit format", () => {
+ const matches = detectPii("passport A12345678");
+ expect(matches.some((m) => m.type === "us_passport")).toBe(true);
+ });
+ });
+
+ describe("us_drivers_license", () => {
+ it("detects 1-letter + 7-digit DL", () => {
+ const matches = detectPii("DL D1234567 state CA");
+ expect(matches.some((m) => m.type === "us_drivers_license")).toBe(true);
+ });
+
+ it("detects DL: prefix form", () => {
+ const matches = detectPii("license DL:12345678");
+ expect(matches.some((m) => m.type === "us_drivers_license")).toBe(true);
+ });
+ });
+
+ describe("iban", () => {
+ it("detects German IBAN", () => {
+ const matches = detectPii("wire to DE89370400440532013000");
+ expect(matches.some((m) => m.type === "iban")).toBe(true);
+ });
+
+ it("detects UK IBAN", () => {
+ const matches = detectPii("acct GB82WEST12345698765432");
+ expect(matches.some((m) => m.type === "iban")).toBe(true);
+ });
+
+ it("does NOT match arbitrary long alphanum", () => {
+ const matches = detectPii("token ABCDEFGHIJKLMNOP12345");
+ expect(matches.some((m) => m.type === "iban")).toBe(false);
+ });
+ });
+
+ describe("phone (international)", () => {
+ it("detects +44 UK number", () => {
+ const matches = detectPii("call +44 20 7946 0958");
+ expect(matches.some((m) => m.type === "phone")).toBe(true);
+ });
+
+ it("detects +81 Japan number", () => {
+ const matches = detectPii("support +81-3-1234-5678");
+ expect(matches.some((m) => m.type === "phone")).toBe(true);
+ });
+ });
+
+ describe("ip_address (IPv6)", () => {
+ it("detects full-form IPv6", () => {
+ const matches = detectPii("addr 2001:0db8:85a3:0000:0000:8a2e:0370:7334");
+ expect(matches.some((m) => m.type === "ip_address")).toBe(true);
+ });
+
+ it("detects compressed IPv6", () => {
+ const matches = detectPii("addr 2001:db8::8a2e:370:7334");
+ expect(matches.some((m) => m.type === "ip_address")).toBe(true);
+ });
+ });
+
+ describe("icd_10", () => {
+ it("detects F32.9 (major depressive disorder)", () => {
+ const matches = detectPii("diagnosis code F32.9 confirmed");
+ expect(matches.some((m) => m.type === "icd_10")).toBe(true);
+ });
+
+ it("detects E11.65 (type 2 diabetes)", () => {
+ const matches = detectPii("pt has E11.65 on chart");
+ expect(matches.some((m) => m.type === "icd_10")).toBe(true);
+ });
+
+ it("detects code without decimals", () => {
+ const matches = detectPii("dx: Z00 routine");
+ expect(matches.some((m) => m.type === "icd_10")).toBe(true);
+ });
+ });
+
+ describe("icd_9", () => {
+ it("detects numeric ICD-9 with decimal", () => {
+ const matches = detectPii("legacy code 250.01 on record");
+ expect(matches.some((m) => m.type === "icd_9")).toBe(true);
+ });
+
+ it("detects V-code", () => {
+ const matches = detectPii("status V70.0");
+ expect(matches.some((m) => m.type === "icd_9")).toBe(true);
+ });
+ });
+
+ describe("npi", () => {
+ it("detects 10-digit NPI starting with 1", () => {
+ const matches = detectPii("provider NPI 1234567893 treating");
+ expect(matches.some((m) => m.type === "npi")).toBe(true);
+ });
+ });
+
+ describe("crypto_wallet", () => {
+ it("detects BTC legacy address", () => {
+ const matches = detectPii("send to 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
+ expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true);
+ });
+
+ it("detects BTC bech32 address", () => {
+ const matches = detectPii("payout bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
+ expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true);
+ });
+
+ it("detects ETH address", () => {
+ const matches = detectPii("eth 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7");
+ expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true);
+ });
+ });
+
describe("zero-width character bypass", () => {
it("detects email with zero-width space inside", () => {
const text = "contact john\u200B@example.com for info";
diff --git a/packages/transformabl-core/__tests__/redact.overlap.test.ts b/packages/transformabl-core/__tests__/redact.overlap.test.ts
new file mode 100644
index 0000000..119a307
--- /dev/null
+++ b/packages/transformabl-core/__tests__/redact.overlap.test.ts
@@ -0,0 +1,87 @@
+// Overlapping-match redaction (H4 / #40).
+//
+// Detection routinely emits overlapping spans โ e.g. `us_bank_number`
+// (`\d{8,17}`) covers the same digit run as `npi` / `us_drivers_license`. The
+// old descending-start substitution let an inner match's replacement shift the
+// text so an outer match's stale `end` sliced the wrong point, leaving trailing
+// PII digits behind in placeholder/remove modes. These lock in the
+// merge-into-maximal-intervals fix.
+
+import { describe, it, expect } from "vitest";
+import { redactPii } from "../src/redact.js";
+import type { PiiMatch } from "../src/types.js";
+
+describe("redactPii โ overlapping matches merge into maximal intervals", () => {
+ // A partial overlap that, under the old code, left the digits "23" behind:
+ // the inner npi span (higher start) was substituted first, shifting the text
+ // so the outer us_bank_number span's stale `end` sliced past its real end.
+ const text = "id 1234567890123 end MORE";
+ const overlapping: PiiMatch[] = [
+ { type: "us_bank_number", value: "1234567890123", start: 3, end: 16 }, // outer
+ { type: "npi", value: "234", start: 4, end: 7 }, // inner, higher start
+ ];
+
+ it("placeholder mode: the outer span is replaced once and no digits survive", () => {
+ const result = redactPii(text, overlapping, { mode: "placeholder" });
+ expect(result).toBe("id [US_BANK_NUMBER] end MORE");
+ expect(result).not.toMatch(/\d/);
+ });
+
+ it("remove mode: the outer span is removed cleanly and no digits survive", () => {
+ const result = redactPii(text, overlapping, { mode: "remove" });
+ expect(result).toBe("id end MORE");
+ expect(result).not.toMatch(/\d/);
+ });
+
+ it("acceptance (#40): output contains no substring of any original match value", () => {
+ for (const mode of ["placeholder", "remove"] as const) {
+ const result = redactPii(text, overlapping, { mode });
+ for (const m of overlapping) {
+ expect(result).not.toContain(m.value);
+ }
+ expect(result).not.toMatch(/\d/);
+ }
+ });
+
+ it("labels the merged span by its widest contributing type", () => {
+ const result = redactPii(text, overlapping, { mode: "placeholder" });
+ expect(result).toContain("[US_BANK_NUMBER]");
+ expect(result).not.toContain("[NPI]");
+ });
+
+ it("identical spans (same start and end) collapse to one replacement", () => {
+ // us_bank_number and us_drivers_license both match an 8-digit run.
+ const t = "acct 12345678 ok";
+ const same: PiiMatch[] = [
+ { type: "us_bank_number", value: "12345678", start: 5, end: 13 },
+ { type: "us_drivers_license", value: "12345678", start: 5, end: 13 },
+ ];
+ const result = redactPii(t, same, { mode: "placeholder" });
+ expect(result).toBe("acct [US_BANK_NUMBER] ok");
+ expect(result).not.toMatch(/\d/);
+ });
+
+ it("respects `types`: only the requested type's span is redacted", () => {
+ // Only redact npi. Its [4,7] span ("234") is replaced; the surrounding
+ // bank digits are the caller's explicit choice to keep, and the merge must
+ // not sweep the excluded us_bank_number span in.
+ const result = redactPii(text, overlapping, { mode: "placeholder", types: ["npi"] });
+ expect(result).toBe("id 1[NPI]567890123 end MORE");
+ });
+
+ it("mask mode masks the whole merged span (keeps only edge chars)", () => {
+ const result = redactPii(text, overlapping, { mode: "mask" });
+ // Full 13-char span masked keeping 2 at each edge: "12" + 9*'*' + "23".
+ expect(result).toBe("id 12*********23 end MORE");
+ });
+
+ it("non-overlapping matches are unaffected by the merge", () => {
+ const t = "mail x@y.com then 123-45-6789 z";
+ const matches: PiiMatch[] = [
+ { type: "email", value: "x@y.com", start: 5, end: 12 },
+ { type: "ssn", value: "123-45-6789", start: 18, end: 29 },
+ ];
+ const result = redactPii(t, matches, { mode: "placeholder" });
+ expect(result).toBe("mail [EMAIL] then [SSN] z");
+ });
+});
diff --git a/packages/transformabl-core/__tests__/scanTruncation.test.ts b/packages/transformabl-core/__tests__/scanTruncation.test.ts
new file mode 100644
index 0000000..307297c
--- /dev/null
+++ b/packages/transformabl-core/__tests__/scanTruncation.test.ts
@@ -0,0 +1,60 @@
+// PII-scan truncation loudness (M2 / #40).
+//
+// detectPii caps scanning at MAX_PII_SCAN_LENGTH (512 KB). Before this fix the
+// cap was silent, so PII past it flowed through unredacted AND unlogged โ
+// a fail-open that wasn't loud. detectPiiDetailed / TransformResult now carry a
+// `scanTruncated` flag so a consumer can tell "scanned clean" from "not fully
+// scanned."
+
+import { describe, it, expect } from "vitest";
+import {
+ detectPii,
+ detectPiiDetailed,
+ MAX_PII_SCAN_LENGTH,
+} from "../src/detect.js";
+import { transformContent } from "../src/transform.js";
+
+describe("scan-cap truncation is reported (not silent)", () => {
+ it("does not flag truncation for normal-sized input", () => {
+ const r = detectPiiDetailed("contact john@example.com please");
+ expect(r.scanTruncated).toBe(false);
+ expect(r.totalLength).toBe("contact john@example.com please".length);
+ expect(r.scannedLength).toBe(r.totalLength);
+ expect(r.matches.some((m) => m.type === "email")).toBe(true);
+ });
+
+ it("flags truncation and does NOT scan PII past the cap", () => {
+ const filler = "a".repeat(MAX_PII_SCAN_LENGTH + 100);
+ const input = `${filler} leak@example.com`;
+ const r = detectPiiDetailed(input);
+
+ expect(r.scanTruncated).toBe(true);
+ expect(r.scannedLength).toBe(MAX_PII_SCAN_LENGTH);
+ expect(r.totalLength).toBe(input.length);
+ // The email is beyond the cap, so it is (knowingly) not found โ the flag
+ // is what makes that honest rather than a silent miss.
+ expect(r.matches.some((m) => m.type === "email")).toBe(false);
+ });
+
+ it("still finds PII that falls before the cap in an over-cap input", () => {
+ const input = `early@example.com ${"a".repeat(MAX_PII_SCAN_LENGTH)} late@example.com`;
+ const r = detectPiiDetailed(input);
+ expect(r.scanTruncated).toBe(true);
+ const emails = r.matches.filter((m) => m.type === "email");
+ expect(emails).toHaveLength(1);
+ expect(emails[0].value).toBe("early@example.com");
+ });
+
+ it("transformContent surfaces scanTruncated on the result", () => {
+ const small = transformContent("john@example.com");
+ expect(small.scanTruncated).toBe(false);
+
+ const big = transformContent("a".repeat(MAX_PII_SCAN_LENGTH + 1));
+ expect(big.scanTruncated).toBe(true);
+ });
+
+ it("detectPii (array wrapper) stays behaviourally identical to detectPiiDetailed().matches", () => {
+ const input = "ssn 123-45-6789 and mail a@b.co";
+ expect(detectPii(input)).toEqual(detectPiiDetailed(input).matches);
+ });
+});
diff --git a/packages/transformabl-core/package.json b/packages/transformabl-core/package.json
index 5cae6eb..e1c995c 100644
--- a/packages/transformabl-core/package.json
+++ b/packages/transformabl-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@gatewaystack/transformabl-core",
- "version": "0.3.0",
+ "version": "0.4.1",
"private": false,
"license": "MIT",
"type": "module",
@@ -20,5 +20,8 @@
},
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/transformabl-core/src/classify.ts b/packages/transformabl-core/src/classify.ts
index 89f2780..db3f858 100644
--- a/packages/transformabl-core/src/classify.ts
+++ b/packages/transformabl-core/src/classify.ts
@@ -58,6 +58,15 @@ const PII_TO_REGULATORY: Record = {
phone: ["gdpr"],
date_of_birth: ["gdpr", "coppa", "hipaa"],
ip_address: ["gdpr"],
+ us_bank_number: ["pci", "gdpr"],
+ us_itin: ["pci", "gdpr"],
+ us_passport: ["gdpr"],
+ us_drivers_license: ["gdpr"],
+ iban: ["pci", "gdpr"],
+ icd_10: ["hipaa"],
+ icd_9: ["hipaa"],
+ npi: ["hipaa"],
+ crypto_wallet: ["gdpr"],
};
/**
diff --git a/packages/transformabl-core/src/detect.ts b/packages/transformabl-core/src/detect.ts
index 31885a5..992aad7 100644
--- a/packages/transformabl-core/src/detect.ts
+++ b/packages/transformabl-core/src/detect.ts
@@ -1,13 +1,14 @@
// packages/transformabl-core/src/detect.ts
//
-// Regex-based PII detection.
+// Regex-based PII detection. Recognizer set tracks Microsoft Presidio's
+// default recognizer list (US-focused) plus healthcare-specific additions
+// that are standard under HIPAA but not covered by Presidio's core list.
//
// FUTURE WORK:
// - ML-based NER detection (plug in spaCy, Presidio, or cloud NER APIs)
// - Address detection (street addresses, zip codes)
// - Name detection (requires NER - too many false positives with regex)
-// - International phone number formats
-// - Passport numbers, driver's license numbers
+// - Country-specific identifiers (UK NHS, AU ABN, IN PAN, etc.)
import type { PiiType, PiiMatch } from "./types.js";
import { stripInvisible } from "./normalize.js";
@@ -24,45 +25,158 @@ interface PiiPattern {
const BUILTIN_PATTERNS: PiiPattern[] = [
{
type: "email",
- pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
+ // Quantifiers are bounded to RFC 5321 limits (local โค64, domain โค255,
+ // TLD โค24). The previous unbounded `+โฆ+` form around a single mandatory
+ // `@` was quadratic (O(nยฒ)) โ a long alphanumeric run with no `@` made
+ // the engine scan-to-end-then-backtrack from every start position, so a
+ // ~1 MB string of `a`s could block the event loop for minutes (ReDoS).
+ // Bounding the work per start position makes it effectively linear.
+ pattern: /[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\.[a-zA-Z]{2,24}/g,
},
{
type: "phone",
- // US phone numbers (10+ digits): (xxx) xxx-xxxx, xxx-xxx-xxxx, +1xxxxxxxxxx
- // Requires area code (3 digits) + 7-digit number to avoid matching short numbers
- pattern: /(?:\+1[-.\s]?)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}\b/g,
+ // US 10-digit formats AND international E.164 (+<7-14 digits>).
+ pattern:
+ /(?:\+1[-.\s]?)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}\b|\+(?!1[-.\s]?\d)\d{1,3}[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}\b/g,
},
{
type: "ssn",
// US Social Security Numbers: xxx-xx-xxxx
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
},
+ {
+ type: "us_itin",
+ // US Individual Taxpayer Identification Number: 9XX-(7X|8X)-XXXX where
+ // the middle group starts with 7, 8, or 9. Must be checked BEFORE the
+ // generic SSN pattern since ITIN overlaps structurally with SSN โ ITIN
+ // is more specific, so order matters when dedup'ing overlapping hits.
+ pattern: /\b9\d{2}-[78]\d-\d{4}\b/g,
+ },
{
type: "credit_card",
// Major card formats: Visa (13-19), MC (16), Amex (15), Discover (16), Diners (14)
// Matches 13-19 digit sequences with optional separators (space or dash)
pattern: /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6(?:011|5\d{2})|3(?:0[0-5]|[68]\d)\d)[-\s]?\d{4,6}[-\s]?\d{4,5}(?:[-\s]?\d{1,4})?\b/g,
},
+ {
+ type: "us_bank_number",
+ // US bank account numbers: 8-17 digits. Loose by design โ banks don't
+ // publish a format. Tightened with word boundaries + minimum length to
+ // avoid matching short IDs. Will false-positive on other long digit
+ // strings; acceptable for a governance layer that prefers over-
+ // redaction on financial context.
+ pattern: /\b\d{8,17}\b/g,
+ },
+ {
+ type: "us_passport",
+ // US passport: 9 digits (older) or 1 letter + 8 digits (newer).
+ pattern: /\b(?:[A-Z]\d{8}|\d{9})\b/g,
+ },
+ {
+ type: "us_drivers_license",
+ // US driver's license formats are state-specific. Approximation:
+ // 1 letter + 7-8 digits, or 7-9 all-digit sequences with "DL:" prefix.
+ // False-positive rate is non-trivial; callers can disable this type.
+ pattern: /\b(?:DL[:\s]?)?(?:[A-Z]\d{7,8}|\d{7,9})\b/g,
+ },
+ {
+ type: "iban",
+ // ISO 13616 IBAN: 2 letters (country) + 2 check digits + 11-30 alphanum.
+ // Optional spaces every 4 chars are stripped by normalize.ts before scan.
+ pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
+ },
{
type: "ip_address",
- // IPv4 addresses
- pattern: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g,
+ // IPv4 OR IPv6 (full + compressed forms). Three alternatives:
+ // (1) IPv4 dotted-quad
+ // (2) IPv6 full form โ 8 hex groups separated by `:`
+ // (3) IPv6 compressed โ contains `::`, with hex groups on either side
+ pattern:
+ /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b|\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|(?
): PiiMatch[] {
+ return detectPiiDetailed(text, customPatterns).matches;
+}
+
+/**
+ * Detect PII and report scan-cap truncation.
+ * Returns matches plus a `scanTruncated` flag so a truncated scan is never a
+ * silent fail-open.
+ */
+export function detectPiiDetailed(
+ text: string,
+ customPatterns?: Array<{ type: string; pattern: RegExp }>
+): DetectionResult {
// Strip zero-width / invisible characters before scanning so an attacker
// cannot defeat the regex by inserting them inside PII values (e.g.
// `john\u200B@example.com`). Matches found in the stripped form are
@@ -70,6 +184,11 @@ export function detectPii(
// obfuscated span โ including the invisible chars themselves.
const { text: scanText, map } = stripInvisible(text);
+ const scanTruncated = scanText.length > MAX_PII_SCAN_LENGTH;
+ const boundedScanText = scanTruncated
+ ? scanText.slice(0, MAX_PII_SCAN_LENGTH)
+ : scanText;
+
const matches: PiiMatch[] = [];
const allPatterns: Array<{ type: string; pattern: RegExp }> = [
...BUILTIN_PATTERNS,
@@ -81,7 +200,7 @@ export function detectPii(
const regex = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
- while ((match = regex.exec(scanText)) !== null) {
+ while ((match = regex.exec(boundedScanText)) !== null) {
const s = match.index;
const e = match.index + match[0].length;
@@ -107,5 +226,11 @@ export function detectPii(
// Sort by position
matches.sort((a, b) => a.start - b.start);
- return matches;
+
+ return {
+ matches,
+ scanTruncated,
+ scannedLength: boundedScanText.length,
+ totalLength: scanText.length,
+ };
}
diff --git a/packages/transformabl-core/src/normalize.ts b/packages/transformabl-core/src/normalize.ts
index 362dfec..9727093 100644
--- a/packages/transformabl-core/src/normalize.ts
+++ b/packages/transformabl-core/src/normalize.ts
@@ -37,8 +37,10 @@ const INVISIBLE_CHAR_RE = /[\u200B-\u200D\u2060\uFEFF]/;
// Defensive upper bound on the normalization pass. Callers should enforce
// their own payload-size limits upstream; this cap exists so an oversized
// input cannot itself turn the O(n) character walk into a DoS vector.
-// PII detection still runs on the full original input โ the oversized-
-// input path just skips ZW-normalization rather than iterating.
+// Over this length ZW-normalization is skipped (the input is returned
+// unchanged). Note the detection scan has its own, smaller cap
+// (MAX_PII_SCAN_LENGTH, 512 KB) past which PII is not scanned โ detectPii
+// truncates and detectPiiDetailed reports it via `scanTruncated`.
const MAX_NORMALIZE_LENGTH = 1_000_000;
export function stripInvisible(input: string): StrippedText {
diff --git a/packages/transformabl-core/src/redact.ts b/packages/transformabl-core/src/redact.ts
index c3e12f2..8508508 100644
--- a/packages/transformabl-core/src/redact.ts
+++ b/packages/transformabl-core/src/redact.ts
@@ -2,7 +2,16 @@
//
// PII redaction: mask, remove, or replace detected PII.
-import type { PiiMatch, RedactionConfig, RedactionMode } from "./types.js";
+import type { PiiMatch, PiiType, RedactionConfig, RedactionMode } from "./types.js";
+
+/** A maximal interval formed by merging overlapping matches. */
+interface MergedSpan {
+ start: number;
+ end: number;
+ /** Type of the widest contributing match โ used for the placeholder label. */
+ repType: PiiType;
+ repWidth: number;
+}
/**
* Redact PII matches from text.
@@ -11,6 +20,15 @@ import type { PiiMatch, RedactionConfig, RedactionMode } from "./types.js";
* - "mask": Replace middle characters with mask char (e.g., "jo**@example.com")
* - "remove": Remove the PII entirely
* - "placeholder": Replace with type label (e.g., "[EMAIL]")
+ *
+ * Overlapping matches are merged into maximal intervals before substitution.
+ * Detection routinely produces overlaps โ e.g. `us_bank_number` (`\d{8,17}`)
+ * spans the same digit run as `npi` / `us_drivers_license`. Substituting them
+ * independently (the old descending-start loop) let an inner match's
+ * replacement shift the text so an outer match's now-stale `end` sliced the
+ * wrong point, leaving trailing PII digits behind in placeholder/remove modes
+ * (H4). Merging first guarantees the spans handed to substitution are
+ * disjoint, so no substitution can invalidate another's offsets.
*/
export function redactPii(
text: string,
@@ -26,30 +44,58 @@ export function redactPii(
? new Set(config.types)
: null; // null = redact all
- // Process matches in reverse order to preserve positions
- const sorted = [...matches].sort((a, b) => b.start - a.start);
- let result = text;
+ // Only redact matches the caller asked for. Filtering BEFORE the merge is
+ // what keeps an excluded type from being swept into a redacted span just
+ // because it overlapped an included one. Drop zero-width/inverted spans too.
+ const selected = matches.filter(
+ (m) => (!typesToRedact || typesToRedact.has(m.type)) && m.end > m.start
+ );
+ if (selected.length === 0) return text;
- for (const match of sorted) {
- if (typesToRedact && !typesToRedact.has(match.type)) continue;
+ // Merge overlapping spans into maximal intervals. Sort ascending by start
+ // (then end) and extend the current interval whenever the next match starts
+ // before the current one ends. Touching spans (start === prev end) stay
+ // separate โ they are already disjoint and safe to substitute independently.
+ const asc = [...selected].sort((a, b) => a.start - b.start || a.end - b.end);
+ const merged: MergedSpan[] = [];
+ for (const m of asc) {
+ const width = m.end - m.start;
+ const last = merged[merged.length - 1];
+ if (last && m.start < last.end) {
+ if (m.end > last.end) last.end = m.end;
+ if (width > last.repWidth) {
+ last.repType = m.type;
+ last.repWidth = width;
+ }
+ } else {
+ merged.push({ start: m.start, end: m.end, repType: m.type, repWidth: width });
+ }
+ }
- let replacement: string;
+ // Substitute right-to-left so earlier offsets stay valid. Spans are disjoint,
+ // so the substring being replaced is exactly the original PII span โ no other
+ // span's coordinates can have shifted underneath it.
+ let result = text;
+ for (let i = merged.length - 1; i >= 0; i--) {
+ const span = merged[i];
+ const original = text.slice(span.start, span.end);
+ let replacement: string;
switch (mode) {
case "mask":
- replacement = maskValue(match.value, maskChar, maskKeep);
+ replacement = maskValue(original, maskChar, maskKeep);
break;
case "remove":
replacement = "";
break;
case "placeholder":
replacement = config?.placeholder
- ? config.placeholder.replace("{TYPE}", match.type.toUpperCase())
- : `[${match.type.toUpperCase()}]`;
+ ? config.placeholder.replace("{TYPE}", span.repType.toUpperCase())
+ : `[${span.repType.toUpperCase()}]`;
break;
}
- result = result.slice(0, match.start) + replacement + result.slice(match.end);
+ result = result.slice(0, span.start) + replacement + result.slice(span.end);
}
return result;
diff --git a/packages/transformabl-core/src/transform.ts b/packages/transformabl-core/src/transform.ts
index d644754..065e2f2 100644
--- a/packages/transformabl-core/src/transform.ts
+++ b/packages/transformabl-core/src/transform.ts
@@ -3,7 +3,7 @@
// Full transformation pipeline: detect โ classify โ redact โ extract metadata.
import type { TransformConfig, TransformResult } from "./types.js";
-import { detectPii } from "./detect.js";
+import { detectPiiDetailed } from "./detect.js";
import { redactPii } from "./redact.js";
import { classifyContent } from "./classify.js";
import { extractMetadata } from "./metadata.js";
@@ -23,7 +23,8 @@ export function transformContent(
config?: TransformConfig
): TransformResult {
// 1. Detect PII
- const piiMatches = detectPii(content, config?.customPatterns);
+ const detection = detectPiiDetailed(content, config?.customPatterns);
+ const piiMatches = detection.matches;
// 2. Classify
const classification =
@@ -54,5 +55,6 @@ export function transformContent(
classification,
metadata,
transformed: redacted !== content,
+ scanTruncated: detection.scanTruncated,
};
}
diff --git a/packages/transformabl-core/src/types.ts b/packages/transformabl-core/src/types.ts
index 171c9e7..4fbb3c2 100644
--- a/packages/transformabl-core/src/types.ts
+++ b/packages/transformabl-core/src/types.ts
@@ -2,12 +2,26 @@
/** Categories of PII that can be detected. */
export type PiiType =
+ // Universal
| "email"
| "phone"
| "ssn"
| "credit_card"
| "ip_address"
- | "date_of_birth";
+ | "date_of_birth"
+ // US identifiers (Presidio parity)
+ | "us_bank_number"
+ | "us_itin"
+ | "us_passport"
+ | "us_drivers_license"
+ // International banking
+ | "iban"
+ // Healthcare (HIPAA PHI)
+ | "icd_10"
+ | "icd_9"
+ | "npi"
+ // Financial
+ | "crypto_wallet";
/** A single PII detection match. */
export interface PiiMatch {
@@ -88,6 +102,13 @@ export interface TransformResult {
metadata: ContentMetadata;
/** Whether any transformation was applied. */
transformed: boolean;
+ /**
+ * True when the input exceeded the PII scan cap (512 KB of invisible-
+ * stripped text) and content past the cap was NOT scanned for PII. A
+ * consumer must surface this rather than treat the result as a clean pass โ
+ * fail-open must be loud (M2 / #40).
+ */
+ scanTruncated: boolean;
}
/** Configuration for the full transformation pipeline. */
diff --git a/packages/transformabl-core/tsconfig.json b/packages/transformabl-core/tsconfig.json
index f3f2f18..6566a49 100644
--- a/packages/transformabl-core/tsconfig.json
+++ b/packages/transformabl-core/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"]
}
diff --git a/packages/transformabl/LICENSE b/packages/transformabl/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/transformabl/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/transformabl/package.json b/packages/transformabl/package.json
index e32affa..9454df2 100644
--- a/packages/transformabl/package.json
+++ b/packages/transformabl/package.json
@@ -1,6 +1,6 @@
{
"name": "@gatewaystack/transformabl",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": false,
"license": "MIT",
"type": "module",
@@ -19,12 +19,15 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@gatewaystack/transformabl-core": "^0.3.0"
+ "@gatewaystack/transformabl-core": "^0.4.1"
},
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0"
},
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/transformabl/src/index.ts b/packages/transformabl/src/index.ts
index 66a7dbd..94a04da 100644
--- a/packages/transformabl/src/index.ts
+++ b/packages/transformabl/src/index.ts
@@ -80,6 +80,8 @@ export function transformabl(config?: TransformablMiddlewareConfig): RequestHand
export {
transformContent,
detectPii,
+ detectPiiDetailed,
+ MAX_PII_SCAN_LENGTH,
redactPii,
classifyContent,
extractMetadata,
diff --git a/packages/transformabl/tsconfig.json b/packages/transformabl/tsconfig.json
index f6426b1..1a667f2 100644
--- a/packages/transformabl/tsconfig.json
+++ b/packages/transformabl/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"],
"references": [
diff --git a/packages/validatabl-core/LICENSE b/packages/validatabl-core/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/validatabl-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/validatabl-core/package.json b/packages/validatabl-core/package.json
index c8dd361..072e135 100644
--- a/packages/validatabl-core/package.json
+++ b/packages/validatabl-core/package.json
@@ -20,5 +20,8 @@
},
"devDependencies": {
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/validatabl-core/tsconfig.json b/packages/validatabl-core/tsconfig.json
index f3f2f18..6566a49 100644
--- a/packages/validatabl-core/tsconfig.json
+++ b/packages/validatabl-core/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"]
}
diff --git a/packages/validatabl/LICENSE b/packages/validatabl/LICENSE
new file mode 100644
index 0000000..c29d293
--- /dev/null
+++ b/packages/validatabl/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Reducibl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/validatabl/package.json b/packages/validatabl/package.json
index 3ffa24e..b214cc9 100644
--- a/packages/validatabl/package.json
+++ b/packages/validatabl/package.json
@@ -27,5 +27,8 @@
"devDependencies": {
"@types/express": "^4.17.21",
"typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/validatabl/tsconfig.json b/packages/validatabl/tsconfig.json
index 31b0f44..978a775 100644
--- a/packages/validatabl/tsconfig.json
+++ b/packages/validatabl/tsconfig.json
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
- "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ "tsBuildInfoFile": "tsconfig.tsbuildinfo"
},
"include": ["src"],
"references": [