@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,46 @@
|
|
|
1
|
+
/** Startup snapshot. Connection credentials never flow back into the process environment. */
|
|
2
|
+
export type ConnectionSource = "env" | "core";
|
|
3
|
+
export type PlatformConnectionValues = {
|
|
4
|
+
database: { url: string };
|
|
5
|
+
valkey: { url: string };
|
|
6
|
+
nats: {
|
|
7
|
+
servers: string[];
|
|
8
|
+
namespace: string;
|
|
9
|
+
replicas: 1 | 3 | 5;
|
|
10
|
+
ignoreClusterUpdates: boolean;
|
|
11
|
+
/** Mounted credentials and CA files are read once; these are contents, not paths. */
|
|
12
|
+
creds?: string;
|
|
13
|
+
ca?: string;
|
|
14
|
+
};
|
|
15
|
+
appSecret: { current: string; previous?: string; next?: string };
|
|
16
|
+
};
|
|
17
|
+
export type PlatformConnectionSources = {
|
|
18
|
+
[Group in keyof PlatformConnectionValues]: { [Key in keyof PlatformConnectionValues[Group]]: ConnectionSource };
|
|
19
|
+
};
|
|
20
|
+
/** Credential-free capability metadata; absent on older processes means unknown. */
|
|
21
|
+
export type PlatformBootstrapMetadata = {
|
|
22
|
+
keyId?: string;
|
|
23
|
+
mode: "core" | "env";
|
|
24
|
+
sources: PlatformConnectionSources;
|
|
25
|
+
keyFormat: "cloud-app-key-v1";
|
|
26
|
+
protocol: 1;
|
|
27
|
+
};
|
|
28
|
+
export type PlatformConnections = PlatformConnectionValues & {
|
|
29
|
+
appId: string;
|
|
30
|
+
sources: PlatformConnectionSources;
|
|
31
|
+
bootstrap?: PlatformBootstrapMetadata;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let current: PlatformConnections | undefined;
|
|
35
|
+
|
|
36
|
+
export const connections = (): PlatformConnections => {
|
|
37
|
+
if (!current)
|
|
38
|
+
throw new Error("connections() used before platform start; use @k2b/cloud/start, the Cloud build entry or development preload");
|
|
39
|
+
return current;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Internal startup binding; tests replace it only after closing their previous clients. */
|
|
43
|
+
export const bindConnections = (value: PlatformConnections | undefined): void => {
|
|
44
|
+
current = value;
|
|
45
|
+
};
|
|
46
|
+
export const hasConnections = (): boolean => current !== undefined;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { envSource } from "../config/define-env";
|
|
3
|
+
import { env } from "../config/env";
|
|
4
|
+
import type { ConnectionSource, PlatformConnections, PlatformConnectionValues } from "./connections";
|
|
5
|
+
|
|
6
|
+
type EnvKey = keyof typeof env.$specs;
|
|
7
|
+
const explicitlySet = (key: EnvKey): boolean => ["env", "alias", "file"].includes(envSource(env, key));
|
|
8
|
+
|
|
9
|
+
/** URL diagnostics deliberately omit both the input and underlying parser errors. */
|
|
10
|
+
const url = (key: string, value: string | undefined, bareHost = false): string => {
|
|
11
|
+
if (!value) throw new Error(`${key} is required for platform start`);
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(bareHost && !value.includes("://") ? `nats://${value}` : value);
|
|
14
|
+
if (bareHost && !parsed.hostname) throw new Error();
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(`${key} must be a valid URL${bareHost ? " or host:port" : ""}`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
};
|
|
20
|
+
const contents = (key: "NATS_CREDS_FILE" | "NATS_TLS_CA_FILE"): string | undefined => {
|
|
21
|
+
const path = env[key];
|
|
22
|
+
if (!path) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
const value = readFileSync(path, "utf8");
|
|
25
|
+
if (!value.trim()) throw new Error();
|
|
26
|
+
return value;
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`${key} must name a readable, non-empty file`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Explicit environment values always win, including aliases, files and false.
|
|
34
|
+
* The Core bootstrap client supplies `core` in key mode; required values then
|
|
35
|
+
* never fall through to environment defaults. Optional values may use defaults.
|
|
36
|
+
*/
|
|
37
|
+
export const resolveConnections = ({ appId, core }: { appId: string; core?: PlatformConnectionValues }): PlatformConnections => {
|
|
38
|
+
const source = (key: EnvKey): ConnectionSource => (!core || explicitlySet(key) ? "env" : "core");
|
|
39
|
+
const choose = <T>(key: EnvKey, fromEnv: () => T, fromCore: T): T => (!core || explicitlySet(key) ? fromEnv() : fromCore);
|
|
40
|
+
// Keep value reads lazy: an absent env value must not evaluate its default in key mode.
|
|
41
|
+
const database = url(
|
|
42
|
+
"DATABASE_URL",
|
|
43
|
+
choose("DATABASE_URL", () => env.DATABASE_URL, core?.database.url),
|
|
44
|
+
);
|
|
45
|
+
const valkey = url(
|
|
46
|
+
"REDIS_URL",
|
|
47
|
+
choose("REDIS_URL", () => env.REDIS_URL, core?.valkey.url),
|
|
48
|
+
);
|
|
49
|
+
const servers = choose("NATS_SERVERS", () => env.NATS_SERVERS, core?.nats.servers) ?? [];
|
|
50
|
+
for (const server of servers) url("NATS_SERVERS", server, true);
|
|
51
|
+
const namespace = choose("SYNC_NAMESPACE", () => env.SYNC_NAMESPACE, core?.nats.namespace) ?? "";
|
|
52
|
+
const replicas = choose("SYNC_REPLICAS", () => env.SYNC_REPLICAS, core?.nats.replicas) ?? env.SYNC_REPLICAS;
|
|
53
|
+
const ignoreClusterUpdates =
|
|
54
|
+
choose("NATS_IGNORE_CLUSTER_UPDATES", () => env.NATS_IGNORE_CLUSTER_UPDATES, core?.nats.ignoreClusterUpdates) ??
|
|
55
|
+
env.NATS_IGNORE_CLUSTER_UPDATES;
|
|
56
|
+
const creds = choose("NATS_CREDS_FILE", () => contents("NATS_CREDS_FILE"), core?.nats.creds);
|
|
57
|
+
const ca = choose("NATS_TLS_CA_FILE", () => contents("NATS_TLS_CA_FILE"), core?.nats.ca);
|
|
58
|
+
const secret = choose("APP_SECRET", () => env.APP_SECRET, core?.appSecret.current) ?? "";
|
|
59
|
+
const rotation = core && !explicitlySet("APP_SECRET") ? core.appSecret : undefined;
|
|
60
|
+
if (core) {
|
|
61
|
+
if (servers.length === 0) throw new Error("NATS_SERVERS is required for platform start");
|
|
62
|
+
if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for platform start");
|
|
63
|
+
if (!secret.trim()) throw new Error("APP_SECRET is required for platform start");
|
|
64
|
+
}
|
|
65
|
+
if (![1, 3, 5].includes(replicas)) throw new Error("SYNC_REPLICAS must be 1, 3, or 5");
|
|
66
|
+
if (typeof ignoreClusterUpdates !== "boolean") throw new Error("NATS_IGNORE_CLUSTER_UPDATES must be a boolean");
|
|
67
|
+
return {
|
|
68
|
+
appId,
|
|
69
|
+
database: { url: database },
|
|
70
|
+
valkey: { url: valkey },
|
|
71
|
+
nats: { servers: [...servers], namespace, replicas, ignoreClusterUpdates, creds, ca },
|
|
72
|
+
appSecret: { current: secret, previous: rotation?.previous, next: rotation?.next },
|
|
73
|
+
sources: {
|
|
74
|
+
database: { url: source("DATABASE_URL") },
|
|
75
|
+
valkey: { url: source("REDIS_URL") },
|
|
76
|
+
nats: {
|
|
77
|
+
servers: source("NATS_SERVERS"),
|
|
78
|
+
namespace: source("SYNC_NAMESPACE"),
|
|
79
|
+
replicas: source("SYNC_REPLICAS"),
|
|
80
|
+
ignoreClusterUpdates: source("NATS_IGNORE_CLUSTER_UPDATES"),
|
|
81
|
+
creds: source("NATS_CREDS_FILE"),
|
|
82
|
+
ca: source("NATS_TLS_CA_FILE"),
|
|
83
|
+
},
|
|
84
|
+
appSecret: {
|
|
85
|
+
current: source("APP_SECRET"),
|
|
86
|
+
...(rotation?.previous ? { previous: "core" as const } : {}),
|
|
87
|
+
...(rotation?.next ? { next: "core" as const } : {}),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { hkdfSync } from "node:crypto";
|
|
2
|
+
import { getProcessApplicationId } from "../_internal/process-identity";
|
|
3
|
+
import { validatePurposeName } from "../_internal/purpose-name";
|
|
4
|
+
import { connections } from "../runtime/connections";
|
|
5
|
+
|
|
6
|
+
/** Derive a purpose-specific signing key from this installation's current secret. */
|
|
7
|
+
export const appKey = (name: string): string => {
|
|
8
|
+
validatePurposeName(name);
|
|
9
|
+
const appId = getProcessApplicationId();
|
|
10
|
+
if (!appId) throw new Error("Start the application before using appKey()");
|
|
11
|
+
const secret = connections().appSecret.current;
|
|
12
|
+
if (!secret.trim()) throw new Error("APP_SECRET is required to derive application signing keys");
|
|
13
|
+
return Buffer.from(hkdfSync("sha256", secret, appId, name, 32)).toString("base64url");
|
|
14
|
+
};
|
package/src/server/client-ip.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
|
|
4
|
-
// The gateway (packages/gateway/src/client-address.ts) resolves this address using
|
|
4
|
+
// The gateway (packages/gateway/src/client-address.ts) resolves this address using the gateway.trusted_proxies setting.
|
|
5
5
|
export const clientIp = (c: Context): string =>
|
|
6
6
|
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
|
|
7
7
|
|
package/src/server/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { CreateApiClientConfig } from "./api-client";
|
|
|
8
8
|
export { api as apiClient } from "./api-client";
|
|
9
9
|
export { appAssetPath } from "./app-assets";
|
|
10
10
|
export type { AppContext } from "./app-context";
|
|
11
|
+
export { appKey } from "./app-key";
|
|
11
12
|
export { clientIp, clientIpBucket } from "./client-ip";
|
|
12
13
|
export { defineHelp, defineHelpCollection, type HelpCollection, type HelpDefinition, type HelpDefinitionDocument } from "./help";
|
|
13
14
|
export { DEFAULT_LOCALE, getLocale, LOCALE_COOKIE, LOCALE_HEADER, locale, preferredLocale, resolveLocale } from "./locale";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import { managerEligibility } from "../_internal/app-access-manager-sql";
|
|
2
3
|
import type { AppMeta } from "../contracts/app";
|
|
3
4
|
import type { AppAccessDeclaration, AppAccessSnapshot } from "../contracts/app-access";
|
|
4
5
|
import { publishAppAccessChanged, wakeAppAccessChanged } from "../events/app-access";
|
|
@@ -107,14 +108,6 @@ const listEntries = async (appId: string, db: SQL = sql): Promise<AccessEntry[]>
|
|
|
107
108
|
: { type: "public" },
|
|
108
109
|
}));
|
|
109
110
|
};
|
|
110
|
-
/** Shared eligibility predicate for the last-manager guard and its presentation. */
|
|
111
|
-
const managerEligibility = (db: SQL) => db`
|
|
112
|
-
EXISTS (SELECT 1 FROM auth.users u WHERE (u.account_expires IS NULL OR u.account_expires > now()) AND
|
|
113
|
-
(u.id = a.user_id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}))))
|
|
114
|
-
OR EXISTS (SELECT 1 FROM auth.service_accounts sa WHERE sa.id = a.service_account_id
|
|
115
|
-
AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent'))
|
|
116
|
-
`;
|
|
117
|
-
|
|
118
111
|
const entryPresentation = async (appId: string): Promise<Map<string, { groupMemberCount?: number; effectiveManager: boolean }>> => {
|
|
119
112
|
const rows = await sql<{ id: string; group_member_count: number | null; effective_manager: boolean }[]>`
|
|
120
113
|
SELECT a.id,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { managerEligibility } from "../_internal/app-access-manager-sql";
|
|
3
|
+
import type { RequestAuthority } from "../server/middleware/auth";
|
|
4
|
+
import { AppAccessManagementError, canRepairAppAccess } from "./app-access-management";
|
|
5
|
+
|
|
6
|
+
export type AppAdminFacts = {
|
|
7
|
+
access: { people: number; groups: number; serviceAccounts: number; managers: number; configured: boolean } | null;
|
|
8
|
+
keys: {
|
|
9
|
+
active: number;
|
|
10
|
+
expired: number;
|
|
11
|
+
revoked: number;
|
|
12
|
+
lastUsedAt: string | null;
|
|
13
|
+
firstCreatedAt: string | null;
|
|
14
|
+
accountStatus: "active" | "disabled" | null;
|
|
15
|
+
};
|
|
16
|
+
tokens: { active: number; total: number };
|
|
17
|
+
events: { hears: number; sends: number | null; failedDeliveries: number; waitingDeliveries: number };
|
|
18
|
+
notifications: number | null;
|
|
19
|
+
firstKnownAt: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Core-owned administration summary: at most 500 input IDs and four batched SQL reads. */
|
|
23
|
+
export const appAdminFacts = async (authority: RequestAuthority, appIds: readonly string[]): Promise<Map<string, AppAdminFacts>> => {
|
|
24
|
+
if (!canRepairAppAccess(authority)) throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required.");
|
|
25
|
+
if (appIds.length > 500) throw new AppAccessManagementError("TOO_MANY_APPS", "At most 500 application IDs may be read at once.", 400);
|
|
26
|
+
const ids = [...new Set(appIds)];
|
|
27
|
+
const facts = new Map<string, AppAdminFacts>(
|
|
28
|
+
ids.map((id) => [
|
|
29
|
+
id,
|
|
30
|
+
{
|
|
31
|
+
access: null,
|
|
32
|
+
keys: { active: 0, expired: 0, revoked: 0, lastUsedAt: null, firstCreatedAt: null, accountStatus: null },
|
|
33
|
+
tokens: { active: 0, total: 0 },
|
|
34
|
+
events: { hears: 0, sends: 0, failedDeliveries: 0, waitingDeliveries: 0 },
|
|
35
|
+
notifications: 0,
|
|
36
|
+
firstKnownAt: null,
|
|
37
|
+
},
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
if (!ids.length) return facts;
|
|
41
|
+
|
|
42
|
+
const [access, credentials, events, notifications] = await Promise.all([
|
|
43
|
+
sql<
|
|
44
|
+
{
|
|
45
|
+
app_id: string;
|
|
46
|
+
people: number;
|
|
47
|
+
groups: number;
|
|
48
|
+
service_accounts: number;
|
|
49
|
+
managers: number;
|
|
50
|
+
configured: boolean;
|
|
51
|
+
first_known_at: Date | null;
|
|
52
|
+
}[]
|
|
53
|
+
>`
|
|
54
|
+
SELECT COALESCE(aa.app_id, s.app_id) AS app_id,
|
|
55
|
+
count(a.user_id)::int AS people, count(a.group_id)::int AS groups,
|
|
56
|
+
count(a.service_account_id)::int AS service_accounts,
|
|
57
|
+
count(*) FILTER (WHERE a.permission = 'admin' AND (${managerEligibility(sql)}))::int AS managers,
|
|
58
|
+
s.configured_at IS NOT NULL AS configured,
|
|
59
|
+
LEAST(min(a.created_at), s.configured_at) AS first_known_at
|
|
60
|
+
FROM auth.app_access aa
|
|
61
|
+
FULL JOIN auth.app_access_state s ON s.app_id = aa.app_id
|
|
62
|
+
LEFT JOIN auth.access a ON a.id = aa.access_id
|
|
63
|
+
WHERE COALESCE(aa.app_id, s.app_id) IN ${sql(ids)}
|
|
64
|
+
GROUP BY aa.app_id, s.app_id, s.configured_at
|
|
65
|
+
`,
|
|
66
|
+
sql<
|
|
67
|
+
{
|
|
68
|
+
app_id: string;
|
|
69
|
+
active: number;
|
|
70
|
+
expired: number;
|
|
71
|
+
revoked: number;
|
|
72
|
+
last_used_at: Date | null;
|
|
73
|
+
first_created_at: Date | null;
|
|
74
|
+
account_status: "active" | "disabled";
|
|
75
|
+
active_tokens: number;
|
|
76
|
+
total_tokens: number;
|
|
77
|
+
first_known_at: Date;
|
|
78
|
+
}[]
|
|
79
|
+
>`
|
|
80
|
+
WITH accounts AS (
|
|
81
|
+
SELECT id, app_id, status, created_at FROM auth.service_accounts
|
|
82
|
+
WHERE kind = 'resource_bound' AND resource_type = 'cloud.app' AND resource_id = app_id
|
|
83
|
+
AND app_id IN ${sql(ids)}
|
|
84
|
+
), canonical_accounts AS (
|
|
85
|
+
SELECT DISTINCT ON (app_id) app_id, status FROM accounts ORDER BY app_id, created_at, id
|
|
86
|
+
)
|
|
87
|
+
SELECT sa.app_id,
|
|
88
|
+
count(*) FILTER (WHERE c.kind = 'app_key' AND c.status = 'active'
|
|
89
|
+
AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS active,
|
|
90
|
+
count(*) FILTER (WHERE c.kind = 'app_key' AND c.status <> 'revoked' AND c.expires_at <= now())::int AS expired,
|
|
91
|
+
count(*) FILTER (WHERE c.kind = 'app_key' AND c.status = 'revoked')::int AS revoked,
|
|
92
|
+
max(c.last_used_at) FILTER (WHERE c.kind = 'app_key') AS last_used_at,
|
|
93
|
+
min(c.created_at) FILTER (WHERE c.kind = 'app_key') AS first_created_at,
|
|
94
|
+
canonical.status AS account_status,
|
|
95
|
+
count(*) FILTER (WHERE c.kind <> 'app_key' AND c.status = 'active'
|
|
96
|
+
AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS active_tokens,
|
|
97
|
+
count(*) FILTER (WHERE c.kind <> 'app_key')::int AS total_tokens,
|
|
98
|
+
LEAST(min(sa.created_at), min(c.created_at)) AS first_known_at
|
|
99
|
+
FROM accounts sa
|
|
100
|
+
JOIN canonical_accounts canonical ON canonical.app_id = sa.app_id
|
|
101
|
+
LEFT JOIN auth.service_account_credentials c ON c.service_account_id = sa.id
|
|
102
|
+
GROUP BY sa.app_id, canonical.status
|
|
103
|
+
`,
|
|
104
|
+
sql<{ app_id: string; hears: number; sends: number; failed: number; waiting: number }[]>`
|
|
105
|
+
WITH subscriptions AS (
|
|
106
|
+
SELECT app_id, count(*) FILTER (WHERE state = 'active')::int AS hears
|
|
107
|
+
FROM events.app_subscriptions WHERE app_id IN ${sql(ids)} GROUP BY app_id
|
|
108
|
+
), declarations AS (
|
|
109
|
+
SELECT app_id, count(*)::int AS sends
|
|
110
|
+
FROM events.app_declarations WHERE app_id IN ${sql(ids)} AND retired_at IS NULL GROUP BY app_id
|
|
111
|
+
), deliveries AS (
|
|
112
|
+
SELECT subscription.app_id, count(*) FILTER (WHERE delivery.state = 'failed')::int AS failed,
|
|
113
|
+
count(*) FILTER (WHERE delivery.state IN ('pending', 'retrying'))::int AS waiting
|
|
114
|
+
FROM events.app_subscriptions subscription
|
|
115
|
+
JOIN events.app_deliveries delivery ON delivery.subscription_id = subscription.id
|
|
116
|
+
WHERE subscription.app_id IN ${sql(ids)} AND delivery.state IN ('failed', 'pending', 'retrying')
|
|
117
|
+
GROUP BY subscription.app_id
|
|
118
|
+
)
|
|
119
|
+
SELECT COALESCE(s.app_id, d.app_id, f.app_id) AS app_id, COALESCE(s.hears, 0) AS hears,
|
|
120
|
+
COALESCE(d.sends, 0) AS sends, COALESCE(f.failed, 0) AS failed, COALESCE(f.waiting, 0) AS waiting
|
|
121
|
+
FROM subscriptions s FULL JOIN declarations d ON d.app_id = s.app_id
|
|
122
|
+
FULL JOIN deliveries f ON f.app_id = COALESCE(s.app_id, d.app_id)
|
|
123
|
+
`,
|
|
124
|
+
sql<{ app_id: string; count: number }[]>`
|
|
125
|
+
SELECT app_id, count(*)::int AS count FROM notifications.definitions
|
|
126
|
+
WHERE app_id IN ${sql(ids)} AND active = true GROUP BY app_id
|
|
127
|
+
`,
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
for (const row of access) {
|
|
131
|
+
const fact = facts.get(row.app_id)!;
|
|
132
|
+
fact.access = {
|
|
133
|
+
people: row.people,
|
|
134
|
+
groups: row.groups,
|
|
135
|
+
serviceAccounts: row.service_accounts,
|
|
136
|
+
managers: row.managers,
|
|
137
|
+
configured: row.configured,
|
|
138
|
+
};
|
|
139
|
+
fact.firstKnownAt = row.first_known_at?.toISOString() ?? null;
|
|
140
|
+
}
|
|
141
|
+
for (const row of credentials) {
|
|
142
|
+
const fact = facts.get(row.app_id)!;
|
|
143
|
+
fact.keys = {
|
|
144
|
+
active: row.active,
|
|
145
|
+
expired: row.expired,
|
|
146
|
+
revoked: row.revoked,
|
|
147
|
+
lastUsedAt: row.last_used_at?.toISOString() ?? null,
|
|
148
|
+
firstCreatedAt: row.first_created_at?.toISOString() ?? null,
|
|
149
|
+
accountStatus: row.account_status,
|
|
150
|
+
};
|
|
151
|
+
fact.tokens = { active: row.active_tokens, total: row.total_tokens };
|
|
152
|
+
const firstKnownAt = row.first_known_at.toISOString();
|
|
153
|
+
if (fact.firstKnownAt === null || firstKnownAt < fact.firstKnownAt) fact.firstKnownAt = firstKnownAt;
|
|
154
|
+
}
|
|
155
|
+
for (const row of events) {
|
|
156
|
+
facts.get(row.app_id)!.events = { hears: row.hears, sends: row.sends, failedDeliveries: row.failed, waitingDeliveries: row.waiting };
|
|
157
|
+
}
|
|
158
|
+
for (const row of notifications) facts.get(row.app_id)!.notifications = row.count;
|
|
159
|
+
return facts;
|
|
160
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { decodeJwt, decodeProtectedHeader, type JWTPayload, jwtVerify } from "jose";
|
|
3
|
+
import { importAppPublicKey } from "../app-key";
|
|
4
|
+
import {
|
|
5
|
+
BOOTSTRAP_PATH,
|
|
6
|
+
BOOTSTRAP_PROOF_BYTES,
|
|
7
|
+
BOOTSTRAP_PROOF_SECONDS,
|
|
8
|
+
BOOTSTRAP_REPLAY_RETENTION_SECONDS,
|
|
9
|
+
BOOTSTRAP_TYPE,
|
|
10
|
+
type BootstrapIdentity,
|
|
11
|
+
bootstrapBodyDigest,
|
|
12
|
+
} from "../runtime/bootstrap-protocol";
|
|
13
|
+
import { getActiveAppKey, getInstallationId } from "./app-keys";
|
|
14
|
+
import { get } from "./settings";
|
|
15
|
+
|
|
16
|
+
export class BootstrapProofError extends Error {
|
|
17
|
+
constructor(readonly code: "invalid_proof" | "wrong_app" | "wrong_installation" | "replayed_proof" | "connections_not_permitted") {
|
|
18
|
+
super(code);
|
|
19
|
+
this.name = "BootstrapProofError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const invalid = () => new BootstrapProofError("invalid_proof");
|
|
23
|
+
|
|
24
|
+
/** Shared Postgres uniqueness rejects a proof used concurrently on different Core replicas. */
|
|
25
|
+
export const consumeBootstrapProof = async (keyId: string, jti: string, exp: number, db: SQL = sql): Promise<boolean> => {
|
|
26
|
+
const rows = await db`
|
|
27
|
+
INSERT INTO auth.app_proof_replays (key_id, jti, retain_until)
|
|
28
|
+
VALUES (${keyId}::uuid, ${jti}, ${new Date((exp + BOOTSTRAP_REPLAY_RETENTION_SECONDS) * 1000)})
|
|
29
|
+
ON CONFLICT (key_id, jti) DO NOTHING RETURNING jti
|
|
30
|
+
`;
|
|
31
|
+
return rows.length === 1;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The ingress, per-key admission and minute maintenance share this installation budget. */
|
|
35
|
+
export const bootstrapRateLimit = async (): Promise<number> => Math.max(1, Math.floor(await get<number>("security.rate_limit_per_second")));
|
|
36
|
+
|
|
37
|
+
/** Drain expired pages, with two minutes of per-key admission capacity to catch up after missed runs. */
|
|
38
|
+
export const cleanupBootstrapProofs = async (
|
|
39
|
+
db: SQL = sql,
|
|
40
|
+
{ signal }: { signal?: AbortSignal } = {},
|
|
41
|
+
): Promise<{ deleted: number; remaining: number }> => {
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
// Include revoked keys: their retained evidence still needs cleaning after revocation.
|
|
44
|
+
const [keys] = await db<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.service_account_credentials WHERE kind = 'app_key'`;
|
|
45
|
+
const maxRows = Math.max(1, keys?.count ?? 0) * (await bootstrapRateLimit()) * 60 * 2;
|
|
46
|
+
let deleted = 0;
|
|
47
|
+
while (deleted < maxRows) {
|
|
48
|
+
signal?.throwIfAborted();
|
|
49
|
+
const batchSize = Math.min(100, maxRows - deleted);
|
|
50
|
+
const query = db`
|
|
51
|
+
WITH batch AS MATERIALIZED (
|
|
52
|
+
SELECT key_id, jti FROM auth.app_proof_replays WHERE retain_until <= now()
|
|
53
|
+
ORDER BY retain_until LIMIT ${batchSize} FOR UPDATE SKIP LOCKED
|
|
54
|
+
)
|
|
55
|
+
DELETE FROM auth.app_proof_replays replay USING batch
|
|
56
|
+
WHERE replay.key_id = batch.key_id AND replay.jti = batch.jti AND replay.retain_until <= now()
|
|
57
|
+
RETURNING replay.jti
|
|
58
|
+
`;
|
|
59
|
+
const abort = () => query.cancel();
|
|
60
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
61
|
+
try {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
const rows = await query;
|
|
64
|
+
deleted += rows.length;
|
|
65
|
+
if (rows.length < batchSize) break;
|
|
66
|
+
} finally {
|
|
67
|
+
signal?.removeEventListener("abort", abort);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
const [backlog] = await db<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_proof_replays WHERE retain_until <= now()`;
|
|
72
|
+
return { deleted, remaining: backlog?.count ?? 0 };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** No caches: every HTTP transmission rechecks revocation, expiry and account state in Postgres. */
|
|
76
|
+
export const authenticateBootstrap = async (
|
|
77
|
+
proof: string,
|
|
78
|
+
request: { method: string; path: string; body: Uint8Array<ArrayBuffer> },
|
|
79
|
+
db: SQL = sql,
|
|
80
|
+
now = Date.now(),
|
|
81
|
+
): Promise<{ identity: BootstrapIdentity; jti: string; exp: number }> => {
|
|
82
|
+
if (Buffer.byteLength(proof) > BOOTSTRAP_PROOF_BYTES) throw invalid();
|
|
83
|
+
let appId: string;
|
|
84
|
+
let keyId: string;
|
|
85
|
+
try {
|
|
86
|
+
const header = decodeProtectedHeader(proof);
|
|
87
|
+
const claims = decodeJwt(proof);
|
|
88
|
+
if (header.alg !== "EdDSA" || header.typ !== BOOTSTRAP_TYPE || typeof header.kid !== "string" || typeof claims.iss !== "string")
|
|
89
|
+
throw invalid();
|
|
90
|
+
appId = claims.iss;
|
|
91
|
+
keyId = header.kid;
|
|
92
|
+
} catch {
|
|
93
|
+
throw invalid();
|
|
94
|
+
}
|
|
95
|
+
// Only validated IDs reach SQL, and the app identity must own this active credential.
|
|
96
|
+
const key = await getActiveAppKey({ appId, keyId }, db);
|
|
97
|
+
if (!key) throw invalid();
|
|
98
|
+
let claims: JWTPayload;
|
|
99
|
+
try {
|
|
100
|
+
const verified = await jwtVerify(proof, await importAppPublicKey(key.publicKey), {
|
|
101
|
+
algorithms: ["EdDSA"],
|
|
102
|
+
typ: BOOTSTRAP_TYPE,
|
|
103
|
+
currentDate: new Date(now),
|
|
104
|
+
requiredClaims: ["iss", "sub", "aud", "iat", "exp", "jti", "htm", "htu", "bdh"],
|
|
105
|
+
});
|
|
106
|
+
claims = verified.payload;
|
|
107
|
+
} catch {
|
|
108
|
+
throw invalid();
|
|
109
|
+
}
|
|
110
|
+
const seconds = Math.floor(now / 1000);
|
|
111
|
+
if (
|
|
112
|
+
!Number.isSafeInteger(claims.iat) ||
|
|
113
|
+
!Number.isSafeInteger(claims.exp) ||
|
|
114
|
+
claims.iat! > seconds + 2 ||
|
|
115
|
+
claims.exp! <= claims.iat! ||
|
|
116
|
+
claims.exp! - claims.iat! > BOOTSTRAP_PROOF_SECONDS ||
|
|
117
|
+
typeof claims.jti !== "string" ||
|
|
118
|
+
claims.jti.length === 0 ||
|
|
119
|
+
claims.jti.length > 64 ||
|
|
120
|
+
claims.jti.includes("\0") ||
|
|
121
|
+
request.method !== "POST" ||
|
|
122
|
+
request.path !== BOOTSTRAP_PATH ||
|
|
123
|
+
claims.htm !== request.method ||
|
|
124
|
+
claims.htu !== request.path ||
|
|
125
|
+
claims.bdh !== (await bootstrapBodyDigest(request.body))
|
|
126
|
+
)
|
|
127
|
+
throw invalid();
|
|
128
|
+
if (claims.iss !== key.appId || claims.sub !== key.appId) throw new BootstrapProofError("wrong_app");
|
|
129
|
+
const installationId = await getInstallationId(db);
|
|
130
|
+
if (claims.aud !== `cloud-core:${installationId}`) throw new BootstrapProofError("wrong_installation");
|
|
131
|
+
if (!key.connectionsAllowed) throw new BootstrapProofError("connections_not_permitted");
|
|
132
|
+
// Admission must succeed before the route atomically consumes this verified nonce.
|
|
133
|
+
return { identity: { installationId, appId, keyId }, jti: claims.jti, exp: claims.exp! };
|
|
134
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { request } from "node:https";
|
|
2
|
+
import { connect } from "node:tls";
|
|
3
|
+
import { DOMAIN_CHECK_PATH_PREFIX, DOMAIN_CHECK_TTL_SECONDS, domainCheckKey } from "../contracts/app-domains";
|
|
4
|
+
import { redis } from "../runtime/clients";
|
|
5
|
+
import { type PublicNetworkAddress, pinnedNetworkLookup, resolveAllowedNetworkAddresses } from "./network-security";
|
|
6
|
+
import { awaitNetworkOperation, readNetworkResponse, withNetworkDeadline } from "./network-transport";
|
|
7
|
+
|
|
8
|
+
export type AppDomainCheckStep = { ok: boolean; detail: string };
|
|
9
|
+
export type AppDomainStatus = {
|
|
10
|
+
checkedAt: string;
|
|
11
|
+
dns: AppDomainCheckStep;
|
|
12
|
+
certificate: AppDomainCheckStep | null;
|
|
13
|
+
reachable: AppDomainCheckStep | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Each network stage gets the platform resolver's five-second budget. The challenge lives longer than all stages combined.
|
|
17
|
+
const STEP_TIMEOUT_MS = 5000;
|
|
18
|
+
const deadline = <T>(work: (signal: AbortSignal) => Promise<T>) =>
|
|
19
|
+
withNetworkDeadline(STEP_TIMEOUT_MS, undefined, new Error("Domain check timed out"), work);
|
|
20
|
+
|
|
21
|
+
const certificate = (host: string, addresses: PublicNetworkAddress[], signal: AbortSignal): Promise<void> =>
|
|
22
|
+
new Promise((resolve, reject) => {
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
// Connect to the checked IP directly; SNI and certificate identity still use the assigned host.
|
|
25
|
+
const socket = connect({ host: addresses[0]!.address, port: 443, servername: host, rejectUnauthorized: true });
|
|
26
|
+
const finish = (error?: Error) => {
|
|
27
|
+
signal.removeEventListener("abort", abort);
|
|
28
|
+
socket.destroy();
|
|
29
|
+
if (error) reject(error);
|
|
30
|
+
else resolve();
|
|
31
|
+
};
|
|
32
|
+
const abort = () => finish(signal.reason);
|
|
33
|
+
socket.once("error", finish);
|
|
34
|
+
socket.once("secureConnect", () => finish());
|
|
35
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
36
|
+
if (signal.aborted) abort();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const reachable = (host: string, addresses: PublicNetworkAddress[], nonce: string, answer: string, signal: AbortSignal): Promise<void> =>
|
|
40
|
+
readNetworkResponse({
|
|
41
|
+
request,
|
|
42
|
+
options: {
|
|
43
|
+
hostname: host,
|
|
44
|
+
port: 443,
|
|
45
|
+
servername: host,
|
|
46
|
+
method: "GET",
|
|
47
|
+
path: `${DOMAIN_CHECK_PATH_PREFIX}${nonce}`,
|
|
48
|
+
headers: { accept: "text/plain", "accept-encoding": "identity" },
|
|
49
|
+
lookup: pinnedNetworkLookup(addresses),
|
|
50
|
+
agent: false,
|
|
51
|
+
},
|
|
52
|
+
signal,
|
|
53
|
+
read: (response, resolve, reject) => {
|
|
54
|
+
if (response.statusCode !== 200) {
|
|
55
|
+
reject(new Error(`Gateway check returned HTTP ${response.statusCode}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let body = "";
|
|
59
|
+
response.setEncoding("utf8");
|
|
60
|
+
response.on("data", (chunk: string) => {
|
|
61
|
+
body += chunk;
|
|
62
|
+
if (body.length > answer.length) reject(new Error("Gateway check returned an unexpected response"));
|
|
63
|
+
});
|
|
64
|
+
response.once("end", () => (body === answer ? resolve() : reject(new Error("Gateway check did not return the challenge answer"))));
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
type DomainCheckDependencies = {
|
|
69
|
+
resolve: (host: string, signal: AbortSignal) => Promise<PublicNetworkAddress[]>;
|
|
70
|
+
certificate: typeof certificate;
|
|
71
|
+
reachable: typeof reachable;
|
|
72
|
+
challenge: (host: string, nonce: string, answer: string) => Promise<() => Promise<void>>;
|
|
73
|
+
now: () => Date;
|
|
74
|
+
nonce: () => string;
|
|
75
|
+
};
|
|
76
|
+
const dependencies: DomainCheckDependencies = {
|
|
77
|
+
// Assignment is administrator-only. Its exact host is the operator-controlled allowlist, restricted to TLS port 443.
|
|
78
|
+
resolve: (host, signal) =>
|
|
79
|
+
awaitNetworkOperation(
|
|
80
|
+
resolveAllowedNetworkAddresses(
|
|
81
|
+
host,
|
|
82
|
+
{
|
|
83
|
+
hosts: new Set(),
|
|
84
|
+
networks: [],
|
|
85
|
+
hostPorts: new Map([[host, new Set([443])]]),
|
|
86
|
+
},
|
|
87
|
+
undefined,
|
|
88
|
+
443,
|
|
89
|
+
),
|
|
90
|
+
signal,
|
|
91
|
+
),
|
|
92
|
+
certificate,
|
|
93
|
+
reachable,
|
|
94
|
+
challenge: async (host, nonce, answer) => {
|
|
95
|
+
const key = domainCheckKey(host, nonce);
|
|
96
|
+
await redis.set(key, answer, "EX", DOMAIN_CHECK_TTL_SECONDS);
|
|
97
|
+
return async () => {
|
|
98
|
+
await redis.del(key);
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
now: () => new Date(),
|
|
102
|
+
nonce: () => crypto.randomUUID(),
|
|
103
|
+
};
|
|
104
|
+
const failure = (error: unknown): AppDomainCheckStep => ({
|
|
105
|
+
ok: false,
|
|
106
|
+
detail: error instanceof Error ? error.message : "Domain check failed",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Stop at the first failure. No redirect is followed and no response body can exceed the challenge answer. */
|
|
110
|
+
export const checkAppDomain = async (host: string, overrides: Partial<DomainCheckDependencies> = {}): Promise<AppDomainStatus> => {
|
|
111
|
+
const deps = { ...dependencies, ...overrides };
|
|
112
|
+
const status: AppDomainStatus = {
|
|
113
|
+
checkedAt: deps.now().toISOString(),
|
|
114
|
+
dns: { ok: false, detail: "DNS lookup failed" },
|
|
115
|
+
certificate: null,
|
|
116
|
+
reachable: null,
|
|
117
|
+
};
|
|
118
|
+
let addresses: PublicNetworkAddress[];
|
|
119
|
+
try {
|
|
120
|
+
addresses = await deadline((signal) => deps.resolve(host, signal));
|
|
121
|
+
if (!addresses.length) throw new Error("No A or AAAA records found");
|
|
122
|
+
status.dns = { ok: true, detail: addresses.map((address) => address.address).join(", ") };
|
|
123
|
+
} catch (error) {
|
|
124
|
+
status.dns = failure(error);
|
|
125
|
+
return status;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await deadline((signal) => deps.certificate(host, addresses, signal));
|
|
129
|
+
status.certificate = { ok: true, detail: "Valid TLS certificate for this host" };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
status.certificate = failure(error);
|
|
132
|
+
return status;
|
|
133
|
+
}
|
|
134
|
+
let cleanup: (() => Promise<void>) | undefined;
|
|
135
|
+
try {
|
|
136
|
+
// The answer stays in Valkey; only this installation's gateway can return it for the nonce in the path.
|
|
137
|
+
const nonce = deps.nonce();
|
|
138
|
+
const answer = deps.nonce();
|
|
139
|
+
await deadline(async (signal) => {
|
|
140
|
+
cleanup = await awaitNetworkOperation(deps.challenge(host, nonce, answer), signal);
|
|
141
|
+
await deps.reachable(host, addresses, nonce, answer, signal);
|
|
142
|
+
});
|
|
143
|
+
status.reachable = { ok: true, detail: "Cloud gateway returned the challenge" };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
status.reachable = failure(error);
|
|
146
|
+
} finally {
|
|
147
|
+
// Expiry also removes a challenge if Valkey becomes unavailable during cleanup.
|
|
148
|
+
const removeChallenge = cleanup;
|
|
149
|
+
if (removeChallenge) await deadline(() => removeChallenge()).catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
return status;
|
|
152
|
+
};
|