@cosmicdrift/kumiko-bundled-features 0.400.0 → 0.402.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.
Files changed (51) hide show
  1. package/dist/agent-tools/agent-manifest.js +123 -46
  2. package/dist/agent-tools/tool-catalog.d.ts +6 -0
  3. package/dist/agent-tools/tool-catalog.js +10 -11
  4. package/dist/agent-tools/types.d.ts +11 -10
  5. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +2 -2
  6. package/dist/documents/entity.d.ts +2 -0
  7. package/dist/documents/entity.js +4 -1
  8. package/dist/documents/feature.d.ts +14 -1
  9. package/dist/documents/feature.js +15 -4
  10. package/dist/documents/handlers/import.write.js +1 -1
  11. package/dist/documents/handlers/link.write.js +2 -0
  12. package/dist/documents/i18n.js +14 -0
  13. package/dist/documents/index.d.ts +1 -0
  14. package/dist/documents/tab.js +2 -2
  15. package/dist/documents/target-write-check.js +10 -3
  16. package/dist/inbound-mail-foundation/entities.js +1 -0
  17. package/dist/inbound-mail-foundation/watch-lease.d.ts +1 -1
  18. package/dist/inbound-mail-foundation/watch-lease.js +2 -2
  19. package/dist/inbound-mail-foundation/watch-supervisor-types.d.ts +5 -5
  20. package/dist/inbound-mail-foundation/watch-supervisor.js +5 -7
  21. package/dist/managed-pages/feature.js +9 -1
  22. package/dist/managed-pages/render-page-body.d.ts +12 -1
  23. package/dist/managed-pages/render-page-body.js +51 -14
  24. package/dist/managed-pages/screens/branding-screen.js +1 -1
  25. package/dist/managed-pages/web/i18n.js +1 -2
  26. package/dist/managed-pages/web/lossy-markdown.d.ts +1 -1
  27. package/dist/managed-pages/web/lossy-markdown.js +2 -16
  28. package/dist/managed-pages/web/markdown-to-document.js +2 -2
  29. package/dist/managed-pages/web/page-body-section.js +0 -1
  30. package/dist/readiness/handlers/run-list.query.js +2 -1
  31. package/dist/readiness/run-view.d.ts +4 -7
  32. package/dist/template-resolver/compile-document-body.js +2 -2
  33. package/dist/template-resolver/document-sanitizer.js +3 -3
  34. package/dist/template-resolver/index.d.ts +1 -1
  35. package/dist/template-resolver/index.js +1 -1
  36. package/dist/template-resolver/web/document-schema.d.ts +1 -0
  37. package/dist/template-resolver/web/document-schema.js +16 -1
  38. package/dist/template-resolver/web/i18n.js +2 -2
  39. package/dist/template-resolver/web/use-collection-entry-create.js +4 -3
  40. package/dist/user-data-rights/boot-checks.js +38 -31
  41. package/dist/user-data-rights/boot-rules.d.ts +17 -0
  42. package/dist/user-data-rights/boot-rules.js +17 -0
  43. package/dist/user-data-rights/web/i18n.js +4 -0
  44. package/dist/user-data-rights/web/request-deletion-screen.js +3 -3
  45. package/package.json +11 -11
  46. package/src/agent-tools/changes.json +19 -0
  47. package/src/inbound-mail-foundation/changes.json +7 -0
  48. package/src/managed-pages/changes.json +6 -0
  49. package/src/readiness/changes.json +6 -0
  50. package/src/template-resolver/changes.json +12 -0
  51. package/src/user-data-rights/changes.json +12 -0
@@ -3,6 +3,23 @@ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-type
3
3
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import * as z from "zod";
5
5
  import { recordManifestDenyQns } from "./manifest-deny-qns.js";
6
+ import { isExplicitlyAgentHidden } from "./tool-catalog.js";
7
+ const FALLBACK_LOCALE = "en";
8
+ /** Requested locale, then English, then the first remaining locale in code-point
9
+ * order; `undefined` when the entry has no text at all (the key is then omitted). */
10
+ function pickLabel(labels, locale) {
11
+ const nonEmpty = (value) => value !== undefined && value !== "" ? value : undefined;
12
+ const firstLocale = Object.keys(labels)
13
+ .sort(compareIds)
14
+ .find((key) => nonEmpty(labels[key]));
15
+ return (nonEmpty(labels[locale]) ??
16
+ nonEmpty(labels[FALLBACK_LOCALE]) ??
17
+ (firstLocale !== undefined ? labels[firstLocale] : undefined));
18
+ }
19
+ function labelProp(labels, locale) {
20
+ const label = pickLabel(labels, locale);
21
+ return label === undefined ? {} : { label };
22
+ }
6
23
  /** The manifest surfaces one string per locale — a plural entry reads as its
7
24
  * CLDR `other` form since the manifest has no `count` to select a category. */
8
25
  function entryAsLabels(entry) {
@@ -73,13 +90,13 @@ function isFilterableField(field) {
73
90
  function isPiiField(field) {
74
91
  return "pii" in field && field.pii === true;
75
92
  }
76
- function buildField(entityName, fieldName, field, translations, searchableFields) {
93
+ function buildField(entityName, fieldName, field, translations, searchableFields, locale) {
77
94
  const options = field.type === "select" || field.type === "multiSelect" ? field.options : undefined;
78
95
  const references = field.type === "reference" ? field.entity : undefined;
79
96
  return {
80
97
  name: fieldName,
81
98
  type: field.type,
82
- labels: labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`),
99
+ ...labelProp(labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`), locale),
83
100
  ...(field.description !== undefined && { description: field.description }),
84
101
  ...(options !== undefined && { options }),
85
102
  ...(references !== undefined && { references }),
@@ -88,29 +105,45 @@ function buildField(entityName, fieldName, field, translations, searchableFields
88
105
  ...(isPiiField(field) && { pii: true }),
89
106
  };
90
107
  }
91
- /** Entity schemas are emitted unfiltered by design: roles gate handlers, screens,
92
- * navs and workspaces, while the handler pipeline gates the values themselves.
93
- * The manifest describes shape, never data. */
94
- function buildEntities(entities, translations, getSearchableFields) {
108
+ /** Shape, never data. Only entities the role reaches are emitted: the manifest is
109
+ * prompt payload, so every extra schema costs tokens on each turn. */
110
+ function buildEntities(entities, translations, getSearchableFields, visibleEntityNames, locale) {
95
111
  const result = [];
96
112
  for (const [entityName, entity] of entities) {
113
+ if (!visibleEntityNames.has(entityName))
114
+ continue;
97
115
  const searchableFields = new Set(getSearchableFields(entityName));
98
- const fields = Object.entries(entity.fields).map(([fieldName, field]) => buildField(entityName, fieldName, field, translations, searchableFields));
116
+ const fields = Object.entries(entity.fields).map(([fieldName, field]) => buildField(entityName, fieldName, field, translations, searchableFields, locale));
99
117
  result.push({
100
118
  name: entityName,
101
- labels: labelsForSuffix(translations, `:entity:${entityName}`),
119
+ ...labelProp(labelsForSuffix(translations, `:entity:${entityName}`), locale),
102
120
  ...(entity.description !== undefined && { description: entity.description }),
103
121
  fields,
104
122
  });
105
123
  }
106
124
  return result;
107
125
  }
108
- function buildFeatures(features) {
126
+ const FEATURE_DESCRIPTION_MAX_CHARS = 300;
127
+ // Only ". " before a capital ends the sentence, so "e.g. the" and "Art. 15" stay whole.
128
+ const SENTENCE_END = /\.\s+(?=\p{Lu})/u;
129
+ /** The manifest is prompt payload; the full text stays in `FeatureDefinition.description`
130
+ * for the docs generators. */
131
+ function leadSentence(description) {
132
+ const text = description.replace(/\s+/g, " ").trim();
133
+ const end = SENTENCE_END.exec(text);
134
+ const sentence = end === null ? text : text.slice(0, end.index + 1);
135
+ return sentence.slice(0, FEATURE_DESCRIPTION_MAX_CHARS);
136
+ }
137
+ function buildFeatures(features, visibleFeatureNames) {
109
138
  const result = [];
110
139
  for (const [, feature] of features) {
140
+ if (!visibleFeatureNames.has(feature.name))
141
+ continue;
111
142
  result.push({
112
143
  name: feature.name,
113
- ...(feature.description !== undefined && { description: feature.description }),
144
+ ...(feature.description !== undefined && {
145
+ description: leadSentence(feature.description),
146
+ }),
114
147
  ...(feature.uiHints?.displayLabel !== undefined && {
115
148
  displayLabel: feature.uiHints.displayLabel,
116
149
  }),
@@ -152,7 +185,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
152
185
  return undefined;
153
186
  // `agent.expose: true` without a `description` still opts in — the author
154
187
  // asked for exposure explicitly, there's just nothing to show as prose.
155
- const description = def.description ?? "";
188
+ const description = def.description !== undefined && def.description !== "" ? def.description : undefined;
156
189
  let inputSchema;
157
190
  try {
158
191
  // A handler whose schema can't be expressed as JSON Schema (transforms,
@@ -168,7 +201,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
168
201
  return {
169
202
  qn,
170
203
  kind,
171
- description,
204
+ ...(description !== undefined && { description }),
172
205
  risk: exposure.risk,
173
206
  inputSchema,
174
207
  ...(entity !== undefined && { entity }),
@@ -199,7 +232,7 @@ function assertDenyQnsExist(denyQns, registry) {
199
232
  throw new Error(`agent denyQns names handlers that are not registered: ${unknown.join(", ")}`);
200
233
  }
201
234
  }
202
- function buildNavs(navs, workspaces, translations, roles, screens) {
235
+ function buildNavs(navs, workspaces, translations, roles, screens, locale) {
203
236
  const result = [];
204
237
  for (const [, nav] of navs) {
205
238
  if (!uiVisible(nav.access, roles))
@@ -215,10 +248,10 @@ function buildNavs(navs, workspaces, translations, roles, screens) {
215
248
  continue;
216
249
  result.push({
217
250
  id: nav.id,
218
- labels: entryAsLabels(translations[nav.label] ?? {}),
251
+ ...labelProp(entryAsLabels(translations[nav.label] ?? {}), locale),
219
252
  ...(nav.screen !== undefined && { screen: nav.screen }),
220
253
  ...(nav.parent !== undefined && { parent: nav.parent }),
221
- workspaces: visibleWorkspaces,
254
+ ...(visibleWorkspaces.length > 0 && { workspaces: visibleWorkspaces }),
222
255
  });
223
256
  }
224
257
  return withoutOrphanedNavs(result);
@@ -235,6 +268,46 @@ function withoutOrphanedNavs(navs) {
235
268
  remaining = reachable;
236
269
  }
237
270
  }
271
+ // Qualified handler, screen and nav ids all start with the owning feature name.
272
+ function featureNameOf(qualifiedId) {
273
+ const idx = qualifiedId.indexOf(":");
274
+ return idx === -1 ? qualifiedId : qualifiedId.slice(0, idx);
275
+ }
276
+ function visibleFeatureNamesOf(handlers, screens, navs) {
277
+ return new Set([...handlers.map((h) => h.qn), ...screens.map((s) => s.id), ...navs.map((n) => n.id)].map(featureNameOf));
278
+ }
279
+ function entitiesOfQueryToolsFor(registry, roles, denyQns) {
280
+ const names = [];
281
+ for (const [qn, def] of registry.getAllQueryHandlers()) {
282
+ const entityName = registry.getHandlerEntity(qn);
283
+ if (entityName === undefined || denyQns.has(qn) || isExplicitlyAgentHidden(def))
284
+ continue;
285
+ if (hasAccess({ roles }, def.access))
286
+ names.push(entityName);
287
+ }
288
+ return names;
289
+ }
290
+ // A reference target is "entity" or "<feature>:entity"; registry entity keys are the bare name.
291
+ function entityNameOfReference(target) {
292
+ return target.slice(target.lastIndexOf(":") + 1);
293
+ }
294
+ function referenceTargetsOf(entity) {
295
+ return Object.values(entity?.fields ?? {}).flatMap((field) => field.type === "reference" ? [entityNameOfReference(field.entity)] : []);
296
+ }
297
+ /** Reachable = tagged on a manifest handler, shown by a manifest screen, or read by a
298
+ * registry-derived `get_`/`list_` tool (which takes labels from the manifest entity). */
299
+ function visibleEntityNamesOf(registry, roles, denyQns, handlers, screens) {
300
+ const reachable = new Set([
301
+ ...handlers.flatMap((handler) => (handler.entity !== undefined ? [handler.entity] : [])),
302
+ ...screens.flatMap((screen) => [screen.entity ?? [], screen.detailFor ?? []].flat()),
303
+ ...entitiesOfQueryToolsFor(registry, roles, denyQns),
304
+ ]);
305
+ const entities = registry.getAllEntities();
306
+ return new Set([
307
+ ...reachable,
308
+ ...[...reachable].flatMap((n) => referenceTargetsOf(entities.get(n))),
309
+ ]);
310
+ }
238
311
  function lastSegment(qualifiedId) {
239
312
  const idx = qualifiedId.lastIndexOf(":");
240
313
  return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
@@ -242,10 +315,31 @@ function lastSegment(qualifiedId) {
242
315
  function detailIdParam(screen) {
243
316
  return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
244
317
  }
245
- function screenTitles(translations, screenId, shortId) {
246
- return entryAsLabels(translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {});
318
+ function screenTitles(translations, screenId, shortId, locale) {
319
+ const title = pickLabel(entryAsLabels(translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {}), locale);
320
+ return title === undefined ? {} : { title };
321
+ }
322
+ function buildScreenEntry(screen, screenWorkspaces, translations, locale) {
323
+ return {
324
+ id: screen.id,
325
+ type: screen.type,
326
+ ...screenTitles(translations, screen.id, lastSegment(screen.id), locale),
327
+ // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
328
+ // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
329
+ // `labelsForSuffix` does for entity/field labels, else fall back to the
330
+ // literal string as authored.
331
+ ...(screen.description !== undefined && {
332
+ description: pickLabel(labelsForSuffix(translations, screen.description), locale) ?? screen.description,
333
+ }),
334
+ ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
335
+ // A detail screen takes the row id under its idParam; everything else is parameterless.
336
+ ...(screen.detailFor !== undefined && { params: [detailIdParam(screen)] }),
337
+ ...(screenWorkspaces.length > 0 && { workspaces: screenWorkspaces }),
338
+ ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
339
+ ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
340
+ };
247
341
  }
248
- function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
342
+ function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds, locale) {
249
343
  const allNavs = [...navs.values()];
250
344
  const result = [];
251
345
  for (const [, screen] of screens) {
@@ -265,37 +359,19 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
265
359
  });
266
360
  if (!uiVisible(screen.access, roles) || !reachable)
267
361
  continue;
268
- const shortId = lastSegment(screen.id);
269
362
  const screenWorkspaces = dedupeSorted(accessibleNavs.flatMap((nav) => visibleWorkspaceIds(nav.workspaces, workspaces, roles)));
270
- result.push({
271
- id: screen.id,
272
- type: screen.type,
273
- titles: screenTitles(translations, screen.id, shortId),
274
- // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
275
- // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
276
- // `labelsForSuffix` does for entity/field labels, else fall back to the
277
- // literal string as authored.
278
- ...(screen.description !== undefined && {
279
- description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
280
- }),
281
- ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
282
- // A detail screen takes the row id under its idParam; everything else is parameterless.
283
- params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
284
- workspaces: screenWorkspaces,
285
- ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
286
- ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
287
- });
363
+ result.push(buildScreenEntry(screen, screenWorkspaces, translations, locale));
288
364
  }
289
365
  return result;
290
366
  }
291
- function buildWorkspaces(workspaces, translations, roles) {
367
+ function buildWorkspaces(workspaces, translations, roles, locale) {
292
368
  const result = [];
293
369
  for (const [, workspace] of workspaces) {
294
370
  if (!uiVisible(workspace.access, roles))
295
371
  continue;
296
372
  result.push({
297
373
  id: workspace.id,
298
- labels: entryAsLabels(translations[workspace.label] ?? {}),
374
+ ...labelProp(entryAsLabels(translations[workspace.label] ?? {}), locale),
299
375
  ...(workspace.default === true && { default: true }),
300
376
  });
301
377
  }
@@ -310,13 +386,14 @@ export function buildAgentManifest(registry, options) {
310
386
  const navMap = registry.getAllNavs();
311
387
  const { roles } = options;
312
388
  assertDenyQnsExist(options.denyQns ?? [], registry);
313
- const features = buildFeatures(registry.features);
314
- const entities = buildEntities(registry.getAllEntities(), translations, (name) => registry.getSearchableFields(name));
315
- const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), new Set(options.denyQns ?? []));
389
+ const denyQns = new Set(options.denyQns ?? []);
390
+ const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), denyQns);
316
391
  const screenMap = registry.getAllScreens();
317
- const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
318
- const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)));
319
- const workspaces = buildWorkspaces(workspaceMap, translations, roles);
392
+ const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap, options.locale);
393
+ const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)), options.locale);
394
+ const workspaces = buildWorkspaces(workspaceMap, translations, roles, options.locale);
395
+ const features = buildFeatures(registry.features, visibleFeatureNamesOf(handlers, screens, navs));
396
+ const entities = buildEntities(registry.getAllEntities(), translations, (name) => registry.getSearchableFields(name), visibleEntityNamesOf(registry, roles, denyQns, handlers, screens), options.locale);
320
397
  const manifest = {
321
398
  builtForRoles: [...roles],
322
399
  features: sortedByKey(features, (f) => f.name),
@@ -330,6 +407,6 @@ export function buildAgentManifest(registry, options) {
330
407
  ...(options.currency && { currency: options.currency }),
331
408
  },
332
409
  };
333
- recordManifestDenyQns(manifest, new Set(options.denyQns ?? []));
410
+ recordManifestDenyQns(manifest, denyQns);
334
411
  return manifest;
335
412
  }
@@ -1,4 +1,10 @@
1
+ import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import type { AgentManifest, RegistrySearchView, ToolCatalog, ToolCatalogOptions } from "./types.ts";
3
+ /** The entity CRUD tools are enumerated off the registry, not off the manifest, so
4
+ * `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
5
+ * the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
6
+ * handlers never carry one, so reusing it would delete every entity tool in every app. */
7
+ export declare function isExplicitlyAgentHidden(def: QueryHandlerDef): boolean;
2
8
  /** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
3
9
  * sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
4
10
  * QNs are `<feature>:query|write:<entity>:<verb>`, so the verb only ever appears once at that
@@ -72,7 +72,7 @@ function isDetailHandlerQn(qn, entityName) {
72
72
  * `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
73
73
  * the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
74
74
  * handlers never carry one, so reusing it would delete every entity tool in every app. */
75
- function isExplicitlyAgentHidden(def) {
75
+ export function isExplicitlyAgentHidden(def) {
76
76
  return def.agent?.expose === false;
77
77
  }
78
78
  /** The model's filters feed the WHERE clause, and only the output is field-filtered — a field
@@ -105,10 +105,10 @@ function addToolsForListHandler(registry, qn, entityName, entity, roles, sink) {
105
105
  addTool(sink, tool, { kind: "findBy", entityName, fieldName, qn });
106
106
  }
107
107
  }
108
- function entityDisplayLabel(entity, entityName, locale) {
108
+ function entityDisplayLabel(entity, entityName) {
109
109
  if (entity?.description)
110
110
  return entity.description;
111
- const label = entity?.labels[locale];
111
+ const label = entity?.label;
112
112
  if (label)
113
113
  return label;
114
114
  return entityName;
@@ -212,7 +212,7 @@ function stripFieldFromSchema(schema, field) {
212
212
  return { ...rest, properties: strippedProperties, required: strippedRequired };
213
213
  }
214
214
  function handlerDescription(handler) {
215
- return handler.description.length > 0 ? handler.description : `Run ${handler.qn}.`;
215
+ return handler.description ?? `Run ${handler.qn}.`;
216
216
  }
217
217
  /** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
218
218
  * sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
@@ -373,18 +373,18 @@ function collectEntityHandlerQns(registry, roleFilter, denyQns) {
373
373
  }
374
374
  return { detailQnByEntity, listQnByEntity, entityListDetailQns };
375
375
  }
376
- function addGetTools(registry, detailQnByEntity, entityByName, locale, sink) {
376
+ function addGetTools(registry, detailQnByEntity, entityByName, sink) {
377
377
  const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
378
378
  for (const [entityName, qn] of getEntries) {
379
- const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
379
+ const label = entityDisplayLabel(entityByName.get(entityName), entityName);
380
380
  const { tool, descriptor } = buildGetTool(entityName, qn, label, entityHandlerRisk(registry, qn));
381
381
  addTool(sink, tool, descriptor);
382
382
  }
383
383
  }
384
- function addListTools(registry, listQnByEntity, entityByName, roles, locale, sink) {
384
+ function addListTools(registry, listQnByEntity, entityByName, roles, sink) {
385
385
  const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
386
386
  for (const [entityName, qn] of listEntries) {
387
- const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
387
+ const label = entityDisplayLabel(entityByName.get(entityName), entityName);
388
388
  const searchableFields = registry.getSearchableFields(entityName);
389
389
  const filterableFields = filterableFieldsOf(entityByName.get(entityName), registry.getEntity(entityName), roles);
390
390
  const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields, entityHandlerRisk(registry, qn));
@@ -472,14 +472,13 @@ function addClientTools(manifest, mode, sink) {
472
472
  export function buildToolCatalog(registry, manifest, options) {
473
473
  const sink = { tools: [], dispatchTable: new Map(), usedNames: new Set() };
474
474
  const roleFilter = { roles: manifest.builtForRoles };
475
- const locale = manifest.tenantSettings.locale;
476
475
  const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
477
476
  const denyQns = new Set(options.denyQns ?? []);
478
477
  assertCatalogMirrorsManifestDenyQns(manifest, denyQns);
479
478
  addRegistrySearchTools(registry, roleFilter, denyQns, sink);
480
479
  const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
481
- addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
482
- addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, locale, sink);
480
+ addGetTools(registry, detailQnByEntity, entityByName, sink);
481
+ addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, sink);
483
482
  addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
484
483
  if (options.mode !== "read-only") {
485
484
  addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
@@ -105,8 +105,8 @@ export type AgentManifestOptions = {
105
105
  export type AgentManifestField = {
106
106
  readonly name: string;
107
107
  readonly type: string;
108
- /** Label per app locale, keyed by locale code. */
109
- readonly labels: Readonly<Record<string, string>>;
108
+ /** Label in the requested locale (English, then any other, as fallback); absent when untranslated. */
109
+ readonly label?: string;
110
110
  readonly description?: string;
111
111
  readonly options?: readonly string[];
112
112
  /** Entity a `reference` field points at. */
@@ -119,14 +119,14 @@ export type AgentManifestField = {
119
119
  };
120
120
  export type AgentManifestEntity = {
121
121
  readonly name: string;
122
- readonly labels: Readonly<Record<string, string>>;
122
+ readonly label?: string;
123
123
  readonly description?: string;
124
124
  readonly fields: readonly AgentManifestField[];
125
125
  };
126
126
  export type AgentManifestHandler = {
127
127
  readonly qn: string;
128
128
  readonly kind: "query" | "write";
129
- readonly description: string;
129
+ readonly description?: string;
130
130
  readonly risk: AgentRisk;
131
131
  readonly inputSchema: Readonly<Record<string, unknown>>;
132
132
  readonly entity?: string;
@@ -136,11 +136,12 @@ export type AgentManifestHandler = {
136
136
  export type AgentManifestScreen = {
137
137
  readonly id: string;
138
138
  readonly type: string;
139
- readonly titles: Readonly<Record<string, string>>;
139
+ readonly title?: string;
140
140
  readonly description?: string;
141
141
  readonly entity?: string;
142
- readonly params: readonly string[];
143
- readonly workspaces: readonly string[];
142
+ /** Only present on detail screens. */
143
+ readonly params?: readonly string[];
144
+ readonly workspaces?: readonly string[];
144
145
  /** Entity this screen is the detail view for. Distinct from `entity`: a custom
145
146
  * screen can render entity A while being the detail view for B. */
146
147
  readonly detailFor?: string;
@@ -149,14 +150,14 @@ export type AgentManifestScreen = {
149
150
  };
150
151
  export type AgentManifestNav = {
151
152
  readonly id: string;
152
- readonly labels: Readonly<Record<string, string>>;
153
+ readonly label?: string;
153
154
  readonly screen?: string;
154
155
  readonly parent?: string;
155
- readonly workspaces: readonly string[];
156
+ readonly workspaces?: readonly string[];
156
157
  };
157
158
  export type AgentManifestWorkspace = {
158
159
  readonly id: string;
159
- readonly labels: Readonly<Record<string, string>>;
160
+ readonly label?: string;
160
161
  readonly default?: true;
161
162
  };
162
163
  export type AgentManifestFeature = {
@@ -1,7 +1,7 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import { Marked } from "marked";
4
- import { isAllowedDocumentLinkHref, isDocumentPlaceholderHref, } from "../../template-resolver/index.js";
4
+ import { isAllowedDocumentUrlHref, isDocumentPlaceholderHref, } from "../../template-resolver/index.js";
5
5
  import { resolveConsentLocale } from "./consent-text.js";
6
6
  export const CONFIRMATION_LABELS = {
7
7
  de: {
@@ -96,7 +96,7 @@ function formatDate(iso, locale, timeZone) {
96
96
  // row as escaped text, so author HTML, images and refused links stay plain text.
97
97
  const ROW_END = "\n\n";
98
98
  function isMailLinkHref(href) {
99
- return isAllowedDocumentLinkHref(href) && !isDocumentPlaceholderHref(href);
99
+ return isAllowedDocumentUrlHref(href) && !isDocumentPlaceholderHref(href);
100
100
  }
101
101
  const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
102
102
  inlineHtmlMarkdown.use({
@@ -1,8 +1,10 @@
1
1
  import { type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ export type DocumentLinkReadMode = "anyLink" | "allLinks";
2
3
  export type DocumentEntityOptions = {
3
4
  readonly categories: readonly [string, ...string[]];
4
5
  readonly adminRoles: readonly string[];
5
6
  readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
7
+ readonly linkReadMode?: DocumentLinkReadMode;
6
8
  readonly retention?: EntityDefinition["retention"];
7
9
  };
8
10
  export declare function createDocumentEntity(options: DocumentEntityOptions): EntityDefinition<{
@@ -4,6 +4,7 @@ import { DOCUMENT_LINK_ENTITY } from "./constants.js";
4
4
  // Readable only through a link to a host the viewer can read, and only when the file's owner is
5
5
  // readable too (see EntityDefinition.readGate); readAccess narrows further.
6
6
  export function createDocumentEntity(options) {
7
+ const documentLink = { entity: DOCUMENT_LINK_ENTITY, field: "documentId" };
7
8
  return createEntity({
8
9
  table: "read_filed_documents",
9
10
  softDelete: true,
@@ -15,7 +16,9 @@ export function createDocumentEntity(options) {
15
16
  }),
16
17
  ...(options.retention !== undefined && { retention: options.retention }),
17
18
  readGate: {
18
- anyLink: { entity: DOCUMENT_LINK_ENTITY, field: "documentId" },
19
+ ...(options.linkReadMode === "allLinks"
20
+ ? { allLinks: documentLink }
21
+ : { anyLink: documentLink }),
19
22
  fileOwner: { field: "file" },
20
23
  linkExemptRoles: options.adminRoles,
21
24
  },
@@ -1,16 +1,29 @@
1
1
  import { type AccessRule, type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { type DocumentCaptureRule } from "./capture.ts";
3
+ import { type DocumentLinkReadMode } from "./entity.ts";
3
4
  export type DocumentsFeatureOptions = {
4
5
  /** Allowed document categories; they become the options of the category field. */
5
6
  readonly categories: readonly [string, ...string[]];
6
- /** Entities a document can be linked to. Each needs an update handler, which linking requires. */
7
+ /** Entities a document can be linked to. Linking needs write access to the target: its update handler, or its entity write roles when it has none. */
7
8
  readonly linkTargets: readonly [string, ...string[]];
9
+ /** Display label per category (de, en); a category without one shows its raw value. */
10
+ readonly categoryLabels?: Readonly<Record<string, {
11
+ readonly de: string;
12
+ readonly en: string;
13
+ }>>;
8
14
  /** Files saved into these fields become documents automatically. */
9
15
  readonly capture?: readonly DocumentCaptureRule[];
10
16
  /** Retention of the document rows, in the entity's own retention vocabulary. */
11
17
  readonly retention?: EntityDefinition["retention"];
12
18
  /** Row-level read rule on the document, AND-ed with the link and file-owner gate. */
13
19
  readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
20
+ /**
21
+ * "anyLink" (default): one readable linked record shows the document. "allLinks": the viewer
22
+ * must read every record it is linked to, so a link to a shared record (a contact) does not
23
+ * open a document that also hangs off a record of another team. The file-owner check applies
24
+ * either way.
25
+ */
26
+ readonly linkReadMode?: DocumentLinkReadMode;
14
27
  /**
15
28
  * Who may use the document handlers. One rule applies to read, write and delete; the split form
16
29
  * sets them apart. Default TenantAdmin and TenantMember; a missing delete follows write.
@@ -5,7 +5,7 @@
5
5
  import { defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { registerCapture, validateCaptureRules } from "./capture.js";
7
7
  import { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DEFAULT_EXPIRING_WITHIN_DAYS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, } from "./constants.js";
8
- import { createDocumentEntity, createDocumentLinkEntity } from "./entity.js";
8
+ import { createDocumentEntity, createDocumentLinkEntity, } from "./entity.js";
9
9
  import { createDocumentExecutors } from "./executor.js";
10
10
  import { createImportDocumentHandler } from "./handlers/import.write.js";
11
11
  import { createLinkDocumentHandler, createUnlinkDocumentHandler } from "./handlers/link.write.js";
@@ -32,10 +32,10 @@ function resolveAccess(access) {
32
32
  const write = access.write ?? DEFAULT_DOCUMENT_ACCESS;
33
33
  return { read: access.read ?? DEFAULT_DOCUMENT_ACCESS, write, delete: access.delete ?? write };
34
34
  }
35
- function categoryOptionLabels(categories) {
35
+ function categoryOptionLabels(categories, labels) {
36
36
  return Object.fromEntries(categories.map((category) => [
37
37
  `documents:entity:filed-document:field:category:option:${category}`,
38
- { en: category, de: category },
38
+ labels?.[category] ?? { en: category, de: category },
39
39
  ]));
40
40
  }
41
41
  function registerDocuments(r, opts) {
@@ -45,6 +45,7 @@ function registerDocuments(r, opts) {
45
45
  categories: opts.categories,
46
46
  adminRoles: opts.adminRoles ?? DEFAULT_ADMIN_ROLES,
47
47
  ...(opts.readAccess !== undefined && { readAccess: opts.readAccess }),
48
+ ...(opts.linkReadMode !== undefined && { linkReadMode: opts.linkReadMode }),
48
49
  ...(opts.retention !== undefined && { retention: opts.retention }),
49
50
  });
50
51
  const linkEntity = createDocumentLinkEntity(linkTargets);
@@ -58,11 +59,13 @@ function registerDocuments(r, opts) {
58
59
  r.writeHandler(defineEntityCreateHandler(DOCUMENT_ENTITY, documentEntity, {
59
60
  access: access.write,
60
61
  description: "Files a new document from an uploaded file; it stays invisible to everyone but the admin roles until it is linked to a record.",
62
+ agent: { risk: "mid" },
61
63
  }));
62
64
  const writeGuard = createDocumentWriteGuard(executors, opts.adminRoles ?? DEFAULT_ADMIN_ROLES);
63
65
  r.writeHandler(defineEntityUpdateHandler(DOCUMENT_ENTITY, documentEntity, {
64
66
  access: access.write,
65
67
  description: "Edits a document's title, category and dates from the entity-edit envelope; it does not change what the document is linked to.",
68
+ agent: { risk: "mid" },
66
69
  }));
67
70
  r.writeHandler(defineEntityDeleteHandler(DOCUMENT_ENTITY, documentEntity, {
68
71
  access: access.delete,
@@ -100,12 +103,20 @@ function registerDocuments(r, opts) {
100
103
  r.bootCheck((ctx) => validateCaptureRules(ctx, capture, opts.categories, linkTargets));
101
104
  r.screen(createDocumentListScreen(access.read, opts.categories));
102
105
  r.translations({
103
- keys: { ...DOCUMENTS_FEATURE_I18N, ...categoryOptionLabels(opts.categories) },
106
+ keys: {
107
+ ...DOCUMENTS_FEATURE_I18N,
108
+ ...categoryOptionLabels(opts.categories, opts.categoryLabels),
109
+ },
104
110
  });
105
111
  }
106
112
  export function createDocumentsFeature(opts) {
107
113
  if (opts.expiringWithinDays !== undefined && opts.expiringWithinDays < 0) {
108
114
  throw new Error("createDocumentsFeature({ expiringWithinDays }) must not be negative.");
109
115
  }
116
+ for (const category of Object.keys(opts.categoryLabels ?? {})) {
117
+ if (!opts.categories.includes(category)) {
118
+ throw new Error(`createDocumentsFeature({ categoryLabels }) names "${category}", which is not in categories.`);
119
+ }
120
+ }
110
121
  return defineFeature(DOCUMENTS_FEATURE_NAME, (r) => registerDocuments(r, opts));
111
122
  }
@@ -53,7 +53,7 @@ export function createImportDocumentHandler(args) {
53
53
  name: "import",
54
54
  schema: importDocumentPayloadSchema,
55
55
  access: { roles: [SYSTEM_ROLE] },
56
- agent: { expose: false },
56
+ agent: { expose: false, risk: "high" },
57
57
  description: "Creates a document with a caller-chosen id from an existing file, links it to the given records and optionally leaves it deleted; a repeat call with the same id changes nothing, and an id already used by another record fails. The document's createdAt is the time of the import; issuedAt carries the business date. Reserved for the system user, meant for data migrations.",
58
58
  handler: async (event, ctx) => {
59
59
  const payload = event.payload;
@@ -13,6 +13,7 @@ export function createLinkDocumentHandler(args) {
13
13
  name: "link",
14
14
  schema: documentLinkPayloadSchema,
15
15
  access,
16
+ agent: { risk: "mid" },
16
17
  description: "Makes a document visible on another record by linking it to that record; the caller must be able to read the document and edit the record.",
17
18
  handler: async (event, ctx) => {
18
19
  const payload = event.payload;
@@ -37,6 +38,7 @@ export function createUnlinkDocumentHandler(args) {
37
38
  name: "unlink",
38
39
  schema: documentLinkPayloadSchema,
39
40
  access,
41
+ agent: { risk: "mid" },
40
42
  description: "Removes a document from a record it was linked to, reporting success when no link existed; the document and its file stay.",
41
43
  handler: async (event, ctx) => {
42
44
  const payload = event.payload;
@@ -18,6 +18,20 @@ export const DOCUMENTS_FEATURE_I18N = {
18
18
  "documents:list.expiry.expiring": { en: "Expiring soon", de: "Läuft bald ab" },
19
19
  "documents:list.expiry.valid": { en: "Valid", de: "Gültig" },
20
20
  "documents:list.expiry.none": { en: "No expiry date", de: "Ohne Ablaufdatum" },
21
+ "documents:write:link:title": {
22
+ en: "Link document to record",
23
+ de: "Dokument mit Datensatz verknüpfen",
24
+ },
25
+ "documents:write:link:field:documentId": { en: "Document", de: "Dokument" },
26
+ "documents:write:link:field:entityType": { en: "Record type", de: "Datensatztyp" },
27
+ "documents:write:link:field:entityId": { en: "Record", de: "Datensatz" },
28
+ "documents:write:unlink:title": {
29
+ en: "Remove document from record",
30
+ de: "Dokument von Datensatz lösen",
31
+ },
32
+ "documents:write:unlink:field:documentId": { en: "Document", de: "Dokument" },
33
+ "documents:write:unlink:field:entityType": { en: "Record type", de: "Datensatztyp" },
34
+ "documents:write:unlink:field:entityId": { en: "Record", de: "Datensatz" },
21
35
  "documents.errors.unknown-category": {
22
36
  en: "Unknown document category",
23
37
  de: "Unbekannte Dokumentenkategorie",
@@ -1,6 +1,7 @@
1
1
  export { documentIdForFile, documentLinkAggregateId } from "./aggregate-id.ts";
2
2
  export type { DocumentCaptureRule } from "./capture.ts";
3
3
  export { DEFAULT_ADMIN_ROLES, DEFAULT_DOCUMENT_ACCESS, DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY, DOCUMENTS_FEATURE_NAME, DOCUMENTS_LIST_SCREEN_ID, DocumentsHandlers, DocumentsQueries, EXPIRY_STATES, type ExpiryState, } from "./constants.ts";
4
+ export type { DocumentLinkReadMode } from "./entity.ts";
4
5
  export { createDocumentsFeature, type DocumentsAccess, type DocumentsFeatureOptions, } from "./feature.ts";
5
6
  export { forTypeQueryQn } from "./queries/for-record.query.ts";
6
7
  export { DOCUMENT_COLUMNS } from "./screens.ts";