@k2b/cloud 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DialogRender, suppressTextSelection } from "@k2b/ui";
|
|
2
2
|
|
|
3
3
|
export type SpotlightPosition = { x: number; y: number };
|
|
4
4
|
const STORAGE_KEY = "cloud.spotlight.position.v1";
|
|
@@ -42,6 +42,7 @@ export const attachSpotlightPosition = (
|
|
|
42
42
|
origin: SpotlightPosition;
|
|
43
43
|
previous: SpotlightPosition | null;
|
|
44
44
|
handle: HTMLElement;
|
|
45
|
+
releaseTextSelection: () => void;
|
|
45
46
|
} | null = null;
|
|
46
47
|
|
|
47
48
|
const home = document.createElement("div");
|
|
@@ -108,6 +109,7 @@ export const attachSpotlightPosition = (
|
|
|
108
109
|
if (!drag) return;
|
|
109
110
|
const handle = drag.handle;
|
|
110
111
|
const id = drag.id;
|
|
112
|
+
drag.releaseTextSelection();
|
|
111
113
|
drag = null;
|
|
112
114
|
if (handle.hasPointerCapture(id)) handle.releasePointerCapture(id);
|
|
113
115
|
delete host.dataset.dragging;
|
|
@@ -132,6 +134,7 @@ export const attachSpotlightPosition = (
|
|
|
132
134
|
origin: { x: rect.x, y: rect.y },
|
|
133
135
|
previous: position,
|
|
134
136
|
handle,
|
|
137
|
+
releaseTextSelection: suppressTextSelection(event.pointerId),
|
|
135
138
|
};
|
|
136
139
|
handle.setPointerCapture(event.pointerId);
|
|
137
140
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DashboardWidgetSize, WidgetStreamLine } from "../contracts/widgets";
|
|
2
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
|
+
import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
|
|
4
|
+
|
|
5
|
+
export type { DashboardWidgetSize, WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
|
|
9
|
+
* `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
|
|
10
|
+
* request, for example to retry one widget; without it Core asks every declared widget. `sizes` names the size to ask
|
|
11
|
+
* a widget for; a widget without one, or with a size it does not offer, answers in its default size. Aborting `signal`
|
|
12
|
+
* stops every widget Core still waits for. Rejects when the request fails or the stream breaks off.
|
|
13
|
+
*/
|
|
14
|
+
export const streamWidgets = async (options: {
|
|
15
|
+
keys?: readonly string[];
|
|
16
|
+
sizes?: Readonly<Record<string, DashboardWidgetSize>>;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
locale: string;
|
|
19
|
+
onLine: (line: WidgetStreamLine) => void;
|
|
20
|
+
}): Promise<void> => {
|
|
21
|
+
const query = new URLSearchParams();
|
|
22
|
+
for (const key of options.keys ?? []) query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
|
|
23
|
+
const search = query.toString();
|
|
24
|
+
const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
|
|
25
|
+
signal: options.signal,
|
|
26
|
+
headers: { accept: NDJSON_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) throw new Error(`Widgets failed with ${response.status}`);
|
|
29
|
+
await readNdjsonLines(response, options.onLine, "Widgets");
|
|
30
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { CapabilityOrigin } from "../contracts/capabilities";
|
|
3
|
+
import { pruneApprovalRequests } from "../services/approvals";
|
|
3
4
|
import { logger } from "../services/logging";
|
|
4
5
|
import { CAPABILITY_CLAIM_RETENTION_HOURS, pruneCapabilityIdempotencyClaims } from "./claims";
|
|
5
6
|
|
|
@@ -280,6 +281,8 @@ export const startCapabilityExecutionMaintenance = (): (() => void) => {
|
|
|
280
281
|
try {
|
|
281
282
|
const removed = await pruneCapabilityExecutions(new Date(Date.now() - CAPABILITY_EXECUTION_RETENTION_DAYS * 86_400_000));
|
|
282
283
|
if (removed > 0) log.info("Pruned expired capability executions", { removed });
|
|
284
|
+
const requests = await pruneApprovalRequests(new Date(Date.now() - CAPABILITY_EXECUTION_RETENTION_DAYS * 86_400_000));
|
|
285
|
+
if (requests > 0) log.info("Pruned expired approval requests", { removed: requests });
|
|
283
286
|
const claims = await pruneCapabilityIdempotencyClaims(new Date(Date.now() - CAPABILITY_CLAIM_RETENTION_HOURS * 3_600_000));
|
|
284
287
|
if (claims > 0) log.info("Pruned expired capability idempotency claims", { removed: claims });
|
|
285
288
|
} catch (error) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
2
|
+
import { resolveCapabilityActionWording, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
3
|
+
import type { CapabilityActionWording } from "../_internal/capability-sentences";
|
|
3
4
|
import { getApp, getCapability } from "../_internal/registry";
|
|
4
5
|
import { dispatchCapability, loadCapabilityCatalogPage } from "../api/capabilities";
|
|
5
6
|
import { env } from "../config/env";
|
|
@@ -14,7 +15,7 @@ import { get } from "../services/settings";
|
|
|
14
15
|
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
15
16
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
16
17
|
import { capabilityMessages } from "../shared/capability-messages";
|
|
17
|
-
import { LOCALE_HEADER } from "../shared/locale";
|
|
18
|
+
import { DEFAULT_LOCALE, LOCALE_HEADER } from "../shared/locale";
|
|
18
19
|
import { readCapabilityResponse } from "./response";
|
|
19
20
|
import { combineCapabilitySignals } from "./signals";
|
|
20
21
|
import type {
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
CapabilityReviewClientResult,
|
|
27
28
|
} from "./types";
|
|
28
29
|
|
|
30
|
+
export type { CapabilityActionWording } from "../_internal/capability-sentences";
|
|
29
31
|
export type {
|
|
30
32
|
CapabilityCatalogApp,
|
|
31
33
|
CapabilityCatalogAppClientResult,
|
|
@@ -225,6 +227,22 @@ export const getCapabilityCatalogApp = async (appId: string, locale?: string): P
|
|
|
225
227
|
}
|
|
226
228
|
};
|
|
227
229
|
|
|
230
|
+
/**
|
|
231
|
+
* How people read calls of one Action in a locale, from the live registry like the Assistant's own calls: its title,
|
|
232
|
+
* the app's sentences, and the labelled fields of the generic sentence. Pass `sentences` and `fields` to the chat's
|
|
233
|
+
* approval card, so an approval a script or app asks for reads like the same call in a chat. `null` for an unknown
|
|
234
|
+
* app or Action.
|
|
235
|
+
*/
|
|
236
|
+
export const getCapabilityActionWording = async (
|
|
237
|
+
appId: string,
|
|
238
|
+
localId: string,
|
|
239
|
+
locale?: string,
|
|
240
|
+
): Promise<CapabilityActionWording | null> => {
|
|
241
|
+
const capability = await getCapability(appId);
|
|
242
|
+
const action = capability?.manifest.actions.find((candidate) => candidate.localId === localId);
|
|
243
|
+
return capability && action ? resolveCapabilityActionWording(action, capability.presentation, locale ?? DEFAULT_LOCALE) : null;
|
|
244
|
+
};
|
|
245
|
+
|
|
228
246
|
export const reviewCapabilityAction = async <TInput = unknown>(
|
|
229
247
|
invocation: Omit<CapabilityInvocation<TInput>, "kind" | "idempotencyKey">,
|
|
230
248
|
caller: CapabilityCaller,
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
confirmed: true,
|
|
59
|
-
|
|
60
|
-
if (!printStructured(ctx, result)) ctx.print(
|
|
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
|
),
|
package/src/cli/commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { CloudCliContext, CloudCliFlagValue, CloudCliModule } from "./index";
|
|
3
|
+
import { isCloudCliAlias, isCloudCliShortcuts } from "./plugin";
|
|
3
4
|
|
|
4
5
|
type FlagKind = "string" | "boolean" | "int" | "enum" | "stringList" | "input";
|
|
5
6
|
type ArgKind = "required" | "optional" | "rest";
|
|
@@ -155,6 +156,8 @@ type CommandNode = {
|
|
|
155
156
|
type CliCommandsConfig = {
|
|
156
157
|
name: string;
|
|
157
158
|
summary: string;
|
|
159
|
+
alias?: string;
|
|
160
|
+
shortcuts?: Readonly<Record<string, string>>;
|
|
158
161
|
requiresCloud?: boolean;
|
|
159
162
|
groupSummaries?: Readonly<Record<string, string>>;
|
|
160
163
|
commands: readonly CliCommandDefinition[];
|
|
@@ -241,10 +244,27 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
|
|
|
241
244
|
|
|
242
245
|
export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
|
|
243
246
|
const root = buildCommandTree(config.commands, config.groupSummaries);
|
|
247
|
+
if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
|
|
248
|
+
throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
|
|
249
|
+
}
|
|
250
|
+
let shortcuts: Record<string, string> | undefined;
|
|
251
|
+
if (config.shortcuts !== undefined) {
|
|
252
|
+
if (!isCloudCliShortcuts(config.shortcuts, config.name, config.alias)) {
|
|
253
|
+
throw new Error("CLI shortcuts require unique kebab-case names and non-empty command paths without flags");
|
|
254
|
+
}
|
|
255
|
+
shortcuts = {};
|
|
256
|
+
for (const [name, target] of Object.entries(config.shortcuts)) {
|
|
257
|
+
const segments = target.trim().split(/\s+/);
|
|
258
|
+
if (!findNode(root, segments)?.command) throw new Error(`Unknown CLI shortcut command path: ${target}`);
|
|
259
|
+
shortcuts[name] = segments.join(" ");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
244
262
|
|
|
245
263
|
return {
|
|
246
264
|
name: config.name,
|
|
247
265
|
summary: config.summary,
|
|
266
|
+
...(config.alias !== undefined ? { alias: config.alias } : {}),
|
|
267
|
+
...(shortcuts !== undefined ? { shortcuts } : {}),
|
|
248
268
|
requiresCloud: config.requiresCloud,
|
|
249
269
|
requiresCloudFor: (args) => {
|
|
250
270
|
const helpRequest = normalizeHelpRequest(args, {});
|
package/src/cli/index.ts
CHANGED
|
@@ -51,6 +51,12 @@ export type CloudCliContext = {
|
|
|
51
51
|
profiles?: {
|
|
52
52
|
saveClientCredentials: (input: CloudCliClientCredentialsProfile) => Promise<void>;
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Open an `http:` or `https:` URL in the user's browser, when the host can.
|
|
56
|
+
* Resolves `false` when nothing was opened, so the command still prints the
|
|
57
|
+
* link. Absent in hosts without a local browser.
|
|
58
|
+
*/
|
|
59
|
+
openUrl?: (url: string) => Promise<boolean>;
|
|
54
60
|
};
|
|
55
61
|
|
|
56
62
|
/** A profile that signs in with the OAuth client-credentials grant. */
|
|
@@ -70,6 +76,16 @@ export type CloudCliClientCredentialsProfile = {
|
|
|
70
76
|
export type CloudCliModule = {
|
|
71
77
|
name: string;
|
|
72
78
|
summary: string;
|
|
79
|
+
/**
|
|
80
|
+
* `cld <alias> …` runs `cld <name> …`. Names match CLOUD_CLI_MODULE_NAME;
|
|
81
|
+
* cld uses them only when exactly one installed module claims them and no built-in or module has that name.
|
|
82
|
+
*/
|
|
83
|
+
alias?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Top-level names mapped to space-separated command paths relative to this module, never preset flags or args.
|
|
86
|
+
* Names match CLOUD_CLI_MODULE_NAME; cld uses them only when exactly one installed module claims them and no built-in or module has that name.
|
|
87
|
+
*/
|
|
88
|
+
shortcuts?: Readonly<Record<string, string>>;
|
|
73
89
|
booleanFlags?: readonly string[];
|
|
74
90
|
requiresCloud?: boolean;
|
|
75
91
|
requiresCloudFor?: (args: string[], flags: CloudCliFlags) => boolean;
|
package/src/cli/plugin.ts
CHANGED
|
@@ -67,6 +67,27 @@ export const cloudCliPluginDigest = (files: readonly Pick<CloudCliPluginFile, "p
|
|
|
67
67
|
|
|
68
68
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
69
|
|
|
70
|
+
/** Whether an alias is a valid extra name for this module. */
|
|
71
|
+
export const isCloudCliAlias = (value: unknown, name: string): value is string =>
|
|
72
|
+
typeof value === "string" && CLOUD_CLI_MODULE_NAME.test(value) && value !== name;
|
|
73
|
+
|
|
74
|
+
/** Validate short names and command-path syntax without loading a module's command tree. */
|
|
75
|
+
export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string): value is Record<string, string> =>
|
|
76
|
+
isRecord(value) &&
|
|
77
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
|
|
78
|
+
Object.entries(value).every(
|
|
79
|
+
([key, target]) =>
|
|
80
|
+
CLOUD_CLI_MODULE_NAME.test(key) &&
|
|
81
|
+
key !== name &&
|
|
82
|
+
key !== alias &&
|
|
83
|
+
typeof target === "string" &&
|
|
84
|
+
target.trim() !== "" &&
|
|
85
|
+
target
|
|
86
|
+
.trim()
|
|
87
|
+
.split(/\s+/)
|
|
88
|
+
.every((segment) => !segment.startsWith("-")),
|
|
89
|
+
);
|
|
90
|
+
|
|
70
91
|
/** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
|
|
71
92
|
export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
|
|
72
93
|
if (!isRecord(value)) throw new Error("plugin manifest is not an object");
|
|
@@ -111,6 +132,15 @@ export const validateCloudCliModule = (value: unknown): CloudCliModule => {
|
|
|
111
132
|
if (typeof value.name !== "string" || !CLOUD_CLI_MODULE_NAME.test(value.name)) {
|
|
112
133
|
throw new Error("default export needs a lowercase kebab-case name");
|
|
113
134
|
}
|
|
135
|
+
if (value.alias !== undefined && !isCloudCliAlias(value.alias, value.name)) {
|
|
136
|
+
throw new Error("alias must be a lowercase kebab-case name different from the module name");
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
value.shortcuts !== undefined &&
|
|
140
|
+
!isCloudCliShortcuts(value.shortcuts, value.name, typeof value.alias === "string" ? value.alias : undefined)
|
|
141
|
+
) {
|
|
142
|
+
throw new Error("shortcuts must be a plain object with unique kebab-case names and non-empty command paths without flags");
|
|
143
|
+
}
|
|
114
144
|
if (typeof value.summary !== "string") throw new Error("default export needs a string summary");
|
|
115
145
|
if (typeof value.run !== "function") throw new Error("default export needs a run function");
|
|
116
146
|
for (const key of ["help", "requiresCloudFor"] as const) {
|
|
@@ -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
|
-
|
|
4
|
-
export
|
|
5
|
-
|
|
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
|
-
});
|