@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { PWA_COOKIES } from "./pwa";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* App public surface and app domains: the contract Core, the platform, and the gateway share.
|
|
6
|
+
*
|
|
7
|
+
* An app with `defineApp({ public: true })` serves `app.public`. The platform mounts that router below
|
|
8
|
+
* `PUBLIC_SURFACE_PREFIX` on the app upstream. Only the gateway sends requests there, for a host an
|
|
9
|
+
* administrator assigned to the app, and sets `PUBLIC_HOST_HEADER` to the request authority (that host plus the
|
|
10
|
+
* client's port, if its Host header had one). The gateway strips both
|
|
11
|
+
* the prefix and the header from every incoming request on every host.
|
|
12
|
+
*/
|
|
13
|
+
export const PUBLIC_SURFACE_PREFIX = "/_cloud/public";
|
|
14
|
+
export const PUBLIC_HOST_HEADER = "X-Cloud-Public-Host";
|
|
15
|
+
|
|
16
|
+
/** The only Cloud path on an app domain: `GET <prefix><nonce>` answers the nonce while Core checks the domain. */
|
|
17
|
+
export const DOMAIN_CHECK_PATH_PREFIX = "/_cloud/domain-check/";
|
|
18
|
+
/**
|
|
19
|
+
* Valkey key Core sets before it requests the check path, with TTL `DOMAIN_CHECK_TTL_SECONDS`. Its value is a
|
|
20
|
+
* separate random answer that never appears in the request; the gateway responds with it, so an unrelated server
|
|
21
|
+
* that echoes the path cannot pass the check.
|
|
22
|
+
*/
|
|
23
|
+
export const domainCheckKey = (host: string, nonce: string): string => `cloud:domain-check:${host}:${nonce}`;
|
|
24
|
+
export const DOMAIN_CHECK_TTL_SECONDS = 60;
|
|
25
|
+
|
|
26
|
+
export type AppDomain = {
|
|
27
|
+
/** Normalized host: lowercase ASCII (IDNA), no port, no wildcard. */
|
|
28
|
+
host: string;
|
|
29
|
+
appId: string;
|
|
30
|
+
/** Another domain of the same app; requests then answer 301 (GET/HEAD) or 308 with path and query kept. */
|
|
31
|
+
redirectTo: string | null;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Durable assignment, owned by Core's migration. Readers before that migration see no table and no domains. */
|
|
36
|
+
export const APP_DOMAINS_DDL = `
|
|
37
|
+
CREATE TABLE IF NOT EXISTS settings.app_domains (
|
|
38
|
+
host TEXT PRIMARY KEY,
|
|
39
|
+
app_id TEXT NOT NULL,
|
|
40
|
+
redirect_to TEXT NULL,
|
|
41
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
42
|
+
)
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
|
|
46
|
+
|
|
47
|
+
/** Returns the normalized host, or null when `input` is not a single DNS host name without port or wildcard. */
|
|
48
|
+
export const normalizeAppDomainHost = (input: string): string | null => {
|
|
49
|
+
const trimmed = input.trim().replace(/\.$/, "");
|
|
50
|
+
if (!trimmed || /[/\\:@?#*\s]/.test(trimmed)) return null;
|
|
51
|
+
let host: string;
|
|
52
|
+
try {
|
|
53
|
+
host = new URL(`http://${trimmed}`).hostname;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (host.length > 253 || /^\d+(\.\d+){3}$/.test(host) || host.startsWith("[")) return null;
|
|
58
|
+
const labels = host.split(".");
|
|
59
|
+
if (labels.length < 2 && !host.endsWith("localhost")) return null;
|
|
60
|
+
return labels.every((label) => HOST_LABEL.test(label)) ? host : null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Reads all assignments. A database without Core's table yet has none. */
|
|
64
|
+
export const readAppDomains = async (db: SQL): Promise<AppDomain[]> => {
|
|
65
|
+
try {
|
|
66
|
+
const rows = await db<{ host: string; app_id: string; redirect_to: string | null; created_at: Date }[]>`
|
|
67
|
+
SELECT host, app_id, redirect_to, created_at FROM settings.app_domains ORDER BY host
|
|
68
|
+
`;
|
|
69
|
+
return rows.map((row) => ({ host: row.host, appId: row.app_id, redirectTo: row.redirect_to, createdAt: row.created_at.toISOString() }));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const code = error && typeof error === "object" && "errno" in error ? String(error.errno) : "";
|
|
72
|
+
if (code === "42P01") return [];
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Whether a registry entry declares `public: true`, read structurally so older entries stay valid. */
|
|
78
|
+
export const declaresPublicSurface = (entry: object): boolean => "public" in entry && entry.public === true;
|
|
79
|
+
|
|
80
|
+
/** Cookies that carry a Cloud credential. The gateway removes them from every request to a public surface. */
|
|
81
|
+
export const isCloudCredentialCookie = (name: string): boolean =>
|
|
82
|
+
name === "session_token" ||
|
|
83
|
+
name === PWA_COOKIES.session ||
|
|
84
|
+
name === PWA_COOKIES.device ||
|
|
85
|
+
name === PWA_COOKIES.pairing ||
|
|
86
|
+
name === "cloud_browser" ||
|
|
87
|
+
name === "__Host-cloud_browser" ||
|
|
88
|
+
name.startsWith("__Secure-cloud-app-pwa-") ||
|
|
89
|
+
// Access proofs that built-in apps set on the Cloud host.
|
|
90
|
+
name === "mail_attachment_grant" ||
|
|
91
|
+
name === "filesv2-share-access";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CapabilityAppIdSchema, type CapabilityManifest } from "./capabilities";
|
|
3
|
+
import { type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
|
|
4
|
+
|
|
5
|
+
export const AppStatusWindowSchema = z.enum(["1h", "6h", "24h", "7d", "30d"]);
|
|
6
|
+
export type AppStatusWindow = z.infer<typeof AppStatusWindowSchema>;
|
|
7
|
+
export const AppStatusSummaryInputSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
appIds: z.array(CapabilityAppIdSchema).min(1).max(200).describe("Application IDs whose status is requested."),
|
|
10
|
+
window: AppStatusWindowSchema.describe("Traffic observation window."),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
13
|
+
export const AppStatusDetailInputSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
appId: CapabilityAppIdSchema.describe("Application ID."),
|
|
16
|
+
window: AppStatusWindowSchema.describe("Traffic observation window."),
|
|
17
|
+
cursor: z.string().min(1).max(2048).optional().describe("Opaque continuation cursor from this provider."),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
export const AppStatusForgetInputSchema = z.object({ appId: CapabilityAppIdSchema.describe("Offline application ID to forget.") }).strict();
|
|
21
|
+
const count = z.number().int().nonnegative();
|
|
22
|
+
export const AppStatusTrafficSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
requests: count,
|
|
25
|
+
errors: count,
|
|
26
|
+
avgMs: z.number().nonnegative().nullable(),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
export const AppStatusLinkSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
kind: z.enum(["logs", "metrics", "routes", "capabilities"]).optional(),
|
|
32
|
+
label: z.string().min(1).max(120),
|
|
33
|
+
href: z
|
|
34
|
+
.string()
|
|
35
|
+
.max(2048)
|
|
36
|
+
.regex(/^\/admin\/[^\\\s\u0000-\u001f\u007f]*$/),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
39
|
+
export const AppStatusAppSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
appId: CapabilityAppIdSchema.describe("Application ID."),
|
|
42
|
+
traffic: AppStatusTrafficSchema.nullable(),
|
|
43
|
+
links: z.array(AppStatusLinkSchema).max(4),
|
|
44
|
+
lastSeenAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
45
|
+
})
|
|
46
|
+
.strict();
|
|
47
|
+
export const AppStatusSummaryDataSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
// Requested apps plus the fifty provider-only apps; their rows carry lastSeenAt.
|
|
50
|
+
apps: z.array(AppStatusAppSchema).max(250),
|
|
51
|
+
otherApps: z.array(CapabilityAppIdSchema).max(50),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
export const AppStatusDetailDataSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
routes: z.array(z.object({ prefix: z.string().min(1).max(2048), requests: count, errors: count }).strict()).max(200),
|
|
57
|
+
failedRequests: z
|
|
58
|
+
.array(
|
|
59
|
+
z
|
|
60
|
+
.object({
|
|
61
|
+
at: z.string().datetime({ offset: true }),
|
|
62
|
+
method: z.string().min(1).max(32),
|
|
63
|
+
path: z.string().min(1).max(2048),
|
|
64
|
+
status: z.number().int().min(100).max(599),
|
|
65
|
+
})
|
|
66
|
+
.strict(),
|
|
67
|
+
)
|
|
68
|
+
.max(50),
|
|
69
|
+
totalFailedRequests: count.nullable().optional(),
|
|
70
|
+
nextCursor: z.string().min(1).max(2048).nullable(),
|
|
71
|
+
})
|
|
72
|
+
.strict();
|
|
73
|
+
export const AppStatusForgetDataSchema = z.object({ forgotten: z.boolean() }).strict();
|
|
74
|
+
export type AppStatusSummaryData = z.infer<typeof AppStatusSummaryDataSchema>;
|
|
75
|
+
export type AppStatusDetailData = z.infer<typeof AppStatusDetailDataSchema>;
|
|
76
|
+
|
|
77
|
+
/** Shared role validation at startup, registry read, and consumer discovery. */
|
|
78
|
+
export const appStatusIssues = (manifest: CapabilityManifest): CapabilityContractIssue[] => {
|
|
79
|
+
const role = manifest.appStatus;
|
|
80
|
+
if (!role) return [];
|
|
81
|
+
const issues: CapabilityContractIssue[] = [];
|
|
82
|
+
for (const [localId, input, data] of [
|
|
83
|
+
[role.summary, AppStatusSummaryInputSchema, AppStatusSummaryDataSchema],
|
|
84
|
+
[role.detail, AppStatusDetailInputSchema, AppStatusDetailDataSchema],
|
|
85
|
+
] as const) {
|
|
86
|
+
const query = manifest.queries.find((query) => query.localId === localId);
|
|
87
|
+
if (!query) issues.push({ code: "kind", path: "$", message: `Missing app-status Query ${localId}` });
|
|
88
|
+
else {
|
|
89
|
+
issues.push(...capabilityContractIssues({ kind: "query", input, data }, { kind: "query", operation: query }));
|
|
90
|
+
if (query.appOnly || query.openWorld)
|
|
91
|
+
issues.push({ code: "kind", path: "$", message: "App-status Queries must be closed-world and person-readable" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const action = manifest.actions.find((action) => action.localId === role.forget);
|
|
95
|
+
if (!action) issues.push({ code: "kind", path: "$", message: `Missing app-status Action ${role.forget}` });
|
|
96
|
+
else
|
|
97
|
+
issues.push(
|
|
98
|
+
...capabilityContractIssues(
|
|
99
|
+
{ kind: "action", input: AppStatusForgetInputSchema, data: AppStatusForgetDataSchema, idempotency: "required" },
|
|
100
|
+
{ kind: "action", operation: action },
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
return issues;
|
|
104
|
+
};
|
|
@@ -1039,6 +1039,8 @@ export type CapabilityFileProviderDeclaration = { list: string; read: string; sa
|
|
|
1039
1039
|
|
|
1040
1040
|
export type CapabilityCalendarSourceDeclaration = { calendars: string; events: string; update?: string; move?: string; delete?: string };
|
|
1041
1041
|
|
|
1042
|
+
export type CapabilityAppStatusDeclaration = { summary: string; detail: string; forget: string };
|
|
1043
|
+
|
|
1042
1044
|
export type CapabilityDiscussionsDeclaration = { item: string; containers: string; activity?: string };
|
|
1043
1045
|
|
|
1044
1046
|
export type CapabilityDefinitions = {
|
|
@@ -1051,6 +1053,7 @@ export type CapabilityDefinitions = {
|
|
|
1051
1053
|
fileProvider?: CapabilityFileProviderDeclaration;
|
|
1052
1054
|
calendarSource?: CapabilityCalendarSourceDeclaration;
|
|
1053
1055
|
discussions?: CapabilityDiscussionsDeclaration;
|
|
1056
|
+
appStatus?: CapabilityAppStatusDeclaration;
|
|
1054
1057
|
events?: CapabilityDefinitionCatalog<AppEventDefinition>;
|
|
1055
1058
|
subscribes?: CapabilityDefinitionCatalog<AppEventSubscriptionDefinition>;
|
|
1056
1059
|
};
|
|
@@ -1179,6 +1182,14 @@ export const CapabilityResourcePageManifestSchema = z
|
|
|
1179
1182
|
})
|
|
1180
1183
|
.strict();
|
|
1181
1184
|
|
|
1185
|
+
export const CapabilityAppStatusManifestSchema = z
|
|
1186
|
+
.object({
|
|
1187
|
+
summary: CapabilityLocalIdSchema,
|
|
1188
|
+
detail: CapabilityLocalIdSchema,
|
|
1189
|
+
forget: CapabilityLocalIdSchema,
|
|
1190
|
+
})
|
|
1191
|
+
.strict();
|
|
1192
|
+
|
|
1182
1193
|
export const CapabilityDiscussionsManifestSchema = z
|
|
1183
1194
|
.object({
|
|
1184
1195
|
item: CapabilityLocalIdSchema,
|
|
@@ -1268,6 +1279,7 @@ const readableManifest = (value: unknown, personCatalog = false): unknown => {
|
|
|
1268
1279
|
resourcePages: sentPages,
|
|
1269
1280
|
resourceTypes: sentModels,
|
|
1270
1281
|
discussions: sentDiscussions,
|
|
1282
|
+
appStatus: sentAppStatus,
|
|
1271
1283
|
...rest
|
|
1272
1284
|
} = sent;
|
|
1273
1285
|
const models = readEntries(sentModels ?? [], CapabilityResourceTypeModelSchema) ?? [];
|
|
@@ -1309,6 +1321,14 @@ const readableManifest = (value: unknown, personCatalog = false): unknown => {
|
|
|
1309
1321
|
(!role.data.activity || queryIds.has(role.data.activity))
|
|
1310
1322
|
? role.data
|
|
1311
1323
|
: undefined;
|
|
1324
|
+
const statusRole = CapabilityAppStatusManifestSchema.safeParse(sentAppStatus);
|
|
1325
|
+
const appStatus =
|
|
1326
|
+
statusRole.success &&
|
|
1327
|
+
queryIds.has(statusRole.data.summary) &&
|
|
1328
|
+
queryIds.has(statusRole.data.detail) &&
|
|
1329
|
+
readableActions.some((action) => action.localId === statusRole.data.forget)
|
|
1330
|
+
? statusRole.data
|
|
1331
|
+
: undefined;
|
|
1312
1332
|
const provider = CapabilityFileProviderManifestSchema.safeParse(sentProvider);
|
|
1313
1333
|
const calendar = CapabilityCalendarSourceManifestSchema.safeParse(sentCalendarSource);
|
|
1314
1334
|
const pageCounts = new Map<string, number>();
|
|
@@ -1328,6 +1348,7 @@ const readableManifest = (value: unknown, personCatalog = false): unknown => {
|
|
|
1328
1348
|
...rest,
|
|
1329
1349
|
...(sentModels !== undefined ? { resourceTypes } : {}),
|
|
1330
1350
|
...(discussions ? { discussions } : {}),
|
|
1351
|
+
...(appStatus ? { appStatus } : {}),
|
|
1331
1352
|
...(sentPages !== undefined ? { resourcePages } : {}),
|
|
1332
1353
|
...(provider.success ? { fileProvider: provider.data } : {}),
|
|
1333
1354
|
...(calendar.success ? { calendarSource: calendar.data } : {}),
|
|
@@ -1371,6 +1392,7 @@ export const CapabilityManifestSchema = z.preprocess(
|
|
|
1371
1392
|
fileProvider: CapabilityFileProviderManifestSchema.optional(),
|
|
1372
1393
|
calendarSource: CapabilityCalendarSourceManifestSchema.optional(),
|
|
1373
1394
|
discussions: CapabilityDiscussionsManifestSchema.optional(),
|
|
1395
|
+
appStatus: CapabilityAppStatusManifestSchema.optional(),
|
|
1374
1396
|
resourceTypes: z.array(CapabilityResourceTypeModelSchema).max(MAX_MANIFEST_ENTRIES).optional(),
|
|
1375
1397
|
policies: z.record(CapabilityLocalIdSchema, CapabilityActionPolicySchema).optional(),
|
|
1376
1398
|
events: z.array(CapabilityEventManifestSchema).max(MAX_MANIFEST_ENTRIES).optional(),
|
package/src/contracts/index.ts
CHANGED
|
@@ -83,6 +83,8 @@ export type AppRuntimeMetadata = {
|
|
|
83
83
|
};
|
|
84
84
|
|
|
85
85
|
export type AppRegistryEntry = {
|
|
86
|
+
/** Opt-in public site, served only through the app public router. */
|
|
87
|
+
public?: true;
|
|
86
88
|
access?: AppAccessRule;
|
|
87
89
|
platformPermissions?: readonly PlatformPermission[];
|
|
88
90
|
id: string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const EnvSettingStatusSchema = z.object({
|
|
4
|
+
key: z.string(),
|
|
5
|
+
envVariable: z.string().nullable(),
|
|
6
|
+
storedValuePresent: z.boolean(),
|
|
7
|
+
envValuePresent: z.boolean(),
|
|
8
|
+
conflict: z.enum(["none", "differs", "unknown"]),
|
|
9
|
+
unreadableStoredValue: z.boolean(),
|
|
10
|
+
invalidEnvValue: z.boolean(),
|
|
11
|
+
needsAttention: z.boolean(),
|
|
12
|
+
});
|
|
13
|
+
export const EnvSettingsProcessSchema = z.object({
|
|
14
|
+
appId: z.string(),
|
|
15
|
+
instanceId: z.string().optional(),
|
|
16
|
+
sampledAt: z.number().optional(),
|
|
17
|
+
status: z.enum(["ok", "unavailable"]),
|
|
18
|
+
settings: z.array(EnvSettingStatusSchema),
|
|
19
|
+
});
|
|
20
|
+
export const EnvSettingsStatusSchema = z.object({ needsAttention: z.boolean(), processes: z.array(EnvSettingsProcessSchema) });
|
|
21
|
+
export type EnvSettingStatus = z.infer<typeof EnvSettingStatusSchema>;
|
|
@@ -36,6 +36,12 @@ type CommonDef = {
|
|
|
36
36
|
presentation?: AppSettingPresentation;
|
|
37
37
|
envBootstrap?: EnvResolver;
|
|
38
38
|
envFallback?: EnvResolver;
|
|
39
|
+
/** Environment name for migration diagnostics; values are never returned. */
|
|
40
|
+
envVariable?: string;
|
|
41
|
+
/** Discard these stored settings when this normalized value changes. */
|
|
42
|
+
resetOnChange?: readonly string[];
|
|
43
|
+
/** Additional server-side validation after kind normalization. */
|
|
44
|
+
validate?: (value: unknown) => string | undefined;
|
|
39
45
|
};
|
|
40
46
|
|
|
41
47
|
type StringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
|
package/src/index.ts
CHANGED
|
@@ -60,5 +60,15 @@ export type {
|
|
|
60
60
|
NotificationSendInput,
|
|
61
61
|
} from "./contracts/notification-types";
|
|
62
62
|
export { notification } from "./contracts/notification-types";
|
|
63
|
+
export { createRedis, createSql, redis, type SqlPoolOptions, sql } from "./runtime/clients";
|
|
64
|
+
export {
|
|
65
|
+
type ConnectionSource,
|
|
66
|
+
connections,
|
|
67
|
+
type PlatformBootstrapMetadata,
|
|
68
|
+
type PlatformConnectionSources,
|
|
69
|
+
type PlatformConnections,
|
|
70
|
+
type PlatformConnectionValues,
|
|
71
|
+
} from "./runtime/connections";
|
|
63
72
|
export type { HelpDefinition, HelpDefinitionDocument } from "./server/help";
|
|
64
73
|
export { defineHelp } from "./server/help";
|
|
74
|
+
export { storage } from "./services/object-store";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { AppKey } from "../app-key";
|
|
2
|
+
import {
|
|
3
|
+
BOOTSTRAP_ATTEMPT_MS,
|
|
4
|
+
BOOTSTRAP_BODY,
|
|
5
|
+
BOOTSTRAP_BUDGET_MS,
|
|
6
|
+
BOOTSTRAP_PATH,
|
|
7
|
+
BOOTSTRAP_RESPONSE_BYTES,
|
|
8
|
+
type BootstrapResponse,
|
|
9
|
+
BootstrapResponseSchema,
|
|
10
|
+
signBootstrapProof,
|
|
11
|
+
} from "./bootstrap-protocol";
|
|
12
|
+
|
|
13
|
+
export class BootstrapError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
readonly kind: "transient" | "permanent",
|
|
16
|
+
readonly reason: string,
|
|
17
|
+
readonly retryAfterMs = 0,
|
|
18
|
+
) {
|
|
19
|
+
super(`Core bootstrap ${kind}: ${reason}`);
|
|
20
|
+
this.name = "BootstrapError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const originUrl = (origin: string | undefined): URL => {
|
|
25
|
+
try {
|
|
26
|
+
if (!origin) throw new Error();
|
|
27
|
+
const url = new URL(origin);
|
|
28
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
29
|
+
throw new Error();
|
|
30
|
+
return new URL(BOOTSTRAP_PATH, url);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new BootstrapError("permanent", "CLOUD_CORE_INTERNAL_ORIGIN must be a direct http(s) Core origin");
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const httpReason = (status: number): string => {
|
|
36
|
+
if (status === 401) return "HTTP 401: unknown, revoked, expired or invalid app key/proof";
|
|
37
|
+
if (status === 403) return "HTTP 403: connections_not_permitted, app/installation mismatch or replayed proof";
|
|
38
|
+
if (status === 404) return "HTTP 404: bootstrap route unavailable; check the direct Core origin and version";
|
|
39
|
+
return `HTTP ${status}`;
|
|
40
|
+
};
|
|
41
|
+
const retryAfter = (header: string | null, now: number): number => {
|
|
42
|
+
if (!header) return 0;
|
|
43
|
+
if (/^\d+$/.test(header)) return Number(header) * 1000;
|
|
44
|
+
const date = Date.parse(header);
|
|
45
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : 0;
|
|
46
|
+
};
|
|
47
|
+
// Keep transport read failures separate from invalid JSON: only transport failures retry.
|
|
48
|
+
const readResponse = async (response: Response, signal: AbortSignal): Promise<unknown> => {
|
|
49
|
+
const tooLarge = () => new BootstrapError("permanent", "response too_large");
|
|
50
|
+
if (Number(response.headers.get("Content-Length")) > BOOTSTRAP_RESPONSE_BYTES) {
|
|
51
|
+
void response.body?.cancel().catch(() => undefined);
|
|
52
|
+
throw tooLarge();
|
|
53
|
+
}
|
|
54
|
+
const reader = response.body?.getReader();
|
|
55
|
+
if (!reader) throw new BootstrapError("permanent", "response invalid_json");
|
|
56
|
+
const abort = () => {
|
|
57
|
+
void reader.cancel().catch(() => undefined);
|
|
58
|
+
};
|
|
59
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
60
|
+
if (signal.aborted) abort();
|
|
61
|
+
const bytes = new Uint8Array(BOOTSTRAP_RESPONSE_BYTES);
|
|
62
|
+
let length = 0;
|
|
63
|
+
try {
|
|
64
|
+
while (true) {
|
|
65
|
+
const chunk = await reader.read();
|
|
66
|
+
if (chunk.done) break;
|
|
67
|
+
if (length + chunk.value.byteLength > bytes.byteLength) {
|
|
68
|
+
void reader.cancel().catch(() => undefined);
|
|
69
|
+
throw tooLarge();
|
|
70
|
+
}
|
|
71
|
+
bytes.set(chunk.value, length);
|
|
72
|
+
length += chunk.value.byteLength;
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
signal.removeEventListener("abort", abort);
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)));
|
|
81
|
+
} catch {
|
|
82
|
+
throw new BootstrapError("permanent", "response invalid_json");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
// Dependency injection keeps retry-budget tests fast and deterministic; not a public startup option.
|
|
86
|
+
type Dependencies = {
|
|
87
|
+
fetch: (url: URL, init: RequestInit) => Promise<Response>;
|
|
88
|
+
now: () => number;
|
|
89
|
+
wallNow: () => number;
|
|
90
|
+
sleep: (ms: number) => Promise<unknown>;
|
|
91
|
+
random: () => number;
|
|
92
|
+
log: (entry: { appId: string; attempt: number; errorClass: "transient" | "permanent" }) => void;
|
|
93
|
+
};
|
|
94
|
+
const defaults: Dependencies = {
|
|
95
|
+
fetch: (url, init) => fetch(url, init),
|
|
96
|
+
now: () => performance.now(),
|
|
97
|
+
wallNow: () => Date.now(),
|
|
98
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
99
|
+
random: Math.random,
|
|
100
|
+
log: (entry) => console.warn("Core bootstrap attempt failed", entry),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** No application imports or listeners exist while this bounded startup request waits. */
|
|
104
|
+
export const bootstrapConnections = async (
|
|
105
|
+
key: AppKey,
|
|
106
|
+
origin: string | undefined,
|
|
107
|
+
dependencies: Partial<Dependencies> = {},
|
|
108
|
+
): Promise<BootstrapResponse> => {
|
|
109
|
+
const url = originUrl(origin);
|
|
110
|
+
const deps = { ...defaults, ...dependencies };
|
|
111
|
+
const deadline = deps.now() + BOOTSTRAP_BUDGET_MS;
|
|
112
|
+
let attempt = 0;
|
|
113
|
+
while (deps.now() < deadline) {
|
|
114
|
+
attempt++;
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
+
let failure: BootstrapError;
|
|
118
|
+
let httpFailure: BootstrapError | undefined;
|
|
119
|
+
try {
|
|
120
|
+
const transmission = async (): Promise<BootstrapResponse> => {
|
|
121
|
+
const proof = await signBootstrapProof(key, deps.wallNow()).catch(() => {
|
|
122
|
+
throw new BootstrapError("permanent", "could not create Ed25519 startup proof");
|
|
123
|
+
});
|
|
124
|
+
controller.signal.throwIfAborted();
|
|
125
|
+
const response = await deps.fetch(url, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { Authorization: `Bearer ${proof}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
128
|
+
body: BOOTSTRAP_BODY,
|
|
129
|
+
redirect: "error",
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
if (response.status !== 200) {
|
|
133
|
+
// A failed transmission may already have consumed its jti.
|
|
134
|
+
httpFailure = new BootstrapError(
|
|
135
|
+
response.status === 429 || response.status === 503 ? "transient" : "permanent",
|
|
136
|
+
httpReason(response.status),
|
|
137
|
+
retryAfter(response.headers.get("retry-after"), deps.wallNow()),
|
|
138
|
+
);
|
|
139
|
+
if (response.status === 403) {
|
|
140
|
+
try {
|
|
141
|
+
// Recognize only the stable permission code; never retain or log raw error payloads.
|
|
142
|
+
const body = await readResponse(response, controller.signal);
|
|
143
|
+
if (typeof body === "object" && body !== null && "code" in body && body.code === "connections_not_permitted")
|
|
144
|
+
httpFailure = new BootstrapError("permanent", "HTTP 403: connections_not_permitted; this key cannot receive connections");
|
|
145
|
+
} catch {
|
|
146
|
+
// A broken or stalled error body cannot turn a permanent HTTP status into a retry.
|
|
147
|
+
}
|
|
148
|
+
} else void response.body?.cancel().catch(() => undefined);
|
|
149
|
+
throw httpFailure;
|
|
150
|
+
}
|
|
151
|
+
const parsed = await readResponse(response, controller.signal);
|
|
152
|
+
const checked = BootstrapResponseSchema.safeParse(parsed);
|
|
153
|
+
if (!checked.success) throw new BootstrapError("permanent", "invalid response schema");
|
|
154
|
+
const { identity } = checked.data;
|
|
155
|
+
if (identity.installationId !== key.installationId) throw new BootstrapError("permanent", "wrong installation");
|
|
156
|
+
if (identity.appId !== key.appId) throw new BootstrapError("permanent", "wrong app");
|
|
157
|
+
if (identity.keyId !== key.keyId) throw new BootstrapError("permanent", "wrong key");
|
|
158
|
+
return checked.data;
|
|
159
|
+
};
|
|
160
|
+
return await Promise.race([
|
|
161
|
+
transmission(),
|
|
162
|
+
new Promise<never>((_, reject) => {
|
|
163
|
+
timer = setTimeout(
|
|
164
|
+
() => {
|
|
165
|
+
controller.abort();
|
|
166
|
+
reject(new BootstrapError("transient", "attempt deadline exceeded"));
|
|
167
|
+
},
|
|
168
|
+
Math.min(BOOTSTRAP_ATTEMPT_MS, Math.max(0, deadline - deps.now())),
|
|
169
|
+
);
|
|
170
|
+
}),
|
|
171
|
+
]);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// Network/TLS/CA errors may embed credentials or destinations; never retain their text or cause.
|
|
174
|
+
const redirect = error instanceof TypeError && "code" in error && error.code === "UnexpectedRedirect";
|
|
175
|
+
failure =
|
|
176
|
+
httpFailure ??
|
|
177
|
+
(error instanceof BootstrapError
|
|
178
|
+
? error
|
|
179
|
+
: redirect
|
|
180
|
+
? new BootstrapError("permanent", "redirect refused; check the direct Core origin")
|
|
181
|
+
: new BootstrapError("transient", "network or TLS failure"));
|
|
182
|
+
deps.log({ appId: key.appId, attempt, errorClass: failure.kind });
|
|
183
|
+
if (failure.kind === "permanent") throw failure;
|
|
184
|
+
} finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
controller.abort();
|
|
187
|
+
}
|
|
188
|
+
// Full jitter, capped at the per-attempt deadline. Retry-After remains a minimum wait.
|
|
189
|
+
const delay = Math.max(failure.retryAfterMs, deps.random() * Math.min(BOOTSTRAP_ATTEMPT_MS, 250 * 2 ** Math.min(attempt - 1, 6)));
|
|
190
|
+
const remaining = deadline - deps.now();
|
|
191
|
+
if (delay >= remaining) break;
|
|
192
|
+
await deps.sleep(delay);
|
|
193
|
+
}
|
|
194
|
+
throw new BootstrapError("transient", "five-minute startup budget exhausted");
|
|
195
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Bootstrap v1 has no platform barrel or application dependencies. */
|
|
2
|
+
import { SignJWT } from "jose";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type AppKey, importAppKeySeed } from "../app-key";
|
|
5
|
+
import type { PlatformConnectionValues } from "./connections";
|
|
6
|
+
|
|
7
|
+
export const BOOTSTRAP_PATH = "/_internal/apps/bootstrap";
|
|
8
|
+
export const BOOTSTRAP_TYPE = "cloud-app+jwt";
|
|
9
|
+
export const BOOTSTRAP_BODY = "{}";
|
|
10
|
+
export const BOOTSTRAP_RESPONSE_BYTES = 64 * 1024;
|
|
11
|
+
// Same compact-token budget as Cloud's identity protocols.
|
|
12
|
+
export const BOOTSTRAP_PROOF_BYTES = 4 * 1024;
|
|
13
|
+
export const BOOTSTRAP_BUDGET_MS = 5 * 60_000;
|
|
14
|
+
export const BOOTSTRAP_ATTEMPT_MS = 10_000;
|
|
15
|
+
export const BOOTSTRAP_PROOF_SECONDS = 30;
|
|
16
|
+
export const BOOTSTRAP_REPLAY_RETENTION_SECONDS = 5 * 60;
|
|
17
|
+
|
|
18
|
+
const nonempty = z.string().refine((value) => value.trim().length > 0);
|
|
19
|
+
export const BootstrapIdentitySchema = z.object({ installationId: z.uuid(), appId: z.string(), keyId: z.uuid() }).strict();
|
|
20
|
+
export type BootstrapIdentity = z.infer<typeof BootstrapIdentitySchema>;
|
|
21
|
+
export const BootstrapConnectionsSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
database: z.object({ url: nonempty }).strict(),
|
|
24
|
+
valkey: z.object({ url: nonempty }).strict(),
|
|
25
|
+
nats: z
|
|
26
|
+
.object({
|
|
27
|
+
servers: z.array(nonempty).nonempty(),
|
|
28
|
+
namespace: nonempty,
|
|
29
|
+
replicas: z.union([z.literal(1), z.literal(3), z.literal(5)]),
|
|
30
|
+
ignoreClusterUpdates: z.boolean(),
|
|
31
|
+
creds: nonempty.optional(),
|
|
32
|
+
ca: nonempty.optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict(),
|
|
35
|
+
appSecret: z.object({ current: nonempty, previous: nonempty.optional(), next: nonempty.optional() }).strict(),
|
|
36
|
+
})
|
|
37
|
+
.strict() satisfies z.ZodType<PlatformConnectionValues>;
|
|
38
|
+
export const BootstrapResponseSchema = z.object({ identity: BootstrapIdentitySchema, connections: BootstrapConnectionsSchema }).strict();
|
|
39
|
+
export type BootstrapResponse = z.infer<typeof BootstrapResponseSchema>;
|
|
40
|
+
|
|
41
|
+
export const bootstrapBodyDigest = async (body: Uint8Array<ArrayBuffer>): Promise<string> =>
|
|
42
|
+
Buffer.from(await crypto.subtle.digest("SHA-256", body)).toString("base64url");
|
|
43
|
+
|
|
44
|
+
/** Call at the HTTP transmission seam, including every retry. */
|
|
45
|
+
export const signBootstrapProof = async (key: AppKey, now = Date.now()): Promise<string> => {
|
|
46
|
+
const iat = Math.floor(now / 1000);
|
|
47
|
+
return new SignJWT({
|
|
48
|
+
htm: "POST",
|
|
49
|
+
htu: BOOTSTRAP_PATH,
|
|
50
|
+
bdh: await bootstrapBodyDigest(new TextEncoder().encode(BOOTSTRAP_BODY)),
|
|
51
|
+
})
|
|
52
|
+
.setProtectedHeader({ alg: "EdDSA", typ: BOOTSTRAP_TYPE, kid: key.keyId })
|
|
53
|
+
.setIssuer(key.appId)
|
|
54
|
+
.setSubject(key.appId)
|
|
55
|
+
.setAudience(`cloud-core:${key.installationId}`)
|
|
56
|
+
.setIssuedAt(iat)
|
|
57
|
+
.setExpirationTime(iat + BOOTSTRAP_PROOF_SECONDS)
|
|
58
|
+
.setJti(crypto.randomUUID())
|
|
59
|
+
.sign(await importAppKeySeed(key.seed));
|
|
60
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { RedisClient, type RedisOptions, SQL } from "bun";
|
|
2
|
+
import { postgresApplicationOptions } from "../_internal/postgres-application-name";
|
|
3
|
+
import { connections, hasConnections } from "./connections";
|
|
4
|
+
|
|
5
|
+
export type SqlPoolOptions = SQL.PostgresOrMySQLOptions;
|
|
6
|
+
|
|
7
|
+
/** Only the pre-start placeholder is proxied; initialized clients are native Bun objects. */
|
|
8
|
+
const notStarted = <T>(name: string): T => {
|
|
9
|
+
const fail = (): never => {
|
|
10
|
+
throw new Error(`${name} used before platform start`);
|
|
11
|
+
};
|
|
12
|
+
return new Proxy(fail, {
|
|
13
|
+
get: fail,
|
|
14
|
+
set: fail,
|
|
15
|
+
apply: fail,
|
|
16
|
+
construct: fail,
|
|
17
|
+
getPrototypeOf: fail,
|
|
18
|
+
defineProperty: fail,
|
|
19
|
+
deleteProperty: fail,
|
|
20
|
+
has: fail,
|
|
21
|
+
ownKeys: fail,
|
|
22
|
+
getOwnPropertyDescriptor: fail,
|
|
23
|
+
setPrototypeOf: fail,
|
|
24
|
+
isExtensible: fail,
|
|
25
|
+
preventExtensions: fail,
|
|
26
|
+
}) as T;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export let sql: SQL = notStarted("sql");
|
|
30
|
+
export let redis: RedisClient = notStarted("redis");
|
|
31
|
+
|
|
32
|
+
/** Own pool and lifecycle; defaults to the installation URL and application name. */
|
|
33
|
+
export const createSql = (options: SqlPoolOptions = {}): SQL => {
|
|
34
|
+
const state = connections();
|
|
35
|
+
const url = options.url ?? state.database.url;
|
|
36
|
+
const connection = { ...postgresApplicationOptions(state.appId, url.toString()), ...options.connection };
|
|
37
|
+
return new SQL({
|
|
38
|
+
...options,
|
|
39
|
+
url,
|
|
40
|
+
...(Object.keys(connection).length ? { connection } : {}),
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Own connection and lifecycle, using the installation's Valkey URL. */
|
|
45
|
+
export const createRedis = (options?: RedisOptions): RedisClient => new RedisClient(connections().valkey.url, options);
|
|
46
|
+
|
|
47
|
+
export const initializeClients = async (): Promise<void> => {
|
|
48
|
+
let database: SQL;
|
|
49
|
+
try {
|
|
50
|
+
database = createSql();
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error("Postgres client configuration is invalid; check DATABASE_URL and native SQL options");
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const valkey = createRedis();
|
|
56
|
+
sql = database;
|
|
57
|
+
redis = valkey;
|
|
58
|
+
} catch {
|
|
59
|
+
await database.close();
|
|
60
|
+
throw new Error("Valkey client configuration is invalid; check REDIS_URL and native Redis options");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Internal test-target teardown, before a fixture selects a different database. */
|
|
65
|
+
export const closePlatformClients = async (): Promise<void> => {
|
|
66
|
+
if (hasConnections()) {
|
|
67
|
+
await sql.close();
|
|
68
|
+
redis.close();
|
|
69
|
+
}
|
|
70
|
+
sql = notStarted("sql");
|
|
71
|
+
redis = notStarted("redis");
|
|
72
|
+
};
|