@emkodev/emkore 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,23 @@ and this project adheres to
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.0] - 2026-04-08
12
+
13
+ ### Added
14
+
15
+ - **`LlmStringParameter` extensions**: Added optional `enum?: readonly string[]`, `format?: string`, `default?: string` fields. These let LLM tool descriptions carry enum constraints (allowed values), format hints (e.g. `"date-time"`, `"uuid"`), and default values — closing the largest gap in MCP tool-call quality for typical CRUD usecases.
16
+ - **`LlmNumberParameter` extensions**: Added optional `minimum?: number`, `maximum?: number`, `default?: number` fields for numeric parameter constraints.
17
+ - **`LlmBooleanParameter` extensions**: Added optional `default?: boolean`.
18
+ - **`LlmArrayParameter.items` extensions**: The `items` descriptor now carries optional `format?`, `enum?`, and `properties?` fields, so arrays of dates, enums, or nested objects can be described accurately to LLMs.
19
+ - **`LlmStringReturnValue` / `LlmNumberReturnValue`**: Added the same optional `enum`/`format` (string) and `minimum`/`maximum` (number) fields on the return-value variants for symmetry with parameters.
20
+ - **`apiDefinitionToJsonSchema()` forwarding**: The converter now copies all the new optional fields through to the JSON Schema output, so consumers that read the projected JSON Schema get the full information.
21
+
22
+ ### Notes
23
+
24
+ All additions are purely additive optional fields. Existing `ApiDefinition` literals continue to type-check and behave identically. The discriminated-union narrowing on `LlmParameter.type` is preserved — adding optional fields to individual member interfaces does not affect narrowing.
25
+
26
+ This release pairs with the LLM-side `apiDefinition` generation work in `@emkode/cli` 0.7.0 and the field-forwarding logic in `@emkodev/emkoord`.
27
+
11
28
  ## [1.0.3] - 2026-01-20
12
29
 
13
30
  ### Added
@@ -0,0 +1,325 @@
1
+ # Permissions
2
+
3
+ ## Design philosophy
4
+
5
+ Every permission is explicit. If an actor has access, it is because someone
6
+ created a `Permission` object that says so. There are no wildcards, no deny
7
+ rules, no implicit inheritance, no role abstractions. This is deliberate:
8
+
9
+ - **No `*` wildcards.** A grant covers exactly what it names. You cannot grant
10
+ `{ resource: "*" }` to cover all resources. If an actor needs access to five
11
+ resources, they get five grants.
12
+ - **No deny rules.** The model is allow-only. Access is the union of what is
13
+ explicitly granted — never subtracted.
14
+ - **No implicit inheritance.** A grant on one resource does not imply access to
15
+ related resources. A grant with a broad scope does not bypass non-scope
16
+ constraints.
17
+ - **No roles.** Consumers map roles to permissions externally. Emkore only sees
18
+ flat permission arrays.
19
+ - **No authentication.** The system does not distinguish "authenticated" from
20
+ "guest." It only sees actors and their grants. A guest is an actor with
21
+ grants — possibly none, possibly explicit grants for public resources.
22
+ Authentication is an external concern.
23
+
24
+ The cost is verbosity. The benefit is that every access decision is traceable to
25
+ a specific grant with no ambiguity.
26
+
27
+ ## Permission structure
28
+
29
+ ```ts
30
+ interface Permission {
31
+ readonly resource: string; // e.g. "invoice", "document"
32
+ readonly action: string; // e.g. "create", "retrieve", "update"
33
+ readonly constraints?: ResourceConstraints;
34
+ }
35
+ ```
36
+
37
+ A permission is a `resource + action` pair with optional `ResourceConstraints`.
38
+
39
+ ## ResourceConstraints
40
+
41
+ ```ts
42
+ interface ResourceConstraints {
43
+ scope?: ResourceScope;
44
+ statuses?: string[];
45
+ timeRestriction?: { from: Date; to: Date };
46
+ businessId?: string;
47
+ teamId?: string;
48
+ projectId?: string;
49
+ resourceId?: string;
50
+ }
51
+ ```
52
+
53
+ Every field is optional. Constraints narrow when and where a permission applies.
54
+
55
+ ## ResourceScope
56
+
57
+ ```
58
+ BUSINESS ────── tenant-wide (the ceiling)
59
+ ├─ PROJECT ──> OWNED
60
+ └─ TEAM ──> OWNED
61
+ ```
62
+
63
+ `BUSINESS` is the largest organizational scope — it covers the entire tenant.
64
+ `PROJECT` and `TEAM` are parallel siblings under it — neither satisfies the
65
+ other. `OWNED` is the smallest meaningful scope — the baseline that comes with
66
+ any grant. Whatever scope you have, you can access your own resources.
67
+
68
+ The scope hierarchy determines **how much beyond your own resources** you can
69
+ see:
70
+
71
+ - `OWNED` — only your resources
72
+ - `TEAM` — your team's resources (and your own)
73
+ - `PROJECT` — your project's resources (and your own)
74
+ - `BUSINESS` — everything in the tenant (and your own, trivially)
75
+
76
+ ### ALL scope
77
+
78
+ `ALL` sits above `BUSINESS` as a god-mode scope. On the grant side, it
79
+ satisfies every scope requirement. On the requirement side, only `ALL` grants
80
+ satisfy it — making it the most restrictive requirement.
81
+
82
+ `ALL` exists for consumers who need a scope tier above the tenant. If a consumer
83
+ does not use it, it is invisible — no grants are issued, no usecases require
84
+ it, and it has no effect.
85
+
86
+ `BUSINESS` is the broadest organizational scope for normal use. `ALL` is
87
+ opt-in for exceptional cases.
88
+
89
+ ### Scope satisfaction
90
+
91
+ A grant's scope satisfies a requirement's scope if it is equal to or above it
92
+ in the hierarchy:
93
+
94
+ | Grant \ Requirement | ALL | BUSINESS | PROJECT | TEAM | OWNED |
95
+ | ------------------- | --- | -------- | ------- | ---- | ----- |
96
+ | ALL | Y | Y | Y | Y | Y |
97
+ | BUSINESS | | Y | Y | Y | Y |
98
+ | PROJECT | | | Y | | Y |
99
+ | TEAM | | | | Y | Y |
100
+ | OWNED | | | | | Y |
101
+
102
+ ## How constraint matching works
103
+
104
+ All constraints follow the same pattern: **the grant defines what the actor can
105
+ do, the requirement defines what the usecase needs, the interceptor checks if
106
+ the grant covers the requirement.**
107
+
108
+ - Scope: grant must satisfy requirement (hierarchy).
109
+ - Statuses: grant must be a superset of requirement.
110
+ - Time: grant window must cover requirement window.
111
+ - IDs: grant must match requirement (exact).
112
+
113
+ When an actor has multiple grants for the same `resource + action`, each grant
114
+ is checked independently. If any single grant satisfies all required
115
+ constraints, the actor passes.
116
+
117
+ ### Rules
118
+
119
+ 1. If the usecase requires **no constraints**, any grant with a matching
120
+ `resource + action` passes — regardless of what constraints the grant
121
+ carries.
122
+
123
+ 2. If the usecase requires constraints but the grant has **none**,
124
+ authorization fails.
125
+
126
+ 3. Each required constraint is checked independently. All must pass (AND logic).
127
+
128
+ 4. **Grant-side constraints that the usecase does not require are ignored.**
129
+ This applies to all constraints except `timeRestriction` — see below.
130
+
131
+ ### Rule 4 in practice
132
+
133
+ If an actor's grant has `{ scope: ResourceScope.BUSINESS, teamId: "sales" }`,
134
+ the `teamId: "sales"` is only enforced when a usecase explicitly requires a
135
+ `teamId`. A usecase that only requires `{ scope: ResourceScope.TEAM }` will not
136
+ check the `teamId` — the actor passes.
137
+
138
+ Grant-side constraints are **not hard limits on the actor**. They are data the
139
+ actor carries. They are enforced only when a usecase asks for them. If you need
140
+ a constraint to always be enforced, every relevant usecase must include it in
141
+ its `requiredPermissions`.
142
+
143
+ ### timeRestriction
144
+
145
+ `timeRestriction` is the exception to rule 4. A grant's time window is always
146
+ enforced against the current time, regardless of whether the usecase requires a
147
+ time restriction. If the actor's grant has a `timeRestriction` and `now` falls
148
+ outside that window, the permission is invalid.
149
+
150
+ When both the grant and the requirement have a `timeRestriction`, both windows
151
+ are checked against `now`. The effective window is their intersection. This
152
+ covers:
153
+
154
+ - **Temporary access** — grant the actor a time window. No usecase changes
155
+ needed. The contractor's grant expires on its own.
156
+ - **Subscription expiration** — the grant's time window represents the
157
+ subscription period.
158
+ - **Narrowed operational windows** — if the usecase restricts its availability
159
+ to certain hours and the grant restricts the actor to different hours, both
160
+ are enforced.
161
+
162
+ > Note: this is the intended behavior. The current implementation does not yet
163
+ > enforce grant-side `timeRestriction` independently — it only checks when the
164
+ > requirement also has one. This is a known issue to be fixed.
165
+
166
+ ### Constraint-by-constraint behavior
167
+
168
+ | Constraint | Comparison |
169
+ | ----------------- | -------------------------------------------------------------------------------- |
170
+ | `scope` | Hierarchical: granted scope must satisfy the required scope per the scope table. |
171
+ | `statuses` | Set containment: granted statuses must be a superset of required statuses. |
172
+ | `timeRestriction` | Grant window and requirement window both checked against `now`. |
173
+ | `businessId` | Exact match. |
174
+ | `teamId` | Exact match. |
175
+ | `projectId` | Exact match. |
176
+ | `resourceId` | Exact match. |
177
+
178
+ ## Public access
179
+
180
+ "Public" is not a system concept. It is the result of granting a guest actor
181
+ explicit permissions for specific resources and actions.
182
+
183
+ A guest actor with `{ resource: "article", action: "retrieve" }` can retrieve
184
+ articles. The same usecase, same interceptor, same authorization path as any
185
+ other actor. What makes it "public" is that the guest was given the grant.
186
+
187
+ Usecases with empty `requiredPermissions` skip the interceptor entirely. This
188
+ means no authorization runs at all — use this for operations that genuinely need
189
+ no access control (health checks, internal tools), not for public access.
190
+
191
+ ## Recipes
192
+
193
+ ### 1. "Can this user edit this specific resource?"
194
+
195
+ The usecase requires `resourceId` so the interceptor verifies the actor has
196
+ access to that exact entity. `resourceId` is an exact match — no pattern or
197
+ prefix matching.
198
+
199
+ ```ts
200
+ // Usecase requires:
201
+ { resource: "document", action: "update",
202
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" } }
203
+
204
+ // Actor grant — access to one specific document
205
+ { resource: "document", action: "update",
206
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" } }
207
+ ```
208
+
209
+ The actor can only update `doc-42`. Any other `resourceId` requirement fails.
210
+
211
+ ### 2. "Can this user see everything in their team?"
212
+
213
+ The usecase requires `TEAM` scope and a `teamId`. The actor's grant must match
214
+ both.
215
+
216
+ ```ts
217
+ // Usecase requires:
218
+ { resource: "ticket", action: "list",
219
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" } }
220
+
221
+ // Actor grant
222
+ { resource: "ticket", action: "list",
223
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" } }
224
+ ```
225
+
226
+ A `BUSINESS`-scoped grant with `teamId: "engineering"` also passes (BUSINESS
227
+ satisfies TEAM). A grant with `teamId: "sales"` fails.
228
+
229
+ ### 3. "Can this user only modify draft resources?"
230
+
231
+ The usecase requires certain statuses; the actor's grant must cover all of them.
232
+
233
+ ```ts
234
+ // Usecase requires:
235
+ { resource: "invoice", action: "update",
236
+ constraints: { statuses: ["draft"] } }
237
+
238
+ // Actor grant — can update drafts and pending invoices
239
+ { resource: "invoice", action: "update",
240
+ constraints: { statuses: ["draft", "pending"] } }
241
+ ```
242
+
243
+ Passes because the granted statuses are a superset of the required statuses. An
244
+ actor with only `statuses: ["pending"]` would fail — `"draft"` is not covered.
245
+
246
+ ### 4. "How do I make something publicly accessible?"
247
+
248
+ Give the guest actor explicit grants for the resources and actions that should
249
+ be public. The guest goes through the same authorization path as everyone else.
250
+
251
+ ```ts
252
+ // Guest actor grants
253
+ { resource: "article", action: "retrieve" }
254
+ { resource: "product", action: "list" }
255
+
256
+ // Usecase — same as for any other actor
257
+ override get requiredPermissions(): Permission[] {
258
+ return [{
259
+ resource: "article",
260
+ action: "retrieve",
261
+ constraints: { scope: ResourceScope.OWNED },
262
+ }];
263
+ }
264
+ ```
265
+
266
+ The guest's grant matches the resource and action. The scope on the requirement
267
+ is `OWNED` — the smallest scope, satisfied by any grant. The interceptor passes.
268
+
269
+ What is "public" is determined entirely by which grants the guest actor
270
+ carries — not by the usecase, not by a special scope, not by empty
271
+ requirements.
272
+
273
+ ### 5. "Can this user only act during a specific time window?"
274
+
275
+ Put a `timeRestriction` on the grant. No usecase changes needed.
276
+
277
+ ```ts
278
+ // Actor grant — temporary contractor access, March 16 only, 10am to 2pm
279
+ { resource: "report", action: "retrieve",
280
+ constraints: {
281
+ scope: ResourceScope.TEAM,
282
+ teamId: "engineering",
283
+ timeRestriction: { from: new Date("2026-03-16T10:00"), to: new Date("2026-03-16T14:00") },
284
+ } }
285
+ ```
286
+
287
+ The interceptor checks `now` against the grant's time window. Outside the
288
+ window, the grant is invalid. The usecase does not need to know about the time
289
+ restriction.
290
+
291
+ ## Grant-side constraints as metadata
292
+
293
+ Grant-side constraints serve a secondary purpose beyond authorization: they
294
+ carry data that usecases and repositories can use at runtime.
295
+
296
+ `Actor.getTeamIds()` extracts all unique `teamId` values from the actor's
297
+ permission constraints. This is useful for filtering queries — e.g. a
298
+ repository can use the actor's team IDs to scope a database query without the
299
+ interceptor needing to understand the query.
300
+
301
+ The `OWNED` scope implies a runtime filter: the usecase or repository should
302
+ return only records owned by the actor. This avoids the need for individual
303
+ `resourceId` grants for every record the actor owns.
304
+
305
+ ## What the permissions model does not do
306
+
307
+ - **No wildcard matching.** You cannot grant `{ resource: "*" }` to cover all
308
+ resources. Every grant names exactly what it covers.
309
+ - **No deny rules.** The model is allow-only. You cannot deny a permission to
310
+ override a broader grant.
311
+ - **No roles.** There is no built-in role abstraction. Consumers manage
312
+ role-to-permission mapping externally.
313
+ - **No authentication.** The system does not know about authentication. It sees
314
+ actors and grants. A "guest" is an actor with explicit grants for public
315
+ resources.
316
+ - **No dynamic/contextual checks.** Constraints are static on the permission
317
+ object. There is no hook to evaluate permissions based on the resource's
318
+ runtime state. The exception is `OWNED` scope, which implies the usecase or
319
+ repository should filter by the actor's identity.
320
+ - **No field-level access control.** Permissions operate at the resource level.
321
+ - **No permission delegation.** An actor cannot grant a subset of its
322
+ permissions to another actor.
323
+ - **No custom scope levels.** The scope hierarchy is fixed.
324
+ - **No built-in persistence.** Emkore provides the model and enforcement.
325
+ Consumers decide how to store and load permissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emkodev/emkore",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "A TypeScript foundation framework for building robust, type-safe business applications with clean architecture patterns",
5
5
  "license": "MIT",
6
6
  "author": "emko.dev",
@@ -13,14 +13,21 @@ interface LlmBaseParameter {
13
13
  // Primitive type parameters
14
14
  interface LlmStringParameter extends LlmBaseParameter {
15
15
  readonly type: "string";
16
+ readonly enum?: readonly string[];
17
+ readonly format?: string;
18
+ readonly default?: string;
16
19
  }
17
20
 
18
21
  interface LlmNumberParameter extends LlmBaseParameter {
19
22
  readonly type: "number" | "integer";
23
+ readonly minimum?: number;
24
+ readonly maximum?: number;
25
+ readonly default?: number;
20
26
  }
21
27
 
22
28
  interface LlmBooleanParameter extends LlmBaseParameter {
23
29
  readonly type: "boolean";
30
+ readonly default?: boolean;
24
31
  }
25
32
 
26
33
  interface LlmNullParameter extends LlmBaseParameter {
@@ -33,6 +40,8 @@ interface LlmArrayParameter extends LlmBaseParameter {
33
40
  readonly items: {
34
41
  readonly type: string;
35
42
  readonly description?: string;
43
+ readonly format?: string;
44
+ readonly enum?: readonly string[];
36
45
  readonly properties?: JsonSchema;
37
46
  };
38
47
  }
@@ -60,10 +69,14 @@ interface LlmBaseReturnValue {
60
69
  // Primitive type returns
61
70
  interface LlmStringReturnValue extends LlmBaseReturnValue {
62
71
  readonly type: "string";
72
+ readonly enum?: readonly string[];
73
+ readonly format?: string;
63
74
  }
64
75
 
65
76
  interface LlmNumberReturnValue extends LlmBaseReturnValue {
66
77
  readonly type: "number" | "integer";
78
+ readonly minimum?: number;
79
+ readonly maximum?: number;
67
80
  }
68
81
 
69
82
  interface LlmBooleanReturnValue extends LlmBaseReturnValue {
@@ -80,7 +93,10 @@ interface LlmArrayReturnValue extends LlmBaseReturnValue {
80
93
  readonly items: {
81
94
  readonly type: string;
82
95
  readonly description?: string;
96
+ readonly format?: string;
97
+ readonly enum?: readonly string[];
83
98
  readonly properties?: JsonSchema;
99
+ readonly required?: readonly string[];
84
100
  };
85
101
  }
86
102
 
@@ -118,6 +134,42 @@ export function apiDefinitionToJsonSchema(
118
134
  description: param.description,
119
135
  };
120
136
 
137
+ // String-typed parameter extras
138
+ if (param.type === "string") {
139
+ const stringParam = param as LlmStringParameter;
140
+ if (stringParam.enum) {
141
+ paramSchema.enum = [...stringParam.enum];
142
+ }
143
+ if (stringParam.format !== undefined) {
144
+ paramSchema.format = stringParam.format;
145
+ }
146
+ if (stringParam.default !== undefined) {
147
+ paramSchema.default = stringParam.default;
148
+ }
149
+ }
150
+
151
+ // Number/integer-typed parameter extras
152
+ if (param.type === "number" || param.type === "integer") {
153
+ const numberParam = param as LlmNumberParameter;
154
+ if (numberParam.minimum !== undefined) {
155
+ paramSchema.minimum = numberParam.minimum;
156
+ }
157
+ if (numberParam.maximum !== undefined) {
158
+ paramSchema.maximum = numberParam.maximum;
159
+ }
160
+ if (numberParam.default !== undefined) {
161
+ paramSchema.default = numberParam.default;
162
+ }
163
+ }
164
+
165
+ // Boolean-typed parameter extras
166
+ if (param.type === "boolean") {
167
+ const booleanParam = param as LlmBooleanParameter;
168
+ if (booleanParam.default !== undefined) {
169
+ paramSchema.default = booleanParam.default;
170
+ }
171
+ }
172
+
121
173
  // Add items for array types (JSON Schema 2020-12 compliance)
122
174
  if (param.type === "array" && "items" in param) {
123
175
  const arrayParam = param as LlmArrayParameter;
@@ -150,6 +202,30 @@ export function apiDefinitionToJsonSchema(
150
202
  description: definition.returns.description,
151
203
  };
152
204
 
205
+ // String-typed return extras
206
+ if (definition.returns.type === "string") {
207
+ const stringReturn = definition.returns as LlmStringReturnValue;
208
+ if (stringReturn.enum) {
209
+ returnsSchema.enum = [...stringReturn.enum];
210
+ }
211
+ if (stringReturn.format !== undefined) {
212
+ returnsSchema.format = stringReturn.format;
213
+ }
214
+ }
215
+
216
+ // Number/integer-typed return extras
217
+ if (
218
+ definition.returns.type === "number" || definition.returns.type === "integer"
219
+ ) {
220
+ const numberReturn = definition.returns as LlmNumberReturnValue;
221
+ if (numberReturn.minimum !== undefined) {
222
+ returnsSchema.minimum = numberReturn.minimum;
223
+ }
224
+ if (numberReturn.maximum !== undefined) {
225
+ returnsSchema.maximum = numberReturn.maximum;
226
+ }
227
+ }
228
+
153
229
  // Add items for array return types (JSON Schema 2020-12 compliance)
154
230
  if (definition.returns.type === "array" && "items" in definition.returns) {
155
231
  const arrayReturn = definition.returns as LlmArrayReturnValue;