@k2b/cloud 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-access-manager-sql.ts +10 -0
  3. package/src/_internal/bounded-json.ts +7 -1
  4. package/src/_internal/capabilities.ts +8 -0
  5. package/src/_internal/define-app.ts +31 -2
  6. package/src/_internal/heartbeat.ts +2 -0
  7. package/src/_internal/nats-connection.ts +10 -9
  8. package/src/_internal/process-sync.ts +6 -6
  9. package/src/_internal/public-surface.ts +76 -0
  10. package/src/_internal/purpose-name.ts +7 -0
  11. package/src/_internal/registry-validation.ts +1 -0
  12. package/src/_internal/registry.ts +49 -3
  13. package/src/_internal/workload-credential.ts +2 -1
  14. package/src/api/admin-app-domains.ts +62 -0
  15. package/src/api/app-bootstrap.ts +131 -0
  16. package/src/api/capabilities.ts +8 -1
  17. package/src/api/index.ts +5 -0
  18. package/src/capabilities/response.ts +2 -1
  19. package/src/capabilities/server-invoke.ts +28 -2
  20. package/src/cli/admin/app-domains.ts +140 -0
  21. package/src/cli/admin/index.ts +107 -88
  22. package/src/config/env.ts +7 -1
  23. package/src/contracts/app-domains.ts +91 -0
  24. package/src/contracts/app-status.ts +104 -0
  25. package/src/contracts/capabilities.ts +22 -0
  26. package/src/contracts/index.ts +1 -0
  27. package/src/contracts/registry.ts +2 -0
  28. package/src/index.ts +1 -0
  29. package/src/runtime/bootstrap-client.ts +195 -0
  30. package/src/runtime/bootstrap-protocol.ts +60 -0
  31. package/src/runtime/connections.ts +9 -0
  32. package/src/server/app-key.ts +14 -0
  33. package/src/server/index.ts +1 -0
  34. package/src/services/app-access-management.ts +1 -8
  35. package/src/services/app-admin-facts.ts +160 -0
  36. package/src/services/app-bootstrap.ts +134 -0
  37. package/src/services/app-domain-check.ts +152 -0
  38. package/src/services/app-domains.ts +172 -0
  39. package/src/services/app-keys.ts +6 -3
  40. package/src/services/downstream-budget.ts +2 -2
  41. package/src/services/identity/workload-runtime.ts +2 -2
  42. package/src/services/index.ts +4 -0
  43. package/src/services/links/index.ts +16 -0
  44. package/src/services/links/runtime.ts +44 -0
  45. package/src/services/links/store.ts +105 -0
  46. package/src/services/logging/index.ts +7 -5
  47. package/src/services/logging/redaction.ts +6 -1
  48. package/src/services/logging/trace.ts +8 -8
  49. package/src/services/request-cache-redis.ts +2 -2
  50. package/src/services/settings/core-settings.ts +20 -0
  51. package/src/services/settings/crypto.ts +2 -2
  52. package/src/services/settings/store.ts +5 -0
  53. package/src/ssr/admin-navigation.ts +0 -1
  54. package/src/ssr/platform-messages.ts +0 -2
  55. package/src/start.ts +35 -14
@@ -12,7 +12,7 @@ import {
12
12
  type WorkloadResponse,
13
13
  workloadSubject,
14
14
  } from "../../_internal/workload-protocol";
15
- import { env } from "../../config/env";
15
+ import { connections } from "../../runtime/connections";
16
16
  import { audit } from "../audit";
17
17
  import { logger } from "../logging";
18
18
  import { serviceAccountCredentials } from "../service-account-credentials";
@@ -204,7 +204,7 @@ export const startAppWorkloadBroker = async (
204
204
  let stopped = false;
205
205
  let maintenance: Promise<void> | undefined;
206
206
  let timer: Timer | undefined;
207
- const subscription = connection.subscribe(workloadSubject(options.namespace ?? env.SYNC_NAMESPACE), {
207
+ const subscription = connection.subscribe(workloadSubject(options.namespace ?? connections().nats.namespace), {
208
208
  queue: "core-workload-v1",
209
209
  callback: (error, message) => {
210
210
  if (error || !message.reply) return;
@@ -253,6 +253,7 @@ export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
253
253
 
254
254
  export { geoip } from "./geoip";
255
255
  export { appDetails } from "./app-details";
256
+ export { appAdminFacts, type AppAdminFacts } from "./app-admin-facts";
256
257
  export {
257
258
  knownBrowsers,
258
259
  KNOWN_BROWSER_LIMITS,
@@ -294,3 +295,6 @@ export type {
294
295
  ObjectListOptions,
295
296
  ObjectListResult,
296
297
  } from "./object-store";
298
+
299
+ export { links } from "./links";
300
+ export type { CreateLinkInput, CreatedLink, RedeemedLink, LinkOptions, PeekedLink, PeekLinkResult, RedeemLinkResult } from "./links";
@@ -0,0 +1,16 @@
1
+ import { getProcessApplicationPackage } from "../../_internal/process-identity";
2
+ import { sql } from "../../runtime/clients";
3
+ import { type CreateLinkInput, createLinksStore, type LinkOptions } from "./store";
4
+
5
+ const store = () => {
6
+ const packageName = getProcessApplicationPackage();
7
+ if (!packageName) throw new Error("Start the application before using one-time links");
8
+ return createLinksStore(sql, packageName);
9
+ };
10
+
11
+ export const links = {
12
+ create: (input: CreateLinkInput, options?: LinkOptions) => store().create(input, options),
13
+ peek: (token: string, input: { purpose: string }, options?: LinkOptions) => store().peek(token, input, options),
14
+ redeem: (token: string, input: { purpose: string }, options?: LinkOptions) => store().redeem(token, input, options),
15
+ };
16
+ export type { CreatedLink, CreateLinkInput, LinkOptions, PeekedLink, PeekLinkResult, RedeemedLink, RedeemLinkResult } from "./store";
@@ -0,0 +1,44 @@
1
+ import { lazySync } from "../../_internal/process-sync";
2
+ import { sql } from "../../runtime/clients";
3
+ import { logger } from "../logging";
4
+ import { cleanupLinksBatch, LINK_CLEANUP_BATCH, setupLinks } from "./store";
5
+
6
+ // All apps compete for one platform schedule, including replicas and apps sharing a package.
7
+ const scheduler = lazySync((sync) =>
8
+ sync.scheduler({
9
+ id: "cloud-links-retention",
10
+ owner: "platform",
11
+ delivery: { maxAttempts: 2, backoffMs: [30_000], ackWaitMs: 60_000 },
12
+ }),
13
+ );
14
+
15
+ /** Provision before app setup and readiness, then drain this worker before NATS during shutdown. */
16
+ export const startLinksRuntime = async (): Promise<() => Promise<void>> => {
17
+ await setupLinks(sql);
18
+ const stopping = new AbortController();
19
+ await scheduler().create({
20
+ id: "retention",
21
+ cron: "0 * * * *",
22
+ timezone: "UTC",
23
+ misfire: "latest",
24
+ meta: { family: "links", label: "One-time link cleanup" },
25
+ process: async ({ signal, heartbeat }) => {
26
+ const runSignal = AbortSignal.any([signal, stopping.signal]);
27
+ const deadline = performance.now() + 5 * 60_000;
28
+ let deleted = 0;
29
+ while (!runSignal.aborted && performance.now() < deadline) {
30
+ const count = await cleanupLinksBatch(sql);
31
+ deleted += count;
32
+ if (count < LINK_CLEANUP_BATCH) break;
33
+ await heartbeat();
34
+ }
35
+ if (deleted > 0) logger("links:cleanup").info("Removed old one-time links", { deleted });
36
+ },
37
+ });
38
+ const worker = await scheduler().process({ concurrency: 1 });
39
+ return async () => {
40
+ stopping.abort();
41
+ worker.stop();
42
+ await worker.drain({ timeoutMs: 30_000 });
43
+ };
44
+ };
@@ -0,0 +1,105 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { SQL, TransactionSQL } from "bun";
3
+ import { isPurposeName, validatePurposeName } from "../../_internal/purpose-name";
4
+ import { ensureSchema } from "../postgres";
5
+
6
+ export type CreateLinkInput = { purpose: string; subject: string; ttl: number };
7
+ export type CreatedLink = { token: string; expiresAt: Date };
8
+ export type RedeemedLink = { subject: string; createdAt: Date };
9
+ export type PeekedLink = RedeemedLink & { expiresAt: Date };
10
+ export type LinkOptions = { tx?: TransactionSQL };
11
+ type LinkResult<T> = { ok: true; data: T } | { ok: false; error: { code: "LINK_INVALID" } };
12
+ export type RedeemLinkResult = LinkResult<RedeemedLink>;
13
+ export type PeekLinkResult = LinkResult<PeekedLink>;
14
+
15
+ const TOKEN = /^cl_[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
16
+ // Match the existing outgoing-mail retention batch budget.
17
+ export const LINK_CLEANUP_BATCH = 1000;
18
+ const hashToken = (token: string): string => createHash("sha256").update(token).digest("hex");
19
+
20
+ const linkCandidate = (token: string, purpose: string) => {
21
+ const value = typeof token === "string" ? token : "";
22
+ const digest = hashToken(value);
23
+ const validPurpose = isPurposeName(purpose);
24
+ return { digest, purpose: validPurpose ? purpose : "", valid: TOKEN.test(value) && validPurpose };
25
+ };
26
+
27
+ export const validateCreateLink = (input: CreateLinkInput): void => {
28
+ validatePurposeName(input.purpose);
29
+ if (typeof input.subject !== "string" || Buffer.byteLength(input.subject, "utf8") > 1024) {
30
+ throw new TypeError("Link subject must be a string of at most 1024 UTF-8 bytes");
31
+ }
32
+ if (!Number.isFinite(input.ttl) || input.ttl < 60 || input.ttl > 30 * 24 * 60 * 60) {
33
+ throw new RangeError("Link ttl must be seconds from 60 to 2592000 (30 days)");
34
+ }
35
+ };
36
+
37
+ /** One reserved platform table in the same database as the application's domain transactions. */
38
+ export const setupLinks = async (db: SQL): Promise<void> => {
39
+ await db.begin(async (tx) => {
40
+ await tx`SELECT pg_advisory_xact_lock(hashtext('cloud.links'))`;
41
+ await ensureSchema(tx, "platform");
42
+ const [existing] = await tx<{ table_exists: boolean; index_exists: boolean }[]>`
43
+ SELECT to_regclass('platform._cloud_links') IS NOT NULL AS table_exists,
44
+ to_regclass('platform._cloud_links_retention') IS NOT NULL AS index_exists`;
45
+ if (!existing?.table_exists)
46
+ await tx`CREATE TABLE IF NOT EXISTS platform._cloud_links (
47
+ package text NOT NULL, token_hash text NOT NULL, purpose text NOT NULL, subject bytea NOT NULL,
48
+ created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, used_at timestamptz,
49
+ PRIMARY KEY (package, token_hash)
50
+ )`.simple();
51
+ // Even IF NOT EXISTS takes a ShareLock; skip index DDL on ordinary app restarts.
52
+ if (!existing?.index_exists)
53
+ await tx`CREATE INDEX IF NOT EXISTS _cloud_links_retention ON platform._cloud_links (LEAST(expires_at, used_at))`.simple();
54
+ });
55
+ };
56
+
57
+ export const createLinksStore = (db: SQL, packageName: string) => ({
58
+ create: async (input: CreateLinkInput, options: LinkOptions = {}): Promise<CreatedLink> => {
59
+ validateCreateLink(input);
60
+ const token = `cl_${randomBytes(32).toString("base64url")}`;
61
+ const [row] = await (options.tx ?? db)<{ expires_at: Date }[]>`
62
+ INSERT INTO platform._cloud_links (package, token_hash, purpose, subject, created_at, expires_at)
63
+ VALUES (${packageName}, ${hashToken(token)}, ${input.purpose}, ${Buffer.from(input.subject, "utf8")},
64
+ statement_timestamp(), statement_timestamp() + ${input.ttl} * interval '1 second')
65
+ RETURNING expires_at`;
66
+ if (!row) throw new Error("One-time link insert returned no expiry");
67
+ return { token, expiresAt: row.expires_at };
68
+ },
69
+ peek: async (token: string, input: { purpose: string }, options: LinkOptions = {}): Promise<PeekLinkResult> => {
70
+ const candidate = linkCandidate(token, input.purpose);
71
+ const [row] = await (options.tx ?? db)<{ subject: Uint8Array; created_at: Date; expires_at: Date }[]>`
72
+ SELECT subject, created_at, expires_at FROM platform._cloud_links
73
+ WHERE package = ${packageName} AND token_hash = ${candidate.digest} AND purpose = ${candidate.purpose}
74
+ AND ${candidate.valid}::boolean AND used_at IS NULL AND expires_at > clock_timestamp()`;
75
+ return row
76
+ ? { ok: true, data: { subject: Buffer.from(row.subject).toString("utf8"), createdAt: row.created_at, expiresAt: row.expires_at } }
77
+ : { ok: false, error: { code: "LINK_INVALID" } };
78
+ },
79
+ redeem: async (token: string, input: { purpose: string }, options: LinkOptions = {}): Promise<RedeemLinkResult> => {
80
+ // Every candidate is hashed and reaches one indexed UPDATE, including malformed credentials.
81
+ const candidate = linkCandidate(token, input.purpose);
82
+ const [row] = await (options.tx ?? db)<{ subject: Uint8Array; created_at: Date }[]>`
83
+ UPDATE platform._cloud_links SET used_at = clock_timestamp()
84
+ WHERE package = ${packageName} AND token_hash = ${candidate.digest} AND purpose = ${candidate.purpose}
85
+ AND ${candidate.valid}::boolean AND used_at IS NULL AND expires_at > clock_timestamp()
86
+ RETURNING subject, created_at`;
87
+ return row
88
+ ? { ok: true, data: { subject: Buffer.from(row.subject).toString("utf8"), createdAt: row.created_at } }
89
+ : { ok: false, error: { code: "LINK_INVALID" } };
90
+ },
91
+ });
92
+
93
+ /** Platform maintenance spans packages; the application facade never exposes this operation. */
94
+ export const cleanupLinksBatch = async (db: SQL): Promise<number> =>
95
+ db.begin(async (tx) => {
96
+ await tx`SET LOCAL statement_timeout = '10s'`;
97
+ const rows = await tx<{ token_hash: string }[]>`
98
+ WITH expired AS (
99
+ SELECT package, token_hash FROM platform._cloud_links
100
+ WHERE LEAST(expires_at, used_at) < statement_timestamp() - interval '24 hours'
101
+ ORDER BY LEAST(expires_at, used_at) LIMIT ${LINK_CLEANUP_BATCH} FOR UPDATE SKIP LOCKED
102
+ ) DELETE FROM platform._cloud_links link USING expired
103
+ WHERE link.package = expired.package AND link.token_hash = expired.token_hash RETURNING link.token_hash`;
104
+ return rows.length;
105
+ });
@@ -2,7 +2,7 @@ import { sql } from "bun";
2
2
  import type { PaginationParams } from "../../contracts/shared";
3
3
  import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
4
4
  import { registerSettings } from "../settings/defaults";
5
- import { redactMetadata } from "./redaction";
5
+ import { redactLinkTokens, redactMetadata } from "./redaction";
6
6
  import { observeSyncEvent, TRACE_STUCK_AFTER_MS, trace } from "./trace";
7
7
  import { readWebVitals } from "./web-vitals";
8
8
 
@@ -120,16 +120,18 @@ const mapRow = (row: DbLogRow): LogEntry => ({
120
120
  /** Fire-and-forget write — mirrors to console, then inserts to DB async. */
121
121
  function write(params: WriteParams): void {
122
122
  const safeMetadata = params.metadata ? (redactMetadata(params.metadata) as Record<string, unknown>) : undefined;
123
- const prefix = `[${params.source}]`;
123
+ const source = redactLinkTokens(params.source);
124
+ const message = redactLinkTokens(params.message);
125
+ const prefix = `[${source}]`;
124
126
  const consoleFn = params.level === "error" ? console.error : params.level === "warn" ? console.warn : console.log;
125
- consoleFn(prefix, params.message, ...(safeMetadata ? [safeMetadata] : []));
127
+ consoleFn(prefix, message, ...(safeMetadata ? [safeMetadata] : []));
126
128
 
127
129
  sql`
128
130
  INSERT INTO logging.entries (level, source, message, metadata)
129
131
  VALUES (
130
132
  ${params.level},
131
- ${params.source},
132
- ${params.message},
133
+ ${source},
134
+ ${message},
133
135
  (${safeMetadata ? JSON.stringify(safeMetadata) : null}::text)::jsonb
134
136
  )
135
137
  `.catch((err: Error) => console.error("[logging] DB write failed:", err.message));
@@ -7,14 +7,18 @@ const SENSITIVE_KEY_PATTERN = /(password|secret|token|cookie|authorization|api[_
7
7
 
8
8
  export const REDACTED = "[REDACTED]";
9
9
 
10
+ /** Recognizable one-time credentials are scrubbed even under ordinary metadata keys. */
11
+ export const redactLinkTokens = (value: string): string => value.replace(/cl_[A-Za-z0-9_-]{43}/g, REDACTED);
12
+
10
13
  export const isSensitiveMetadataKey = (key: string): boolean => SENSITIVE_KEY_PATTERN.test(key);
11
14
 
12
15
  export const redactMetadata = (input: unknown): unknown => {
16
+ if (typeof input === "string") return redactLinkTokens(input);
13
17
  if (input === null || typeof input !== "object") return input;
14
18
  if (Array.isArray(input)) return input.map(redactMetadata);
15
19
  const out: Record<string, unknown> = {};
16
20
  for (const [key, value] of Object.entries(input)) {
17
- out[key] = isSensitiveMetadataKey(key) ? REDACTED : redactMetadata(value);
21
+ out[redactLinkTokens(key)] = isSensitiveMetadataKey(key) ? REDACTED : redactMetadata(value);
18
22
  }
19
23
  return out;
20
24
  };
@@ -60,6 +64,7 @@ export const redactSensitivePath = (pathname: string): string => {
60
64
  .split("/")
61
65
  .map((segment) => {
62
66
  const value = decodeSegment(segment);
67
+ if (redactLinkTokens(value) !== value) return ":token";
63
68
  if (UUID.test(value)) return ":id";
64
69
  // Dot-separated parts cover signed tokens and credential filenames such as .ics.
65
70
  // Lowercase word slugs preserve readable routes like getting-started-with-grids.
@@ -4,7 +4,7 @@ import { sql } from "bun";
4
4
  import { getProcessApplicationId } from "../../_internal/process-identity";
5
5
  import type { PaginationParams } from "../../contracts/shared";
6
6
  import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
7
- import { isSensitiveMetadataKey, REDACTED, redactMetadata } from "./redaction";
7
+ import { isSensitiveMetadataKey, REDACTED, redactLinkTokens, redactMetadata } from "./redaction";
8
8
 
9
9
  export type TraceAttributeValue = string | number | boolean | null | undefined;
10
10
  export type TraceAttributes = Record<string, TraceAttributeValue>;
@@ -499,10 +499,10 @@ const start = async (params: TraceStartParams, preservePresentation = false): Pr
499
499
  VALUES (
500
500
  ${context.traceId},
501
501
  ${context.spanId},
502
- ${params.spanKey ?? null},
502
+ ${params.spanKey ? redactLinkTokens(params.spanKey) : null},
503
503
  ${params.parent?.spanId ?? null},
504
- ${params.name},
505
- ${params.source},
504
+ ${redactLinkTokens(params.name)},
505
+ ${redactLinkTokens(params.source)},
506
506
  ${params.appId ?? null},
507
507
  ${params.category ?? "custom"},
508
508
  ${params.kind ?? "internal"},
@@ -567,10 +567,10 @@ const complete = async (params: TraceCompleteParams, preservePresentation = fals
567
567
  VALUES (
568
568
  ${context.traceId},
569
569
  ${context.spanId},
570
- ${params.spanKey ?? null},
570
+ ${params.spanKey ? redactLinkTokens(params.spanKey) : null},
571
571
  ${params.parent?.spanId ?? null},
572
- ${params.name},
573
- ${params.source},
572
+ ${redactLinkTokens(params.name)},
573
+ ${redactLinkTokens(params.source)},
574
574
  ${params.appId ?? null},
575
575
  ${params.category ?? "custom"},
576
576
  ${params.kind ?? "internal"},
@@ -636,7 +636,7 @@ const record = async (params: TraceRecordParams, preservePresentation = false):
636
636
  VALUES (
637
637
  ${context.traceId},
638
638
  ${context.spanId},
639
- ${params.event},
639
+ ${redactLinkTokens(params.event)},
640
640
  ${params.severity ?? "info"},
641
641
  (${attributesJson}::text)::jsonb,
642
642
  ${body},
@@ -1,5 +1,5 @@
1
1
  import { RedisClient } from "bun";
2
- import { env } from "../config/env";
2
+ import { connections } from "../runtime/connections";
3
3
 
4
4
  // Cache commands must not queue across a disconnect: delayed fills/invalidations
5
5
  // are not useful, and reads must be able to fall back to Postgres. Keep this
@@ -10,7 +10,7 @@ let reconnecting: Promise<void> | undefined;
10
10
 
11
11
  export const requestCacheRedis = async (): Promise<RedisClient> => {
12
12
  if (!client) {
13
- client = new RedisClient(env.REDIS_URL, { enableOfflineQueue: false });
13
+ client = new RedisClient(connections().valkey.url, { enableOfflineQueue: false });
14
14
  // The first operation waits for normal connection establishment (Bun's
15
15
  // connection timeout applies). Later disconnected operations fail promptly.
16
16
  initialConnection = client.connect().catch(() => {});
@@ -49,6 +49,26 @@ export const CORE_SETTINGS = {
49
49
  },
50
50
  },
51
51
  },
52
+ "gateway.ingress_cert_resolver": {
53
+ kind: "string",
54
+ default: "le",
55
+ label: "App domain certificate resolver",
56
+ description:
57
+ "Traefik certificate resolver used by app domain routers. Must name a configured ACME resolver; changes apply within five seconds.",
58
+ validate: (value: unknown) =>
59
+ typeof value === "string" && /^[a-zA-Z0-9_-]+$/.test(value)
60
+ ? undefined
61
+ : "Use a Traefik certificate resolver name (letters, digits, underscores or hyphens).",
62
+ presentation: {
63
+ translations: {
64
+ de: {
65
+ label: "Zertifikatsresolver für App-Domains",
66
+ description:
67
+ "Traefik-Zertifikatsresolver der App-Domains. Muss einen konfigurierten ACME-Resolver benennen; Änderungen wirken innerhalb von fünf Sekunden.",
68
+ },
69
+ },
70
+ },
71
+ },
52
72
  "gateway.trusted_proxies": {
53
73
  kind: "string_list",
54
74
  default: DEFAULT_TRUSTED_PROXIES,
@@ -1,3 +1,4 @@
1
+ import { connections } from "../../runtime/connections";
1
2
  /**
2
3
  * Encryption helpers for settings at-rest.
3
4
  *
@@ -7,7 +8,6 @@
7
8
  */
8
9
 
9
10
  import { createHash } from "node:crypto";
10
- import { env } from "../../config/env";
11
11
  import { crypto } from "../../server/services";
12
12
 
13
13
  /**
@@ -34,7 +34,7 @@ import { crypto } from "../../server/services";
34
34
  * with deployments that have always used hex-format APP_SECRET.
35
35
  */
36
36
  export const getAppSecret = (): string => {
37
- const raw = env.APP_SECRET.trim();
37
+ const raw = connections().appSecret.current.trim();
38
38
  if (!raw) {
39
39
  throw new Error("APP_SECRET is required to read or write encrypted settings");
40
40
  }
@@ -16,6 +16,7 @@
16
16
  import { sql } from "bun";
17
17
  import { HTTPException } from "hono/http-exception";
18
18
  import { hasRole, type User } from "../../contracts/shared";
19
+ import { connections } from "../../runtime/connections";
19
20
  import { claimCacheFill, completeCacheFill, MISSING_SETTING } from "../cache-fill";
20
21
  import { stripImageDataUrlMetadata } from "../image-metadata";
21
22
  import { toPgTextArray } from "../postgres";
@@ -89,6 +90,8 @@ export const readKeyState = async (key: string, db: SqlClient = sql): Promise<Se
89
90
  export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
90
91
  const result = new Map<string, unknown>();
91
92
  if (keys.length === 0) return result;
93
+ // Missing startup is a programming error, never a cache outage or unreadable row.
94
+ connections();
92
95
  // Bound access is read with its endpoint in one database snapshot. Never
93
96
  // resolve it from an independently cached secret.
94
97
  const boundKeys = keys.filter((key) => settingOwners(key).length > 0);
@@ -163,6 +166,7 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
163
166
  export const readStates = async (keys: readonly string[], db: SqlClient = sql): Promise<Map<string, SettingRead>> => {
164
167
  const result = new Map<string, SettingRead>();
165
168
  if (keys.length === 0) return result;
169
+ connections();
166
170
  const snapshotKeys = [...new Set(keys.flatMap((key) => [key, ...settingOwners(key).map((owner) => owner.key)]))];
167
171
  const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray(snapshotKeys)}::text[])`;
168
172
  const byKey = new Map(rows.map((row) => [row.key, row]));
@@ -206,6 +210,7 @@ const knownKeysWith = (extraKnownKeys: readonly string[]) =>
206
210
  Array.from(new Set([...allKnownKeys(), ...retainedLegacyKeys, ...extraKnownKeys]));
207
211
 
208
212
  export const listLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<LegacySettingRow[]> => {
213
+ connections();
209
214
  const knownKeys = knownKeysWith(extraKnownKeys);
210
215
  const rows = await sql<LegacyStoredRow[]>`
211
216
  SELECT key, value, updated_at
@@ -70,7 +70,6 @@ export const buildAdminGroups = (apps: readonly RuntimeContext["apps"][number][]
70
70
  links: [
71
71
  { href: "/admin", icon: "ti-dashboard", label: t.overview },
72
72
  { href: "/admin/apps", icon: "ti-apps", label: t.apps },
73
- { href: "/admin/app-credentials", icon: "ti-key", label: t.appCredentials },
74
73
  { href: "/admin/rail", icon: "ti-layout-sidebar", label: t.rail },
75
74
  { href: "/admin/announcements", icon: "ti-speakerphone", label: t.announcements },
76
75
  ],
@@ -40,7 +40,6 @@ export const platformMessages = i18n.define({
40
40
  general: "General",
41
41
  rail: "App bar",
42
42
  announcements: "Announcements",
43
- appCredentials: "App credentials",
44
43
  ai: "AI",
45
44
  observability: "Observability",
46
45
  baseSettings: "Base settings",
@@ -147,7 +146,6 @@ export const platformMessages = i18n.define({
147
146
  general: "Allgemein",
148
147
  rail: "App-Leiste",
149
148
  announcements: "Ankündigungen",
150
- appCredentials: "App-Schlüssel",
151
149
  ai: "KI",
152
150
  observability: "Observability",
153
151
  baseSettings: "Grundeinstellungen",
package/src/start.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  // Keep this entry independent of the platform barrel and application modules.
2
+ import { parseAppKey } from "./app-key";
3
+ import { env } from "./config/env";
4
+ import { bootstrapConnections } from "./runtime/bootstrap-client";
5
+ import type { BootstrapResponse } from "./runtime/bootstrap-protocol";
2
6
  import { initializeClients } from "./runtime/clients";
3
7
  import { bindConnections, hasConnections } from "./runtime/connections";
4
8
  import { resolveConnections } from "./runtime/resolve-connections";
@@ -9,24 +13,41 @@ export type PlatformStartOptions = {
9
13
  source?: "env";
10
14
  };
11
15
 
16
+ let initializing = false;
17
+
12
18
  /** Initialize before importing any application module, also in dev and data workers. */
13
19
  export const initializePlatform = async ({ appId, source }: PlatformStartOptions): Promise<void> => {
14
20
  if (!/^[a-z][a-z0-9-]{0,79}$/.test(appId)) throw new Error("Platform start requires a valid appId");
15
- if (hasConnections()) throw new Error("Platform has already started");
16
- if (appId === "core" || appId === "gateway") source = "env";
17
- // Slice 6b seam: validate the app key and fetch missing values from Core here,
18
- // then pass the typed response to resolveConnections({ appId, core }).
19
- // A present key must never silently fall back to the legacy env mode.
20
- if (source !== "env" && (process.env.CLOUD_APP_KEY?.trim() || process.env.CLOUD_APP_KEY_FILE?.trim())) {
21
- throw new Error("Core connection bootstrap is not available yet");
22
- }
23
- const resolved = resolveConnections({ appId });
24
- bindConnections(resolved);
21
+ if (initializing || hasConnections()) throw new Error("Platform has already started");
22
+ initializing = true;
25
23
  try {
26
- await initializeClients();
27
- } catch (error) {
28
- bindConnections(undefined);
29
- throw error;
24
+ const keyValue = env.CLOUD_APP_KEY;
25
+ const envOnly = source === "env" || appId === "core" || appId === "gateway";
26
+ if (keyValue && envOnly) throw new Error("App keys are forbidden in forced env mode (including Core and Gateway)");
27
+ let bootstrap: BootstrapResponse | undefined;
28
+ if (keyValue) {
29
+ // Parsing never prints the private value. A present invalid key cannot fall back to env.
30
+ const key = parseAppKey(keyValue);
31
+ if (key.appId !== appId) throw new Error("App-key appId does not match the declared application");
32
+ bootstrap = await bootstrapConnections(key, env.CLOUD_CORE_INTERNAL_ORIGIN);
33
+ }
34
+ const resolved = resolveConnections({ appId, core: bootstrap?.connections });
35
+ resolved.bootstrap = {
36
+ mode: bootstrap ? "core" : "env",
37
+ ...(bootstrap ? { keyId: bootstrap.identity.keyId } : {}),
38
+ sources: resolved.sources,
39
+ keyFormat: "cloud-app-key-v1",
40
+ protocol: 1,
41
+ };
42
+ bindConnections(resolved);
43
+ try {
44
+ await initializeClients();
45
+ } catch (error) {
46
+ bindConnections(undefined);
47
+ throw error;
48
+ }
49
+ } finally {
50
+ initializing = false;
30
51
  }
31
52
  };
32
53