@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.
Files changed (104) hide show
  1. package/package.json +8 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-access-manager-sql.ts +10 -0
  5. package/src/_internal/bounded-json.ts +7 -1
  6. package/src/_internal/capabilities.ts +8 -0
  7. package/src/_internal/define-app.ts +69 -2
  8. package/src/_internal/heartbeat.ts +21 -0
  9. package/src/_internal/nats-connection.ts +10 -9
  10. package/src/_internal/postgres-application-name.ts +11 -0
  11. package/src/_internal/process-sync.ts +15 -6
  12. package/src/_internal/public-surface.ts +76 -0
  13. package/src/_internal/purpose-name.ts +7 -0
  14. package/src/_internal/readiness.ts +6 -1
  15. package/src/_internal/registry-validation.ts +1 -0
  16. package/src/_internal/registry.ts +49 -3
  17. package/src/_internal/resource-principals.ts +4 -34
  18. package/src/_internal/workload-credential.ts +154 -0
  19. package/src/_internal/workload-protocol.ts +36 -0
  20. package/src/ai/application-tasks.ts +5 -2
  21. package/src/ai/chart-block.ts +36 -711
  22. package/src/ai/default-tool-contracts.ts +21 -259
  23. package/src/api/admin-app-domains.ts +62 -0
  24. package/src/api/admin-app-keys.ts +66 -0
  25. package/src/api/admin-core-settings.ts +2 -0
  26. package/src/api/admin-identity.ts +27 -1
  27. package/src/api/admin-settings-env.ts +120 -0
  28. package/src/api/app-bootstrap.ts +131 -0
  29. package/src/api/capabilities.ts +17 -4
  30. package/src/api/index.ts +8 -0
  31. package/src/api/settings-ops.ts +35 -0
  32. package/src/app-key.ts +125 -0
  33. package/src/browser/CloudResourceSearch.tsx +48 -41
  34. package/src/capabilities/discussion-providers.ts +3 -2
  35. package/src/capabilities/resource-readers.ts +13 -2
  36. package/src/capabilities/response.ts +6 -2
  37. package/src/capabilities/server-invoke.ts +73 -29
  38. package/src/capabilities/server.ts +1 -1
  39. package/src/capabilities/types.ts +1 -1
  40. package/src/cli/admin/app-credentials.ts +32 -7
  41. package/src/cli/admin/app-domains.ts +140 -0
  42. package/src/cli/admin/app-keys.ts +226 -0
  43. package/src/cli/admin/index.ts +109 -84
  44. package/src/cli/admin/settings.ts +62 -0
  45. package/src/config/define-env.ts +63 -8
  46. package/src/config/env.ts +34 -22
  47. package/src/config/index.ts +12 -1
  48. package/src/config/workflow-ai.ts +28 -0
  49. package/src/contracts/app-domains.ts +91 -0
  50. package/src/contracts/app-status.ts +104 -0
  51. package/src/contracts/capabilities.ts +22 -0
  52. package/src/contracts/index.ts +1 -0
  53. package/src/contracts/registry.ts +2 -0
  54. package/src/contracts/settings-env.ts +21 -0
  55. package/src/contracts/settings-types.ts +6 -0
  56. package/src/index.ts +10 -0
  57. package/src/runtime/bootstrap-client.ts +195 -0
  58. package/src/runtime/bootstrap-protocol.ts +60 -0
  59. package/src/runtime/clients.ts +72 -0
  60. package/src/runtime/connections.ts +46 -0
  61. package/src/runtime/resolve-connections.ts +91 -0
  62. package/src/server/app-key.ts +14 -0
  63. package/src/server/client-ip.ts +1 -1
  64. package/src/server/index.ts +1 -0
  65. package/src/services/app-access-management.ts +1 -8
  66. package/src/services/app-admin-facts.ts +160 -0
  67. package/src/services/app-bootstrap.ts +134 -0
  68. package/src/services/app-domain-check.ts +152 -0
  69. package/src/services/app-domains.ts +172 -0
  70. package/src/services/app-keys.ts +301 -0
  71. package/src/services/downstream-budget.ts +2 -2
  72. package/src/services/gateway.ts +6 -0
  73. package/src/services/identity/index.ts +3 -0
  74. package/src/services/identity/resource-readers.ts +80 -0
  75. package/src/services/identity/runtime-config.ts +14 -6
  76. package/src/services/identity/workload-runtime.ts +253 -0
  77. package/src/services/index.ts +8 -1
  78. package/src/services/links/index.ts +16 -0
  79. package/src/services/links/runtime.ts +44 -0
  80. package/src/services/links/store.ts +105 -0
  81. package/src/services/logging/index.ts +7 -5
  82. package/src/services/logging/redaction.ts +6 -1
  83. package/src/services/logging/trace.ts +8 -8
  84. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  85. package/src/services/request-cache-redis.ts +2 -2
  86. package/src/services/service-account-credentials.ts +63 -21
  87. package/src/services/settings/api.ts +3 -3
  88. package/src/services/settings/binding.ts +45 -0
  89. package/src/services/settings/core-settings.ts +119 -1
  90. package/src/services/settings/crypto.ts +2 -2
  91. package/src/services/settings/defaults.ts +16 -2
  92. package/src/services/settings/env-status.ts +69 -0
  93. package/src/services/settings/index.ts +69 -26
  94. package/src/services/settings/store.ts +78 -8
  95. package/src/services/settings/validation.ts +40 -0
  96. package/src/shared/index.ts +11 -10
  97. package/src/shared/markdown/extensions/tables.ts +10 -1
  98. package/src/ssr/admin-navigation.ts +0 -1
  99. package/src/ssr/platform-messages.ts +0 -2
  100. package/src/start.ts +57 -0
  101. package/src/workflows/ai/index.ts +1 -0
  102. package/src/workflows/ai/runtime.ts +16 -8
  103. package/src/workflows/ai/settings.ts +5 -0
  104. 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
+ };
@@ -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 GATEWAY_TRUSTED_PROXIES.
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
 
@@ -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
+ };