@k2b/cloud 0.45.0 → 0.47.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 +11 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +40 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +6 -0
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +99 -15
- package/src/api/index.ts +3 -0
- package/src/api/mcp.ts +4 -2
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +100 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +69 -0
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +64 -36
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/start.ts +36 -0
- package/src/styles/global.css +2 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
package/src/config/define-env.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Every runtime environment variable is declared once with a schema, a
|
|
5
5
|
* default, a scope and a one-sentence doc. Registries read `process.env`
|
|
6
6
|
* lazily: each key is parsed on access, so tests can change variables and
|
|
7
|
-
* consumers never depend on an import-time snapshot.
|
|
7
|
+
* consumers never depend on an import-time snapshot. Configured secret files
|
|
8
|
+
* are also validated when the registry is created. `.env.example`,
|
|
8
9
|
* `.env.prod.example` and the configuration reference are generated from the
|
|
9
10
|
* `$specs` of every registry by `scripts/generate-config-docs.ts`.
|
|
10
11
|
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
11
13
|
import { z } from "zod";
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -17,7 +19,7 @@ import { z } from "zod";
|
|
|
17
19
|
export type EnvScope = "runtime" | "secret" | "development";
|
|
18
20
|
|
|
19
21
|
export type EnvSpec<T> = {
|
|
20
|
-
/** Parses the trimmed
|
|
22
|
+
/** Parses the raw value (trimmed for env, one trailing newline removed for files). Empty and unset values never reach the schema. */
|
|
21
23
|
schema: z.ZodType<T, unknown>;
|
|
22
24
|
/** Used when the variable is unset or empty; a function is evaluated on every access. */
|
|
23
25
|
default?: T | (() => T);
|
|
@@ -31,6 +33,8 @@ export type EnvSpec<T> = {
|
|
|
31
33
|
required?: boolean;
|
|
32
34
|
/** Alternative variable names, consulted after the canonical key. */
|
|
33
35
|
aliases?: string[];
|
|
36
|
+
/** Allow NAME_FILE to supply the secret. Configured files are validated when the registry is created. */
|
|
37
|
+
secretFile?: boolean;
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
export type EnvSpecs = Record<string, EnvSpec<unknown>>;
|
|
@@ -42,20 +46,59 @@ type EnvValue<S extends EnvSpec<unknown>> = S extends { default: unknown } | { r
|
|
|
42
46
|
export type EnvRegistry<S extends EnvSpecs> = { readonly [K in keyof S]: EnvValue<S[K]> } & {
|
|
43
47
|
readonly $specs: S;
|
|
44
48
|
readonly $keys: readonly string[];
|
|
49
|
+
/** Read only an explicitly supplied value, without the code default. */
|
|
50
|
+
readonly $readPresent: <K extends keyof S & string>(key: K) => z.output<S[K]["schema"]> | undefined;
|
|
45
51
|
};
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
/** Effective value source; empty values fall through to the default or unset. */
|
|
54
|
+
export type EnvSource = "env" | "alias" | "file" | "default" | "unset";
|
|
55
|
+
|
|
56
|
+
type RawEnv = { value: string | undefined; source: "env" | "alias" | "file" | "unset" };
|
|
57
|
+
|
|
58
|
+
// A mounted secret is a startup input, not a live configuration channel. A new
|
|
59
|
+
// path is still observed when tests or tooling deliberately change process.env.
|
|
60
|
+
const fileValues = new WeakMap<EnvSpec<unknown>, { path: string; value: string }>();
|
|
61
|
+
|
|
62
|
+
const rawValue = (key: string, spec: EnvSpec<unknown>): RawEnv => {
|
|
63
|
+
const names = [key, ...(spec.aliases ?? [])];
|
|
64
|
+
const fileKey = `${key}_FILE`;
|
|
65
|
+
const path = spec.secretFile ? process.env[fileKey]?.trim() || undefined : undefined;
|
|
66
|
+
if (path !== undefined) {
|
|
67
|
+
const conflict = names.find((name) => process.env[name]?.trim());
|
|
68
|
+
if (conflict) throw new Error(`${conflict} and ${fileKey} must not both be set`);
|
|
69
|
+
const cached = fileValues.get(spec);
|
|
70
|
+
let value = cached?.path === path ? cached.value : undefined;
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
try {
|
|
73
|
+
value = readFileSync(path, "utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
// File errors must identify the source without including contents or an underlying error.
|
|
76
|
+
throw new Error(`${fileKey} could not read file ${JSON.stringify(path)}`);
|
|
77
|
+
}
|
|
78
|
+
if (value.endsWith("\r\n")) value = value.slice(0, -2);
|
|
79
|
+
else if (value.endsWith("\n")) value = value.slice(0, -1);
|
|
80
|
+
fileValues.set(spec, { path, value });
|
|
81
|
+
}
|
|
82
|
+
return { value: value === "" ? undefined : value, source: value === "" ? "unset" : "file" };
|
|
83
|
+
}
|
|
84
|
+
for (const name of names) {
|
|
49
85
|
const value = process.env[name]?.trim();
|
|
50
|
-
if (value) return value;
|
|
86
|
+
if (value) return { value, source: name === key ? "env" : "alias" };
|
|
51
87
|
}
|
|
52
|
-
return undefined;
|
|
88
|
+
return { value: undefined, source: "unset" };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Inspect the effective source without evaluating defaults; invalid file configuration throws as on value access. */
|
|
92
|
+
export const envSource = <S extends EnvSpecs>(registry: EnvRegistry<S>, key: keyof S & string): EnvSource => {
|
|
93
|
+
const spec = registry.$specs[key]!;
|
|
94
|
+
const { source } = rawValue(key, spec);
|
|
95
|
+
return source === "unset" && spec.default !== undefined ? "default" : source;
|
|
53
96
|
};
|
|
54
97
|
|
|
55
98
|
const isFactory = <T>(value: T | (() => T)): value is () => T => typeof value === "function";
|
|
56
99
|
|
|
57
100
|
export const readEnv = <T>(key: string, spec: EnvSpec<T>): T | undefined => {
|
|
58
|
-
const raw = rawValue(key, spec
|
|
101
|
+
const { value: raw } = rawValue(key, spec);
|
|
59
102
|
if (raw === undefined) {
|
|
60
103
|
if (spec.default !== undefined) return isFactory(spec.default) ? spec.default() : spec.default;
|
|
61
104
|
if (spec.required) throw new Error(`${key} is required`);
|
|
@@ -63,6 +106,7 @@ export const readEnv = <T>(key: string, spec: EnvSpec<T>): T | undefined => {
|
|
|
63
106
|
}
|
|
64
107
|
const parsed = spec.schema.safeParse(raw);
|
|
65
108
|
if (!parsed.success) {
|
|
109
|
+
if (spec.secretFile) throw new Error(`${key} is invalid`);
|
|
66
110
|
const reason = parsed.error.issues.map((issue) => issue.message).join("; ") || "is invalid";
|
|
67
111
|
throw new Error(`${key} ${reason}`);
|
|
68
112
|
}
|
|
@@ -71,8 +115,19 @@ export const readEnv = <T>(key: string, spec: EnvSpec<T>): T | undefined => {
|
|
|
71
115
|
|
|
72
116
|
export const defineEnv = <S extends EnvSpecs>(specs: S): EnvRegistry<S> => {
|
|
73
117
|
const keys = Object.freeze(Object.keys(specs));
|
|
74
|
-
const registry: Record<string, unknown> = {
|
|
118
|
+
const registry: Record<string, unknown> = {
|
|
119
|
+
$specs: specs,
|
|
120
|
+
$keys: keys,
|
|
121
|
+
$readPresent: (key: string) => {
|
|
122
|
+
const spec = specs[key];
|
|
123
|
+
if (!spec) throw new Error(`Unknown environment variable: ${key}`);
|
|
124
|
+
return readEnv(key, { ...spec, default: undefined, required: false });
|
|
125
|
+
},
|
|
126
|
+
};
|
|
75
127
|
for (const key of keys) {
|
|
128
|
+
const spec = specs[key]!;
|
|
129
|
+
// Fail during module initialization, even when this secret is first used by a later request.
|
|
130
|
+
if (spec.secretFile && process.env[`${key}_FILE`]?.trim()) readEnv(key, spec);
|
|
76
131
|
Object.defineProperty(registry, key, { enumerable: true, configurable: true, get: () => readEnv(key, specs[key]!) });
|
|
77
132
|
}
|
|
78
133
|
// The getters above are the only way to build a typed lazy object.
|
package/src/config/env.ts
CHANGED
|
@@ -4,23 +4,14 @@
|
|
|
4
4
|
* `src/env.ts`; values are parsed lazily on access (see `define-env.ts`).
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { DEFAULT_TRUSTED_PROXIES } from "../services/settings/validation";
|
|
7
8
|
import { defineEnv, envBoolean, envList } from "./define-env";
|
|
9
|
+
import { DEFAULT_WORKFLOW_AI_BUDGET, workflowAiBudgetSchema } from "./workflow-ai";
|
|
8
10
|
|
|
9
11
|
const syncReplicas = z
|
|
10
12
|
.enum(["1", "3", "5"], { error: "must be 1, 3, or 5" })
|
|
11
13
|
.transform((value): 1 | 3 | 5 => (value === "1" ? 1 : value === "3" ? 3 : 5));
|
|
12
14
|
|
|
13
|
-
const budgetSchema = z
|
|
14
|
-
.object({
|
|
15
|
-
maxStepsPerSource: z.number().int().positive(),
|
|
16
|
-
tasksPerMinute: z.number().int().positive(),
|
|
17
|
-
burst: z.number().int().positive(),
|
|
18
|
-
sourcesPer24Hours: z.number().int().positive(),
|
|
19
|
-
scopeConcurrent: z.number().int().positive(),
|
|
20
|
-
globalConcurrent: z.number().int().positive(),
|
|
21
|
-
})
|
|
22
|
-
.strict();
|
|
23
|
-
|
|
24
15
|
const registry = defineEnv({
|
|
25
16
|
// Process
|
|
26
17
|
NODE_ENV: {
|
|
@@ -35,6 +26,7 @@ const registry = defineEnv({
|
|
|
35
26
|
// Infrastructure
|
|
36
27
|
DATABASE_URL: {
|
|
37
28
|
schema: z.string(),
|
|
29
|
+
aliases: ["DATABASEURL", "TLS_DATABASE_URL", "POSTGRES_URL", "PGURL", "PG_URL", "TLS_POSTGRES_DATABASE_URL"],
|
|
38
30
|
example: "postgresql://ipa:ipa@localhost:5432/ipa",
|
|
39
31
|
doc: "Postgres connection read by Bun SQL and by application-owned pools.",
|
|
40
32
|
},
|
|
@@ -54,7 +46,7 @@ const registry = defineEnv({
|
|
|
54
46
|
schema: z.string(),
|
|
55
47
|
default: "",
|
|
56
48
|
example: "dev",
|
|
57
|
-
doc: "Sync namespace shared by every app of one installation;
|
|
49
|
+
doc: "Sync namespace shared by every app of one installation; separates NATS resource names, not access. Use one NATS account per installation.",
|
|
58
50
|
},
|
|
59
51
|
SYNC_REPLICAS: {
|
|
60
52
|
schema: syncReplicas,
|
|
@@ -88,9 +80,9 @@ const registry = defineEnv({
|
|
|
88
80
|
return z.NEVER;
|
|
89
81
|
}
|
|
90
82
|
})
|
|
91
|
-
.pipe(
|
|
92
|
-
default:
|
|
93
|
-
doc: "JSON scoped workflow inference budget: active or successful logical requests per source/24h, token-bucket admission per minute and burst, distinct sources per rolling 24h, outstanding tasks per scope and globally; capacity/rate overload uses bounded workflow retries, daily/source limits fail visibly, and cache hits consume no reservation.",
|
|
83
|
+
.pipe(workflowAiBudgetSchema),
|
|
84
|
+
default: DEFAULT_WORKFLOW_AI_BUDGET,
|
|
85
|
+
doc: "Deprecated: bootstraps `ai.workflow_budget` once when no stored value exists; remove after `cld admin settings status --env` succeeds. JSON scoped workflow inference budget: active or successful logical requests per source/24h, token-bucket admission per minute and burst, distinct sources per rolling 24h, outstanding tasks per scope and globally; capacity/rate overload uses bounded workflow retries, daily/source limits fail visibly, and cache hits consume no reservation.",
|
|
94
86
|
},
|
|
95
87
|
|
|
96
88
|
// Workflow inference: p95 ~4s in the pilot; 60s bounds a whole attempt,
|
|
@@ -98,12 +90,12 @@ const registry = defineEnv({
|
|
|
98
90
|
WORKFLOW_AI_ATTEMPT_TIMEOUT_MS: {
|
|
99
91
|
schema: z.coerce.number().int().positive(),
|
|
100
92
|
default: 60_000,
|
|
101
|
-
doc: "Absolute workflow AI attempt budget in milliseconds, including admission and repair; expiry aborts the provider and follows the bounded retry policy.",
|
|
93
|
+
doc: "Deprecated: bootstraps `ai.workflow_attempt_timeout_ms` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Absolute workflow AI attempt budget in milliseconds, including admission and repair; expiry aborts the provider and follows the bounded retry policy.",
|
|
102
94
|
},
|
|
103
95
|
WORKFLOW_AI_QUEUE_TIMEOUT_MS: {
|
|
104
96
|
schema: z.coerce.number().int().positive(),
|
|
105
97
|
default: 60_000,
|
|
106
|
-
doc: "Maximum workflow AI queue wait in milliseconds; recovery fails overdue or lost deliveries visibly so an operator can retry the workflow.",
|
|
98
|
+
doc: "Deprecated: bootstraps `ai.workflow_queue_timeout_ms` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Maximum workflow AI queue wait in milliseconds; recovery fails overdue or lost deliveries visibly so an operator can retry the workflow.",
|
|
107
99
|
},
|
|
108
100
|
|
|
109
101
|
// Installation
|
|
@@ -115,12 +107,13 @@ const registry = defineEnv({
|
|
|
115
107
|
GOTENBERG_URL: {
|
|
116
108
|
schema: z.string(),
|
|
117
109
|
example: "http://localhost:3001",
|
|
118
|
-
doc: "Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
|
|
110
|
+
doc: "Deprecated: env only bootstraps `gotenberg.url`; remove after `cld admin settings status --env` succeeds. Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
|
|
119
111
|
},
|
|
120
112
|
APP_SECRET: {
|
|
121
113
|
schema: z.string(),
|
|
122
114
|
default: "",
|
|
123
115
|
scope: "secret",
|
|
116
|
+
secretFile: true,
|
|
124
117
|
example: "change-me-generate-with-openssl-rand-hex-32",
|
|
125
118
|
doc: "Deployment-wide secret for at-rest settings encryption and signed cursors; generate once with `openssl rand -hex 32` and never change it after data has been written.",
|
|
126
119
|
},
|
|
@@ -128,37 +121,49 @@ const registry = defineEnv({
|
|
|
128
121
|
schema: z.string(),
|
|
129
122
|
default: "",
|
|
130
123
|
scope: "secret",
|
|
124
|
+
secretFile: true,
|
|
131
125
|
doc: "Core only: temporary first-administrator or recovery login token; remove it after normal administrator sign-in works.",
|
|
132
126
|
},
|
|
133
127
|
|
|
128
|
+
// Gateway trust boundary
|
|
129
|
+
GATEWAY_TRUSTED_PROXIES: {
|
|
130
|
+
schema: envList,
|
|
131
|
+
default: DEFAULT_TRUSTED_PROXIES,
|
|
132
|
+
doc: "Deprecated: bootstraps `gateway.trusted_proxies` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Comma-separated IP addresses or CIDR ranges of the reverse proxies allowed to name the client in `X-Forwarded-For`; the default trusts loopback and private networks, which covers Traefik on a Docker network. Set the proxy's exact address or subnet when clients on private networks reach the gateway directly or through a proxy that appends to their `X-Forwarded-For`. Invalid bootstrap input with no stored row and invalid stored proxy lists stop gateway startup.",
|
|
133
|
+
},
|
|
134
|
+
|
|
134
135
|
// Outbound network
|
|
135
136
|
OUTGOING_MAIL_ALLOWED_TARGETS: {
|
|
136
137
|
schema: envList,
|
|
137
138
|
default: [],
|
|
138
139
|
scope: "runtime",
|
|
139
|
-
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`
|
|
140
|
+
doc: "Deprecated: bootstraps `outgoing_mail.allowed_targets` once when no stored value exists; remove after `cld admin settings status --env` succeeds. 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` are ignored with a bootstrap warning.",
|
|
140
141
|
},
|
|
141
142
|
|
|
142
143
|
// Identity and OAuth
|
|
143
144
|
CLOUD_IDENTITY_KEY_ENCRYPTION_KEY: {
|
|
144
145
|
schema: z.string(),
|
|
145
146
|
scope: "secret",
|
|
147
|
+
secretFile: true,
|
|
146
148
|
example: "change-me-generate-with-openssl-rand-hex-32",
|
|
147
149
|
doc: "Core only: 64-hex KEK for platform identity signing keys stored in Postgres; generate once with `openssl rand -hex 32` and never distribute it to other apps.",
|
|
148
150
|
},
|
|
149
151
|
CLOUD_IDENTITY_NEXT_KEY: {
|
|
150
152
|
schema: z.string(),
|
|
151
153
|
scope: "secret",
|
|
154
|
+
secretFile: true,
|
|
152
155
|
doc: "Core only: pre-distributed next KEK before promoting it to current; cannot be combined with `CLOUD_IDENTITY_PREVIOUS_KEY`.",
|
|
153
156
|
},
|
|
154
157
|
CLOUD_IDENTITY_PREVIOUS_KEY: {
|
|
155
158
|
schema: z.string(),
|
|
156
159
|
scope: "secret",
|
|
160
|
+
secretFile: true,
|
|
157
161
|
doc: "Core only: old KEK set during an explicit rolling rewrap of stored identity signing keys, then removed.",
|
|
158
162
|
},
|
|
159
163
|
CLOUD_OAUTH_BROKER_SECRET: {
|
|
160
164
|
schema: z.string(),
|
|
161
165
|
scope: "secret",
|
|
166
|
+
secretFile: true,
|
|
162
167
|
doc: "Core and OAuth only: 64-hex secret that lets the OAuth app request token signatures from Core; generate independently with `openssl rand -hex 32`.",
|
|
163
168
|
},
|
|
164
169
|
CLOUD_CORE_INTERNAL_ORIGIN: {
|
|
@@ -168,11 +173,12 @@ const registry = defineEnv({
|
|
|
168
173
|
CLOUD_APP_CREDENTIAL: {
|
|
169
174
|
schema: z.string(),
|
|
170
175
|
scope: "secret",
|
|
171
|
-
|
|
176
|
+
secretFile: true,
|
|
177
|
+
doc: "Optional host-run app override for the automatically delivered app workload credential; provision through Core's admin workload API with `identity:invoke`.",
|
|
172
178
|
},
|
|
173
179
|
CLOUD_IDENTITY_JWKS_ORIGIN: {
|
|
174
180
|
schema: z.string(),
|
|
175
|
-
doc: "Optional private origin of the Core listener for session and invocation JWKS; unset
|
|
181
|
+
doc: "Deprecated: use `CLOUD_CORE_INTERNAL_ORIGIN`; the explicit old origin remains honored for this release. Optional private origin of the Core listener for session and invocation JWKS; unset uses `CLOUD_CORE_INTERNAL_ORIGIN`, then the public issuer origin.",
|
|
176
182
|
},
|
|
177
183
|
CLOUD_OAUTH_JWKS_ORIGIN: {
|
|
178
184
|
schema: z.string(),
|
package/src/config/index.ts
CHANGED
|
@@ -4,5 +4,16 @@
|
|
|
4
4
|
* by application `src/env.ts` files. SSR config is per-app via defineApp().
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export {
|
|
7
|
+
export {
|
|
8
|
+
defineEnv,
|
|
9
|
+
type EnvRegistry,
|
|
10
|
+
type EnvScope,
|
|
11
|
+
type EnvSource,
|
|
12
|
+
type EnvSpec,
|
|
13
|
+
type EnvSpecs,
|
|
14
|
+
envBoolean,
|
|
15
|
+
envList,
|
|
16
|
+
envSource,
|
|
17
|
+
envString,
|
|
18
|
+
} from "./define-env";
|
|
8
19
|
export { env } from "./env";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const workflowAiBudgetSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
maxStepsPerSource: z.number().int().positive(),
|
|
6
|
+
tasksPerMinute: z.number().int().positive(),
|
|
7
|
+
burst: z.number().int().positive(),
|
|
8
|
+
sourcesPer24Hours: z.number().int().positive(),
|
|
9
|
+
scopeConcurrent: z.number().int().positive(),
|
|
10
|
+
globalConcurrent: z.number().int().positive(),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
13
|
+
export const DEFAULT_WORKFLOW_AI_BUDGET = {
|
|
14
|
+
maxStepsPerSource: 2,
|
|
15
|
+
tasksPerMinute: 10,
|
|
16
|
+
burst: 20,
|
|
17
|
+
sourcesPer24Hours: 2_000,
|
|
18
|
+
scopeConcurrent: 1,
|
|
19
|
+
globalConcurrent: 4,
|
|
20
|
+
};
|
|
21
|
+
export const validateWorkflowAiBudget = (value: unknown): string | undefined => {
|
|
22
|
+
try {
|
|
23
|
+
if (typeof value === "string" && workflowAiBudgetSchema.safeParse(JSON.parse(value)).success) return;
|
|
24
|
+
} catch {
|
|
25
|
+
/* invalid JSON */
|
|
26
|
+
}
|
|
27
|
+
return "Workflow AI budget must be a JSON object with positive whole numbers for maxStepsPerSource, tasksPerMinute, burst, sourcesPer24Hours, scopeConcurrent and globalConcurrent, without extra fields";
|
|
28
|
+
};
|
|
@@ -13,6 +13,7 @@ import type { RequestAuthority } from "../server/middleware/auth";
|
|
|
13
13
|
import type { AppEventDefinition, AppEventSubscriptionDefinition } from "./app-events";
|
|
14
14
|
import { CommandPathSchema } from "./commands";
|
|
15
15
|
import type { AccessSubject, RequestActor, User } from "./shared";
|
|
16
|
+
import { AuthenticatedPrincipalSchema } from "./shared";
|
|
16
17
|
|
|
17
18
|
export const CAPABILITY_PROTOCOL_VERSION = 2 as const;
|
|
18
19
|
export const CAPABILITY_MAX_REQUEST_BYTES = 256 * 1024;
|
|
@@ -121,6 +122,38 @@ export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
|
|
|
121
122
|
|
|
122
123
|
export type CloudResourceReference = z.infer<typeof CloudResourceReferenceSchema>;
|
|
123
124
|
|
|
125
|
+
/** Authorized presentation returned as `data.view` by a canonical resource reader. */
|
|
126
|
+
export const CloudResourceReaderViewSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
title: z.string().min(1).max(500),
|
|
129
|
+
context: CloudResourceReferenceSchema.shape.context,
|
|
130
|
+
status: CloudResourceReferenceSchema.shape.status,
|
|
131
|
+
done: z.boolean().optional(),
|
|
132
|
+
permission: z.enum(["read", "write", "admin"]),
|
|
133
|
+
container: z
|
|
134
|
+
.object({ ref: CloudResourceRefSchema, title: z.string().min(1).max(500) })
|
|
135
|
+
.strict()
|
|
136
|
+
.optional(),
|
|
137
|
+
})
|
|
138
|
+
.strict();
|
|
139
|
+
export type CloudResourceReaderView = z.infer<typeof CloudResourceReaderViewSchema>;
|
|
140
|
+
|
|
141
|
+
/** Concrete accounts, reusing the grant principal identities; never a group or wildcard. */
|
|
142
|
+
export const ResourcePrincipalSchema = z.discriminatedUnion("type", [
|
|
143
|
+
AuthenticatedPrincipalSchema.options[0].strict(),
|
|
144
|
+
AuthenticatedPrincipalSchema.options[2].strict(),
|
|
145
|
+
]);
|
|
146
|
+
export type ResourcePrincipal = z.infer<typeof ResourcePrincipalSchema>;
|
|
147
|
+
export const ResourceReadableInputSchema = z
|
|
148
|
+
.object({
|
|
149
|
+
id: CloudResourceRefSchema.shape.id,
|
|
150
|
+
container: CloudResourceRefSchema.optional().describe("Expected current container; required for a contained type."),
|
|
151
|
+
principals: z.array(ResourcePrincipalSchema).max(500).describe("Concrete accounts whose current resource readability is checked."),
|
|
152
|
+
})
|
|
153
|
+
.strict();
|
|
154
|
+
export type ResourceReadableInput = z.output<typeof ResourceReadableInputSchema>;
|
|
155
|
+
export const ResourceReadableDataSchema = z.object({ readable: z.array(ResourcePrincipalSchema).max(500) }).strict();
|
|
156
|
+
|
|
124
157
|
// Consumer permalinks may encode all 512 UTF-16 units of an id (up to 4,608 characters).
|
|
125
158
|
export const CloudResourceHrefSchema = z.string().min(1).max(8192).regex(CLOUD_PATH_PATTERN);
|
|
126
159
|
|
|
@@ -832,6 +865,15 @@ export type CapabilityResourceTypeDefinition = {
|
|
|
832
865
|
/** One colour per kind of resource, so a card or list shows at a glance what it stands for. */
|
|
833
866
|
color?: CapabilityResourceColor;
|
|
834
867
|
reader?: string;
|
|
868
|
+
/** Local type in the same application. Describes containment, never inherited authority. */
|
|
869
|
+
container?: string;
|
|
870
|
+
access?: {
|
|
871
|
+
/** Return only readable input principals, using current grants and account status, never credential scopes. */
|
|
872
|
+
readable?: (
|
|
873
|
+
input: ResourceReadableInput,
|
|
874
|
+
context: CapabilityAppExecutionContext,
|
|
875
|
+
) => readonly ResourcePrincipal[] | Promise<readonly ResourcePrincipal[]>;
|
|
876
|
+
};
|
|
835
877
|
/** Root-relative pages recognized when a person pastes a Cloud link. Requires a reader. */
|
|
836
878
|
pages?: readonly string[];
|
|
837
879
|
};
|
|
@@ -911,7 +953,23 @@ export type CapabilityPresentationCatalog = {
|
|
|
911
953
|
translations: Readonly<Record<string, CapabilityPresentationTranslation>>;
|
|
912
954
|
};
|
|
913
955
|
|
|
956
|
+
/** App authority has no grant-bearing subject, user, nested exchange, or interactive credential. */
|
|
957
|
+
export type CapabilityAppExecutionContext = {
|
|
958
|
+
actor: { kind: "app"; appId: string };
|
|
959
|
+
accessSubject: { type: "none" };
|
|
960
|
+
user: null;
|
|
961
|
+
authority?: never;
|
|
962
|
+
query?: never;
|
|
963
|
+
idempotencyKey?: never;
|
|
964
|
+
locale: string;
|
|
965
|
+
requestId: string;
|
|
966
|
+
origin: CapabilityOrigin;
|
|
967
|
+
via?: CapabilityVia;
|
|
968
|
+
signal: AbortSignal;
|
|
969
|
+
};
|
|
970
|
+
|
|
914
971
|
export type CapabilityQueryDefinition<Input extends z.ZodType = z.ZodType<any>, Data extends z.ZodType = z.ZodType<any>> = {
|
|
972
|
+
appOnly?: never;
|
|
915
973
|
title: string;
|
|
916
974
|
description: string;
|
|
917
975
|
input: Input;
|
|
@@ -929,6 +987,7 @@ export type CapabilityIdempotencyPolicy = "none" | "required";
|
|
|
929
987
|
export type CapabilityActionApprovalPolicy = "rememberable" | "none";
|
|
930
988
|
|
|
931
989
|
export type CapabilityActionDefinition<Input extends z.ZodType = z.ZodType<any>, Data extends z.ZodType = z.ZodType<any>> = {
|
|
990
|
+
appOnly?: never;
|
|
932
991
|
title: string;
|
|
933
992
|
description: string;
|
|
934
993
|
input: Input;
|
|
@@ -980,6 +1039,8 @@ export type CapabilityFileProviderDeclaration = { list: string; read: string; sa
|
|
|
980
1039
|
|
|
981
1040
|
export type CapabilityCalendarSourceDeclaration = { calendars: string; events: string; update?: string; move?: string; delete?: string };
|
|
982
1041
|
|
|
1042
|
+
export type CapabilityDiscussionsDeclaration = { item: string; containers: string; activity?: string };
|
|
1043
|
+
|
|
983
1044
|
export type CapabilityDefinitions = {
|
|
984
1045
|
protocolVersion: typeof CAPABILITY_PROTOCOL_VERSION;
|
|
985
1046
|
presentation?: CapabilityPresentationCatalog;
|
|
@@ -989,6 +1050,7 @@ export type CapabilityDefinitions = {
|
|
|
989
1050
|
commands?: CapabilityDefinitionCatalog<CapabilityCommandDefinition>;
|
|
990
1051
|
fileProvider?: CapabilityFileProviderDeclaration;
|
|
991
1052
|
calendarSource?: CapabilityCalendarSourceDeclaration;
|
|
1053
|
+
discussions?: CapabilityDiscussionsDeclaration;
|
|
992
1054
|
events?: CapabilityDefinitionCatalog<AppEventDefinition>;
|
|
993
1055
|
subscribes?: CapabilityDefinitionCatalog<AppEventSubscriptionDefinition>;
|
|
994
1056
|
};
|
|
@@ -1044,6 +1106,7 @@ const CapabilityOperationManifestBaseSchema = z
|
|
|
1044
1106
|
.strict();
|
|
1045
1107
|
|
|
1046
1108
|
export const CapabilityQueryManifestSchema = CapabilityOperationManifestBaseSchema.extend({
|
|
1109
|
+
appOnly: z.literal(true).optional(),
|
|
1047
1110
|
openWorld: z.boolean(),
|
|
1048
1111
|
universalSearch: z
|
|
1049
1112
|
.object({ tags: z.array(CapabilitySearchTagManifestSchema).max(100), scopeTypes: z.array(CapabilityLocalIdSchema).max(100).optional() })
|
|
@@ -1116,6 +1179,24 @@ export const CapabilityResourcePageManifestSchema = z
|
|
|
1116
1179
|
})
|
|
1117
1180
|
.strict();
|
|
1118
1181
|
|
|
1182
|
+
export const CapabilityDiscussionsManifestSchema = z
|
|
1183
|
+
.object({
|
|
1184
|
+
item: CapabilityLocalIdSchema,
|
|
1185
|
+
containers: CapabilityLocalIdSchema,
|
|
1186
|
+
activity: CapabilityLocalIdSchema.optional(),
|
|
1187
|
+
})
|
|
1188
|
+
.strict();
|
|
1189
|
+
export const CapabilityResourceTypeModelSchema = z
|
|
1190
|
+
.object({
|
|
1191
|
+
type: CapabilityLocalIdSchema,
|
|
1192
|
+
container: CapabilityLocalIdSchema.optional(),
|
|
1193
|
+
view: z.literal(true).optional(),
|
|
1194
|
+
/** Availability claim, retained in person catalogs even though the generated query is hidden. */
|
|
1195
|
+
readable: z.literal(true).optional(),
|
|
1196
|
+
})
|
|
1197
|
+
.strict();
|
|
1198
|
+
export type CapabilityResourceTypeModel = z.infer<typeof CapabilityResourceTypeModelSchema>;
|
|
1199
|
+
|
|
1119
1200
|
/** Keeps the sent entries this release reads completely. */
|
|
1120
1201
|
const readEntries = <T extends z.ZodType>(values: unknown, entry: T): z.output<T>[] | null => {
|
|
1121
1202
|
// Not a bounded list: leave the value to the manifest schema, which rejects it.
|
|
@@ -1146,7 +1227,7 @@ const readEntries = <T extends z.ZodType>(values: unknown, entry: T): z.output<T
|
|
|
1146
1227
|
* pages and pages without a readable Type/reader are omitted; live prefix ownership is checked separately.
|
|
1147
1228
|
* Every other entry stays available.
|
|
1148
1229
|
*/
|
|
1149
|
-
const readableManifest = (value: unknown): unknown => {
|
|
1230
|
+
const readableManifest = (value: unknown, personCatalog = false): unknown => {
|
|
1150
1231
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
1151
1232
|
const sent = value as Record<string, unknown>;
|
|
1152
1233
|
const types = readEntries(sent.types, CapabilityResourceTypeManifestSchema);
|
|
@@ -1180,7 +1261,54 @@ const readableManifest = (value: unknown): unknown => {
|
|
|
1180
1261
|
policies[action.localId] = policy;
|
|
1181
1262
|
return true;
|
|
1182
1263
|
});
|
|
1183
|
-
const {
|
|
1264
|
+
const {
|
|
1265
|
+
calendarSource: sentCalendarSource,
|
|
1266
|
+
fileProvider: sentProvider,
|
|
1267
|
+
policies: _policies,
|
|
1268
|
+
resourcePages: sentPages,
|
|
1269
|
+
resourceTypes: sentModels,
|
|
1270
|
+
discussions: sentDiscussions,
|
|
1271
|
+
...rest
|
|
1272
|
+
} = sent;
|
|
1273
|
+
const models = readEntries(sentModels ?? [], CapabilityResourceTypeModelSchema) ?? [];
|
|
1274
|
+
const resourceTypes = models.flatMap((model) => {
|
|
1275
|
+
const type = types.find((type) => type.localId === model.type);
|
|
1276
|
+
if (!type || (model.container && !typeIds.has(model.container))) return [];
|
|
1277
|
+
const seen = new Set([model.type]);
|
|
1278
|
+
let parent = model.container;
|
|
1279
|
+
while (parent) {
|
|
1280
|
+
if (seen.has(parent)) return [];
|
|
1281
|
+
seen.add(parent);
|
|
1282
|
+
parent = models.find((candidate) => candidate.type === parent)?.container;
|
|
1283
|
+
}
|
|
1284
|
+
if (models.filter((candidate) => candidate.type === model.type).length !== 1) return [];
|
|
1285
|
+
const { view, readable, ...rest } = model;
|
|
1286
|
+
return [
|
|
1287
|
+
{
|
|
1288
|
+
...rest,
|
|
1289
|
+
...(readable &&
|
|
1290
|
+
(readableQueries.some((query) => query.localId === `${model.type}.readable` && query.appOnly === true) ||
|
|
1291
|
+
// Core validates the full manifest before hiding app-only queries from person catalogs.
|
|
1292
|
+
(personCatalog &&
|
|
1293
|
+
Array.isArray(sent.queries) &&
|
|
1294
|
+
!sent.queries.some(
|
|
1295
|
+
(query: unknown) =>
|
|
1296
|
+
typeof query === "object" && query !== null && "localId" in query && query.localId === `${model.type}.readable`,
|
|
1297
|
+
)))
|
|
1298
|
+
? { readable }
|
|
1299
|
+
: {}),
|
|
1300
|
+
...(view && type.reader && queryIds.has(type.reader) ? { view } : {}),
|
|
1301
|
+
},
|
|
1302
|
+
];
|
|
1303
|
+
});
|
|
1304
|
+
const role = CapabilityDiscussionsManifestSchema.safeParse(sentDiscussions);
|
|
1305
|
+
const discussions =
|
|
1306
|
+
role.success &&
|
|
1307
|
+
typeIds.has(role.data.item) &&
|
|
1308
|
+
queryIds.has(role.data.containers) &&
|
|
1309
|
+
(!role.data.activity || queryIds.has(role.data.activity))
|
|
1310
|
+
? role.data
|
|
1311
|
+
: undefined;
|
|
1184
1312
|
const provider = CapabilityFileProviderManifestSchema.safeParse(sentProvider);
|
|
1185
1313
|
const calendar = CapabilityCalendarSourceManifestSchema.safeParse(sentCalendarSource);
|
|
1186
1314
|
const pageCounts = new Map<string, number>();
|
|
@@ -1198,6 +1326,8 @@ const readableManifest = (value: unknown): unknown => {
|
|
|
1198
1326
|
: [];
|
|
1199
1327
|
return {
|
|
1200
1328
|
...rest,
|
|
1329
|
+
...(sentModels !== undefined ? { resourceTypes } : {}),
|
|
1330
|
+
...(discussions ? { discussions } : {}),
|
|
1201
1331
|
...(sentPages !== undefined ? { resourcePages } : {}),
|
|
1202
1332
|
...(provider.success ? { fileProvider: provider.data } : {}),
|
|
1203
1333
|
...(calendar.success ? { calendarSource: calendar.data } : {}),
|
|
@@ -1228,7 +1358,7 @@ const readableManifest = (value: unknown): unknown => {
|
|
|
1228
1358
|
* Producers stay strict: `app.start()` registers exactly the manifest its release defines.
|
|
1229
1359
|
*/
|
|
1230
1360
|
export const CapabilityManifestSchema = z.preprocess(
|
|
1231
|
-
readableManifest,
|
|
1361
|
+
(value) => readableManifest(value),
|
|
1232
1362
|
z.object({
|
|
1233
1363
|
protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
|
|
1234
1364
|
appId: CapabilityAppIdSchema,
|
|
@@ -1240,6 +1370,8 @@ export const CapabilityManifestSchema = z.preprocess(
|
|
|
1240
1370
|
// Optional without a default: a manifest without a provider keeps the shape and hash of earlier releases.
|
|
1241
1371
|
fileProvider: CapabilityFileProviderManifestSchema.optional(),
|
|
1242
1372
|
calendarSource: CapabilityCalendarSourceManifestSchema.optional(),
|
|
1373
|
+
discussions: CapabilityDiscussionsManifestSchema.optional(),
|
|
1374
|
+
resourceTypes: z.array(CapabilityResourceTypeModelSchema).max(MAX_MANIFEST_ENTRIES).optional(),
|
|
1243
1375
|
policies: z.record(CapabilityLocalIdSchema, CapabilityActionPolicySchema).optional(),
|
|
1244
1376
|
events: z.array(CapabilityEventManifestSchema).max(MAX_MANIFEST_ENTRIES).optional(),
|
|
1245
1377
|
subscribes: z.array(CapabilitySubscriptionManifestSchema).max(MAX_MANIFEST_ENTRIES).optional(),
|
|
@@ -1297,7 +1429,8 @@ export const CapabilityCatalogAppSchema = z.object({
|
|
|
1297
1429
|
appName: z.string().min(1).max(200),
|
|
1298
1430
|
appIcon: z.string().min(1).max(120),
|
|
1299
1431
|
appDescription: z.string().max(1000),
|
|
1300
|
-
|
|
1432
|
+
// This is Core's person projection, whose app-only queries have already been hidden.
|
|
1433
|
+
manifest: z.preprocess((value) => readableManifest(value, true), CapabilityManifestSchema.out),
|
|
1301
1434
|
events: z.array(CapabilityEventCatalogEntrySchema).max(MAX_MANIFEST_ENTRIES).optional(),
|
|
1302
1435
|
});
|
|
1303
1436
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type CapabilityManifest, CapabilitySemanticLinkSchema, CloudResourceRefSchema } from "./capabilities";
|
|
3
|
+
import { type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
|
|
4
|
+
import { typeViewIssues } from "./resource-types";
|
|
5
|
+
|
|
6
|
+
/** Same page bound as resource lists and owner activity pages. */
|
|
7
|
+
export const DISCUSSION_PAGE_LIMIT = 100;
|
|
8
|
+
const cursor = z.string().min(1).max(2048).describe("Opaque continuation cursor.");
|
|
9
|
+
export const DiscussionContainersInputSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
query: z.string().max(500).optional().describe("Optional container search text."),
|
|
12
|
+
cursor: cursor.optional(),
|
|
13
|
+
limit: z.number().int().min(1).max(DISCUSSION_PAGE_LIMIT).default(50).describe("Maximum containers in this page."),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
export const DiscussionContainerSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
ref: CloudResourceRefSchema,
|
|
19
|
+
title: z.string().min(1).max(500),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
|
22
|
+
export const DiscussionContainersDataSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
items: z.array(DiscussionContainerSchema).max(DISCUSSION_PAGE_LIMIT),
|
|
25
|
+
next: cursor.nullable(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
/** Shared owner activity, independent of the application or discussion consumer. */
|
|
30
|
+
export const ResourceActivityEventSchema = z.object({
|
|
31
|
+
id: z.string().max(100),
|
|
32
|
+
at: z.string().datetime({ offset: true }),
|
|
33
|
+
ref: CloudResourceRefSchema.strip().nullable(),
|
|
34
|
+
title: z.string().max(500).nullable().optional(),
|
|
35
|
+
actor: z.object({ type: z.enum(["user", "service_account", "system"]), id: z.uuid().nullable(), name: z.string().max(1000) }),
|
|
36
|
+
icon: z
|
|
37
|
+
.string()
|
|
38
|
+
.regex(/^ti ti-[a-z0-9-]+$/)
|
|
39
|
+
.max(80),
|
|
40
|
+
sentence: z.string().max(4000),
|
|
41
|
+
href: CapabilitySemanticLinkSchema.shape.href.optional(),
|
|
42
|
+
count: z.number().int().min(1),
|
|
43
|
+
});
|
|
44
|
+
export type ResourceActivityEvent = z.infer<typeof ResourceActivityEventSchema>;
|
|
45
|
+
export const DiscussionActivityInputSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
container: CloudResourceRefSchema.shape.id.describe("Container whose authorized activity is requested."),
|
|
48
|
+
item: CloudResourceRefSchema.shape.id.optional().describe("Optional item inside that container."),
|
|
49
|
+
timeZone: z.string().min(1).max(200).optional().describe("IANA display time zone."),
|
|
50
|
+
before: z.string().datetime({ offset: true }).optional().describe("Exclusive upper timestamp bound."),
|
|
51
|
+
after: z.string().datetime({ offset: true }).optional().describe("Exclusive lower timestamp bound."),
|
|
52
|
+
cursor: cursor.optional(),
|
|
53
|
+
limit: z.number().int().min(1).max(DISCUSSION_PAGE_LIMIT).default(50).describe("Maximum events in this page."),
|
|
54
|
+
})
|
|
55
|
+
.strict();
|
|
56
|
+
export const DiscussionActivityDataSchema = z.array(ResourceActivityEventSchema).max(DISCUSSION_PAGE_LIMIT);
|
|
57
|
+
// The wire contract accepts additive fields; the public consumer schema strips them.
|
|
58
|
+
// Zod's output JSON Schema is closed even for stripping objects, so use an explicit
|
|
59
|
+
// open acceptance shape here rather than treating stripped fields as forbidden.
|
|
60
|
+
const DiscussionActivityWireSchema = z
|
|
61
|
+
.array(
|
|
62
|
+
ResourceActivityEventSchema.loose().extend({
|
|
63
|
+
actor: ResourceActivityEventSchema.shape.actor.loose(),
|
|
64
|
+
ref: CloudResourceRefSchema.loose().nullable(),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
.max(DISCUSSION_PAGE_LIMIT);
|
|
68
|
+
export type DiscussionContainer = z.infer<typeof DiscussionContainerSchema>;
|
|
69
|
+
|
|
70
|
+
/** Startup/registry check: discovery is never permission to read content. */
|
|
71
|
+
export const discussionsIssues = (manifest: CapabilityManifest): CapabilityContractIssue[] => {
|
|
72
|
+
const role = manifest.discussions;
|
|
73
|
+
if (!role) return [];
|
|
74
|
+
const model = manifest.resourceTypes?.find((model) => model.type === role.item);
|
|
75
|
+
const issues: CapabilityContractIssue[] = [];
|
|
76
|
+
if (!model?.container) issues.push({ code: "kind", path: "$.item", message: "Discussion item must have a container type" });
|
|
77
|
+
for (const localId of [role.item, ...(model?.container ? [model.container] : [])]) issues.push(...typeViewIssues(manifest, localId));
|
|
78
|
+
for (const [localId, input, data] of [
|
|
79
|
+
[role.containers, DiscussionContainersInputSchema, DiscussionContainersDataSchema],
|
|
80
|
+
...(role.activity ? [[role.activity, DiscussionActivityInputSchema, DiscussionActivityWireSchema] as const] : []),
|
|
81
|
+
] as const) {
|
|
82
|
+
const query = manifest.queries.find((query) => query.localId === localId);
|
|
83
|
+
if (!query) issues.push({ code: "kind", path: "$", message: `Missing discussion Query ${localId}` });
|
|
84
|
+
else {
|
|
85
|
+
issues.push(...capabilityContractIssues({ kind: "query", input, data }, { kind: "query", operation: query }));
|
|
86
|
+
if (query.appOnly || query.openWorld)
|
|
87
|
+
issues.push({ code: "kind", path: "$", message: `Discussion Query ${localId} must be closed-world and person-readable` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return issues;
|
|
91
|
+
};
|