the-stripper
v0.1.1
Published
Strip and reshape JSON safely before sending it to the client.
Maintainers
Readme
The Stripper
The Stripper is a small JavaScript/TypeScript library for cleaning JSON before you send it back from a server action, API route, or Cloud Run function.
Think of JSON like boxes inside boxes:
- A key is the label on a box:
"secret","user_email","docs" - A value is what is inside the box
- A path is the route to a box:
"docs.0.report_status" - A sanitised response is the clean box you can safely return
The Stripper helps you do two simple things:
- Remove unsafe pieces from a JSON object
- Build a new clean JSON shape from a raw JSON object
The library is:
- Pure and synchronous
- Dependency-free at runtime
- Safe for server-side usage in Next.js/GCP
- Non-mutating for arrays and plain objects
- Conservative with non-plain objects such as
Date
Production Safety Defaults
The Stripper is meant to sit between raw server data and the JSON you return to a browser.
So it has a few safety rules built in:
- It removes dangerous object keys:
__proto__,constructor, andprototype. - It creates clean objects without inherited prototype values.
getPathonly reads real keys on the object, not hidden inherited keys.RegExpkey patterns are checked safely, so/secret/gand/secret/ydo not skip every second matching key.**wildcard paths are matched with caching, so complicated wildcard paths do not explode into very slow recursive work.
Simple version:
The Stripper should return clean JSON data, not magic prototype behavior.Can It Clean Any JSON?
Yes, it can clean any JSON-like value.
JSON-like means:
- objects
- arrays
- strings
- numbers
- booleans
null
It can clean small JSON, big JSON, nested JSON, and arrays of JSON rows.
But it is not magic. You must tell it the cleaning rule.
For example:
const safe = stripJson(raw, {
remove: {
keys: ["secret", "user_email"],
},
});Simple rule:
The Stripper can clean the JSON, but you decide what is unsafe.Important boundaries:
- It does not guess which fields are sensitive.
- It preserves non-plain objects like
Dateinstead of opening them. - It throws
JsonStripperErrorfor circular JSON, invalid paths, too-deep JSON, or too-large JSON. - It does not mutate the original plain object or array.
- It always blocks prototype-pollution keys, even if you forgot to list them in
remove.keys.
Install
npm install the-stripperImport
import {
stripJson,
shapeJson,
shapeDocsResponse,
createJsonStripper,
getPath,
parsePath,
matchesPath,
JsonStripperError,
} from "the-stripper";TypeScript types ship with the package — no @types/the-stripper needed. The package is ESM-only (import, not require).
Function Navigation
Click a function name to jump to its explanation and example.
| Function | Use it when |
| --- | --- |
| stripJson(value, options) | You already like the JSON shape and want to remove unsafe fields. |
| shapeJson(input, shape) | You want to build a new clean JSON shape from raw JSON. |
| shapeDocsResponse(result, options) | Your API response looks like { docs: [...], pagination: ... }. |
| createJsonStripper(options) | You want to save one cleaning rule and reuse it. |
| getPath(value, path) | You want to safely read one nested value. |
| parsePath(path) | You want to turn a path string into path pieces. |
| matchesPath(actualPath, rulePath) | You want to check if a real path matches a wildcard path rule. |
| JsonStripperError | You want to catch library errors cleanly. |
| TypeScript Types | You want to see the exported type shapes. |
Run The Examples
The commands below (npm run example, npm run portfolio:sample, node attempts-corner.js) only work inside a clone of this repository — they build and import the local dist/ output, not the published the-stripper package. In your own project, import ... from "the-stripper" as shown above.
example.js shows every public function using this raw JSON file:
samples/attempts-corner.raw.jsonRun:
npm run exampleThe output is printed in separate labelled sections.
Run The Portfolio Sample
portfolio-example.js uses this sample file:
samples/projects.jsonRun:
npm run portfolio:sampleSmall snippet:
import { stripJson } from "the-stripper";
const cleanJson = stripJson(rawJson, {
remove: {
keys: ["_id", "end"],
},
});That removes every _id and every end key, wherever they are hiding.
If you only want to remove a specific field, like each project's first image, use a path:
const cleanJson = stripJson(rawJson, {
remove: {
keys: ["_id"],
paths: ["pageProps.projects.*.images.0"],
},
});Basic Words
Key
A key is the property name.
{
"user_email": "[email protected]"
}Here, the key is user_email.
Path
A path tells the library where to go.
{
"docs": [
{
"report_status": "evaluated"
}
]
}The path to "evaluated" is:
docs.0.report_statusThat means:
- Go into
docs - Pick item
0 - Read
report_status
*
* means "one thing here".
docs.*.user_emailThat means:
- Go into
docs - For each item inside
docs - Remove or match
user_email
**
** means "look anywhere below here".
**.secretThat means:
- Look at the whole object
- No matter how deep
secretis - Match it
stripJson(value, options)
stripJson takes JSON in, removes unwanted pieces, and gives clean JSON back.
It does not change the original object.
Remove By Key Name With remove.keys
Use this when a key is unsafe everywhere.
Input:
const raw = {
name: "Asha",
secret: "hide-this",
profile: {
email: "[email protected]",
secret: "hide-this-too",
},
};Code:
const safe = stripJson(raw, {
remove: {
keys: ["secret"],
},
});Output:
{
"name": "Asha",
"profile": {
"email": "[email protected]"
}
}Simple rule:
If any key is named "secret", remove it from anywhere.Remove By Key Pattern With remove.keyPatterns
Use this when many unsafe keys start or end the same way.
Input:
const raw = {
attempt_id: "attempt-1",
change_request_id: "internal-1",
change_request_status: "pending",
title: "Example Assessment",
};Code:
const safe = stripJson(raw, {
remove: {
keyPatterns: ["change_request_*"],
},
});Output:
{
"attempt_id": "attempt-1",
"title": "Example Assessment"
}Simple rule:
"change_request_*" means remove keys that start with "change_request_".The * inside a key pattern means "any text".
More examples:
stripJson(raw, {
remove: {
keyPatterns: ["*_token", /^private_/],
},
});This removes keys like:
debug_token
auth_token
private_note
private_payloadRemove By Exact Path With remove.paths
Use this when only one exact place is unsafe.
Input:
const raw = {
report_metadata: {
result: {
total_user_score: 2,
score_by_question: {
q1: 1,
},
},
},
};Code:
const safe = stripJson(raw, {
remove: {
paths: ["report_metadata.result.score_by_question"],
},
});Output:
{
"report_metadata": {
"result": {
"total_user_score": 2
}
}
}Simple rule:
Walk this exact route and remove the thing there.Remove From Every Array Item With *
Use * when the unsafe field appears inside many array rows.
Input:
const raw = {
docs: [
{ attempt_id: "a1", user_email: "[email protected]" },
{ attempt_id: "a2", user_email: "[email protected]" },
],
};Code:
const safe = stripJson(raw, {
remove: {
paths: ["docs.*.user_email"],
},
});Output:
{
"docs": [
{
"attempt_id": "a1"
},
{
"attempt_id": "a2"
}
]
}Simple rule:
Inside docs, remove user_email from every item.Remove From Anywhere Deep With **
Use ** when the unsafe field can be hiding at any depth.
Input:
const raw = {
secret: "top",
assessment_metadata: {
totp: {
secret: "deep",
},
},
};Code:
const safe = stripJson(raw, {
remove: {
paths: [["**", "secret"]],
},
});Output:
{
"assessment_metadata": {
"totp": {}
}
}Simple rule:
Find any path that ends with secret and remove it.Use A Path Array When A Key Has A Dot
String paths split on dots. So if the real key contains a dot, use a path array.
Input:
const raw = {
"metadata.with.dot": {
secret: "hide",
safe: "keep",
},
};Code:
const safe = stripJson(raw, {
remove: {
paths: [["metadata.with.dot", "secret"]],
},
});Output:
{
"metadata.with.dot": {
"safe": "keep"
}
}Simple rule:
Use an array path when one key name has a dot inside it.Keep Only Some Paths With keep.paths
Use keep.paths when you want to start with nothing and copy only safe fields.
Input:
const raw = {
docs: [
{
assessment_title: "Example Assessment",
attempt_id: "attempt-1",
user_email: "[email protected]",
},
],
pagination: {
total: 1,
page: 1,
},
debug: {
trace_id: "internal",
},
};Code:
const safe = stripJson(raw, {
keep: {
paths: [
"docs.*.assessment_title",
"docs.*.attempt_id",
"pagination",
],
},
});Output:
{
"docs": [
{
"assessment_title": "Example Assessment",
"attempt_id": "attempt-1"
}
],
"pagination": {
"total": 1,
"page": 1
}
}Simple rule:
Only copy the paths I listed. Leave everything else behind.Safety Limits
Use maxDepth to stop JSON that is too deeply nested.
Use maxNodes to stop JSON that is too large.
const safe = stripJson(raw, {
remove: {
keys: ["secret"],
},
maxDepth: 50,
maxNodes: 100000,
});Default values:
maxDepth = 50
maxNodes = 100000If the JSON is circular, too deep, too large, or has a bad path, The Stripper throws JsonStripperError.
shapeJson(input, shape)
shapeJson builds a brand-new clean object.
Use it when you want to say:
Take these exact fields from the raw object and put them into this new shape.Input:
const raw = {
assessment_title: "Example Assessment",
assessment_slug: "example-assessment",
assessment_metadata: {
report: {
type: "summary_with_response",
},
},
report_metadata: {
result: {
total_user_score: 2,
max_possible_score: 30,
score_percentage: 7,
},
},
user_email: "[email protected]",
};Code:
const safe = shapeJson(raw, {
title: "assessment_title",
slug: "assessment_slug",
report_type: "assessment_metadata.report.type",
score: {
total_user_score: "report_metadata.result.total_user_score",
max_possible_score: "report_metadata.result.max_possible_score",
score_percentage: "report_metadata.result.score_percentage",
},
label: (row) =>
`${row.report_metadata.result.total_user_score}/${row.report_metadata.result.max_possible_score}`,
missing_value: null,
});Output:
{
"title": "Example Assessment",
"slug": "example-assessment",
"report_type": "summary_with_response",
"score": {
"total_user_score": 2,
"max_possible_score": 30,
"score_percentage": 7
},
"label": "2/30",
"missing_value": null
}Simple rules:
- A string path copies a value from that path
- A nested object creates a nested output object
- A function can calculate a new value
nullwritesnull- Anything not listed in the shape is not returned
Shape An Array
Use { from, each } when the raw JSON has an array.
Input:
const raw = {
docs: [
{
assessment_title: "Assessment One",
attempt_id: "a1",
user_email: "[email protected]",
},
{
assessment_title: "Assessment Two",
attempt_id: "a2",
user_email: "[email protected]",
},
],
};Code:
const safe = shapeJson(raw, {
docs: {
from: "docs",
each: {
assessment_title: "assessment_title",
attempt_id: "attempt_id",
},
},
});Output:
{
"docs": [
{
"assessment_title": "Assessment One",
"attempt_id": "a1"
},
{
"assessment_title": "Assessment Two",
"attempt_id": "a2"
}
]
}Simple rule:
For each item in docs, build this smaller item.shapeDocsResponse(result, options)
shapeDocsResponse is for the common API shape:
{
docs: [...],
pagination: {...}
}It does three things:
- Reads the docs array
- Maps each row into a clean row
- Keeps pagination if pagination exists
Input:
const rawResponse = {
docs: [
{
assessment_title: "Example Assessment",
assessment_slug: "example-assessment",
attempt_id: "attempt-1",
assessment_metadata: {
report: {
type: "summary_with_response",
},
},
user_email: "[email protected]",
secret: "hide",
},
],
pagination: {
total: 1,
page: 1,
},
};Code:
const safe = shapeDocsResponse(rawResponse, {
docsPath: "docs",
paginationPath: "pagination",
mapRow: (row) => ({
assessment_title: row.assessment_title,
assessment_slug: row.assessment_slug,
attempt_id: row.attempt_id,
report_type: getPath(row, "assessment_metadata.report.type"),
}),
finalStrip: {
remove: {
keys: ["secret", "user_email"],
},
},
});Output:
{
"docs": [
{
"assessment_title": "Example Assessment",
"assessment_slug": "example-assessment",
"attempt_id": "attempt-1",
"report_type": "summary_with_response"
}
],
"pagination": {
"total": 1,
"page": 1
}
}Simple rule:
Clean every row in docs, then return docs and pagination.Options:
docsPath: where the docs array lives. Default is"docs"paginationPath: where pagination lives. Default is"pagination"mapRow: function that returns one clean rowfinalStrip: extrastripJsoncleanup after mapping
If the raw object has no pagination, the output has only docs.
createJsonStripper(options)
createJsonStripper saves a cleaning rule so you can use it again and again.
Code:
const removeSecrets = createJsonStripper({
remove: {
keys: [
"totp",
"secret",
"score_by_question",
"user_id",
"user_email",
"author_email",
],
keyPatterns: ["change_request_*"],
},
});
const safeOne = removeSecrets(rawOne);
const safeTwo = removeSecrets(rawTwo);Simple rule:
Make one cleaner. Use that cleaner on many JSON objects.getPath(value, path)
getPath safely reads one value from inside a JSON object.
Input:
const raw = {
docs: [
{
report_status: "evaluated",
},
],
};Code:
const reportStatus = getPath(raw, "docs.0.report_status");Output:
"evaluated";You can also use a path array:
const reportStatus = getPath(raw, ["docs", 0, "report_status"]);If the path is missing, getPath returns undefined.
Important:
getPath does not use * or **.It reads one exact path only.
parsePath(path)
parsePath turns a path into path pieces.
Code:
const path = parsePath("docs.*.report_status");Output:
["docs", "*", "report_status"];Use it when you want the library to check that a path is valid.
Bad paths throw JsonStripperError:
parsePath("");
parsePath("docs..report_status");Simple rule:
Take a path string and split it into pieces.matchesPath(actualPath, rulePath)
matchesPath answers yes or no:
Does this real path match this rule path?Code:
const matched = matchesPath(
["docs", 0, "report_status"],
["docs", "*", "report_status"],
);Output:
true;Another example:
matchesPath(
["assessment_metadata", "totp", "secret"],
["**", "secret"],
);Output:
true;Simple rules:
*matches one piece**matches many pieces, or no pieces- Normal text must match the same normal text
JsonStripperError
JsonStripperError is the error class used by this library.
Catch it when you want to handle Stripper problems cleanly.
try {
const safe = stripJson(raw, {
remove: {
paths: ["docs..bad_path"],
},
});
} catch (error) {
if (error instanceof JsonStripperError) {
console.error("The Stripper could not clean this JSON:", error.message);
} else {
throw error;
}
}It can happen when:
- A path is empty or invalid
- JSON is circular
- JSON is deeper than
maxDepth - JSON has more nodes than
maxNodes
Full Attempts Corner Style Example
This is the pattern for a server action file.
The library stays universal. The server action decides the product-specific response shape.
The local demo file can also show the clean contract:
node attempts-corner.jsIt prints two clean sections:
- non-published clean after
- published clean after
If you pass a raw JSON file path, it prints only the clean JSON response:
node attempts-corner.js samples/attempts-corner.raw.json"use server";
import { getPath, shapeDocsResponse } from "the-stripper";
const SAFE_REPORT_RESULT_PATHS = {
totalUserScore: "report_metadata.result.total_user_score",
scorePercentage: "report_metadata.result.score_percentage",
maxPossibleScore: "report_metadata.result.max_possible_score",
};
const ASSESSMENT_RAW_INTERNAL_KEYS = [
"assessment_id",
"assessment_created_at",
"assessment_metadata",
];
const ATTEMPT_ACTOR_RAW_KEYS = [
"user_id",
"user_email",
"author_email",
];
const ATTEMPT_TIMING_AND_STATE_RAW_KEYS = [
"archived_at",
"attempt_start_time",
"attempt_calculated_end_time",
"is_attempt_closed",
"planned_assessment_id",
"is_plagiarised",
"report_updated_at",
];
const SECRET_RAW_KEYS = ["totp", "secret"];
const REPORT_RESULT_INTERNAL_KEYS = [
"total_questions",
"correct_questions",
"wrong_questions",
"percentage",
"answered_questions",
"unanswered_questions",
];
const QUESTION_SCORE_DETAIL_KEYS = [
"score_by_question",
"max_points",
"max_negative_points",
"user_score",
];
const FINAL_STRIP = {
remove: {
keys: [
...ASSESSMENT_RAW_INTERNAL_KEYS,
...ATTEMPT_ACTOR_RAW_KEYS,
...ATTEMPT_TIMING_AND_STATE_RAW_KEYS,
...SECRET_RAW_KEYS,
...REPORT_RESULT_INTERNAL_KEYS,
...QUESTION_SCORE_DETAIL_KEYS,
],
keyPatterns: ["change_request_*"],
},
};
export async function shapeAttemptsCornerAssessmentResponse(rawResponse) {
return shapeDocsResponse(rawResponse, {
mapRow: mapAttemptsCornerAssessmentRow,
finalStrip: FINAL_STRIP,
});
}
function mapAttemptsCornerAssessmentRow(row) {
const reportStatus = getPath(row, "report_status") ?? null;
const isPublished = String(reportStatus).toLowerCase() === "published";
const baseRow = {
assessment_title: getPath(row, "assessment_title") ?? null,
assessment_slug: getPath(row, "assessment_slug") ?? null,
attempt_id: getPath(row, "attempt_id") ?? null,
attempt_closed_at: getPath(row, "attempt_closed_at") ?? null,
report_status: reportStatus,
report_type: getPath(row, "assessment_metadata.report.type") ?? null,
};
if (!isPublished) {
return {
...baseRow,
report_metadata: null,
};
}
return {
...baseRow,
report_metadata: {
result: {
total_user_score:
getPath(row, SAFE_REPORT_RESULT_PATHS.totalUserScore) ?? null,
score_percentage:
getPath(row, SAFE_REPORT_RESULT_PATHS.scorePercentage) ?? null,
max_possible_score:
getPath(row, SAFE_REPORT_RESULT_PATHS.maxPossibleScore) ?? null,
},
},
};
}For a non-published report, the row becomes:
{
"assessment_title": "Example Assessment",
"assessment_slug": "example-assessment",
"attempt_id": "attempt-1",
"attempt_closed_at": "2026-06-20T11:12:25.422Z",
"report_status": "evaluated",
"report_type": "summary_with_response",
"report_metadata": null
}For a published report, the row becomes:
{
"assessment_title": "Example Assessment",
"assessment_slug": "example-assessment",
"attempt_id": "attempt-1",
"attempt_closed_at": "2026-06-20T11:12:25.422Z",
"report_status": "published",
"report_type": "summary_with_response",
"report_metadata": {
"result": {
"total_user_score": 2,
"score_percentage": 7,
"max_possible_score": 30
}
}
}TypeScript Types
type StripPathSegment = string | number | "*" | "**";
type StripPath = string | readonly StripPathSegment[];
type StripJsonOptions = {
remove?: {
keys?: readonly string[];
keyPatterns?: readonly (string | RegExp)[];
paths?: readonly StripPath[];
};
keep?: {
paths?: readonly StripPath[];
};
maxDepth?: number;
maxNodes?: number;
};
type ShapeResolver<TRow> = (row: TRow) => unknown;
type ShapeField<TRow> =
| string
| readonly StripPathSegment[]
| ShapeResolver<TRow>
| ShapeArray<TRow>
| ShapeObject<TRow>
| null;
type ShapeArray<TRow> = {
from: StripPath;
each: ShapeObject<unknown> | ShapeResolver<unknown>;
};
type ShapeObject<TRow> = {
[outputKey: string]: ShapeField<TRow>;
};