@ekka-ai/shelves 0.2.2
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/CHANGELOG.md +44 -0
- package/LICENSE +8 -0
- package/README.md +70 -0
- package/dist/check.d.ts +26 -0
- package/dist/check.js +151 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +124 -0
- package/dist/contracts/PINNED.json +8 -0
- package/dist/contracts/gate/attested/shelf.v1.json +77 -0
- package/dist/contracts/shelves/protocol-v1.schema.json +595 -0
- package/dist/contracts/shelves/registry.json +129 -0
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +44 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +22 -0
- package/dist/plugin.d.ts +43 -0
- package/dist/plugin.js +125 -0
- package/dist/protocol.d.ts +131 -0
- package/dist/protocol.js +166 -0
- package/dist/registry.d.ts +46 -0
- package/dist/registry.js +131 -0
- package/dist/scaffold.d.ts +20 -0
- package/dist/scaffold.js +200 -0
- package/dist/shelf.d.ts +85 -0
- package/dist/shelf.js +71 -0
- package/package.json +56 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 EKKA Inc. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Proprietary. Use permitted for EKKA customers under their EKKA agreement; no redistribution.
|
|
4
|
+
|
|
5
|
+
This software may be installed and used only by an EKKA customer, and only to serve EKKA shelves
|
|
6
|
+
from that customer's own services, as its EKKA agreement allows. It may not be copied, modified for
|
|
7
|
+
redistribution, sublicensed, sold, or distributed, in whole or in part, except as that agreement
|
|
8
|
+
permits. It is provided "as is", without warranty of any kind, except as that agreement provides.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# ekka-shelves
|
|
2
|
+
|
|
3
|
+
`@ekka-ai/shelves`: the Business API side of EKKA shelves (E1, ekka-ai/.github#674).
|
|
4
|
+
|
|
5
|
+
A Business API that wants to serve an EKKA shelf, for example `hr.salary`, scaffolds a typed class, fills the method body, registers one plugin, and runs `check`. The plugin mounts the shelf protocol v1: `ping`, `manifest` and `invoke` under `/ekka/shelves/v1/`.
|
|
6
|
+
|
|
7
|
+
- A Business API installs `@ekka-ai/shelves` ALONE, from public npm. The shelf contracts it needs are bundled at build
|
|
8
|
+
(`dist/contracts/`, with `PINNED.json`: the contracts version and a sha256 per file); the full
|
|
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.
|
|
13
|
+
|
|
14
|
+
## Use it
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx @ekka-ai/shelves add hr.salary # a predefined shelf: its shape is EKKA's registry
|
|
18
|
+
npx @ekka-ai/shelves add communications.email \
|
|
19
|
+
--keys id:string:unique --fields id:string,subject:string,sent_on:date --verbs read
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`add` writes `src/ekka/<id>.ts` (a class with empty typed methods), an empty role entry in
|
|
23
|
+
`src/ekka/policy.ts` (the ONE place a provider role is written; an empty role refuses to start) and
|
|
24
|
+
the shelf into `src/ekka/index.ts`. Fill the method bodies with your own code, then:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
await app.register(ekkaShelves({ provider, shelves, authenticate, authorize, capabilitiesOf, principalOf }));
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`authenticate` and `authorize(capability)` are YOUR service's own bearer check and capability guard;
|
|
31
|
+
the plugin never decides who a caller is.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx @ekka-ai/shelves generate [--check] # src/ekka/shelves.generated.ts: capabilities, one principal per shelf, routes, the offer
|
|
35
|
+
npx @ekka-ai/shelves check # the real endpoint through src/ekka/check-app.ts; prints what EKKA will see
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
What is enforced, and tested (`test/`):
|
|
39
|
+
- `read` takes EXACTLY the unique key set; `list` any subset of the declared keys. An undeclared key is
|
|
40
|
+
`unknown_key`, never ignored. `list` returns at most min(limit, page_cap), with `next_cursor`.
|
|
41
|
+
- Every record leaving the service is checked against the shelf's exact field set and value types
|
|
42
|
+
(`month`, `date`, `currency_code`, `money_minor`, never a float); a bad one is `provider_error`, and
|
|
43
|
+
neither its values nor a thrown error's cause travel.
|
|
44
|
+
- The manifest is authenticated and lists only the presenting principal's shelves; a shelf this key does
|
|
45
|
+
not serve and a shelf that does not exist get the same answer. Only `ping` is public.
|
|
46
|
+
- A private shelf may never take a predefined id (at `add`, and at declaration).
|
|
47
|
+
- `test/conformance.test.ts` checks everything served against the OFFICIAL `protocol-v1.schema.json`.
|
|
48
|
+
|
|
49
|
+
## Develop
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pnpm install # needs NODE_AUTH_TOKEN with read:packages for the org registry
|
|
53
|
+
pnpm test
|
|
54
|
+
pnpm run build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Release
|
|
58
|
+
|
|
59
|
+
- **Public npm, proprietary licence** (the owner's decision): `@ekka-ai/shelves` publishes to
|
|
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).
|
|
69
|
+
|
|
70
|
+
Bump `version` in `package.json` in a PR, merge it, then push the tag `v<version>` on that commit. The publish workflow refuses a tag that does not match the version.
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface Answer {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly json: unknown;
|
|
4
|
+
}
|
|
5
|
+
/** How check talks to the endpoint: fastify.inject in-process, or fetch against a URL. */
|
|
6
|
+
export type Transport = (req: {
|
|
7
|
+
method: 'GET' | 'POST';
|
|
8
|
+
path: string;
|
|
9
|
+
bearer?: string;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
}) => Promise<Answer>;
|
|
12
|
+
export interface CheckTarget {
|
|
13
|
+
readonly transport: Transport;
|
|
14
|
+
/** The bearer key of each shelf's principal. */
|
|
15
|
+
readonly bearerFor: (shelfId: string) => string;
|
|
16
|
+
/** The shelf ids this service should serve (from its own shelf list). */
|
|
17
|
+
readonly shelves: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface CheckLine {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly shelf: string;
|
|
22
|
+
readonly what: string;
|
|
23
|
+
readonly detail?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const runCheck: (t: CheckTarget) => Promise<CheckLine[]>;
|
|
26
|
+
export declare const formatCheck: (lines: readonly CheckLine[]) => string;
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @ekka-ai/shelves check` (E1 spec, section 6 P2): the real endpoint, the real fixtures, and what
|
|
3
|
+
* EKKA will see. The same command runs in CI.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ IT TRUSTS NOTHING IT IS CHECKING. Every answer is re-checked here against the manifest the
|
|
6
|
+
* endpoint itself served, so a Business API that bypasses the plugin, or a plugin bug, is red here
|
|
7
|
+
* before an Enclave's import probe ever meets it. Red on: a record with the wrong field set, a
|
|
8
|
+
* missing not-found, a missing next cursor, an ACCEPTED undeclared key, a page over the cap, a
|
|
9
|
+
* manifest that shows another credential's shelves, a ping that says more than its version.
|
|
10
|
+
*/
|
|
11
|
+
import { capabilityOf, PROTOCOL, ROUTES } from './protocol.js';
|
|
12
|
+
import { loadRegistry } from './registry.js';
|
|
13
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
14
|
+
/** A predefined shelf's shape is the REGISTRY's (the manifest carries only its major); a private one's is its own. */
|
|
15
|
+
const shapeOf = (m) => {
|
|
16
|
+
if (m.origin === 'private')
|
|
17
|
+
return m.shape ? { keys: m.shape.keys, fields: m.shape.fields, pageCap: m.shape.page_cap } : undefined;
|
|
18
|
+
const known = loadRegistry().shelves.get(m.id);
|
|
19
|
+
return known && known.interfaceMajor === m.interface_major ? known : undefined;
|
|
20
|
+
};
|
|
21
|
+
const FORMAT = {
|
|
22
|
+
month: /^[0-9]{4}-(0[1-9]|1[0-2])$/,
|
|
23
|
+
date: /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/,
|
|
24
|
+
currency_code: /^[A-Z]{3}$/,
|
|
25
|
+
};
|
|
26
|
+
const typeOk = (t, v) => t === 'integer' || t === 'money_minor'
|
|
27
|
+
? Number.isSafeInteger(v)
|
|
28
|
+
: t === 'boolean'
|
|
29
|
+
? typeof v === 'boolean'
|
|
30
|
+
: typeof v === 'string' && (!FORMAT[t] || FORMAT[t].test(v));
|
|
31
|
+
const exactFields = (m, r) => {
|
|
32
|
+
if (!isObj(r))
|
|
33
|
+
return 'not an object';
|
|
34
|
+
const declared = m.fields.map((f) => f.name);
|
|
35
|
+
const extra = Object.keys(r).filter((k) => !declared.includes(k));
|
|
36
|
+
const missing = m.fields.filter((f) => f.required && !(f.name in r)).map((f) => f.name);
|
|
37
|
+
const wrong = m.fields
|
|
38
|
+
.filter((f) => f.name in r)
|
|
39
|
+
.filter((f) => !typeOk(f.type, r[f.name]))
|
|
40
|
+
.map((f) => f.name);
|
|
41
|
+
return [extra.length ? `extra: ${extra.join(',')}` : '', missing.length ? `missing: ${missing.join(',')}` : '', wrong.length ? `wrong type: ${wrong.join(',')}` : '']
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join('; ');
|
|
44
|
+
};
|
|
45
|
+
const refusalCode = (a) => isObj(a.json) && isObj(a.json['refusal']) && typeof a.json['refusal']['code'] === 'string' ? a.json['refusal']['code'] : undefined;
|
|
46
|
+
const result = (a) => (isObj(a.json) && isObj(a.json['result']) ? a.json['result'] : undefined);
|
|
47
|
+
let seq = 0;
|
|
48
|
+
/** A well-formed assertion. v1 providers MAY verify it; `check` does not sign, so a verifying provider is run with verification off. */
|
|
49
|
+
const CHECK_AUTH = { alg: 'ed25519', key_id: 'ekka-shelves-check', payload_b64: 'Y2hlY2s=', sig_b64: 'Y2hlY2s=' };
|
|
50
|
+
const invoke = (shelf, major, verb, by, page) => ({
|
|
51
|
+
protocol: PROTOCOL,
|
|
52
|
+
request_id: `check-${++seq}`,
|
|
53
|
+
shelf,
|
|
54
|
+
interface_major: major,
|
|
55
|
+
verb,
|
|
56
|
+
by,
|
|
57
|
+
...(page ? { page } : {}),
|
|
58
|
+
auth: CHECK_AUTH,
|
|
59
|
+
});
|
|
60
|
+
/** A key set that finds nothing: the first string unique key, changed to a value no fixture uses. */
|
|
61
|
+
const missingFrom = (shape, by) => {
|
|
62
|
+
const k = shape.keys.find((x) => x.unique && x.type === 'string');
|
|
63
|
+
return k ? { ...by, [k.name]: `ekka-check-missing-${k.name}` } : undefined;
|
|
64
|
+
};
|
|
65
|
+
export const runCheck = async (t) => {
|
|
66
|
+
const lines = [];
|
|
67
|
+
const say = (ok, shelf, what, detail) => lines.push({ ok, shelf, what, ...(detail ? { detail } : {}) });
|
|
68
|
+
const reg = loadRegistry();
|
|
69
|
+
const ping = await t.transport({ method: 'GET', path: ROUTES.ping });
|
|
70
|
+
say(ping.status === 200 && isObj(ping.json) && Object.keys(ping.json).join() === 'protocol' && ping.json['protocol'] === PROTOCOL, '-', 'ping answers { protocol: 1 } and nothing else, with no credential', JSON.stringify(ping.json));
|
|
71
|
+
const anon = await t.transport({ method: 'GET', path: ROUTES.manifest });
|
|
72
|
+
say(anon.status === 401 || anon.status === 403, '-', 'the manifest refuses a caller with no credential', `HTTP ${anon.status}`);
|
|
73
|
+
for (const id of t.shelves) {
|
|
74
|
+
const bearer = t.bearerFor(id);
|
|
75
|
+
const man = await t.transport({ method: 'GET', path: ROUTES.manifest, bearer });
|
|
76
|
+
const listed = isObj(man.json) && Array.isArray(man.json['shelves']) ? man.json['shelves'] : [];
|
|
77
|
+
const m = listed.find((s) => s.id === id);
|
|
78
|
+
say(man.status === 200 && !!m, id, 'the manifest, with this shelf’s key, lists it', `HTTP ${man.status}`);
|
|
79
|
+
const others = listed.filter((s) => s.id !== id).map((s) => s.id);
|
|
80
|
+
say(others.length === 0, id, 'the manifest shows this key no other shelf', others.join(', ') || undefined);
|
|
81
|
+
if (!m)
|
|
82
|
+
continue;
|
|
83
|
+
const shape = shapeOf(m);
|
|
84
|
+
const known = reg.shelves.get(id);
|
|
85
|
+
if (m.origin === 'predefined') {
|
|
86
|
+
say(!!shape && !!known, id, known ? `served as EKKA\u2019s ${id}@${known.interfaceMajor} (registry ${reg.version})` : `${id} is not in the registry`, `interface_major ${String(m.interface_major)}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
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
|
+
}
|
|
91
|
+
say(m.probe_fixture.synthetic === true, id, 'its probe fixture is marked synthetic');
|
|
92
|
+
if (!shape)
|
|
93
|
+
continue;
|
|
94
|
+
const major = m.interface_major ?? 1;
|
|
95
|
+
const fx = m.probe_fixture;
|
|
96
|
+
if (m.verbs.includes('read') && fx.read) {
|
|
97
|
+
const hit = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'read', fx.read) });
|
|
98
|
+
const rec = result(hit)?.['record'];
|
|
99
|
+
const bad = rec === undefined ? `no record (HTTP ${hit.status}${refusalCode(hit) ? `, ${refusalCode(hit)}` : ''})` : exactFields(shape, rec);
|
|
100
|
+
say(!bad, id, 'read with the fixture returns one record, exactly the shelf\u2019s fields', bad || `EKKA sees: ${JSON.stringify(rec)}`);
|
|
101
|
+
const missing = missingFrom(shape, fx.read);
|
|
102
|
+
if (missing) {
|
|
103
|
+
const miss = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'read', missing) });
|
|
104
|
+
say(result(miss)?.['not_found'] === true, id, 'read with a key that finds nothing says not_found', `HTTP ${miss.status}`);
|
|
105
|
+
}
|
|
106
|
+
const extra = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'read', { ...fx.read, ekka_check_undeclared: 'x' }) });
|
|
107
|
+
say(extra.status >= 400 && refusalCode(extra) === 'unknown_key', id, 'read REFUSES an undeclared key (unknown_key), never ignores it', extra.status < 400 ? `ACCEPTED: HTTP ${extra.status}` : `${refusalCode(extra)}`);
|
|
108
|
+
const first = Object.keys(fx.read)[0];
|
|
109
|
+
if (first) {
|
|
110
|
+
const partial = Object.fromEntries(Object.entries(fx.read).filter(([k]) => k !== first));
|
|
111
|
+
const short = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'read', partial) });
|
|
112
|
+
say(short.status >= 400, id, `read refuses a key set without ${first} (it would not name one record)`, `HTTP ${short.status}`);
|
|
113
|
+
}
|
|
114
|
+
const paged = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'read', fx.read, { limit: 1 }) });
|
|
115
|
+
say(paged.status >= 400 && refusalCode(paged) === 'shape', id, 'read refuses a page (a page is for list only)', `HTTP ${paged.status}`);
|
|
116
|
+
const wrongMajor = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major + 1, 'read', fx.read) });
|
|
117
|
+
say(wrongMajor.status >= 400, id, `read refuses interface ${major + 1} (it serves ${major})`, `HTTP ${wrongMajor.status}`);
|
|
118
|
+
}
|
|
119
|
+
if (m.verbs.includes('list') && fx.list) {
|
|
120
|
+
const page = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'list', fx.list, { limit: shape.pageCap }) });
|
|
121
|
+
const r = result(page);
|
|
122
|
+
const records = r?.['records'];
|
|
123
|
+
const cursorOk = !!r && 'next_cursor' in r && (typeof r['next_cursor'] === 'string' || r['next_cursor'] === null);
|
|
124
|
+
const recBad = Array.isArray(records) ? records.map((x, i) => [i, exactFields(shape, x)]).find(([, p]) => p) : undefined;
|
|
125
|
+
say(Array.isArray(records) && cursorOk && !recBad && records.length <= shape.pageCap, id, 'list with the fixture returns records and a next cursor, every record exactly the shelf\u2019s fields', !Array.isArray(records)
|
|
126
|
+
? `no records (HTTP ${page.status}${refusalCode(page) ? `, ${refusalCode(page)}` : ''})`
|
|
127
|
+
: !cursorOk
|
|
128
|
+
? 'no next_cursor'
|
|
129
|
+
: recBad
|
|
130
|
+
? `record ${recBad[0]}: ${recBad[1]}`
|
|
131
|
+
: `EKKA sees ${records.length} record(s), next_cursor ${JSON.stringify(r?.['next_cursor'])}`);
|
|
132
|
+
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
|
+
const overRecords = result(over)?.['records'];
|
|
134
|
+
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}`);
|
|
135
|
+
const extra = await t.transport({ method: 'POST', path: ROUTES.invoke, bearer, body: invoke(id, major, 'list', { ...fx.list, ekka_check_undeclared: 'x' }) });
|
|
136
|
+
say(extra.status >= 400 && refusalCode(extra) === 'unknown_key', id, 'list REFUSES an undeclared key', extra.status < 400 ? `ACCEPTED: HTTP ${extra.status}` : `${refusalCode(extra)}`);
|
|
137
|
+
}
|
|
138
|
+
// Another shelf's key must not reach this one: one key per shelf is the point.
|
|
139
|
+
for (const other of t.shelves.filter((o) => o !== id)) {
|
|
140
|
+
const cross = await t.transport({
|
|
141
|
+
method: 'POST',
|
|
142
|
+
path: ROUTES.invoke,
|
|
143
|
+
bearer: t.bearerFor(other),
|
|
144
|
+
body: invoke(id, m.interface_major ?? 1, m.verbs[0] ?? 'read', m.probe_fixture.read ?? m.probe_fixture.list ?? {}),
|
|
145
|
+
});
|
|
146
|
+
say(cross.status === 403 && refusalCode(cross) === 'forbidden', id, `the ${other} key is refused here (${capabilityOf(id)} is not its capability)`, `HTTP ${cross.status}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
};
|
|
151
|
+
export const formatCheck = (lines) => lines.map((l) => ` ${l.ok ? 'ok ' : 'FAIL'} ${l.shelf === '-' ? '' : `${l.shelf}: `}${l.what}${l.detail ? `\n ${l.detail}` : ''}`).join('\n');
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npx @ekka-ai/shelves add <id> [--keys … --fields … --verbs …]
|
|
4
|
+
* npx @ekka-ai/shelves generate [--check]
|
|
5
|
+
* npx @ekka-ai/shelves check [--app src/ekka/check-app.ts]
|
|
6
|
+
*
|
|
7
|
+
* Run from the Business API's root. `generate` and `check` read src/ekka/index.ts (TypeScript, loaded
|
|
8
|
+
* through tsx, which the service already has); `check --app` names a module whose default export is
|
|
9
|
+
* `async () => ({ app, bearerFor })`, with `app` a Fastify instance built as it runs in production.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
+
import { formatCheck, runCheck } from './check.js';
|
|
15
|
+
import { generate } from './generate.js';
|
|
16
|
+
import { add } from './scaffold.js';
|
|
17
|
+
const flag = (args, name) => {
|
|
18
|
+
const i = args.indexOf(`--${name}`);
|
|
19
|
+
if (i >= 0)
|
|
20
|
+
return args[i + 1];
|
|
21
|
+
return args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
22
|
+
};
|
|
23
|
+
const loadTs = async (file) => {
|
|
24
|
+
const abs = resolve(file);
|
|
25
|
+
if (!existsSync(abs))
|
|
26
|
+
throw new Error(`${file} does not exist (run from the service root)`);
|
|
27
|
+
try {
|
|
28
|
+
const { tsImport } = (await import('tsx/esm/api'));
|
|
29
|
+
return await tsImport(pathToFileURL(abs).href, import.meta.url);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
if (e.code !== 'ERR_MODULE_NOT_FOUND')
|
|
33
|
+
throw e;
|
|
34
|
+
return (await import(pathToFileURL(abs).href));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const loadShelves = async (root) => {
|
|
38
|
+
const m = await loadTs(resolve(root, 'src/ekka/index.ts'));
|
|
39
|
+
if (!m.provider?.id)
|
|
40
|
+
throw new Error('src/ekka/index.ts: provider.id is empty; name this service (it is how EKKA shows it at import)');
|
|
41
|
+
if (!m.shelves?.length)
|
|
42
|
+
throw new Error('src/ekka/index.ts: no shelves');
|
|
43
|
+
return { provider: m.provider, shelves: m.shelves };
|
|
44
|
+
};
|
|
45
|
+
export const main = async (argv) => {
|
|
46
|
+
const [cmd, ...args] = argv;
|
|
47
|
+
const root = process.cwd();
|
|
48
|
+
if (cmd === 'add') {
|
|
49
|
+
const id = args.find((a) => !a.startsWith('--') && !args[args.indexOf(a) - 1]?.startsWith('--'));
|
|
50
|
+
if (!id)
|
|
51
|
+
throw new Error('usage: ekka-shelves add <id> [--keys … --fields … --verbs …]');
|
|
52
|
+
const r = add({ root, id, keys: flag(args, 'keys'), fields: flag(args, 'fields'), verbs: flag(args, 'verbs') });
|
|
53
|
+
process.stdout.write(`${r.isPrivate ? 'private' : 'predefined'} shelf ${id}\n${r.touched.map((f) => ` wrote ${f}`).join('\n')}\n`);
|
|
54
|
+
process.stdout.write('Next: fill the method bodies, the roles in src/ekka/policy.ts, then `ekka-shelves generate`.\n');
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (cmd === 'generate') {
|
|
58
|
+
const { provider, shelves } = await loadShelves(root);
|
|
59
|
+
const out = resolve(root, flag(args, 'out') ?? 'src/ekka/shelves.generated.ts');
|
|
60
|
+
const text = generate(provider, shelves);
|
|
61
|
+
if (args.includes('--check')) {
|
|
62
|
+
const now = existsSync(out) ? readFileSync(out, 'utf8') : '';
|
|
63
|
+
if (now !== text) {
|
|
64
|
+
process.stderr.write(`${out} is out of date: run \`npx @ekka-ai/shelves generate\` and commit it.\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
process.stdout.write('no drift: the generated shelf ids match the code\n');
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
writeFileSync(out, text);
|
|
71
|
+
process.stdout.write(`wrote ${out}\n`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (cmd === 'check') {
|
|
75
|
+
const { shelves } = await loadShelves(root);
|
|
76
|
+
const mod = await loadTs(resolve(root, flag(args, 'app') ?? 'src/ekka/check-app.ts'));
|
|
77
|
+
const { app, bearerFor } = await mod.default();
|
|
78
|
+
const transport = async (req) => {
|
|
79
|
+
const res = await app.inject({
|
|
80
|
+
method: req.method,
|
|
81
|
+
url: req.path,
|
|
82
|
+
headers: req.bearer ? { authorization: `Bearer ${req.bearer}` } : {},
|
|
83
|
+
...(req.body !== undefined ? { payload: req.body } : {}),
|
|
84
|
+
});
|
|
85
|
+
let json = null;
|
|
86
|
+
try {
|
|
87
|
+
json = JSON.parse(res.body);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
json = res.body;
|
|
91
|
+
}
|
|
92
|
+
return { status: res.statusCode, json };
|
|
93
|
+
};
|
|
94
|
+
const lines = await runCheck({ transport, bearerFor, shelves: shelves.map((s) => s.descriptor.id) });
|
|
95
|
+
await app.close();
|
|
96
|
+
process.stdout.write(`${formatCheck(lines)}\n`);
|
|
97
|
+
const bad = lines.filter((l) => !l.ok).length;
|
|
98
|
+
process.stdout.write(bad ? `\n${bad} check(s) FAILED\n` : `\nall ${lines.length} checks passed: this is what EKKA will see\n`);
|
|
99
|
+
return bad ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
process.stderr.write('usage: ekka-shelves add <id> [--keys --fields --verbs] | generate [--check] | check [--app <module>]\n');
|
|
102
|
+
return 2;
|
|
103
|
+
};
|
|
104
|
+
// Run when invoked as the `ekka-shelves` binary, not when imported by a test.
|
|
105
|
+
// ⛔ COMPARE REAL PATHS. npm runs a bin through a SYMLINK (node_modules/.bin/ekka-shelves), so
|
|
106
|
+
// argv[1] is the link and import.meta.url the file it points at. Compared as given they never match,
|
|
107
|
+
// main never ran, and the process exited 0: `check` "passed" in CI without checking anything.
|
|
108
|
+
const invokedAsBinary = () => {
|
|
109
|
+
const entry = process.argv[1];
|
|
110
|
+
if (!entry)
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
if (invokedAsBinary()) {
|
|
120
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
121
|
+
process.stderr.write(`ekka-shelves: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contracts_version": "0.42.0",
|
|
3
|
+
"files": {
|
|
4
|
+
"shelves/registry.json": "bce1309515d952e9df42d79a3f2ca2ce908b3f2e1481ecbfe416c6016930573a",
|
|
5
|
+
"shelves/protocol-v1.schema.json": "53505506ea9ee9a163bf888c16d8d0c6470cc3377c9f72d8f3d9e6e108f20d1d",
|
|
6
|
+
"gate/attested/shelf.v1.json": "267e084fbc2e7cb16dd3c8c99fa9f7d4c460d43042304e689419544b80a1a91a"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://schemas.ekka.ai/gate/attested/shelf.v1.json",
|
|
4
|
+
"title": "ShelfAttestedBody",
|
|
5
|
+
"description": "What the `shelf` gate declares as ATTESTABLE for one enforced shelf invocation (E1 v3.3; ekka-ai/.github#674). 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
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"op": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"enum": [
|
|
11
|
+
"read",
|
|
12
|
+
"list"
|
|
13
|
+
],
|
|
14
|
+
"description": "The verb performed."
|
|
15
|
+
},
|
|
16
|
+
"resource": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"minLength": 1,
|
|
19
|
+
"maxLength": 512,
|
|
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 (owner rule 4)."
|
|
22
|
+
},
|
|
23
|
+
"offer_version": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"minLength": 1,
|
|
26
|
+
"maxLength": 64,
|
|
27
|
+
"description": "The provider offer version pinned for the run and echoed by the provider."
|
|
28
|
+
},
|
|
29
|
+
"request_sha256_b64": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"minLength": 1,
|
|
32
|
+
"description": "Base64 SHA-256 over the request body sent to the provider: proves what was asked without copying the lookup values."
|
|
33
|
+
},
|
|
34
|
+
"request_bytes": {
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"minimum": 0
|
|
37
|
+
},
|
|
38
|
+
"response_sha256_b64": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"minLength": 1,
|
|
41
|
+
"description": "Base64 SHA-256 over the provider's response body: binds the receipt to exactly what came back."
|
|
42
|
+
},
|
|
43
|
+
"bytes": {
|
|
44
|
+
"type": "integer",
|
|
45
|
+
"minimum": 0,
|
|
46
|
+
"description": "Size of the response body."
|
|
47
|
+
},
|
|
48
|
+
"count": {
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"minimum": 0,
|
|
51
|
+
"description": "Records returned: 0 or 1 for `read`, the page size for `list`."
|
|
52
|
+
},
|
|
53
|
+
"found": {
|
|
54
|
+
"type": "boolean",
|
|
55
|
+
"description": "`read` only: whether the record existed. Absent for `list`."
|
|
56
|
+
},
|
|
57
|
+
"http_status": {
|
|
58
|
+
"type": "integer",
|
|
59
|
+
"minimum": 100,
|
|
60
|
+
"maximum": 599
|
|
61
|
+
},
|
|
62
|
+
"duration_ms": {
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"minimum": 0
|
|
65
|
+
},
|
|
66
|
+
"truncated": {
|
|
67
|
+
"type": "boolean",
|
|
68
|
+
"description": "Whether the response hit the Enclave's size ceiling, so a digest over a cut body never reads as one over the whole."
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"required": [
|
|
72
|
+
"op",
|
|
73
|
+
"resource",
|
|
74
|
+
"offer_version"
|
|
75
|
+
],
|
|
76
|
+
"additionalProperties": false
|
|
77
|
+
}
|