@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.
- package/package.json +3 -3
- 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 +31 -2
- package/src/_internal/heartbeat.ts +2 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/process-sync.ts +6 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/workload-credential.ts +2 -1
- package/src/api/admin-app-domains.ts +62 -0
- package/src/api/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +8 -1
- package/src/api/index.ts +5 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/server-invoke.ts +28 -2
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/index.ts +107 -88
- package/src/config/env.ts +7 -1
- 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/index.ts +1 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/connections.ts +9 -0
- package/src/server/app-key.ts +14 -0
- 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 +6 -3
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/identity/workload-runtime.ts +2 -2
- package/src/services/index.ts +4 -0
- 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/request-cache-redis.ts +2 -2
- package/src/services/settings/core-settings.ts +20 -0
- package/src/services/settings/crypto.ts +2 -2
- package/src/services/settings/store.ts +5 -0
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +35 -14
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import { getApp } from "../_internal/registry";
|
|
4
|
+
import { declaresPublicSurface, normalizeAppDomainHost } from "../contracts/app-domains";
|
|
5
|
+
import type { User } from "../contracts/shared";
|
|
6
|
+
import { sql } from "../runtime/clients";
|
|
7
|
+
import { publicCloudOrigin } from "../shared/app-url";
|
|
8
|
+
import { type AppDomainStatus, checkAppDomain } from "./app-domain-check";
|
|
9
|
+
import { audit } from "./audit";
|
|
10
|
+
import { get } from "./settings";
|
|
11
|
+
|
|
12
|
+
export type AdminAppDomain = {
|
|
13
|
+
host: string;
|
|
14
|
+
redirectTo: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
active: boolean;
|
|
17
|
+
status: AppDomainStatus | null;
|
|
18
|
+
};
|
|
19
|
+
type AdminActor = Pick<User, "id" | "uid" | "provider" | "roles">;
|
|
20
|
+
type AppInput = { actor: AdminActor; appId: string };
|
|
21
|
+
type DomainInput = AppInput & { host: string };
|
|
22
|
+
type Row = { host: string; app_id: string; redirect_to: string | null; created_at: Date; status: AppDomainStatus | null };
|
|
23
|
+
type Dependencies = { db?: SQL; getApp?: typeof getApp; cloudUrl?: () => string | Promise<string>; probe?: typeof checkAppDomain };
|
|
24
|
+
const normalizeHost = (value: string) => (value.includes("\\") ? null : normalizeAppDomainHost(value));
|
|
25
|
+
const invalid = (code: string, message: string) => fail({ ...err.badInput(message), code });
|
|
26
|
+
const conflict = (code: string, message: string) => fail({ ...err.conflict("App domain"), code, message });
|
|
27
|
+
const notFound = () => fail({ ...err.notFound("App domain"), code: "APP_DOMAIN_NOT_FOUND" });
|
|
28
|
+
const authorized = (input: AppInput): Result<void> => {
|
|
29
|
+
if (!input.actor.roles.includes("admin")) return fail(err.forbidden("Administrator access required"));
|
|
30
|
+
if (!/^[a-z][a-z0-9-]{0,79}$/.test(input.appId)) return invalid("APP_DOMAIN_INVALID_APP", "Invalid app ID");
|
|
31
|
+
return ok();
|
|
32
|
+
};
|
|
33
|
+
const domain = (row: Row, active: boolean): AdminAppDomain => ({
|
|
34
|
+
host: row.host,
|
|
35
|
+
redirectTo: row.redirect_to,
|
|
36
|
+
createdAt: row.created_at.toISOString(),
|
|
37
|
+
active,
|
|
38
|
+
status: row.status,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** All domain mutations share one transaction lock; the host primary key also enforces unique ownership. */
|
|
42
|
+
export const createAppDomainService = (dependencies: Dependencies = {}) => {
|
|
43
|
+
const app = dependencies.getApp ?? getApp;
|
|
44
|
+
const database = () => dependencies.db ?? sql;
|
|
45
|
+
const active = async (appId: string) => {
|
|
46
|
+
const entry = await app(appId);
|
|
47
|
+
return entry !== null && declaresPublicSurface(entry);
|
|
48
|
+
};
|
|
49
|
+
const record = (db: SQL, input: DomainInput, action: string, redirectTo?: string | null) =>
|
|
50
|
+
audit.record(
|
|
51
|
+
{
|
|
52
|
+
action: `app_domain.${action}`,
|
|
53
|
+
outcome: "allowed",
|
|
54
|
+
actor: { userId: input.actor.id, uid: input.actor.uid, provider: input.actor.provider, roles: input.actor.roles },
|
|
55
|
+
target: { type: "app_domain", id: input.host, label: input.host },
|
|
56
|
+
metadata: { appId: input.appId, ...(redirectTo !== undefined ? { redirectTo } : {}) },
|
|
57
|
+
},
|
|
58
|
+
db,
|
|
59
|
+
);
|
|
60
|
+
const references = (db: SQL, appId: string, host: string) => db<{ host: string }[]>`
|
|
61
|
+
SELECT host FROM settings.app_domains WHERE app_id = ${appId} AND redirect_to = ${host} ORDER BY host`;
|
|
62
|
+
const validateRedirect = async (db: SQL, appId: string, host: string, target: string): Promise<Result<void>> => {
|
|
63
|
+
if (host === target) return invalid("APP_DOMAIN_REDIRECT_TARGET", "A domain cannot redirect to itself");
|
|
64
|
+
const refs = await references(db, appId, host);
|
|
65
|
+
if (refs.length) return conflict("APP_DOMAIN_REFERENCED", `Domains redirect to ${host}: ${refs.map((row) => row.host).join(", ")}`);
|
|
66
|
+
const [row] = await db<Row[]>`SELECT * FROM settings.app_domains WHERE host = ${target} AND app_id = ${appId}`;
|
|
67
|
+
if (!row || row.redirect_to)
|
|
68
|
+
return invalid("APP_DOMAIN_REDIRECT_TARGET", "Redirect target must be a domain of this app without a redirect");
|
|
69
|
+
return ok();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
async list(input: AppInput): Promise<Result<{ domains: AdminAppDomain[] }>> {
|
|
74
|
+
const permission = authorized(input);
|
|
75
|
+
if (!permission.ok) return permission;
|
|
76
|
+
const [rows, isActive] = await Promise.all([
|
|
77
|
+
database()<Row[]>`SELECT * FROM settings.app_domains WHERE app_id = ${input.appId} ORDER BY host`,
|
|
78
|
+
active(input.appId),
|
|
79
|
+
]);
|
|
80
|
+
return ok({ domains: rows.map((row) => domain(row, isActive)) });
|
|
81
|
+
},
|
|
82
|
+
async add(input: DomainInput & { redirectTo?: string }): Promise<Result<AdminAppDomain>> {
|
|
83
|
+
const permission = authorized(input);
|
|
84
|
+
if (!permission.ok) return permission;
|
|
85
|
+
const host = normalizeHost(input.host);
|
|
86
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Expected a DNS host name without a port, path or wildcard");
|
|
87
|
+
const cloudHost = normalizeAppDomainHost(
|
|
88
|
+
new URL(publicCloudOrigin(await (dependencies.cloudUrl ?? (() => get<string>("app.url")))())).hostname,
|
|
89
|
+
);
|
|
90
|
+
if (host === cloudHost) return invalid("APP_DOMAIN_CLOUD_HOST", "The Cloud host cannot be assigned to an app");
|
|
91
|
+
const isActive = await active(input.appId);
|
|
92
|
+
const redirectTo = input.redirectTo === undefined ? null : normalizeHost(input.redirectTo);
|
|
93
|
+
if (input.redirectTo !== undefined && !redirectTo) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid redirect host");
|
|
94
|
+
return database().begin(async (db) => {
|
|
95
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended('core.app-domains', 0))`;
|
|
96
|
+
const [known] = await db<{ app_id: string }[]>`SELECT app_id FROM platform.application_packages WHERE app_id = ${input.appId}`;
|
|
97
|
+
if (!known) return invalid("APP_DOMAIN_UNKNOWN_APP", "Unknown app ID; the app must have registered its package at least once");
|
|
98
|
+
const existing = await db`SELECT host FROM settings.app_domains WHERE host = ${host}`;
|
|
99
|
+
if (existing.length) return conflict("APP_DOMAIN_ASSIGNED", `Domain ${host} is already assigned`);
|
|
100
|
+
if (redirectTo) {
|
|
101
|
+
const valid = await validateRedirect(db, input.appId, host, redirectTo);
|
|
102
|
+
if (!valid.ok) return valid;
|
|
103
|
+
}
|
|
104
|
+
const [row] = await db<Row[]>`INSERT INTO settings.app_domains(host, app_id, redirect_to)
|
|
105
|
+
VALUES (${host}, ${input.appId}, ${redirectTo}) ON CONFLICT (host) DO NOTHING RETURNING *`;
|
|
106
|
+
if (!row) return conflict("APP_DOMAIN_ASSIGNED", `Domain ${host} is already assigned`);
|
|
107
|
+
await record(db, { ...input, host }, "add", redirectTo);
|
|
108
|
+
return ok(domain(row, isActive));
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
async set(input: DomainInput & { redirectTo: string | null }): Promise<Result<AdminAppDomain>> {
|
|
112
|
+
const permission = authorized(input);
|
|
113
|
+
if (!permission.ok) return permission;
|
|
114
|
+
const host = normalizeHost(input.host);
|
|
115
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid domain host");
|
|
116
|
+
const redirectTo = input.redirectTo === null ? null : normalizeHost(input.redirectTo);
|
|
117
|
+
if (input.redirectTo !== null && !redirectTo) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid redirect host");
|
|
118
|
+
const isActive = await active(input.appId);
|
|
119
|
+
return database().begin(async (db) => {
|
|
120
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended('core.app-domains', 0))`;
|
|
121
|
+
const [existing] = await db<Row[]>`SELECT * FROM settings.app_domains WHERE host = ${host} AND app_id = ${input.appId}`;
|
|
122
|
+
if (!existing) return notFound();
|
|
123
|
+
if (redirectTo) {
|
|
124
|
+
const valid = await validateRedirect(db, input.appId, host, redirectTo);
|
|
125
|
+
if (!valid.ok) return valid;
|
|
126
|
+
}
|
|
127
|
+
const [row] = await db<
|
|
128
|
+
Row[]
|
|
129
|
+
>`UPDATE settings.app_domains SET redirect_to = ${redirectTo} WHERE host = ${host} AND app_id = ${input.appId} RETURNING *`;
|
|
130
|
+
if (!row) return notFound();
|
|
131
|
+
await record(db, { ...input, host }, "set", redirectTo);
|
|
132
|
+
return ok(domain(row, isActive));
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
async remove(input: DomainInput): Promise<Result<{ deleted: true }>> {
|
|
136
|
+
const permission = authorized(input);
|
|
137
|
+
if (!permission.ok) return permission;
|
|
138
|
+
const host = normalizeHost(input.host);
|
|
139
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid domain host");
|
|
140
|
+
return database().begin(async (db) => {
|
|
141
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended('core.app-domains', 0))`;
|
|
142
|
+
const refs = await references(db, input.appId, host);
|
|
143
|
+
if (refs.length) return conflict("APP_DOMAIN_REFERENCED", `Domains redirect to ${host}: ${refs.map((row) => row.host).join(", ")}`);
|
|
144
|
+
const rows = await db`DELETE FROM settings.app_domains WHERE host = ${host} AND app_id = ${input.appId} RETURNING host`;
|
|
145
|
+
if (!rows.length) return notFound();
|
|
146
|
+
await record(db, { ...input, host }, "remove");
|
|
147
|
+
return ok({ deleted: true as const });
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
async check(input: DomainInput): Promise<Result<{ status: AppDomainStatus | null }>> {
|
|
151
|
+
const permission = authorized(input);
|
|
152
|
+
if (!permission.ok) return permission;
|
|
153
|
+
const host = normalizeHost(input.host);
|
|
154
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid domain host");
|
|
155
|
+
const db = database();
|
|
156
|
+
const [claimed] = await db<{ check_started_at: string }[]>`UPDATE settings.app_domains SET check_started_at = now()
|
|
157
|
+
WHERE host = ${host} AND app_id = ${input.appId} AND (check_started_at IS NULL OR check_started_at <= now() - interval '60 seconds')
|
|
158
|
+
RETURNING check_started_at::text`;
|
|
159
|
+
if (!claimed) {
|
|
160
|
+
const [row] = await db<Row[]>`SELECT * FROM settings.app_domains WHERE host = ${host} AND app_id = ${input.appId}`;
|
|
161
|
+
return row ? ok({ status: row.status }) : notFound();
|
|
162
|
+
}
|
|
163
|
+
const status = await (dependencies.probe ?? checkAppDomain)(host);
|
|
164
|
+
// A removed/reassigned domain must never inherit an earlier owner's in-flight result.
|
|
165
|
+
await db`UPDATE settings.app_domains SET status = (${JSON.stringify(status)}::text)::jsonb
|
|
166
|
+
WHERE host = ${host} AND app_id = ${input.appId} AND check_started_at = ${claimed.check_started_at}::timestamptz`;
|
|
167
|
+
return ok({ status });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const appDomains = createAppDomainService();
|
package/src/services/app-keys.ts
CHANGED
|
@@ -269,7 +269,10 @@ export const revokeAppKey = async (params: { actor: AdminActor; appId: string; k
|
|
|
269
269
|
};
|
|
270
270
|
|
|
271
271
|
/** Bootstrap and signed workload requests must query this directly; no cache. */
|
|
272
|
-
export const getActiveAppKey = async (
|
|
272
|
+
export const getActiveAppKey = async (
|
|
273
|
+
params: { appId: string; keyId: string },
|
|
274
|
+
db: SQL = sql,
|
|
275
|
+
): Promise<(AppKeyMetadata & { connectionsAllowed: boolean }) | null> => {
|
|
273
276
|
try {
|
|
274
277
|
validateAppKeyAppId(params.appId);
|
|
275
278
|
} catch {
|
|
@@ -277,14 +280,14 @@ export const getActiveAppKey = async (params: { appId: string; keyId: string },
|
|
|
277
280
|
}
|
|
278
281
|
if (params.keyId.length !== 36 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(params.keyId))
|
|
279
282
|
return null;
|
|
280
|
-
const [row] = await db<Row[]>`
|
|
283
|
+
const [row] = await db<(Row & { connections_allowed: boolean })[]>`
|
|
281
284
|
SELECT ${db.unsafe(columns)} FROM auth.service_account_credentials c
|
|
282
285
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
283
286
|
WHERE c.kind = 'app_key' AND c.id = ${params.keyId}::uuid AND c.status = 'active'
|
|
284
287
|
AND (c.expires_at IS NULL OR c.expires_at > now()) AND sa.status = 'active'
|
|
285
288
|
AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
|
|
286
289
|
`;
|
|
287
|
-
return row ? map(row) : null;
|
|
290
|
+
return row ? { ...map(row), connectionsAllowed: row.connections_allowed } : null;
|
|
288
291
|
};
|
|
289
292
|
|
|
290
293
|
/** Call only after successful authentication; a late touch cannot revive a key. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { connections } from "../runtime/connections";
|
|
2
2
|
import { logger } from "./logging";
|
|
3
3
|
import { requestCacheRedis } from "./request-cache-redis";
|
|
4
4
|
|
|
@@ -31,7 +31,7 @@ export const createDownstreamBudget = (options: { client?: () => Promise<Client>
|
|
|
31
31
|
async acquire(input: { downstream: Downstream; timeoutMs: number; signal: AbortSignal }): Promise<() => void> {
|
|
32
32
|
input.signal.throwIfAborted();
|
|
33
33
|
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0) throw new RangeError("Downstream timeout must be positive and finite");
|
|
34
|
-
const namespace = options.namespace?.() ??
|
|
34
|
+
const namespace = options.namespace?.() ?? connections().nats.namespace;
|
|
35
35
|
if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for shared downstream budgets");
|
|
36
36
|
const key = `cloud:downstream:{${namespace}}:${input.downstream}`;
|
|
37
37
|
const token = crypto.randomUUID();
|