@k2b/cloud 0.46.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.
Files changed (68) hide show
  1. package/package.json +8 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/define-app.ts +38 -0
  5. package/src/_internal/heartbeat.ts +19 -0
  6. package/src/_internal/postgres-application-name.ts +11 -0
  7. package/src/_internal/process-sync.ts +9 -0
  8. package/src/_internal/readiness.ts +6 -1
  9. package/src/_internal/resource-principals.ts +4 -34
  10. package/src/_internal/workload-credential.ts +153 -0
  11. package/src/_internal/workload-protocol.ts +36 -0
  12. package/src/ai/application-tasks.ts +5 -2
  13. package/src/ai/chart-block.ts +36 -711
  14. package/src/ai/default-tool-contracts.ts +21 -259
  15. package/src/api/admin-app-keys.ts +66 -0
  16. package/src/api/admin-core-settings.ts +2 -0
  17. package/src/api/admin-identity.ts +27 -1
  18. package/src/api/admin-settings-env.ts +120 -0
  19. package/src/api/capabilities.ts +9 -3
  20. package/src/api/index.ts +3 -0
  21. package/src/api/settings-ops.ts +35 -0
  22. package/src/app-key.ts +125 -0
  23. package/src/browser/CloudResourceSearch.tsx +48 -41
  24. package/src/capabilities/discussion-providers.ts +3 -2
  25. package/src/capabilities/resource-readers.ts +13 -2
  26. package/src/capabilities/response.ts +4 -1
  27. package/src/capabilities/server-invoke.ts +46 -28
  28. package/src/capabilities/server.ts +1 -1
  29. package/src/capabilities/types.ts +1 -1
  30. package/src/cli/admin/app-credentials.ts +32 -7
  31. package/src/cli/admin/app-keys.ts +226 -0
  32. package/src/cli/admin/index.ts +7 -1
  33. package/src/cli/admin/settings.ts +62 -0
  34. package/src/config/define-env.ts +63 -8
  35. package/src/config/env.ts +27 -21
  36. package/src/config/index.ts +12 -1
  37. package/src/config/workflow-ai.ts +28 -0
  38. package/src/contracts/settings-env.ts +21 -0
  39. package/src/contracts/settings-types.ts +6 -0
  40. package/src/index.ts +9 -0
  41. package/src/runtime/clients.ts +72 -0
  42. package/src/runtime/connections.ts +37 -0
  43. package/src/runtime/resolve-connections.ts +91 -0
  44. package/src/server/client-ip.ts +1 -1
  45. package/src/services/app-keys.ts +298 -0
  46. package/src/services/gateway.ts +6 -0
  47. package/src/services/identity/index.ts +3 -0
  48. package/src/services/identity/resource-readers.ts +80 -0
  49. package/src/services/identity/runtime-config.ts +14 -6
  50. package/src/services/identity/workload-runtime.ts +253 -0
  51. package/src/services/index.ts +4 -1
  52. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  53. package/src/services/service-account-credentials.ts +63 -21
  54. package/src/services/settings/api.ts +3 -3
  55. package/src/services/settings/binding.ts +45 -0
  56. package/src/services/settings/core-settings.ts +99 -1
  57. package/src/services/settings/defaults.ts +16 -2
  58. package/src/services/settings/env-status.ts +69 -0
  59. package/src/services/settings/index.ts +69 -26
  60. package/src/services/settings/store.ts +73 -8
  61. package/src/services/settings/validation.ts +40 -0
  62. package/src/shared/index.ts +11 -10
  63. package/src/shared/markdown/extensions/tables.ts +10 -1
  64. package/src/start.ts +36 -0
  65. package/src/workflows/ai/index.ts +1 -0
  66. package/src/workflows/ai/runtime.ts +16 -8
  67. package/src/workflows/ai/settings.ts +5 -0
  68. package/src/shared/markdown/formula.ts +0 -1283
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -21,10 +21,13 @@
21
21
  "!src/**/*.test-fixture.ts",
22
22
  "!src/**/*.test-fixture.tsx",
23
23
  "!scripts/**/*.test.ts",
24
+ "!scripts/**/*.test-fixture.ts",
24
25
  "!scripts/runtime-recovery-*.ts"
25
26
  ],
26
27
  "exports": {
27
28
  ".": "./src/index.ts",
29
+ "./app-key": "./src/app-key.ts",
30
+ "./start": "./src/start.ts",
28
31
  "./desktop": "./src/desktop/index.ts",
29
32
  "./desktop/solid": "./src/desktop/solid.tsx",
30
33
  "./server": "./src/server/index.ts",
@@ -127,8 +130,8 @@
127
130
  "@tabler/icons": "3.48.0",
128
131
  "@tailwindcss/typography": "0.5.20",
129
132
  "@k2b/nessi": "0.18.0",
130
- "@k2b/ssr": "0.20.1",
131
- "@k2b/ui": "0.36.0",
133
+ "@k2b/ssr": "0.20.2",
134
+ "@k2b/ui": "0.37.0",
132
135
  "@k2b/stdlib": "0.30.0",
133
136
  "@k2b/sync": "7.0.1",
134
137
  "@nats-io/jetstream": "3.4.0",
@@ -159,7 +162,8 @@
159
162
  "yjs": "13.6.33",
160
163
  "hash-wasm": "4.12.0",
161
164
  "@types/sanitize-html": "2.16.1",
162
- "@types/web-push": "3.6.4"
165
+ "@types/web-push": "3.6.4",
166
+ "@k2b/editor": "0.1.0"
163
167
  },
164
168
  "devDependencies": {
165
169
  "@babel/core": "8.0.6",
package/scripts/build.ts CHANGED
@@ -116,9 +116,13 @@ const { app, plugin } = await import(configPath);
116
116
  // every production app without requiring app-owned bootstrap imports.
117
117
  const serverEntry = resolve(dist, ".server-entry.ts");
118
118
  const reflectMetadata = Bun.resolveSync("reflect-metadata", frameworkDir);
119
+ const platformStart = resolve(frameworkDir, "src/start.ts");
120
+ const declaredAppId = app?.meta.id ?? appId;
121
+ if (declaredAppId !== appId)
122
+ throw new Error(`APP_ID ${JSON.stringify(appId)} does not match declared app ${JSON.stringify(declaredAppId)}`);
119
123
  await writeFile(
120
124
  serverEntry,
121
- `import ${JSON.stringify(reflectMetadata)};\nexport { default } from ${JSON.stringify(resolve(appDir, "src/index.ts"))};\n`,
125
+ `import ${JSON.stringify(reflectMetadata)};\nimport { startPlatform } from ${JSON.stringify(platformStart)};\nconst application = await startPlatform({ appId: ${JSON.stringify(declaredAppId)}, load: () => import(${JSON.stringify(resolve(appDir, "src/index.ts"))}) });\nexport default application.default;\n`,
122
126
  );
123
127
  let server: Awaited<ReturnType<typeof Bun.build>>;
124
128
  try {
@@ -22,6 +22,7 @@ import { cp, mkdir } from "node:fs/promises";
22
22
  import { dirname, resolve } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import tailwind from "bun-plugin-tailwind";
25
+ import { startPlatform } from "../src/start";
25
26
  import { writeAppFavicon } from "./app-favicon";
26
27
  import { buildBrowserPerformance } from "./browser-performance";
27
28
  import { writeInstallableIcons } from "./installable-assets";
@@ -43,7 +44,10 @@ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DI
43
44
  // Share the resolved source directory with defineApp before importing config.
44
45
  process.env.APP_DIR = appDir;
45
46
 
46
- const { app, plugin } = await import(resolve(appDir, "src/config"));
47
+ const { app, plugin } = await startPlatform({ appId, load: () => import(resolve(appDir, "src/config")) });
48
+ if (app && app.meta.id !== appId) {
49
+ throw new Error(`APP_ID ${JSON.stringify(appId)} does not match declared app ${JSON.stringify(app.meta.id)}`);
50
+ }
47
51
  Bun.plugin((app?.plugin ?? plugin)());
48
52
 
49
53
  // ── Build CSS ───────────────────────────────────────────────────────────────
@@ -2,6 +2,7 @@ import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, valid
2
2
  import { createOwnedAppAccessRoutes } from "../api/app-access";
3
3
  import { createAppEventDeliveryRoutes } from "../api/app-event-delivery";
4
4
  import { createResourceAppAccessRoutes } from "../api/app-resource-access";
5
+ import { createSettingsOpsRoutes, settingsInvocationOperation } from "../api/settings-ops";
5
6
  import { type AppAccessDeclaration, compileAppAccess } from "../contracts/app-access";
6
7
  import { startAppEventDelivery } from "../events/app-event-delivery";
7
8
  import { bindAppEvents } from "../events/app-events";
@@ -119,6 +120,7 @@ import { publicFileVersion, servePublicAsset, sharedStylesheetVersion, versioned
119
120
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
120
121
  import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
121
122
  import { compileWidgetDeclarations } from "./widget-declarations";
123
+ import { createAppWorkloadCredential } from "./workload-credential";
122
124
 
123
125
  /** Only development links use this start stamp to pick up rebuilds. */
124
126
  const developmentStamp = Date.now();
@@ -691,6 +693,10 @@ export const defineApp = <
691
693
  // ── 3. start() — builds and boots the Hono server ────────────────────
692
694
  const start = async (startOpts: StartOptions): Promise<StartResult> => {
693
695
  const port = startOpts.port ?? 3000;
696
+ if (env.GOTENBERG_URL)
697
+ console.warn("[deprecation] GOTENBERG_URL only bootstraps gotenberg.url; remove it after cld admin settings status --env succeeds.");
698
+ if (env.CLOUD_IDENTITY_JWKS_ORIGIN)
699
+ console.warn("[deprecation] CLOUD_IDENTITY_JWKS_ORIGIN is deprecated; use CLOUD_CORE_INTERNAL_ORIGIN instead.");
694
700
  const baseUrl = opts.baseUrl;
695
701
  const log = logger("app");
696
702
  const http = createHttpDrain();
@@ -864,6 +870,7 @@ export const defineApp = <
864
870
  await capabilityHeartbeat?.stop();
865
871
  });
866
872
  cleanup.push(() => heartbeat.stop());
873
+ cleanup.push(() => membership.leave());
867
874
 
868
875
  // Runtime context — start the registry watcher so middleware.runtime() and
869
876
  // the lifecycle context below see populated cluster state. Idempotent: the
@@ -909,6 +916,18 @@ export const defineApp = <
909
916
  if (!c.get("user")?.roles.includes("admin")) return c.json({ message: "Insufficient permissions" }, 403);
910
917
  return next();
911
918
  })
919
+ .use(
920
+ "/_internal/settings/*",
921
+ requireInvocation(
922
+ (c) => ({
923
+ targetAppId: meta.id,
924
+ operation: settingsInvocationOperation(c.req.method, new URL(c.req.url).pathname.slice("/_internal/settings/".length)),
925
+ schemaHash: null,
926
+ }),
927
+ invocationApp,
928
+ ),
929
+ )
930
+ .route("/_internal/settings", createSettingsOpsRoutes(Object.keys(opts.settings ?? {})))
912
931
  .route("/_internal/sync", createSyncOpsRoutes(getProcessSync));
913
932
 
914
933
  if (installable) server.route("/", createInstallableRoutes(meta.id, installable));
@@ -1145,6 +1164,8 @@ export const defineApp = <
1145
1164
 
1146
1165
  let stopNotificationRegistration: (() => void) | undefined;
1147
1166
  cleanup.push(() => stopNotificationRegistration?.());
1167
+ let workloadCredential: ReturnType<typeof createAppWorkloadCredential> | undefined;
1168
+ cleanup.push(() => workloadCredential?.stop());
1148
1169
  const stopLifecycle = startOpts.lifecycle?.stop;
1149
1170
  if (stopLifecycle) cleanup.push(() => stopLifecycle(cloudCtx));
1150
1171
 
@@ -1169,6 +1190,23 @@ export const defineApp = <
1169
1190
 
1170
1191
  await loadSettingsCache();
1171
1192
 
1193
+ // The instance can obtain its credential before workers run; routes remain undiscoverable.
1194
+ await membership.register();
1195
+ workloadCredential = createAppWorkloadCredential({
1196
+ appId: meta.id,
1197
+ instanceId: membership.instanceId,
1198
+ onRecovered: () => log.info("App workload credential refresh recovered", { appId: meta.id }),
1199
+ onUnavailable: (reason) => {
1200
+ if (reason === "release_unavailable") {
1201
+ log.warn("Workload credential release was not confirmed; Core will reconcile after removal", { appId: meta.id });
1202
+ } else {
1203
+ log.error("App workload credential unavailable; background invocation is disabled", { appId: meta.id, reason });
1204
+ }
1205
+ },
1206
+ });
1207
+ // The NATS request has a five-second timeout; an outage leaves workers fail-closed.
1208
+ await workloadCredential.start();
1209
+
1172
1210
  if (startOpts.lifecycle?.start) {
1173
1211
  log.info(`Start: ${meta.id}`);
1174
1212
  await startOpts.lifecycle.start(cloudCtx);
@@ -94,6 +94,25 @@ export const createRegistryMembership = (
94
94
  };
95
95
 
96
96
  return {
97
+ instanceId,
98
+ // Establish process identity before lifecycle workers start, without advertising routes yet.
99
+ register: async () => {
100
+ let timer: Timer | undefined;
101
+ // This runs before the ordinary heartbeat supplies its write deadline.
102
+ const write = upsert().finally(async () => {
103
+ if (left) await options.registry().delete({ key });
104
+ });
105
+ try {
106
+ await Promise.race([
107
+ write,
108
+ new Promise<never>((_, reject) => {
109
+ timer = setTimeout(() => reject(new Error(`Instance registration timed out after ${writeTimeoutMs}ms`)), writeTimeoutMs);
110
+ }),
111
+ ]);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ },
97
116
  join: async (sharedKey: string, sharedValue: unknown, entry: SharedEntry): Promise<void> => {
98
117
  left = false;
99
118
  await prepareShared(sharedKey);
@@ -1,3 +1,4 @@
1
+ // Slice 7 removes this legacy env-URL rewrite together with Bun-global client imports.
1
2
  /** Name Bun's default pool before its first use without changing connection ownership. */
2
3
  export const configurePostgresApplicationName = (
3
4
  applicationId: string,
@@ -28,3 +29,13 @@ export const configurePostgresApplicationName = (
28
29
  url.searchParams.set("application_name", `cloud:${applicationId}`);
29
30
  environment[variable] = url.toString();
30
31
  };
32
+
33
+ /** Preserve operator URL options; otherwise name every connection through native client options. */
34
+ export const postgresApplicationOptions = (applicationId: string, connectionUrl: string): Record<string, string> => {
35
+ const url = new URL(connectionUrl);
36
+ let operatorConfigured = false;
37
+ url.searchParams.forEach((_value, key) => {
38
+ if (key.toLowerCase() === "application_name" || key.toLowerCase() === "options") operatorConfigured = true;
39
+ });
40
+ return operatorConfigured ? {} : { application_name: `cloud:${applicationId}` };
41
+ };
@@ -19,6 +19,13 @@ import { withSyncBudgets } from "./sync-budget";
19
19
 
20
20
  let current: Sync | undefined;
21
21
  let starting = false;
22
+ let processNats: NatsConnection | undefined;
23
+
24
+ /** Existing non-retained NATS transport, owned and drained by the process lifecycle. */
25
+ export const getProcessNats = (): NatsConnection => {
26
+ if (!processNats) throw new Error("NATS is not available before app.start()");
27
+ return processNats;
28
+ };
22
29
 
23
30
  /** Bind the process instance. `startProcessSync()` does this; tests may bind their own. */
24
31
  export const bindProcessSync = (sync: Sync): void => {
@@ -173,6 +180,7 @@ export const startProcessSync = async ({ application }: { application: string })
173
180
  starting = true;
174
181
  try {
175
182
  const { sync: active, connection } = await waitForNats(() => connectReadySync(application), { application });
183
+ processNats = connection;
176
184
  let stopPromise: Promise<void> | undefined;
177
185
  return {
178
186
  sync: active,
@@ -185,6 +193,7 @@ export const startProcessSync = async ({ application }: { application: string })
185
193
  await flushSyncTraceEvents();
186
194
  } finally {
187
195
  if (current === active) unbindProcessSync();
196
+ if (processNats === connection) processNats = undefined;
188
197
  await connection.drain();
189
198
  }
190
199
  }
@@ -3,4 +3,9 @@ import { buildMetadata } from "./build-metadata";
3
3
  export const APP_READINESS_PATH = "/_cloud/ready";
4
4
 
5
5
  export const appReadinessResponse = (appId: string): Response =>
6
- Response.json({ status: "ready", appId, version: buildMetadata.version, release: buildMetadata.release });
6
+ Response.json({
7
+ status: "ready",
8
+ appId,
9
+ version: buildMetadata.version,
10
+ release: buildMetadata.release,
11
+ });
@@ -1,36 +1,6 @@
1
- import { sql } from "bun";
2
1
  import type { ResourcePrincipal } from "../contracts/capabilities";
3
- import { decodeAccountCategoryEnabled } from "../services/account-category-policy";
4
- import { toPgTextArray } from "../services/postgres";
2
+ import { resolveResourceReaders } from "../services/identity/resource-readers";
5
3
 
6
- /** One bounded identity lookup before any provider-owned readability check. */
7
- export const activeResourcePrincipals = async (principals: ResourcePrincipal[]): Promise<ResourcePrincipal[]> => {
8
- if (!principals.length) return [];
9
- const users = principals.flatMap((principal) => (principal.type === "user" ? [principal.userId] : []));
10
- const accounts = principals.flatMap((principal) => (principal.type === "service_account" ? [principal.serviceAccountId] : []));
11
- const rows = await sql<{ type: ResourcePrincipal["type"]; id: string; category_enabled: string | null }[]>`
12
- SELECT 'user' AS type, u.id, category.value AS category_enabled
13
- FROM auth.users u
14
- LEFT JOIN settings.entries category ON category.key = 'user.category.' ||
15
- CASE WHEN u.provider = 'ipa' THEN 'freeipa' WHEN u.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
16
- WHERE u.id = ANY(${toPgTextArray(users)}::uuid[])
17
- AND (u.account_expires IS NULL OR u.account_expires > clock_timestamp())
18
- UNION ALL
19
- SELECT 'service_account', sa.id, category.value
20
- FROM auth.service_accounts sa
21
- LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
22
- LEFT JOIN settings.entries category ON sa.kind = 'user_delegated' AND category.key = 'user.category.' ||
23
- CASE WHEN du.provider = 'ipa' THEN 'freeipa' WHEN du.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
24
- WHERE sa.id = ANY(${toPgTextArray(accounts)}::uuid[]) AND sa.status = 'active'
25
- AND (sa.kind <> 'user_delegated' OR (du.id IS NOT NULL AND (du.account_expires IS NULL OR du.account_expires > clock_timestamp())))
26
- `;
27
- const enabled = new Map<string | null, boolean>();
28
- const active = new Set<string>();
29
- for (const row of rows) {
30
- if (!enabled.has(row.category_enabled)) enabled.set(row.category_enabled, await decodeAccountCategoryEnabled(row.category_enabled));
31
- if (enabled.get(row.category_enabled)) active.add(`${row.type}:${row.id}`);
32
- }
33
- return principals.filter((principal) =>
34
- active.has(`${principal.type}:${(principal.type === "user" ? principal.userId : principal.serviceAccountId).toLowerCase()}`),
35
- );
36
- };
4
+ /** Apply the same current-account rules as provider-owned readability checks. */
5
+ export const activeResourcePrincipals = async (principals: ResourcePrincipal[]): Promise<ResourcePrincipal[]> =>
6
+ (await resolveResourceReaders(principals)).map((reader) => reader.principal);
@@ -0,0 +1,153 @@
1
+ import { env } from "../config/env";
2
+ import { getProcessApplicationId } from "./process-identity";
3
+ import { getProcessNats } from "./process-sync";
4
+ import {
5
+ WORKLOAD_CHECK_INTERVAL_MS,
6
+ WORKLOAD_REQUEST_TIMEOUT_MS,
7
+ type WorkloadRequest,
8
+ WorkloadResponseSchema,
9
+ workloadSubject,
10
+ } from "./workload-protocol";
11
+
12
+ export type WorkloadCredentialHealth = { status: "ready"; source: "runtime" | "override" } | { status: "unavailable"; reason: string };
13
+ let active: { appId: string; token: string; expiresAt: number } | undefined;
14
+ let health: WorkloadCredentialHealth | undefined;
15
+ let refreshRuntime: { appId: string; refresh: () => Promise<void> } | undefined;
16
+
17
+ /** Refresh only this process's app authority; concurrent broker rejections share one request. */
18
+ export const refreshAppWorkloadCredential = async (appId: string): Promise<string | undefined> => {
19
+ if (appId !== getProcessApplicationId()) return undefined;
20
+ if (refreshRuntime?.appId === appId) await refreshRuntime.refresh();
21
+ return getAppWorkloadCredential();
22
+ };
23
+
24
+ /** One server-side accessor for workers and app-authority invocation clients. Never send to a target app. */
25
+ export const getAppWorkloadCredential = (): string | undefined => {
26
+ const override = env.CLOUD_APP_CREDENTIAL?.trim();
27
+ if (override) return override;
28
+ if (active && active.appId === getProcessApplicationId() && active.expiresAt > Date.now()) return active.token;
29
+ return undefined;
30
+ };
31
+
32
+ export const getAppWorkloadCredentialHealth = (): WorkloadCredentialHealth | undefined => {
33
+ if (env.CLOUD_APP_CREDENTIAL?.trim()) return { status: "ready", source: "override" };
34
+ if (active && active.expiresAt <= Date.now()) return { status: "unavailable", reason: "expired" };
35
+ return health;
36
+ };
37
+
38
+ export const createAppWorkloadCredential = (options: {
39
+ appId: string;
40
+ instanceId: string;
41
+ request?: (request: WorkloadRequest) => Promise<unknown>;
42
+ onUnavailable?: (reason: string) => void;
43
+ onRecovered?: () => void;
44
+ }) => {
45
+ let credentialId: string | null = null;
46
+ let running = false;
47
+ let refreshFailed = false;
48
+ let timer: Timer | undefined;
49
+ let inFlight: Promise<void> | undefined;
50
+ const request =
51
+ options.request ??
52
+ (async (input: WorkloadRequest) => {
53
+ const response = await getProcessNats().request(workloadSubject(env.SYNC_NAMESPACE), JSON.stringify(input), {
54
+ timeout: WORKLOAD_REQUEST_TIMEOUT_MS,
55
+ });
56
+ return response.json<unknown>();
57
+ });
58
+ const unavailable = (reason: string, preserve = false) => {
59
+ refreshFailed = true;
60
+ if (preserve && active && active.expiresAt > Date.now()) return;
61
+ if (!preserve) {
62
+ active = undefined;
63
+ credentialId = null;
64
+ }
65
+ const changed = health?.status !== "unavailable" || health.reason !== reason;
66
+ health = { status: "unavailable", reason };
67
+ if (changed) options.onUnavailable?.(reason);
68
+ };
69
+ const recovered = () => {
70
+ health = { status: "ready", source: "runtime" };
71
+ if (refreshFailed) options.onRecovered?.();
72
+ refreshFailed = false;
73
+ };
74
+ const refresh = async (): Promise<void> => {
75
+ if (env.CLOUD_APP_CREDENTIAL?.trim()) {
76
+ active = undefined;
77
+ health = { status: "ready", source: "override" };
78
+ return;
79
+ }
80
+ try {
81
+ const response = WorkloadResponseSchema.parse(
82
+ await request({ operation: "refresh", appId: options.appId, instanceId: options.instanceId, credentialId }),
83
+ );
84
+ if (!running) return;
85
+ if (response.status === "issued") {
86
+ if (
87
+ response.appId !== options.appId ||
88
+ response.instanceId !== options.instanceId ||
89
+ Date.parse(response.expiresAt) <= Date.now()
90
+ ) {
91
+ unavailable("invalid_response");
92
+ return;
93
+ }
94
+ credentialId = response.credentialId;
95
+ active = { appId: options.appId, token: response.token, expiresAt: Date.parse(response.expiresAt) };
96
+ recovered();
97
+ } else if (response.status === "current" && response.credentialId === credentialId && active && active.expiresAt > Date.now()) {
98
+ recovered();
99
+ } else {
100
+ unavailable(response.status === "current" ? "invalid_response" : response.status, response.status === "unavailable");
101
+ }
102
+ } catch {
103
+ if (running) {
104
+ unavailable("issuance_unavailable", true);
105
+ }
106
+ }
107
+ };
108
+ const refreshOnce = (): Promise<void> => {
109
+ if (inFlight) return inFlight;
110
+ const operation = refresh().finally(() => {
111
+ if (inFlight === operation) inFlight = undefined;
112
+ });
113
+ inFlight = operation;
114
+ return operation;
115
+ };
116
+ const schedule = () => {
117
+ if (!running) return;
118
+ timer = setTimeout(
119
+ () => {
120
+ void refreshOnce().finally(schedule);
121
+ },
122
+ refreshFailed || health?.status === "unavailable" ? 5_000 : WORKLOAD_CHECK_INTERVAL_MS,
123
+ );
124
+ };
125
+ return {
126
+ start: async () => {
127
+ if (running) {
128
+ await inFlight;
129
+ return;
130
+ }
131
+ running = true;
132
+ refreshRuntime = { appId: options.appId, refresh: refreshOnce };
133
+ health = { status: "unavailable", reason: "starting" };
134
+ await refreshOnce();
135
+ schedule();
136
+ },
137
+ refresh: refreshOnce,
138
+ stop: async () => {
139
+ running = false;
140
+ if (refreshRuntime?.refresh === refreshOnce) refreshRuntime = undefined;
141
+ clearTimeout(timer);
142
+ await inFlight;
143
+ if (!env.CLOUD_APP_CREDENTIAL?.trim()) {
144
+ await request({ operation: "release", appId: options.appId, instanceId: options.instanceId, credentialId }).catch(() => {
145
+ // Core's registry reconciliation revokes after disappearance even when this reply is lost.
146
+ options.onUnavailable?.("release_unavailable");
147
+ });
148
+ }
149
+ active = undefined;
150
+ health = undefined;
151
+ },
152
+ };
153
+ };
@@ -0,0 +1,36 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { CapabilityAppIdSchema } from "../contracts/capabilities";
4
+
5
+ // Core NATS request/reply only: no JetStream subject, KV value, or retained raw token.
6
+ export const workloadSubject = (namespace: string): string => `cloud.workload.${createHash("sha256").update(namespace).digest("hex")}.v1`;
7
+ export const WORKLOAD_CHECK_INTERVAL_MS = 60_000;
8
+ export const WORKLOAD_REQUEST_TIMEOUT_MS = 5_000;
9
+ export const WORKLOAD_LIFETIME_MS = 60 * 60_000;
10
+ export const WORKLOAD_ROTATE_BEFORE_MS = WORKLOAD_LIFETIME_MS / 2;
11
+
12
+ export const WorkloadRequestSchema = z
13
+ .object({
14
+ operation: z.enum(["refresh", "release"]),
15
+ appId: CapabilityAppIdSchema,
16
+ instanceId: z.string().regex(/^[A-Za-z0-9_-]{16}$/),
17
+ credentialId: z.uuid().nullable(),
18
+ })
19
+ .strict();
20
+ export type WorkloadRequest = z.infer<typeof WorkloadRequestSchema>;
21
+
22
+ export const WorkloadResponseSchema = z.discriminatedUnion("status", [
23
+ z
24
+ .object({
25
+ status: z.literal("issued"),
26
+ appId: CapabilityAppIdSchema,
27
+ instanceId: z.string(),
28
+ credentialId: z.uuid(),
29
+ token: z.string().regex(/^cld_[a-f0-9]{24}_[a-f0-9]{64}$/),
30
+ expiresAt: z.iso.datetime(),
31
+ })
32
+ .strict(),
33
+ z.object({ status: z.literal("current"), credentialId: z.uuid() }).strict(),
34
+ z.object({ status: z.enum(["unavailable", "revoked", "unregistered"]) }).strict(),
35
+ ]);
36
+ export type WorkloadResponse = z.infer<typeof WorkloadResponseSchema>;
@@ -1,6 +1,6 @@
1
1
  import type { SQL } from "bun";
2
- import { env } from "../config/env";
3
2
  import type { AccessSubject } from "../server/services/access";
3
+ import { coreSettings } from "../services/settings/api";
4
4
  import { WorkflowAiAdmissionError, type WorkflowAiScope, workflowAiHash, workflowAiModelHash } from "../workflows/ai/policy";
5
5
  import { submitWorkflowAiTask } from "../workflows/ai/runtime";
6
6
  import {
@@ -56,7 +56,10 @@ export const runApplicationAiTask = async (input: ApplicationAiTaskInput) => {
56
56
  let task = await getWorkflowAiTaskByEffectKey(effectKey);
57
57
  if (!task) {
58
58
  const resolved = await resolveApplicationAiTaskModel(input.usageSubject, input.request.model);
59
- const admissionSignal = AbortSignal.any([input.signal, AbortSignal.timeout(env.WORKFLOW_AI_QUEUE_TIMEOUT_MS)]);
59
+ const admissionSignal = AbortSignal.any([
60
+ input.signal,
61
+ AbortSignal.timeout(await coreSettings.get<number>("ai.workflow_queue_timeout_ms")),
62
+ ]);
60
63
  for (;;) {
61
64
  input.signal.throwIfAborted();
62
65
  if (admissionSignal.aborted) throw new WorkflowAiAdmissionError("WORKFLOW_AI_QUEUE_TIMEOUT");