@k2b/cloud 0.47.0 → 0.48.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 +3 -3
- package/src/_internal/app-access-manager-sql.ts +10 -0
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +8 -0
- package/src/_internal/define-app.ts +31 -2
- package/src/_internal/heartbeat.ts +2 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/process-sync.ts +6 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/workload-credential.ts +2 -1
- package/src/api/admin-app-domains.ts +62 -0
- package/src/api/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +8 -1
- package/src/api/index.ts +5 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/server-invoke.ts +28 -2
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/index.ts +107 -88
- package/src/config/env.ts +7 -1
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +22 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/registry.ts +2 -0
- package/src/index.ts +1 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/connections.ts +9 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/index.ts +1 -0
- package/src/services/app-access-management.ts +1 -8
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-bootstrap.ts +134 -0
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-keys.ts +6 -3
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/identity/workload-runtime.ts +2 -2
- package/src/services/index.ts +4 -0
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +7 -5
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +8 -8
- package/src/services/request-cache-redis.ts +2 -2
- package/src/services/settings/core-settings.ts +20 -0
- package/src/services/settings/crypto.ts +2 -2
- package/src/services/settings/store.ts +5 -0
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +35 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.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": {
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"@tailwindcss/typography": "0.5.20",
|
|
132
132
|
"@k2b/nessi": "0.18.0",
|
|
133
133
|
"@k2b/ssr": "0.20.2",
|
|
134
|
-
"@k2b/ui": "0.
|
|
134
|
+
"@k2b/ui": "0.38.0",
|
|
135
135
|
"@k2b/stdlib": "0.30.0",
|
|
136
136
|
"@k2b/sync": "7.0.1",
|
|
137
137
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"hash-wasm": "4.12.0",
|
|
164
164
|
"@types/sanitize-html": "2.16.1",
|
|
165
165
|
"@types/web-push": "3.6.4",
|
|
166
|
-
"@k2b/editor": "0.1.
|
|
166
|
+
"@k2b/editor": "0.1.1"
|
|
167
167
|
},
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@babel/core": "8.0.6",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
|
|
3
|
+
|
|
4
|
+
/** Eligibility of access entry alias `a`, shared by the last-manager guard and administration reads. */
|
|
5
|
+
export const managerEligibility = (db: SQL) => db`
|
|
6
|
+
EXISTS (SELECT 1 FROM auth.users u WHERE (u.account_expires IS NULL OR u.account_expires > now()) AND
|
|
7
|
+
(u.id = a.user_id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}))))
|
|
8
|
+
OR EXISTS (SELECT 1 FROM auth.service_accounts sa WHERE sa.id = a.service_account_id
|
|
9
|
+
AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent'))
|
|
10
|
+
`;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export type BoundedJsonResult = { ok: true; data: unknown } | { ok: false; reason: "too_large" | "invalid_json" };
|
|
2
2
|
|
|
3
3
|
/** Reads JSON without allowing a missing or forged Content-Length to bypass the byte cap. */
|
|
4
|
-
export const readBoundedJson = async (
|
|
4
|
+
export const readBoundedJson = async (
|
|
5
|
+
message: Request | Response,
|
|
6
|
+
maxBytes: number,
|
|
7
|
+
signal?: AbortSignal,
|
|
8
|
+
consumeBytes?: (bytes: number) => void,
|
|
9
|
+
): Promise<BoundedJsonResult> => {
|
|
5
10
|
signal?.throwIfAborted();
|
|
6
11
|
const declared = message.headers.get("content-length");
|
|
7
12
|
if (declared !== null) {
|
|
@@ -27,6 +32,7 @@ export const readBoundedJson = async (message: Request | Response, maxBytes: num
|
|
|
27
32
|
while (true) {
|
|
28
33
|
const next = await reader.read();
|
|
29
34
|
if (next.done) break;
|
|
35
|
+
consumeBytes?.(next.value.byteLength);
|
|
30
36
|
total += next.value.byteLength;
|
|
31
37
|
if (total > maxBytes) {
|
|
32
38
|
await reader.cancel().catch(() => undefined);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AppEventDefinition, AppEventSubscriptionDefinition } from "../contracts/app-events";
|
|
2
|
+
import { appStatusIssues } from "../contracts/app-status";
|
|
2
3
|
import {
|
|
4
|
+
CapabilityAppStatusManifestSchema,
|
|
3
5
|
type CapabilityEventManifest,
|
|
4
6
|
CapabilityEventManifestSchema,
|
|
5
7
|
CapabilityQualifiedIdSchema,
|
|
@@ -108,6 +110,7 @@ const DECLARATION_FIELDS = {
|
|
|
108
110
|
fileProvider: true,
|
|
109
111
|
calendarSource: true,
|
|
110
112
|
discussions: true,
|
|
113
|
+
appStatus: true,
|
|
111
114
|
events: true,
|
|
112
115
|
subscribes: true,
|
|
113
116
|
} satisfies DefinedFields<CapabilityDefinitions>;
|
|
@@ -710,6 +713,7 @@ export const compileCapabilities = (
|
|
|
710
713
|
}
|
|
711
714
|
if (definitions.fileProvider) assertDefinedFields(definitions.fileProvider, FILE_PROVIDER_FIELDS, "fileProvider");
|
|
712
715
|
|
|
716
|
+
if (definitions.appStatus !== undefined) CapabilityAppStatusManifestSchema.parse(definitions.appStatus);
|
|
713
717
|
if (definitions.discussions !== undefined) CapabilityDiscussionsManifestSchema.parse(definitions.discussions);
|
|
714
718
|
for (const [localId, type] of Object.entries(definitions.types ?? {})) {
|
|
715
719
|
if (type.access !== undefined) {
|
|
@@ -1037,6 +1041,7 @@ export const compileCapabilities = (
|
|
|
1037
1041
|
});
|
|
1038
1042
|
const manifestBase = {
|
|
1039
1043
|
...(resourceTypes.length ? { resourceTypes } : {}),
|
|
1044
|
+
...(definitions.appStatus ? { appStatus: definitions.appStatus } : {}),
|
|
1040
1045
|
...(definitions.discussions ? { discussions: definitions.discussions } : {}),
|
|
1041
1046
|
...(resourcePages.length ? { resourcePages } : {}),
|
|
1042
1047
|
protocolVersion: CAPABILITY_PROTOCOL_VERSION,
|
|
@@ -1058,6 +1063,8 @@ export const compileCapabilities = (
|
|
|
1058
1063
|
...manifestBase,
|
|
1059
1064
|
manifestHash: capabilityHash(manifestBase),
|
|
1060
1065
|
});
|
|
1066
|
+
const statusIssues = appStatusIssues(manifest);
|
|
1067
|
+
if (statusIssues.length) throw new Error(`appStatus: ${statusIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
1061
1068
|
const discussionIssues = discussionsIssues(manifest);
|
|
1062
1069
|
if (discussionIssues.length)
|
|
1063
1070
|
throw new Error(`discussions: ${discussionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
@@ -1234,6 +1241,7 @@ export const resolveCapabilityManifestPresentation = (
|
|
|
1234
1241
|
: {}),
|
|
1235
1242
|
...(current.subscribes ? { subscribes: current.subscribes } : {}),
|
|
1236
1243
|
...(current.resourceTypes ? { resourceTypes: current.resourceTypes } : {}),
|
|
1244
|
+
...(current.appStatus ? { appStatus: current.appStatus } : {}),
|
|
1237
1245
|
...(current.discussions ? { discussions: current.discussions } : {}),
|
|
1238
1246
|
...(current.resourcePages ? { resourcePages: current.resourcePages } : {}),
|
|
1239
1247
|
...(current.calendarSource ? { calendarSource: current.calendarSource } : {}),
|
|
@@ -27,7 +27,7 @@ import { bindProcessApplicationId, clearProcessApplicationId } from "./process-i
|
|
|
27
27
|
|
|
28
28
|
import { resolve } from "node:path";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
|
-
import type { HtmlFn, SsrConfig } from "@k2b/ssr";
|
|
30
|
+
import type { HtmlFn, SsrConfig, SsrOptions } from "@k2b/ssr";
|
|
31
31
|
import { createConfig as createSsrConfig } from "@k2b/ssr";
|
|
32
32
|
import { routes } from "@k2b/ssr/hono";
|
|
33
33
|
import type { Sync } from "@k2b/sync";
|
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
CloudLifecycleContext,
|
|
49
49
|
WidgetEndpoint,
|
|
50
50
|
} from "../contracts/app";
|
|
51
|
+
import { PUBLIC_SURFACE_PREFIX } from "../contracts/app-domains";
|
|
51
52
|
import {
|
|
52
53
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
53
54
|
CAPABILITY_MAX_REQUEST_BYTES,
|
|
@@ -68,8 +69,10 @@ import { getLocale, resolveLocale } from "../server/locale";
|
|
|
68
69
|
import { requireAppAccess, stopAppAccessConnections } from "../server/middleware/app-access";
|
|
69
70
|
import { type AuthContext, auth } from "../server/middleware/auth";
|
|
70
71
|
import { requireInvocation } from "../server/middleware/invocation";
|
|
72
|
+
import { createPublicSurfaceHandler, validatePublicSurfaceRoutes } from "./public-surface";
|
|
71
73
|
import "../events/app-access";
|
|
72
74
|
import { createNestedCapabilityQuery } from "../capabilities/nested";
|
|
75
|
+
import { connections } from "../runtime/connections";
|
|
73
76
|
import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
|
|
74
77
|
import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
|
|
75
78
|
import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
|
|
@@ -83,6 +86,7 @@ import {
|
|
|
83
86
|
widgetInvocationOperation,
|
|
84
87
|
} from "../services/identity/invocation-operations";
|
|
85
88
|
import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
|
|
89
|
+
import { startLinksRuntime } from "../services/links/runtime";
|
|
86
90
|
import { logger } from "../services/logging";
|
|
87
91
|
import { startNotificationDefinitionRegistration } from "../services/notifications/catalog";
|
|
88
92
|
import { readRailSnapshot } from "../services/rail-snapshot";
|
|
@@ -164,6 +168,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
164
168
|
/** Shared object-store package. Defaults to the app ID. */
|
|
165
169
|
package?: string;
|
|
166
170
|
access?: AppAccessDeclaration;
|
|
171
|
+
/** Serve a separate public site on domains assigned by an administrator. */
|
|
172
|
+
public?: true;
|
|
167
173
|
name: string;
|
|
168
174
|
icon: string;
|
|
169
175
|
description: string;
|
|
@@ -372,6 +378,10 @@ export type AppDefinition<S extends AppSettingsMap = {}, N extends NotificationD
|
|
|
372
378
|
config: SsrConfig;
|
|
373
379
|
meta: AppMeta;
|
|
374
380
|
baseUrl: string;
|
|
381
|
+
/** Normal Hono router for the public site. Adding routes requires `public: true`. */
|
|
382
|
+
readonly public: Hono;
|
|
383
|
+
/** Public HTML template and island assets, reusing this app's single SSR plugin/build. Call before adding page routes. */
|
|
384
|
+
publicSsr: <T extends object = object>(template: NonNullable<SsrOptions<T>["template"]>) => { html: HtmlFn<T> };
|
|
375
385
|
start: (opts: StartOptions) => Promise<StartResult>;
|
|
376
386
|
/**
|
|
377
387
|
* The process-wide @k2b/sync instance, connected and ready once `start()`
|
|
@@ -478,6 +488,8 @@ export const defineApp = <
|
|
|
478
488
|
>(
|
|
479
489
|
opts: AppOptions<S, N, AppId>,
|
|
480
490
|
): AppDefinition<S, N, AppId> => {
|
|
491
|
+
validatePublicSurfaceRoutes(opts.routes);
|
|
492
|
+
const publicRouter = new Hono();
|
|
481
493
|
const appSkills = opts.skills ? validateAppSkillCatalog(opts.skills) : undefined;
|
|
482
494
|
const skillsManifestHash = appSkills ? appSkillManifestHash(appSkills) : undefined;
|
|
483
495
|
configurePostgresApplicationName(opts.id);
|
|
@@ -692,6 +704,8 @@ export const defineApp = <
|
|
|
692
704
|
|
|
693
705
|
// ── 3. start() — builds and boots the Hono server ────────────────────
|
|
694
706
|
const start = async (startOpts: StartOptions): Promise<StartResult> => {
|
|
707
|
+
if (!opts.public && publicRouter.routes.length)
|
|
708
|
+
throw new Error(`App "${opts.id}" adds app.public routes without declaring public: true`);
|
|
695
709
|
const port = startOpts.port ?? 3000;
|
|
696
710
|
if (env.GOTENBERG_URL)
|
|
697
711
|
console.warn("[deprecation] GOTENBERG_URL only bootstraps gotenberg.url; remove it after cld admin settings status --env succeeds.");
|
|
@@ -705,7 +719,7 @@ export const defineApp = <
|
|
|
705
719
|
// Settings are encrypted at rest, so every container needs the secret. It
|
|
706
720
|
// is only read on the first decrypt, which meant a container without it
|
|
707
721
|
// booted cleanly and failed much later with an unrelated-looking error.
|
|
708
|
-
if (!
|
|
722
|
+
if (!connections().appSecret.current.trim()) {
|
|
709
723
|
throw new Error(
|
|
710
724
|
`APP_SECRET is not set (app "${meta.id}"). Settings are encrypted at rest and every app in the ` +
|
|
711
725
|
"deployment must share the same value; without it the first settings read fails.",
|
|
@@ -751,6 +765,7 @@ export const defineApp = <
|
|
|
751
765
|
const entry: AppRegistryEntry = {
|
|
752
766
|
package: packageName,
|
|
753
767
|
id: meta.id,
|
|
768
|
+
...(opts.public ? { public: true as const } : {}),
|
|
754
769
|
access: meta.access,
|
|
755
770
|
name: meta.name,
|
|
756
771
|
icon: meta.icon,
|
|
@@ -818,6 +833,7 @@ export const defineApp = <
|
|
|
818
833
|
registry: appInstanceRegistry,
|
|
819
834
|
lock: appMembershipLock,
|
|
820
835
|
startedAt: entry.startedAt,
|
|
836
|
+
bootstrap: connections().bootstrap,
|
|
821
837
|
});
|
|
822
838
|
// Heartbeat
|
|
823
839
|
const heartbeat = createHeartbeat(meta.id, entry, {
|
|
@@ -894,7 +910,10 @@ export const defineApp = <
|
|
|
894
910
|
// app middleware cannot report their template. Without this, every hashed
|
|
895
911
|
// island chunk would land in telemetry as its own route.
|
|
896
912
|
const invocationApp = { getApp: async (id: string) => (id === meta.id ? meta : null) };
|
|
913
|
+
const publicSurface = createPublicSurfaceHandler(opts.public ? publicRouter : undefined, isDevelopment);
|
|
897
914
|
const server = new Hono<AuthContext>()
|
|
915
|
+
.all(PUBLIC_SURFACE_PREFIX, publicSurface)
|
|
916
|
+
.all(`${PUBLIC_SURFACE_PREFIX}/*`, publicSurface)
|
|
898
917
|
.use("*", routeTemplate)
|
|
899
918
|
.get(APP_READINESS_PATH, () => appReadinessResponse(meta.id))
|
|
900
919
|
.route(ssrMountPath, routes(config))
|
|
@@ -1179,6 +1198,8 @@ export const defineApp = <
|
|
|
1179
1198
|
// Core creates it in its own setup, so it starts publishing after that.
|
|
1180
1199
|
if (meta.id !== "core") await startOutbox();
|
|
1181
1200
|
|
|
1201
|
+
cleanup.push(await startLinksRuntime());
|
|
1202
|
+
|
|
1182
1203
|
if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
|
|
1183
1204
|
log.info(`Setup: ${meta.id}`);
|
|
1184
1205
|
const setup = startOpts.lifecycle.setup;
|
|
@@ -1264,6 +1285,14 @@ export const defineApp = <
|
|
|
1264
1285
|
config,
|
|
1265
1286
|
meta,
|
|
1266
1287
|
baseUrl: opts.baseUrl,
|
|
1288
|
+
public: publicRouter,
|
|
1289
|
+
publicSsr: <T extends object = object>(template: NonNullable<SsrOptions<T>["template"]>) => {
|
|
1290
|
+
if (!opts.public) throw new Error(`App "${opts.id}" uses publicSsr without declaring public: true`);
|
|
1291
|
+
const site = createSsrConfig<T>({ dev: config.dev, rootDir: config.rootDir, basePath: "", template });
|
|
1292
|
+
publicRouter.route("/_ssr", routes(site.config));
|
|
1293
|
+
publicRouter.all("/_ssr/*", (c) => c.notFound());
|
|
1294
|
+
return { html: site.html };
|
|
1295
|
+
},
|
|
1267
1296
|
start,
|
|
1268
1297
|
get sync() {
|
|
1269
1298
|
return getProcessSync();
|
|
@@ -48,6 +48,7 @@ export const createRegistryMembership = (
|
|
|
48
48
|
instanceId?: string;
|
|
49
49
|
hostname?: string;
|
|
50
50
|
startedAt?: number;
|
|
51
|
+
bootstrap?: AppInstanceRecord["bootstrap"];
|
|
51
52
|
writeTimeoutMs?: number;
|
|
52
53
|
},
|
|
53
54
|
) => {
|
|
@@ -60,6 +61,7 @@ export const createRegistryMembership = (
|
|
|
60
61
|
hostname: options.hostname ?? hostname(),
|
|
61
62
|
startedAt: options.startedAt ?? Date.now(),
|
|
62
63
|
entries: {},
|
|
64
|
+
...(options.bootstrap ? { bootstrap: structuredClone(options.bootstrap) } : {}),
|
|
63
65
|
};
|
|
64
66
|
const writeTimeoutMs = options.writeTimeoutMs ?? HEARTBEAT_WRITE_TIMEOUT_MS;
|
|
65
67
|
if (!Number.isFinite(writeTimeoutMs) || writeTimeoutMs <= 0) throw new RangeError("Membership write timeout must be positive and finite");
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* NATS connection for @k2b/sync — one connection per app process, created by
|
|
3
|
-
* the app lifecycle. Configuration comes from
|
|
3
|
+
* the app lifecycle. Configuration comes from the platform startup snapshot; sync
|
|
4
4
|
* itself reads no ENV and loads no credentials.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
7
6
|
import { connect, credsAuthenticator, type NatsConnection } from "@nats-io/transport-node";
|
|
8
|
-
import {
|
|
7
|
+
import { connections } from "../runtime/connections";
|
|
9
8
|
|
|
10
9
|
export type ConnectNatsOptions = {
|
|
11
10
|
/** Connection name shown in NATS monitoring (`connz`), e.g. `cloud-<appId>-<instanceId>`. */
|
|
@@ -13,15 +12,17 @@ export type ConnectNatsOptions = {
|
|
|
13
12
|
};
|
|
14
13
|
|
|
15
14
|
/** Dial `NATS_SERVERS` once. `startProcessSync()` requires the list and retries while NATS does not answer. */
|
|
16
|
-
export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> =>
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> => {
|
|
16
|
+
const nats = connections().nats;
|
|
17
|
+
return connect({
|
|
18
|
+
servers: nats.servers,
|
|
19
19
|
name,
|
|
20
|
-
ignoreClusterUpdates:
|
|
20
|
+
ignoreClusterUpdates: nats.ignoreClusterUpdates,
|
|
21
21
|
// Keep reconnecting for the life of the process: sync work resumes when the cluster is back.
|
|
22
22
|
reconnect: true,
|
|
23
23
|
maxReconnectAttempts: -1,
|
|
24
24
|
reconnectTimeWait: 2_000,
|
|
25
|
-
...(
|
|
26
|
-
...(
|
|
25
|
+
...(nats.creds ? { authenticator: credsAuthenticator(new TextEncoder().encode(nats.creds)) } : {}),
|
|
26
|
+
...(nats.ca ? { tls: { ca: nats.ca } } : {}),
|
|
27
27
|
});
|
|
28
|
+
};
|
|
@@ -11,7 +11,7 @@ import { hostname } from "node:os";
|
|
|
11
11
|
import { createSync, type Sync, SyncError } from "@k2b/sync";
|
|
12
12
|
import { expBackoff } from "@k2b/sync/retry";
|
|
13
13
|
import type { NatsConnection } from "@nats-io/transport-node";
|
|
14
|
-
import {
|
|
14
|
+
import { connections } from "../runtime/connections";
|
|
15
15
|
import { logger } from "../services/logging";
|
|
16
16
|
import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
|
|
17
17
|
import { connectNats } from "./nats-connection";
|
|
@@ -135,12 +135,12 @@ const connectReadySync = async (application: string): Promise<{ sync: Sync; conn
|
|
|
135
135
|
sync = withSyncBudgets(
|
|
136
136
|
createSync({
|
|
137
137
|
connection,
|
|
138
|
-
namespace:
|
|
138
|
+
namespace: connections().nats.namespace,
|
|
139
139
|
application,
|
|
140
|
-
defaults: { replicas:
|
|
140
|
+
defaults: { replicas: connections().nats.replicas },
|
|
141
141
|
observe: (event) => observeSyncEvent(event, application),
|
|
142
142
|
}),
|
|
143
|
-
{ connection, namespace:
|
|
143
|
+
{ connection, namespace: connections().nats.namespace },
|
|
144
144
|
);
|
|
145
145
|
bindProcessSync(sync);
|
|
146
146
|
await sync.ready();
|
|
@@ -164,13 +164,13 @@ const connectReadySync = async (application: string): Promise<{ sync: Sync; conn
|
|
|
164
164
|
* serves readiness before this returns.
|
|
165
165
|
*/
|
|
166
166
|
export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
|
|
167
|
-
if (!
|
|
167
|
+
if (!connections().nats.namespace.trim()) {
|
|
168
168
|
throw new Error(
|
|
169
169
|
`SYNC_NAMESPACE is not set (application "${application}"). Every process of one Cloud installation ` +
|
|
170
170
|
"must share the same @k2b/sync namespace.",
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
|
-
if (
|
|
173
|
+
if (connections().nats.servers.length === 0) {
|
|
174
174
|
throw new Error(
|
|
175
175
|
`NATS_SERVERS is not set (application "${application}"). @k2b/sync needs a comma-separated list of ` +
|
|
176
176
|
"nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import type { Handler, Hono } from "hono";
|
|
3
|
+
import { isCloudCredentialCookie, normalizeAppDomainHost, PUBLIC_HOST_HEADER, PUBLIC_SURFACE_PREFIX } from "../contracts/app-domains";
|
|
4
|
+
import { canonicalRoute } from "./app-path";
|
|
5
|
+
|
|
6
|
+
type RequestServer = Pick<Server<unknown>, "upgrade" | "requestIP" | "timeout">;
|
|
7
|
+
const isRequestServer = (value: unknown): value is RequestServer =>
|
|
8
|
+
typeof value === "object" &&
|
|
9
|
+
value !== null &&
|
|
10
|
+
"upgrade" in value &&
|
|
11
|
+
typeof value.upgrade === "function" &&
|
|
12
|
+
"requestIP" in value &&
|
|
13
|
+
typeof value.requestIP === "function" &&
|
|
14
|
+
"timeout" in value &&
|
|
15
|
+
typeof value.timeout === "function";
|
|
16
|
+
|
|
17
|
+
/** Bun associates transport operations with its native Request, before the public-origin view is built. */
|
|
18
|
+
const publicEnvironment = (environment: object | undefined, request: Request, original: Request): object | undefined => {
|
|
19
|
+
if (!environment) return environment;
|
|
20
|
+
const bindServer = (server: RequestServer) =>
|
|
21
|
+
new Proxy(server, {
|
|
22
|
+
get(target, property) {
|
|
23
|
+
const native = (candidate: Request) => (candidate === request ? original : candidate);
|
|
24
|
+
if (property === "upgrade")
|
|
25
|
+
return (candidate: Request, options: Parameters<RequestServer["upgrade"]>[1]) => target.upgrade(native(candidate), options);
|
|
26
|
+
if (property === "requestIP") return (candidate: Request) => target.requestIP(native(candidate));
|
|
27
|
+
if (property === "timeout") return (candidate: Request, seconds: number) => target.timeout(native(candidate), seconds);
|
|
28
|
+
const value: unknown = Reflect.get(target, property, target);
|
|
29
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
if (isRequestServer(environment)) return bindServer(environment);
|
|
33
|
+
if ("server" in environment && isRequestServer(environment.server)) {
|
|
34
|
+
const server = bindServer(environment.server);
|
|
35
|
+
return new Proxy(environment, {
|
|
36
|
+
get: (target, property) => (property === "server" ? server : Reflect.get(target, property, target)),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return environment;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Framework mounts must never be advertised as application routes. */
|
|
43
|
+
export const validatePublicSurfaceRoutes = (routes: readonly string[]): void => {
|
|
44
|
+
for (const route of routes) {
|
|
45
|
+
const path = new URL(canonicalRoute(decodeURIComponent(route).replaceAll("\\", "/")), "https://cloud.invalid").pathname;
|
|
46
|
+
if (path === PUBLIC_SURFACE_PREFIX || path.startsWith(`${PUBLIC_SURFACE_PREFIX}/`)) {
|
|
47
|
+
throw new Error(`Route "${route}" uses the reserved ${PUBLIC_SURFACE_PREFIX} prefix`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Dispatch before Cloud context/authentication, using a fresh public-origin request. */
|
|
53
|
+
export const createPublicSurfaceHandler =
|
|
54
|
+
(router: Pick<Hono, "fetch"> | undefined, development: boolean): Handler =>
|
|
55
|
+
(c) => {
|
|
56
|
+
const authority = c.req.header(PUBLIC_HOST_HEADER);
|
|
57
|
+
const parts = authority?.match(/^([^:]+)(?::([0-9]{1,5}))?$/);
|
|
58
|
+
const host = parts?.[1];
|
|
59
|
+
if (!router || !authority || !host || normalizeAppDomainHost(host) !== host || (parts?.[2] && Number(parts[2]) > 65535))
|
|
60
|
+
return c.notFound();
|
|
61
|
+
const original = new URL(c.req.url);
|
|
62
|
+
const url = new URL(`${development && host.endsWith(".localhost") ? "http" : "https"}://${authority}`);
|
|
63
|
+
url.pathname = original.pathname.slice(PUBLIC_SURFACE_PREFIX.length) || "/";
|
|
64
|
+
url.search = original.search;
|
|
65
|
+
const headers = new Headers(c.req.raw.headers);
|
|
66
|
+
const cookies = headers
|
|
67
|
+
.get("cookie")
|
|
68
|
+
?.split(";")
|
|
69
|
+
.filter((cookie) => !isCloudCredentialCookie(cookie.split("=", 1)[0]!.trim()));
|
|
70
|
+
if (cookies?.some((cookie) => cookie.trim())) headers.set("cookie", cookies.join(";").trim());
|
|
71
|
+
else headers.delete("cookie");
|
|
72
|
+
headers.delete(PUBLIC_HOST_HEADER);
|
|
73
|
+
headers.set("host", authority);
|
|
74
|
+
const request = new Request(url, new Request(c.req.raw, { headers }));
|
|
75
|
+
return router.fetch(request, publicEnvironment(c.env, request, c.req.raw));
|
|
76
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const PURPOSE_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
2
|
+
|
|
3
|
+
export const isPurposeName = (name: string): boolean => typeof name === "string" && PURPOSE_NAME.test(name);
|
|
4
|
+
|
|
5
|
+
export const validatePurposeName = (name: string): void => {
|
|
6
|
+
if (!isPurposeName(name)) throw new TypeError("Purpose name must match [a-z0-9][a-z0-9.-]{0,63}");
|
|
7
|
+
};
|
|
@@ -20,6 +20,7 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
20
20
|
for (const field of ["id", "name", "icon", "description", "baseUrl"] as const) {
|
|
21
21
|
if (!isString(value[field]) || value[field].length === 0) return invalid(field, "a non-empty string");
|
|
22
22
|
}
|
|
23
|
+
if (value.public !== undefined && value.public !== true) return invalid("public", "true when present");
|
|
23
24
|
if (value.package !== undefined && (typeof value.package !== "string" || !/^[a-z][a-z0-9-]{0,79}$/.test(value.package))) {
|
|
24
25
|
return invalid("package", "a lowercase package name of 1–80 characters");
|
|
25
26
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { EphemeralConfig, MutexConfig } from "@k2b/sync";
|
|
3
3
|
import type { AppAppearanceColor } from "../contracts/app";
|
|
4
|
+
import { appStatusIssues } from "../contracts/app-status";
|
|
4
5
|
import { calendarSourceIssues } from "../contracts/calendar-source";
|
|
5
6
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
6
7
|
import { discussionsIssues } from "../contracts/discussions";
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
WIDGET_TITLE_MAX_LENGTH,
|
|
17
18
|
type WidgetSettingsDeclaration,
|
|
18
19
|
} from "../contracts/widgets";
|
|
20
|
+
import type { PlatformBootstrapMetadata } from "../runtime/connections";
|
|
19
21
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
20
22
|
import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
|
|
21
23
|
import { lazySync } from "./process-sync";
|
|
@@ -63,6 +65,8 @@ export type AppInstanceRecord = {
|
|
|
63
65
|
hostname: string;
|
|
64
66
|
startedAt: number;
|
|
65
67
|
entries: Record<string, string>;
|
|
68
|
+
/** Missing on older instances means unknown, never env or successful bootstrap. */
|
|
69
|
+
bootstrap?: PlatformBootstrapMetadata;
|
|
66
70
|
};
|
|
67
71
|
|
|
68
72
|
// The id and owner are a cross-version contract: all replicas must use the same lock.
|
|
@@ -103,6 +107,8 @@ export type AppRegistryDetail = AppRegistryEntry & {
|
|
|
103
107
|
updatedAt: number;
|
|
104
108
|
expiresAt: number;
|
|
105
109
|
version: string;
|
|
110
|
+
/** Per-process startup metadata for admin readers. null means an older/unknown producer. */
|
|
111
|
+
instances?: { instanceId: string; hostname: string; startedAt: number; bootstrap: PlatformBootstrapMetadata | null }[];
|
|
106
112
|
};
|
|
107
113
|
|
|
108
114
|
export type AppRegistryIssue = {
|
|
@@ -238,6 +244,7 @@ const reportLeftOutEntries = (sent: unknown, manifest: CapabilityManifest): void
|
|
|
238
244
|
});
|
|
239
245
|
if (groups.calendarSource !== undefined && !manifest.calendarSource) leftOut.push("calendarSource");
|
|
240
246
|
if (groups.fileProvider !== undefined && !manifest.fileProvider) leftOut.push("fileProvider");
|
|
247
|
+
if (groups.appStatus !== undefined && !manifest.appStatus) leftOut.push("appStatus");
|
|
241
248
|
if (groups.discussions !== undefined && !manifest.discussions) leftOut.push("discussions");
|
|
242
249
|
if (leftOut.length === 0) return;
|
|
243
250
|
console.warn(
|
|
@@ -348,6 +355,32 @@ const withValidDiscussions = (manifest: CapabilityManifest): CapabilityManifest
|
|
|
348
355
|
return rest;
|
|
349
356
|
};
|
|
350
357
|
|
|
358
|
+
const checkedAppStatus = new Map<string, { manifestHash: string; valid: boolean }>();
|
|
359
|
+
const withValidAppStatus = (manifest: CapabilityManifest): CapabilityManifest => {
|
|
360
|
+
if (!manifest.appStatus) return manifest;
|
|
361
|
+
const checked = checkedAppStatus.get(manifest.appId);
|
|
362
|
+
let valid = checked?.manifestHash === manifest.manifestHash ? checked.valid : undefined;
|
|
363
|
+
if (valid === undefined) {
|
|
364
|
+
const issues = appStatusIssues(manifest);
|
|
365
|
+
valid = issues.length === 0;
|
|
366
|
+
checkedAppStatus.set(manifest.appId, { manifestHash: manifest.manifestHash, valid });
|
|
367
|
+
if (!valid)
|
|
368
|
+
console.error(
|
|
369
|
+
JSON.stringify({
|
|
370
|
+
level: "error",
|
|
371
|
+
source: "capability-registry",
|
|
372
|
+
message: "Ignored an incompatible appStatus provider",
|
|
373
|
+
appId: manifest.appId,
|
|
374
|
+
manifestHash: manifest.manifestHash,
|
|
375
|
+
issues,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (valid) return manifest;
|
|
380
|
+
const { appStatus: _ignored, ...rest } = manifest;
|
|
381
|
+
return rest;
|
|
382
|
+
};
|
|
383
|
+
|
|
351
384
|
/** Ignores record fields from a newer release; the endpoint always comes from the live app registry. */
|
|
352
385
|
export const resolveLiveCapabilityRegistryEntry = (
|
|
353
386
|
key: string,
|
|
@@ -362,7 +395,7 @@ export const resolveLiveCapabilityRegistryEntry = (
|
|
|
362
395
|
if (!endpoint) return null;
|
|
363
396
|
try {
|
|
364
397
|
const read = parseCapabilityManifest(record.manifest, app.id);
|
|
365
|
-
const supported = withValidDiscussions(withValidCalendarSource(withValidFileProvider(read)));
|
|
398
|
+
const supported = withValidAppStatus(withValidDiscussions(withValidCalendarSource(withValidFileProvider(read))));
|
|
366
399
|
const manifest = supported.resourcePages
|
|
367
400
|
? {
|
|
368
401
|
...supported,
|
|
@@ -411,8 +444,21 @@ export const getCapability = async (appId: string): Promise<CapabilityRegistryEn
|
|
|
411
444
|
* Same as `listApps` but returns registry metadata for admin observability.
|
|
412
445
|
*/
|
|
413
446
|
export const listAppsDetailed = async (): Promise<AppRegistryDetail[]> => {
|
|
414
|
-
const snapshot = await
|
|
415
|
-
|
|
447
|
+
const [snapshot, instances] = await Promise.all([
|
|
448
|
+
readAppRegistrySnapshot(),
|
|
449
|
+
appInstanceRegistry().snapshot({ prefix: APP_INSTANCE_PREFIX }),
|
|
450
|
+
]);
|
|
451
|
+
return snapshot.apps.map((app) => ({
|
|
452
|
+
...app,
|
|
453
|
+
instances: instances.entries
|
|
454
|
+
.filter(({ value }) => value.appId === app.id)
|
|
455
|
+
.map(({ value }) => ({
|
|
456
|
+
instanceId: value.instanceId,
|
|
457
|
+
hostname: value.hostname,
|
|
458
|
+
startedAt: value.startedAt,
|
|
459
|
+
bootstrap: value.bootstrap ?? null,
|
|
460
|
+
})),
|
|
461
|
+
}));
|
|
416
462
|
};
|
|
417
463
|
|
|
418
464
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { env } from "../config/env";
|
|
2
|
+
import { connections } from "../runtime/connections";
|
|
2
3
|
import { getProcessApplicationId } from "./process-identity";
|
|
3
4
|
import { getProcessNats } from "./process-sync";
|
|
4
5
|
import {
|
|
@@ -50,7 +51,7 @@ export const createAppWorkloadCredential = (options: {
|
|
|
50
51
|
const request =
|
|
51
52
|
options.request ??
|
|
52
53
|
(async (input: WorkloadRequest) => {
|
|
53
|
-
const response = await getProcessNats().request(workloadSubject(
|
|
54
|
+
const response = await getProcessNats().request(workloadSubject(connections().nats.namespace), JSON.stringify(input), {
|
|
54
55
|
timeout: WORKLOAD_REQUEST_TIMEOUT_MS,
|
|
55
56
|
});
|
|
56
57
|
return response.json<unknown>();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { describeRoute } from "hono-openapi";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type AuthContext, auth, respond, v } from "../server";
|
|
5
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
6
|
+
import { appDomains } from "../services/app-domains";
|
|
7
|
+
import { logger } from "../services/logging";
|
|
8
|
+
|
|
9
|
+
const appParams = z.object({ appId: z.string().regex(/^[a-z][a-z0-9-]{0,79}$/) });
|
|
10
|
+
const domainParams = appParams.extend({ host: z.string().max(1024) });
|
|
11
|
+
const host = z.string().max(1024);
|
|
12
|
+
const addInput = z.object({ host, redirectTo: host.optional() }).strict();
|
|
13
|
+
const setInput = z.object({ redirectTo: host.nullable() }).strict();
|
|
14
|
+
const describe = (summary: string) => describeRoute({ tags: ["App domains"], summary });
|
|
15
|
+
|
|
16
|
+
export const createAdminAppDomainRoutes = (
|
|
17
|
+
authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
|
|
18
|
+
service: typeof appDomains = appDomains,
|
|
19
|
+
) =>
|
|
20
|
+
new Hono<AuthContext>()
|
|
21
|
+
.use("/:appId/domains/*", noTransform())
|
|
22
|
+
.use("/:appId/domains/*", authenticate)
|
|
23
|
+
.use("/:appId/domains/*", async (c, next) => {
|
|
24
|
+
c.header("Cache-Control", "no-store");
|
|
25
|
+
if (c.req.method !== "GET") return auth.requireOAuthScope("admin")(c, next);
|
|
26
|
+
return next();
|
|
27
|
+
})
|
|
28
|
+
.get("/:appId/domains", describe("List assigned domains and their last check"), v("param", appParams), (c) =>
|
|
29
|
+
respond(c, () => service.list({ actor: c.get("user"), ...c.req.valid("param") })),
|
|
30
|
+
)
|
|
31
|
+
.post(
|
|
32
|
+
"/:appId/domains",
|
|
33
|
+
describe("Assign a domain and start its first check in the background"),
|
|
34
|
+
v("param", appParams),
|
|
35
|
+
v("json", addInput),
|
|
36
|
+
async (c) => {
|
|
37
|
+
const input = { actor: c.get("user"), ...c.req.valid("param"), ...c.req.valid("json") };
|
|
38
|
+
const result = await service.add(input);
|
|
39
|
+
if (result.ok)
|
|
40
|
+
void service.check({ ...input, host: result.data.host }).catch((error: unknown) => {
|
|
41
|
+
logger("core:app-domains").error("Initial domain check failed; retry through the domain check endpoint", {
|
|
42
|
+
host: result.data.host,
|
|
43
|
+
error: error instanceof Error ? error.message : String(error),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
return respond(c, result, 201);
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
.patch("/:appId/domains/:host", describe("Change or clear the domain redirect"), v("param", domainParams), v("json", setInput), (c) =>
|
|
50
|
+
respond(c, () => service.set({ actor: c.get("user"), ...c.req.valid("param"), ...c.req.valid("json") })),
|
|
51
|
+
)
|
|
52
|
+
.delete("/:appId/domains/:host", describe("Remove an unreferenced domain assignment"), v("param", domainParams), (c) =>
|
|
53
|
+
respond(c, () => service.remove({ actor: c.get("user"), ...c.req.valid("param") })),
|
|
54
|
+
)
|
|
55
|
+
.post(
|
|
56
|
+
"/:appId/domains/:host/check",
|
|
57
|
+
describe("Check DNS, certificate and gateway reachability, at most once per 60 seconds"),
|
|
58
|
+
v("param", domainParams),
|
|
59
|
+
(c) => respond(c, () => service.check({ actor: c.get("user"), ...c.req.valid("param") })),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export default createAdminAppDomainRoutes();
|