@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.
- package/package.json +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/define-app.ts +38 -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/resource-principals.ts +4 -34
- 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/chart-block.ts +36 -711
- package/src/ai/default-tool-contracts.ts +21 -259
- 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 +9 -3
- package/src/api/index.ts +3 -0
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/capabilities/discussion-providers.ts +3 -2
- package/src/capabilities/resource-readers.ts +13 -2
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +46 -28
- package/src/capabilities/server.ts +1 -1
- 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/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -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/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/identity/index.ts +3 -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/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/start.ts +36 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "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.
|
|
131
|
-
"@k2b/ui": "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)};\
|
|
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 {
|
package/scripts/preload.ts
CHANGED
|
@@ -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({
|
|
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 {
|
|
4
|
-
import { toPgTextArray } from "../services/postgres";
|
|
2
|
+
import { resolveResourceReaders } from "../services/identity/resource-readers";
|
|
5
3
|
|
|
6
|
-
/**
|
|
7
|
-
export const activeResourcePrincipals = async (principals: ResourcePrincipal[]): Promise<ResourcePrincipal[]> =>
|
|
8
|
-
|
|
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([
|
|
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");
|