Reads by name
The app ships a stored read. Callers send its name, and each gets the rows their roles allow.
pnpm add @niscorp/moss @niscorp/charter @niscorp/nova @niscorp/prism @niscorp/strata @niscorp/tide @niscorp/vex zod @electric-sql/pgliteA table with rows in it
An environment as before, and one table of bookings with three rows.
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);
`);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.
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' }] } },
};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.
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);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.
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' }));
}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.
console.log('ruth, her own question:', await ask('ruth', { intent: 'Every booking', shape: [{ id: '' }] }));
server.close();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.
npx tsx reads.mtsgrace [{"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."}