@k2b/cloud 0.47.0 → 0.49.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/app-access-resources.ts +2 -1
- package/src/_internal/app-capabilities.ts +73 -0
- package/src/_internal/app-event-catalog.ts +1 -1
- package/src/_internal/app-queries.ts +1 -1
- package/src/_internal/application-package.ts +2 -1
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +25 -2
- package/src/_internal/define-app.ts +33 -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/ai/app-skill-store.ts +2 -1
- package/src/ai/app-skills.ts +2 -1
- package/src/ai/approvals.ts +2 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/credentials.ts +2 -1
- package/src/ai/dictation-runtime.ts +1 -1
- package/src/ai/dictations.ts +1 -1
- package/src/ai/files-store.ts +2 -1
- package/src/ai/inference-calls.ts +2 -1
- package/src/ai/map-store.ts +1 -1
- package/src/ai/map-tool.ts +1 -1
- package/src/ai/memories.ts +1 -1
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +1 -1
- package/src/ai/memory-workflow-evidence.ts +1 -1
- package/src/ai/message-queue.ts +2 -1
- package/src/ai/migrate.ts +1 -1
- package/src/ai/model-access-migrate.ts +1 -1
- package/src/ai/model-access.ts +1 -1
- package/src/ai/model-catalog.ts +1 -1
- package/src/ai/model-delete.ts +1 -1
- package/src/ai/model-usage.ts +2 -1
- package/src/ai/open-questions.ts +1 -1
- package/src/ai/openai-api-slots.ts +2 -1
- package/src/ai/prefs.ts +2 -1
- package/src/ai/projects.ts +2 -1
- package/src/ai/provider-connections.ts +1 -1
- package/src/ai/quota-limits.ts +2 -1
- package/src/ai/quota-provider.ts +2 -1
- package/src/ai/quota-report.ts +2 -1
- package/src/ai/quotas-migrate.ts +2 -1
- package/src/ai/quotas.ts +2 -1
- package/src/ai/request-headers.ts +2 -1
- package/src/ai/review-content.ts +2 -1
- package/src/ai/skill-search.ts +2 -1
- package/src/ai/skills.ts +2 -1
- package/src/ai/store.ts +2 -1
- package/src/ai/structured-runs.ts +2 -1
- package/src/ai/tool-audit.ts +2 -1
- package/src/ai/turn-timing.ts +1 -1
- package/src/ai/usage.ts +2 -1
- package/src/ai/web-provenance.ts +2 -1
- package/src/api/admin-ai-quotas.ts +1 -1
- package/src/api/admin-ai.ts +1 -1
- package/src/api/admin-app-domains.ts +62 -0
- package/src/api/admin-app-keys.ts +47 -3
- package/src/api/admin-core-settings.ts +1 -1
- package/src/api/admin-outgoing-mail.ts +1 -1
- package/src/api/app-bootstrap.ts +153 -0
- package/src/api/app-events.ts +1 -1
- package/src/api/auth.ts +1 -1
- package/src/api/authenticate-app-request.ts +129 -0
- package/src/api/capabilities.ts +87 -6
- package/src/api/index.ts +7 -0
- package/src/api/me.ts +1 -1
- package/src/api/search.bench.ts +1 -1
- package/src/app-pwa/service.ts +2 -1
- package/src/app-pwa/transaction.ts +4 -2
- package/src/capabilities/claims.ts +2 -1
- package/src/capabilities/executions.ts +8 -4
- package/src/capabilities/geo-client.ts +1 -1
- package/src/capabilities/migrate.ts +3 -1
- package/src/capabilities/resource-readers.ts +2 -2
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/server-invoke.ts +73 -20
- package/src/capabilities/server.ts +2 -1
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +25 -3
- package/src/cli/admin/index.ts +107 -88
- package/src/config/env.ts +10 -4
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +37 -2
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-event-delivery.ts +1 -1
- package/src/events/live-delivery.ts +2 -1
- package/src/index.ts +1 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +67 -0
- package/src/runtime/clients.ts +6 -2
- package/src/runtime/connections.ts +9 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/index.ts +2 -0
- package/src/server/ratelimit.ts +1 -1
- package/src/server/services/access.ts +2 -1
- package/src/services/account-category-policy.ts +1 -1
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/account-lifecycle/index.ts +1 -1
- package/src/services/account-lifecycle/ipa-backfill.ts +2 -1
- package/src/services/accounts/app.ts +1 -1
- package/src/services/accounts/avatar.ts +1 -1
- package/src/services/accounts/base-group.ts +1 -1
- package/src/services/accounts/directory-shares.ts +1 -1
- package/src/services/accounts/duplicate-emails.ts +1 -1
- package/src/services/accounts/entities.ts +2 -1
- package/src/services/accounts/group-sql.ts +2 -1
- package/src/services/accounts/groups.ts +1 -1
- package/src/services/accounts/identities.ts +13 -11
- package/src/services/accounts/ipa-data.ts +1 -1
- package/src/services/accounts/local-groups.ts +2 -1
- package/src/services/accounts/machine-group-management.ts +2 -1
- package/src/services/accounts/posix.ts +13 -12
- package/src/services/accounts/request-policy.ts +2 -1
- package/src/services/accounts/switching.ts +1 -1
- package/src/services/accounts/users.ts +2 -1
- package/src/services/announcements/index.ts +1 -1
- package/src/services/app-access-management.ts +3 -9
- package/src/services/app-access.ts +2 -1
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-approval.ts +34 -30
- package/src/services/app-bootstrap.ts +217 -0
- package/src/services/app-details.ts +3 -2
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-events/accept.ts +5 -5
- package/src/services/app-events/admin.ts +15 -15
- package/src/services/app-events/declarations.ts +6 -3
- package/src/services/app-events/deliver.ts +6 -6
- package/src/services/app-events.ts +3 -2
- package/src/services/app-keys.ts +45 -11
- package/src/services/approvals/content.ts +1 -1
- package/src/services/approvals/index.ts +2 -1
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/migrate.ts +1 -1
- package/src/services/audit/index.ts +2 -1
- package/src/services/auth-flows/ipa.ts +1 -1
- package/src/services/auth-flows/magic-link.ts +1 -1
- package/src/services/auth-flows/password-reset.ts +1 -1
- package/src/services/auth-flows/proxy-return.ts +1 -1
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/geo/admin.ts +13 -12
- package/src/services/geo/identity.ts +46 -0
- package/src/services/geo/ledger.ts +118 -106
- package/src/services/geo/migrate.ts +2 -1
- package/src/services/geo/route-chain.ts +3 -3
- package/src/services/geo/service.ts +4 -24
- package/src/services/geoip/index.ts +5 -5
- package/src/services/geoip/store.ts +24 -21
- package/src/services/help/index.ts +2 -1
- package/src/services/help/store.ts +2 -1
- package/src/services/identity/app-request.ts +44 -0
- package/src/services/identity/constants.ts +1 -0
- package/src/services/identity/index.ts +1 -0
- package/src/services/identity/invocation-actor.ts +2 -1
- package/src/services/identity/invocation-token.ts +112 -0
- package/src/services/identity/key-ring.ts +2 -2
- package/src/services/identity/resource-readers.ts +1 -1
- package/src/services/identity/routes.ts +11 -3
- package/src/services/identity/runtime-config.ts +14 -3
- package/src/services/identity/workload-auth.ts +2 -0
- package/src/services/identity/workload-runtime.ts +3 -3
- package/src/services/index.ts +4 -0
- package/src/services/ipa/groups.ts +1 -1
- package/src/services/ipa/profile.ts +1 -1
- package/src/services/ipa/search.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- package/src/services/ipa/users.ts +1 -1
- package/src/services/known-browsers.ts +2 -1
- package/src/services/legal-consent.ts +1 -1
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +13 -8
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +9 -9
- package/src/services/logging/web-vitals.ts +1 -1
- package/src/services/mandates/index.ts +2 -1
- package/src/services/notifications/batches.ts +1 -1
- package/src/services/notifications/browser.ts +1 -1
- package/src/services/notifications/catalog.ts +1 -1
- package/src/services/notifications/dispatcher.ts +1 -1
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/observability.ts +2 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/quiet.ts +1 -1
- package/src/services/notifications/user.ts +1 -1
- package/src/services/oauth-tokens.ts +1 -1
- package/src/services/outbox.ts +15 -14
- package/src/services/outgoing-mail/admin.ts +1 -1
- package/src/services/outgoing-mail/dispatcher.ts +1 -1
- package/src/services/outgoing-mail/drain.ts +2 -1
- package/src/services/outgoing-mail/enqueue.ts +2 -1
- package/src/services/outgoing-mail/messages.ts +2 -1
- package/src/services/outgoing-mail/retention.ts +1 -1
- package/src/services/outgoing-mail/send.ts +1 -1
- package/src/services/outgoing-mail/store.ts +2 -1
- package/src/services/panel-context-consent.ts +32 -28
- package/src/services/providers/local/auth.ts +1 -1
- package/src/services/providers/local/users.ts +1 -1
- package/src/services/pwa-devices.ts +2 -1
- package/src/services/rail-preferences.ts +16 -12
- package/src/services/rail-shortcuts.ts +12 -10
- package/src/services/request-cache-redis.ts +3 -3
- package/src/services/service-account-credentials.ts +2 -1
- package/src/services/service-accounts.ts +2 -1
- package/src/services/session/index.ts +2 -1
- package/src/services/session/user.ts +2 -1
- package/src/services/settings/core-settings.ts +20 -0
- package/src/services/settings/crypto.ts +2 -2
- package/src/services/settings/index.ts +1 -1
- package/src/services/settings/store.ts +6 -1
- package/src/services/settings/versions.ts +2 -1
- package/src/services/weather/forecast.ts +2 -1
- package/src/services/weather/locations.ts +1 -1
- package/src/services/weather/migrate.ts +2 -1
- package/src/services/weather/short-id.ts +2 -1
- package/src/services/webauthn.ts +1 -1
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/compact-shell.browser-harness.ts +2 -0
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +35 -14
- package/src/workflows/ai/store.ts +2 -1
- package/src/workflows/store/budget.ts +2 -1
- package/src/workflows/store/definitions.ts +2 -1
- package/src/workflows/store/events.ts +2 -1
- package/src/workflows/store/observability.ts +2 -1
- package/src/workflows/store/retention.ts +2 -1
- package/src/workflows/store/runs.ts +2 -1
- package/src/workflows/store/transaction.ts +2 -1
- package/src/workflows/store/worker.ts +2 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { decodeJwt, decodeProtectedHeader, type JWTPayload, jwtVerify } from "jose";
|
|
3
|
+
import { importAppPublicKey, validateAppKeyAppId } 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
|
+
BootstrapIdentitySchema,
|
|
12
|
+
bootstrapBodyDigest,
|
|
13
|
+
} from "../runtime/bootstrap-protocol";
|
|
14
|
+
import { sql } from "../runtime/clients";
|
|
15
|
+
import { getActiveAppKey, getInstallationId, getStoredAppKey, type LastRejectedStart } from "./app-keys";
|
|
16
|
+
import { get } from "./settings";
|
|
17
|
+
|
|
18
|
+
export class BootstrapProofError extends Error {
|
|
19
|
+
constructor(readonly code: "invalid_proof" | "wrong_app" | "wrong_installation" | "replayed_proof" | "connections_not_permitted") {
|
|
20
|
+
super(code);
|
|
21
|
+
this.name = "BootstrapProofError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Evidence attached only after signature and every request claim have been verified. */
|
|
25
|
+
export class RejectedBootstrapStart extends BootstrapProofError {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly identity: BootstrapIdentity,
|
|
28
|
+
readonly jti: string,
|
|
29
|
+
readonly reason: LastRejectedStart["reason"],
|
|
30
|
+
) {
|
|
31
|
+
super(reason === "connections_not_permitted" ? reason : "invalid_proof");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const invalid = () => new BootstrapProofError("invalid_proof");
|
|
35
|
+
|
|
36
|
+
/** A refused proof is never consumed. Already-consumed proofs cannot change administrator state. */
|
|
37
|
+
export const recordRejectedBootstrapStart = async (rejection: RejectedBootstrapStart, db: SQL = sql): Promise<void> => {
|
|
38
|
+
const { identity, jti, reason } = rejection;
|
|
39
|
+
await db`
|
|
40
|
+
INSERT INTO auth.app_start_rejections (app_id, rejected_at, reason, key_id)
|
|
41
|
+
SELECT ${identity.appId}, clock_timestamp(), ${reason}, ${identity.keyId}::uuid
|
|
42
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.app_proof_replays WHERE key_id = ${identity.keyId}::uuid AND jti = ${jti})
|
|
43
|
+
ON CONFLICT (app_id) DO UPDATE
|
|
44
|
+
SET rejected_at = EXCLUDED.rejected_at, reason = EXCLUDED.reason, key_id = EXCLUDED.key_id
|
|
45
|
+
`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const clearRejectedBootstrapStart = async (appId: string, db: SQL = sql): Promise<void> => {
|
|
49
|
+
await db`DELETE FROM auth.app_start_rejections WHERE app_id = ${appId}`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Shared Postgres uniqueness rejects a proof used concurrently on different Core replicas. */
|
|
53
|
+
export const consumeBootstrapProof = async (keyId: string, jti: string, exp: number, db: SQL = sql): Promise<boolean> => {
|
|
54
|
+
const rows = await db`
|
|
55
|
+
INSERT INTO auth.app_proof_replays (key_id, jti, retain_until)
|
|
56
|
+
VALUES (${keyId}::uuid, ${jti}, ${new Date((exp + BOOTSTRAP_REPLAY_RETENTION_SECONDS) * 1000)})
|
|
57
|
+
ON CONFLICT (key_id, jti) DO NOTHING RETURNING jti
|
|
58
|
+
`;
|
|
59
|
+
return rows.length === 1;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The ingress, per-key admission and minute maintenance share this installation budget. */
|
|
63
|
+
export const bootstrapRateLimit = async (): Promise<number> => Math.max(1, Math.floor(await get<number>("security.rate_limit_per_second")));
|
|
64
|
+
|
|
65
|
+
/** Drain expired pages, with two minutes of per-key admission capacity to catch up after missed runs. */
|
|
66
|
+
export const cleanupBootstrapProofs = async (
|
|
67
|
+
db: SQL = sql,
|
|
68
|
+
{ signal }: { signal?: AbortSignal } = {},
|
|
69
|
+
): Promise<{ deleted: number; remaining: number }> => {
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
// Include revoked keys: their retained evidence still needs cleaning after revocation.
|
|
72
|
+
const [keys] = await db<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.service_account_credentials WHERE kind = 'app_key'`;
|
|
73
|
+
const maxRows = Math.max(1, keys?.count ?? 0) * (await bootstrapRateLimit()) * 60 * 2;
|
|
74
|
+
let deleted = 0;
|
|
75
|
+
while (deleted < maxRows) {
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
const batchSize = Math.min(100, maxRows - deleted);
|
|
78
|
+
const query = db`
|
|
79
|
+
WITH batch AS MATERIALIZED (
|
|
80
|
+
SELECT key_id, jti FROM auth.app_proof_replays WHERE retain_until <= now()
|
|
81
|
+
ORDER BY retain_until LIMIT ${batchSize} FOR UPDATE SKIP LOCKED
|
|
82
|
+
)
|
|
83
|
+
DELETE FROM auth.app_proof_replays replay USING batch
|
|
84
|
+
WHERE replay.key_id = batch.key_id AND replay.jti = batch.jti AND replay.retain_until <= now()
|
|
85
|
+
RETURNING replay.jti
|
|
86
|
+
`;
|
|
87
|
+
const abort = () => query.cancel();
|
|
88
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
89
|
+
try {
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
const rows = await query;
|
|
92
|
+
deleted += rows.length;
|
|
93
|
+
if (rows.length < batchSize) break;
|
|
94
|
+
} finally {
|
|
95
|
+
signal?.removeEventListener("abort", abort);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
const [backlog] = await db<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_proof_replays WHERE retain_until <= now()`;
|
|
100
|
+
return { deleted, remaining: backlog?.count ?? 0 };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Reject malformed or expired envelopes before allocating or reading a signed request body. Not authentication. */
|
|
104
|
+
export const inspectAppProof = (proof: string, now = Date.now()): { appId: string; keyId: string } => {
|
|
105
|
+
if (Buffer.byteLength(proof) > BOOTSTRAP_PROOF_BYTES || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{86}$/.test(proof))
|
|
106
|
+
throw invalid();
|
|
107
|
+
let appId: string;
|
|
108
|
+
let keyId: string;
|
|
109
|
+
try {
|
|
110
|
+
const header = decodeProtectedHeader(proof);
|
|
111
|
+
const claims = decodeJwt(proof);
|
|
112
|
+
if (header.alg !== "EdDSA" || header.typ !== BOOTSTRAP_TYPE || typeof header.kid !== "string" || typeof claims.iss !== "string")
|
|
113
|
+
throw invalid();
|
|
114
|
+
validateAppKeyAppId(claims.iss);
|
|
115
|
+
if (!BootstrapIdentitySchema.shape.keyId.safeParse(header.kid).success) throw invalid();
|
|
116
|
+
const seconds = Math.floor(now / 1000);
|
|
117
|
+
if (
|
|
118
|
+
!Number.isSafeInteger(claims.iat) ||
|
|
119
|
+
!Number.isSafeInteger(claims.exp) ||
|
|
120
|
+
claims.iat! > seconds + 2 ||
|
|
121
|
+
claims.exp! <= seconds ||
|
|
122
|
+
claims.exp! <= claims.iat! ||
|
|
123
|
+
claims.exp! - claims.iat! > BOOTSTRAP_PROOF_SECONDS
|
|
124
|
+
)
|
|
125
|
+
throw invalid();
|
|
126
|
+
appId = claims.iss;
|
|
127
|
+
keyId = header.kid;
|
|
128
|
+
} catch {
|
|
129
|
+
throw invalid();
|
|
130
|
+
}
|
|
131
|
+
return { appId, keyId };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** No caches: every HTTP transmission rechecks revocation, expiry and account state in Postgres. */
|
|
135
|
+
export const authenticateAppProof = async (
|
|
136
|
+
proof: string,
|
|
137
|
+
request: { method: string; path: string; body: Uint8Array<ArrayBuffer> },
|
|
138
|
+
db: SQL = sql,
|
|
139
|
+
now = Date.now(),
|
|
140
|
+
): Promise<{ identity: BootstrapIdentity; key: NonNullable<Awaited<ReturnType<typeof getActiveAppKey>>>; jti: string; exp: number }> => {
|
|
141
|
+
const { appId, keyId } = inspectAppProof(proof, now);
|
|
142
|
+
// Only validated IDs reach SQL, and the app identity must own this active credential.
|
|
143
|
+
const key = await getActiveAppKey({ appId, keyId }, db);
|
|
144
|
+
if (!key) throw invalid();
|
|
145
|
+
return verifyAppProof(proof, request, key, db, now);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const verifyAppProof = async (
|
|
149
|
+
proof: string,
|
|
150
|
+
request: { method: string; path: string; body: Uint8Array<ArrayBuffer> },
|
|
151
|
+
key: NonNullable<Awaited<ReturnType<typeof getStoredAppKey>>>,
|
|
152
|
+
db: SQL,
|
|
153
|
+
now: number,
|
|
154
|
+
) => {
|
|
155
|
+
let claims: JWTPayload;
|
|
156
|
+
try {
|
|
157
|
+
const verified = await jwtVerify(proof, await importAppPublicKey(key.publicKey), {
|
|
158
|
+
algorithms: ["EdDSA"],
|
|
159
|
+
typ: BOOTSTRAP_TYPE,
|
|
160
|
+
currentDate: new Date(now),
|
|
161
|
+
requiredClaims: ["iss", "sub", "aud", "iat", "exp", "jti", "htm", "htu", "bdh"],
|
|
162
|
+
});
|
|
163
|
+
claims = verified.payload;
|
|
164
|
+
} catch {
|
|
165
|
+
throw invalid();
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
typeof claims.jti !== "string" ||
|
|
169
|
+
claims.jti.length === 0 ||
|
|
170
|
+
claims.jti.length > 64 ||
|
|
171
|
+
claims.jti.includes("\0") ||
|
|
172
|
+
claims.htm !== request.method ||
|
|
173
|
+
claims.htu !== request.path ||
|
|
174
|
+
claims.bdh !== (await bootstrapBodyDigest(request.body))
|
|
175
|
+
)
|
|
176
|
+
throw invalid();
|
|
177
|
+
if (claims.iss !== key.appId || claims.sub !== key.appId) throw new BootstrapProofError("wrong_app");
|
|
178
|
+
const installationId = await getInstallationId(db);
|
|
179
|
+
if (claims.aud !== `cloud-core:${installationId}`) throw new BootstrapProofError("wrong_installation");
|
|
180
|
+
// Admission must succeed before the route atomically consumes this verified nonce.
|
|
181
|
+
return { identity: { installationId, appId: key.appId, keyId: key.keyId }, key, jti: claims.jti, exp: claims.exp! };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Only connection delivery requires connections_allowed; runners can still sign requests. */
|
|
185
|
+
export const authenticateBootstrap = async (
|
|
186
|
+
proof: string,
|
|
187
|
+
request: { method: string; path: string; body: Uint8Array<ArrayBuffer> },
|
|
188
|
+
db: SQL = sql,
|
|
189
|
+
now = Date.now(),
|
|
190
|
+
): Promise<{ identity: BootstrapIdentity; jti: string; exp: number }> => {
|
|
191
|
+
const ids = inspectAppProof(proof, now);
|
|
192
|
+
const key = await getStoredAppKey(ids, db);
|
|
193
|
+
if (!key) throw invalid();
|
|
194
|
+
const inactive = key.status !== "active" || key.accountStatus !== "active";
|
|
195
|
+
let verified: Awaited<ReturnType<typeof verifyAppProof>>;
|
|
196
|
+
try {
|
|
197
|
+
verified = await verifyAppProof(proof, request, key, db, now);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// Inactive keys have always returned invalid_proof, regardless of their claims.
|
|
200
|
+
if (inactive) throw invalid();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
if (request.method !== "POST" || request.path !== BOOTSTRAP_PATH) throw invalid();
|
|
204
|
+
const reason =
|
|
205
|
+
key.status === "revoked"
|
|
206
|
+
? "revoked"
|
|
207
|
+
: key.status === "expired"
|
|
208
|
+
? "expired"
|
|
209
|
+
: key.accountStatus === "disabled"
|
|
210
|
+
? "disabled"
|
|
211
|
+
: !key.connectionsAllowed
|
|
212
|
+
? "connections_not_permitted"
|
|
213
|
+
: null;
|
|
214
|
+
if (reason) throw new RejectedBootstrapStart(verified.identity, verified.jti, reason);
|
|
215
|
+
const { identity, jti, exp } = verified;
|
|
216
|
+
return { identity, jti, exp };
|
|
217
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
2
|
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
3
3
|
import { getApp, listCapabilities } from "../_internal/registry";
|
|
4
4
|
import {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../contracts/app-details";
|
|
10
10
|
import { type NotificationDefinitionPresentationCatalog, resolveNotificationDefinitionPresentation } from "../contracts/notification-types";
|
|
11
11
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
12
|
+
import { sql } from "../runtime/clients";
|
|
12
13
|
import type { RequestAuthority } from "../server/middleware/auth";
|
|
13
14
|
import { resolveAppIdentityPresentation } from "../shared/app-presentation";
|
|
14
15
|
import { AppAccessManagementError, appAccessGrantCounts, canRepairAppAccess } from "./app-access-management";
|
|
@@ -48,8 +49,8 @@ type Dependencies = {
|
|
|
48
49
|
|
|
49
50
|
/** Core-owned administration read shared by SSR and HTTP; app access is not administrator authority. */
|
|
50
51
|
export function createAppDetailsService(dependencies: Dependencies = {}) {
|
|
51
|
-
const db = dependencies.db ?? sql;
|
|
52
52
|
return async (appId: string, options: { authority: RequestAuthority; locale?: string }): Promise<AppDetails | null> => {
|
|
53
|
+
const db = dependencies.db ?? sql;
|
|
53
54
|
if (!canRepairAppAccess(options.authority))
|
|
54
55
|
throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required.");
|
|
55
56
|
const locale = options.locale ?? "en";
|
|
@@ -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();
|
|
@@ -15,8 +15,8 @@ const Metadata = z.object({
|
|
|
15
15
|
version: Envelope.shape.version.catch(1),
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
export function createEventAcceptance(db: SQL, reconcile: () => Promise<void>) {
|
|
19
|
-
const markSeen = async (sequence: number, tx: SQL = db) => {
|
|
18
|
+
export function createEventAcceptance(db: () => SQL, reconcile: () => Promise<void>) {
|
|
19
|
+
const markSeen = async (sequence: number, tx: SQL = db()) => {
|
|
20
20
|
// Keep holes until replay or the periodic DLQ/retention check accounts for them.
|
|
21
21
|
// Advancing the high watermark alone would hide eviction before the next tick.
|
|
22
22
|
await tx`
|
|
@@ -36,7 +36,7 @@ export function createEventAcceptance(db: SQL, reconcile: () => Promise<void>) {
|
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
const { id, type, source, version } = metadata.data;
|
|
39
|
-
await db.begin(async (tx) => {
|
|
39
|
+
await db().begin(async (tx) => {
|
|
40
40
|
// Invalid or oversized input is represented by metadata only, never a second failing payload insert.
|
|
41
41
|
await tx`
|
|
42
42
|
INSERT INTO events.app_events(id, type, version, source, subject_key, envelope, state, code)
|
|
@@ -61,7 +61,7 @@ export function createEventAcceptance(db: SQL, reconcile: () => Promise<void>) {
|
|
|
61
61
|
} catch {
|
|
62
62
|
log.warn("Declaration refresh failed; accepting against persisted declarations", { sequence });
|
|
63
63
|
}
|
|
64
|
-
const [declaration] = await db<StoredDeclaration[]>`
|
|
64
|
+
const [declaration] = await db()<StoredDeclaration[]>`
|
|
65
65
|
SELECT * FROM events.app_declarations
|
|
66
66
|
WHERE app_id = ${envelope.source} AND type = ${envelope.type} AND version = ${envelope.version}
|
|
67
67
|
`;
|
|
@@ -85,7 +85,7 @@ export function createEventAcceptance(db: SQL, reconcile: () => Promise<void>) {
|
|
|
85
85
|
}
|
|
86
86
|
const subjectKey = `${envelope.subject.type}:${envelope.subject.id}`;
|
|
87
87
|
try {
|
|
88
|
-
await db.begin(async (tx) => {
|
|
88
|
+
await db().begin(async (tx) => {
|
|
89
89
|
const inserted = await tx`
|
|
90
90
|
INSERT INTO events.app_events(id, type, version, source, subject_key, envelope, state, code)
|
|
91
91
|
VALUES (${envelope.id}::uuid, ${envelope.type}, ${envelope.version}, ${envelope.source}, ${subjectKey},
|