@k2b/cloud 0.29.1 → 0.30.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 (77) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +45 -9
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/message-utils.ts +3 -0
  17. package/src/ai/chat/messages.ts +8 -2
  18. package/src/ai/chat/tool-names.ts +2 -0
  19. package/src/ai/chat/turn-layout.ts +2 -2
  20. package/src/ai/chat/turn-view.tsx +29 -11
  21. package/src/ai/code-runtime-tools.ts +59 -44
  22. package/src/ai/default-tool-contracts.ts +198 -0
  23. package/src/ai/default-tools.ts +25 -1
  24. package/src/ai/executor.ts +19 -21
  25. package/src/ai/index.ts +9 -1
  26. package/src/ai/migrate.ts +59 -12
  27. package/src/ai/runtime-tools.ts +1 -0
  28. package/src/ai/skill-content.ts +54 -0
  29. package/src/ai/skills-routes.ts +22 -13
  30. package/src/ai/skills.ts +177 -231
  31. package/src/ai/tool-sentences.ts +38 -0
  32. package/src/ai/types.ts +8 -1
  33. package/src/api/admin-ai-skills.ts +35 -16
  34. package/src/api/fanout.ts +128 -0
  35. package/src/api/help.ts +8 -3
  36. package/src/api/mcp.ts +4 -1
  37. package/src/api/search.ts +31 -106
  38. package/src/api/widgets.ts +259 -100
  39. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  40. package/src/browser/ndjson.ts +34 -0
  41. package/src/browser/reload.ts +46 -0
  42. package/src/browser/search-stream.ts +4 -26
  43. package/src/browser/widgets.ts +28 -0
  44. package/src/capabilities/server.ts +20 -2
  45. package/src/cli/access.ts +48 -14
  46. package/src/cli/admin/ai-skills.ts +59 -39
  47. package/src/contracts/announcement-cookie.ts +62 -0
  48. package/src/contracts/announcements.ts +14 -52
  49. package/src/contracts/capabilities.ts +24 -0
  50. package/src/contracts/pwa-paths.ts +7 -2
  51. package/src/contracts/pwa.ts +2 -7
  52. package/src/contracts/registry.ts +1 -0
  53. package/src/contracts/shared.ts +5 -0
  54. package/src/contracts/widgets.ts +20 -0
  55. package/src/index.ts +1 -0
  56. package/src/server/help.ts +8 -1
  57. package/src/services/accounts/users.ts +15 -0
  58. package/src/services/help/index.ts +18 -5
  59. package/src/services/help/types.ts +3 -2
  60. package/src/services/identity/constants.ts +2 -0
  61. package/src/services/identity/key-ring.ts +123 -42
  62. package/src/services/identity/runtime-config.ts +13 -2
  63. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  64. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  65. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  66. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  67. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  68. package/src/ssr/PwaRuntime.island.tsx +1 -1
  69. package/src/ssr/app-navigation.ts +5 -7
  70. package/src/ssr/global-search-host.tsx +55 -0
  71. package/src/ssr/help.ts +3 -3
  72. package/src/ssr/rail-context.ts +4 -3
  73. package/src/ssr/runtime.ts +14 -1
  74. package/src/ai/code-mode-skill.ts +0 -114
  75. package/src/ai/data-analysis-skill.ts +0 -10
  76. package/src/ai/grids-skill.ts +0 -158
  77. package/src/ai/skill-seeds.ts +0 -560
package/src/cli/access.ts CHANGED
@@ -28,10 +28,24 @@ export type AccessCommandAdapter<TResource extends AccessResource> = {
28
28
  resourceArgDescription?: string;
29
29
  resolveResource: AccessCommandResourceResolver<TResource>;
30
30
  list: (ctx: CloudCliContext, resource: TResource) => Promise<AccessEntry[]>;
31
- grant: (ctx: CloudCliContext, resource: TResource, principal: Principal, permission: AccessPermission) => Promise<AccessEntry>;
32
- update: (ctx: CloudCliContext, resource: TResource, accessId: string, permission: AccessPermission) => Promise<void>;
31
+ /** `scope` is one of `scopes`, or undefined when the command line names none. */
32
+ grant: (
33
+ ctx: CloudCliContext,
34
+ resource: TResource,
35
+ principal: Principal,
36
+ permission: AccessPermission,
37
+ scope?: string,
38
+ ) => Promise<AccessEntry>;
39
+ /** `scope` is one of `scopes`, or undefined to keep the grant's scope. */
40
+ update: (ctx: CloudCliContext, resource: TResource, accessId: string, permission: AccessPermission, scope?: string) => Promise<void>;
33
41
  revoke: (ctx: CloudCliContext, resource: TResource, accessId: string) => Promise<void>;
34
42
  allowedPermissions?: readonly AccessPermission[];
43
+ /**
44
+ * Parts of the resource a grant can cover, as the resource's access API names them in `scope`:
45
+ * the whole resource first (shown for entries without a scope), then narrower parts. Adds
46
+ * `--scope` to `grant` and `set` and a SCOPE column to `list`.
47
+ */
48
+ scopes?: readonly [string, ...string[]];
35
49
  allowPublic?: boolean;
36
50
  allowAuthenticated?: boolean;
37
51
  allowServiceAccounts?: boolean;
@@ -243,7 +257,7 @@ const entryTypeLabel = (entry: AccessEntry): string => {
243
257
  };
244
258
 
245
259
  // Resource-bound service accounts back resource API keys, which are managed with those keys.
246
- const accessRows = (entries: AccessEntry[], options: { includeServiceAccounts?: boolean } = {}) =>
260
+ const accessRows = (entries: AccessEntry[], options: { includeServiceAccounts?: boolean; scopes?: readonly string[] } = {}) =>
247
261
  entries
248
262
  .filter((entry) => options.includeServiceAccounts || entry.serviceAccountKind !== "resource_bound")
249
263
  .sort((a, b) => {
@@ -256,6 +270,7 @@ const accessRows = (entries: AccessEntry[], options: { includeServiceAccounts?:
256
270
  principal: entryDisplayName(entry),
257
271
  type: entryTypeLabel(entry),
258
272
  permission: entry.permission,
273
+ ...(options.scopes ? { scope: entry.scope ?? options.scopes[0] } : {}),
259
274
  createdAt: entry.createdAt,
260
275
  }));
261
276
 
@@ -269,7 +284,7 @@ const printStructured = (ctx: CloudCliContext, value: unknown): boolean => {
269
284
  export const printAccessEntries = (
270
285
  ctx: CloudCliContext,
271
286
  entries: AccessEntry[],
272
- options: { includeServiceAccounts?: boolean; jsonValue?: unknown } = {},
287
+ options: { includeServiceAccounts?: boolean; jsonValue?: unknown; scopes?: readonly string[] } = {},
273
288
  ) => {
274
289
  if (printStructured(ctx, options.jsonValue ?? entries)) return;
275
290
  const rows = accessRows(entries, options);
@@ -281,6 +296,7 @@ export const printAccessEntries = (
281
296
  { key: "principal", label: "PRINCIPAL" },
282
297
  { key: "type", label: "TYPE" },
283
298
  { key: "permission", label: "PERMISSION" },
299
+ ...(options.scopes ? [{ key: "scope" as const, label: "SCOPE" }] : []),
284
300
  { key: "accessId", label: "ACCESS ID" },
285
301
  ]);
286
302
  };
@@ -349,6 +365,19 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
349
365
  includeServiceAccountFlag: adapter.allowServiceAccounts === true,
350
366
  };
351
367
  const resolve = (ctx: CloudCliContext, args: string[]) => adapter.resolveResource(ctx, args);
368
+ const scopeFlag: { scope?: ReturnType<typeof flag.string> } = adapter.scopes
369
+ ? {
370
+ scope: flag.string({
371
+ description: `Part of the ${adapter.resourceLabel} the grant covers. Allowed: ${adapter.scopes.join(", ")}`,
372
+ }),
373
+ }
374
+ : {};
375
+ const scopeSuffix = (scope: string | undefined): string => (scope && scope !== adapter.scopes?.[0] ? ` (${scope})` : "");
376
+ const scopeOf = (flags: { scope?: string }): string | undefined => {
377
+ if (flags.scope === undefined) return undefined;
378
+ if (!adapter.scopes?.includes(flags.scope)) throw new Error(`Scope must be one of: ${adapter.scopes?.join(", ") ?? "none"}.`);
379
+ return flags.scope;
380
+ };
352
381
  const resourceArgs = {
353
382
  args: arg.rest({
354
383
  valueLabel: adapter.resourceArgLabel ?? adapter.resourceLabel,
@@ -374,6 +403,7 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
374
403
  printAccessEntries(ctx, entries, {
375
404
  includeServiceAccounts: flags.includeServiceAccounts,
376
405
  jsonValue: { resource, entries },
406
+ scopes: adapter.scopes,
377
407
  });
378
408
  },
379
409
  });
@@ -385,16 +415,18 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
385
415
  flags: {
386
416
  ...principalFlags(principalFlagOptions),
387
417
  permission: flag.enum(allowed, { required: true, description: `Permission to grant. Allowed: ${allowed.join(", ")}` }),
418
+ ...scopeFlag,
388
419
  },
389
420
  examples: adapter.examples?.grant,
390
421
  async run({ ctx, args, flags }) {
391
422
  const resource = await resolve(ctx, args.args);
392
423
  const permission = flags.permission as AccessPermission;
393
424
  assertAllowedPermission(permission, allowed);
425
+ const scope = scopeOf(flags);
394
426
  const principal = await resolveAccessPrincipal(ctx, flags as PrincipalFlags, adapter);
395
- const entry = await adapter.grant(ctx, resource, principal, permission);
427
+ const entry = await adapter.grant(ctx, resource, principal, permission, scope);
396
428
  if (!printStructured(ctx, { resource, entry }))
397
- ctx.print(`Granted ${permission} on ${resource.label} to ${entryDisplayName(entry)}.`);
429
+ ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry)}.`);
398
430
  },
399
431
  });
400
432
 
@@ -407,17 +439,19 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
407
439
  ...principalFlags(principalFlagOptions),
408
440
  ...accessIdFlag,
409
441
  permission: flag.enum(allowed, { required: true, description: `Permission to set. Allowed: ${allowed.join(", ")}` }),
442
+ ...scopeFlag,
410
443
  },
411
444
  examples: adapter.examples?.set,
412
445
  async run({ ctx, args, flags }) {
413
446
  const resource = await resolve(ctx, args.args);
414
447
  const permission = flags.permission as AccessPermission;
415
448
  assertAllowedPermission(permission, allowed);
449
+ const scope = scopeOf(flags);
416
450
 
417
451
  if (flags.accessId) {
418
- await adapter.update(ctx, resource, flags.accessId, permission);
419
- if (!printStructured(ctx, { resource, accessId: flags.accessId, permission, action: "updated" })) {
420
- ctx.print(`Updated ${flags.accessId} to ${permission} on ${resource.label}.`);
452
+ await adapter.update(ctx, resource, flags.accessId, permission, scope);
453
+ if (!printStructured(ctx, { resource, accessId: flags.accessId, permission, scope, action: "updated" })) {
454
+ ctx.print(`Updated ${flags.accessId} to ${permission}${scopeSuffix(scope)} on ${resource.label}.`);
421
455
  }
422
456
  return;
423
457
  }
@@ -426,15 +460,15 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
426
460
  const entries = await adapter.list(ctx, resource);
427
461
  const existing = entries.find((entry) => principalKey(entry.principal) === principalKey(principal));
428
462
  if (existing) {
429
- await adapter.update(ctx, resource, existing.id, permission);
430
- if (!printStructured(ctx, { resource, accessId: existing.id, permission, action: "updated" })) {
431
- ctx.print(`Updated ${entryDisplayName(existing)} to ${permission} on ${resource.label}.`);
463
+ await adapter.update(ctx, resource, existing.id, permission, scope);
464
+ if (!printStructured(ctx, { resource, accessId: existing.id, permission, scope, action: "updated" })) {
465
+ ctx.print(`Updated ${entryDisplayName(existing)} to ${permission}${scopeSuffix(scope)} on ${resource.label}.`);
432
466
  }
433
467
  return;
434
468
  }
435
- const entry = await adapter.grant(ctx, resource, principal, permission);
469
+ const entry = await adapter.grant(ctx, resource, principal, permission, scope);
436
470
  if (!printStructured(ctx, { resource, entry, action: "created" })) {
437
- ctx.print(`Granted ${permission} on ${resource.label} to ${entryDisplayName(entry)}.`);
471
+ ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry)}.`);
438
472
  }
439
473
  },
440
474
  });
@@ -4,7 +4,7 @@ import { apiGet, apiJson, queryString } from "./shared";
4
4
 
5
5
  export const aiSkillCommands = [
6
6
  command("ai skills list", {
7
- summary: "List all Skills with template origin, version, status, and revision (platform admin)",
7
+ summary: "List all Skills with source app, availability, status, and revision (platform admin)",
8
8
  flags: {
9
9
  search: flag.string(),
10
10
  page: flag.int({ min: 1, default: 1 }),
@@ -15,49 +15,69 @@ export const aiSkillCommands = [
15
15
  ctx,
16
16
  `/api/admin/core/ai-skills${queryString(flags)}`,
17
17
  );
18
- printRows(ctx, result, result.items, [
19
- { key: "shortId" },
20
- { key: "name" },
21
- { key: "revision" },
22
- { key: "templateId" },
23
- { key: "templateVersion" },
24
- { key: "currentTemplateVersion" },
25
- { key: "templateStatus" },
26
- ]);
27
- },
28
- }),
29
- command("ai skills templates", {
30
- summary: "List current trusted Skill template IDs and versions",
31
- run: async ({ ctx }) => {
32
- const result = await apiGet<{ templates: { templateId: string; name: string; version: number }[] }>(
18
+ printRows(
33
19
  ctx,
34
- "/api/admin/core/ai-skills/templates",
20
+ result,
21
+ result.items.map((skill) => ({
22
+ ...skill,
23
+ app: skill.source?.appName ?? "",
24
+ status: skill.source?.status ?? "",
25
+ available: skill.source?.available ?? "",
26
+ appVersion: skill.source?.appVersion ?? "",
27
+ })),
28
+ [
29
+ { key: "shortId" },
30
+ { key: "name" },
31
+ { key: "revision" },
32
+ { key: "app" },
33
+ { key: "status" },
34
+ { key: "available" },
35
+ { key: "appVersion" },
36
+ ],
35
37
  );
36
- printRows(ctx, result, result.templates, [{ key: "templateId" }, { key: "name" }, { key: "version" }]);
37
38
  },
38
39
  }),
39
- ...(["associate", "reset"] as const).map((mode) =>
40
- command(`ai skills ${mode}`, {
41
- summary:
42
- mode === "associate"
43
- ? "Associate an existing Skill with a template, preserving all content"
44
- : "Replace all linked Skill content and references with the current template",
45
- args: { skillId: arg.required() },
46
- flags: {
47
- templateId: flag.string({ name: "template", required: true }),
48
- templateVersion: flag.int({ name: "template-version", required: true, min: 1 }),
49
- expectedRevision: flag.int({ name: "revision", required: true, min: 1 }),
50
- yes: confirmFlag("Confirm the exact Skill and template; export customized content before resetting"),
51
- },
40
+ command("ai skills reset", {
41
+ summary: "Replace Skill content and references with the owning app's current version",
42
+ args: { skillId: arg.required() },
43
+ flags: {
44
+ expectedRevision: flag.int({ name: "revision", required: true, min: 1 }),
45
+ expectedAppVersion: flag.string({ name: "app-version", required: true }),
46
+ yes: confirmFlag("Confirm resetting this Skill; export customized content first"),
47
+ },
48
+ run: async ({ ctx, args, flags }) => {
49
+ if (!flags.yes) throw new Error("Review the Skill first, then confirm with --yes.");
50
+ const result = await apiJson(ctx, "POST", `/api/admin/core/ai-skills/${encodeURIComponent(args.skillId)}/reset`, {
51
+ expectedRevision: flags.expectedRevision,
52
+ expectedAppVersion: flags.expectedAppVersion,
53
+ confirmed: true,
54
+ });
55
+ if (!printStructured(ctx, result)) ctx.print("Skill reset to the app version.");
56
+ },
57
+ }),
58
+ ...(
59
+ [
60
+ ["restore", "Install a deleted app Skill again from the app's version, when its name is free", "App Skill installed."],
61
+ [
62
+ "adopt",
63
+ "Link the existing Skill that holds an app Skill's name to that app, keeping its content",
64
+ "Existing Skill linked to the app.",
65
+ ],
66
+ ] as const
67
+ ).map(([action, summary, done]) =>
68
+ command(`ai skills ${action}`, {
69
+ summary,
70
+ args: { appId: arg.required(), name: arg.required() },
71
+ flags: { yes: confirmFlag(`Confirm the app and Skill name to ${action}`) },
52
72
  run: async ({ ctx, args, flags }) => {
53
- if (!flags.yes) throw new Error("Review the Skill and template first, then confirm with --yes.");
54
- const { yes: _yes, ...input } = flags;
55
- const result = await apiJson(ctx, "POST", `/api/admin/core/ai-skills/${encodeURIComponent(args.skillId)}/template`, {
56
- ...input,
57
- mode,
58
- confirmed: true,
59
- });
60
- if (!printStructured(ctx, result)) ctx.print("Skill template updated.");
73
+ if (!flags.yes) throw new Error("Review the app Skill first, then confirm with --yes.");
74
+ const result = await apiJson(
75
+ ctx,
76
+ "POST",
77
+ `/api/admin/core/ai-skills/apps/${encodeURIComponent(args.appId)}/skills/${encodeURIComponent(args.name)}/${action}`,
78
+ { confirmed: true },
79
+ );
80
+ if (!printStructured(ctx, result)) ctx.print(done);
61
81
  },
62
82
  }),
63
83
  ),
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The announcements cookie, without schemas: the shell reads and writes it on every page and must not load the
3
+ * announcement validators for that. `AnnouncementCookieStateSchema` in `./announcements` describes the same shape.
4
+ */
5
+ export const ANNOUNCEMENTS_COOKIE = "cloud_announcements";
6
+ export const ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
7
+ export const MAX_DISMISSED_BANNER_VERSIONS = 50;
8
+
9
+ export type AnnouncementCookieState = {
10
+ seenAnnouncementVersion: number;
11
+ dismissedBannerVersions: number[];
12
+ };
13
+
14
+ export const DEFAULT_ANNOUNCEMENT_COOKIE_STATE: AnnouncementCookieState = {
15
+ seenAnnouncementVersion: 0,
16
+ dismissedBannerVersions: [],
17
+ };
18
+
19
+ const isVersion = (value: unknown, minimum: number): value is number =>
20
+ typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
21
+
22
+ /** Any invalid field resets the whole state, as a failed schema parse did. */
23
+ const normalizeCookieState = (value: unknown): AnnouncementCookieState => {
24
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
25
+ const { seenAnnouncementVersion = 0, dismissedBannerVersions = [] } = value as Partial<Record<keyof AnnouncementCookieState, unknown>>;
26
+ if (!isVersion(seenAnnouncementVersion, 0)) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
27
+ if (!Array.isArray(dismissedBannerVersions) || !dismissedBannerVersions.every((version) => isVersion(version, 1)))
28
+ return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
29
+ const dismissed = [...new Set(dismissedBannerVersions)].sort((a, b) => b - a).slice(0, MAX_DISMISSED_BANNER_VERSIONS);
30
+
31
+ return {
32
+ seenAnnouncementVersion: Math.max(0, seenAnnouncementVersion),
33
+ dismissedBannerVersions: dismissed,
34
+ };
35
+ };
36
+
37
+ export const parseAnnouncementCookieValue = (value: string | null | undefined): AnnouncementCookieState => {
38
+ if (!value) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
39
+ try {
40
+ return normalizeCookieState(JSON.parse(decodeURIComponent(value)));
41
+ } catch {
42
+ return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
43
+ }
44
+ };
45
+
46
+ export const parseAnnouncementCookieHeader = (cookieHeader: string | null | undefined): AnnouncementCookieState => {
47
+ if (!cookieHeader) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
48
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${ANNOUNCEMENTS_COOKIE}=([^;]+)`));
49
+ return parseAnnouncementCookieValue(match?.[1]);
50
+ };
51
+
52
+ export const serializeAnnouncementCookieState = (state: AnnouncementCookieState): string =>
53
+ encodeURIComponent(JSON.stringify(normalizeCookieState(state)));
54
+
55
+ export const mergeAnnouncementCookieState = (
56
+ current: AnnouncementCookieState,
57
+ patch: Partial<AnnouncementCookieState>,
58
+ ): AnnouncementCookieState =>
59
+ normalizeCookieState({
60
+ seenAnnouncementVersion: Math.max(current.seenAnnouncementVersion, patch.seenAnnouncementVersion ?? 0),
61
+ dismissedBannerVersions: [...current.dismissedBannerVersions, ...(patch.dismissedBannerVersions ?? [])],
62
+ });
@@ -1,8 +1,17 @@
1
1
  import { z } from "zod";
2
-
3
- export const ANNOUNCEMENTS_COOKIE = "cloud_announcements";
4
- export const ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
5
- export const MAX_DISMISSED_BANNER_VERSIONS = 50;
2
+ import type { AnnouncementCookieState } from "./announcement-cookie";
3
+
4
+ export {
5
+ ANNOUNCEMENTS_COOKIE,
6
+ ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
7
+ type AnnouncementCookieState,
8
+ DEFAULT_ANNOUNCEMENT_COOKIE_STATE,
9
+ MAX_DISMISSED_BANNER_VERSIONS,
10
+ mergeAnnouncementCookieState,
11
+ parseAnnouncementCookieHeader,
12
+ parseAnnouncementCookieValue,
13
+ serializeAnnouncementCookieState,
14
+ } from "./announcement-cookie";
6
15
 
7
16
  export const AnnouncementKindSchema = z.enum(["announcement", "banner"]);
8
17
  export type AnnouncementKind = z.infer<typeof AnnouncementKindSchema>;
@@ -13,13 +22,7 @@ export type AnnouncementTone = z.infer<typeof AnnouncementToneSchema>;
13
22
  export const AnnouncementCookieStateSchema = z.object({
14
23
  seenAnnouncementVersion: z.number().int().nonnegative().default(0),
15
24
  dismissedBannerVersions: z.array(z.number().int().positive()).default([]),
16
- });
17
- export type AnnouncementCookieState = z.infer<typeof AnnouncementCookieStateSchema>;
18
-
19
- export const DEFAULT_ANNOUNCEMENT_COOKIE_STATE: AnnouncementCookieState = {
20
- seenAnnouncementVersion: 0,
21
- dismissedBannerVersions: [],
22
- };
25
+ }) satisfies z.ZodType<AnnouncementCookieState>;
23
26
 
24
27
  export const AnnouncementEntrySchema = z.object({
25
28
  id: z.uuid(),
@@ -78,44 +81,3 @@ export const ActiveAnnouncementsResponseSchema = z.object({
78
81
  latestAnnouncementVersion: z.number().int().nonnegative(),
79
82
  });
80
83
  export type ActiveAnnouncementsResponse = z.infer<typeof ActiveAnnouncementsResponseSchema>;
81
-
82
- const normalizeCookieState = (value: unknown): AnnouncementCookieState => {
83
- const parsed = AnnouncementCookieStateSchema.safeParse(value);
84
- if (!parsed.success) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
85
- const dismissed = [...new Set(parsed.data.dismissedBannerVersions)]
86
- .filter((version) => Number.isInteger(version) && version > 0)
87
- .sort((a, b) => b - a)
88
- .slice(0, MAX_DISMISSED_BANNER_VERSIONS);
89
-
90
- return {
91
- seenAnnouncementVersion: Math.max(0, parsed.data.seenAnnouncementVersion),
92
- dismissedBannerVersions: dismissed,
93
- };
94
- };
95
-
96
- export const parseAnnouncementCookieValue = (value: string | null | undefined): AnnouncementCookieState => {
97
- if (!value) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
98
- try {
99
- return normalizeCookieState(JSON.parse(decodeURIComponent(value)));
100
- } catch {
101
- return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
102
- }
103
- };
104
-
105
- export const parseAnnouncementCookieHeader = (cookieHeader: string | null | undefined): AnnouncementCookieState => {
106
- if (!cookieHeader) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
107
- const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${ANNOUNCEMENTS_COOKIE}=([^;]+)`));
108
- return parseAnnouncementCookieValue(match?.[1]);
109
- };
110
-
111
- export const serializeAnnouncementCookieState = (state: AnnouncementCookieState): string =>
112
- encodeURIComponent(JSON.stringify(normalizeCookieState(state)));
113
-
114
- export const mergeAnnouncementCookieState = (
115
- current: AnnouncementCookieState,
116
- patch: Partial<AnnouncementCookieState>,
117
- ): AnnouncementCookieState =>
118
- normalizeCookieState({
119
- seenAnnouncementVersion: Math.max(current.seenAnnouncementVersion, patch.seenAnnouncementVersion ?? 0),
120
- dismissedBannerVersions: [...current.dismissedBannerVersions, ...(patch.dismissedBannerVersions ?? [])],
121
- });
@@ -349,6 +349,26 @@ export type CapabilityUniversalSearchDefinition = {
349
349
 
350
350
  export type CapabilitySchemaPresentation = Readonly<Record<string, string>>;
351
351
 
352
+ /**
353
+ * What one Action does, in words people read in approvals, receipts, and step rows. Each sentence is a
354
+ * template: `{input.to}` inserts a field of the validated call input by its dotted schema path, and
355
+ * `done` may also read the Action's result data with `{data.id}`. Cloud inserts values as escaped,
356
+ * bounded plain text, formats fields with a `date` or `date-time` schema format for the reader, and
357
+ * names an object by its email, address, or path before its display name. A sentence whose value is
358
+ * missing falls back to the generic wording, so every sentence stays optional. A `done` sentence
359
+ * replaces the result's runtime summary in the receipt.
360
+ */
361
+ export type CapabilityActionSentences = {
362
+ /** What will happen, as a short instruction: "Send email to {input.to}". */
363
+ approval?: string;
364
+ /** What happened after the Action ran: "Sent email to {input.to}". */
365
+ done?: string;
366
+ /** What did not happen because the person rejected it: "Did not send email to {input.to}". */
367
+ rejected?: string;
368
+ /** What did not happen because the turn ended first: "Email to {input.to} not sent". */
369
+ notRun?: string;
370
+ };
371
+
352
372
  export type CapabilityOperationPresentationTranslation = {
353
373
  title?: string;
354
374
  description?: string;
@@ -358,6 +378,8 @@ export type CapabilityOperationPresentationTranslation = {
358
378
  data?: CapabilitySchemaPresentation;
359
379
  /** Search-tag presentation keyed by the stable tag token. */
360
380
  searchTags?: Readonly<Record<string, { title?: string; description?: string }>>;
381
+ /** Actions only: the complete set of sentences in this locale. */
382
+ sentences?: CapabilityActionSentences;
361
383
  };
362
384
 
363
385
  export type CapabilityPresentationTranslation = {
@@ -370,6 +392,8 @@ export type CapabilityPresentationTranslation = {
370
392
  export type CapabilityPresentationCatalog = {
371
393
  /** Locale of the complete Type, Query, Action, search-tag, and schema presentation. */
372
394
  baseLocale: string;
395
+ /** Optional Action sentences in the base locale, keyed by Action local ID. */
396
+ sentences?: Readonly<Record<string, CapabilityActionSentences>>;
373
397
  /** Partial presentation overlays with exact -> ancestor -> base fallback. */
374
398
  translations: Readonly<Record<string, CapabilityPresentationTranslation>>;
375
399
  };
@@ -1,6 +1,11 @@
1
1
  /**
2
- * Paths of the installable mobile app, without schemas, so browser code that runs on every page can test for an
3
- * app page without loading the contract's validators.
2
+ * Paths and ids of the installable mobile app, without schemas, so browser code that runs on every page can test for
3
+ * an app page without loading the contract's validators.
4
4
  */
5
5
  export const PWA_SCOPE = "/pwa/";
6
6
  export const PWA_AUTH_PATH = "/pwa/_auth";
7
+ export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
8
+ /** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
9
+ export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
10
+ /** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
11
+ export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { PWA_AUTH_PATH, PWA_SCOPE } from "./pwa-paths";
2
+ import { isPwaPartId, PWA_AUTH_PATH, PWA_RESERVED_IDS, PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "./pwa-paths";
3
3
 
4
- export { PWA_AUTH_PATH, PWA_SCOPE };
4
+ export { isPwaPartId, PWA_AUTH_PATH, PWA_RESERVED_IDS, PWA_SCOPE, PWA_SERVICE_WORKER_PATH };
5
5
 
6
6
  /**
7
7
  * The installable mobile app (preview). One shell application `pwa` owns the
@@ -11,11 +11,6 @@ export { PWA_AUTH_PATH, PWA_SCOPE };
11
11
  export const PWA_SHELL_APP_ID = "pwa";
12
12
  export const PWA_API_PATH = "/api/auth/pwa/v1";
13
13
  export const PWA_MANIFEST_PATH = "/pwa/manifest.webmanifest";
14
- export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
15
- /** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
16
- export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
17
- /** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
18
- export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
19
14
  /**
20
15
  * The page canvas of the app (`--k2b-surface-canvas` of `@k2b/ui`) in light and dark. App documents paint it
21
16
  * before any stylesheet loads and give it to the status bar.
@@ -88,6 +88,7 @@ export type AppRegistryEntry = {
88
88
  adminNav?: AppAdminNavigationGroup[];
89
89
  capabilities?: AppRegistryCapabilitySummary;
90
90
  help?: AppRegistryHelpSummary;
91
+ skills?: { manifestHash: string };
91
92
  legalLinks?: AppRegistryLegalLink[];
92
93
  searchLinks?: readonly AppSearchLink[];
93
94
  /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
@@ -329,6 +329,11 @@ export const AccessEntrySchema = z.object({
329
329
  avatarHash: z.string().nullable().optional(),
330
330
  /** Kind of a `service_account` principal; presentation only, never authorization. */
331
331
  serviceAccountKind: ServiceAccountKindSchema.optional(),
332
+ /**
333
+ * A narrower part of the resource the grant covers, defined by the application, such as Mail's
334
+ * `assigned` conversations. Absent for a grant on the whole resource.
335
+ */
336
+ scope: z.string().min(1).max(64).optional(),
332
337
  });
333
338
  export type AccessEntry = z.infer<typeof AccessEntrySchema>;
334
339
 
@@ -181,3 +181,23 @@ export const WidgetResponseSchema = z
181
181
  })
182
182
  .strip();
183
183
  export type WidgetResponse = z.infer<typeof WidgetResponseSchema>;
184
+
185
+ /** A widget's stable key on the dashboard and in the widget stream: `<appId>/<widgetId>`. */
186
+ export const widgetKey = (appId: string, widgetId: string): string => `${appId}/${widgetId}`;
187
+
188
+ /**
189
+ * How one widget answered in the widget stream: `ok` carries its response, `empty` is a `204`, `forbidden` a `403`,
190
+ * `timeout` means it did not answer within its own budget, and `error` covers every other failure.
191
+ */
192
+ export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
193
+
194
+ /**
195
+ * One line of `GET /api/widgets/v1` (`application/x-ndjson`): `start` names the widgets Core asks, in registry
196
+ * order; one `widget` line follows per widget in the order they finish; `done` closes the stream. Every widget named
197
+ * in `start` gets exactly one line.
198
+ */
199
+ export type WidgetStreamLine =
200
+ | { type: "start"; widgets: string[] }
201
+ | { type: "widget"; key: string; status: "ok"; widget: WidgetResponse; ms: number }
202
+ | { type: "widget"; key: string; status: Exclude<WidgetStreamStatus, "ok">; ms: number }
203
+ | { type: "done"; status: "complete" | "partial" };
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  export type { RuntimeCompatibilityIssue } from "./_internal/runtime-compatibility";
36
36
  export { assessRuntimeCompatibility } from "./_internal/runtime-compatibility";
37
37
  export { buildRuntimeFromRegistry } from "./_internal/runtime-context";
38
+ export { type AppSkillDefinition, skill } from "./ai/app-skills";
38
39
  export { defineCapabilities } from "./contracts/capabilities";
39
40
  export type {
40
41
  AnyBoundNotificationDefinition,
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
3
3
  import { z } from "zod";
4
4
  import { type HelpDocumentManifest, type HelpDocumentPayload, type HelpSearchPayload, helpLocaleChain } from "../shared/help";
5
5
  import { markdownToPlainText, renderHelpMarkdown } from "../shared/markdown";
6
+ import { auth } from "./middleware/auth";
6
7
 
7
8
  const metadataSchema = z.object({
8
9
  id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
@@ -133,6 +134,9 @@ export const defineHelp = (
133
134
  * Define one app-owned help corpus. The explicit source list is deliberate:
134
135
  * IDs, ordering and ownership stay visible in code; no filesystem scanning or
135
136
  * build-time convention is required.
137
+ *
138
+ * @deprecated Declare Help with `defineHelp()` and pass it to `app.start({ help })`. This router only
139
+ * requires sign-in; it does not apply the app's Help visibility, so the mounting app's routes must guard it.
136
140
  */
137
141
  export const defineHelpCollection = (options: { basePath: string; sources: readonly string[] }): HelpCollection => {
138
142
  const basePath = options.basePath.replace(/\/$/, "");
@@ -151,7 +155,10 @@ export const defineHelpCollection = (options: { basePath: string; sources: reado
151
155
  url: `${basePath}/${encodeURIComponent(id)}`,
152
156
  }));
153
157
 
154
- const router = new Hono()
158
+ // Help is for signed-in people wherever an app mounts this router; its own route policy may narrow it further.
159
+ const router = new Hono();
160
+ router.use(auth.requireRole("authenticated"), auth.requireUser());
161
+ router
155
162
  .get("/search", (context) => {
156
163
  const query = context.req.query("q")?.trim().toLocaleLowerCase().slice(0, 200) ?? "";
157
164
  const payload: HelpSearchPayload = {
@@ -223,6 +223,21 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
223
223
  return buildUserMutationTarget(rows[0]!);
224
224
  };
225
225
 
226
+ /** Audience roles for a resolved access subject, including delegated users and group managers. */
227
+ export const getRoles = async (userId: string): Promise<Role[]> => {
228
+ const { groupsAdmin } = await getFreeIpaConfig();
229
+ const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
230
+ CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
231
+ SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
232
+ AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
233
+ ) END AS effective_admin,
234
+ ARRAY(${managedGroupsNamesSubquery(sql`u.id`)}) AS manages
235
+ FROM auth.users u WHERE u.id=${userId}::uuid`;
236
+ if (!row) return [];
237
+ const { provider, profile } = resolveProviderProfile(row);
238
+ return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
239
+ };
240
+
226
241
  export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
227
242
  const { groupsAdmin } = await getFreeIpaConfig();
228
243
  const rows = await sql<DbRow[]>`