In this tutorial we add consents management to an application on RESTHeart Cloud: every user must accept the current Terms of Service and Privacy Policy before they can use it, and the acceptance is recorded with the version they agreed to and the date.

Almost all of it is server-side configuration — four JSON documents, no code and no deploy. The client's only job is to react to a status code, so the same tutorial applies whether your frontend is React, Angular or anything else; the last part shows the frontend written twice, once with each.

That the rule lives on the server is the whole point: it applies to your web app, to your mobile client, to curl, and to the API integration a customer wrote against your service last year.

Here is the flow we are building:

  1. The user registers and receives a verification email.
  2. They click the link, get the user role, and land in the app already signed in.
  3. The app asks who they are, and that comes back 451 — they have not accepted anything yet.
  4. The app shows an acceptance form. They accept, the app gets a fresh token, and they are in.

Two flags, not one: tos for the terms and pp for the privacy policy, each with its own version, both accepted by the same request.

You will need a service on RESTHeart Cloud — the free tier is enough — with the Guards plugin enabled from Service → Guards.

Quick Start

If you would rather see it run than read about it first, you need a service on RESTHeart Cloud with the Guards plugin enabled, and the four server-side documents described in Part 1:

  1. the schema (userConsentsSchema) on the users collection,
  2. the permission (userCanPatchOwnConsents) for the acceptance PATCH,
  3. the two JWT claims (latestConsents/tos and latestConsents/pp),
  4. the guard rule (consentsGate) that blocks users who have not accepted.

Once those are in place, clone the starter of your choice, check out the finished branch, and point it at your service:

React:

git clone https://github.com/SoftInstigate/restheart-cloud-starter-react.git
cd restheart-cloud-starter-react
git checkout feat/consents-gate
npm install

Angular:

git clone https://github.com/SoftInstigate/restheart-cloud-starter-ng.git
cd restheart-cloud-starter-ng
git checkout feat/consents-gate
npm install

Edit src/environments/environment.ts (or environment.development.ts) and set apiUrl to your RESTHeart Cloud service URL. Then:

npm start

Register a user, click the verification link, and you will land on the consents form — the app asked /users/me, the server answered 451, and the overlay appeared. Accept, and you are in.

To verify the server-side is working before touching the frontend, run the curl checks in Check it before touching the frontend.


The Data Model

Two fields on the user document:

{
  "latestConsents": {
    "tos": "2026-07-01",
    "pp":  "2026-07-01",
    "acceptedAt": { "$date": 1754438400000 }
  },
  "consents": [
    { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": { "$date": 1754438400000 } }
  ]
}

latestConsents is what the gate reads — flat, overwritten at every acceptance. consents is an append-only history, for the day someone asks what a user agreed to and when.

Neither field exists until the user accepts for the first time, and that absence is what the rule blocks on. Registration does not write them: there is exactly one way to accept, and it is the same one whether the user just signed up, arrived through Google, or has had an account for two years and is meeting a new version of the terms.


Part 1 — The Server

Four documents, all of them configuration in the RESTHeart Cloud console. No code, no deploy.

1. A schema that validates the shape

Service → Schemas → New Schema. The _id goes in its own fielduserConsentsSchema — and the editor takes only the document:

{
  "title": "User with consents",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["_id", "password", "roles", "profile"],
  "properties": {
    "_id":      { "type": "string" },
    "_etag":    { "type": "object" },
    "password": { "type": "string" },
    "roles":    { "type": "array", "items": { "type": "string" } },
    "profile": {
      "type": "object",
      "required": ["name", "surname"],
      "properties": {
        "name":      { "type": "string" },
        "surname":   { "type": "string" },
        "avatarUrl": { "type": "string" }
      }
    },
    "latestConsents": {
      "type": "object",
      "properties": {
        "tos":        { "type": "string" },
        "pp":         { "type": "string" },
        "acceptedAt": { "type": "object", "properties": { "_$date": { "type": "number" } } }
      },
      "required": ["tos", "pp"]
    },
    "consents": { "type": "array" },
    "socialAuths": { "type": "array" },
    "teams":       { "type": "array" },
    "team":        { "type": "object" }
  }
}

Then apply it: Service → Collections → users → Schema, pointing at userConsentsSchema.

Note _$date with the underscore. acceptedAt is a BSON date, and inside a schema document the BSON type keys are escaped so the parser does not read them as actual values while the schema is being stored. Declare it as {"type": "string"} and every acceptance is rejected.

Note also what is not in required: latestConsents and consents, along with the OAuth and team fields. The document is validated as it is inserted, before the initial team is attached — a schema that demands those fields rejects every registration.

2. A permission for the acceptance

Nothing authorizes PATCH /users/{userId} out of the box, and a guard never gets a say on a request the ACL already refused: without this, the acceptance is a 403 and the user is locked out for good.

Service → Permissions:

{
  "_id": "userCanPatchOwnConsents",
  "predicate": "path-template('/users/{userId}') and method(PATCH) and (equals(@user._id, ${userId}) or equals(@user.sub, ${userId})) and bson-request-whitelist(consents)",
  "roles": ["user"],
  "priority": 1,
  "mongo": {
    "mergeRequest": {
      "latestConsents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": "@now" },
      "_$push": { "consents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": "@now" } }
    }
  }
}

This is the part worth staring at.

bson-request-whitelist(consents) narrows the permission to a single field — it grants the acceptance and nothing else.

mergeRequest moves the decision of what is being accepted to the server. The client sends {"consents": []} and the server stamps the versions and the timestamp. Without it, a client could accept terms it was never shown, or backdate the acceptance to before the terms existed. This is why the versions never appear in the React code: the client has no say in them.

_$push is a MongoDB update operator, written with a leading underscore in the permission document and unescaped to $push before the merge. It is what grows the history array instead of overwriting it.

One thing that will cost you an afternoon if you get it wrong: write latestConsents as a nested object, not with dotted keys like latestConsents.tos. A merged request that sets both a field and a path inside it is rejected by MongoDB with 500 ConflictingUpdateOperators.

3. The two claims

Service → Users → JWT Claims: add both latestConsents/tos and latestConsents/pp.

If one of the two is missing, the comparison on it is false for every token-authenticated user forever — including the ones who just accepted — and the rule blocks them permanently while the condition looks perfectly reasonable.

Keep the list to these two. A JWT payload is base64, not encrypted: everything in it is readable by any client holding the token. In particular, leave the consents history out — it is an array that grows at every acceptance, and no access decision reads it.

4. The rule

Service → Guards:

{
  "id": "consentsGate",
  "name": "Block users who have not accepted the current ToS and Privacy Policy",
  "condition": "not path-prefix('/auth') and not path-prefix('/token') and not (method(PATCH) and path-template('/users/{userId}') and bson-request-whitelist(consents)) and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01'))",
  "action": "block",
  "status_code": 451,
  "message": "You must accept the current Terms of Service and Privacy Policy",
  "on_error": "allow"
}

Read as it is meant to be read:

not path-prefix('/auth')
and not path-prefix('/token')
and not (method(PATCH) and path-template('/users/{userId}') and bson-request-whitelist(consents))
and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01'))

The first two lines are the exclusions, and they are not decoration — more on them at the end, because getting them wrong is how you lock everyone out including yourself.

The third exempts the acceptance itself: the very request that unblocks the user is made while they are still blocked.

Note what is not excluded: /users/me. Blocking it is what makes the whole thing work with no setup on your side — reading the user document is the first thing any client does, so a blocked user is refused immediately, whatever the app is and whether or not it has data of its own yet. It also means the app never gets a user document, which the client half below is built around.

The last line uses not (A and B) — blocked when either acceptance is missing. not A and not B would block only the users who accepted neither, which is the kind of bug that ships.

The 451 is what the React app will key on. Any status your frontend can act on works; 451 Unavailable For Legal Reasons happens to mean exactly this.

Check it before touching the frontend

# a token — must succeed
curl -X GET 'https://<service>/token' -u '<email>:<password>'

# the user document — must be blocked with 451, and this is the one that matters
curl -X GET 'https://<service>/users/me' -H 'Authorization: Bearer <token>'

# the acceptance — must return 200
curl -X PATCH 'https://<service>/users/<email>' \
  -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
  -d '{"consents": []}'

# a fresh token, then the blocked request again — must now pass
curl -X GET 'https://<service>/token' -u '<email>:<password>'
curl -X GET 'https://<service>/users/me' -H 'Authorization: Bearer <new-token>'

If the second request comes back 401 rather than 451, the token is missing or expired — the rule never got a user to look at. If it comes back 200, re-read the last line of the condition.


Part 2 — The Client

Whatever your frontend is written in, it has exactly three jobs:

  1. Notice the 451 on the session check.
  2. Show an acceptance screen while that is the case.
  3. On acceptance, send the PATCH and get a new token.

Two things make this smaller than it looks, and both come from blocking /users/me.

Nothing has to go looking. Restoring the session is the first thing any client does, and it is refused, so a blocked user is discovered before the app has rendered anything. There is no probe to write, no collection to read, no path to configure.

The gate cannot live inside the app. A blocked user has no session — the request that would establish one is the one being refused — so every route guard you have will treat them as signed out and send them to the login screen. The overlay has to sit above the router, where no guard stands between the user and the form.

And one thing that is easy to miss: the failure is absorbed before you see it. Session restoration runs on its own schedule, with no call site of yours to wrap in a try, and the adapters swallow its errors deliberately — an app that hangs because the network blinked is worse than one that shows a login screen. So the kit hands you AuthConfig.onError, which sees every failure including the ones nobody is waiting on. Without it, "blocked" and "signed out" are the same thing to your app.

Note what is not on the list: comparing versions. Nothing in the client knows what 2026-07-01 is, and nothing reads latestConsents. That is what stops the two sides from drifting apart.

Both starters have main without any of this, on purpose — which consents you collect and when you re-ask are product decisions. Everything below is already written on the feat/consents-gate branch of each.

Both depend on the kit; you need at least 0.8.0, which ships acceptConsents, onError, api(), and the token claims the acceptance needs. Check what you actually installed — npm ls @restheart-cloud/kit-react — because a stale node_modules fails with Property 'acceptConsents' does not exist, and nothing in that message points at the version.

Below, the same three jobs done twice — or skip ahead to the Quick Start and read the code on the branch.

In React

git clone https://github.com/SoftInstigate/restheart-cloud-starter-react.git
cd restheart-cloud-starter-react
git checkout feat/consents-gate
npm install

Edit src/environments/environment.ts, set apiUrl to your service, and run npm start. The finished code is already on the branch — the walkthrough below explains what each piece does.

1. Catch the 451: src/consents-signal.ts

A flag, and a handler that raises it:

import type { ApiError } from '@restheart-cloud/kit-react';

type Listener = (blocked: boolean) => void;

let blocked = false;
const listeners = new Set<Listener>();

export function isBlocked(): boolean {
  return blocked;
}

export function setBlocked(next: boolean): void {
  if (blocked === next) return;
  blocked = next;
  listeners.forEach(l => l(next));
}

export function subscribe(l: Listener): () => void {
  listeners.add(l);
  return () => listeners.delete(l);
}

/** Raises the flag on any 451 from the service. */
export const consentsOnError = (err: ApiError): void => {
  if (err.status === 451) setBlocked(true);
};

Hand it to the provider in src/main.tsx:

import { consentsOnError } from './consents-signal';

<RhAuthProvider config={{ apiBaseUrl: environment.apiUrl, onError: consentsOnError }}>
  <App />
</RhAuthProvider>

That is the entire detection logic. If you bump the version in the rule tomorrow, this code does not change and does not need redeploying — the server starts answering 451 and the app reacts.

2. The form: src/ConsentsGate.tsx

import { useEffect, useState } from 'react';
import { useAuth } from '@restheart-cloud/kit-react';
import { isBlocked, setBlocked, subscribe } from './consents-signal';
import './ConsentsGate.css';

export function ConsentsGate({ children }: { children: React.ReactNode }) {
  const auth = useAuth();
  const [blocked, setBlockedState] = useState(isBlocked);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [acceptedTos, setAcceptedTos] = useState(false);
  const [acceptedPp, setAcceptedPp] = useState(false);

  useEffect(() => subscribe(setBlockedState), []);

  if (!blocked) return <>{children}</>;

  const accept = async () => {
    setBusy(true);
    setError(null);
    try {
      await auth.acceptConsents();
      // The token is new and /users/me answers now: load the session properly.
      await auth.checkSession();
      setBlocked(false);
    } catch {
      setError('We could not record your acceptance. Please try again.');
    } finally {
      setBusy(false);
    }
  };

  const signOut = async () => {
    setBlocked(false); // the flag outlives the session — clear it
    await auth.logout();
  };

  return (
    <div className="consents-overlay" role="dialog" aria-modal="true" aria-labelledby="consents-title">
      <div className="consents-card">
        <h1 id="consents-title">Before you continue</h1>
        <p>Please review these documents and accept them to use the application.</p>

        <label className="consents-check">
          <input type="checkbox" checked={acceptedTos}
                 onChange={e => setAcceptedTos(e.target.checked)} />
          <span>
            I have read and accept the{' '}
            <a href="/terms.html" target="_blank" rel="noreferrer">Terms of Service</a>
          </span>
        </label>

        <label className="consents-check">
          <input type="checkbox" checked={acceptedPp}
                 onChange={e => setAcceptedPp(e.target.checked)} />
          <span>
            I have read and accept the{' '}
            <a href="/privacy.html" target="_blank" rel="noreferrer">Privacy Policy</a>
          </span>
        </label>

        {error && <p className="field-error">{error}</p>}
        <button type="button" className="btn-primary" onClick={accept}
                disabled={busy || !acceptedTos || !acceptedPp}>
          {busy ? 'Saving…' : 'I accept'}
        </button>
        <button type="button" className="btn-plain" onClick={signOut}>Sign out</button>
      </div>
    </div>
  );
}

auth.acceptConsents() does four things in order:

  1. the PATCH that records the acceptance;
  2. GET /token?renew=true, for a token that reflects it — without this the rule keeps blocking, see the note below;
  3. GET /users/me, to read the document back as the server wrote it;
  4. setUser() with the result.

Where does it get the user id? Not from auth.user — there isn't one, that is the whole situation. It falls back to the token's sub claim, which is the user id, and which the client has been holding all along. This is why blocking /users/me costs nothing: the app never needed the document to know who it is talking about, only to display them.

signOut lowers the flag before logging out, and that line is easier to justify than to remember. The flag is module state, not session state: it survives the logout. Leave it up and the next person to sign in on that tab meets the acceptance form before they have made a single request.

The CSS that makes it a blocking dialog rather than a paragraph is at the end, shared with the Angular version.

3. Mount it above the router: src/App.tsx

import { ConsentsGate } from './ConsentsGate';

// at the end of the component, where it used to `return element;`
return <ConsentsGate>{element}</ConsentsGate>;

Not inside the shell. That is the mistake worth avoiding: a blocked user never reaches the shell, because AuthGuard sees no session and redirects to the login page. Above the router there is no guard in the way.

That is all. Register a new user, click the verification link, and you land facing the form — because the first thing the app did was ask who you are, and the answer was 451.

In Angular

git clone https://github.com/SoftInstigate/restheart-cloud-starter-ng.git
cd restheart-cloud-starter-ng
git checkout feat/consents-gate
npm install

Edit src/environments/environment.ts, set apiUrl to your service, and run npm start. Same three jobs as the React version, explained below.

1. The flag: src/app/consents.ts

import { signal } from '@angular/core';
import type { ApiError } from '@restheart-cloud/kit-ng';

export const consentsBlocked = signal(false);

/** Raises the flag on any 451 from the service. */
export function consentsOnError(err: ApiError): void {
  if (err.status === 451) consentsBlocked.set(true);
}

A plain module, not a service. onError is handed to provideRhAuth while the injector is still being assembled, so there is nothing to inject from yet — and a signal works perfectly well outside DI.

Register it in src/app/app.config.ts:

import { consentsOnError } from './consents';

// in appConfig.providers
provideRhAuth({ apiBaseUrl: environment.apiUrl, onError: consentsOnError }),

No interceptor. An Angular interceptor sees HttpClient traffic, and while the kit now routes through it too, the failure we care about is one authGuard swallows before any interceptor result reaches the app. onError is what sees it.

2. The form: src/app/consents-gate.ts

import { Component, inject, signal } from '@angular/core';
import { RhAuthService } from '@restheart-cloud/kit-ng';
import { consentsBlocked } from './consents';

@Component({
  selector: 'app-consents-gate',
  templateUrl: './consents-gate.html',
  styleUrl: './consents-gate.css',
})
export class ConsentsGate {
  protected readonly auth = inject(RhAuthService);
  protected readonly blocked = consentsBlocked;
  protected readonly busy = signal(false);
  protected readonly error = signal<string | null>(null);
  protected readonly acceptedTos = signal(false);
  protected readonly acceptedPp = signal(false);

  protected accept(): void {
    this.busy.set(true);
    this.error.set(null);
    this.auth.acceptConsents().subscribe({
      next: () => window.location.assign('/'),
      error: () => {
        this.error.set('We could not record your acceptance. Please try again.');
        this.busy.set(false);
      },
    });
  }

  protected signOut(): void {
    consentsBlocked.set(false);
    this.auth.logout().subscribe(() => window.location.assign('/auth/login'));
  }
}

with the template in consents-gate.html, wrapped in @if (blocked()) { … } — same two boxes, bound the Angular way:

<label class="consents-check">
  <input type="checkbox" [checked]="acceptedTos()"
         (change)="acceptedTos.set($any($event.target).checked)" />
  <span>
    I have read and accept the
    <a href="/terms.html" target="_blank" rel="noreferrer">Terms of Service</a>
  </span>
</label>

<button type="button" class="btn-primary" (click)="accept()"
        [disabled]="busy() || !acceptedTos() || !acceptedPp()">
  {{ busy() ? 'Saving…' : 'I accept' }}
</button>

Why a reload instead of a navigation? Because authGuard already cancelled the navigation that brought the user here — its checkSession() errored on the 451, and a guard that errors does not redirect, it stops. There is no failed navigation to replay: router.navigateByUrl('/') from a URL that is already / does nothing at all. Re-entering with a new token and a readable user document is one line and happens once per user, ever.

RhAuthService.acceptConsents() does the same four steps as the React one, and takes the user id from the token's sub for the same reason: there is no user document yet.

3. Mount it at the root: src/app/app.html

<app-consents-gate />

@if (apiConfigured) {
  <router-outlet />
} @else {
  …
}

with ConsentsGate added to App's imports. Outside the outlet, not inside the shell: with /users/me refused there is no session, authGuard fails, and nothing inside the outlet ever renders. A gate that lived there would never be seen.

The documents themselves

Two boxes, two documents, and the user has to be able to read them before accepting. That last part is the constraint that decides where they live.

Put them where the application isn't: public/terms.html and public/privacy.html, plain HTML files served as-is. Both starters ship placeholders — replace them.

The temptation is to make them app routes, /terms and /privacy, styled like everything else. Don't. A blocked user has no session, so a route inside the app sits behind the gate they are trying to read their way out of — they click the link, a second tab opens, the app boots, /users/me comes back 451, and the overlay covers the terms. A static file has no session to check.

Give each document a version line matching the rule:

<h1>Terms of Service</h1>
<p class="version">Version 2026-07-01</p>

That date now appears in three places: the document, the Guards rule, and the permission's mergeRequest. They have to move together. Change the rule and forget the document and users accept one version while the server records another — which is the exact question the whole exercise exists to answer, answered wrongly.

The checkboxes are user experience, not data. They gate the button and nothing else. The request that follows carries no versions, and the server stamps both in one write — there is no world in which a user accepts one document and not the other, because the permission does not offer that. If you need them recorded separately, that is two permissions and two flags in the rule, not two checkboxes.

The CSS, for either

Put it in ConsentsGate.css (React) or consents-gate.css (Angular):

.consents-overlay {
  position: fixed;
  inset: 0;
  /* Above everything the shell already stacks — in both starters that means
     the header (100), the account dropdown (200) and the progress bar (300). */
  z-index: 400;
  display: grid;
  place-items: center;
  padding: 1rem;
  background: rgb(0 0 0 / 0.55);
}

.consents-card {
  width: min(32rem, 100%);
  padding: 2rem;
  border-radius: 0.75rem;
  background: var(--surface, #fff);
  box-shadow: 0 1.25rem 3rem rgb(0 0 0 / 0.25);
}

.consents-card h1 { margin-top: 0; }

.consents-check {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  margin: 1rem 0;
  cursor: pointer;
  line-height: 1.5;
}

.consents-check input {
  /* Aligned to the first line of text, not to its box. */
  margin-top: 0.15em;
  width: 1.05em;
  height: 1.05em;
  flex: none;
  cursor: pointer;
}

.consents-card .btn-plain {
  display: block;
  margin-top: 0.75rem;
  padding: 0;
  font: inherit;
  background: none;
  border: none;
  color: inherit;
  text-decoration: underline;
  cursor: pointer;
}

There is no close button and no backdrop click: the only ways out are accepting both documents or signing out.

And whichever one you wrote, remember what it is: the overlay is user experience, not enforcement. Remove it with the dev tools and every request the app makes still comes back 451. That is the difference between a gate and a checkbox.


Four Things That Will Bite You

The gate has to live above your guards

The failure mode is quiet and looks like something else entirely: you configure everything, sign in as a user who has accepted nothing, and land on the login page. Not the acceptance form — the login page, as if the password were wrong.

What happened is that /users/me came back 451, so the session was never established, so every guard you have concluded the user is signed out and redirected. Mount the overlay inside the authenticated part of the app and it is behind the very guard that just turned the user away. It has to sit above the router, where nothing stands between a blocked user and the form.

Nobody is listening

The second quiet one. Session restoration is not a call you made — it happens on mount, on navigation, on a token refresh — and the adapters absorb its failures on purpose, because an app that hangs when the network blinks is worse than one that shows a login screen. So the 451 is caught and discarded, and no code of yours ever hears about it.

That is what AuthConfig.onError is for: it sees every failure, including the ones no caller is waiting on. It is also how you tell a blocked user from an offline one — status: 0 means the request never reached the service — which the app otherwise cannot distinguish, and today usually gets wrong.

The token is a snapshot

A JWT is issued once and carries the user as they were at that moment. Your user accepts, the write succeeds, and the rule keeps blocking them — because the token in their browser still says they have not accepted, and that is what the rule reads. For the whole life of that token.

So the acceptance has to be followed by a new one. acceptConsents() handles it with GET /token?renew=true, which is the right call in a browser: it is holding a bearer token and has no credentials to re-send. (Authenticating with a password is the other way to get a fresh token — every GET /token with Basic credentials is issued from the document as it reads at that moment. That is what makes the curl check above easy, and is irrelevant to a signed-in SPA.)

The gate also blocks the way back in

A rule matches requests, and the requests a blocked user needs in order to stop being blocked are requests like any other. Only /ping, /health/db and CORS preflight are never guarded. Everything else is fair game — which means that without the exclusions:

  • /token is blocked, so a user without consents cannot sign in at all, and the renewal after the acceptance can never take effect;
  • /auth/* is blocked, so registration, email verification and the OAuth callback all fail;
  • and the acceptance PATCH itself, if you forget its exemption, is refused — leaving the user permanently unable to do the one thing that would free them.

The application appears broken and nothing in the logs says "misconfigured rule". Hence not path-prefix('/auth') and not path-prefix('/token') at the front of the condition, where they are hard to miss when re-reading it.

/users/me is the deliberate exception: blocking it is what trips the gate in the first place. It costs nothing because the client can still tell who the user is — the id is the sub claim of the token it is already holding.

The service administrator is never guarded

Deliberately. A rule that blocks everything still leaves the Cloud console working, so there is always a way back. Which is also why you should test a new rule on a non-production service first: the one identity that can always fix it is the one that never sees the problem.


What You Get

A user who has not accepted is served nothing but the requests that let them accept — from your React app, from a mobile client, from curl, from the integration a customer wrote against your API last year. And they find out immediately, because the first question any client asks is who they are. The versions and the timestamps in your database were written by the server, so they mean something the day someone asks. And the history is an append-only array, not a field that got overwritten.

When the terms change, you edit two documents in the console — the version in the rule and the version in the permission — and every user meets the form again on their next request. Nothing to redeploy, on the frontend or anywhere else.

Start with a free service at cloud.restheart.com, clone the React starter or the Angular onegit checkout feat/consents-gate for the finished version of Part 2 — and read the Guards documentation for the parts this tutorial moved past quickly.


RESTHeart Cloud is a MongoDB Technology Partner. The consents pattern is documented in full under Guards → Example: Gating on Consents.

Ready to Build Something Great?

Focus on what makes your app unique. Your backend is ready in minutes. Start with our free tier - no credit card required.