@k2b/cloud 0.46.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 +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- 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 +69 -2
- package/src/_internal/heartbeat.ts +21 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +15 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +154 -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-domains.ts +62 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +17 -4
- package/src/api/index.ts +8 -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 +6 -2
- package/src/capabilities/server-invoke.ts +73 -29
- 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-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +109 -84
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +34 -22
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- 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/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +10 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +46 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/client-ip.ts +1 -1
- 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 +301 -0
- package/src/services/downstream-budget.ts +2 -2
- 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 +8 -1
- 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/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/request-cache-redis.ts +2 -2
- 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 +119 -1
- package/src/services/settings/crypto.ts +2 -2
- 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 +78 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +57 -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.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": {
|
|
@@ -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.38.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.1"
|
|
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 ───────────────────────────────────────────────────────────────
|
|
@@ -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 } : {}),
|
|
@@ -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";
|
|
@@ -26,7 +27,7 @@ import { bindProcessApplicationId, clearProcessApplicationId } from "./process-i
|
|
|
26
27
|
|
|
27
28
|
import { resolve } from "node:path";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import type { HtmlFn, SsrConfig } from "@k2b/ssr";
|
|
30
|
+
import type { HtmlFn, SsrConfig, SsrOptions } from "@k2b/ssr";
|
|
30
31
|
import { createConfig as createSsrConfig } from "@k2b/ssr";
|
|
31
32
|
import { routes } from "@k2b/ssr/hono";
|
|
32
33
|
import type { Sync } from "@k2b/sync";
|
|
@@ -47,6 +48,7 @@ import type {
|
|
|
47
48
|
CloudLifecycleContext,
|
|
48
49
|
WidgetEndpoint,
|
|
49
50
|
} from "../contracts/app";
|
|
51
|
+
import { PUBLIC_SURFACE_PREFIX } from "../contracts/app-domains";
|
|
50
52
|
import {
|
|
51
53
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
52
54
|
CAPABILITY_MAX_REQUEST_BYTES,
|
|
@@ -67,8 +69,10 @@ import { getLocale, resolveLocale } from "../server/locale";
|
|
|
67
69
|
import { requireAppAccess, stopAppAccessConnections } from "../server/middleware/app-access";
|
|
68
70
|
import { type AuthContext, auth } from "../server/middleware/auth";
|
|
69
71
|
import { requireInvocation } from "../server/middleware/invocation";
|
|
72
|
+
import { createPublicSurfaceHandler, validatePublicSurfaceRoutes } from "./public-surface";
|
|
70
73
|
import "../events/app-access";
|
|
71
74
|
import { createNestedCapabilityQuery } from "../capabilities/nested";
|
|
75
|
+
import { connections } from "../runtime/connections";
|
|
72
76
|
import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
|
|
73
77
|
import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
|
|
74
78
|
import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
|
|
@@ -82,6 +86,7 @@ import {
|
|
|
82
86
|
widgetInvocationOperation,
|
|
83
87
|
} from "../services/identity/invocation-operations";
|
|
84
88
|
import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
|
|
89
|
+
import { startLinksRuntime } from "../services/links/runtime";
|
|
85
90
|
import { logger } from "../services/logging";
|
|
86
91
|
import { startNotificationDefinitionRegistration } from "../services/notifications/catalog";
|
|
87
92
|
import { readRailSnapshot } from "../services/rail-snapshot";
|
|
@@ -119,6 +124,7 @@ import { publicFileVersion, servePublicAsset, sharedStylesheetVersion, versioned
|
|
|
119
124
|
import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
|
|
120
125
|
import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
|
|
121
126
|
import { compileWidgetDeclarations } from "./widget-declarations";
|
|
127
|
+
import { createAppWorkloadCredential } from "./workload-credential";
|
|
122
128
|
|
|
123
129
|
/** Only development links use this start stamp to pick up rebuilds. */
|
|
124
130
|
const developmentStamp = Date.now();
|
|
@@ -162,6 +168,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
162
168
|
/** Shared object-store package. Defaults to the app ID. */
|
|
163
169
|
package?: string;
|
|
164
170
|
access?: AppAccessDeclaration;
|
|
171
|
+
/** Serve a separate public site on domains assigned by an administrator. */
|
|
172
|
+
public?: true;
|
|
165
173
|
name: string;
|
|
166
174
|
icon: string;
|
|
167
175
|
description: string;
|
|
@@ -370,6 +378,10 @@ export type AppDefinition<S extends AppSettingsMap = {}, N extends NotificationD
|
|
|
370
378
|
config: SsrConfig;
|
|
371
379
|
meta: AppMeta;
|
|
372
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> };
|
|
373
385
|
start: (opts: StartOptions) => Promise<StartResult>;
|
|
374
386
|
/**
|
|
375
387
|
* The process-wide @k2b/sync instance, connected and ready once `start()`
|
|
@@ -476,6 +488,8 @@ export const defineApp = <
|
|
|
476
488
|
>(
|
|
477
489
|
opts: AppOptions<S, N, AppId>,
|
|
478
490
|
): AppDefinition<S, N, AppId> => {
|
|
491
|
+
validatePublicSurfaceRoutes(opts.routes);
|
|
492
|
+
const publicRouter = new Hono();
|
|
479
493
|
const appSkills = opts.skills ? validateAppSkillCatalog(opts.skills) : undefined;
|
|
480
494
|
const skillsManifestHash = appSkills ? appSkillManifestHash(appSkills) : undefined;
|
|
481
495
|
configurePostgresApplicationName(opts.id);
|
|
@@ -690,7 +704,13 @@ export const defineApp = <
|
|
|
690
704
|
|
|
691
705
|
// ── 3. start() — builds and boots the Hono server ────────────────────
|
|
692
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`);
|
|
693
709
|
const port = startOpts.port ?? 3000;
|
|
710
|
+
if (env.GOTENBERG_URL)
|
|
711
|
+
console.warn("[deprecation] GOTENBERG_URL only bootstraps gotenberg.url; remove it after cld admin settings status --env succeeds.");
|
|
712
|
+
if (env.CLOUD_IDENTITY_JWKS_ORIGIN)
|
|
713
|
+
console.warn("[deprecation] CLOUD_IDENTITY_JWKS_ORIGIN is deprecated; use CLOUD_CORE_INTERNAL_ORIGIN instead.");
|
|
694
714
|
const baseUrl = opts.baseUrl;
|
|
695
715
|
const log = logger("app");
|
|
696
716
|
const http = createHttpDrain();
|
|
@@ -699,7 +719,7 @@ export const defineApp = <
|
|
|
699
719
|
// Settings are encrypted at rest, so every container needs the secret. It
|
|
700
720
|
// is only read on the first decrypt, which meant a container without it
|
|
701
721
|
// booted cleanly and failed much later with an unrelated-looking error.
|
|
702
|
-
if (!
|
|
722
|
+
if (!connections().appSecret.current.trim()) {
|
|
703
723
|
throw new Error(
|
|
704
724
|
`APP_SECRET is not set (app "${meta.id}"). Settings are encrypted at rest and every app in the ` +
|
|
705
725
|
"deployment must share the same value; without it the first settings read fails.",
|
|
@@ -745,6 +765,7 @@ export const defineApp = <
|
|
|
745
765
|
const entry: AppRegistryEntry = {
|
|
746
766
|
package: packageName,
|
|
747
767
|
id: meta.id,
|
|
768
|
+
...(opts.public ? { public: true as const } : {}),
|
|
748
769
|
access: meta.access,
|
|
749
770
|
name: meta.name,
|
|
750
771
|
icon: meta.icon,
|
|
@@ -812,6 +833,7 @@ export const defineApp = <
|
|
|
812
833
|
registry: appInstanceRegistry,
|
|
813
834
|
lock: appMembershipLock,
|
|
814
835
|
startedAt: entry.startedAt,
|
|
836
|
+
bootstrap: connections().bootstrap,
|
|
815
837
|
});
|
|
816
838
|
// Heartbeat
|
|
817
839
|
const heartbeat = createHeartbeat(meta.id, entry, {
|
|
@@ -864,6 +886,7 @@ export const defineApp = <
|
|
|
864
886
|
await capabilityHeartbeat?.stop();
|
|
865
887
|
});
|
|
866
888
|
cleanup.push(() => heartbeat.stop());
|
|
889
|
+
cleanup.push(() => membership.leave());
|
|
867
890
|
|
|
868
891
|
// Runtime context — start the registry watcher so middleware.runtime() and
|
|
869
892
|
// the lifecycle context below see populated cluster state. Idempotent: the
|
|
@@ -887,7 +910,10 @@ export const defineApp = <
|
|
|
887
910
|
// app middleware cannot report their template. Without this, every hashed
|
|
888
911
|
// island chunk would land in telemetry as its own route.
|
|
889
912
|
const invocationApp = { getApp: async (id: string) => (id === meta.id ? meta : null) };
|
|
913
|
+
const publicSurface = createPublicSurfaceHandler(opts.public ? publicRouter : undefined, isDevelopment);
|
|
890
914
|
const server = new Hono<AuthContext>()
|
|
915
|
+
.all(PUBLIC_SURFACE_PREFIX, publicSurface)
|
|
916
|
+
.all(`${PUBLIC_SURFACE_PREFIX}/*`, publicSurface)
|
|
891
917
|
.use("*", routeTemplate)
|
|
892
918
|
.get(APP_READINESS_PATH, () => appReadinessResponse(meta.id))
|
|
893
919
|
.route(ssrMountPath, routes(config))
|
|
@@ -909,6 +935,18 @@ export const defineApp = <
|
|
|
909
935
|
if (!c.get("user")?.roles.includes("admin")) return c.json({ message: "Insufficient permissions" }, 403);
|
|
910
936
|
return next();
|
|
911
937
|
})
|
|
938
|
+
.use(
|
|
939
|
+
"/_internal/settings/*",
|
|
940
|
+
requireInvocation(
|
|
941
|
+
(c) => ({
|
|
942
|
+
targetAppId: meta.id,
|
|
943
|
+
operation: settingsInvocationOperation(c.req.method, new URL(c.req.url).pathname.slice("/_internal/settings/".length)),
|
|
944
|
+
schemaHash: null,
|
|
945
|
+
}),
|
|
946
|
+
invocationApp,
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
.route("/_internal/settings", createSettingsOpsRoutes(Object.keys(opts.settings ?? {})))
|
|
912
950
|
.route("/_internal/sync", createSyncOpsRoutes(getProcessSync));
|
|
913
951
|
|
|
914
952
|
if (installable) server.route("/", createInstallableRoutes(meta.id, installable));
|
|
@@ -1145,6 +1183,8 @@ export const defineApp = <
|
|
|
1145
1183
|
|
|
1146
1184
|
let stopNotificationRegistration: (() => void) | undefined;
|
|
1147
1185
|
cleanup.push(() => stopNotificationRegistration?.());
|
|
1186
|
+
let workloadCredential: ReturnType<typeof createAppWorkloadCredential> | undefined;
|
|
1187
|
+
cleanup.push(() => workloadCredential?.stop());
|
|
1148
1188
|
const stopLifecycle = startOpts.lifecycle?.stop;
|
|
1149
1189
|
if (stopLifecycle) cleanup.push(() => stopLifecycle(cloudCtx));
|
|
1150
1190
|
|
|
@@ -1158,6 +1198,8 @@ export const defineApp = <
|
|
|
1158
1198
|
// Core creates it in its own setup, so it starts publishing after that.
|
|
1159
1199
|
if (meta.id !== "core") await startOutbox();
|
|
1160
1200
|
|
|
1201
|
+
cleanup.push(await startLinksRuntime());
|
|
1202
|
+
|
|
1161
1203
|
if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
|
|
1162
1204
|
log.info(`Setup: ${meta.id}`);
|
|
1163
1205
|
const setup = startOpts.lifecycle.setup;
|
|
@@ -1169,6 +1211,23 @@ export const defineApp = <
|
|
|
1169
1211
|
|
|
1170
1212
|
await loadSettingsCache();
|
|
1171
1213
|
|
|
1214
|
+
// The instance can obtain its credential before workers run; routes remain undiscoverable.
|
|
1215
|
+
await membership.register();
|
|
1216
|
+
workloadCredential = createAppWorkloadCredential({
|
|
1217
|
+
appId: meta.id,
|
|
1218
|
+
instanceId: membership.instanceId,
|
|
1219
|
+
onRecovered: () => log.info("App workload credential refresh recovered", { appId: meta.id }),
|
|
1220
|
+
onUnavailable: (reason) => {
|
|
1221
|
+
if (reason === "release_unavailable") {
|
|
1222
|
+
log.warn("Workload credential release was not confirmed; Core will reconcile after removal", { appId: meta.id });
|
|
1223
|
+
} else {
|
|
1224
|
+
log.error("App workload credential unavailable; background invocation is disabled", { appId: meta.id, reason });
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
});
|
|
1228
|
+
// The NATS request has a five-second timeout; an outage leaves workers fail-closed.
|
|
1229
|
+
await workloadCredential.start();
|
|
1230
|
+
|
|
1172
1231
|
if (startOpts.lifecycle?.start) {
|
|
1173
1232
|
log.info(`Start: ${meta.id}`);
|
|
1174
1233
|
await startOpts.lifecycle.start(cloudCtx);
|
|
@@ -1226,6 +1285,14 @@ export const defineApp = <
|
|
|
1226
1285
|
config,
|
|
1227
1286
|
meta,
|
|
1228
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
|
+
},
|
|
1229
1296
|
start,
|
|
1230
1297
|
get sync() {
|
|
1231
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");
|
|
@@ -94,6 +96,25 @@ export const createRegistryMembership = (
|
|
|
94
96
|
};
|
|
95
97
|
|
|
96
98
|
return {
|
|
99
|
+
instanceId,
|
|
100
|
+
// Establish process identity before lifecycle workers start, without advertising routes yet.
|
|
101
|
+
register: async () => {
|
|
102
|
+
let timer: Timer | undefined;
|
|
103
|
+
// This runs before the ordinary heartbeat supplies its write deadline.
|
|
104
|
+
const write = upsert().finally(async () => {
|
|
105
|
+
if (left) await options.registry().delete({ key });
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
await Promise.race([
|
|
109
|
+
write,
|
|
110
|
+
new Promise<never>((_, reject) => {
|
|
111
|
+
timer = setTimeout(() => reject(new Error(`Instance registration timed out after ${writeTimeoutMs}ms`)), writeTimeoutMs);
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
97
118
|
join: async (sharedKey: string, sharedValue: unknown, entry: SharedEntry): Promise<void> => {
|
|
98
119
|
left = false;
|
|
99
120
|
await prepareShared(sharedKey);
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -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";
|
|
@@ -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 => {
|
|
@@ -128,12 +135,12 @@ const connectReadySync = async (application: string): Promise<{ sync: Sync; conn
|
|
|
128
135
|
sync = withSyncBudgets(
|
|
129
136
|
createSync({
|
|
130
137
|
connection,
|
|
131
|
-
namespace:
|
|
138
|
+
namespace: connections().nats.namespace,
|
|
132
139
|
application,
|
|
133
|
-
defaults: { replicas:
|
|
140
|
+
defaults: { replicas: connections().nats.replicas },
|
|
134
141
|
observe: (event) => observeSyncEvent(event, application),
|
|
135
142
|
}),
|
|
136
|
-
{ connection, namespace:
|
|
143
|
+
{ connection, namespace: connections().nats.namespace },
|
|
137
144
|
);
|
|
138
145
|
bindProcessSync(sync);
|
|
139
146
|
await sync.ready();
|
|
@@ -157,13 +164,13 @@ const connectReadySync = async (application: string): Promise<{ sync: Sync; conn
|
|
|
157
164
|
* serves readiness before this returns.
|
|
158
165
|
*/
|
|
159
166
|
export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
|
|
160
|
-
if (!
|
|
167
|
+
if (!connections().nats.namespace.trim()) {
|
|
161
168
|
throw new Error(
|
|
162
169
|
`SYNC_NAMESPACE is not set (application "${application}"). Every process of one Cloud installation ` +
|
|
163
170
|
"must share the same @k2b/sync namespace.",
|
|
164
171
|
);
|
|
165
172
|
}
|
|
166
|
-
if (
|
|
173
|
+
if (connections().nats.servers.length === 0) {
|
|
167
174
|
throw new Error(
|
|
168
175
|
`NATS_SERVERS is not set (application "${application}"). @k2b/sync needs a comma-separated list of ` +
|
|
169
176
|
"nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
|
|
@@ -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
|
}
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
});
|
|
@@ -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
|
}
|