@k2b/cloud 0.41.0 → 0.42.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 +6 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -6,11 +6,13 @@ import { dispatchCapability, loadCapabilityCatalogPage } from "../api/capabiliti
|
|
|
6
6
|
import { env } from "../config/env";
|
|
7
7
|
import {
|
|
8
8
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
9
|
+
CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
|
|
9
10
|
CapabilityActionReviewDataSchema,
|
|
10
11
|
type CapabilityOrigin,
|
|
11
12
|
capabilityResultSchema,
|
|
12
13
|
} from "../contracts/capabilities";
|
|
13
14
|
import type { RequestAuthority } from "../server";
|
|
15
|
+
import { canUseApp } from "../services/app-access";
|
|
14
16
|
import { get } from "../services/settings";
|
|
15
17
|
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
16
18
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
@@ -184,7 +186,7 @@ export const invokeCapabilityWithDataSchema = <TDataSchema extends z.ZodType, TI
|
|
|
184
186
|
): Promise<CapabilityClientResult<z.output<TDataSchema>>> => invokeCapabilityWithResultSchema(invocation, dataSchema, caller);
|
|
185
187
|
|
|
186
188
|
export const listCapabilityCatalog = async (
|
|
187
|
-
options: { cursor?: string; limit?: number; locale?: string } = {},
|
|
189
|
+
options: { cursor?: string; limit?: number; locale?: string; authority?: RequestAuthority | null } = {},
|
|
188
190
|
): Promise<CapabilityCatalogClientResult> => {
|
|
189
191
|
const limit = options.limit ?? 25;
|
|
190
192
|
const messages = capabilityMessages(options.locale);
|
|
@@ -197,30 +199,35 @@ export const listCapabilityCatalog = async (
|
|
|
197
199
|
try {
|
|
198
200
|
return {
|
|
199
201
|
ok: true,
|
|
200
|
-
data: await loadCapabilityCatalogPage({ cursor: options.cursor, limit }, {}, options.locale),
|
|
202
|
+
data: await loadCapabilityCatalogPage({ cursor: options.cursor, limit }, {}, options.locale, options.authority ?? null),
|
|
201
203
|
};
|
|
202
204
|
} catch {
|
|
203
205
|
return { ok: false, error: { code: "APP_UNAVAILABLE", message: messages.cloudUnavailable, status: 503 } };
|
|
204
206
|
}
|
|
205
207
|
};
|
|
206
208
|
|
|
207
|
-
export const getCapabilityCatalogApp = async (
|
|
209
|
+
export const getCapabilityCatalogApp = async (
|
|
210
|
+
appId: string,
|
|
211
|
+
locale?: string,
|
|
212
|
+
authority: RequestAuthority | null = null,
|
|
213
|
+
): Promise<CapabilityCatalogAppClientResult> => {
|
|
208
214
|
try {
|
|
209
215
|
const [capability, registeredApp] = await Promise.all([getCapability(appId), getApp(appId)]);
|
|
210
216
|
const presentedApp = registeredApp && locale ? resolveAppPresentation(registeredApp, locale) : registeredApp;
|
|
211
217
|
return {
|
|
212
218
|
ok: true,
|
|
213
|
-
data:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
data:
|
|
220
|
+
capability && registeredApp && (await canUseApp(authority, registeredApp))
|
|
221
|
+
? {
|
|
222
|
+
appId: capability.appId,
|
|
223
|
+
appName: presentedApp?.name ?? capability.appName,
|
|
224
|
+
appIcon: capability.appIcon,
|
|
225
|
+
appDescription: presentedApp?.description ?? capability.appDescription,
|
|
226
|
+
manifest: locale
|
|
227
|
+
? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale)
|
|
228
|
+
: capability.manifest,
|
|
229
|
+
}
|
|
230
|
+
: null,
|
|
224
231
|
};
|
|
225
232
|
} catch {
|
|
226
233
|
return { ok: false, error: { code: "APP_UNAVAILABLE", message: capabilityMessages(locale).cloudUnavailable, status: 503 } };
|
|
@@ -237,7 +244,10 @@ export const getCapabilityActionWording = async (
|
|
|
237
244
|
appId: string,
|
|
238
245
|
localId: string,
|
|
239
246
|
locale?: string,
|
|
247
|
+
authority: RequestAuthority | null = null,
|
|
240
248
|
): Promise<CapabilityActionWording | null> => {
|
|
249
|
+
const registeredApp = await getApp(appId);
|
|
250
|
+
if (!registeredApp || !(await canUseApp(authority, registeredApp))) return null;
|
|
241
251
|
const capability = await getCapability(appId);
|
|
242
252
|
const action = capability?.manifest.actions.find((candidate) => candidate.localId === localId);
|
|
243
253
|
return capability && action ? resolveCapabilityActionWording(action, capability.presentation, locale ?? DEFAULT_LOCALE) : null;
|
|
@@ -263,7 +273,12 @@ export const reviewCapabilityAction = async <TInput = unknown>(
|
|
|
263
273
|
locale: caller.locale ?? undefined,
|
|
264
274
|
origin: caller.origin ?? "app",
|
|
265
275
|
});
|
|
266
|
-
return readCapabilityResponse(
|
|
276
|
+
return await readCapabilityResponse(
|
|
277
|
+
outcome,
|
|
278
|
+
CapabilityActionReviewDataSchema,
|
|
279
|
+
CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
|
|
280
|
+
combineCapabilitySignals(caller.signal, invocation.signal),
|
|
281
|
+
);
|
|
267
282
|
}
|
|
268
283
|
const response = await (caller.transport ?? fetch)(
|
|
269
284
|
new Request(
|
|
@@ -276,7 +291,12 @@ export const reviewCapabilityAction = async <TInput = unknown>(
|
|
|
276
291
|
{ body: JSON.stringify({ input: invocation.input }) },
|
|
277
292
|
),
|
|
278
293
|
);
|
|
279
|
-
return readCapabilityResponse(
|
|
294
|
+
return await readCapabilityResponse(
|
|
295
|
+
response,
|
|
296
|
+
CapabilityActionReviewDataSchema,
|
|
297
|
+
CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
|
|
298
|
+
combineCapabilitySignals(caller.signal, invocation.signal),
|
|
299
|
+
);
|
|
280
300
|
} catch (cause) {
|
|
281
301
|
return unavailable(cause, undefined, caller.locale ?? undefined);
|
|
282
302
|
}
|
package/src/cli/access.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { principalLabel } from "../access/principal-label";
|
|
1
2
|
import type { AccessEntry, EntityListItem, PermissionLevel, Principal } from "../contracts";
|
|
2
3
|
import { arg, command, confirmFlag, flag, paginationFlags } from "./commands";
|
|
3
4
|
import type { CloudCliContext, CloudCliTableColumn } from "./index";
|
|
@@ -120,21 +121,7 @@ const principalKey = (principal: Principal): string => {
|
|
|
120
121
|
}
|
|
121
122
|
};
|
|
122
123
|
|
|
123
|
-
const entryDisplayName = (entry: AccessEntry): string =>
|
|
124
|
-
if (entry.displayName) return entry.displayName;
|
|
125
|
-
switch (entry.principal.type) {
|
|
126
|
-
case "user":
|
|
127
|
-
return entry.principal.userId;
|
|
128
|
-
case "group":
|
|
129
|
-
return entry.principal.groupId;
|
|
130
|
-
case "service_account":
|
|
131
|
-
return entry.principal.serviceAccountId;
|
|
132
|
-
case "authenticated":
|
|
133
|
-
return "All users (incl. guests)";
|
|
134
|
-
case "public":
|
|
135
|
-
return "Public";
|
|
136
|
-
}
|
|
137
|
-
};
|
|
124
|
+
const entryDisplayName = (entry: AccessEntry, locale?: string): string => principalLabel(entry, locale);
|
|
138
125
|
|
|
139
126
|
const formatEntityCandidate = (item: EntityListItem): string => {
|
|
140
127
|
if (item.kind === "user") return `${item.user.displayName} (${item.user.uid}${item.user.mail ? `, ${item.user.mail}` : ""})`;
|
|
@@ -257,17 +244,20 @@ const entryTypeLabel = (entry: AccessEntry): string => {
|
|
|
257
244
|
};
|
|
258
245
|
|
|
259
246
|
// Resource-bound service accounts back resource API keys, which are managed with those keys.
|
|
260
|
-
const accessRows = (
|
|
247
|
+
const accessRows = (
|
|
248
|
+
entries: AccessEntry[],
|
|
249
|
+
options: { includeServiceAccounts?: boolean; scopes?: readonly string[]; locale?: string } = {},
|
|
250
|
+
) =>
|
|
261
251
|
entries
|
|
262
252
|
.filter((entry) => options.includeServiceAccounts || entry.serviceAccountKind !== "resource_bound")
|
|
263
253
|
.sort((a, b) => {
|
|
264
254
|
const rank = permissionRank(b.permission) - permissionRank(a.permission);
|
|
265
255
|
if (rank !== 0) return rank;
|
|
266
|
-
return entryDisplayName(a).localeCompare(entryDisplayName(b));
|
|
256
|
+
return entryDisplayName(a, options.locale).localeCompare(entryDisplayName(b, options.locale));
|
|
267
257
|
})
|
|
268
258
|
.map((entry) => ({
|
|
269
259
|
accessId: entry.id,
|
|
270
|
-
principal: entryDisplayName(entry),
|
|
260
|
+
principal: entryDisplayName(entry, options.locale),
|
|
271
261
|
type: entryTypeLabel(entry),
|
|
272
262
|
permission: entry.permission,
|
|
273
263
|
...(options.scopes ? { scope: entry.scope ?? options.scopes[0] } : {}),
|
|
@@ -287,7 +277,7 @@ export const printAccessEntries = (
|
|
|
287
277
|
options: { includeServiceAccounts?: boolean; jsonValue?: unknown; scopes?: readonly string[] } = {},
|
|
288
278
|
) => {
|
|
289
279
|
if (printStructured(ctx, options.jsonValue ?? entries)) return;
|
|
290
|
-
const rows = accessRows(entries, options);
|
|
280
|
+
const rows = accessRows(entries, { ...options, locale: ctx.options.locale });
|
|
291
281
|
if (rows.length === 0) {
|
|
292
282
|
ctx.print("No direct grants.");
|
|
293
283
|
return;
|
|
@@ -426,7 +416,7 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
|
|
|
426
416
|
const principal = await resolveAccessPrincipal(ctx, flags as PrincipalFlags, adapter);
|
|
427
417
|
const entry = await adapter.grant(ctx, resource, principal, permission, scope);
|
|
428
418
|
if (!printStructured(ctx, { resource, entry }))
|
|
429
|
-
ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry)}.`);
|
|
419
|
+
ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry, ctx.options.locale)}.`);
|
|
430
420
|
},
|
|
431
421
|
});
|
|
432
422
|
|
|
@@ -462,13 +452,15 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
|
|
|
462
452
|
if (existing) {
|
|
463
453
|
await adapter.update(ctx, resource, existing.id, permission, scope);
|
|
464
454
|
if (!printStructured(ctx, { resource, accessId: existing.id, permission, scope, action: "updated" })) {
|
|
465
|
-
ctx.print(
|
|
455
|
+
ctx.print(
|
|
456
|
+
`Updated ${entryDisplayName(existing, ctx.options.locale)} to ${permission}${scopeSuffix(scope)} on ${resource.label}.`,
|
|
457
|
+
);
|
|
466
458
|
}
|
|
467
459
|
return;
|
|
468
460
|
}
|
|
469
461
|
const entry = await adapter.grant(ctx, resource, principal, permission, scope);
|
|
470
462
|
if (!printStructured(ctx, { resource, entry, action: "created" })) {
|
|
471
|
-
ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry)}.`);
|
|
463
|
+
ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry, ctx.options.locale)}.`);
|
|
472
464
|
}
|
|
473
465
|
},
|
|
474
466
|
});
|
|
@@ -496,7 +488,7 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
|
|
|
496
488
|
const entry = entries.find((candidate) => principalKey(candidate.principal) === principalKey(principal));
|
|
497
489
|
if (!entry) throw new Error("No direct grant for that principal.");
|
|
498
490
|
accessId = entry.id;
|
|
499
|
-
label = entryDisplayName(entry);
|
|
491
|
+
label = entryDisplayName(entry, ctx.options.locale);
|
|
500
492
|
} else if (principalFlagCount(flags as PrincipalFlags) > 0) {
|
|
501
493
|
throw new Error("Pass either --access-id or one principal flag, not both.");
|
|
502
494
|
}
|
package/src/cli/admin/linux.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { type LinuxIdentityConfiguration, LinuxIdentityConfigurationSchema } from "../../contracts/posix";
|
|
2
3
|
import { cliText, command, confirmFlag, flag, printStructured, readCliInput } from "../index";
|
|
3
4
|
import { apiGet, apiJson, queryString } from "./shared";
|
|
@@ -31,6 +32,10 @@ export const linuxCommands = [
|
|
|
31
32
|
flags: {
|
|
32
33
|
config: flag.input({ required: true, description: "Configuration JSON: --config, --config-file, or --stdin" }),
|
|
33
34
|
rangeReserved: flag.boolean({ name: "range-reserved", description: "Confirm the range is reserved across all connected systems" }),
|
|
35
|
+
replaceUnreadable: flag.boolean({
|
|
36
|
+
name: "replace-unreadable",
|
|
37
|
+
description: "Explicitly replace an unreadable stored Linux configuration",
|
|
38
|
+
}),
|
|
34
39
|
yes: confirmFlag("Confirm replacing Linux identity configuration"),
|
|
35
40
|
},
|
|
36
41
|
async run({ ctx, flags }) {
|
|
@@ -40,7 +45,9 @@ export const linuxCommands = [
|
|
|
40
45
|
);
|
|
41
46
|
const input = await readCliInput(flags.config, { label: "Linux configuration", required: true });
|
|
42
47
|
if (input === undefined) throw new Error("Missing Linux configuration.");
|
|
43
|
-
const config = LinuxIdentityConfigurationSchema.
|
|
48
|
+
const { unreadable: _unreadable, ...config } = LinuxIdentityConfigurationSchema.safeExtend({
|
|
49
|
+
unreadable: z.boolean().optional(),
|
|
50
|
+
}).parse(JSON.parse(input));
|
|
44
51
|
if (config.enabled && !flags.rangeReserved)
|
|
45
52
|
throw new Error(
|
|
46
53
|
cliText(ctx, {
|
|
@@ -48,7 +55,11 @@ export const linuxCommands = [
|
|
|
48
55
|
de: "Zum Aktivieren ist --range-reserved erforderlich.",
|
|
49
56
|
}),
|
|
50
57
|
);
|
|
51
|
-
const result = await apiJson<unknown>(ctx, "PUT", `${path}/configuration`, {
|
|
58
|
+
const result = await apiJson<unknown>(ctx, "PUT", `${path}/configuration`, {
|
|
59
|
+
config,
|
|
60
|
+
rangeReserved: flags.rangeReserved,
|
|
61
|
+
...(flags.replaceUnreadable ? { replaceUnreadable: true } : {}),
|
|
62
|
+
});
|
|
52
63
|
if (!printStructured(ctx, result)) ctx.print(JSON.stringify(result, null, 2));
|
|
53
64
|
},
|
|
54
65
|
}),
|
|
@@ -35,13 +35,25 @@ export const outgoingMailCommands = [
|
|
|
35
35
|
flags: {
|
|
36
36
|
contentDays: flag.string({ required: true, description: "Content retention in days" }),
|
|
37
37
|
recordDays: flag.string({ required: true, description: "Record retention in days (at least content retention)" }),
|
|
38
|
+
replaceContent: flag.boolean({ name: "replace-unreadable-content", description: "Explicitly replace unreadable content retention" }),
|
|
39
|
+
replaceRecords: flag.boolean({ name: "replace-unreadable-records", description: "Explicitly replace unreadable record retention" }),
|
|
38
40
|
yes: confirmFlag("Confirm changing outgoing mail retention"),
|
|
39
41
|
},
|
|
40
42
|
async run({ ctx, flags }) {
|
|
41
43
|
confirmed(flags.yes);
|
|
42
44
|
const retention = MailRetentionSchema.parse({ contentDays: Number(flags.contentDays), recordDays: Number(flags.recordDays) });
|
|
43
45
|
if (retention.recordDays < retention.contentDays) throw new Error("Record retention must be at least content retention.");
|
|
44
|
-
const result = await apiJson<MailRetention>(ctx, "PUT", `${root}/retention`,
|
|
46
|
+
const result = await apiJson<MailRetention>(ctx, "PUT", `${root}/retention`, {
|
|
47
|
+
...retention,
|
|
48
|
+
...(flags.replaceContent || flags.replaceRecords
|
|
49
|
+
? {
|
|
50
|
+
replaceUnreadable: [
|
|
51
|
+
...(flags.replaceContent ? ["outgoing_mail.content_retention_days"] : []),
|
|
52
|
+
...(flags.replaceRecords ? ["outgoing_mail.record_retention_days"] : []),
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
56
|
+
});
|
|
45
57
|
if (!printStructured(ctx, result)) print(ctx, result);
|
|
46
58
|
},
|
|
47
59
|
}),
|
|
@@ -183,7 +195,12 @@ export const outgoingMailCommands = [
|
|
|
183
195
|
printJsonOrTable(
|
|
184
196
|
ctx,
|
|
185
197
|
result,
|
|
186
|
-
result.items.map((app) => ({
|
|
198
|
+
result.items.map((app) => ({
|
|
199
|
+
...app,
|
|
200
|
+
reads: app.readDeclared,
|
|
201
|
+
profiles: app.profiles.join(","),
|
|
202
|
+
logApps: app.logApps.map((id) => `${id}:${app.logContentApps?.includes(id) ? "content" : "metadata"}`).join(","),
|
|
203
|
+
})),
|
|
187
204
|
[
|
|
188
205
|
{ key: "appId" },
|
|
189
206
|
{ key: "name" },
|
|
@@ -226,15 +243,20 @@ export const outgoingMailCommands = [
|
|
|
226
243
|
summary: "Choose which other applications' mail logs an app may read",
|
|
227
244
|
args: { app: arg.required({ valueLabel: "app" }) },
|
|
228
245
|
flags: {
|
|
229
|
-
apps: flag.string({ description: "Comma-separated source application IDs" }),
|
|
246
|
+
apps: flag.string({ description: "Comma-separated source application IDs (replaces all grants)" }),
|
|
247
|
+
contentApps: flag.string({ description: "Subset of --apps allowed to read text and attachment names" }),
|
|
230
248
|
ownOnly: flag.boolean({ description: "Remove all other application log grants" }),
|
|
231
249
|
yes: confirmFlag("Confirm application mail log access change"),
|
|
232
250
|
},
|
|
233
251
|
async run({ ctx, args, flags }) {
|
|
234
252
|
confirmed(flags.yes);
|
|
235
253
|
if ((flags.apps !== undefined) === !!flags.ownOnly) throw new Error("Choose exactly one of --apps or --own-only.");
|
|
254
|
+
if (flags.contentApps !== undefined && flags.apps === undefined) throw new Error("--content-apps requires --apps.");
|
|
236
255
|
const reader = MailLogAppIdSchema.safeParse(args.app);
|
|
237
|
-
const value = MailLogAccessSchema.safeParse({
|
|
256
|
+
const value = MailLogAccessSchema.safeParse({
|
|
257
|
+
apps: flags.apps?.split(",").map((app) => app.trim()) ?? [],
|
|
258
|
+
...(flags.contentApps !== undefined ? { contentApps: flags.contentApps.split(",").map((app) => app.trim()) } : {}),
|
|
259
|
+
});
|
|
238
260
|
if (!reader.success || args.app === "core" || !value.success || value.data.apps.includes(args.app))
|
|
239
261
|
throw new Error("Choose a valid reader and distinct source apps other than Core or the reader itself.");
|
|
240
262
|
print(ctx, await apiJson(ctx, "PUT", `${root}/apps/${encodeURIComponent(args.app)}/log-access`, value.data));
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
5
5
|
CAPABILITY_MAX_CATALOG_BYTES,
|
|
6
6
|
CAPABILITY_MAX_RESULT_BYTES,
|
|
7
|
+
CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
|
|
8
|
+
CapabilityActionReviewDataSchema,
|
|
7
9
|
CapabilityCatalogSchema,
|
|
8
10
|
CapabilityErrorSchema,
|
|
9
11
|
type CapabilityManifest,
|
|
@@ -295,6 +297,23 @@ export default defineCliCommands({
|
|
|
295
297
|
await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id }, manifest: app.manifest });
|
|
296
298
|
},
|
|
297
299
|
}),
|
|
300
|
+
command("review", {
|
|
301
|
+
summary: "Read the complete proposed Action without executing it",
|
|
302
|
+
args: { appId: arg.required({ description: "Owning app id" }), actionId: arg.required({ description: "App-local action id" }) },
|
|
303
|
+
flags: { input: flag.input({ required: true, description: "Strict JSON input; supports --input-file or --stdin" }) },
|
|
304
|
+
run: async ({ ctx, args, flags }) => {
|
|
305
|
+
const response = await ctx.fetch(
|
|
306
|
+
`/api/capabilities/v1/actions/${encodeURIComponent(args.appId)}/${encodeURIComponent(args.actionId)}/review`,
|
|
307
|
+
{
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "content-type": "application/json" },
|
|
310
|
+
body: JSON.stringify({ input: await parseInput(flags.input) }),
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
const review = CapabilityActionReviewDataSchema.parse(await readCapabilityJson(response, CAPABILITY_MAX_REVIEW_RESPONSE_BYTES));
|
|
314
|
+
printGenericResult(ctx, review);
|
|
315
|
+
},
|
|
316
|
+
}),
|
|
298
317
|
command("action", {
|
|
299
318
|
summary: "Invoke an app mutation",
|
|
300
319
|
args: {
|
package/src/config/env.ts
CHANGED
|
@@ -98,12 +98,6 @@ const registry = defineEnv({
|
|
|
98
98
|
scope: "runtime",
|
|
99
99
|
doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for outgoing mail SMTP and bounce IMAP connections; unset allows only public targets, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
|
|
100
100
|
},
|
|
101
|
-
GEO_ALLOWED_TARGETS: {
|
|
102
|
-
schema: envList,
|
|
103
|
-
default: ["geo"],
|
|
104
|
-
scope: "runtime",
|
|
105
|
-
doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for Geo requests; unset allows public targets and `geo`, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
|
|
106
|
-
},
|
|
107
101
|
|
|
108
102
|
// Identity and OAuth
|
|
109
103
|
CLOUD_IDENTITY_KEY_ENCRYPTION_KEY: {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { RequestAuthority } from "../server/middleware/auth";
|
|
3
|
+
import { type Role, RoleSchema } from "./shared";
|
|
4
|
+
|
|
5
|
+
const publicRoutes = z
|
|
6
|
+
.array(
|
|
7
|
+
z
|
|
8
|
+
.string()
|
|
9
|
+
.startsWith("/")
|
|
10
|
+
.refine((path) => !path.startsWith("//") && !/[?#]/.test(path)),
|
|
11
|
+
)
|
|
12
|
+
.readonly()
|
|
13
|
+
.optional();
|
|
14
|
+
export const FixedAppAccessRuleSchema = z.discriminatedUnion("kind", [
|
|
15
|
+
z.object({ kind: z.literal("signedIn") }).strict(),
|
|
16
|
+
z.object({ kind: z.literal("noGuests") }).strict(),
|
|
17
|
+
z.object({ kind: z.literal("roles"), roles: z.array(RoleSchema).min(1).readonly() }).strict(),
|
|
18
|
+
]);
|
|
19
|
+
export type FixedAppAccessRule = z.infer<typeof FixedAppAccessRuleSchema>;
|
|
20
|
+
export const AppAccessRuleSchema = z.union([
|
|
21
|
+
z.object({ kind: z.literal("signedIn"), publicRoutes }).strict(),
|
|
22
|
+
z.object({ kind: z.literal("noGuests"), publicRoutes }).strict(),
|
|
23
|
+
z.object({ kind: z.literal("roles"), roles: z.array(RoleSchema).min(1).readonly(), publicRoutes }).strict(),
|
|
24
|
+
z.object({ kind: z.literal("grants"), until: FixedAppAccessRuleSchema.optional(), publicRoutes }).strict(),
|
|
25
|
+
]);
|
|
26
|
+
/** Serializable entry rule evaluated by the platform; never application callback code. */
|
|
27
|
+
export type AppAccessRule = z.infer<typeof AppAccessRuleSchema>;
|
|
28
|
+
/** The optional management restriction runs in the owning application, in addition to a management grant. */
|
|
29
|
+
export type AppAccessDeclaration = AppAccessRule & {
|
|
30
|
+
canManage?: (authority: RequestAuthority) => boolean | Promise<boolean>;
|
|
31
|
+
};
|
|
32
|
+
type PublicOptions = { publicRoutes?: readonly string[] };
|
|
33
|
+
export const appAccess = {
|
|
34
|
+
signedIn: (options: PublicOptions = {}): AppAccessRule & { kind: "signedIn" } => ({ kind: "signedIn", ...options }),
|
|
35
|
+
noGuests: (options: PublicOptions = {}): AppAccessRule & { kind: "noGuests" } => ({ kind: "noGuests", ...options }),
|
|
36
|
+
roles: (roles: readonly Role[], options: PublicOptions = {}): AppAccessRule & { kind: "roles" } => ({ kind: "roles", roles, ...options }),
|
|
37
|
+
grants: (
|
|
38
|
+
options: PublicOptions & { until?: FixedAppAccessRule; canManage?: AppAccessDeclaration["canManage"] } = {},
|
|
39
|
+
): AppAccessDeclaration => ({ kind: "grants", ...options }),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const compileAppAccess = (declaration: AppAccessDeclaration | undefined): AppAccessRule | undefined => {
|
|
43
|
+
if (!declaration) return undefined;
|
|
44
|
+
const { canManage, ...rule } = declaration;
|
|
45
|
+
if (canManage && (rule.kind !== "grants" || typeof canManage !== "function"))
|
|
46
|
+
throw new Error("Only configurable app access accepts canManage");
|
|
47
|
+
return AppAccessRuleSchema.parse(rule);
|
|
48
|
+
};
|
package/src/contracts/app.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Sync } from "@k2b/sync";
|
|
2
|
+
import type { AppAccessRule } from "./app-access";
|
|
2
3
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
3
4
|
import type { AppRegistryHelpSummary } from "./registry";
|
|
4
5
|
import type { Role } from "./shared";
|
|
@@ -15,7 +16,7 @@ export type LegalLink = {
|
|
|
15
16
|
icon?: string;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
/** Static page links for the global search UI,
|
|
19
|
+
/** Static page links for the global search UI, shown before resource results.
|
|
19
20
|
* These declare navigation only; they do not create capabilities or readers.
|
|
20
21
|
*/
|
|
21
22
|
export type AppSearchLink = {
|
|
@@ -24,6 +25,7 @@ export type AppSearchLink = {
|
|
|
24
25
|
description?: string;
|
|
25
26
|
href: string;
|
|
26
27
|
icon?: string;
|
|
28
|
+
/** Alternate search terms. At `nav.href`, these also match the automatic app launcher. */
|
|
27
29
|
keywords?: readonly string[];
|
|
28
30
|
};
|
|
29
31
|
|
|
@@ -121,6 +123,7 @@ export type AppPanel = {
|
|
|
121
123
|
};
|
|
122
124
|
|
|
123
125
|
export type AppMeta = {
|
|
126
|
+
access?: AppAccessRule;
|
|
124
127
|
platformPermissions?: readonly PlatformPermission[];
|
|
125
128
|
id: string;
|
|
126
129
|
name: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { APP_APPROVAL_LIMITS } from "./app-approval";
|
|
3
|
-
import {
|
|
3
|
+
import { CapabilityActionReviewPresentationSchema, CapabilityAppIdSchema } from "./capabilities";
|
|
4
4
|
|
|
5
5
|
export { APPROVAL_COUNTS_PATH, APPROVALS_API_PATH, APPROVALS_PAGE_PATH } from "./approval-paths";
|
|
6
6
|
|
|
@@ -42,7 +42,7 @@ export const PendingApprovalSchema = z
|
|
|
42
42
|
/** What the person reads: the first line names the app and the Action, later lines add `Label: value` details. */
|
|
43
43
|
sentence: z.string(),
|
|
44
44
|
/** The owning app's review as the person saw it when Cloud asked. */
|
|
45
|
-
review:
|
|
45
|
+
review: CapabilityActionReviewPresentationSchema.nullable(),
|
|
46
46
|
/** Where the request waits. The title is chat content, written by a person or a model, never Cloud's own words. */
|
|
47
47
|
requester: z.object({ kind: z.literal("chat"), appId: z.string(), title: z.string().nullable(), href: z.string() }).strict(),
|
|
48
48
|
/** Ways the person may remember this approval, the same the chat card offers. */
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape set of built avatars: a person's comic avatar and an agent's robot. A stored config names one option per
|
|
5
|
+
* part by a stable id. Ids are never removed or renamed once released, so every stored config keeps rendering; new
|
|
6
|
+
* options only add ids. `v` changes only when the schema itself breaks, and each version keeps its own schema and
|
|
7
|
+
* drawing.
|
|
8
|
+
*/
|
|
9
|
+
export const AVATAR_SHAPE_SET_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export const PERSON_FACES = ["round", "oval", "square"] as const;
|
|
12
|
+
export const PERSON_SKIN_TONES = ["s1", "s2", "s3", "s4", "s5", "s6"] as const;
|
|
13
|
+
/** Every hairstyle passed a human review for unwanted associations; see the review list in the renderer tests. */
|
|
14
|
+
export const PERSON_HAIRSTYLES = ["none", "short", "buzz", "curly", "afro", "long", "bob", "bun", "locs"] as const;
|
|
15
|
+
export const PERSON_HAIR_COLORS = ["black", "dark", "brown", "blond", "red", "grey", "white", "blue", "pink"] as const;
|
|
16
|
+
export const PERSON_EYES = ["dots", "ovals", "happy", "wink", "calm"] as const;
|
|
17
|
+
export const PERSON_BROWS = ["none", "straight", "arched", "thick", "raised"] as const;
|
|
18
|
+
export const PERSON_MOUTHS = ["smile", "grin", "neutral", "oh", "smirk"] as const;
|
|
19
|
+
export const PERSON_BEARDS = ["none", "stubble", "moustache", "goatee", "full"] as const;
|
|
20
|
+
export const PERSON_GLASSES = ["none", "round", "square", "half", "sun"] as const;
|
|
21
|
+
export const PERSON_HEADWEAR = ["none", "beanie", "cap", "hijab", "turban", "kippa", "phones"] as const;
|
|
22
|
+
export const PERSON_EXTRAS = ["none", "freckles", "blush", "earrings", "mole"] as const;
|
|
23
|
+
export const AVATAR_BACKGROUNDS = ["sky", "mint", "sand", "rose", "lilac", "stone", "peach", "teal"] as const;
|
|
24
|
+
|
|
25
|
+
export const ROBOT_HEADS = ["square", "round", "capsule", "tv"] as const;
|
|
26
|
+
export const ROBOT_ANTENNAS = ["none", "ball", "double", "dish"] as const;
|
|
27
|
+
export const ROBOT_EYES = ["round", "visor", "pixel", "cyclops", "happy"] as const;
|
|
28
|
+
export const ROBOT_MOUTHS = ["grill", "smile", "led", "line", "none"] as const;
|
|
29
|
+
export const ROBOT_BODIES = ["plain", "panel", "light", "buttons"] as const;
|
|
30
|
+
export const ROBOT_COLORS = ["steel", "white", "blue", "teal", "coral", "yellow", "purple", "graphite"] as const;
|
|
31
|
+
|
|
32
|
+
export const PersonAvatarSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
v: z.literal(AVATAR_SHAPE_SET_VERSION),
|
|
35
|
+
kind: z.literal("person"),
|
|
36
|
+
face: z.enum(PERSON_FACES),
|
|
37
|
+
skin: z.enum(PERSON_SKIN_TONES),
|
|
38
|
+
hair: z.enum(PERSON_HAIRSTYLES),
|
|
39
|
+
hairColor: z.enum(PERSON_HAIR_COLORS),
|
|
40
|
+
eyes: z.enum(PERSON_EYES),
|
|
41
|
+
brows: z.enum(PERSON_BROWS),
|
|
42
|
+
mouth: z.enum(PERSON_MOUTHS),
|
|
43
|
+
beard: z.enum(PERSON_BEARDS),
|
|
44
|
+
glasses: z.enum(PERSON_GLASSES),
|
|
45
|
+
head: z.enum(PERSON_HEADWEAR),
|
|
46
|
+
extra: z.enum(PERSON_EXTRAS),
|
|
47
|
+
bg: z.enum(AVATAR_BACKGROUNDS),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
export type PersonAvatar = z.infer<typeof PersonAvatarSchema>;
|
|
51
|
+
|
|
52
|
+
export const RobotAvatarSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
v: z.literal(AVATAR_SHAPE_SET_VERSION),
|
|
55
|
+
kind: z.literal("robot"),
|
|
56
|
+
head: z.enum(ROBOT_HEADS),
|
|
57
|
+
antenna: z.enum(ROBOT_ANTENNAS),
|
|
58
|
+
eyes: z.enum(ROBOT_EYES),
|
|
59
|
+
mouth: z.enum(ROBOT_MOUTHS),
|
|
60
|
+
body: z.enum(ROBOT_BODIES),
|
|
61
|
+
color: z.enum(ROBOT_COLORS),
|
|
62
|
+
bg: z.enum(AVATAR_BACKGROUNDS),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
export type RobotAvatar = z.infer<typeof RobotAvatarSchema>;
|
|
66
|
+
|
|
67
|
+
export const AvatarConfigSchema = z.discriminatedUnion("kind", [PersonAvatarSchema, RobotAvatarSchema]);
|
|
68
|
+
export type AvatarConfig = z.infer<typeof AvatarConfigSchema>;
|
|
69
|
+
|
|
70
|
+
/** Which picture others see: an uploaded photo, the built avatar, or the fallback (initials for people). */
|
|
71
|
+
export const AVATAR_KINDS = ["photo", "built", "initials"] as const;
|
|
72
|
+
export type AvatarKind = (typeof AVATAR_KINDS)[number];
|
|
73
|
+
|
|
74
|
+
/** A stored config is a handful of short ids; anything larger than this is not a config. */
|
|
75
|
+
export const MAX_AVATAR_CONFIG_BYTES = 1024;
|
|
76
|
+
|
|
77
|
+
/** The JSON text whose hash identifies a built avatar: keys in schema order, so equal configs hash equally. */
|
|
78
|
+
export const canonicalAvatarConfig = (config: AvatarConfig): string =>
|
|
79
|
+
JSON.stringify(config.kind === "person" ? PersonAvatarSchema.parse(config) : RobotAvatarSchema.parse(config));
|
|
80
|
+
|
|
81
|
+
type Random = () => number;
|
|
82
|
+
|
|
83
|
+
const pick = <T>(list: readonly T[], random: Random): T => list[Math.floor(random() * list.length) % list.length] as T;
|
|
84
|
+
|
|
85
|
+
/** A first draft for a person: random, with a calm smile and nothing on top, so the person starts from a plain face. */
|
|
86
|
+
export const startPersonAvatar = (random: Random = Math.random): PersonAvatar => ({
|
|
87
|
+
v: AVATAR_SHAPE_SET_VERSION,
|
|
88
|
+
kind: "person",
|
|
89
|
+
face: pick(PERSON_FACES, random),
|
|
90
|
+
skin: pick(PERSON_SKIN_TONES, random),
|
|
91
|
+
hair: pick(PERSON_HAIRSTYLES.slice(1), random),
|
|
92
|
+
hairColor: pick(PERSON_HAIR_COLORS.slice(0, 6), random),
|
|
93
|
+
eyes: pick(PERSON_EYES, random),
|
|
94
|
+
brows: pick(PERSON_BROWS.slice(1), random),
|
|
95
|
+
mouth: "smile",
|
|
96
|
+
beard: "none",
|
|
97
|
+
glasses: "none",
|
|
98
|
+
head: "none",
|
|
99
|
+
extra: "none",
|
|
100
|
+
bg: pick(AVATAR_BACKGROUNDS, random),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Headwear the random button may choose; religious headwear is only ever the person's own choice. */
|
|
104
|
+
export const RANDOM_PERSON_HEADWEAR = ["beanie", "cap", "phones"] as const;
|
|
105
|
+
|
|
106
|
+
/** "Random" in the builder: everything changes except the skin tone, which stays the person's choice. */
|
|
107
|
+
export const randomizePersonAvatar = (current: PersonAvatar, random: Random = Math.random): PersonAvatar => {
|
|
108
|
+
const maybe = <T extends string>(chance: number, list: readonly T[]): T | "none" => (random() < chance ? pick(list, random) : "none");
|
|
109
|
+
return {
|
|
110
|
+
v: AVATAR_SHAPE_SET_VERSION,
|
|
111
|
+
kind: "person",
|
|
112
|
+
face: pick(PERSON_FACES, random),
|
|
113
|
+
skin: current.skin,
|
|
114
|
+
hair: pick(PERSON_HAIRSTYLES.slice(1), random),
|
|
115
|
+
hairColor: random() < 0.85 ? pick(PERSON_HAIR_COLORS.slice(0, 7), random) : pick(PERSON_HAIR_COLORS, random),
|
|
116
|
+
eyes: pick(PERSON_EYES, random),
|
|
117
|
+
brows: pick(PERSON_BROWS.slice(1), random),
|
|
118
|
+
mouth: pick(PERSON_MOUTHS, random),
|
|
119
|
+
beard: maybe(0.3, PERSON_BEARDS.slice(1)),
|
|
120
|
+
glasses: maybe(0.35, PERSON_GLASSES.slice(1)),
|
|
121
|
+
head: maybe(0.25, RANDOM_PERSON_HEADWEAR),
|
|
122
|
+
extra: maybe(0.35, PERSON_EXTRAS.slice(1)),
|
|
123
|
+
bg: pick(AVATAR_BACKGROUNDS, random),
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const randomRobotAvatar = (random: Random = Math.random): RobotAvatar => ({
|
|
128
|
+
v: AVATAR_SHAPE_SET_VERSION,
|
|
129
|
+
kind: "robot",
|
|
130
|
+
head: pick(ROBOT_HEADS, random),
|
|
131
|
+
antenna: pick(ROBOT_ANTENNAS, random),
|
|
132
|
+
eyes: pick(ROBOT_EYES, random),
|
|
133
|
+
mouth: pick(ROBOT_MOUTHS, random),
|
|
134
|
+
body: pick(ROBOT_BODIES, random),
|
|
135
|
+
color: pick(ROBOT_COLORS, random),
|
|
136
|
+
bg: pick(AVATAR_BACKGROUNDS, random),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** A small seeded generator (cyrb53 seed, mulberry32 stream): the same seed draws the same robot everywhere. */
|
|
140
|
+
export const seededRandom = (seed: string): Random => {
|
|
141
|
+
let h1 = 0xdeadbeef;
|
|
142
|
+
let h2 = 0x41c6ce57;
|
|
143
|
+
for (let index = 0; index < seed.length; index++) {
|
|
144
|
+
const code = seed.charCodeAt(index);
|
|
145
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
146
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
147
|
+
}
|
|
148
|
+
let state = (Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)) >>> 0;
|
|
149
|
+
return () => {
|
|
150
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
151
|
+
let t = state;
|
|
152
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
153
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
154
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The parts a seed draws from, frozen at shape set 1. New robot options join the builder, never this list, so an
|
|
160
|
+
* agent's robot never changes when the shape set grows.
|
|
161
|
+
*/
|
|
162
|
+
const ROBOT_SEED_PARTS_V1 = {
|
|
163
|
+
head: ["square", "round", "capsule", "tv"],
|
|
164
|
+
antenna: ["none", "ball", "double", "dish"],
|
|
165
|
+
eyes: ["round", "visor", "pixel", "cyclops", "happy"],
|
|
166
|
+
mouth: ["grill", "smile", "led", "line", "none"],
|
|
167
|
+
body: ["plain", "panel", "light", "buttons"],
|
|
168
|
+
color: ["steel", "white", "blue", "teal", "coral", "yellow", "purple", "graphite"],
|
|
169
|
+
bg: ["sky", "mint", "sand", "rose", "lilac", "stone", "peach", "teal"],
|
|
170
|
+
} as const satisfies { [Part in Exclude<keyof RobotAvatar, "v" | "kind">]: readonly RobotAvatar[Part][] };
|
|
171
|
+
|
|
172
|
+
/** The robot an agent shows until someone changes it: drawn from its account id, so it never changes by itself. */
|
|
173
|
+
export const robotAvatarFromSeed = (seed: string): RobotAvatar => {
|
|
174
|
+
const random = seededRandom(seed);
|
|
175
|
+
return {
|
|
176
|
+
v: AVATAR_SHAPE_SET_VERSION,
|
|
177
|
+
kind: "robot",
|
|
178
|
+
head: pick(ROBOT_SEED_PARTS_V1.head, random),
|
|
179
|
+
antenna: pick(ROBOT_SEED_PARTS_V1.antenna, random),
|
|
180
|
+
eyes: pick(ROBOT_SEED_PARTS_V1.eyes, random),
|
|
181
|
+
mouth: pick(ROBOT_SEED_PARTS_V1.mouth, random),
|
|
182
|
+
body: pick(ROBOT_SEED_PARTS_V1.body, random),
|
|
183
|
+
color: pick(ROBOT_SEED_PARTS_V1.color, random),
|
|
184
|
+
bg: pick(ROBOT_SEED_PARTS_V1.bg, random),
|
|
185
|
+
};
|
|
186
|
+
};
|