akanjs 3.0.0-alpha.14 → 3.0.0-alpha.16

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.
Files changed (42) hide show
  1. package/base/symbols.ts +1 -1
  2. package/common/index.ts +0 -1
  3. package/common/mcpExposure.ts +10 -17
  4. package/constant/purify.ts +17 -11
  5. package/constant/types.ts +14 -12
  6. package/dictionary/dictInfo.ts +3 -83
  7. package/dictionary/locale.ts +2 -16
  8. package/package.json +1 -1
  9. package/server/akanServer.ts +16 -5
  10. package/server/mcp/McpRouter.ts +11 -24
  11. package/signal/guard.ts +5 -3
  12. package/signal/mcp/McpDocument.ts +22 -58
  13. package/signal/serializer/fetch.serializer.ts +0 -3
  14. package/signal/slice.ts +11 -24
  15. package/signal/types.ts +0 -32
  16. package/store/actionTag.ts +5 -0
  17. package/store/agent/AgentBridge.ts +24 -24
  18. package/store/agent/StoreCatalogue.ts +15 -13
  19. package/store/rootStore.ts +2 -1
  20. package/store/store.ts +6 -2
  21. package/store/storeInstance.ts +5 -5
  22. package/store/storeRegistry.ts +1 -1
  23. package/types/base/symbols.d.ts +1 -1
  24. package/types/common/index.d.ts +1 -1
  25. package/types/common/mcpExposure.d.ts +3 -12
  26. package/types/constant/purify.d.ts +2 -2
  27. package/types/constant/types.d.ts +2 -2
  28. package/types/dictionary/dictInfo.d.ts +15 -46
  29. package/types/dictionary/locale.d.ts +1 -9
  30. package/types/server/akanServer.d.ts +4 -4
  31. package/types/server/mcp/McpRouter.d.ts +9 -17
  32. package/types/signal/guard.d.ts +4 -3
  33. package/types/signal/mcp/McpDocument.d.ts +7 -18
  34. package/types/signal/slice.d.ts +9 -11
  35. package/types/signal/types.d.ts +0 -30
  36. package/types/store/actionTag.d.ts +4 -0
  37. package/types/store/agent/AgentBridge.d.ts +1 -3
  38. package/types/store/rootStore.d.ts +2 -1
  39. package/types/store/store.d.ts +2 -1
  40. package/types/store/storeInstance.d.ts +3 -2
  41. package/types/ui/Signal/RestApi.d.ts +2 -3
  42. package/ui/Signal/RestApi.tsx +24 -37
package/base/symbols.ts CHANGED
@@ -13,7 +13,7 @@ export const STATE_META = Symbol.for("akan.state");
13
13
  export const STATE_INIT_META = Symbol.for("akan.state.init");
14
14
  export const STATE_DERIVED_META = Symbol.for("akan.state.derived");
15
15
  export const ACTION_META = Symbol.for("akan.action");
16
- /** Which module declared each action, which is the dictionary node its words are written in. */
16
+ /** Which module declared each action. See `ActionOwner`. */
17
17
  export const ACTION_OWNER_META = Symbol.for("akan.action.owner");
18
18
  /** What a dispatcher does, carried on the function so a component handed one can annotate the DOM with it. */
19
19
  export const ACTION_TAG = Symbol.for("akan.action.tag");
package/common/index.ts CHANGED
@@ -30,7 +30,6 @@ export { lowerlize } from "./lowerlize";
30
30
  export {
31
31
  isMcpDescribableArg,
32
32
  type McpExposureEndpoint,
33
- type McpExposureHints,
34
33
  type McpExposureOption,
35
34
  mcpBaseVerbOf,
36
35
  mcpHintsOf,
@@ -8,7 +8,7 @@ import { capitalize } from "./capitalize";
8
8
  * eventually disagree, and an audit surface that disagrees with the catalogue is worse than none.
9
9
  *
10
10
  * Every rejection returns the sentence an author reads, at boot in the server log and in the explorer. Fail-closed
11
- * with no reason is what left a deliberate `mcp: { expose: true }` vanishing with nowhere to look but the source.
11
+ * with no reason leaves an author whose endpoint is missing from the catalogue nowhere to look but the source.
12
12
  */
13
13
  export interface McpExposureEndpoint {
14
14
  type: string;
@@ -26,20 +26,10 @@ export interface McpExposureOption {
26
26
  readOnly?: boolean;
27
27
  }
28
28
 
29
- /** The author's overrides for the hints derived below. */
30
- export interface McpExposureHints {
31
- readOnly?: boolean;
32
- destructive?: boolean;
33
- idempotent?: boolean;
34
- }
35
-
36
29
  /** `Any` publishes as the empty schema, which tells a model nothing — so it is left out rather than described. */
37
30
  export const isMcpDescribableArg = (arg: { refName: string }) => arg.refName !== "Any";
38
31
 
39
- /**
40
- * Which slice-level verb opts in each CRUD endpoint a model generates. They carry no signal option of their own,
41
- * so `mcp: { get: true }` on the slice is the only place their exposure can be written.
42
- */
32
+ /** Which CRUD verb a generated endpoint key is, or `null` when the key is not one of the five. */
43
33
  export const mcpBaseVerbOf = (refName: string, key: string) => {
44
34
  const cap = capitalize(refName);
45
35
  if (key === refName || key === `light${cap}`) return "get" as const;
@@ -54,19 +44,22 @@ export const mcpBaseVerbOf = (refName: string, key: string) => {
54
44
  * and never stand in for a guard. `openWorldHint` is always false: every endpoint reaches this app's own
55
45
  * database, not the wider internet.
56
46
  */
57
- export const mcpHintsOf = (key: string, endpoint: { type: string }, mcp: McpExposureHints = {}) => {
58
- const readOnly = mcp.readOnly ?? endpoint.type === "query";
59
- const destructive = mcp.destructive ?? (!readOnly && /^(remove|delete)/.test(key));
47
+ export const mcpHintsOf = (key: string, endpoint: { type: string }) => {
48
+ const readOnly = endpoint.type === "query";
49
+ const destructive = !readOnly && /^(remove|delete)/.test(key);
60
50
  return {
61
51
  readOnlyHint: readOnly,
62
52
  destructiveHint: destructive,
63
- idempotentHint: mcp.idempotent ?? (readOnly || /^(set|update)/.test(key)),
53
+ idempotentHint: readOnly || /^(set|update)/.test(key),
64
54
  openWorldHint: false,
65
55
  };
66
56
  };
67
57
 
68
58
  /** The sentence explaining why this endpoint is not in the catalogue, or `null` when it is. */
69
59
  export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExposureOption = {}): string | null => {
60
+
61
+ if (!endpoint.guards?.length)
62
+ return "it declares no guards, and exposure follows them — write `guards: [Public]` if anonymous access is the intent.";
70
63
  if (endpoint.type === "prompt") return mcpPromptRefusalOf(endpoint);
71
64
  if (endpoint.type === "pubsub" || endpoint.type === "message")
72
65
  return `\`${endpoint.type}\` rides the websocket, and its internal arguments read a socket an MCP request does not have.`;
@@ -76,7 +69,7 @@ export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExp
76
69
  return `a return typed \`${endpoint.returns.refName}\` cannot be described to a model.`;
77
70
  if (endpoint.fileUpload || endpoint.args.some((arg) => arg.refName === "Upload"))
78
71
  return "a file upload has no MCP representation.";
79
- if (endpoint.type === "mutation" && !endpoint.guards?.some((name) => name !== "Public"))
72
+ if (endpoint.type === "mutation" && !endpoint.guards.some((name) => name !== "Public"))
80
73
  return "a mutation needs a real guard — `[Public]` is having none, spelled out.";
81
74
  const opaque = endpoint.args.find((arg) => !isMcpDescribableArg(arg) && arg.type !== "search" && !arg.nullable);
82
75
  if (opaque)
@@ -31,18 +31,24 @@ type Purified<O> = O extends BaseObject
31
31
  : O extends object
32
32
  ? PurifiedModel<O>
33
33
  : O;
34
- type PurifiedWithObjectToId<T, StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> = {
35
- [K in StateKeys as null extends T[K] ? never : K]: Purified<T[K]>;
36
- } & {
37
- [K in StateKeys as null extends T[K] ? K : never]?: Purified<T[K]> | undefined;
38
- };
39
- export type PurifiedModel<T> = T extends (infer S)[]
40
- ? PurifiedModel<S>[]
41
- : T extends string | number | boolean | Dayjs | File
34
+ type PurifiedWithObjectToId<T, StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> =
35
+
36
+ unknown extends T
42
37
  ? T
43
- : T extends Map<infer K, infer V>
44
- ? Map<K, PurifiedModel<V>>
45
- : PurifiedWithObjectToId<T>;
38
+ : {
39
+ [K in StateKeys as null extends T[K] ? never : K]: Purified<T[K]>;
40
+ } & {
41
+ [K in StateKeys as null extends T[K] ? K : never]?: Purified<T[K]> | undefined;
42
+ };
43
+ export type PurifiedModel<T> = unknown extends T
44
+ ? T
45
+ : T extends (infer S)[]
46
+ ? PurifiedModel<S>[]
47
+ : T extends string | number | boolean | Dayjs | File
48
+ ? T
49
+ : T extends Map<infer K, infer V>
50
+ ? Map<K, PurifiedModel<V>>
51
+ : PurifiedWithObjectToId<T>;
46
52
 
47
53
  export type UploadableClientArg<T> = [T] extends [File[]] ? File[] | FileList : T;
48
54
 
package/constant/types.ts CHANGED
@@ -14,18 +14,20 @@ type ObjectToId<O> = O extends BaseObject
14
14
  ? DocumentModel<O>
15
15
  : O;
16
16
 
17
- type Docify<T, _StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> = {
18
- [K in _StateKeys as null extends T[K] ? never : K]-?: ObjectToId<NonNullable<T[K]>>;
19
- } & {
20
- [K in _StateKeys as null extends T[K] ? K : never]?: ObjectToId<NonNullable<T[K]>> | undefined;
21
- };
22
- export type DocumentModel<T> = T extends (infer S)[]
23
- ? DocumentModel<S>[]
24
- : T extends string | number | boolean | Dayjs | File
25
- ? T
26
- : T extends Map<infer K, infer V>
27
- ? Map<K, DocumentModel<V>>
28
- : Docify<T>;
17
+ type Docify<T, _StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> = unknown extends T
18
+ ? T
19
+ : { [K in _StateKeys as null extends T[K] ? never : K]-?: ObjectToId<NonNullable<T[K]>> } & {
20
+ [K in _StateKeys as null extends T[K] ? K : never]?: ObjectToId<NonNullable<T[K]>> | undefined;
21
+ };
22
+ export type DocumentModel<T> = unknown extends T
23
+ ? T
24
+ : T extends (infer S)[]
25
+ ? DocumentModel<S>[]
26
+ : T extends string | number | boolean | Dayjs | File
27
+ ? T
28
+ : T extends Map<infer K, infer V>
29
+ ? Map<K, DocumentModel<V>>
30
+ : Docify<T>;
29
31
 
30
32
  export type FieldState<T> = T extends { id: string } ? T | null : T;
31
33
  export type DefaultOf<S> = GetStateObject<{ [K in keyof S]: FieldState<S[K]> }>;
@@ -56,17 +56,6 @@ type DictEndpointShape<Endpoint> =
56
56
  : Endpoint extends Record<string, EndpointInfo>
57
57
  ? EndpointCompactShape<Endpoint>
58
58
  : Record<never, never>;
59
- /**
60
- * The actions of a store, which is every method on it that dispatches.
61
- *
62
- * Derived from the shape rather than from a marker like `ENDPOINT_DICT_SHAPE`, because a store's custom actions
63
- * are declared on the subclass — after `store()` has already returned, so there is nothing for it to stamp. What
64
- * makes the shape readable anyway is that `st.do.<action>()` is typed `void`: an action returns nothing, so the
65
- * void-returning methods are exactly the actions and `get` / `pick` / `slice` fall out on their own.
66
- */
67
- type DictStoreShape<Store> = {
68
- [K in keyof Store as Store[K] extends (...args: never[]) => void | Promise<void> ? K & string : never]: true;
69
- };
70
59
  type DictArgNames<ArgNames> = ArgNames extends readonly string[] ? ArgNames[number] : never;
71
60
  type DictFilterQuery<Filter> = DictFilterShape<Filter>["query"];
72
61
  type DictFilterSort<Filter> = DictFilterShape<Filter>["sort"];
@@ -151,7 +140,6 @@ export class ModelDictInfo<
151
140
  BaseSignalKey extends string = never,
152
141
  SliceKey extends string = "",
153
142
  EndpointKey extends string = never,
154
- StoreKey extends string = never,
155
143
  ErrorKey extends string = never,
156
144
  EtcKey extends string = never,
157
145
  > {
@@ -257,9 +245,6 @@ export class ModelDictInfo<
257
245
  endpointDictionary: { [K in EndpointKey]: FunctionTranslation<Languages> } = {} as {
258
246
  [K in EndpointKey]: FunctionTranslation<Languages>;
259
247
  };
260
- storeDictionary: { [K in StoreKey]: FieldTranslation<Languages> } = {} as {
261
- [K in StoreKey]: FieldTranslation<Languages>;
262
- };
263
248
  errorDictionary: { [K in ErrorKey]: Languages } = {} as {
264
249
  [K in ErrorKey]: Languages;
265
250
  };
@@ -293,7 +278,6 @@ export class ModelDictInfo<
293
278
  BaseSignalKey,
294
279
  SliceKey,
295
280
  EndpointKey,
296
- StoreKey,
297
281
  ErrorKey,
298
282
  EtcKey
299
283
  >;
@@ -318,7 +302,6 @@ export class ModelDictInfo<
318
302
  BaseSignalKey,
319
303
  SliceKey,
320
304
  EndpointKey,
321
- StoreKey,
322
305
  ErrorKey,
323
306
  EtcKey
324
307
  >;
@@ -344,7 +327,6 @@ export class ModelDictInfo<
344
327
  BaseSignalKey,
345
328
  SliceKey,
346
329
  EndpointKey,
347
- StoreKey,
348
330
  ErrorKey,
349
331
  EtcKey
350
332
  >;
@@ -369,7 +351,6 @@ export class ModelDictInfo<
369
351
  BaseSignalKey,
370
352
  SliceKey,
371
353
  EndpointKey,
372
- StoreKey,
373
354
  ErrorKey,
374
355
  EtcKey
375
356
  >;
@@ -394,7 +375,6 @@ export class ModelDictInfo<
394
375
  BaseSignalKey,
395
376
  SliceKey,
396
377
  EndpointKey,
397
- StoreKey,
398
378
  ErrorKey,
399
379
  EtcKey
400
380
  >;
@@ -420,7 +400,6 @@ export class ModelDictInfo<
420
400
  BaseSignalKey,
421
401
  keyof DictSliceShape<Slice> & string,
422
402
  EndpointKey,
423
- StoreKey,
424
403
  ErrorKey,
425
404
  EtcKey
426
405
  >;
@@ -446,41 +425,6 @@ export class ModelDictInfo<
446
425
  BaseSignalKey,
447
426
  SliceKey,
448
427
  keyof DictEndpointShape<Endpoint> & string,
449
- StoreKey,
450
- ErrorKey,
451
- EtcKey
452
- >;
453
- }
454
- /**
455
- * What a store's custom actions are called, in the words a person would use.
456
- *
457
- * Optional, and the only stage that is: an action whose name matches the endpoint it wraps already reads as
458
- * that endpoint's `.desc()`, which is most of them — the house naming rule (`st.do.X` reads the same as
459
- * `fetch.X`) is what makes that true. This stage is for the rest, where inheriting the endpoint's words would
460
- * be actively wrong: nine `getSummaryListIn*` actions that all call one endpoint, or `logout` over
461
- * `signoutUser`, where the store name is the verb a user would say and the endpoint name is the verb the API
462
- * has. `akan quality scan` names those and no others.
463
- *
464
- * Labels only, no `.arg()`. Parameter names are not in a class's type the way an endpoint builder's are, and an
465
- * action mostly takes none anyway — its data comes from the form state the user already filled in.
466
- */
467
- store<Store>(
468
- translate: (t: (trans: Languages) => FieldTranslation<Languages>) => Partial<{
469
- [K in keyof DictStoreShape<Store>]: FieldTranslation<Languages>;
470
- }>,
471
- ) {
472
- Object.assign(this.storeDictionary, translate(FieldTranslation.translate));
473
- return this as unknown as ModelDictInfo<
474
- Languages,
475
- ModelKey,
476
- InsightKey,
477
- QueryKey,
478
- SortKey,
479
- EnumKey,
480
- BaseSignalKey,
481
- SliceKey,
482
- EndpointKey,
483
- StoreKey | (keyof DictStoreShape<Store> & string),
484
428
  ErrorKey,
485
429
  EtcKey
486
430
  >;
@@ -497,7 +441,6 @@ export class ModelDictInfo<
497
441
  BaseSignalKey,
498
442
  SliceKey,
499
443
  EndpointKey,
500
- StoreKey,
501
444
  ErrorKey | (keyof ErrorDict & string),
502
445
  EtcKey
503
446
  >;
@@ -514,7 +457,6 @@ export class ModelDictInfo<
514
457
  BaseSignalKey,
515
458
  SliceKey,
516
459
  EndpointKey,
517
- StoreKey,
518
460
  ErrorKey,
519
461
  EtcKey | (keyof EtcDict & string)
520
462
  >;
@@ -531,7 +473,6 @@ export class ModelDictInfo<
531
473
  GetBaseSignalKey<RefName>,
532
474
  SliceKey,
533
475
  EndpointKey,
534
- StoreKey,
535
476
  ErrorKey,
536
477
  EtcKey
537
478
  >;
@@ -555,7 +496,6 @@ export class ModelDictInfo<
555
496
  this.#registerBaseSignalToRoot(refName, rootDict);
556
497
  this.#registerSliceToRoot(refName, rootDict);
557
498
  this.#registerEndpointToRoot(refName, rootDict);
558
- this.#registerStoreToRoot(refName, rootDict);
559
499
  this.#registerErrorToRoot(refName, rootDict);
560
500
  this.#registerModelToRoot(refName, rootDict);
561
501
  this.#registerEtcToRoot(refName, rootDict);
@@ -838,22 +778,6 @@ export class ModelDictInfo<
838
778
  });
839
779
  }
840
780
  /** Under its own `store` node rather than beside `signal`, because the two hold the same key by design. */
841
- #registerStoreToRoot(refName: string, rootDict: RootDictionary) {
842
- this.languages.forEach((language) => {
843
- ensureNode(getRootModelNode(rootDict, language, refName), "store");
844
- });
845
- Object.entries(this.storeDictionary as { [key: string]: FieldTranslation<Languages> }).forEach(([key, value]) => {
846
- value.trans.forEach((t, idx) => {
847
- ensureNode(ensureNode(getTranslatedRootModelNode(rootDict, this.languages, idx, refName), "store"), key).t = t;
848
- });
849
- value.descTrans?.forEach((t, idx) => {
850
- ensureNode(
851
- ensureNode(ensureNode(getTranslatedRootModelNode(rootDict, this.languages, idx, refName), "store"), key),
852
- "desc",
853
- ).t = t;
854
- });
855
- });
856
- }
857
781
  #registerErrorToRoot(refName: string, rootDict: RootDictionary) {
858
782
  this.languages.forEach((language) => {
859
783
  ensureNode(getRootModelNode(rootDict, language, refName), "error");
@@ -883,12 +807,11 @@ export class ModelDictInfo<
883
807
  /**
884
808
  * Every parameter of `ModelDictInfo` is listed here positionally, so a parameter added to the class has to be
885
809
  * added to all three lists below in the same slot. Omitting one does not fail to compile — inference silently
886
- * shifts, so the last parameter falls off the end and becomes its default `never`: adding `StoreKey` before
887
- * `ErrorKey` once cost an extending app the whole of the lib's `EtcKey` (`.translate()`) union, which reads at
888
- * the call site as `l("<model>.<key>")` no longer existing.
810
+ * shifts, so the last parameter falls off the end and becomes its default `never`, which reads at the call site
811
+ * as `l("<model>.<key>")` no longer existing.
889
812
  */
890
813
 
891
- type AnyModelDictInfo = ModelDictInfo<any, any, any, any, any, any, any, any, any, any, any, any>;
814
+ type AnyModelDictInfo = ModelDictInfo<any, any, any, any, any, any, any, any, any, any, any>;
892
815
 
893
816
  type MergeTwoModelDicts<ModelDict1, ModelDict2> =
894
817
  ModelDict1 extends ModelDictInfo<
@@ -901,7 +824,6 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
901
824
  infer BaseSignalKey1,
902
825
  infer SliceKey1,
903
826
  infer EndpointKey1,
904
- infer StoreKey1,
905
827
  infer ErrorKey1,
906
828
  infer EtcKey1
907
829
  >
@@ -915,7 +837,6 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
915
837
  infer BaseSignalKey2,
916
838
  infer SliceKey2,
917
839
  infer EndpointKey2,
918
- infer StoreKey2,
919
840
  infer ErrorKey2,
920
841
  infer EtcKey2
921
842
  >
@@ -929,7 +850,6 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
929
850
  BaseSignalKey1 | BaseSignalKey2,
930
851
  SliceKey1 | SliceKey2,
931
852
  EndpointKey1 | EndpointKey2,
932
- StoreKey1 | StoreKey2,
933
853
  ErrorKey1 | ErrorKey2,
934
854
  EtcKey1 | EtcKey2
935
855
  >
@@ -168,14 +168,6 @@ export type ModelTrans<
168
168
  }>;
169
169
  error: { [K in ErrorKey]: Trans };
170
170
  } & { [K in EtcKey]: Trans };
171
- /**
172
- * Store action labels, keyed off the resolved `.store()` keys rather than off the store class.
173
- *
174
- * The endpoint half needs the class because it reads argument names out of it. A store entry is a label and a
175
- * description with no arguments to name, so the keys the stage already resolved are the whole of it — which is
176
- * also why a generated `dict.ts` passes no store type at all.
177
- */
178
- export type StoreTranslatorKey<T extends string, StoreKey extends string> = `${T}.store.${StoreKey}${"" | ".desc"}`;
179
171
  export type ModelTranslatorKey<T extends string, Model, Insight, Filter, Slice, Endpoint, EtcKey extends string> =
180
172
  | `${T}.modelName`
181
173
  | `${T}.modelDesc`
@@ -242,14 +234,11 @@ export const registerModelTrans = <
242
234
  infer _BaseSignalKey,
243
235
  infer _SliceKey,
244
236
  infer _EndpointKey,
245
- infer StoreKey,
246
237
  infer ErrorKey,
247
238
  infer EtcKey
248
239
  >
249
240
  ? DictModule<
250
- | ModelTranslatorKey<RefName, Model, Insight, Filter, Slice, Endpoint, EtcKey>
251
- | EnumTranslatorKey<EnumKey>
252
- | StoreTranslatorKey<RefName, StoreKey>,
241
+ ModelTranslatorKey<RefName, Model, Insight, Filter, Slice, Endpoint, EtcKey> | EnumTranslatorKey<EnumKey>,
253
242
  `${RefName}.error.${ErrorKey}`
254
243
  >
255
244
  : never => {
@@ -263,14 +252,11 @@ export const registerModelTrans = <
263
252
  infer _BaseSignalKey,
264
253
  infer _SliceKey,
265
254
  infer _EndpointKey,
266
- infer StoreKey,
267
255
  infer ErrorKey,
268
256
  infer EtcKey
269
257
  >
270
258
  ? DictModule<
271
- | ModelTranslatorKey<RefName, Model, Insight, Filter, Slice, Endpoint, EtcKey>
272
- | EnumTranslatorKey<EnumKey>
273
- | StoreTranslatorKey<RefName, StoreKey>,
259
+ ModelTranslatorKey<RefName, Model, Insight, Filter, Slice, Endpoint, EtcKey> | EnumTranslatorKey<EnumKey>,
274
260
  `${RefName}.error.${ErrorKey}`
275
261
  >
276
262
  : never;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.14",
3
+ "version": "3.0.0-alpha.16",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -38,16 +38,16 @@ export interface AkanServerProps extends AkanLibProps {
38
38
 
39
39
  export interface AkanServerOptions {
40
40
  openapi?: boolean;
41
- /** `true` mounts `/mcp` with everything its endpoints opted into; the object form adds the read-only valve. */
41
+ /** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
42
42
  mcp?: boolean | McpServerOption;
43
43
  }
44
44
 
45
45
  export interface McpServerOption {
46
46
  enabled?: boolean;
47
47
  /**
48
- * Drops every mutation from the catalogue whatever it opted into — for a deployment that must not be able to
49
- * write, such as a read replica or a demo. Off by default: `mcp: { expose: true }` plus the endpoint's guards
50
- * are the decision, and this switch cannot tell its author why their endpoint vanished.
48
+ * Drops every mutation from the catalogue whatever its guards allow — for a deployment that must not be able to
49
+ * write, such as a read replica or a demo. Off by default, and reported per endpoint in the boot log, because a
50
+ * switch that silently unlists a published endpoint cannot tell its author why it vanished.
51
51
  */
52
52
  readOnly?: boolean;
53
53
  /**
@@ -116,7 +116,7 @@ export class AkanServer {
116
116
  prefix = "/api";
117
117
  websocketPrefix = "/ws";
118
118
  openapi = AkanServer.#isOpenApiEnvEnabled();
119
- mcp = AkanServer.#isEnvEnabled("AKAN_MCP", "AKAN_PUBLIC_MCP");
119
+ mcp = AkanServer.#isEnvOn("AKAN_MCP", "AKAN_PUBLIC_MCP");
120
120
  mcpReadOnly = AkanServer.#isEnvEnabled("AKAN_MCP_READONLY", "AKAN_PUBLIC_MCP_READONLY");
121
121
  mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
122
122
  mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
@@ -593,6 +593,17 @@ export class AkanServer {
593
593
  return names.some((name) => process.env[name] === "true" || process.env[name] === "1");
594
594
  }
595
595
 
596
+ /**
597
+ * On unless the env says otherwise, which is the opposite of `#isEnvEnabled`.
598
+ *
599
+ * MCP exposure follows an endpoint's guards rather than an opt-in, so there is nothing an app has to declare for
600
+ * its catalogue to be right — and a switch that must be found before anything works is a switch most deployments
601
+ * never find. `AKAN_MCP=false` is the way off.
602
+ */
603
+ static #isEnvOn(...names: string[]) {
604
+ return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
605
+ }
606
+
596
607
  /** Both differ per environment, so they belong in env rather than in the app's source alongside the switch. */
597
608
  static #mcpAuthFromEnv(): McpAuthOption {
598
609
  const authorizationServers = AkanServer.#envList("AKAN_MCP_AUTH_SERVERS");
@@ -118,47 +118,34 @@ export class McpRouter {
118
118
  }
119
119
 
120
120
  /**
121
- * Says once, at boot, what this build actually published — and names every endpoint whose author opted in and
122
- * was kept out anyway.
121
+ * Says once, at boot, what this build actually published — and names every endpoint that was kept out.
123
122
  *
124
- * The rejections behind those are fail-closed by design: an endpoint MCP cannot carry is simply not in the
125
- * catalogue. That is the right default and the wrong silence a deliberate `mcp: { expose: true }` that
126
- * vanished left its author nowhere to look but the framework source, and the read-only valve had the same hole
127
- * called out when it was given its default. A refusal turns on a resolved return type, and on whether a guard
128
- * list holds anything more than `Public`, so it is the one class of these that reads only from here.
123
+ * The rejections are fail-closed by design: an endpoint MCP cannot carry, or whose guards do not admit it, is
124
+ * simply not in the catalogue. That is the right default and the wrong silence, and it matters more now that
125
+ * exposure follows the guards nobody wrote an opt-in whose absence would explain a missing tool, so this log
126
+ * is the only place the answer exists. A refusal turns on a resolved return type and a resolved guard list, so
127
+ * it reads only from here.
129
128
  *
130
- * An entry published with no description rides here because the scanner finds only a literal
131
- * `mcp: { expose: true }` inside a builder call never a hoisted option, an `expose: flag`, or a slice's verb
132
- * map and the text every generated entry borrows is a *model* `.desc()`, which no source rule would read as
133
- * that entry's description. This holds the resolved catalogue, so it can simply look.
134
- *
135
- * So does an entry that declares no guards. Its access is what an explicit `[Public]` would grant, so it is not
136
- * a refusal — but nobody decided it, and a named slice inherits no `get:` from the slice call, which is the way
137
- * that happens without anyone writing it down. `akan quality scan` names that shape too
138
- * (`akan.mcp.unguarded-exposure`), the omission being syntactic; what is left for here is every exposure the
139
- * scanner cannot read as a literal, and the generated entries it skips.
129
+ * An entry published with no description rides here for the same reason: the text every generated entry borrows
130
+ * is a *model* `.desc()`, which no source rule would read as that entry's description. This holds the resolved
131
+ * catalogue, so it can simply look.
140
132
  *
141
133
  * Called by whatever mounts the router rather than from `createRoutes`, so building a router to answer one
142
134
  * request — which tests and tooling do — does not narrate a catalogue nobody asked about.
143
135
  */
144
136
  report() {
145
137
  try {
146
- const { tools, prompts, resourceTemplates, refusals, undescribed, unguarded } = this.#getDocument();
138
+ const { tools, prompts, resourceTemplates, refusals, undescribed } = this.#getDocument();
147
139
  const counts = `tools=${tools.length} prompts=${prompts.length} resourceTemplates=${resourceTemplates.length}`;
148
140
  McpRouter.logger.info(`MCP catalogue: ${counts}${this.#props.readOnly ? " (read-only deployment)" : ""}`);
149
141
 
150
142
  if (!tools.length && !prompts.length)
151
143
  McpRouter.logger.warn(
152
- "MCP is enabled but nothing opted in. Add `mcp: { expose: true }` to an endpoint, or `mcp: { get: true }` to a slice.",
144
+ "MCP is enabled but published nothing. Every candidate was refused; see the reasons below.",
153
145
  );
154
146
  for (const { key, reason } of refusals) McpRouter.logger.warn(`MCP did not expose "${key}": ${reason}`);
155
147
  for (const { key, reason } of undescribed)
156
148
  McpRouter.logger.warn(`MCP exposed "${key}" with no description: ${reason}`);
157
- for (const key of unguarded)
158
- McpRouter.logger.warn(
159
- `MCP exposed "${key}", which declares no guards. Write \`guards: [Public]\` if anonymous reads are the intent — ` +
160
- `a slice's \`guards: { get: … }\` reaches base CRUD and the root slice, never a named slice.`,
161
- );
162
149
  } catch (error) {
163
150
 
164
151
  McpRouter.logger.warn(
package/signal/guard.ts CHANGED
@@ -10,17 +10,19 @@ export interface Guard {
10
10
  * with no arguments — which is what lets a catalogue hide what the caller certainly cannot use. `resource` needs
11
11
  * the call's arguments and fails closed without them, so evaluating one early would erase legitimate entries.
12
12
  *
13
- * Unmarked means `resource`. That is the safe default for listing (the entry stays visible and is stopped at
14
- * call time), and it is why the marker can be retrofitted one guard at a time.
13
+ * Required, with no default. Exposure is decided by a guard rather than by an opt-in, so an unmarked guard would
14
+ * silently take the `resource` path and list its endpoint to every caller the whole guarded surface's names,
15
+ * refused only at call time. There is no safe guess here, so the author states it.
15
16
  */
16
17
  export type GuardScope = "account" | "resource";
17
18
 
18
- export type GuardCls<Name extends string = string> = Cls<Guard, { readonly name: Name; readonly scope?: GuardScope }>;
19
+ export type GuardCls<Name extends string = string> = Cls<Guard, { readonly name: Name; readonly scope: GuardScope }>;
19
20
 
20
21
  /** Creates a named guard base class for signal access checks. */
21
22
  export const guard = <T extends string>(name: T): GuardCls<T> => {
22
23
  return class Guard {
23
24
  static name = name;
25
+ static scope: GuardScope = "account";
24
26
  canPass(context: SignalContext): PromiseOrObject<boolean> {
25
27
  return true;
26
28
  }