@promptbook/cli 0.112.0-96 → 0.112.0-97

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 (62) hide show
  1. package/apps/agents-server/README.md +3 -3
  2. package/apps/agents-server/playwright.config.ts +2 -1
  3. package/apps/agents-server/src/app/admin/code-runners/CodeRunnersClient.tsx +358 -19
  4. package/apps/agents-server/src/app/admin/database/page.tsx +2 -1
  5. package/apps/agents-server/src/app/admin/servers/CreateServerDialog.tsx +46 -505
  6. package/apps/agents-server/src/app/admin/servers/ServersClient.tsx +23 -11
  7. package/apps/agents-server/src/app/admin/servers/ServersRegistryApi.ts +5 -0
  8. package/apps/agents-server/src/app/admin/servers/ServersRegistryDnsTypes.ts +87 -0
  9. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +258 -128
  10. package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +46 -334
  11. package/apps/agents-server/src/app/admin/servers/useServersRegistryState.ts +26 -2
  12. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +435 -0
  13. package/apps/agents-server/src/app/admin/update/page.tsx +14 -0
  14. package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +197 -0
  15. package/apps/agents-server/src/app/api/admin/code-runners/route.ts +4 -35
  16. package/apps/agents-server/src/app/api/admin/servers/[serverId]/route.ts +10 -5
  17. package/apps/agents-server/src/app/api/admin/servers/route.ts +97 -6
  18. package/apps/agents-server/src/app/api/admin/update/route.ts +52 -0
  19. package/apps/agents-server/src/app/api/auth/login/route.ts +8 -0
  20. package/apps/agents-server/src/app/api/auth/logout/route.ts +10 -2
  21. package/apps/agents-server/src/app/page.tsx +10 -0
  22. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +6 -0
  23. package/apps/agents-server/src/database/$provideClientSql.ts +4 -17
  24. package/apps/agents-server/src/database/$provideDatabaseAdminExecutor.ts +3 -24
  25. package/apps/agents-server/src/database/$providePostgresPool.ts +27 -0
  26. package/apps/agents-server/src/database/$provideSupabaseForServer.ts +11 -1
  27. package/apps/agents-server/src/database/agentsServerDatabaseMode.ts +20 -1
  28. package/apps/agents-server/src/database/postgres/$provideLocalPostgresSupabase.ts +1261 -0
  29. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +1 -0
  30. package/apps/agents-server/src/languages/translations/czech.yaml +1 -0
  31. package/apps/agents-server/src/languages/translations/english.yaml +1 -0
  32. package/apps/agents-server/src/middleware.ts +32 -0
  33. package/apps/agents-server/src/tools/$provideServer.ts +2 -2
  34. package/apps/agents-server/src/utils/codeRunnerAuthentication.ts +394 -0
  35. package/apps/agents-server/src/utils/codeRunnerConfiguration.ts +67 -0
  36. package/apps/agents-server/src/utils/serverManagement/standaloneVpsServerMetadata.ts +145 -0
  37. package/apps/agents-server/src/utils/serverRegistry.ts +7 -6
  38. package/apps/agents-server/src/utils/session.ts +37 -9
  39. package/apps/agents-server/src/utils/shibboleth/createShibbolethAuthenticationLogPayload.ts +173 -0
  40. package/apps/agents-server/src/utils/shibboleth/writeShibbolethAuthenticationLog.ts +27 -0
  41. package/apps/agents-server/src/utils/standaloneVpsDnsDiagnostics.ts +258 -0
  42. package/apps/agents-server/src/utils/standaloneVpsRawIpBootstrap.ts +87 -0
  43. package/apps/agents-server/src/utils/vpsConfiguration.ts +87 -15
  44. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +664 -0
  45. package/esm/apps/agents-server/src/database/agentsServerDatabaseMode.d.ts +9 -1
  46. package/esm/apps/agents-server/src/utils/serverRegistry.d.ts +1 -1
  47. package/esm/index.es.js +8 -6
  48. package/esm/index.es.js.map +1 -1
  49. package/esm/src/version.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/src/book-components/Chat/utils/renderMarkdown.ts +1 -3
  52. package/src/cli/cli-commands/agents-server/ensureAgentsServerEnvFile.ts +1 -1
  53. package/src/other/templates/getTemplatesPipelineCollection.ts +698 -755
  54. package/src/scrapers/document/DocumentScraper.ts +1 -1
  55. package/src/scrapers/document-legacy/LegacyDocumentScraper.ts +1 -1
  56. package/src/version.ts +2 -2
  57. package/src/versions.txt +1 -0
  58. package/umd/apps/agents-server/src/database/agentsServerDatabaseMode.d.ts +9 -1
  59. package/umd/apps/agents-server/src/utils/serverRegistry.d.ts +1 -1
  60. package/umd/index.umd.js +8 -6
  61. package/umd/index.umd.js.map +1 -1
  62. package/umd/src/version.d.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import { createClient, type SupabaseClient } from '@supabase/supabase-js';
2
2
  import { spaceTrim } from 'spacetrim';
3
3
  import { DatabaseError } from '../../../../src/errors/DatabaseError';
4
- import { isAgentsServerSqliteMode } from '../database/agentsServerDatabaseMode';
4
+ import { isAgentsServerStandaloneMode } from '../database/agentsServerDatabaseMode';
5
5
 
6
6
  /**
7
7
  * Supported `_Server.environment` values.
@@ -139,7 +139,7 @@ export async function listRegisteredServersUsingServiceRole(options?: {
139
139
  }): Promise<Array<ServerRecord>> {
140
140
  const environmentServers = listEnvironmentRegisteredServers();
141
141
 
142
- if (isAgentsServerSqliteMode()) {
142
+ if (isAgentsServerStandaloneMode()) {
143
143
  return environmentServers;
144
144
  }
145
145
 
@@ -171,13 +171,14 @@ export async function listRegisteredServersUsingServiceRole(options?: {
171
171
  /**
172
172
  * Loads virtual server records from the comma-separated `SERVERS` environment variable.
173
173
  *
174
- * @returns Server records with deterministic table prefixes derived from normalized domains.
174
+ * @returns Server records with deterministic table prefixes from the configured server prefix or normalized domains.
175
175
  */
176
176
  export function listEnvironmentRegisteredServers(): Array<ServerRecord> {
177
177
  const rawServers = process.env[SERVERS_ENV_NAME];
178
178
  if (!rawServers) {
179
179
  return [];
180
180
  }
181
+ const configuredTablePrefix = process.env.SUPABASE_TABLE_PREFIX?.trim() || '';
181
182
 
182
183
  const normalizedDomains = uniqueStrings(
183
184
  rawServers
@@ -191,7 +192,7 @@ export function listEnvironmentRegisteredServers(): Array<ServerRecord> {
191
192
  name: domain,
192
193
  environment: SERVER_ENVIRONMENT.PRODUCTION,
193
194
  domain,
194
- tablePrefix: buildEnvironmentServerTablePrefix(domain),
195
+ tablePrefix: configuredTablePrefix || buildEnvironmentServerTablePrefix(domain),
195
196
  createdAt: ENVIRONMENT_SERVER_TIMESTAMP,
196
197
  updatedAt: ENVIRONMENT_SERVER_TIMESTAMP,
197
198
  }));
@@ -349,10 +350,10 @@ export function isServerEnvironment(value: string): value is ServerEnvironment {
349
350
  * @returns Shared untyped Supabase client.
350
351
  */
351
352
  export function getServerRegistryClient(): SupabaseClient {
352
- if (isAgentsServerSqliteMode()) {
353
+ if (isAgentsServerStandaloneMode()) {
353
354
  throw new DatabaseError(
354
355
  spaceTrim(`
355
- Cannot create a Supabase server-registry client while Agents Server is using SQLite.
356
+ Cannot create a Supabase server-registry client while Agents Server is using a standalone database backend.
356
357
  `),
357
358
  );
358
359
  }
@@ -1,6 +1,8 @@
1
1
  import { createHmac } from 'crypto';
2
2
  import { cookies, headers } from 'next/headers';
3
3
  import { cache } from 'react';
4
+ import { isStandaloneVpsRawIpBootstrapActive } from './standaloneVpsRawIpBootstrap';
5
+ import { writeShibbolethAuthenticationLog } from './shibboleth/writeShibbolethAuthenticationLog';
4
6
 
5
7
  /**
6
8
  * Cookie name used to store the signed user session.
@@ -50,6 +52,10 @@ export type SessionCookieSecurityContext = {
50
52
  * Raw forwarded protocol header emitted by the reverse proxy.
51
53
  */
52
54
  readonly forwardedProto: string | null;
55
+ /**
56
+ * Canonical public site URL from `NEXT_PUBLIC_SITE_URL`.
57
+ */
58
+ readonly nextPublicSiteUrl: string | null | undefined;
53
59
  /**
54
60
  * Comma-separated configured domain list from `SERVERS`.
55
61
  */
@@ -121,7 +127,13 @@ export function shouldUseSecureSessionCookieForRequest(context: SessionCookieSec
121
127
  return false;
122
128
  }
123
129
 
124
- if (parseConfiguredServers(context.configuredServers).length > 0) {
130
+ if (
131
+ parseConfiguredServers(context.configuredServers).length > 0 &&
132
+ !isStandaloneVpsRawIpBootstrapActive({
133
+ nextPublicSiteUrl: context.nextPublicSiteUrl,
134
+ publicIpAddress: context.publicIpAddress,
135
+ })
136
+ ) {
125
137
  return true;
126
138
  }
127
139
 
@@ -150,9 +162,17 @@ export function shouldUseSecureSessionCookieForRequest(context: SessionCookieSec
150
162
  */
151
163
  export async function setSession(user: SessionUser) {
152
164
  const token = serializeSessionToken(user);
153
- const secure = await shouldUseSecureSessionCookie();
165
+ const headerStore = await headers();
166
+ const cookieStore = await cookies();
167
+ const secure = shouldUseSecureSessionCookieForHeaders(headerStore);
154
168
 
155
- (await cookies()).set(SESSION_COOKIE_NAME, token, {
169
+ writeShibbolethAuthenticationLog(headerStore, {
170
+ event: 'session-set',
171
+ hasSessionCookie: cookieStore.has(SESSION_COOKIE_NAME),
172
+ isSecureSessionCookie: secure,
173
+ });
174
+
175
+ cookieStore.set(SESSION_COOKIE_NAME, token, {
156
176
  httpOnly: true,
157
177
  secure,
158
178
  path: '/',
@@ -164,9 +184,17 @@ export async function setSession(user: SessionUser) {
164
184
  * Clears the current authenticated session cookie.
165
185
  */
166
186
  export async function clearSession() {
167
- (await cookies()).delete(SESSION_COOKIE_NAME);
187
+ const headerStore = await headers();
188
+ const cookieStore = await cookies();
189
+
190
+ writeShibbolethAuthenticationLog(headerStore, {
191
+ event: 'session-cleared',
192
+ hasSessionCookie: cookieStore.has(SESSION_COOKIE_NAME),
193
+ });
194
+
195
+ cookieStore.delete(SESSION_COOKIE_NAME);
168
196
  // Also clear legacy adminToken
169
- (await cookies()).delete('adminToken');
197
+ cookieStore.delete('adminToken');
170
198
  }
171
199
 
172
200
  /**
@@ -189,18 +217,18 @@ export async function getSession(): Promise<SessionUser | null> {
189
217
  }
190
218
 
191
219
  /**
192
- * Resolves the runtime cookie security decision from the current request headers.
220
+ * Resolves the runtime cookie security decision from one request header snapshot.
193
221
  *
222
+ * @param headerStore - Request headers from the active request.
194
223
  * @returns `true` when the session cookie should keep the `Secure` flag.
195
224
  */
196
- async function shouldUseSecureSessionCookie(): Promise<boolean> {
197
- const headerStore = await headers();
198
-
225
+ function shouldUseSecureSessionCookieForHeaders(headerStore: Pick<Headers, 'get'>): boolean {
199
226
  return shouldUseSecureSessionCookieForRequest({
200
227
  isProduction: process.env.NODE_ENV === 'production',
201
228
  host: headerStore.get('host'),
202
229
  forwardedHost: headerStore.get('x-forwarded-host'),
203
230
  forwardedProto: headerStore.get('x-forwarded-proto'),
231
+ nextPublicSiteUrl: process.env.NEXT_PUBLIC_SITE_URL,
204
232
  configuredServers: process.env.SERVERS,
205
233
  publicIpAddress: process.env.PTBK_PUBLIC_IP_ADDRESS,
206
234
  });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Minimal read-only header accessor shared by middleware, route handlers, and server utilities.
3
+ *
4
+ * @private Internal helper of the Shibboleth authentication diagnostics.
5
+ */
6
+ export type ReadonlyHeadersLike = Pick<Headers, 'get'>;
7
+
8
+ /**
9
+ * One resolved Shibboleth-related attribute included in the diagnostic payload.
10
+ *
11
+ * @private Internal helper of the Shibboleth authentication diagnostics.
12
+ */
13
+ export type ShibbolethAuthenticationLogAttribute = {
14
+ readonly fieldName: string;
15
+ readonly headerName: string;
16
+ readonly fingerprint: string;
17
+ readonly valueLength: number;
18
+ };
19
+
20
+ /**
21
+ * Structured, privacy-preserving diagnostic payload for one Shibboleth-related request event.
22
+ *
23
+ * @private Internal helper of the Agents Server authentication diagnostics.
24
+ */
25
+ export type ShibbolethAuthenticationLogPayload = {
26
+ readonly event: string;
27
+ readonly pathname?: string;
28
+ readonly method?: string;
29
+ readonly hasSessionCookie?: boolean;
30
+ readonly isSecureSessionCookie?: boolean;
31
+ readonly headerNames: ReadonlyArray<string>;
32
+ readonly attributeFields: ReadonlyArray<string>;
33
+ readonly attributes: ReadonlyArray<ShibbolethAuthenticationLogAttribute>;
34
+ };
35
+
36
+ /**
37
+ * Context describing where the Shibboleth diagnostic event happened.
38
+ *
39
+ * @private Internal helper of the Shibboleth authentication diagnostics.
40
+ */
41
+ export type CreateShibbolethAuthenticationLogPayloadOptions = {
42
+ readonly event: string;
43
+ readonly pathname?: string;
44
+ readonly method?: string;
45
+ readonly hasSessionCookie?: boolean;
46
+ readonly isSecureSessionCookie?: boolean;
47
+ };
48
+
49
+ type ShibbolethHeaderDefinition = {
50
+ readonly fieldName: string;
51
+ readonly headerNames: ReadonlyArray<string>;
52
+ };
53
+
54
+ const SHIBBOLETH_HEADER_DEFINITIONS: ReadonlyArray<ShibbolethHeaderDefinition> = [
55
+ { fieldName: 'sessionId', headerNames: ['shib-session-id', 'x-shib-session-id'] },
56
+ { fieldName: 'sessionIndex', headerNames: ['shib-session-index', 'x-shib-session-index'] },
57
+ { fieldName: 'sessionExpires', headerNames: ['shib-session-expires', 'x-shib-session-expires'] },
58
+ { fieldName: 'applicationId', headerNames: ['shib-application-id', 'x-shib-application-id'] },
59
+ { fieldName: 'remoteUser', headerNames: ['remote-user', 'x-remote-user'] },
60
+ { fieldName: 'displayName', headerNames: ['displayname', 'x-displayname'] },
61
+ { fieldName: 'mail', headerNames: ['mail', 'x-mail'] },
62
+ { fieldName: 'unstructuredName', headerNames: ['unstructuredname', 'x-unstructuredname'] },
63
+ {
64
+ fieldName: 'eduPersonPrincipalName',
65
+ headerNames: ['edupersonprincipalname', 'eppn', 'x-edupersonprincipalname', 'x-eppn'],
66
+ },
67
+ { fieldName: 'persistentId', headerNames: ['persistent-id', 'x-persistent-id'] },
68
+ { fieldName: 'nameId', headerNames: ['name-id', 'x-name-id'] },
69
+ ];
70
+
71
+ /**
72
+ * Builds a privacy-preserving Shibboleth diagnostic payload from incoming request headers.
73
+ *
74
+ * Only the presence of relevant headers plus short fingerprints of their values are logged,
75
+ * never the raw personally identifiable header contents.
76
+ *
77
+ * @param headers - Request headers or another read-only header accessor.
78
+ * @param options - Event metadata describing where the diagnostic event originated.
79
+ * @returns Structured log payload, or `null` when the request does not look Shibboleth-related.
80
+ *
81
+ * @private Internal helper of the Agents Server authentication diagnostics.
82
+ */
83
+ export function createShibbolethAuthenticationLogPayload(
84
+ headers: ReadonlyHeadersLike,
85
+ options: CreateShibbolethAuthenticationLogPayloadOptions,
86
+ ): ShibbolethAuthenticationLogPayload | null {
87
+ const attributes = SHIBBOLETH_HEADER_DEFINITIONS.map((definition) => resolveShibbolethHeader(headers, definition)).filter(
88
+ (attribute): attribute is ShibbolethAuthenticationLogAttribute => attribute !== null,
89
+ );
90
+
91
+ if (attributes.length === 0) {
92
+ return null;
93
+ }
94
+
95
+ return {
96
+ event: options.event,
97
+ pathname: options.pathname,
98
+ method: options.method,
99
+ hasSessionCookie: options.hasSessionCookie,
100
+ isSecureSessionCookie: options.isSecureSessionCookie,
101
+ headerNames: attributes.map(({ headerName }) => headerName),
102
+ attributeFields: attributes.map(({ fieldName }) => fieldName),
103
+ attributes,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Resolves one Shibboleth attribute from any of its expected header aliases.
109
+ *
110
+ * @param headers - Request header accessor.
111
+ * @param definition - Logical Shibboleth field with supported header aliases.
112
+ * @returns Sanitized attribute snapshot or `null` when absent.
113
+ */
114
+ function resolveShibbolethHeader(
115
+ headers: ReadonlyHeadersLike,
116
+ definition: ShibbolethHeaderDefinition,
117
+ ): ShibbolethAuthenticationLogAttribute | null {
118
+ for (const headerName of definition.headerNames) {
119
+ const rawValue = headers.get(headerName);
120
+ const value = normalizeHeaderValue(rawValue);
121
+
122
+ if (!value) {
123
+ continue;
124
+ }
125
+
126
+ return {
127
+ fieldName: definition.fieldName,
128
+ headerName,
129
+ fingerprint: createHeaderValueFingerprint(value),
130
+ valueLength: value.length,
131
+ };
132
+ }
133
+
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Normalizes one header value before diagnostic processing.
139
+ *
140
+ * @param value - Raw header value.
141
+ * @returns Trimmed non-empty value or `null`.
142
+ */
143
+ function normalizeHeaderValue(value: string | null): string | null {
144
+ const normalizedValue = value?.trim() || '';
145
+ return normalizedValue === '' ? null : normalizedValue;
146
+ }
147
+
148
+ /**
149
+ * Creates a short stable fingerprint so logs can correlate the same identity/session
150
+ * without storing the raw Shibboleth attribute value.
151
+ *
152
+ * @param value - Raw Shibboleth header value.
153
+ * @returns Short deterministic fingerprint.
154
+ */
155
+ function createHeaderValueFingerprint(value: string): string {
156
+ let hashPrimary = 0xdeadbeef ^ value.length;
157
+ let hashSecondary = 0x41c6ce57 ^ value.length;
158
+
159
+ for (const character of value) {
160
+ const codePoint = character.codePointAt(0) || 0;
161
+ hashPrimary = Math.imul(hashPrimary ^ codePoint, 2654435761);
162
+ hashSecondary = Math.imul(hashSecondary ^ codePoint, 1597334677);
163
+ }
164
+
165
+ hashPrimary =
166
+ Math.imul(hashPrimary ^ (hashPrimary >>> 16), 2246822507) ^ Math.imul(hashSecondary ^ (hashSecondary >>> 13), 3266489909);
167
+ hashSecondary =
168
+ Math.imul(hashSecondary ^ (hashSecondary >>> 16), 2246822507) ^
169
+ Math.imul(hashPrimary ^ (hashPrimary >>> 13), 3266489909);
170
+
171
+ const combinedHash = 4294967296 * (2097151 & hashSecondary) + (hashPrimary >>> 0);
172
+ return combinedHash.toString(16).padStart(14, '0').slice(0, 12);
173
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ createShibbolethAuthenticationLogPayload,
3
+ type CreateShibbolethAuthenticationLogPayloadOptions,
4
+ type ReadonlyHeadersLike,
5
+ } from './createShibbolethAuthenticationLogPayload';
6
+
7
+ /**
8
+ * Writes one sanitized Shibboleth diagnostic event to the application logs when
9
+ * the current request carries Shibboleth-related headers.
10
+ *
11
+ * @param headers - Request headers or another read-only header accessor.
12
+ * @param options - Event metadata describing where the diagnostic event originated.
13
+ *
14
+ * @private Internal helper of the Agents Server authentication diagnostics.
15
+ */
16
+ export function writeShibbolethAuthenticationLog(
17
+ headers: ReadonlyHeadersLike,
18
+ options: CreateShibbolethAuthenticationLogPayloadOptions,
19
+ ): void {
20
+ const payload = createShibbolethAuthenticationLogPayload(headers, options);
21
+
22
+ if (!payload) {
23
+ return;
24
+ }
25
+
26
+ console.info(`[auth][shibboleth] ${JSON.stringify(payload)}`);
27
+ }
@@ -0,0 +1,258 @@
1
+ import { lookup } from 'dns/promises';
2
+ import type {
3
+ ManagedServerDnsDiagnostic,
4
+ ManagedServerDnsExpectedRecord,
5
+ ManagedServerDnsProviderGuide,
6
+ ManagedServerDnsStatus,
7
+ } from '../app/admin/servers/ServersRegistryDnsTypes';
8
+
9
+ /**
10
+ * Resolver signature used for DNS lookups.
11
+ */
12
+ type ResolveDnsAddresses = (domain: string) => Promise<ReadonlyArray<string>>;
13
+
14
+ /**
15
+ * Input used to create one standalone VPS DNS diagnostic.
16
+ */
17
+ type CreateStandaloneVpsDomainDnsDiagnosticOptions = {
18
+ /**
19
+ * Domain currently configured in the standalone VPS server registry.
20
+ */
21
+ readonly domain: string;
22
+
23
+ /**
24
+ * Public IP address detected/stored for the VPS runtime.
25
+ */
26
+ readonly publicIpAddress: string | null | undefined;
27
+
28
+ /**
29
+ * Optional already-working hostname that subdomains may target via CNAME.
30
+ */
31
+ readonly fallbackCnameTargetDomain?: string | null | undefined;
32
+
33
+ /**
34
+ * Optional resolver override used by unit tests.
35
+ */
36
+ readonly resolveDnsAddresses?: ResolveDnsAddresses;
37
+ };
38
+
39
+ /**
40
+ * Provider guides shown together with DNS record instructions.
41
+ */
42
+ const DNS_PROVIDER_GUIDES: ReadonlyArray<ManagedServerDnsProviderGuide> = [
43
+ {
44
+ label: 'Cloudflare DNS records',
45
+ href: 'https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/',
46
+ },
47
+ {
48
+ label: 'GoDaddy DNS records',
49
+ href: 'https://www.godaddy.com/help/manage-dns-records-680',
50
+ },
51
+ {
52
+ label: 'Namecheap DNS records',
53
+ href: 'https://www.namecheap.com/support/knowledgebase/article.aspx/319/2237/how-can-i-set-up-an-a-address-record-for-my-domain/',
54
+ },
55
+ {
56
+ label: 'Squarespace DNS records',
57
+ href: 'https://support.squarespace.com/hc/en-us/articles/360002101888-Adding-custom-DNS-records-to-your-Squarespace-managed-domain',
58
+ },
59
+ ];
60
+
61
+ /**
62
+ * DNS error codes treated as "not resolving yet" during propagation.
63
+ */
64
+ const DNS_PENDING_ERROR_CODES = new Set(['ENOTFOUND', 'ENODATA', 'EAI_AGAIN', 'ESERVFAIL']);
65
+
66
+ /**
67
+ * Creates the browser-safe DNS status for one standalone VPS domain.
68
+ *
69
+ * @param options - Domain, VPS IP, and optional resolver override.
70
+ * @returns DNS diagnostic rendered on `/admin/servers`.
71
+ */
72
+ export async function createStandaloneVpsDomainDnsDiagnostic(
73
+ options: CreateStandaloneVpsDomainDnsDiagnosticOptions,
74
+ ): Promise<ManagedServerDnsDiagnostic> {
75
+ const publicIpAddress = normalizePublicIpAddress(options.publicIpAddress);
76
+ const expectedRecords = createExpectedDnsRecords({
77
+ domain: options.domain,
78
+ publicIpAddress,
79
+ fallbackCnameTargetDomain: options.fallbackCnameTargetDomain,
80
+ });
81
+
82
+ if (!publicIpAddress) {
83
+ return createDnsDiagnostic({
84
+ expectedRecords,
85
+ publicIpAddress: null,
86
+ resolvedAddresses: [],
87
+ status: 'unavailable',
88
+ summary: 'The VPS public IP address is not available yet, so DNS cannot be verified automatically.',
89
+ });
90
+ }
91
+
92
+ try {
93
+ const resolvedAddresses = uniqueStrings(
94
+ await (options.resolveDnsAddresses || resolveDnsAddresses)(options.domain),
95
+ );
96
+
97
+ if (resolvedAddresses.includes(publicIpAddress)) {
98
+ return createDnsDiagnostic({
99
+ expectedRecords,
100
+ publicIpAddress,
101
+ resolvedAddresses,
102
+ status: 'verified',
103
+ summary: `DNS is ready. \`${options.domain}\` resolves to this VPS.`,
104
+ });
105
+ }
106
+
107
+ if (resolvedAddresses.length === 0) {
108
+ return createDnsDiagnostic({
109
+ expectedRecords,
110
+ publicIpAddress,
111
+ resolvedAddresses,
112
+ status: 'pending',
113
+ summary: `\`${options.domain}\` does not resolve yet. Add the record below and wait for DNS propagation.`,
114
+ });
115
+ }
116
+
117
+ return createDnsDiagnostic({
118
+ expectedRecords,
119
+ publicIpAddress,
120
+ resolvedAddresses,
121
+ status: 'misconfigured',
122
+ summary: `\`${options.domain}\` currently resolves to ${resolvedAddresses.join(', ')}, not to this VPS IP \`${publicIpAddress}\`.`,
123
+ });
124
+ } catch (error) {
125
+ return createDnsDiagnostic({
126
+ expectedRecords,
127
+ publicIpAddress,
128
+ resolvedAddresses: [],
129
+ status: 'unavailable',
130
+ summary: `DNS verification failed: ${error instanceof Error ? error.message : 'Unknown DNS lookup error.'}`,
131
+ });
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Resolves one hostname to all currently known IP addresses.
137
+ *
138
+ * @param domain - Domain to resolve.
139
+ * @returns Unique list of resolved IP addresses.
140
+ */
141
+ async function resolveDnsAddresses(domain: string): Promise<ReadonlyArray<string>> {
142
+ try {
143
+ return (await lookup(domain, { all: true, verbatim: true })).map((record) => record.address);
144
+ } catch (error) {
145
+ const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : null;
146
+
147
+ if (code && DNS_PENDING_ERROR_CODES.has(code)) {
148
+ return [];
149
+ }
150
+
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Creates the expected DNS records shown in the admin UI.
157
+ *
158
+ * @param options - Record inputs.
159
+ * @returns DNS records the admin can copy into their provider.
160
+ */
161
+ function createExpectedDnsRecords(options: {
162
+ readonly domain: string;
163
+ readonly publicIpAddress: string | null;
164
+ readonly fallbackCnameTargetDomain?: string | null | undefined;
165
+ }): ReadonlyArray<ManagedServerDnsExpectedRecord> {
166
+ const expectedRecords: Array<ManagedServerDnsExpectedRecord> = [];
167
+
168
+ if (options.publicIpAddress) {
169
+ expectedRecords.push({
170
+ type: isIpv6Address(options.publicIpAddress) ? 'AAAA' : 'A',
171
+ name: options.domain,
172
+ value: options.publicIpAddress,
173
+ note: 'Recommended. Point this hostname directly to the VPS public IP address.',
174
+ });
175
+ }
176
+
177
+ if (options.fallbackCnameTargetDomain && options.fallbackCnameTargetDomain !== options.domain) {
178
+ expectedRecords.push({
179
+ type: 'CNAME',
180
+ name: options.domain,
181
+ value: options.fallbackCnameTargetDomain,
182
+ note: `Optional alternative for subdomains only. Use this only when \`${options.fallbackCnameTargetDomain}\` already works on this VPS.`,
183
+ });
184
+ }
185
+
186
+ return expectedRecords;
187
+ }
188
+
189
+ /**
190
+ * Normalizes the stored VPS public IP address.
191
+ *
192
+ * @param value - Raw environment/config value.
193
+ * @returns Normalized IP address or `null` when unavailable.
194
+ */
195
+ function normalizePublicIpAddress(value: string | null | undefined): string | null {
196
+ const normalizedValue = (value || '').trim();
197
+
198
+ if (!normalizedValue || normalizedValue === 'localhost') {
199
+ return null;
200
+ }
201
+
202
+ return normalizedValue;
203
+ }
204
+
205
+ /**
206
+ * Creates one consistent browser payload for the DNS diagnostic.
207
+ *
208
+ * @param options - Normalized DNS state.
209
+ * @returns Browser-safe DNS payload.
210
+ */
211
+ function createDnsDiagnostic(options: {
212
+ readonly expectedRecords: ReadonlyArray<ManagedServerDnsExpectedRecord>;
213
+ readonly publicIpAddress: string | null;
214
+ readonly resolvedAddresses: ReadonlyArray<string>;
215
+ readonly status: ManagedServerDnsStatus;
216
+ readonly summary: string;
217
+ }): ManagedServerDnsDiagnostic {
218
+ return {
219
+ status: options.status,
220
+ summary: options.summary,
221
+ publicIpAddress: options.publicIpAddress,
222
+ resolvedAddresses: options.resolvedAddresses,
223
+ expectedRecords: options.expectedRecords,
224
+ providerGuides: DNS_PROVIDER_GUIDES,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Detects whether one resolved address is IPv6.
230
+ *
231
+ * @param value - Candidate IP address.
232
+ * @returns `true` when the address looks like IPv6.
233
+ */
234
+ function isIpv6Address(value: string): boolean {
235
+ return value.includes(':');
236
+ }
237
+
238
+ /**
239
+ * Removes empty values and duplicates while preserving order.
240
+ *
241
+ * @param values - Candidate values.
242
+ * @returns Stable unique list.
243
+ */
244
+ function uniqueStrings(values: ReadonlyArray<string>): Array<string> {
245
+ const uniqueValues: Array<string> = [];
246
+
247
+ for (const value of values) {
248
+ const normalizedValue = value.trim();
249
+
250
+ if (!normalizedValue || uniqueValues.includes(normalizedValue)) {
251
+ continue;
252
+ }
253
+
254
+ uniqueValues.push(normalizedValue);
255
+ }
256
+
257
+ return uniqueValues;
258
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Request-independent context used to decide whether standalone VPS setup should
3
+ * continue allowing raw-IP bootstrap access.
4
+ */
5
+ export type StandaloneVpsRawIpBootstrapContext = {
6
+ /**
7
+ * Current canonical public site URL stored in the environment.
8
+ */
9
+ readonly nextPublicSiteUrl: string | null | undefined;
10
+
11
+ /**
12
+ * Known public IPv4/IPv6 address of the standalone VPS.
13
+ */
14
+ readonly publicIpAddress: string | null | undefined;
15
+ };
16
+
17
+ /**
18
+ * Returns whether the standalone VPS should keep allowing raw-IP bootstrap access.
19
+ *
20
+ * This stays enabled while the canonical public site URL still points to the VPS
21
+ * raw IP over plain HTTP, which means domain activation has not completed yet.
22
+ */
23
+ export function isStandaloneVpsRawIpBootstrapActive(context: StandaloneVpsRawIpBootstrapContext): boolean {
24
+ const parsedPublicSiteUrl = parseAbsoluteHttpUrl(context.nextPublicSiteUrl);
25
+ if (!parsedPublicSiteUrl || parsedPublicSiteUrl.protocol !== 'http:') {
26
+ return false;
27
+ }
28
+
29
+ const normalizedSiteHost = normalizeHost(parsedPublicSiteUrl.host);
30
+ if (!normalizedSiteHost || !isIpAddressHost(normalizedSiteHost)) {
31
+ return false;
32
+ }
33
+
34
+ const normalizedConfiguredPublicIpAddress = normalizeHost(context.publicIpAddress || '');
35
+ if (normalizedConfiguredPublicIpAddress && normalizedSiteHost !== normalizedConfiguredPublicIpAddress) {
36
+ return false;
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Parses one absolute HTTP(S) URL from environment configuration.
44
+ *
45
+ * @param value - Raw environment value.
46
+ * @returns Parsed URL or `null` when missing/invalid.
47
+ */
48
+ function parseAbsoluteHttpUrl(value: string | null | undefined): URL | null {
49
+ if (!value) {
50
+ return null;
51
+ }
52
+
53
+ try {
54
+ const parsedUrl = new URL(value);
55
+
56
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
57
+ return null;
58
+ }
59
+
60
+ return parsedUrl;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Removes ports and IPv6 brackets from host-like strings.
68
+ *
69
+ * @param host - Raw host header value.
70
+ * @returns Normalized bare hostname or IP address.
71
+ */
72
+ function normalizeHost(host: string | null | undefined): string {
73
+ return (host || '')
74
+ .trim()
75
+ .replace(/^\[(.+)\](?::\d+)?$/u, '$1')
76
+ .replace(/:\d+$/u, '');
77
+ }
78
+
79
+ /**
80
+ * Checks whether a host string points to a raw IPv4 or IPv6 address.
81
+ *
82
+ * @param host - Host header or hostname.
83
+ * @returns `true` when the host is a raw IP address.
84
+ */
85
+ function isIpAddressHost(host: string): boolean {
86
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(host) || host.includes(':');
87
+ }