Moss · guide

Reads by name

The app ships a stored read. Callers send its name, and each gets the rows their roles allow.

Install it
pnpm add @niscorp/moss @niscorp/charter @niscorp/nova @niscorp/prism @niscorp/strata @niscorp/tide @niscorp/vex zod @electric-sql/pglite
Step 1 of 5

A table with rows in it

An environment as before, and one table of bookings with three rows.

A table with rows in it
import { PGlite } from '@electric-sql/pglite';
import { createServer, defineApp, mintDevToken } from '@niscorp/moss';
import { createPglitePool } from '@niscorp/vex/pglite';

const db = new PGlite();
const runtime = { db, pool: createPglitePool(db), session: 'dev-open' as const };
await db.exec(`
  CREATE TABLE bookings (id text PRIMARY KEY, member_id text NOT NULL, seats integer NOT NULL);
  INSERT INTO bookings VALUES ('B-1', 'grace', 2), ('B-2', 'ada', 1), ('B-3', 'linus', 4);
`);
Step 2 of 5

Who may read it, and how far

In the charter a role’s data says what it may do with each table. A member and the desk may both read bookings.

scoping says how far a role reaches. behaviors says what each reach means for a table: at the personal reach, a read of bookings is matched to the person asking. Moss supplies who that is, as userId.

Who may read it, and how far
const charter = {
  public: [],
  member: { actions: [], data: ['bookings.read'], scoping: 'personal' },
  desk: { actions: [], data: ['bookings.read'] },
};
const assignments = { grace: ['member'], ruth: ['desk'] };
const behaviors = {
  bookings: { default: { read: [] }, personal: { read: [{ match: 'member_id', to: 'userId' }] } },
};
Step 3 of 5

The read, stored under a name

A read is written once, as data, and shipped with the app under a name. This is the whole of the app’s data API: the names it ships.

The read, stored under a name
const entries = [
  {
    fingerprint: 'bookings/list',
    intent: 'Every booking and its seats',
    shape: [{ member: '', seats: 0 }],
    dsl: {
      from: ['bookings'],
      fields: [{ field: 'bookings.member_id', as: 'member' }, { field: 'bookings.seats', as: 'seats' }],
      sort: [{ field: 'bookings.member_id', dir: 'asc' as const }],
    },
  },
];
const server = await createServer(defineApp({ charter, assignments, actions: {}, behaviors, entries }), runtime);
Step 4 of 5

Ask by name, as three people

Every caller sends the same thing: the name. Moss compiles each caller’s policy from their roles and hands it to Vex, which adds it to the SQL. The answer carries the rows as result, and that is what is printed here.

Grace gets her own booking. Ruth at the desk gets all three. Somebody who is not signed in has no grant on the table and is refused.

Ask by name, as three people
const ask = async (who: string | null, body: unknown) => {
  const headers: Record<string, string> = { 'content-type': 'application/json' };
  if (who !== null) headers['authorization'] = 'Bearer ' + mintDevToken(who);
  const answer = await server.request('/api/vex', { method: 'POST', headers, body: JSON.stringify(body) });
  const said = (await answer.json()) as { result?: unknown };
  return JSON.stringify(said.result ?? said);
};
for (const who of ['grace', 'ruth', null]) {
  console.log(who ?? 'nobody', await ask(who, { fingerprint: 'bookings/list' }));
}
Step 5 of 5

Ask with a question of your own

The endpoint is locked. A caller that sends a new question instead of a stored name is refused, whoever they are. Nothing a browser sends can become a query.

Ask with a question of your own
console.log('ruth, her own question:', await ask('ruth', { intent: 'Every booking', shape: [{ id: '' }] }));
server.close();
Then

Run it.

Grace’s one row, the desk’s three, the refusal for somebody with no grant, and the refusal for a question that was not stored.

Run it
npx tsx reads.mts
It prints
grace [{"member":"grace","seats":2}]
ruth [{"member":"ada","seats":1},{"member":"grace","seats":2},{"member":"linus","seats":4}]
nobody {"error":"scope_denied","message":"Entity \"bookings\" is not allowed by scope policy (default: deny)"}
ruth, her own question: {"error":"locked","message":"This endpoint is replay-only — unknown or changed fingerprints cannot generate here."}