@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "EKKA's predefined shelves (E1 v3.3; ekka-ai/.github#674). 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
|
+
"registry_version": 1,
|
|
4
|
+
"categories": [
|
|
5
|
+
{
|
|
6
|
+
"id": "hr",
|
|
7
|
+
"title": "People and pay"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"shelves": [
|
|
11
|
+
{
|
|
12
|
+
"id": "hr.salary",
|
|
13
|
+
"category": "hr",
|
|
14
|
+
"interface_major": 1,
|
|
15
|
+
"title": "Pay for one person in one month",
|
|
16
|
+
"owner_rule": "hr",
|
|
17
|
+
"keys": [
|
|
18
|
+
{
|
|
19
|
+
"name": "id",
|
|
20
|
+
"type": "string",
|
|
21
|
+
"unique": true
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "month",
|
|
25
|
+
"type": "month",
|
|
26
|
+
"unique": true
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "name",
|
|
30
|
+
"type": "string",
|
|
31
|
+
"unique": false
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"verbs": [
|
|
35
|
+
"read",
|
|
36
|
+
"list"
|
|
37
|
+
],
|
|
38
|
+
"fields": [
|
|
39
|
+
{
|
|
40
|
+
"name": "id",
|
|
41
|
+
"type": "string",
|
|
42
|
+
"required": true
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "name",
|
|
46
|
+
"type": "string",
|
|
47
|
+
"required": true
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "month",
|
|
51
|
+
"type": "month",
|
|
52
|
+
"required": true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "currency",
|
|
56
|
+
"type": "currency_code",
|
|
57
|
+
"required": true
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "gross_minor",
|
|
61
|
+
"type": "money_minor",
|
|
62
|
+
"required": true
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "net_minor",
|
|
66
|
+
"type": "money_minor",
|
|
67
|
+
"required": true
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"page_cap": 100
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "hr.timesheet",
|
|
74
|
+
"category": "hr",
|
|
75
|
+
"interface_major": 1,
|
|
76
|
+
"title": "Minutes one person worked in one month",
|
|
77
|
+
"owner_rule": "hr",
|
|
78
|
+
"keys": [
|
|
79
|
+
{
|
|
80
|
+
"name": "id",
|
|
81
|
+
"type": "string",
|
|
82
|
+
"unique": true
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "name",
|
|
86
|
+
"type": "string",
|
|
87
|
+
"unique": false
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "month",
|
|
91
|
+
"type": "month",
|
|
92
|
+
"unique": false
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
"verbs": [
|
|
96
|
+
"read",
|
|
97
|
+
"list"
|
|
98
|
+
],
|
|
99
|
+
"fields": [
|
|
100
|
+
{
|
|
101
|
+
"name": "id",
|
|
102
|
+
"type": "string",
|
|
103
|
+
"required": true
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "name",
|
|
107
|
+
"type": "string",
|
|
108
|
+
"required": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "month",
|
|
112
|
+
"type": "month",
|
|
113
|
+
"required": true
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "minutes",
|
|
117
|
+
"type": "integer",
|
|
118
|
+
"required": true
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "approved",
|
|
122
|
+
"type": "boolean",
|
|
123
|
+
"required": true
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"page_cap": 100
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GENERATOR: src/ekka/shelves.generated.ts, the one source for what the shelves add to the
|
|
3
|
+
* Business API (E1 spec v3.4, section 6 P4).
|
|
4
|
+
*
|
|
5
|
+
* It writes the capability ids (`shelf.<id>.read`), ONE principal per shelf (`ekka-shelf-<id>`,
|
|
6
|
+
* one key each) and the protocol routes with their prefix. The host's
|
|
7
|
+
* capability table imports these instead of anyone typing them in three places.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ NO EXPORTER ROWS, NO SURFACES (spec v3.4, section 13). A Business API's API-catalog exporter
|
|
10
|
+
* must never make a shelf route callable through the API gate: that would let a plan reach the
|
|
11
|
+
* shelf around the shelf executor, its record check and its receipt. The host's exporter reads
|
|
12
|
+
* SHELF_ROUTE_PREFIX to REFUSE any catalog rule that admits it.
|
|
13
|
+
*/
|
|
14
|
+
import { buildOffer, capabilityOf, principalOf, ROUTES, SHELF_ROUTE_PREFIX } from './protocol.js';
|
|
15
|
+
// ⛔ NO OFFER, NO DIGEST IN THIS FILE. The manifest is built at startup from the registered shelves
|
|
16
|
+
// (spec P3), and a probe fixture can come from the service's configuration (a seeded synthetic
|
|
17
|
+
// record), so a digest written here would disagree with the one served. One source: the startup.
|
|
18
|
+
import { prepareShelves } from './plugin.js';
|
|
19
|
+
export const GENERATED_HEADER = '// GENERATED by @ekka-ai/shelves (`npx @ekka-ai/shelves generate`). Do not edit: change the shelf classes or\n' +
|
|
20
|
+
'// src/ekka/policy.ts and generate again. `npx @ekka-ai/shelves generate --check` fails on drift (CI).\n';
|
|
21
|
+
const lit = (v) => JSON.stringify(v, null, 2);
|
|
22
|
+
export const generate = (provider, shelves) => {
|
|
23
|
+
prepareShelves(shelves); // refuses the same offers the plugin would refuse at startup
|
|
24
|
+
const ids = shelves.map((s) => s.descriptor.id).sort();
|
|
25
|
+
buildOffer(provider, shelves); // refuses a provider id or offer version EKKA could not show
|
|
26
|
+
const principalCaps = Object.fromEntries(ids.map((id) => [principalOf(id), [capabilityOf(id)]]));
|
|
27
|
+
return [
|
|
28
|
+
GENERATED_HEADER,
|
|
29
|
+
`export const SHELF_IDS = ${lit(ids)} as const;`,
|
|
30
|
+
'',
|
|
31
|
+
'/** One capability per shelf; read and list both require it. */',
|
|
32
|
+
`export const SHELF_CAPABILITY_IDS = ${lit(ids.map(capabilityOf))} as const;`,
|
|
33
|
+
'',
|
|
34
|
+
'/** One principal, so one key, per shelf. A key never reaches another shelf, or another shelf’s name. */',
|
|
35
|
+
`export const SHELF_PRINCIPAL_IDS = ${lit(ids.map(principalOf))} as const;`,
|
|
36
|
+
'',
|
|
37
|
+
`export const SHELF_PRINCIPAL_CAPABILITIES = ${lit(principalCaps)} as const;`,
|
|
38
|
+
'',
|
|
39
|
+
'/** The protocol routes. The exporter REFUSES any API-catalog rule that admits one of them. */',
|
|
40
|
+
`export const SHELF_ROUTES = ${lit(Object.values(ROUTES))} as const;`,
|
|
41
|
+
`export const SHELF_ROUTE_PREFIX = ${lit(SHELF_ROUTE_PREFIX)};`,
|
|
42
|
+
'',
|
|
43
|
+
].join('\n');
|
|
44
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The predefined shelves, read from the pinned contracts package, never copied. */
|
|
2
|
+
export declare function predefinedShelves(): {
|
|
3
|
+
id: string;
|
|
4
|
+
interface_major: number;
|
|
5
|
+
}[];
|
|
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';
|
|
8
|
+
export { PROTOCOL, ROUTES, SHELF_ROUTE_PREFIX, buildOffer, manifestFor, canonicalJson, capabilityOf, principalOf, type Manifest, type ManifestShelf, type Offer, } from './protocol.js';
|
|
9
|
+
export { loadRegistry, registrySource, parseRegistry, type Registry, type PredefinedShelf, type Verb, type FieldType } from './registry.js';
|
|
10
|
+
export { generate, GENERATED_HEADER } from './generate.js';
|
|
11
|
+
export { add, className } from './scaffold.js';
|
|
12
|
+
export { runCheck, formatCheck, type CheckTarget, type Transport, type Answer, type CheckLine } from './check.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ekka-ai/shelves: the Business API side of E1 (ekka-ai/.github#674, E1-V3-SPEC section 6).
|
|
3
|
+
*
|
|
4
|
+
* npx @ekka-ai/shelves add <id> scaffold a typed shelf class (P1)
|
|
5
|
+
* npx @ekka-ai/shelves generate write src/ekka/shelves.generated.ts (P4)
|
|
6
|
+
* npx @ekka-ai/shelves check the real endpoint, the fixtures, what EKKA will see (P2)
|
|
7
|
+
* app.register(ekkaShelves({...})) mount the shelf protocol v1 (P3)
|
|
8
|
+
*
|
|
9
|
+
* The one source of truth is @ekka-ai/contracts (0.42.0): the shelf registry and protocol v1.
|
|
10
|
+
*/
|
|
11
|
+
import { loadRegistry } from './registry.js';
|
|
12
|
+
/** The predefined shelves, read from the pinned contracts package, never copied. */
|
|
13
|
+
export function predefinedShelves() {
|
|
14
|
+
return [...loadRegistry().shelves.values()].map(({ id, interfaceMajor }) => ({ id, interface_major: interfaceMajor }));
|
|
15
|
+
}
|
|
16
|
+
export { ekkaShelves, prepareShelves } from './plugin.js';
|
|
17
|
+
export { Shelf, ShelfRefusal, notImplemented, predefined, privateShelf, verbProblems, } from './shelf.js';
|
|
18
|
+
export { PROTOCOL, ROUTES, SHELF_ROUTE_PREFIX, buildOffer, manifestFor, canonicalJson, capabilityOf, principalOf, } from './protocol.js';
|
|
19
|
+
export { loadRegistry, registrySource, parseRegistry } from './registry.js';
|
|
20
|
+
export { generate, GENERATED_HEADER } from './generate.js';
|
|
21
|
+
export { add, className } from './scaffold.js';
|
|
22
|
+
export { runCheck, formatCheck } from './check.js';
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PLUGIN: one `register` mounts the shelf protocol on a Business API (E1 spec, section 6 P3).
|
|
3
|
+
*
|
|
4
|
+
* await app.register(ekkaShelves({ provider, shelves, authenticate, authorize, capabilitiesOf, run }));
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE HOST OWNS AUTHORITY. This package never decides who a caller is. `authenticate` is the
|
|
7
|
+
* host's own bearer check (Trado: authenticatePrincipal + its org scope) and runs on every request
|
|
8
|
+
* but ping, BEFORE the body is looked at. `authorize(capability)` is the host's own guard (Trado:
|
|
9
|
+
* requireCapability) and runs for the one shelf named in the body. A thrown error from either is
|
|
10
|
+
* the host's answer; an authorize refusal is also mapped to the protocol's `forbidden`.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ A SHELF THIS CREDENTIAL DOES NOT SERVE AND A SHELF THAT DOES NOT EXIST GET THE SAME ANSWER.
|
|
13
|
+
* A private shelf's name is a fact about the company; "no such shelf" would confirm or deny it.
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
|
16
|
+
import { type ShelfValidators } from './protocol.js';
|
|
17
|
+
import { type AnyShelf } from './shelf.js';
|
|
18
|
+
type Hook = (request: FastifyRequest, reply: FastifyReply) => Promise<void> | void;
|
|
19
|
+
export interface EkkaShelvesOptions {
|
|
20
|
+
readonly provider: {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly offerVersion: string;
|
|
23
|
+
};
|
|
24
|
+
readonly shelves: readonly AnyShelf[];
|
|
25
|
+
/** The host's bearer check. Throws its own 401 on a missing or unknown credential. */
|
|
26
|
+
readonly authenticate: Hook;
|
|
27
|
+
/** The host's own capability guard for one capability, e.g. Trado's requireCapability. */
|
|
28
|
+
readonly authorize: (capability: string) => Hook;
|
|
29
|
+
/** The capabilities of the principal `authenticate` found, read to filter the manifest. */
|
|
30
|
+
readonly capabilitiesOf: (request: FastifyRequest) => readonly string[];
|
|
31
|
+
/** The id of the principal `authenticate` found: the manifest names it. */
|
|
32
|
+
readonly principalOf: (request: FastifyRequest) => string;
|
|
33
|
+
/** The host's admission or limits around a method call. Defaults to calling it directly. */
|
|
34
|
+
readonly run?: <T>(request: FastifyRequest, work: () => Promise<T>) => Promise<T>;
|
|
35
|
+
}
|
|
36
|
+
interface Mounted {
|
|
37
|
+
readonly shelf: AnyShelf;
|
|
38
|
+
readonly v: ShelfValidators;
|
|
39
|
+
}
|
|
40
|
+
/** Validate the whole offer at startup: a shelf that cannot keep a promise must not be served. */
|
|
41
|
+
export declare const prepareShelves: (shelves: readonly AnyShelf[]) => Map<string, Mounted>;
|
|
42
|
+
export declare const ekkaShelves: (opts: EkkaShelvesOptions) => FastifyPluginAsync;
|
|
43
|
+
export {};
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { buildOffer, capabilityOf, compileShelf, isInvokeRequest, manifestFor, PROTOCOL, recordProblem, REFUSAL_STATUS, refusalMessage, RESERVED_VERBS, ROUTES, undeclaredKeys, } from './protocol.js';
|
|
2
|
+
import { ShelfRefusal, verbProblems } from './shelf.js';
|
|
3
|
+
/** Validate the whole offer at startup: a shelf that cannot keep a promise must not be served. */
|
|
4
|
+
export const prepareShelves = (shelves) => {
|
|
5
|
+
const out = new Map();
|
|
6
|
+
const problems = [];
|
|
7
|
+
for (const s of shelves) {
|
|
8
|
+
if (out.has(s.descriptor.id))
|
|
9
|
+
problems.push(`${s.descriptor.id} is registered twice`);
|
|
10
|
+
problems.push(...verbProblems(s));
|
|
11
|
+
out.set(s.descriptor.id, { shelf: s, v: compileShelf(s.descriptor.shape) });
|
|
12
|
+
}
|
|
13
|
+
if (problems.length)
|
|
14
|
+
throw new Error(`@ekka-ai/shelves refused to start:\n - ${problems.join('\n - ')}`);
|
|
15
|
+
return out;
|
|
16
|
+
};
|
|
17
|
+
export const ekkaShelves = (opts) => {
|
|
18
|
+
const mounted = prepareShelves(opts.shelves);
|
|
19
|
+
const offer = buildOffer(opts.provider, opts.shelves);
|
|
20
|
+
const run = opts.run ?? (async (_r, work) => work());
|
|
21
|
+
const plugin = async (app) => {
|
|
22
|
+
const envelope = (body) => ({
|
|
23
|
+
protocol: PROTOCOL,
|
|
24
|
+
request_id: body.request_id,
|
|
25
|
+
offer_version: opts.provider.offerVersion,
|
|
26
|
+
shelf: body.shelf,
|
|
27
|
+
verb: body.verb,
|
|
28
|
+
});
|
|
29
|
+
const refuse = (reply, code, message, body) => reply
|
|
30
|
+
.code(REFUSAL_STATUS[code])
|
|
31
|
+
.header('cache-control', 'no-store')
|
|
32
|
+
.send({ ...(body ? envelope(body) : { protocol: PROTOCOL }), refusal: { code, message: refusalMessage(message) } });
|
|
33
|
+
// The one public route: the protocol version, and no name of anything.
|
|
34
|
+
app.get(ROUTES.ping, async (_req, reply) => reply.header('cache-control', 'no-store').send({ protocol: PROTOCOL }));
|
|
35
|
+
app.get(ROUTES.manifest, { onRequest: opts.authenticate }, async (request, reply) => {
|
|
36
|
+
// PER-PRINCIPAL LISTING: shelf names can be a client list, so a key sees only its own.
|
|
37
|
+
const view = manifestFor(offer, opts.principalOf(request), opts.capabilitiesOf(request));
|
|
38
|
+
if (view.shelves.length === 0)
|
|
39
|
+
return refuse(reply, 'forbidden', 'this credential serves no shelf');
|
|
40
|
+
return reply.header('cache-control', 'no-store').send(view);
|
|
41
|
+
});
|
|
42
|
+
app.post(ROUTES.invoke, { onRequest: opts.authenticate, bodyLimit: 16 * 1024 }, async (request, reply) => {
|
|
43
|
+
const body = request.body;
|
|
44
|
+
if (!isInvokeRequest(body))
|
|
45
|
+
return refuse(reply, 'shape', 'not a shelf protocol v1 request');
|
|
46
|
+
const m = mounted.get(body.shelf);
|
|
47
|
+
const forbidden = () => refuse(reply, 'forbidden', 'this credential does not serve that shelf', body);
|
|
48
|
+
if (!m)
|
|
49
|
+
return forbidden();
|
|
50
|
+
try {
|
|
51
|
+
await opts.authorize(capabilityOf(body.shelf))(request, reply);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return forbidden();
|
|
55
|
+
}
|
|
56
|
+
const { shelf, v } = m;
|
|
57
|
+
const { shape, interfaceMajor, id } = shelf.descriptor;
|
|
58
|
+
if (RESERVED_VERBS.includes(body.verb)) {
|
|
59
|
+
return refuse(reply, 'not_implemented', `"${body.verb}" is reserved and not served in protocol v1`, body);
|
|
60
|
+
}
|
|
61
|
+
if (body.verb !== 'read' && body.verb !== 'list')
|
|
62
|
+
return refuse(reply, 'not_implemented', `no verb "${body.verb}"`, body);
|
|
63
|
+
const verb = body.verb;
|
|
64
|
+
if (!shape.verbs.includes(verb))
|
|
65
|
+
return refuse(reply, 'not_implemented', `${id} does not serve "${verb}"`, body);
|
|
66
|
+
// A private shelf has no registry major; protocol v1 serves it as interface 1.
|
|
67
|
+
const served = interfaceMajor ?? 1;
|
|
68
|
+
if (body.interface_major !== served) {
|
|
69
|
+
return refuse(reply, 'shape', `${id} is served at interface ${served}, not ${body.interface_major}`, body);
|
|
70
|
+
}
|
|
71
|
+
if (verb === 'read' && body.page !== undefined)
|
|
72
|
+
return refuse(reply, 'shape', 'a read takes no page', body);
|
|
73
|
+
// ⛔ AN UNDECLARED KEY IS REFUSED, NEVER IGNORED. Ignoring it would turn "read this person's
|
|
74
|
+
// salary" into "read someone's salary" the moment a key is misspelled.
|
|
75
|
+
const extra = undeclaredKeys(v, verb, body.by);
|
|
76
|
+
if (extra.length)
|
|
77
|
+
return refuse(reply, 'unknown_key', `${id} ${verb} does not take: ${extra.join(', ')}`, body);
|
|
78
|
+
try {
|
|
79
|
+
if (verb === 'read') {
|
|
80
|
+
if (!v.readBy(body.by))
|
|
81
|
+
return refuse(reply, 'shape', `${id} read takes exactly ${v.readKeys.join(', ')}, with their declared types`, body);
|
|
82
|
+
const record = await run(request, () => shelf.read(body.by));
|
|
83
|
+
if (record === null)
|
|
84
|
+
return reply.header('cache-control', 'no-store').send({ ...envelope(body), result: { not_found: true } });
|
|
85
|
+
if (!v.record(record)) {
|
|
86
|
+
request.log.warn({ shelf: id, verb, problem: recordProblem(shape, record) }, 'shelf record refused on the way out');
|
|
87
|
+
return refuse(reply, 'provider_error', 'the provider returned a record that is not this shelf’s shape', body);
|
|
88
|
+
}
|
|
89
|
+
return reply.header('cache-control', 'no-store').send({ ...envelope(body), result: { record } });
|
|
90
|
+
}
|
|
91
|
+
if (!v.listBy(body.by))
|
|
92
|
+
return refuse(reply, 'shape', `${id} list filters on ${v.listKeys.join(', ')}, with their declared types`, body);
|
|
93
|
+
// protocol-v1 `page`: the provider returns at most min(limit, page_cap) records.
|
|
94
|
+
const limit = Math.min(body.page?.limit ?? shape.pageCap, shape.pageCap);
|
|
95
|
+
const page = { limit, cursor: body.page?.cursor ?? null };
|
|
96
|
+
const out = await run(request, () => shelf.list(body.by, page));
|
|
97
|
+
const bad = !out || !Array.isArray(out.records)
|
|
98
|
+
? 'no records array'
|
|
99
|
+
: !(typeof out.nextCursor === 'string' || out.nextCursor === null)
|
|
100
|
+
? 'no next cursor (null when this is the last page)'
|
|
101
|
+
: out.records.length > limit
|
|
102
|
+
? `${out.records.length} records for a page of ${limit}`
|
|
103
|
+
: (() => {
|
|
104
|
+
const i = out.records.findIndex((r) => !v.record(r));
|
|
105
|
+
return i < 0 ? '' : `record ${i}: ${recordProblem(shape, out.records[i])}`;
|
|
106
|
+
})();
|
|
107
|
+
if (bad || !out) {
|
|
108
|
+
request.log.warn({ shelf: id, verb, problem: bad }, 'shelf page refused on the way out');
|
|
109
|
+
return refuse(reply, 'provider_error', 'the provider returned a page that is not this shelf’s shape', body);
|
|
110
|
+
}
|
|
111
|
+
return reply
|
|
112
|
+
.header('cache-control', 'no-store')
|
|
113
|
+
.send({ ...envelope(body), result: { records: out.records, next_cursor: out.nextCursor } });
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof ShelfRefusal)
|
|
117
|
+
return refuse(reply, err.code, err.message, body);
|
|
118
|
+
// The cause never travels: it can name a table, a host, or a value.
|
|
119
|
+
request.log.error({ shelf: id, verb, err }, 'shelf method failed');
|
|
120
|
+
return refuse(reply, 'provider_error', 'the provider could not answer', body);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
return plugin;
|
|
125
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import type { FieldType, ShelfShape, Verb } from './registry.js';
|
|
3
|
+
import type { AnyShelf, RefusalCode, Row } from './shelf.js';
|
|
4
|
+
export declare const PROTOCOL: 1;
|
|
5
|
+
export declare const ROUTES: {
|
|
6
|
+
readonly ping: "/ekka/shelves/v1/ping";
|
|
7
|
+
readonly manifest: "/ekka/shelves/v1/manifest";
|
|
8
|
+
readonly invoke: "/ekka/shelves/v1/invoke";
|
|
9
|
+
};
|
|
10
|
+
export declare const SHELF_ROUTE_PREFIX = "/ekka/shelves/";
|
|
11
|
+
/** Protocol-reserved verbs: named so a caller gets `not_implemented`, not "no such verb". */
|
|
12
|
+
export declare const RESERVED_VERBS: readonly ["write", "update", "delete", "send"];
|
|
13
|
+
/** protocol-v1 `invocation_assertion`: the Enclave's signed invocation. v1 providers MAY verify it. */
|
|
14
|
+
export declare const InvocationAssertion: Type.TObject<{
|
|
15
|
+
alg: Type.TLiteral<"ed25519">;
|
|
16
|
+
key_id: Type.TString;
|
|
17
|
+
payload_b64: Type.TString;
|
|
18
|
+
sig_b64: Type.TString;
|
|
19
|
+
}>;
|
|
20
|
+
export declare const InvokeRequest: Type.TObject<{
|
|
21
|
+
protocol: Type.TLiteral<1>;
|
|
22
|
+
request_id: Type.TString;
|
|
23
|
+
shelf: Type.TString;
|
|
24
|
+
interface_major: Type.TInteger;
|
|
25
|
+
verb: Type.TString;
|
|
26
|
+
by: Type.TRecord<"^.*$", Type.TUnion<[Type.TString, Type.TInteger, Type.TBoolean]>>;
|
|
27
|
+
page: Type.TOptional<Type.TObject<{
|
|
28
|
+
limit: Type.TInteger;
|
|
29
|
+
cursor: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
|
30
|
+
}>>;
|
|
31
|
+
auth: Type.TObject<{
|
|
32
|
+
alg: Type.TLiteral<"ed25519">;
|
|
33
|
+
key_id: Type.TString;
|
|
34
|
+
payload_b64: Type.TString;
|
|
35
|
+
sig_b64: Type.TString;
|
|
36
|
+
}>;
|
|
37
|
+
}>;
|
|
38
|
+
export type InvokeRequestBody = {
|
|
39
|
+
protocol: 1;
|
|
40
|
+
request_id: string;
|
|
41
|
+
shelf: string;
|
|
42
|
+
interface_major: number;
|
|
43
|
+
verb: string;
|
|
44
|
+
by: Record<string, string | number | boolean>;
|
|
45
|
+
page?: {
|
|
46
|
+
limit: number;
|
|
47
|
+
cursor?: string | null;
|
|
48
|
+
};
|
|
49
|
+
auth: {
|
|
50
|
+
alg: 'ed25519';
|
|
51
|
+
key_id: string;
|
|
52
|
+
payload_b64: string;
|
|
53
|
+
sig_b64: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
export declare const isInvokeRequest: (v: unknown) => v is InvokeRequestBody;
|
|
57
|
+
/** The HTTP status for each typed refusal; the body always carries the refusal itself. */
|
|
58
|
+
export declare const REFUSAL_STATUS: Record<RefusalCode, number>;
|
|
59
|
+
/** protocol-v1 `refusal.message` is at most 500 characters, and never a record value. */
|
|
60
|
+
export declare const refusalMessage: (m: string) => string;
|
|
61
|
+
/** The validators one shelf needs, compiled once at startup. */
|
|
62
|
+
export interface ShelfValidators {
|
|
63
|
+
readonly readKeys: readonly string[];
|
|
64
|
+
readonly listKeys: readonly string[];
|
|
65
|
+
readonly readBy: (by: unknown) => boolean;
|
|
66
|
+
readonly listBy: (by: unknown) => boolean;
|
|
67
|
+
readonly record: (r: unknown) => boolean;
|
|
68
|
+
}
|
|
69
|
+
export declare const compileShelf: (shape: ShelfShape) => ShelfValidators;
|
|
70
|
+
/** Which names in `by` this verb does not accept. Non-empty means `unknown_key`, never a guess. */
|
|
71
|
+
export declare const undeclaredKeys: (v: ShelfValidators, verb: Verb, by: Record<string, unknown>) => string[];
|
|
72
|
+
/** Why a returned record is not a record of this shelf, in words, never with its values. */
|
|
73
|
+
export declare const recordProblem: (shape: ShelfShape, r: unknown) => string;
|
|
74
|
+
/** Sorted keys, no whitespace (SECURITY.CANONICALIZE.V1 for integer-only JSON): same object, same bytes. */
|
|
75
|
+
export declare const canonicalJson: (v: unknown) => string;
|
|
76
|
+
export declare const sha256: (s: string) => string;
|
|
77
|
+
/** protocol-v1 `manifest_shelf`: a predefined shelf carries interface_major, a private one its shape. */
|
|
78
|
+
export interface ManifestShelf {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
readonly origin: 'predefined' | 'private';
|
|
81
|
+
readonly interface_major?: number;
|
|
82
|
+
readonly shape?: {
|
|
83
|
+
readonly keys: readonly {
|
|
84
|
+
name: string;
|
|
85
|
+
type: FieldType;
|
|
86
|
+
unique: boolean;
|
|
87
|
+
}[];
|
|
88
|
+
readonly fields: readonly {
|
|
89
|
+
name: string;
|
|
90
|
+
type: FieldType;
|
|
91
|
+
required: boolean;
|
|
92
|
+
}[];
|
|
93
|
+
readonly page_cap: number;
|
|
94
|
+
};
|
|
95
|
+
readonly verbs: readonly Verb[];
|
|
96
|
+
readonly required_role: Readonly<Partial<Record<Verb, string>>>;
|
|
97
|
+
readonly probe_fixture: {
|
|
98
|
+
readonly synthetic: true;
|
|
99
|
+
readonly read?: Row;
|
|
100
|
+
readonly list?: Partial<Row>;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** protocol-v1 `manifest`, as served to ONE principal. */
|
|
104
|
+
export interface Manifest {
|
|
105
|
+
readonly protocol: 1;
|
|
106
|
+
readonly provider_id: string;
|
|
107
|
+
readonly offer_version: string;
|
|
108
|
+
/** Over the WHOLE offer, every shelf, whoever is looking: what an import pins. */
|
|
109
|
+
readonly digest: string;
|
|
110
|
+
readonly principal: string;
|
|
111
|
+
readonly shelves: readonly ManifestShelf[];
|
|
112
|
+
}
|
|
113
|
+
export declare const PROVIDER_ID: RegExp;
|
|
114
|
+
export declare const manifestShelf: (s: AnyShelf) => ManifestShelf;
|
|
115
|
+
export interface Offer {
|
|
116
|
+
readonly provider_id: string;
|
|
117
|
+
readonly offer_version: string;
|
|
118
|
+
readonly digest: string;
|
|
119
|
+
readonly shelves: readonly ManifestShelf[];
|
|
120
|
+
}
|
|
121
|
+
export declare const buildOffer: (provider: {
|
|
122
|
+
id: string;
|
|
123
|
+
offerVersion: string;
|
|
124
|
+
}, shelves: readonly AnyShelf[]) => Offer;
|
|
125
|
+
/** The manifest ONE principal is shown: only the shelves it serves, with the whole offer's digest. */
|
|
126
|
+
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 (spec P4). */
|
|
128
|
+
export declare const capabilityOf: (shelfId: string) => string;
|
|
129
|
+
/** One principal per shelf, so one key per shelf (spec P4; v3.2 section 12). */
|
|
130
|
+
export declare const principalOf: (shelfId: string) => string;
|
|
131
|
+
export type { Row };
|