@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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * THE SHELF PROTOCOL v1, as the Business API side sees it (E1 spec, section 2). The normative
3
+ * schema is `@ekka-ai/contracts/shelves/protocol-v1.schema.json`; the TypeBox schemas below mirror
4
+ * it, and tests/conformance.test.ts checks what this package SERVES against the official file.
5
+ *
6
+ * POST /ekka/shelves/v1/invoke request -> response (exactly one of result or refusal)
7
+ * GET /ekka/shelves/v1/manifest bearer-authenticated; names only the presenting principal's shelves
8
+ * GET /ekka/shelves/v1/ping the one public route: { protocol: 1 } and nothing else
9
+ *
10
+ * ⛔ EVERY OBJECT IS CLOSED (additionalProperties: false), and a record is checked against the shelf's
11
+ * EXACT field set on the way OUT, so a method that returns one field too many, or one too few, fails
12
+ * here rather than handing EKKA a record the plan was never consented to read.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { Type } from 'typebox';
16
+ import { Compile } from 'typebox/compile';
17
+ import { SEGMENT, SHELF_ID } from './registry.js';
18
+ export const PROTOCOL = 1;
19
+ export const ROUTES = {
20
+ ping: '/ekka/shelves/v1/ping',
21
+ manifest: '/ekka/shelves/v1/manifest',
22
+ invoke: '/ekka/shelves/v1/invoke',
23
+ };
24
+ export const SHELF_ROUTE_PREFIX = '/ekka/shelves/';
25
+ /** Protocol-reserved verbs: named so a caller gets `not_implemented`, not "no such verb". */
26
+ export const RESERVED_VERBS = ['write', 'update', 'delete', 'send'];
27
+ const SAFE = { minimum: -9007199254740991, maximum: 9007199254740991 };
28
+ const scalar = Type.Union([Type.String({ maxLength: 1024 }), Type.Integer(SAFE), Type.Boolean()]);
29
+ const b64 = (maxLength) => Type.String({ minLength: 1, maxLength, pattern: '^[A-Za-z0-9+/]+={0,2}$' });
30
+ /** protocol-v1 `invocation_assertion`: the Enclave's signed invocation. v1 providers MAY verify it. */
31
+ export const InvocationAssertion = Type.Object({ alg: Type.Literal('ed25519'), key_id: Type.String({ minLength: 1, maxLength: 128 }), payload_b64: b64(4096), sig_b64: b64(256) }, { additionalProperties: false });
32
+ export const InvokeRequest = Type.Object({
33
+ protocol: Type.Literal(PROTOCOL),
34
+ request_id: Type.String({ minLength: 1, maxLength: 128 }),
35
+ shelf: Type.String({ maxLength: 200, pattern: SHELF_ID.source }),
36
+ interface_major: Type.Integer({ minimum: 1 }),
37
+ // Any verb word here, so a reserved verb gets `not_implemented` rather than a bare `shape`.
38
+ verb: Type.String({ minLength: 1, maxLength: 16 }),
39
+ by: Type.Record(Type.String({ pattern: SEGMENT.source }), scalar, { maxProperties: 16 }),
40
+ page: Type.Optional(Type.Object({ limit: Type.Integer({ minimum: 1, maximum: 1000 }), cursor: Type.Optional(Type.Union([Type.String({ maxLength: 1024 }), Type.Null()])) }, { additionalProperties: false })),
41
+ auth: InvocationAssertion,
42
+ }, { additionalProperties: false });
43
+ const checkRequest = Compile(InvokeRequest);
44
+ export const isInvokeRequest = (v) => checkRequest.Check(v);
45
+ /** The HTTP status for each typed refusal; the body always carries the refusal itself. */
46
+ export const REFUSAL_STATUS = {
47
+ unknown_key: 400,
48
+ shape: 400,
49
+ forbidden: 403,
50
+ not_implemented: 501,
51
+ provider_error: 502,
52
+ };
53
+ /** protocol-v1 `refusal.message` is at most 500 characters, and never a record value. */
54
+ export const refusalMessage = (m) => (m.length <= 500 ? m : `${m.slice(0, 497)}...`);
55
+ /** The wire form of each value type (registry.schema `value_type`). */
56
+ const typeSchema = (t) => {
57
+ switch (t) {
58
+ case 'string':
59
+ return Type.String({ maxLength: 4096 });
60
+ case 'integer':
61
+ case 'money_minor':
62
+ return Type.Integer(SAFE);
63
+ case 'boolean':
64
+ return Type.Boolean();
65
+ case 'month':
66
+ return Type.String({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' });
67
+ case 'date':
68
+ return Type.String({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$' });
69
+ case 'currency_code':
70
+ return Type.String({ pattern: '^[A-Z]{3}$' });
71
+ }
72
+ };
73
+ export const compileShelf = (shape) => {
74
+ const unique = shape.keys.filter((k) => k.unique);
75
+ const readBy = Compile(Type.Object(Object.fromEntries(unique.map((k) => [k.name, typeSchema(k.type)])), { additionalProperties: false }));
76
+ const listBy = Compile(Type.Object(Object.fromEntries(shape.keys.map((k) => [k.name, Type.Optional(typeSchema(k.type))])), { additionalProperties: false }));
77
+ const record = Compile(Type.Object(Object.fromEntries(shape.fields.map((f) => [f.name, f.required ? typeSchema(f.type) : Type.Optional(typeSchema(f.type))])), { additionalProperties: false }));
78
+ return {
79
+ readKeys: unique.map((k) => k.name),
80
+ listKeys: shape.keys.map((k) => k.name),
81
+ readBy: (v) => readBy.Check(v),
82
+ listBy: (v) => listBy.Check(v),
83
+ record: (v) => record.Check(v),
84
+ };
85
+ };
86
+ /** Which names in `by` this verb does not accept. Non-empty means `unknown_key`, never a guess. */
87
+ export const undeclaredKeys = (v, verb, by) => {
88
+ const allowed = verb === 'read' ? v.readKeys : v.listKeys;
89
+ return Object.keys(by).filter((k) => !allowed.includes(k));
90
+ };
91
+ /** Why a returned record is not a record of this shelf, in words, never with its values. */
92
+ export const recordProblem = (shape, r) => {
93
+ if (typeof r !== 'object' || r === null || Array.isArray(r))
94
+ return 'not an object';
95
+ const names = Object.keys(r);
96
+ const declared = shape.fields.map((f) => f.name);
97
+ const extra = names.filter((n) => !declared.includes(n));
98
+ const missing = shape.fields.filter((f) => f.required && !names.includes(f.name)).map((f) => f.name);
99
+ if (extra.length)
100
+ return `fields not in the shelf: ${extra.join(', ')}`;
101
+ if (missing.length)
102
+ return `required fields missing: ${missing.join(', ')}`;
103
+ return 'a field has the wrong type or format';
104
+ };
105
+ /* ------------------------------------------------------------------ the manifest */
106
+ /** Sorted keys, no whitespace (SECURITY.CANONICALIZE.V1 for integer-only JSON): same object, same bytes. */
107
+ export const canonicalJson = (v) => {
108
+ if (v === null || typeof v !== 'object')
109
+ return JSON.stringify(v);
110
+ if (Array.isArray(v))
111
+ return `[${v.map(canonicalJson).join(',')}]`;
112
+ const o = v;
113
+ return `{${Object.keys(o)
114
+ .filter((k) => o[k] !== undefined)
115
+ .sort()
116
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
117
+ .join(',')}}`;
118
+ };
119
+ export const sha256 = (s) => `sha256:${createHash('sha256').update(s, 'utf8').digest('hex')}`;
120
+ export const PROVIDER_ID = /^[a-z][a-z0-9-]{0,62}$/;
121
+ export const manifestShelf = (s) => {
122
+ const { id, isPrivate, interfaceMajor, shape } = s.descriptor;
123
+ const probe = { synthetic: true };
124
+ if (s.fixtures.read)
125
+ probe.read = s.fixtures.read.by;
126
+ if (s.fixtures.list)
127
+ probe.list = s.fixtures.list.by;
128
+ return {
129
+ id,
130
+ origin: isPrivate ? 'private' : 'predefined',
131
+ ...(isPrivate
132
+ ? {
133
+ shape: {
134
+ keys: shape.keys.map((k) => ({ name: k.name, type: k.type, unique: k.unique })),
135
+ fields: shape.fields.map((f) => ({ name: f.name, type: f.type, required: f.required })),
136
+ page_cap: shape.pageCap,
137
+ },
138
+ }
139
+ : { interface_major: interfaceMajor }),
140
+ verbs: [...shape.verbs],
141
+ required_role: { ...s.roles },
142
+ probe_fixture: probe,
143
+ };
144
+ };
145
+ export const buildOffer = (provider, shelves) => {
146
+ if (!PROVIDER_ID.test(provider.id))
147
+ throw new Error(`provider id "${provider.id}" must be lowercase letters, digits and hyphens`);
148
+ if (provider.offerVersion.length < 1 || provider.offerVersion.length > 64)
149
+ throw new Error('offer version must be 1 to 64 characters');
150
+ const entries = shelves.map(manifestShelf).sort((a, b) => a.id.localeCompare(b.id));
151
+ const digest = sha256(canonicalJson({ provider_id: provider.id, offer_version: provider.offerVersion, shelves: entries }));
152
+ return { provider_id: provider.id, offer_version: provider.offerVersion, digest, shelves: entries };
153
+ };
154
+ /** The manifest ONE principal is shown: only the shelves it serves, with the whole offer's digest. */
155
+ export const manifestFor = (offer, principal, capabilities) => ({
156
+ protocol: PROTOCOL,
157
+ provider_id: offer.provider_id,
158
+ offer_version: offer.offer_version,
159
+ digest: offer.digest,
160
+ principal,
161
+ shelves: offer.shelves.filter((s) => capabilities.includes(capabilityOf(s.id))),
162
+ });
163
+ /** The capability a Business API checks for every verb of one shelf (spec P4). */
164
+ export const capabilityOf = (shelfId) => `shelf.${shelfId}.read`;
165
+ /** One principal per shelf, so one key per shelf (spec P4; v3.2 section 12). */
166
+ export const principalOf = (shelfId) => `ekka-shelf-${shelfId}`;
@@ -0,0 +1,46 @@
1
+ export type FieldType = 'string' | 'integer' | 'boolean' | 'month' | 'date' | 'currency_code' | 'money_minor';
2
+ export declare const FIELD_TYPES: readonly FieldType[];
3
+ /** v1 ships these two; write, update, delete and send are protocol-reserved (spec section 2). */
4
+ export type Verb = 'read' | 'list';
5
+ export declare const VERBS: readonly Verb[];
6
+ export interface KeyDecl {
7
+ readonly name: string;
8
+ readonly type: FieldType;
9
+ /** The unique keys, together, name exactly one record: `read` takes exactly this set. */
10
+ readonly unique: boolean;
11
+ }
12
+ export interface FieldDecl {
13
+ readonly name: string;
14
+ readonly type: FieldType;
15
+ readonly required: boolean;
16
+ }
17
+ export interface ShelfShape {
18
+ readonly keys: readonly KeyDecl[];
19
+ readonly fields: readonly FieldDecl[];
20
+ readonly verbs: readonly Verb[];
21
+ readonly pageCap: number;
22
+ }
23
+ export interface PredefinedShelf extends ShelfShape {
24
+ readonly id: string;
25
+ readonly category: string;
26
+ readonly interfaceMajor: number;
27
+ readonly ownerRule: string;
28
+ }
29
+ export interface Registry {
30
+ readonly version: number;
31
+ readonly categories: readonly string[];
32
+ readonly shelves: ReadonlyMap<string, PredefinedShelf>;
33
+ }
34
+ /** Any shelf id on the wire (protocol-v1 `shelf_id`): lowercase dotted segments, 200 at most. */
35
+ export declare const SHELF_ID: RegExp;
36
+ /** A predefined id is exactly `<category>.<shelf>` (registry.schema `shelf_id`). */
37
+ export declare const PREDEFINED_ID: RegExp;
38
+ /** A key, a field, or a provider role name: one lowercase segment. */
39
+ export declare const SEGMENT: RegExp;
40
+ export declare const isShelfId: (id: string) => boolean;
41
+ /** Validate one shape, wherever it came from: the registry or a private declaration. */
42
+ export declare const assertShape: (id: string, shape: ShelfShape) => void;
43
+ export declare const parseRegistry: (raw: unknown) => Registry;
44
+ /** Where the registry is read from: the bundled copy when installed, the pinned devDependency in src/. */
45
+ export declare const registrySource: (path?: string | undefined) => string;
46
+ export declare const loadRegistry: (path?: string | undefined) => Registry;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * THE SHELF REGISTRY: EKKA's fixed vocabulary of predefined shelves (E1 spec, section 3 A1).
3
+ *
4
+ * ⛔ ONE SOURCE: `@ekka-ai/contracts/shelves/registry.json`, reviewed by PR in ekka-contracts. A
5
+ * Business API never redefines a predefined shelf; this package only reads it.
6
+ *
7
+ * ⛔ AN INSTALLED PACKAGE READS ONLY ITS BUNDLED COPY. `npm run build` copies the pinned file into
8
+ * dist/contracts/ (scripts/bundle-contracts.mjs, with PINNED.json), so a client installs this
9
+ * package alone and never needs the internal contracts catalog. A missing bundle is an error, never
10
+ * a silent fallback to whatever else is installed. Only when running from src/ (this repo's own
11
+ * tests) does it read the pinned devDependency. `EKKA_SHELF_REGISTRY` may name another copy of the
12
+ * same file (a test, or a newer registry being tried before its release).
13
+ *
14
+ * The rules every registry shelf follows are the registry's own: a read key set is an identifier set,
15
+ * never a human name; money is `money_minor` (an integer count of minor units) beside a
16
+ * `currency_code`; no floats anywhere, because canonical JSON is byte-deterministic only for integers.
17
+ */
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
21
+ export const FIELD_TYPES = ['string', 'integer', 'boolean', 'month', 'date', 'currency_code', 'money_minor'];
22
+ export const VERBS = ['read', 'list'];
23
+ /** Any shelf id on the wire (protocol-v1 `shelf_id`): lowercase dotted segments, 200 at most. */
24
+ export const SHELF_ID = /^[a-z][a-z0-9_]{0,31}(\.[a-z][a-z0-9_]{0,31})+$/;
25
+ /** A predefined id is exactly `<category>.<shelf>` (registry.schema `shelf_id`). */
26
+ export const PREDEFINED_ID = /^[a-z][a-z0-9_]{0,31}\.[a-z][a-z0-9_]{0,31}$/;
27
+ /** A key, a field, or a provider role name: one lowercase segment. */
28
+ export const SEGMENT = /^[a-z][a-z0-9_]{0,31}$/;
29
+ const fail = (msg) => {
30
+ throw new Error(`shelf registry: ${msg}`);
31
+ };
32
+ export const isShelfId = (id) => id.length <= 200 && SHELF_ID.test(id);
33
+ /** Validate one shape, wherever it came from: the registry or a private declaration. */
34
+ export const assertShape = (id, shape) => {
35
+ if (!isShelfId(id))
36
+ fail(`"${id}" is not a shelf id (lowercase dotted segments, e.g. hr.salary)`);
37
+ if (shape.keys.length === 0 || shape.keys.length > 16)
38
+ fail(`${id} must declare 1 to 16 keys`);
39
+ if (!shape.keys.some((k) => k.unique))
40
+ fail(`${id} has no unique key, so "read" could not name one record`);
41
+ if (shape.fields.length === 0 || shape.fields.length > 64)
42
+ fail(`${id} must declare 1 to 64 fields`);
43
+ if (shape.verbs.length === 0)
44
+ fail(`${id} serves no verb`);
45
+ if (!Number.isInteger(shape.pageCap) || shape.pageCap < 1 || shape.pageCap > 1000)
46
+ fail(`${id} page cap must be 1 to 1000`);
47
+ const seen = new Set();
48
+ for (const f of shape.fields) {
49
+ if (!SEGMENT.test(f.name))
50
+ fail(`${id} field "${f.name}" is not a field name (one lowercase segment)`);
51
+ if (!FIELD_TYPES.includes(f.type))
52
+ fail(`${id} field "${f.name}" has type "${String(f.type)}" (${FIELD_TYPES.join(', ')}; never a float)`);
53
+ if (seen.has(f.name))
54
+ fail(`${id} declares field "${f.name}" twice`);
55
+ seen.add(f.name);
56
+ }
57
+ // Money without its currency is a number nobody can read correctly.
58
+ if (shape.fields.some((f) => f.type === 'money_minor') && !shape.fields.some((f) => f.type === 'currency_code')) {
59
+ fail(`${id} has a money_minor field but no currency_code field beside it`);
60
+ }
61
+ const keys = new Set();
62
+ for (const k of shape.keys) {
63
+ if (keys.has(k.name))
64
+ fail(`${id} declares key "${k.name}" twice`);
65
+ keys.add(k.name);
66
+ const field = shape.fields.find((f) => f.name === k.name);
67
+ // A key the record does not carry could not be checked against what came back.
68
+ if (!field)
69
+ fail(`${id} key "${k.name}" is not one of its fields`);
70
+ else if (field.type !== k.type)
71
+ fail(`${id} key "${k.name}" is ${k.type} but the field is ${field.type}`);
72
+ }
73
+ for (const v of shape.verbs)
74
+ if (!VERBS.includes(v))
75
+ fail(`${id} verb "${String(v)}" is not shipped in protocol v1`);
76
+ };
77
+ export const parseRegistry = (raw) => {
78
+ const r = raw;
79
+ if (!Number.isInteger(r.registry_version))
80
+ fail('registry_version is missing');
81
+ const categories = (r.categories ?? []).map((c) => c.id);
82
+ const shelves = new Map();
83
+ for (const s of r.shelves ?? []) {
84
+ if (shelves.has(s.id))
85
+ fail(`${s.id} is declared twice`);
86
+ if (!PREDEFINED_ID.test(s.id))
87
+ fail(`${s.id}: a predefined id is exactly <category>.<shelf>`);
88
+ if (!categories.includes(s.category))
89
+ fail(`${s.id} names category "${s.category}", which is not in the registry`);
90
+ if (!s.id.startsWith(`${s.category}.`))
91
+ fail(`${s.id} is not inside its category "${s.category}"`);
92
+ if (!Number.isInteger(s.interface_major) || s.interface_major < 1)
93
+ fail(`${s.id} interface_major must be a positive integer`);
94
+ const shelf = {
95
+ id: s.id,
96
+ category: s.category,
97
+ interfaceMajor: s.interface_major,
98
+ ownerRule: s.owner_rule,
99
+ keys: s.keys,
100
+ verbs: s.verbs,
101
+ fields: s.fields,
102
+ pageCap: s.page_cap,
103
+ };
104
+ assertShape(s.id, shelf);
105
+ shelves.set(s.id, shelf);
106
+ }
107
+ return { version: r.registry_version, categories, shelves };
108
+ };
109
+ let cached;
110
+ let cachedFrom = '';
111
+ /** Where the registry is read from: the bundled copy when installed, the pinned devDependency in src/. */
112
+ export const registrySource = (path = process.env['EKKA_SHELF_REGISTRY']) => {
113
+ if (path)
114
+ return path;
115
+ const here = fileURLToPath(import.meta.url);
116
+ const bundled = fileURLToPath(new URL('./contracts/shelves/registry.json', import.meta.url));
117
+ if (existsSync(bundled))
118
+ return bundled;
119
+ if (/[\\/]src[\\/]registry\.ts$/.test(here))
120
+ return createRequire(import.meta.url).resolve('@ekka-ai/contracts/shelves/registry.json');
121
+ return fail(`the bundled registry is missing (${bundled}); this install of @ekka-ai/shelves is broken`);
122
+ };
123
+ export const loadRegistry = (path = process.env['EKKA_SHELF_REGISTRY']) => {
124
+ const file = registrySource(path);
125
+ if (cached && cachedFrom === file)
126
+ return cached;
127
+ const reg = parseRegistry(JSON.parse(readFileSync(file, 'utf8')));
128
+ cached = reg;
129
+ cachedFrom = file;
130
+ return reg;
131
+ };
@@ -0,0 +1,20 @@
1
+ import { type KeyDecl, type FieldDecl } from './registry.js';
2
+ export declare const className: (id: string) => string;
3
+ /** `id:string:unique,month:string:unique,name:string` */
4
+ export declare const parseKeys: (spec: string) => KeyDecl[];
5
+ /** `id:string,subject:string,sent_at:string?` (a trailing ? is optional) */
6
+ export declare const parseFields: (spec: string) => FieldDecl[];
7
+ export interface AddOptions {
8
+ readonly root: string;
9
+ readonly id: string;
10
+ readonly keys?: string;
11
+ readonly fields?: string;
12
+ readonly verbs?: string;
13
+ readonly pkg?: string;
14
+ }
15
+ export interface AddResult {
16
+ readonly file: string;
17
+ readonly isPrivate: boolean;
18
+ readonly touched: readonly string[];
19
+ }
20
+ export declare const add: (o: AddOptions) => AddResult;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * `npx @ekka-ai/shelves add <id> [--keys … --fields … --verbs …]` (E1 spec, section 6 P1).
3
+ *
4
+ * Writes src/ekka/<id>.ts: a class with EMPTY typed methods. The person fills the method bodies with
5
+ * their own code, which is the whole mapping from their data to the shelf.
6
+ *
7
+ * add hr.salary a predefined shelf: its shape is EKKA's registry
8
+ * add communications.email --keys id:string:unique --fields id:string,subject:string,sent:boolean
9
+ *
10
+ * ⛔ A PRIVATE SHELF MAY NOT TAKE A PREDEFINED ID (owner rule 2), and a predefined shelf takes no
11
+ * --keys or --fields: its shape is EKKA's, not the company's.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { assertShape, FIELD_TYPES, isShelfId, loadRegistry, VERBS } from './registry.js';
16
+ export const className = (id) => id
17
+ .split(/[._]/)
18
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
19
+ .join('');
20
+ const tsType = (t) => (t === 'integer' || t === 'money_minor' ? 'number' : t === 'boolean' ? 'boolean' : 'string');
21
+ const parseType = (t, where) => {
22
+ if (!t || !FIELD_TYPES.includes(t))
23
+ throw new Error(`${where}: type must be one of ${FIELD_TYPES.join(', ')} (no floats)`);
24
+ return t;
25
+ };
26
+ /** `id:string:unique,month:string:unique,name:string` */
27
+ export const parseKeys = (spec) => spec.split(',').map((part) => {
28
+ const [name, type, flag] = part.trim().split(':');
29
+ if (!name)
30
+ throw new Error(`--keys: empty entry in "${spec}"`);
31
+ return { name, type: parseType(type, `--keys ${name}`), unique: flag === 'unique' };
32
+ });
33
+ /** `id:string,subject:string,sent_at:string?` (a trailing ? is optional) */
34
+ export const parseFields = (spec) => spec.split(',').map((part) => {
35
+ const p = part.trim();
36
+ const optional = p.endsWith('?');
37
+ const [name, type] = (optional ? p.slice(0, -1) : p).split(':');
38
+ if (!name)
39
+ throw new Error(`--fields: empty entry in "${spec}"`);
40
+ return { name, type: parseType(type, `--fields ${name}`), required: !optional };
41
+ });
42
+ /** Values nobody could mistake for real data: ISO 4217 reserves XTS for testing; year 2000 is not a pay month anyone reads. */
43
+ const fixtureValue = (k) => {
44
+ switch (k.type) {
45
+ case 'string':
46
+ return `'fixture-${k.name}'`;
47
+ case 'month':
48
+ return "'2000-01'";
49
+ case 'date':
50
+ return "'2000-01-01'";
51
+ case 'currency_code':
52
+ return "'XTS'";
53
+ case 'boolean':
54
+ return 'true';
55
+ default:
56
+ return '1';
57
+ }
58
+ };
59
+ export const add = (o) => {
60
+ const reg = loadRegistry();
61
+ const pkg = o.pkg ?? '@ekka-ai/shelves';
62
+ if (!isShelfId(o.id))
63
+ throw new Error(`"${o.id}" is not a shelf id: lowercase dotted segments, e.g. hr.salary`);
64
+ const known = reg.shelves.get(o.id);
65
+ const declares = o.keys !== undefined || o.fields !== undefined;
66
+ let shape;
67
+ if (known) {
68
+ if (declares) {
69
+ throw new Error(`"${o.id}" is a predefined EKKA shelf (registry ${reg.version}); its keys and fields are EKKA's. ` +
70
+ `A private shelf may not reuse a predefined id. Run: add ${o.id} (no --keys, no --fields)`);
71
+ }
72
+ shape = known;
73
+ }
74
+ else {
75
+ if (!o.keys || !o.fields) {
76
+ throw new Error(`"${o.id}" is not a predefined shelf, so it is private: declare its shape with --keys and --fields`);
77
+ }
78
+ const verbs = (o.verbs ?? 'read,list').split(',').map((v) => v.trim());
79
+ for (const v of verbs)
80
+ if (!VERBS.includes(v))
81
+ throw new Error(`--verbs: "${v}" is not served in protocol v1 (${VERBS.join(', ')})`);
82
+ shape = { keys: parseKeys(o.keys), fields: parseFields(o.fields), verbs, pageCap: 100 };
83
+ assertShape(o.id, shape);
84
+ }
85
+ const dir = join(o.root, 'src', 'ekka');
86
+ mkdirSync(dir, { recursive: true });
87
+ const file = join(dir, `${o.id}.ts`);
88
+ if (existsSync(file))
89
+ throw new Error(`${file} already exists; nothing was changed`);
90
+ const C = className(o.id);
91
+ const unique = shape.keys.filter((k) => k.unique);
92
+ const verbs = shape.verbs;
93
+ const byType = `export type ${C}By = {\n${unique.map((k) => ` readonly ${k.name}: ${tsType(k.type)};`).join('\n')}\n};`;
94
+ const filterType = `export type ${C}Filter = {\n${shape.keys.map((k) => ` readonly ${k.name}?: ${tsType(k.type)};`).join('\n')}\n};`;
95
+ const recordType = `export type ${C}Record = {\n${shape.fields.map((f) => ` readonly ${f.name}${f.required ? '' : '?'}: ${tsType(f.type)};`).join('\n')}\n};`;
96
+ const descriptor = known
97
+ ? `predefined('${o.id}')`
98
+ : `privateShelf('${o.id}', {\n keys: ${JSON.stringify(shape.keys)},\n fields: ${JSON.stringify(shape.fields)},\n verbs: ${JSON.stringify(shape.verbs)},\n })`;
99
+ const fixtures = [
100
+ verbs.includes('read')
101
+ ? ` read: { by: { ${unique.map((k) => `${k.name}: ${fixtureValue(k)}`).join(', ')} } },`
102
+ : '',
103
+ verbs.includes('list') ? ' list: { by: {} },' : '',
104
+ ]
105
+ .filter(Boolean)
106
+ .join('\n');
107
+ const methods = [
108
+ verbs.includes('read')
109
+ ? ` override async read(by: ${C}By): Promise<${C}Record | null> {\n // Fill in: find the ONE record these keys name, in your own data; return null when there is none.\n void by;\n throw notImplemented('${o.id}', 'read');\n }`
110
+ : '',
111
+ verbs.includes('list')
112
+ ? ` override async list(by: ${C}Filter, page: Page): Promise<ListResult<${C}Record>> {\n // Fill in: at most page.limit records matching every key given; nextCursor is null on the last page.\n void by;\n void page;\n throw notImplemented('${o.id}', 'list');\n }`
113
+ : '',
114
+ ]
115
+ .filter(Boolean)
116
+ .join('\n\n');
117
+ const source = `/**
118
+ * ${o.id}${known ? `@${known.interfaceMajor}: an EKKA predefined shelf (registry ${reg.version}). Its keys and fields are EKKA's.` : ': a PRIVATE shelf of this company. Its shape is declared here.'}
119
+ *
120
+ * Fill the method bodies with this service's own code: that code is the mapping from your data to
121
+ * the shelf. The provider role each verb needs is written in ./policy.ts, and only there.
122
+ * The fixtures are SYNTHETIC: \`check\` and the Enclave's import probe send them, and the manifest
123
+ * carries them to EKKA marked synthetic. Never a real person.
124
+ */
125
+ import { ${known ? 'predefined' : 'privateShelf'}, notImplemented, Shelf, type Fixtures, type ListResult, type Page } from '${pkg}';
126
+
127
+ import { policy } from './policy.js';
128
+
129
+ ${byType}
130
+
131
+ ${filterType}
132
+
133
+ ${recordType}
134
+
135
+ export class ${C} extends Shelf<${C}By, ${C}Filter, ${C}Record> {
136
+ readonly descriptor = ${descriptor};
137
+ readonly roles = policy['${o.id}'];
138
+ readonly fixtures: Fixtures<${C}By, ${C}Filter> = {
139
+ ${fixtures}
140
+ };
141
+
142
+ ${methods}
143
+ }
144
+ `;
145
+ writeFileSync(file, source);
146
+ const touched = [file, addToPolicy(dir, o.id, verbs), addToIndex(dir, o.id, C)];
147
+ return { file, isPrivate: !known, touched };
148
+ };
149
+ const POLICY_HEADER = `/**
150
+ * WHICH PROVIDER ROLE MAY SERVE WHICH SHELF VERB: the ONE place a provider role is written.
151
+ *
152
+ * An organization maps these roles to its own roles when it imports this service into EKKA. A verb
153
+ * with an empty role refuses to start: that decision is a person's, never a default.
154
+ */
155
+ import type { Roles } from '@ekka-ai/shelves';
156
+
157
+ export const policy = {
158
+ // @ekka-ai/shelves: entries
159
+ } as const satisfies Record<string, Roles>;
160
+ `;
161
+ const addToPolicy = (dir, id, verbs) => {
162
+ const file = join(dir, 'policy.ts');
163
+ const src = existsSync(file) ? readFileSync(file, 'utf8') : POLICY_HEADER;
164
+ if (src.includes(`'${id}':`))
165
+ return file;
166
+ const marker = ' // @ekka-ai/shelves: entries';
167
+ if (!src.includes(marker))
168
+ throw new Error(`${file} has lost its "${marker.trim()}" line; add the entry for ${id} by hand`);
169
+ const entry = ` '${id}': { ${verbs.map((v) => `${v}: ''`).join(', ')} },`;
170
+ writeFileSync(file, src.replace(marker, `${entry}\n${marker}`));
171
+ return file;
172
+ };
173
+ const INDEX_HEADER = `/**
174
+ * The shelves this service serves. \`app.register(ekkaShelves({ provider, shelves, … }))\` mounts them;
175
+ * \`npx @ekka-ai/shelves generate\` and \`check\` read this file.
176
+ */
177
+ import type { AnyShelf } from '@ekka-ai/shelves';
178
+ // @ekka-ai/shelves: imports
179
+
180
+ /** Who this service is to EKKA. offerVersion is immutable: change a shelf, bump it. */
181
+ export const provider = { id: '', offerVersion: '1' } as const;
182
+
183
+ export const shelves: readonly AnyShelf[] = [
184
+ // @ekka-ai/shelves: shelves
185
+ ];
186
+ `;
187
+ const addToIndex = (dir, id, C) => {
188
+ const file = join(dir, 'index.ts');
189
+ let src = existsSync(file) ? readFileSync(file, 'utf8') : INDEX_HEADER;
190
+ if (src.includes(`new ${C}()`))
191
+ return file;
192
+ for (const m of ['// @ekka-ai/shelves: imports', ' // @ekka-ai/shelves: shelves']) {
193
+ if (!src.includes(m))
194
+ throw new Error(`${file} has lost its "${m.trim()}" line; add ${C} by hand`);
195
+ }
196
+ src = src.replace('// @ekka-ai/shelves: imports', `import { ${C} } from './${id}.js';\n// @ekka-ai/shelves: imports`);
197
+ src = src.replace(' // @ekka-ai/shelves: shelves', ` new ${C}(),\n // @ekka-ai/shelves: shelves`);
198
+ writeFileSync(file, src);
199
+ return file;
200
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A SHELF, AS A BUSINESS API WRITES IT (E1 spec, section 6 P1).
3
+ *
4
+ * `npx @ekka-ai/shelves add hr.salary` writes a class that extends `Shelf` with empty typed methods. The
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 (owner rule 3).
7
+ *
8
+ * - A PREDEFINED shelf takes its keys, fields, verbs and page cap from EKKA's registry.
9
+ * `predefined('hr.salary')` refuses an id the registry does not define.
10
+ * - A PRIVATE shelf declares its shape in the file. `privateShelf(...)` refuses an id the registry
11
+ * DOES define (owner rule 2): a company may not publish its own `hr.salary`.
12
+ * - `roles` is read from ONE policy file, `src/ekka/policy.ts`: a provider role is written in
13
+ * exactly one place (spec v3.2, section 12).
14
+ */
15
+ import { type FieldType, type ShelfShape, type Verb } from './registry.js';
16
+ export type Scalar = string | number | boolean;
17
+ export type Row = Readonly<Record<string, Scalar>>;
18
+ export interface Page {
19
+ readonly limit: number;
20
+ readonly cursor: string | null;
21
+ }
22
+ export interface ListResult<R extends Row> {
23
+ readonly records: readonly R[];
24
+ /** Opaque to EKKA; null when this is the last page. Missing is a failed call, never "no more". */
25
+ readonly nextCursor: string | null;
26
+ }
27
+ /** The provider role each verb requires. Written in src/ekka/policy.ts, nowhere else. */
28
+ export type Roles = Partial<Readonly<Record<Verb, string>>>;
29
+ /**
30
+ * SYNTHETIC inputs `check` and the Enclave's import probe send. Never real people: the manifest
31
+ * carries them to EKKA, marked `synthetic: true`.
32
+ */
33
+ export interface Fixtures<By extends Row, Filter extends Partial<Row> = Partial<Row>> {
34
+ /** `by` must find a synthetic record. `check` derives a key that finds none from it. */
35
+ readonly read?: {
36
+ readonly by: By;
37
+ };
38
+ readonly list?: {
39
+ readonly by: Filter;
40
+ };
41
+ }
42
+ export interface ShelfDescriptor {
43
+ readonly id: string;
44
+ /** The EKKA interface major for a predefined shelf; null for a private one. */
45
+ readonly interfaceMajor: number | null;
46
+ readonly isPrivate: boolean;
47
+ readonly shape: ShelfShape;
48
+ }
49
+ export declare const predefined: (id: string) => ShelfDescriptor;
50
+ export interface PrivateShapeInput {
51
+ readonly keys: readonly {
52
+ readonly name: string;
53
+ readonly type: FieldType;
54
+ readonly unique: boolean;
55
+ }[];
56
+ readonly fields: readonly {
57
+ readonly name: string;
58
+ readonly type: FieldType;
59
+ readonly required: boolean;
60
+ }[];
61
+ readonly verbs: readonly Verb[];
62
+ readonly pageCap?: number;
63
+ }
64
+ export declare const privateShelf: (id: string, shape: PrivateShapeInput) => ShelfDescriptor;
65
+ /** A refusal a method may throw on purpose, for example its own row policy. */
66
+ export type RefusalCode = 'unknown_key' | 'forbidden' | 'not_implemented' | 'shape' | 'provider_error';
67
+ export declare class ShelfRefusal extends Error {
68
+ readonly code: RefusalCode;
69
+ constructor(code: RefusalCode, message: string);
70
+ }
71
+ export declare const notImplemented: (id: string, verb: Verb) => ShelfRefusal;
72
+ /**
73
+ * `By` is the unique key set `read` takes; `Filter` is the equality filter `list` takes (any subset
74
+ * of the declared keys, unique or not); `R` is one record, exactly the shelf's fields.
75
+ */
76
+ export declare abstract class Shelf<By extends Row = Row, Filter extends Partial<Row> = Partial<Row>, R extends Row = Row> {
77
+ abstract readonly descriptor: ShelfDescriptor;
78
+ abstract readonly roles: Roles;
79
+ abstract readonly fixtures: Fixtures<By, Filter>;
80
+ read?(by: By): Promise<R | null>;
81
+ list?(by: Filter, page: Page): Promise<ListResult<R>>;
82
+ }
83
+ export type AnyShelf = Shelf<any, any, any>;
84
+ /** What a verb needs before it may be served: a method, a role, and a fixture to probe it with. */
85
+ export declare const verbProblems: (s: AnyShelf) => string[];