@ekka-ai/shelves
v0.3.0
Published
Serve an EKKA shelf from your Fastify API: scaffold a typed shelf, mount the shelf protocol v1, and check it the way EKKA will
Readme
@ekka-ai/shelves
Serve an EKKA shelf from your own Fastify API.
A shelf is a small, typed, read-only view of your data, for example hr.salary: one person's pay
for one month. An EKKA customer's AI plans read a shelf through their own EKKA Enclave. It calls your
API with a key you issued, asks for exactly the records the plan is allowed to read, and checks every
record it gets back. This package gives your API the three endpoints that make that work, under
/ekka/shelves/v1/ (ping, manifest, invoke). It also gives you a CLI that scaffolds the shelf
and then checks your API the way EKKA will.
Your code stays yours: you write the lookup from your data to the shelf's fields, and your auth decides who may call.
Use it
You need Node 22 or later, a Fastify 5 service, and TypeScript run through tsx. The example adds
hr.salary (EKKA's predefined salary shelf) to a service that already has its own bearer-token auth.
0. Install
npm i @ekka-ai/shelves
npm i -D tsx # the CLI loads your TypeScript shelf files through itRun every command below from the service's root, with npx ekka-shelves … (the copy you just installed).
1. Scaffold the shelf
npx ekka-shelves add hr.salary # a predefined shelf: its keys and fields are EKKA's
npx ekka-shelves add communications.email \
--keys id:string:unique --fields id:string,subject:string,sent_on:date --verbs read # a private shelf of your ownadd writes three files under src/ekka/:
hr.salary.ts: a class with empty typed methods and its fixtures;policy.ts: an empty role per verb;index.ts: yourproviderand the list of shelves.
2. Fill in the three files
index.ts›provider.id: how EKKA names your service, in lowercase letters, digits and hyphens (for exampleacme-payroll). KeepofferVersionas it is until you change a shelf; then bump it.policy.ts: a ROLE for each verb, one lowercase word (letters, digits,_), for examplepayroll_reader. A role is the name for "who may read this shelf". Your customer's EKKA admin maps it to their own roles when they import your service. An empty role refuses to start: it is a person's decision, never a default.hr.salary.ts›readandlist: your own code, from your own data to the shelf's fields. That code IS the mapping, and EKKA keeps no mapping table.- The fixtures in
hr.salary.tsare the ONE record thatcheckand EKKA's import probe ask for:- The record must EXIST in your data. It must be a SYNTHETIC person, never a real one. The scaffold names
{ id: 'fixture-id', month: '2000-01' }. Add that made-up row to every environment EKKA imports from, production included if EKKA imports from production (use currencyXTS, the ISO code reserved for testing), or point both fixtures at a synthetic record you already have. - The
listfixture names the SAME record by every unique key. Never{}: it would match every row, and the probe would carry real records to EKKA. Startup refuses it, andcheckfails if listing the fixture returns any other record.
- The record must EXIST in your data. It must be a SYNTHETIC person, never a real one. The scaffold names
3. Mount the plugin with YOUR auth
import { ekkaShelves } from '@ekka-ai/shelves';
import { provider, shelves } from './ekka/index.js';
await app.register(ekkaShelves({
provider,
shelves,
authenticate: requireUser, // your existing bearer check: sets who the caller is, or throws 401
authorize: (capability) => async (req) => { // your capability guard: throw 403 if the caller lacks it
if (!req.user?.scopes.includes(capability)) throw Object.assign(new Error('forbidden'), { statusCode: 403 });
},
capabilitiesOf: (req) => req.user?.scopes ?? [], // the capabilities the caller holds
principalOf: (req) => req.user?.id ?? '', // the caller's principal name
}));The plugin never decides who a caller is; your hooks do.
4. One key per shelf: add the shelf's principal to your auth
npx ekka-shelves generate # writes src/ekka/shelves.generated.ts; `generate --check` fails in CI when it is staleFor each shelf, your auth needs:
- ONE principal, named
ekka-shelf-<shelf id>(hereekka-shelf-hr.salary); - holding exactly ONE capability,
shelf.<shelf id>.read(hereshelf.hr.salary.read); - with its OWN key: a random secret of 32 bytes or more that your auth accepts as a bearer token, and nothing else.
shelves.generated.ts lists these names (SHELF_PRINCIPAL_CAPABILITIES), so your auth table can import them instead of typing them. Keep the key where you keep your other service secrets. When your customer's admin imports your service into EKKA (ekka shelf import <your https origin>), they paste that key at a hidden prompt. EKKA keeps it in the customer's vault and presents it only to your origin.
5. check: see what EKKA will see
check calls your REAL app in-process. It needs one small file that you write, src/ekka/check-app.ts:
// src/ekka/check-app.ts: your app, built as production builds it, and a key for each shelf's principal.
import { randomBytes } from 'node:crypto';
export default async () => {
const key = randomBytes(32).toString('base64url');
// Make YOUR auth accept `key` as the principal ekka-shelf-hr.salary with capability shelf.hr.salary.read.
// For a token table read from the environment, set it BEFORE importing your app:
process.env.API_TOKENS = JSON.stringify({ [key]: { id: 'ekka-shelf-hr.salary', scopes: ['shelf.hr.salary.read'] } });
const { buildApp } = await import('../app.js');
const app = await buildApp();
await app.ready();
return { app, bearerFor: (_shelfId: string) => key };
};npx ekka-shelves check # every line ok, or the line that is not, with what to change6. Go live: how EKKA reaches your service
Deploy as usual. The shelf endpoints are served by your app, on your origin.
The origin EKKA is given must be a bare https origin, like
https://payroll-api.example.com: no path, no query, no user info. EKKA follows no redirects, and it caps the size of an answer.A public address works as it is. A private address (on the customer's own network) works only when the customer's admin imports with
--reach private.localhostand127.0.0.1are never reachable: the import refuses them on purpose. To try it on your laptop, usecheck(step 5).The customer's EKKA admin imports your service with
ekka shelf import https://your-origin. The command:- calls
ping; - shows the origin for them to confirm;
- asks for each shelf's key (step 4) at a hidden prompt;
- reads your manifest;
- probes each shelf once with your fixture, and once with a key you do not declare, which must be refused.
They then map your roles to their own roles.
- calls
Change a shelf later (a role, a fixture, a field)? Bump
offerVersioninsrc/ekka/index.ts. EKKA refuses a changed offer under the same version.
Real or synthetic data (the whole store). If ALL of a shelf's rows are generated (a staging or
demo store), say so in its class: override readonly data = 'synthetic' as const. The customer's
admin then sees "synthetic" beside the shelf, and every signed receipt says so. Leave it unset for a
system of record, even when you have added one synthetic fixture person to it: data is about the
store, and the fixture is always synthetic.
A shelf of your own (a private shelf)
When no EKKA shelf fits, declare your own shape:
npx ekka-shelves add acme.expense \
--keys id:integer:unique,employee_id:string \
--fields id:integer,employee_id:string,spent_on:date,currency:currency_code,amount_minor:money_minor,approved:boolean \
--verbs read,list- Read keys: read by an identifier, never by a person's name: two people can share a name.
- Money: an integer in the currency's minor unit (
money_minor), always with acurrency_code. There are no floats anywhere. - Types:
string,integer,boolean,month(YYYY-MM),date(YYYY-MM-DD),currency_code(ISO 4217) andmoney_minor. - Id: a private id may not reuse one of EKKA's (
hr.salaryis EKKA's).
EKKA's own reference provider is a service like this. It serves hr.salary and a private
reference.expense (an integer id key, money in minor units), both from synthetic data. Its fixtures
are { id: 'fixture-0001', month: '2000-01' } and { id: 1 }, and check passes all 33 checks on it.
What the plugin guarantees
readtakes EXACTLY the shelf's unique keys.listtakes any of its declared keys. A key the shelf does not declare is refused (unknown_key), never ignored.listreturns at most the page size asked for, never more than the shelf's cap, and anext_cursor.- Every record leaving your service is checked against the shelf's exact fields and types. A bad one
becomes a
provider_error, and neither its values nor your error's details leave your service. manifestneeds a key, and it lists only that key's shelves. A shelf that key does not serve, and a shelf that does not exist, get the same answer. Onlypingis public.- Startup refuses a shelf with no role, no fixture, or a list fixture that could match real records.
Versions and license
0.2.2is the first published version.- Proprietary: use is permitted for EKKA customers under their EKKA agreement; no redistribution
(
LICENSE).
