Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ interface ParseFieldOptions {
pattern?: string; // String only, valid RegExp
geo?: boolean; // GeoPoint only → 2dsphere index
ttlSeconds?: number; // Date only → TTL index
clientWritable?: boolean; // default true; false = fromParams ignores it
index?: boolean | 1 | -1; // mutually exclusive with `unique`
unique?: boolean; // mutually exclusive with `index`
}
Expand Down Expand Up @@ -274,6 +275,27 @@ class BaseModel extends Parse.Object {
**skipped** (`IMG`, `File` need their own handling).
- An Array field converts to pointers **only if `targetClass` is set**.
- A Pointer given `null` or `{}` is set to `null` (explicit clear).
- Fields declared `clientWritable: false` are **skipped**, whatever the body
contains — see below.

**`fromParams` sets every declared field the request carries.** That is mass
assignment: an endpoint accepting `{"title": "…"}` also accepts
`{"title": "…", "status": "published", "views": 9999}`, and the row saves with
values the caller was never meant to choose. Nothing throws; nothing is logged.

Mark server-owned fields on the model and the rule holds wherever `fromParams`
is used, rather than depending on every endpoint remembering to overwrite them:

```ts
@ParseField({type: 'Number', min: 0, clientWritable: false})
declare views: number;

note.views += 1; // your code — unaffected
Note.fromParams(req.params); // `views` in the body is discarded
```

It is the mirror of Parse's `protectedFields`, which hides fields on the way
**out**; this refuses them on the way **in**.

`Parse.Query` returns `Parse.Object`, not your typed class — cast the result
(`row as Product`) or direct property access is unavailable.
Expand Down Expand Up @@ -720,8 +742,32 @@ function generateRandomString(length?: number): string;
function generateRandomInteger(length: number): string;
function sleep(ms: number): Promise<unknown>;
function formatCount(num: any, locale?: string): string; // compact notation

function paginate<T>(
query: Parse.Query,
params: {limit?: unknown; skip?: unknown},
options?: {defaultLimit?: number; maxLimit?: number; useMasterKey?: boolean; sessionToken?: string}
): Promise<{results: T[]; count: number; limit: number; skip: number; hasMore: boolean}>;
```

**`paginate` is how to write a list endpoint.** It reads `limit` and `skip` as
the strings a GET sends, caps the limit (`MAX_QUERY_LIMIT` by default), calls
`withCount()` so `count` is the **total matching rows** rather than the size of
this page, and computes `hasMore`.

```ts
const query = new Parse.Query(Product).descending('createdAt');
return paginate<Product>(query, req.params, {useMasterKey: true});
```

The hand-written version returns `results.length` as the count — the length of
an array the client already has, which no paginator can use — and it runs
perfectly while doing so.

**Sort the query yourself.** `paginate` does not choose an order, because the
right one belongs to the endpoint. Without one, MongoDB guarantees nothing:
rows appear on two pages or on none as data changes, and nothing errors.

**`catchError` is the error convention** — use it for all async work rather than
`try/catch` around `await`:

Expand Down
2 changes: 1 addition & 1 deletion docs/assets/search-index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/assets/search-index.json

Large diffs are not rendered by default.

39 changes: 35 additions & 4 deletions docs/guide/from-params.html
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,45 @@ <h3 id="server-fields">Server-controlled fields</h3>
<p>
If a field is declared, a caller can set it. A <code>status</code>,
<code>isApproved</code> or <code>ownerId</code> that your server means to own will be
taken from the body if it is there.
taken from the body if it is there — with no error, and no log. That is mass
assignment, and it is the reason <code>clientWritable</code> exists.
</p>

<p>
Declare it on the <b>model</b>, and <code>fromParams</code> discards the field whatever
the request contains:
</p>

<pre class="code-block"><code><span class="t-dec">@ParseField</span>({type: <span class="t-str">'String'</span>, enum: [<span class="t-str">'pending'</span>, <span class="t-str">'approved'</span>], clientWritable: <span class="t-key">false</span>})
<span class="t-key">declare</span> status: string;

<span class="t-dec">@ParseField</span>({type: <span class="t-str">'Pointer'</span>, targetClass: <span class="t-str">'_User'</span>, clientWritable: <span class="t-key">false</span>})
<span class="t-key">declare</span> owner: Parse.User;</code></pre>

<p>
It governs <code>fromParams</code> only, not the field. Your own code writes it as
freely as ever:
</p>

<pre class="code-block"><code><span class="t-key">const</span> product = Product.fromParams(req.params);

<span class="t-com">// Overwrite anything the caller must not decide.</span>
product.set(<span class="t-str">'status'</span>, <span class="t-str">'pending'</span>);
product.set(<span class="t-str">'owner'</span>, req.user!);</code></pre>
<span class="t-com">// Ignored if the body sent them; yours to set here.</span>
product.status = <span class="t-str">'pending'</span>;
product.owner = req.user!;</code></pre>

<div class="note">
<span class="note-title">Why declare it rather than overwrite it</span>
<p>
Overwriting after the call works, and it is still correct. But it has to be done in
every endpoint that builds this model, and remembered by everyone who adds the next
one. Forgetting is silent — the value is simply the caller's. Declared on the field,
the rule holds wherever <code>fromParams</code> is used.
</p>
<p>
This is the mirror of Parse's <code>protectedFields</code>, which hides fields on the
way <b>out</b>. <code>clientWritable</code> refuses them on the way <b>in</b>.
</p>
</div>

<p>
A <code>@BeforeSave</code> trigger is the stronger place for this, because it applies on
Expand Down
36 changes: 36 additions & 0 deletions docs/guide/queries.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,42 @@ <h2 id="sorting">Sorting and pagination</h2>
"page 2" is not guaranteed to exclude what was on page 1.
</p>

<h3 id="paginate">Or use <code>paginate</code></h3>
<p>
The code above is what every list endpoint ends up writing, and the obvious version of
it is wrong in a way that runs. <code>paginate</code> does the whole job:
</p>
<pre class="code-block"><code><span class="t-key">const</span> query = <span class="t-key">new</span> Parse.Query(Product).descending(<span class="t-str">'createdAt'</span>);
<span class="t-key">return</span> paginate&lt;Product&gt;(query, req.params, {useMasterKey: <span class="t-key">true</span>});

<span class="t-com">// → {results, count, limit, skip, hasMore}</span></code></pre>
<p>
It reads <code>limit</code> and <code>skip</code> as the strings a GET sends, caps the
limit, and asks the database for the <b>total</b> matching rows in the same round trip
via <code>withCount()</code>.
</p>

<div class="note">
<span class="note-title">Why <code>count</code> is the total, not the page size</span>
<p>
The hand-written version usually returns <code>results.length</code>. That is the size
of the page you already have: a client cannot draw "page 3 of 12" from it, or decide
whether to enable a next button. Nothing fails — the endpoint answers 200 with
plausible JSON, and the list is quietly unusable.
</p>
<p>
A second <code>count()</code> query is the other common answer. It costs two round
trips and can disagree with the first under concurrent writes, which shows up as a
page count that flickers.
</p>
</div>

<p>
<code>paginate</code> deliberately does not sort for you — the right order belongs to the
endpoint. It is still required: an unsorted paginated list repeats and drops rows as
data changes underneath it.
</p>

<h3 id="deep-paging">Paging deeply</h3>
<p><code>skip</code> gets slower the further in you go, because the database still walks the skipped rows. For a large export, page on a sort key instead:</p>
<pre class="code-block"><code><span class="t-key">let</span> cursor: Date | <span class="t-key">undefined</span>;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "parse-server-kit",
"version": "3.0.5",
"version": "3.1.0",
"description": "Decorator-driven toolkit for Parse Server: models, cloud functions, triggers, cron, REST routing, ACL, transactions, optimistic locking and Swagger, from TypeScript decorators.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
35 changes: 34 additions & 1 deletion src/decorators/parseDecorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@ interface ParseFieldBaseOptions {
geo?: boolean;
/** Create a TTL index that expires documents N seconds after this Date field. Only valid for Date. */
ttlSeconds?: number;
/**
* Whether a client may set this field through `Model.fromParams()`.
* Defaults to `true`.
*
* `fromParams` builds an object from request parameters, and by default it
* will set **any** field declared on the model. That is convenient and it is
* also mass assignment: an endpoint that does
*
* ```ts
* const note = Note.fromParams(req.params);
* ```
*
* accepts `{"title": "…", "status": "published", "views": 9999}` just as
* readily as it accepts a title. Nothing is thrown and nothing is logged —
* the row simply saves with values the caller was never meant to choose.
*
* Mark server-owned fields `clientWritable: false` and `fromParams` ignores
* them, whatever the request contains. Your own code is unaffected: this
* governs one function, not the field. Set it directly when you mean to:
*
* ```ts
* @ParseField({type: 'Number', clientWritable: false}) declare views: number;
*
* note.views += 1; // fine — this is your code
* Note.fromParams(req.params); // `views` in the body is discarded
* ```
*
* Parse's own `protectedFields` is the mirror of this and does not replace
* it: that hides fields on the way **out**, this refuses them on the way in.
*/
clientWritable?: boolean;
}

interface ParseFieldWithIndex extends ParseFieldBaseOptions {
Expand Down Expand Up @@ -96,7 +127,7 @@ export type ParseFieldOptions =
export function ParseField(options: ParseFieldOptions) {
const {
type, required = false, targetClass, description, indexName,
min, max, minLength, maxLength, geo, ttlSeconds,
min, max, minLength, maxLength, geo, ttlSeconds, clientWritable,
} = options;
const enumValues = 'enum' in options ? options.enum : undefined;
const pattern = 'pattern' in options ? options.pattern : undefined;
Expand Down Expand Up @@ -223,6 +254,8 @@ export function ParseField(options: ParseFieldOptions) {
...(pattern !== undefined && {pattern}),
...(geo && {geo: true}),
...(ttlSeconds !== undefined && {ttlSeconds}),
// Stored only when false: absent means writable, which is the default.
...(clientWritable === false && {clientWritable: false}),
};

existingFields[propertyKey as string] = fieldMeta;
Expand Down
4 changes: 4 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ declare namespace Parse {
select(...keys: string[]): this;
ascending(...keys: string[]): this;
descending(...keys: string[]): this;
addAscending(...keys: string[]): this;
addDescending(...keys: string[]): this;
limit(n: number): this;
/** Return {results, count} from find() instead of an array. */
withCount(value?: boolean): this;
skip(n: number): this;
find(options?: any): Promise<any[]>;
first(options?: any): Promise<any>;
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export type { RoleString } from './utils/constants';
export { catchError, getUserRoles, getUsersRoles, formatCount,
generateRandomPassword, generateRandomInteger, generateRandomString, sleep } from './utils/helper';
export { importFiles } from './utils/dynamicImport';
export { paginate } from './utils/pagination';
export type { Page, PaginateOptions } from './utils/pagination';

// ── Role cache (opt-in) ──
export { configureRoleCache, invalidateRoles, roleCacheEnabled, roleCacheStats } from './utils/roleCache';
Expand Down
73 changes: 71 additions & 2 deletions src/models/BaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ function buildPointer(targetClass: string, value: any, field: string): Parse.Obj
return pointer;
}

/**
* Say — once, and only outside production — that a field was thrown away.
*
* The discard itself is silent by design. Refusing the request would tell an
* attacker exactly which fields are protected, and would break a client that is
* merely out of date; neither is worth it, and the value does not land either
* way.
*
* But there is a third caller, and silence serves them badly: the developer who
* wired a form to send `status`, watched the request return 200, and cannot see
* why the value never arrives. This is for them, which is why it is gated on
* NODE_ENV rather than switched off entirely.
*
* Once per class and field, because a busy dev server would otherwise print it
* on every request until the log is useless.
*/
const warnedDiscards = new Set<string>();

function warnDiscarded(className: string, field: string): void {
if (process.env.NODE_ENV === 'production') return;

const key = `${className}.${field}`;
if (warnedDiscards.has(key)) return;
warnedDiscards.add(key);

console.warn(
`[fromParams] Ignored '${field}' from the request body: ${key} is declared ` +
'clientWritable: false. Set it in your own code if the server should ' +
'choose it. (Development only — this is silent in production.)'
);
}

const ParseObject = Parse.Object as unknown as new (className?: string) => Parse.Object;

export class BaseModel extends ParseObject {
Expand Down Expand Up @@ -74,18 +106,36 @@ export class BaseModel extends ParseObject {
this: T,
params: any
): InstanceType<T> {
const fieldsMeta = Reflect.getMetadata('parse:fields', this) || {};

/*
* Strip anything the client is not allowed to set, BEFORE building the
* object.
*
* This has to happen here rather than in the loop below, because
* `Parse.Object.fromJSON` assigns every key it is given. Filtering
* afterwards would only skip re-setting a value that was already on the
* object — which looks like protection and is not.
*/
const source: Record<string, unknown> = {};
for (const key of Object.keys(params ?? {})) {
if (fieldsMeta[key]?.clientWritable === false) {
warnDiscarded(this.name, key);
continue;
}
source[key] = params[key];
}
// A copy, so this never writes `className` into the caller's `req.params`.
// Mutating the request object meant a second call — a retried transaction,
// say — saw an input the first call had already altered.
const source = {...params, className: new this().className};
source.className = new this().className;

const raw = Parse.Object.fromJSON(source, true);
Object.setPrototypeOf(raw, this.prototype);
const obj = raw as InstanceType<T>;

if (params.id) obj.id = params.id;

const fieldsMeta = Reflect.getMetadata('parse:fields', this) || {};
const excluded = this.excludedPointerClasses();

for (const field in fieldsMeta) {
Expand All @@ -98,6 +148,25 @@ export class BaseModel extends ParseObject {
continue;
}

/*
* A field the client is not allowed to choose.
*
* Without this, every declared field is settable from the request body —
* which is mass assignment. `createNote` accepting `{"title": "…"}` also
* accepts `{"title": "…", "status": "published", "views": 9999}`, and
* nothing objects: the row saves with values the caller was never meant
* to pick, no error, no log.
*
* Silently skipped rather than rejected, deliberately. A client that
* sends a field it should not is usually a stale app or a hopeful one,
* not something worth failing a request over — and the outcome is the
* same either way: the value is not used. Your own code is untouched;
* this governs `fromParams`, not the field.
*/
if (fieldMeta.clientWritable === false) {
continue;
}

// Check exclusion list (per-model override, else the library setting)
if (targetClass && excluded.includes(targetClass)) {
continue;
Expand Down
Loading
Loading