@ekka-ai/shelves 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -46
- package/dist/check.js +23 -2
- package/dist/cli.js +26 -7
- package/dist/contracts/PINNED.json +4 -4
- package/dist/contracts/gate/attested/shelf.v1.json +10 -2
- package/dist/contracts/shelves/protocol-v1.schema.json +9 -1
- package/dist/contracts/shelves/registry.json +1 -1
- package/dist/generate.js +12 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/plugin.d.ts +4 -4
- package/dist/protocol.d.ts +5 -3
- package/dist/protocol.js +4 -3
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +1 -1
- package/dist/scaffold.d.ts +2 -2
- package/dist/scaffold.js +11 -7
- package/dist/shelf.d.ts +24 -4
- package/dist/shelf.js +42 -4
- package/package.json +20 -16
- package/CHANGELOG.md +0 -44
package/README.md
CHANGED
|
@@ -1,70 +1,172 @@
|
|
|
1
|
-
# ekka-shelves
|
|
1
|
+
# @ekka-ai/shelves
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Serve an **EKKA shelf** from your own Fastify API.
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A shelf is a small, typed, read-only view of your data, for example `hr.salary`: one person's pay
|
|
6
|
+
for one month. An EKKA customer's AI plans read a shelf through their own EKKA Enclave. It calls your
|
|
7
|
+
API with a key you issued, asks for exactly the records the plan is allowed to read, and checks every
|
|
8
|
+
record it gets back. This package gives your API the three endpoints that make that work, under
|
|
9
|
+
`/ekka/shelves/v1/` (`ping`, `manifest`, `invoke`). It also gives you a CLI that scaffolds the shelf
|
|
10
|
+
and then checks your API the way EKKA will.
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
`@ekka-ai/contracts` catalog is a devDependency and stays internal.
|
|
10
|
-
- `v0.1.0`, `v0.2.0` and `v0.2.1` are tags that were never published; `0.2.2` is the first published version (CHANGELOG.md).
|
|
11
|
-
- The shelf registry and the protocol are NOT defined here. They come from `@ekka-ai/contracts` (pinned), `shelves/registry.json` and `shelves/protocol-v1.schema.json`.
|
|
12
|
-
- Spec: `codex-hub/ekka-memory-api-provider/E1-V3-SPEC.md`, section 6.
|
|
12
|
+
Your code stays yours: you write the lookup from your data to the shelf's fields, and your auth
|
|
13
|
+
decides who may call.
|
|
13
14
|
|
|
14
15
|
## Use it
|
|
15
16
|
|
|
17
|
+
You need Node 22 or later, a Fastify 5 service, and TypeScript run through `tsx`. The example adds
|
|
18
|
+
`hr.salary` (EKKA's predefined salary shelf) to a service that already has its own bearer-token auth.
|
|
19
|
+
|
|
20
|
+
### 0. Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm i @ekka-ai/shelves
|
|
24
|
+
npm i -D tsx # the CLI loads your TypeScript shelf files through it
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run every command below from the service's root, with `npx ekka-shelves …` (the copy you just installed).
|
|
28
|
+
|
|
29
|
+
### 1. Scaffold the shelf
|
|
30
|
+
|
|
16
31
|
```sh
|
|
17
|
-
npx
|
|
18
|
-
npx
|
|
19
|
-
--keys id:string:unique --fields id:string,subject:string,sent_on:date --verbs read
|
|
32
|
+
npx ekka-shelves add hr.salary # a predefined shelf: its keys and fields are EKKA's
|
|
33
|
+
npx ekka-shelves add communications.email \
|
|
34
|
+
--keys id:string:unique --fields id:string,subject:string,sent_on:date --verbs read # a private shelf of your own
|
|
20
35
|
```
|
|
21
36
|
|
|
22
|
-
`add` writes `src/ekka
|
|
23
|
-
`
|
|
24
|
-
|
|
37
|
+
`add` writes three files under `src/ekka/`:
|
|
38
|
+
- `hr.salary.ts`: a class with empty typed methods and its fixtures;
|
|
39
|
+
- `policy.ts`: an empty role per verb;
|
|
40
|
+
- `index.ts`: your `provider` and the list of shelves.
|
|
41
|
+
|
|
42
|
+
### 2. Fill in the three files
|
|
43
|
+
|
|
44
|
+
- **`index.ts` › `provider.id`**: how EKKA names your service, in lowercase letters, digits and hyphens (for example `acme-payroll`). Keep `offerVersion` as it is until you change a shelf; then bump it.
|
|
45
|
+
- **`policy.ts`**: a ROLE for each verb, one lowercase word (letters, digits, `_`), for example `payroll_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.
|
|
46
|
+
- **`hr.salary.ts` › `read` and `list`**: your own code, from your own data to the shelf's fields. That code IS the mapping, and EKKA keeps no mapping table.
|
|
47
|
+
- **The fixtures in `hr.salary.ts`** are the ONE record that `check` and EKKA's import probe ask for:
|
|
48
|
+
- 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 currency `XTS`, the ISO code reserved for testing), or point both fixtures at a synthetic record you already have.
|
|
49
|
+
- The `list` fixture 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, and `check` fails if listing the fixture returns any other record.
|
|
50
|
+
|
|
51
|
+
### 3. Mount the plugin with YOUR auth
|
|
25
52
|
|
|
26
53
|
```ts
|
|
27
|
-
|
|
54
|
+
import { ekkaShelves } from '@ekka-ai/shelves';
|
|
55
|
+
import { provider, shelves } from './ekka/index.js';
|
|
56
|
+
|
|
57
|
+
await app.register(ekkaShelves({
|
|
58
|
+
provider,
|
|
59
|
+
shelves,
|
|
60
|
+
authenticate: requireUser, // your existing bearer check: sets who the caller is, or throws 401
|
|
61
|
+
authorize: (capability) => async (req) => { // your capability guard: throw 403 if the caller lacks it
|
|
62
|
+
if (!req.user?.scopes.includes(capability)) throw Object.assign(new Error('forbidden'), { statusCode: 403 });
|
|
63
|
+
},
|
|
64
|
+
capabilitiesOf: (req) => req.user?.scopes ?? [], // the capabilities the caller holds
|
|
65
|
+
principalOf: (req) => req.user?.id ?? '', // the caller's principal name
|
|
66
|
+
}));
|
|
28
67
|
```
|
|
29
68
|
|
|
30
|
-
|
|
31
|
-
|
|
69
|
+
The plugin never decides who a caller is; your hooks do.
|
|
70
|
+
|
|
71
|
+
### 4. One key per shelf: add the shelf's principal to your auth
|
|
32
72
|
|
|
33
73
|
```sh
|
|
34
|
-
npx
|
|
35
|
-
npx @ekka-ai/shelves check # the real endpoint through src/ekka/check-app.ts; prints what EKKA will see
|
|
74
|
+
npx ekka-shelves generate # writes src/ekka/shelves.generated.ts; `generate --check` fails in CI when it is stale
|
|
36
75
|
```
|
|
37
76
|
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
77
|
+
For each shelf, your auth needs:
|
|
78
|
+
- ONE principal, named `ekka-shelf-<shelf id>` (here `ekka-shelf-hr.salary`);
|
|
79
|
+
- holding exactly ONE capability, `shelf.<shelf id>.read` (here `shelf.hr.salary.read`);
|
|
80
|
+
- with its OWN key: a random secret of 32 bytes or more that your auth accepts as a bearer token, and nothing else.
|
|
81
|
+
|
|
82
|
+
`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.
|
|
83
|
+
|
|
84
|
+
### 5. `check`: see what EKKA will see
|
|
85
|
+
|
|
86
|
+
`check` calls your REAL app in-process. It needs one small file that you write, `src/ekka/check-app.ts`:
|
|
48
87
|
|
|
49
|
-
|
|
88
|
+
```ts
|
|
89
|
+
// src/ekka/check-app.ts: your app, built as production builds it, and a key for each shelf's principal.
|
|
90
|
+
import { randomBytes } from 'node:crypto';
|
|
50
91
|
|
|
92
|
+
export default async () => {
|
|
93
|
+
const key = randomBytes(32).toString('base64url');
|
|
94
|
+
// Make YOUR auth accept `key` as the principal ekka-shelf-hr.salary with capability shelf.hr.salary.read.
|
|
95
|
+
// For a token table read from the environment, set it BEFORE importing your app:
|
|
96
|
+
process.env.API_TOKENS = JSON.stringify({ [key]: { id: 'ekka-shelf-hr.salary', scopes: ['shelf.hr.salary.read'] } });
|
|
97
|
+
const { buildApp } = await import('../app.js');
|
|
98
|
+
const app = await buildApp();
|
|
99
|
+
await app.ready();
|
|
100
|
+
return { app, bearerFor: (_shelfId: string) => key };
|
|
101
|
+
};
|
|
51
102
|
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
npx ekka-shelves check # every line ok, or the line that is not, with what to change
|
|
55
106
|
```
|
|
56
107
|
|
|
57
|
-
|
|
108
|
+
### 6. Go live: how EKKA reaches your service
|
|
109
|
+
|
|
110
|
+
- Deploy as usual. The shelf endpoints are served by your app, on your origin.
|
|
111
|
+
- The origin EKKA is given must be a bare **https** origin, like `https://payroll-api.example.com`:
|
|
112
|
+
no path, no query, no user info. EKKA follows no redirects, and it caps the size of an answer.
|
|
113
|
+
- A **public** address works as it is. A **private** address (on the customer's own network) works
|
|
114
|
+
only when the customer's admin imports with `--reach private`. `localhost` and `127.0.0.1` are
|
|
115
|
+
never reachable: the import refuses them on purpose. To try it on your laptop, use `check` (step 5).
|
|
116
|
+
- The customer's EKKA admin imports your service with `ekka shelf import https://your-origin`. The
|
|
117
|
+
command:
|
|
118
|
+
1. calls `ping`;
|
|
119
|
+
2. shows the origin for them to confirm;
|
|
120
|
+
3. asks for each shelf's key (step 4) at a hidden prompt;
|
|
121
|
+
4. reads your manifest;
|
|
122
|
+
5. probes each shelf once with your fixture, and once with a key you do not declare, which must
|
|
123
|
+
be refused.
|
|
124
|
+
|
|
125
|
+
They then map your roles to their own roles.
|
|
126
|
+
- Change a shelf later (a role, a fixture, a field)? Bump `offerVersion` in `src/ekka/index.ts`.
|
|
127
|
+
EKKA refuses a changed offer under the same version.
|
|
128
|
+
|
|
129
|
+
**Real or synthetic data (the whole store).** If ALL of a shelf's rows are generated (a staging or
|
|
130
|
+
demo store), say so in its class: `override readonly data = 'synthetic' as const`. The customer's
|
|
131
|
+
admin then sees "synthetic" beside the shelf, and every signed receipt says so. Leave it unset for a
|
|
132
|
+
system of record, even when you have added one synthetic fixture person to it: `data` is about the
|
|
133
|
+
store, and the fixture is always synthetic.
|
|
134
|
+
|
|
135
|
+
## A shelf of your own (a private shelf)
|
|
136
|
+
|
|
137
|
+
When no EKKA shelf fits, declare your own shape:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
npx ekka-shelves add acme.expense \
|
|
141
|
+
--keys id:integer:unique,employee_id:string \
|
|
142
|
+
--fields id:integer,employee_id:string,spent_on:date,currency:currency_code,amount_minor:money_minor,approved:boolean \
|
|
143
|
+
--verbs read,list
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **Read keys:** read by an identifier, never by a person's name: two people can share a name.
|
|
147
|
+
- **Money:** an integer in the currency's minor unit (`money_minor`), always with a `currency_code`.
|
|
148
|
+
There are no floats anywhere.
|
|
149
|
+
- **Types:** `string`, `integer`, `boolean`, `month` (YYYY-MM), `date` (YYYY-MM-DD), `currency_code`
|
|
150
|
+
(ISO 4217) and `money_minor`.
|
|
151
|
+
- **Id:** a private id may not reuse one of EKKA's (`hr.salary` is EKKA's).
|
|
152
|
+
|
|
153
|
+
EKKA's own reference provider is a service like this. It serves `hr.salary` and a private
|
|
154
|
+
`reference.expense` (an integer id key, money in minor units), both from synthetic data. Its fixtures
|
|
155
|
+
are `{ id: 'fixture-0001', month: '2000-01' }` and `{ id: 1 }`, and `check` passes all 33 checks on it.
|
|
156
|
+
|
|
157
|
+
## What the plugin guarantees
|
|
158
|
+
|
|
159
|
+
- `read` takes EXACTLY the shelf's unique keys. `list` takes any of its declared keys. A key the shelf
|
|
160
|
+
does not declare is refused (`unknown_key`), never ignored. `list` returns at most the page size
|
|
161
|
+
asked for, never more than the shelf's cap, and a `next_cursor`.
|
|
162
|
+
- Every record leaving your service is checked against the shelf's exact fields and types. A bad one
|
|
163
|
+
becomes a `provider_error`, and neither its values nor your error's details leave your service.
|
|
164
|
+
- `manifest` needs a key, and it lists only that key's shelves. A shelf that key does not serve, and
|
|
165
|
+
a shelf that does not exist, get the same answer. Only `ping` is public.
|
|
166
|
+
- Startup refuses a shelf with no role, no fixture, or a list fixture that could match real records.
|
|
58
167
|
|
|
59
|
-
|
|
60
|
-
registry.npmjs.org, under LICENSE ("Proprietary. Use permitted for EKKA customers
|
|
61
|
-
under their EKKA agreement; no redistribution"). Publish ONLY with `node scripts/publish.mjs` (the workflow does): it
|
|
62
|
-
asks npm where it would publish and refuses anything but registry.npmjs.org. `publishConfig.registry`
|
|
63
|
-
alone is not enough, because the `@ekka-ai:registry` line in .npmrc beats it (the v0.2.0 failure).
|
|
64
|
-
A bare `npm publish` or `pnpm publish` is refused by the prepublishOnly guard. `@ekka-ai/contracts` stays internal: it is a devDependency, and only its three shelf files
|
|
65
|
-
are bundled.
|
|
66
|
-
- ⚠️ **The `NPM_TOKEN` secret EXPIRES ON 2026-11-29.** After that every publish fails at the npm
|
|
67
|
-
step. Renewing it is a publish blocker: the owner replaces the secret on this repo before then
|
|
68
|
-
(tracked on ekka-ai/.github#672).
|
|
168
|
+
## Versions and license
|
|
69
169
|
|
|
70
|
-
|
|
170
|
+
- `0.2.2` is the first published version.
|
|
171
|
+
- Proprietary: use is permitted for EKKA customers under their EKKA agreement; no redistribution
|
|
172
|
+
(`LICENSE`).
|
package/dist/check.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `npx @ekka-ai/shelves check
|
|
2
|
+
* `npx @ekka-ai/shelves check`: the real endpoint, the real fixtures, and what
|
|
3
3
|
* EKKA will see. The same command runs in CI.
|
|
4
4
|
*
|
|
5
5
|
* ⛔ IT TRUSTS NOTHING IT IS CHECKING. Every answer is re-checked here against the manifest the
|
|
@@ -89,6 +89,7 @@ export const runCheck = async (t) => {
|
|
|
89
89
|
say(!!shape && !known, id, known ? `a private shelf may not reuse the predefined id ${id}` : 'a private shelf, with the shape it declares', shape?.fields.map((f) => f.name).join(', '));
|
|
90
90
|
}
|
|
91
91
|
say(m.probe_fixture.synthetic === true, id, 'its probe fixture is marked synthetic');
|
|
92
|
+
say(m.data === undefined || m.data === 'real' || m.data === 'synthetic', id, `its data is declared ${m.data ?? 'real (unset)'}`, m.data === 'synthetic' ? 'synthetic rows: an importer and the receipt will say so' : undefined);
|
|
92
93
|
if (!shape)
|
|
93
94
|
continue;
|
|
94
95
|
const major = m.interface_major ?? 1;
|
|
@@ -96,7 +97,11 @@ export const runCheck = async (t) => {
|
|
|
96
97
|
if (m.verbs.includes('read') && fx.read) {
|
|
97
98
|
const hit = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'read', fx.read) });
|
|
98
99
|
const rec = result(hit)?.['record'];
|
|
99
|
-
const bad = rec === undefined
|
|
100
|
+
const bad = rec === undefined
|
|
101
|
+
? result(hit)?.['not_found'] === true
|
|
102
|
+
? `no record for the read fixture ${JSON.stringify(fx.read)}: your data needs this ONE synthetic record (a made-up person is fine), or change \`fixtures.read\` in src/ekka/${id}.ts to a synthetic record you have`
|
|
103
|
+
: `no record (HTTP ${hit.status}${refusalCode(hit) ? `, ${refusalCode(hit)}` : ''})`
|
|
104
|
+
: exactFields(shape, rec);
|
|
100
105
|
say(!bad, id, 'read with the fixture returns one record, exactly the shelf\u2019s fields', bad || `EKKA sees: ${JSON.stringify(rec)}`);
|
|
101
106
|
const missing = missingFrom(shape, fx.read);
|
|
102
107
|
if (missing) {
|
|
@@ -129,6 +134,22 @@ export const runCheck = async (t) => {
|
|
|
129
134
|
: recBad
|
|
130
135
|
? `record ${recBad[0]}: ${recBad[1]}`
|
|
131
136
|
: `EKKA sees ${records.length} record(s), next_cursor ${JSON.stringify(r?.['next_cursor'])}`);
|
|
137
|
+
// ⛔ ONLY THE FIXTURE ROW. A list fixture names one synthetic record by
|
|
138
|
+
// its unique keys; any other record here is a record the import probe would carry to EKKA. The
|
|
139
|
+
// detail names only unique-key values, never a record.
|
|
140
|
+
const lf = fx.list;
|
|
141
|
+
if (Array.isArray(records)) {
|
|
142
|
+
const pin = shape.keys.filter((k) => k.unique && k.name in lf).map((k) => k.name);
|
|
143
|
+
const keysOf = (x) => Object.fromEntries(pin.map((k) => [k, isObj(x) ? x[k] : undefined]));
|
|
144
|
+
const stray = pin.length ? records.findIndex((x) => pin.some((k) => !isObj(x) || x[k] !== lf[k])) : -1;
|
|
145
|
+
say(pin.length > 0 && records.length > 0 && stray < 0, id, 'list with the fixture returns ONLY the fixture row, never another record', pin.length === 0
|
|
146
|
+
? 'the list fixture names no unique key, so it could match real records'
|
|
147
|
+
: records.length === 0
|
|
148
|
+
? `no record for ${JSON.stringify(fx.list)}: your data needs this synthetic record`
|
|
149
|
+
: stray >= 0
|
|
150
|
+
? `record ${stray} is ${JSON.stringify(keysOf(records[stray]))}, not the fixture ${JSON.stringify(keysOf(lf))}: the import probe would carry it to EKKA`
|
|
151
|
+
: `${records.length} record(s), all ${JSON.stringify(keysOf(lf))}`);
|
|
152
|
+
}
|
|
132
153
|
const over = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'list', fx.list, { limit: Math.min(1000, shape.pageCap + 1) }) });
|
|
133
154
|
const overRecords = result(over)?.['records'];
|
|
134
155
|
say(over.status === 200 && Array.isArray(overRecords) && overRecords.length <= shape.pageCap, id, `list asked for more than its cap returns at most ${shape.pageCap} records`, `HTTP ${over.status}`);
|
package/dist/cli.js
CHANGED
|
@@ -20,19 +20,29 @@ const flag = (args, name) => {
|
|
|
20
20
|
return args[i + 1];
|
|
21
21
|
return args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
22
22
|
};
|
|
23
|
-
const
|
|
23
|
+
const CHECK_APP_HELP = 'create it: its default export is `async () => ({ app, bearerFor })`, where `app` is your Fastify app built exactly as ' +
|
|
24
|
+
'production builds it (your auth included) and `bearerFor(shelfId)` returns a key your auth accepts for the principal ' +
|
|
25
|
+
'`ekka-shelf-<shelfId>`. README › "5. check" has a complete example.';
|
|
26
|
+
const loadTs = async (file, missing = 'run from the service root') => {
|
|
24
27
|
const abs = resolve(file);
|
|
25
28
|
if (!existsSync(abs))
|
|
26
|
-
throw new Error(`${file} does not exist (
|
|
29
|
+
throw new Error(`${file} does not exist (${missing})`);
|
|
30
|
+
let tsImport;
|
|
27
31
|
try {
|
|
28
|
-
|
|
29
|
-
return await tsImport(pathToFileURL(abs).href, import.meta.url);
|
|
32
|
+
({ tsImport } = (await import('tsx/esm/api')));
|
|
30
33
|
}
|
|
31
34
|
catch (e) {
|
|
32
35
|
if (e.code !== 'ERR_MODULE_NOT_FOUND')
|
|
33
36
|
throw e;
|
|
34
|
-
return (await import(pathToFileURL(abs).href));
|
|
35
37
|
}
|
|
38
|
+
if (tsImport)
|
|
39
|
+
return tsImport(pathToFileURL(abs).href, import.meta.url);
|
|
40
|
+
// Without tsx, Node cannot load TypeScript, and its own error ("Cannot find module …/x.js") hides why.
|
|
41
|
+
if (/\.[cm]?ts$/.test(abs)) {
|
|
42
|
+
throw new Error(`${file} is TypeScript and tsx is not installed where @ekka-ai/shelves can load it. In this service run: ` +
|
|
43
|
+
'`npm i @ekka-ai/shelves && npm i -D tsx`, then use `npx ekka-shelves …` (the local copy, not a one-off npx download).');
|
|
44
|
+
}
|
|
45
|
+
return (await import(pathToFileURL(abs).href));
|
|
36
46
|
};
|
|
37
47
|
const loadShelves = async (root) => {
|
|
38
48
|
const m = await loadTs(resolve(root, 'src/ekka/index.ts'));
|
|
@@ -51,7 +61,16 @@ export const main = async (argv) => {
|
|
|
51
61
|
throw new Error('usage: ekka-shelves add <id> [--keys … --fields … --verbs …]');
|
|
52
62
|
const r = add({ root, id, keys: flag(args, 'keys'), fields: flag(args, 'fields'), verbs: flag(args, 'verbs') });
|
|
53
63
|
process.stdout.write(`${r.isPrivate ? 'private' : 'predefined'} shelf ${id}\n${r.touched.map((f) => ` wrote ${f}`).join('\n')}\n`);
|
|
54
|
-
process.stdout.write(
|
|
64
|
+
process.stdout.write([
|
|
65
|
+
'Next (README › "Use it"):',
|
|
66
|
+
' 1. install, if you have not: npm i @ekka-ai/shelves && npm i -D tsx',
|
|
67
|
+
" 2. src/ekka/index.ts: name this service in provider.id (lowercase letters, digits, hyphens)",
|
|
68
|
+
' 3. src/ekka/policy.ts: a role for each verb, one lowercase word, e.g. payroll_reader',
|
|
69
|
+
` 4. src/ekka/${id}.ts: fill read/list from your data; make the fixture record exist (a synthetic one)`,
|
|
70
|
+
' 5. mount ekkaShelves in your app; give your auth one principal and key per shelf (ekka-shelf-<id>)',
|
|
71
|
+
' 6. npx ekka-shelves generate, then write src/ekka/check-app.ts and run npx ekka-shelves check',
|
|
72
|
+
'',
|
|
73
|
+
].join('\n'));
|
|
55
74
|
return 0;
|
|
56
75
|
}
|
|
57
76
|
if (cmd === 'generate') {
|
|
@@ -73,7 +92,7 @@ export const main = async (argv) => {
|
|
|
73
92
|
}
|
|
74
93
|
if (cmd === 'check') {
|
|
75
94
|
const { shelves } = await loadShelves(root);
|
|
76
|
-
const mod = await loadTs(resolve(root, flag(args, 'app') ?? 'src/ekka/check-app.ts'));
|
|
95
|
+
const mod = await loadTs(resolve(root, flag(args, 'app') ?? 'src/ekka/check-app.ts'), CHECK_APP_HELP);
|
|
77
96
|
const { app, bearerFor } = await mod.default();
|
|
78
97
|
const transport = async (req) => {
|
|
79
98
|
const res = await app.inject({
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contracts_version": "0.
|
|
2
|
+
"contracts_version": "0.43.1",
|
|
3
3
|
"files": {
|
|
4
|
-
"shelves/registry.json": "
|
|
5
|
-
"shelves/protocol-v1.schema.json": "
|
|
6
|
-
"gate/attested/shelf.v1.json": "
|
|
4
|
+
"shelves/registry.json": "62d5214d5e36569595c6251501c1465efa6bead4ea7cea5836d5d9a1a4d6bbfa",
|
|
5
|
+
"shelves/protocol-v1.schema.json": "f791b722223f2359222b7230a19da93b58998a222dd1007284c2d858ae0e4aa7",
|
|
6
|
+
"gate/attested/shelf.v1.json": "eea0957687e78312bca5cc30f2c5bb02778702aa45184465bb76208b781b9641"
|
|
7
7
|
}
|
|
8
8
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://schemas.ekka.ai/gate/attested/shelf.v1.json",
|
|
4
4
|
"title": "ShelfAttestedBody",
|
|
5
|
-
"description": "What the `shelf` gate declares as ATTESTABLE for one enforced shelf invocation
|
|
5
|
+
"description": "What the `shelf` gate declares as ATTESTABLE for one enforced shelf invocation. A fact reaches the signature only by being declared here; the signer canonicalizes and signs this object WHOLE and never reads a key inside it. CLOSED SHAPE ON PURPOSE: an unlisted key is refused rather than dropped. NO BODIES AND NO LOOKUP VALUES, EVER: a record is a company's data and `by` names a person, so only digests, counts and status appear. NO FLOATING-POINT NUMBERS: every numeric field is an integer.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
8
8
|
"op": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"minLength": 1,
|
|
19
19
|
"maxLength": 512,
|
|
20
20
|
"pattern": "^shelves/[a-z][a-z0-9_]{0,31}(\\.[a-z][a-z0-9_]{0,31})+$",
|
|
21
|
-
"description": "`shelves/<shelf_id>`, the canonical shelf string
|
|
21
|
+
"description": "`shelves/<shelf_id>`, the canonical shelf string."
|
|
22
22
|
},
|
|
23
23
|
"offer_version": {
|
|
24
24
|
"type": "string",
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
"maxLength": 64,
|
|
27
27
|
"description": "The provider offer version pinned for the run and echoed by the provider."
|
|
28
28
|
},
|
|
29
|
+
"data": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": [
|
|
32
|
+
"real",
|
|
33
|
+
"synthetic"
|
|
34
|
+
],
|
|
35
|
+
"description": "Whether the data behind the shelf is `real` or `synthetic`, as the pinned offer declared it (shelves/protocol-v1.schema.json, manifest_shelf.data). Absent means real. Signed, so a receipt over a synthetic store can never be read as evidence about real people."
|
|
36
|
+
},
|
|
29
37
|
"request_sha256_b64": {
|
|
30
38
|
"type": "string",
|
|
31
39
|
"minLength": 1,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://schemas.ekka.ai/shelves/protocol-v1.schema.json",
|
|
4
4
|
"title": "ShelfProtocolV1",
|
|
5
|
-
"description": "The shelf protocol v1 between an EKKA Enclave and a Business API
|
|
5
|
+
"description": "The shelf protocol v1 between an EKKA Enclave and a Business API. Three routes, mounted by the provider's `@ekka/shelves` plugin and nothing else: `GET /ekka/shelves/v1/ping` (public, answers `ping` below), `GET /ekka/shelves/v1/manifest` (bearer-authenticated, answers `manifest`), `POST /ekka/shelves/v1/invoke` (bearer-authenticated, takes `request`, answers `response`). The Enclave never learns another route, and a plan never supplies a URL, method or header. The bearer is the per-shelf principal key the provider issued; it is the provider's transport auth, not EKKA's authority. This file declares shapes; which definition a route uses is stated in each definition.",
|
|
6
6
|
"definitions": {
|
|
7
7
|
"shelf_id": {
|
|
8
8
|
"type": "string",
|
|
@@ -433,6 +433,14 @@
|
|
|
433
433
|
"private"
|
|
434
434
|
]
|
|
435
435
|
},
|
|
436
|
+
"data": {
|
|
437
|
+
"type": "string",
|
|
438
|
+
"enum": [
|
|
439
|
+
"real",
|
|
440
|
+
"synthetic"
|
|
441
|
+
],
|
|
442
|
+
"description": "A FACT ABOUT THE DATA BEHIND THE SHELF, declared by the provider: `real` (a system of record) or `synthetic` (generated fixtures, e.g. a staging store). ABSENT MEANS REAL. An importer shows it beside the shelf, so nobody mistakes a synthetic store for company data, and the receipt carries it (gate/attested/shelf.v1.json `data`)."
|
|
443
|
+
},
|
|
436
444
|
"interface_major": {
|
|
437
445
|
"type": "integer",
|
|
438
446
|
"minimum": 1,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "EKKA's predefined shelves
|
|
2
|
+
"description": "EKKA's predefined shelves. RULES EVERY SHELF HERE FOLLOWS: (1) A read key set is an IDENTIFIER set, never a human name. Names collide, so a read by name can return the wrong person's record; a name may be a field and a list filter, never a unique key. (2) Money is an integer count of minor units (`money_minor`) plus a currency code (`currency_code`, ISO 4217) on the same shelf, never a float. (3) Frozen once published: a breaking change is a new interface_major beside the old one, never an edit in place. (4) A private shelf a company declares may never reuse an id listed here.",
|
|
3
3
|
"registry_version": 1,
|
|
4
4
|
"categories": [
|
|
5
5
|
{
|
package/dist/generate.js
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE GENERATOR: src/ekka/shelves.generated.ts, the one source for what the shelves add to the
|
|
3
|
-
* Business API
|
|
3
|
+
* Business API.
|
|
4
4
|
*
|
|
5
5
|
* It writes the capability ids (`shelf.<id>.read`), ONE principal per shelf (`ekka-shelf-<id>`,
|
|
6
6
|
* one key each) and the protocol routes with their prefix. The host's
|
|
7
7
|
* capability table imports these instead of anyone typing them in three places.
|
|
8
8
|
*
|
|
9
|
-
* ⛔ NO EXPORTER ROWS, NO SURFACES
|
|
9
|
+
* ⛔ NO EXPORTER ROWS, NO SURFACES. A Business API's API-catalog exporter
|
|
10
10
|
* must never make a shelf route callable through the API gate: that would let a plan reach the
|
|
11
11
|
* shelf around the shelf executor, its record check and its receipt. The host's exporter reads
|
|
12
12
|
* SHELF_ROUTE_PREFIX to REFUSE any catalog rule that admits it.
|
|
13
13
|
*/
|
|
14
14
|
import { buildOffer, capabilityOf, principalOf, ROUTES, SHELF_ROUTE_PREFIX } from './protocol.js';
|
|
15
15
|
// ⛔ NO OFFER, NO DIGEST IN THIS FILE. The manifest is built at startup from the registered shelves
|
|
16
|
-
// (
|
|
16
|
+
// (the plugin does it), and a probe fixture can come from the service's configuration (a seeded synthetic
|
|
17
17
|
// record), so a digest written here would disagree with the one served. One source: the startup.
|
|
18
18
|
import { prepareShelves } from './plugin.js';
|
|
19
|
-
export const GENERATED_HEADER = '// GENERATED by @ekka-ai/shelves (`npx
|
|
20
|
-
'// src/ekka/policy.ts and generate again. `npx
|
|
19
|
+
export const GENERATED_HEADER = '// GENERATED by @ekka-ai/shelves (`npx ekka-shelves generate`). Do not edit: change the shelf classes or\n' +
|
|
20
|
+
'// src/ekka/policy.ts and generate again. Run `npx ekka-shelves generate --check` in CI: it fails when this file is stale.\n' +
|
|
21
|
+
'//\n' +
|
|
22
|
+
'// What it is for: your auth and routing import these names instead of typing them. Give each principal\n' +
|
|
23
|
+
'// below its OWN key and exactly its capabilities (README, step 4).\n';
|
|
21
24
|
const lit = (v) => JSON.stringify(v, null, 2);
|
|
22
25
|
export const generate = (provider, shelves) => {
|
|
23
26
|
prepareShelves(shelves); // refuses the same offers the plugin would refuse at startup
|
|
@@ -28,15 +31,16 @@ export const generate = (provider, shelves) => {
|
|
|
28
31
|
GENERATED_HEADER,
|
|
29
32
|
`export const SHELF_IDS = ${lit(ids)} as const;`,
|
|
30
33
|
'',
|
|
31
|
-
'/**
|
|
34
|
+
'/** The capability each shelf checks, one per shelf: read and list both require it. */',
|
|
32
35
|
`export const SHELF_CAPABILITY_IDS = ${lit(ids.map(capabilityOf))} as const;`,
|
|
33
36
|
'',
|
|
34
|
-
'/**
|
|
37
|
+
'/** The principal EKKA calls as, one per shelf, so one key per shelf: a key never reaches another shelf. */',
|
|
35
38
|
`export const SHELF_PRINCIPAL_IDS = ${lit(ids.map(principalOf))} as const;`,
|
|
36
39
|
'',
|
|
40
|
+
'/** Principal → its capabilities: the entries to add to your auth table. */',
|
|
37
41
|
`export const SHELF_PRINCIPAL_CAPABILITIES = ${lit(principalCaps)} as const;`,
|
|
38
42
|
'',
|
|
39
|
-
'
|
|
43
|
+
'/**\n * The routes the plugin mounts. Only EKKA\'s shelf protocol may reach them: if your service also publishes\n * its routes to EKKA as a plain API, leave everything under SHELF_ROUTE_PREFIX out of that list.\n */',
|
|
40
44
|
`export const SHELF_ROUTES = ${lit(Object.values(ROUTES))} as const;`,
|
|
41
45
|
`export const SHELF_ROUTE_PREFIX = ${lit(SHELF_ROUTE_PREFIX)};`,
|
|
42
46
|
'',
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare function predefinedShelves(): {
|
|
|
4
4
|
interface_major: number;
|
|
5
5
|
}[];
|
|
6
6
|
export { ekkaShelves, prepareShelves, type EkkaShelvesOptions } from './plugin.js';
|
|
7
|
-
export { Shelf, ShelfRefusal, notImplemented, predefined, privateShelf, verbProblems, type AnyShelf, type Fixtures, type ListResult, type Page, type RefusalCode, type Roles, type Row, type ShelfDescriptor, } from './shelf.js';
|
|
7
|
+
export { Shelf, ShelfRefusal, notImplemented, predefined, privateShelf, listFixtureProblems, verbProblems, DATA_KINDS, type AnyShelf, type DataKind, type Fixtures, type ListResult, type Page, type RefusalCode, type Roles, type Row, type ShelfDescriptor, } from './shelf.js';
|
|
8
8
|
export { PROTOCOL, ROUTES, SHELF_ROUTE_PREFIX, buildOffer, manifestFor, canonicalJson, capabilityOf, principalOf, type Manifest, type ManifestShelf, type Offer, } from './protocol.js';
|
|
9
9
|
export { loadRegistry, registrySource, parseRegistry, type Registry, type PredefinedShelf, type Verb, type FieldType } from './registry.js';
|
|
10
10
|
export { generate, GENERATED_HEADER } from './generate.js';
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @ekka-ai/shelves:
|
|
2
|
+
* @ekka-ai/shelves: serve EKKA shelves from a Business API.
|
|
3
3
|
*
|
|
4
|
-
* npx @ekka-ai/shelves add <id> scaffold a typed shelf class
|
|
5
|
-
* npx @ekka-ai/shelves generate write src/ekka/shelves.generated.ts
|
|
6
|
-
* npx @ekka-ai/shelves check the real endpoint, the fixtures, what EKKA will see
|
|
7
|
-
* app.register(ekkaShelves({...})) mount the shelf protocol v1
|
|
4
|
+
* npx @ekka-ai/shelves add <id> scaffold a typed shelf class
|
|
5
|
+
* npx @ekka-ai/shelves generate write src/ekka/shelves.generated.ts
|
|
6
|
+
* npx @ekka-ai/shelves check the real endpoint, the fixtures, what EKKA will see
|
|
7
|
+
* app.register(ekkaShelves({...})) mount the shelf protocol v1
|
|
8
8
|
*
|
|
9
|
-
* The one source of truth is @ekka-ai/contracts
|
|
9
|
+
* The one source of truth is the pinned @ekka-ai/contracts: the shelf registry and protocol v1.
|
|
10
10
|
*/
|
|
11
11
|
import { loadRegistry } from './registry.js';
|
|
12
12
|
/** The predefined shelves, read from the pinned contracts package, never copied. */
|
|
@@ -14,7 +14,7 @@ export function predefinedShelves() {
|
|
|
14
14
|
return [...loadRegistry().shelves.values()].map(({ id, interfaceMajor }) => ({ id, interface_major: interfaceMajor }));
|
|
15
15
|
}
|
|
16
16
|
export { ekkaShelves, prepareShelves } from './plugin.js';
|
|
17
|
-
export { Shelf, ShelfRefusal, notImplemented, predefined, privateShelf, verbProblems, } from './shelf.js';
|
|
17
|
+
export { Shelf, ShelfRefusal, notImplemented, predefined, privateShelf, listFixtureProblems, verbProblems, DATA_KINDS, } from './shelf.js';
|
|
18
18
|
export { PROTOCOL, ROUTES, SHELF_ROUTE_PREFIX, buildOffer, manifestFor, canonicalJson, capabilityOf, principalOf, } from './protocol.js';
|
|
19
19
|
export { loadRegistry, registrySource, parseRegistry } from './registry.js';
|
|
20
20
|
export { generate, GENERATED_HEADER } from './generate.js';
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE PLUGIN: one `register` mounts the shelf protocol on a Business API
|
|
2
|
+
* THE PLUGIN: one `register` mounts the shelf protocol on a Business API.
|
|
3
3
|
*
|
|
4
4
|
* await app.register(ekkaShelves({ provider, shelves, authenticate, authorize, capabilitiesOf, run }));
|
|
5
5
|
*
|
|
6
6
|
* ⛔ THE HOST OWNS AUTHORITY. This package never decides who a caller is. `authenticate` is the
|
|
7
|
-
* host's own bearer check (
|
|
8
|
-
* but ping, BEFORE the body is looked at. `authorize(capability)` is the host's own guard (
|
|
7
|
+
* host's own bearer check (for example its principal lookup and organization scope) and runs on every request
|
|
8
|
+
* but ping, BEFORE the body is looked at. `authorize(capability)` is the host's own guard (for example
|
|
9
9
|
* requireCapability) and runs for the one shelf named in the body. A thrown error from either is
|
|
10
10
|
* the host's answer; an authorize refusal is also mapped to the protocol's `forbidden`.
|
|
11
11
|
*
|
|
@@ -24,7 +24,7 @@ export interface EkkaShelvesOptions {
|
|
|
24
24
|
readonly shelves: readonly AnyShelf[];
|
|
25
25
|
/** The host's bearer check. Throws its own 401 on a missing or unknown credential. */
|
|
26
26
|
readonly authenticate: Hook;
|
|
27
|
-
/** The host's own capability guard for one capability, e.g.
|
|
27
|
+
/** The host's own capability guard for one capability, e.g. a requireCapability middleware. */
|
|
28
28
|
readonly authorize: (capability: string) => Hook;
|
|
29
29
|
/** The capabilities of the principal `authenticate` found, read to filter the manifest. */
|
|
30
30
|
readonly capabilitiesOf: (request: FastifyRequest) => readonly string[];
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from 'typebox';
|
|
2
2
|
import type { FieldType, ShelfShape, Verb } from './registry.js';
|
|
3
|
-
import type { AnyShelf, RefusalCode, Row } from './shelf.js';
|
|
3
|
+
import type { AnyShelf, DataKind, RefusalCode, Row } from './shelf.js';
|
|
4
4
|
export declare const PROTOCOL: 1;
|
|
5
5
|
export declare const ROUTES: {
|
|
6
6
|
readonly ping: "/ekka/shelves/v1/ping";
|
|
@@ -78,6 +78,8 @@ export declare const sha256: (s: string) => string;
|
|
|
78
78
|
export interface ManifestShelf {
|
|
79
79
|
readonly id: string;
|
|
80
80
|
readonly origin: 'predefined' | 'private';
|
|
81
|
+
/** Absent means real. */
|
|
82
|
+
readonly data?: DataKind;
|
|
81
83
|
readonly interface_major?: number;
|
|
82
84
|
readonly shape?: {
|
|
83
85
|
readonly keys: readonly {
|
|
@@ -124,8 +126,8 @@ export declare const buildOffer: (provider: {
|
|
|
124
126
|
}, shelves: readonly AnyShelf[]) => Offer;
|
|
125
127
|
/** The manifest ONE principal is shown: only the shelves it serves, with the whole offer's digest. */
|
|
126
128
|
export declare const manifestFor: (offer: Offer, principal: string, capabilities: readonly string[]) => Manifest;
|
|
127
|
-
/** The capability a Business API checks for every verb of one shelf
|
|
129
|
+
/** The capability a Business API checks for every verb of one shelf. */
|
|
128
130
|
export declare const capabilityOf: (shelfId: string) => string;
|
|
129
|
-
/** One principal per shelf, so one key per shelf
|
|
131
|
+
/** One principal per shelf, so one key per shelf. */
|
|
130
132
|
export declare const principalOf: (shelfId: string) => string;
|
|
131
133
|
export type { Row };
|
package/dist/protocol.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE SHELF PROTOCOL v1, as the Business API side sees it
|
|
2
|
+
* THE SHELF PROTOCOL v1, as the Business API side sees it. The normative
|
|
3
3
|
* schema is `@ekka-ai/contracts/shelves/protocol-v1.schema.json`; the TypeBox schemas below mirror
|
|
4
4
|
* it, and tests/conformance.test.ts checks what this package SERVES against the official file.
|
|
5
5
|
*
|
|
@@ -128,6 +128,7 @@ export const manifestShelf = (s) => {
|
|
|
128
128
|
return {
|
|
129
129
|
id,
|
|
130
130
|
origin: isPrivate ? 'private' : 'predefined',
|
|
131
|
+
...(s.data ? { data: s.data } : {}),
|
|
131
132
|
...(isPrivate
|
|
132
133
|
? {
|
|
133
134
|
shape: {
|
|
@@ -160,7 +161,7 @@ export const manifestFor = (offer, principal, capabilities) => ({
|
|
|
160
161
|
principal,
|
|
161
162
|
shelves: offer.shelves.filter((s) => capabilities.includes(capabilityOf(s.id))),
|
|
162
163
|
});
|
|
163
|
-
/** The capability a Business API checks for every verb of one shelf
|
|
164
|
+
/** The capability a Business API checks for every verb of one shelf. */
|
|
164
165
|
export const capabilityOf = (shelfId) => `shelf.${shelfId}.read`;
|
|
165
|
-
/** One principal per shelf, so one key per shelf
|
|
166
|
+
/** One principal per shelf, so one key per shelf. */
|
|
166
167
|
export const principalOf = (shelfId) => `ekka-shelf-${shelfId}`;
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type FieldType = 'string' | 'integer' | 'boolean' | 'month' | 'date' | 'currency_code' | 'money_minor';
|
|
2
2
|
export declare const FIELD_TYPES: readonly FieldType[];
|
|
3
|
-
/** v1 ships these two; write, update, delete and send are
|
|
3
|
+
/** v1 ships these two; write, update, delete and send are reserved by the protocol. */
|
|
4
4
|
export type Verb = 'read' | 'list';
|
|
5
5
|
export declare const VERBS: readonly Verb[];
|
|
6
6
|
export interface KeyDecl {
|
package/dist/registry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE SHELF REGISTRY: EKKA's fixed vocabulary of predefined shelves
|
|
2
|
+
* THE SHELF REGISTRY: EKKA's fixed vocabulary of predefined shelves.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ ONE SOURCE: `@ekka-ai/contracts/shelves/registry.json`, reviewed by PR in ekka-contracts. A
|
|
5
5
|
* Business API never redefines a predefined shelf; this package only reads it.
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type KeyDecl, type FieldDecl } from './registry.js';
|
|
2
2
|
export declare const className: (id: string) => string;
|
|
3
3
|
/** `id:string:unique,month:string:unique,name:string` */
|
|
4
|
-
export declare const parseKeys: (
|
|
4
|
+
export declare const parseKeys: (list: string) => KeyDecl[];
|
|
5
5
|
/** `id:string,subject:string,sent_at:string?` (a trailing ? is optional) */
|
|
6
|
-
export declare const parseFields: (
|
|
6
|
+
export declare const parseFields: (list: string) => FieldDecl[];
|
|
7
7
|
export interface AddOptions {
|
|
8
8
|
readonly root: string;
|
|
9
9
|
readonly id: string;
|
package/dist/scaffold.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `npx @ekka-ai/shelves add <id> [--keys … --fields … --verbs …]
|
|
2
|
+
* `npx @ekka-ai/shelves add <id> [--keys … --fields … --verbs …]`: scaffold a typed shelf class.
|
|
3
3
|
*
|
|
4
4
|
* Writes src/ekka/<id>.ts: a class with EMPTY typed methods. The person fills the method bodies with
|
|
5
5
|
* their own code, which is the whole mapping from their data to the shelf.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* add hr.salary a predefined shelf: its shape is EKKA's registry
|
|
8
8
|
* add communications.email --keys id:string:unique --fields id:string,subject:string,sent:boolean
|
|
9
9
|
*
|
|
10
|
-
* ⛔ A PRIVATE SHELF MAY NOT TAKE A PREDEFINED ID (
|
|
10
|
+
* ⛔ A PRIVATE SHELF MAY NOT TAKE A PREDEFINED ID (EKKA's registry owns those ids), and a predefined shelf takes no
|
|
11
11
|
* --keys or --fields: its shape is EKKA's, not the company's.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -24,19 +24,19 @@ const parseType = (t, where) => {
|
|
|
24
24
|
return t;
|
|
25
25
|
};
|
|
26
26
|
/** `id:string:unique,month:string:unique,name:string` */
|
|
27
|
-
export const parseKeys = (
|
|
27
|
+
export const parseKeys = (list) => list.split(',').map((part) => {
|
|
28
28
|
const [name, type, flag] = part.trim().split(':');
|
|
29
29
|
if (!name)
|
|
30
|
-
throw new Error(`--keys: empty entry in "${
|
|
30
|
+
throw new Error(`--keys: empty entry in "${list}"`);
|
|
31
31
|
return { name, type: parseType(type, `--keys ${name}`), unique: flag === 'unique' };
|
|
32
32
|
});
|
|
33
33
|
/** `id:string,subject:string,sent_at:string?` (a trailing ? is optional) */
|
|
34
|
-
export const parseFields = (
|
|
34
|
+
export const parseFields = (list) => list.split(',').map((part) => {
|
|
35
35
|
const p = part.trim();
|
|
36
36
|
const optional = p.endsWith('?');
|
|
37
37
|
const [name, type] = (optional ? p.slice(0, -1) : p).split(':');
|
|
38
38
|
if (!name)
|
|
39
|
-
throw new Error(`--fields: empty entry in "${
|
|
39
|
+
throw new Error(`--fields: empty entry in "${list}"`);
|
|
40
40
|
return { name, type: parseType(type, `--fields ${name}`), required: !optional };
|
|
41
41
|
});
|
|
42
42
|
/** Values nobody could mistake for real data: ISO 4217 reserves XTS for testing; year 2000 is not a pay month anyone reads. */
|
|
@@ -100,7 +100,8 @@ export const add = (o) => {
|
|
|
100
100
|
verbs.includes('read')
|
|
101
101
|
? ` read: { by: { ${unique.map((k) => `${k.name}: ${fixtureValue(k)}`).join(', ')} } },`
|
|
102
102
|
: '',
|
|
103
|
-
|
|
103
|
+
// The SAME synthetic record, by every unique key: never `{}`, which would match every real row.
|
|
104
|
+
verbs.includes('list') ? ` list: { by: { ${unique.map((k) => `${k.name}: ${fixtureValue(k)}`).join(', ')} } },` : '',
|
|
104
105
|
]
|
|
105
106
|
.filter(Boolean)
|
|
106
107
|
.join('\n');
|
|
@@ -134,6 +135,9 @@ ${recordType}
|
|
|
134
135
|
|
|
135
136
|
export class ${C} extends Shelf<${C}By, ${C}Filter, ${C}Record> {
|
|
136
137
|
readonly descriptor = ${descriptor};
|
|
138
|
+
// Uncomment when these rows are GENERATED (a staging or demo store), not a system of record: the
|
|
139
|
+
// manifest, the importer and the signed receipt then say "synthetic". Unset means real.
|
|
140
|
+
// override readonly data = 'synthetic' as const;
|
|
137
141
|
readonly roles = policy['${o.id}'];
|
|
138
142
|
readonly fixtures: Fixtures<${C}By, ${C}Filter> = {
|
|
139
143
|
${fixtures}
|
package/dist/shelf.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A SHELF, AS A BUSINESS API WRITES IT
|
|
2
|
+
* A SHELF, AS A BUSINESS API WRITES IT.
|
|
3
3
|
*
|
|
4
4
|
* `npx @ekka-ai/shelves add hr.salary` writes a class that extends `Shelf` with empty typed methods. The
|
|
5
5
|
* company fills the method bodies with its own code: that code IS the mapping from its data to the
|
|
6
|
-
* shelf, and EKKA keeps no mapping table
|
|
6
|
+
* shelf, and EKKA keeps no mapping table.
|
|
7
7
|
*
|
|
8
8
|
* - A PREDEFINED shelf takes its keys, fields, verbs and page cap from EKKA's registry.
|
|
9
9
|
* `predefined('hr.salary')` refuses an id the registry does not define.
|
|
10
10
|
* - A PRIVATE shelf declares its shape in the file. `privateShelf(...)` refuses an id the registry
|
|
11
|
-
* DOES define
|
|
11
|
+
* DOES define: a company may not publish its own `hr.salary`.
|
|
12
12
|
* - `roles` is read from ONE policy file, `src/ekka/policy.ts`: a provider role is written in
|
|
13
|
-
* exactly one place
|
|
13
|
+
* exactly one place.
|
|
14
14
|
*/
|
|
15
15
|
import { type FieldType, type ShelfShape, type Verb } from './registry.js';
|
|
16
16
|
export type Scalar = string | number | boolean;
|
|
@@ -24,6 +24,9 @@ export interface ListResult<R extends Row> {
|
|
|
24
24
|
/** Opaque to EKKA; null when this is the last page. Missing is a failed call, never "no more". */
|
|
25
25
|
readonly nextCursor: string | null;
|
|
26
26
|
}
|
|
27
|
+
/** protocol-v1 manifest_shelf.data. */
|
|
28
|
+
export type DataKind = 'real' | 'synthetic';
|
|
29
|
+
export declare const DATA_KINDS: readonly DataKind[];
|
|
27
30
|
/** The provider role each verb requires. Written in src/ekka/policy.ts, nowhere else. */
|
|
28
31
|
export type Roles = Partial<Readonly<Record<Verb, string>>>;
|
|
29
32
|
/**
|
|
@@ -75,11 +78,28 @@ export declare const notImplemented: (id: string, verb: Verb) => ShelfRefusal;
|
|
|
75
78
|
*/
|
|
76
79
|
export declare abstract class Shelf<By extends Row = Row, Filter extends Partial<Row> = Partial<Row>, R extends Row = Row> {
|
|
77
80
|
abstract readonly descriptor: ShelfDescriptor;
|
|
81
|
+
/**
|
|
82
|
+
* WHAT THE DATA BEHIND THIS SHELF IS (contracts 0.43.0, manifest_shelf.data): `'real'`, a system of
|
|
83
|
+
* record, or `'synthetic'`, generated fixtures such as a staging or demo store. Leave it unset for
|
|
84
|
+
* real data (absent means real). Set `'synthetic'` whenever the rows are made up: an importer shows
|
|
85
|
+
* it beside the shelf, and the signed receipt carries it, so a synthetic store is never read as
|
|
86
|
+
* evidence about real people.
|
|
87
|
+
*/
|
|
88
|
+
readonly data?: DataKind;
|
|
78
89
|
abstract readonly roles: Roles;
|
|
79
90
|
abstract readonly fixtures: Fixtures<By, Filter>;
|
|
80
91
|
read?(by: By): Promise<R | null>;
|
|
81
92
|
list?(by: Filter, page: Page): Promise<ListResult<R>>;
|
|
82
93
|
}
|
|
83
94
|
export type AnyShelf = Shelf<any, any, any>;
|
|
95
|
+
/**
|
|
96
|
+
* ⛔ THE LIST FIXTURE MUST NAME THE SYNTHETIC RECORD,, AND ONLY IT.
|
|
97
|
+
*
|
|
98
|
+
* `check` and the Enclave's import probe send it to a service holding real data. `{}` matches every
|
|
99
|
+
* row, and `{ month: '2000-01' }` matches every person paid that month, so either one could carry real
|
|
100
|
+
* records into the probe. So a list fixture names the fixture row by EVERY unique key, with the same
|
|
101
|
+
* values as the read fixture when there is one: then it can only ever match that one synthetic row.
|
|
102
|
+
*/
|
|
103
|
+
export declare const listFixtureProblems: (s: AnyShelf) => string[];
|
|
84
104
|
/** What a verb needs before it may be served: a method, a role, and a fixture to probe it with. */
|
|
85
105
|
export declare const verbProblems: (s: AnyShelf) => string[];
|
package/dist/shelf.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A SHELF, AS A BUSINESS API WRITES IT
|
|
2
|
+
* A SHELF, AS A BUSINESS API WRITES IT.
|
|
3
3
|
*
|
|
4
4
|
* `npx @ekka-ai/shelves add hr.salary` writes a class that extends `Shelf` with empty typed methods. The
|
|
5
5
|
* company fills the method bodies with its own code: that code IS the mapping from its data to the
|
|
6
|
-
* shelf, and EKKA keeps no mapping table
|
|
6
|
+
* shelf, and EKKA keeps no mapping table.
|
|
7
7
|
*
|
|
8
8
|
* - A PREDEFINED shelf takes its keys, fields, verbs and page cap from EKKA's registry.
|
|
9
9
|
* `predefined('hr.salary')` refuses an id the registry does not define.
|
|
10
10
|
* - A PRIVATE shelf declares its shape in the file. `privateShelf(...)` refuses an id the registry
|
|
11
|
-
* DOES define
|
|
11
|
+
* DOES define: a company may not publish its own `hr.salary`.
|
|
12
12
|
* - `roles` is read from ONE policy file, `src/ekka/policy.ts`: a provider role is written in
|
|
13
|
-
* exactly one place
|
|
13
|
+
* exactly one place.
|
|
14
14
|
*/
|
|
15
15
|
import { assertShape, loadRegistry, SEGMENT } from './registry.js';
|
|
16
|
+
export const DATA_KINDS = ['real', 'synthetic'];
|
|
16
17
|
export const predefined = (id) => {
|
|
17
18
|
const reg = loadRegistry();
|
|
18
19
|
const shelf = reg.shelves.get(id);
|
|
@@ -47,11 +48,47 @@ export const notImplemented = (id, verb) => new ShelfRefusal('not_implemented',
|
|
|
47
48
|
* of the declared keys, unique or not); `R` is one record, exactly the shelf's fields.
|
|
48
49
|
*/
|
|
49
50
|
export class Shelf {
|
|
51
|
+
/**
|
|
52
|
+
* WHAT THE DATA BEHIND THIS SHELF IS (contracts 0.43.0, manifest_shelf.data): `'real'`, a system of
|
|
53
|
+
* record, or `'synthetic'`, generated fixtures such as a staging or demo store. Leave it unset for
|
|
54
|
+
* real data (absent means real). Set `'synthetic'` whenever the rows are made up: an importer shows
|
|
55
|
+
* it beside the shelf, and the signed receipt carries it, so a synthetic store is never read as
|
|
56
|
+
* evidence about real people.
|
|
57
|
+
*/
|
|
58
|
+
data;
|
|
50
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* ⛔ THE LIST FIXTURE MUST NAME THE SYNTHETIC RECORD,, AND ONLY IT.
|
|
62
|
+
*
|
|
63
|
+
* `check` and the Enclave's import probe send it to a service holding real data. `{}` matches every
|
|
64
|
+
* row, and `{ month: '2000-01' }` matches every person paid that month, so either one could carry real
|
|
65
|
+
* records into the probe. So a list fixture names the fixture row by EVERY unique key, with the same
|
|
66
|
+
* values as the read fixture when there is one: then it can only ever match that one synthetic row.
|
|
67
|
+
*/
|
|
68
|
+
export const listFixtureProblems = (s) => {
|
|
69
|
+
const { id, shape } = s.descriptor;
|
|
70
|
+
const fl = s.fixtures.list?.by;
|
|
71
|
+
if (!shape.verbs.includes('list') || !fl)
|
|
72
|
+
return [];
|
|
73
|
+
const unique = shape.keys.filter((k) => k.unique).map((k) => k.name);
|
|
74
|
+
const example = `{ ${unique.map((k) => `${k}: …`).join(', ')} }`;
|
|
75
|
+
if (Object.keys(fl).length === 0) {
|
|
76
|
+
return [`${id} list fixture is empty ({}): it matches EVERY row, so \`check\` and the Enclave's import probe would list real records. Name the synthetic record by its unique keys: ${example}`];
|
|
77
|
+
}
|
|
78
|
+
const missing = unique.filter((k) => !(k in fl));
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
return [`${id} list fixture must name the synthetic record by every unique key (${unique.join(', ')}); without ${missing.join(', ')} it can match real records. Use ${example}`];
|
|
81
|
+
}
|
|
82
|
+
const rd = s.fixtures.read?.by;
|
|
83
|
+
const differ = rd ? unique.filter((k) => rd[k] !== fl[k]) : [];
|
|
84
|
+
return differ.length ? [`${id} list and read fixtures must name the SAME synthetic record; they differ on ${differ.join(', ')}`] : [];
|
|
85
|
+
};
|
|
51
86
|
/** What a verb needs before it may be served: a method, a role, and a fixture to probe it with. */
|
|
52
87
|
export const verbProblems = (s) => {
|
|
53
88
|
const out = [];
|
|
54
89
|
const { id, shape } = s.descriptor;
|
|
90
|
+
if (s.data !== undefined && !DATA_KINDS.includes(s.data))
|
|
91
|
+
out.push(`${id} data "${String(s.data)}" must be 'real' or 'synthetic' (or unset, which means real)`);
|
|
55
92
|
for (const v of shape.verbs) {
|
|
56
93
|
if (typeof s[v] !== 'function')
|
|
57
94
|
out.push(`${id} serves "${v}" but the class has no ${v}() method`);
|
|
@@ -63,6 +100,7 @@ export const verbProblems = (s) => {
|
|
|
63
100
|
if (!s.fixtures[v])
|
|
64
101
|
out.push(`${id} "${v}" has no synthetic fixture, so it cannot be probed`);
|
|
65
102
|
}
|
|
103
|
+
out.push(...listFixtureProblems(s));
|
|
66
104
|
for (const v of ['read', 'list']) {
|
|
67
105
|
if (!shape.verbs.includes(v) && typeof s[v] === 'function')
|
|
68
106
|
out.push(`${id} has a ${v}() method but does not serve "${v}"`);
|
package/package.json
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekka-ai/shelves",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "EKKA
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Serve an EKKA shelf from your Fastify API: scaffold a typed shelf, mount the shelf protocol v1, and check it the way EKKA will",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
8
15
|
"bin": {
|
|
9
16
|
"ekka-shelves": "dist/cli.js"
|
|
10
17
|
},
|
|
11
18
|
"files": [
|
|
12
19
|
"dist/**/*",
|
|
13
20
|
"LICENSE",
|
|
14
|
-
"README.md"
|
|
15
|
-
"CHANGELOG.md"
|
|
21
|
+
"README.md"
|
|
16
22
|
],
|
|
17
|
-
"repository": {
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/ekka-ai/ekka-shelves.git"
|
|
20
|
-
},
|
|
21
23
|
"publishConfig": {
|
|
22
24
|
"registry": "https://registry.npmjs.org",
|
|
23
25
|
"access": "public"
|
|
@@ -30,22 +32,24 @@
|
|
|
30
32
|
"build": "tsc -p tsconfig.json && node scripts/bundle-contracts.mjs && chmod +x dist/cli.js",
|
|
31
33
|
"typecheck": "tsc -p tsconfig.test.json",
|
|
32
34
|
"test": "vitest run",
|
|
33
|
-
"prepublishOnly": "node scripts/publish-guard.mjs"
|
|
35
|
+
"prepublishOnly": "node scripts/publish-guard.mjs",
|
|
36
|
+
"example": "pnpm run build && tsx examples/reference-provider/src/server.ts",
|
|
37
|
+
"example:check": "pnpm run build && cd examples/reference-provider && node ../../dist/cli.js check"
|
|
34
38
|
},
|
|
35
39
|
"devDependencies": {
|
|
40
|
+
"@ekka-ai/contracts": "0.43.1",
|
|
36
41
|
"@types/node": "^22.10.0",
|
|
37
|
-
"typescript": "^5.7.0",
|
|
38
|
-
"vitest": "^2.1.0",
|
|
39
|
-
"fastify": "5.12.5",
|
|
40
|
-
"typebox": "1.3.34",
|
|
41
42
|
"ajv": "8.20.0",
|
|
43
|
+
"fastify": "5.12.5",
|
|
42
44
|
"tsx": "4.23.15",
|
|
43
|
-
"
|
|
45
|
+
"typebox": "1.3.34",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vitest": "^2.1.0"
|
|
44
48
|
},
|
|
45
49
|
"peerDependencies": {
|
|
46
50
|
"fastify": "^5.0.0",
|
|
47
|
-
"
|
|
48
|
-
"
|
|
51
|
+
"tsx": "^4.0.0",
|
|
52
|
+
"typebox": "^1.3.0"
|
|
49
53
|
},
|
|
50
54
|
"peerDependenciesMeta": {
|
|
51
55
|
"tsx": {
|
package/CHANGELOG.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.2.2 (the first published version)
|
|
4
|
-
|
|
5
|
-
- FIXED: v0.2.1 reached the right registry and had no login for it (ENEEDAUTH). setup-node writes its
|
|
6
|
-
npmrc with no final newline, so the workflow's appended `//registry.npmjs.org/:_authToken` line was
|
|
7
|
-
glued onto the `@ekka-ai:registry=` line. The workflow now writes a leading newline, and
|
|
8
|
-
scripts/publish.mjs runs `npm whoami` against npmjs before publishing.
|
|
9
|
-
- Same code as 0.2.0 otherwise.
|
|
10
|
-
|
|
11
|
-
## 0.2.1 (tagged, NEVER published)
|
|
12
|
-
|
|
13
|
-
`v0.2.1` exists as a tag on 43427af; its publish failed (ENEEDAUTH, above). Its registry fix stands:
|
|
14
|
-
|
|
15
|
-
- FIXED: the publish went to the wrong registry. For a SCOPED package, the `@ekka-ai:registry` line in
|
|
16
|
-
.npmrc beats `publishConfig.registry`, so v0.2.0 was sent to GitHub Packages and refused (E401),
|
|
17
|
-
while the guard, which read publishConfig, printed "publish target: https://registry.npmjs.org".
|
|
18
|
-
Publishing now goes only through `scripts/publish.mjs`: it sets the scope's registry on the command
|
|
19
|
-
line, runs `npm publish --dry-run`, reads the target npm reports, and refuses anything but npmjs.
|
|
20
|
-
The prepublishOnly guard refuses any publish that did not come through it.
|
|
21
|
-
- Same code as 0.2.0 otherwise.
|
|
22
|
-
|
|
23
|
-
## 0.2.0 (tagged, NEVER published)
|
|
24
|
-
|
|
25
|
-
`v0.2.0` exists as a tag on 16b7654. Its publish failed (E401 to GitHub Packages, above); nothing
|
|
26
|
-
reached any registry. Do not re-run the 0.2.0 publish. What it contained:
|
|
27
|
-
|
|
28
|
-
- The shelf contracts are BUNDLED at build (dist/contracts/, with PINNED.json: version and a sha256
|
|
29
|
-
per file): shelves/registry.json, shelves/protocol-v1.schema.json, gate/attested/shelf.v1.json from
|
|
30
|
-
@ekka-ai/contracts 0.42.0. A Business API installs this package alone; contracts is a
|
|
31
|
-
devDependency. An installed package reads only its bundled copy.
|
|
32
|
-
- FIXED, fail-open: run through npm's bin symlink, the CLI compared the link to the file it points at,
|
|
33
|
-
ran nothing and exited 0, so `check` "passed" without checking. It now compares real paths;
|
|
34
|
-
test/cli.test.ts runs it through a symlink.
|
|
35
|
-
- The publish guard read npm_config_registry, which under pnpm is the default registry, and refused
|
|
36
|
-
every publish. 0.2.0 made it read publishConfig.registry, which was also wrong (see 0.2.1).
|
|
37
|
-
|
|
38
|
-
## 0.1.0 (tagged, NEVER published)
|
|
39
|
-
|
|
40
|
-
`v0.1.0` exists as a tag on 8ed1293 but was never published: its publish run was refused by the
|
|
41
|
-
publish guard above. That was fortunate: 0.1.0 carries the fail-open CLI. 0.2.1 is the first
|
|
42
|
-
published version; do not re-run the 0.1.0 publish.
|
|
43
|
-
|
|
44
|
-
- add, generate, check and the shelf plugin (ekka-ai/ekka-shelves#3, #4).
|