@promptbook/cli 0.112.0-93 → 0.112.0-95
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/apps/agents-server/src/app/admin/_components/AdminConfigurationShell.tsx +13 -7
- package/apps/agents-server/src/app/admin/code-runners/CodeRunnersClient.tsx +225 -0
- package/apps/agents-server/src/app/admin/code-runners/page.tsx +14 -0
- package/apps/agents-server/src/app/admin/environment/EnvironmentVariablesClient.tsx +259 -0
- package/apps/agents-server/src/app/admin/environment/page.tsx +21 -0
- package/apps/agents-server/src/app/admin/logs/LogsClient.tsx +78 -0
- package/apps/agents-server/src/app/admin/logs/page.tsx +14 -0
- package/apps/agents-server/src/app/admin/servers/ServersClient.tsx +64 -33
- package/apps/agents-server/src/app/admin/servers/ServersRegistryApi.ts +5 -0
- package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +15 -2
- package/apps/agents-server/src/app/admin/servers/page.tsx +3 -3
- package/apps/agents-server/src/app/admin/servers/useServersRegistryState.ts +12 -2
- package/apps/agents-server/src/app/api/admin/code-runners/route.ts +104 -0
- package/apps/agents-server/src/app/api/admin/environment/route.ts +65 -0
- package/apps/agents-server/src/app/api/admin/logs/route.ts +24 -0
- package/apps/agents-server/src/app/api/admin/servers/[serverId]/route.ts +79 -3
- package/apps/agents-server/src/app/api/admin/servers/route.ts +36 -1
- package/apps/agents-server/src/app/page.tsx +101 -1
- package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +23 -4
- package/apps/agents-server/src/languages/ServerTranslationKeys.ts +4 -0
- package/apps/agents-server/src/languages/translations/czech.yaml +4 -0
- package/apps/agents-server/src/languages/translations/english.yaml +4 -0
- package/apps/agents-server/src/tools/$provideServer.ts +27 -0
- package/apps/agents-server/src/utils/serverRegistry.ts +20 -1
- package/apps/agents-server/src/utils/session.ts +123 -2
- package/apps/agents-server/src/utils/vpsConfiguration.ts +550 -0
- package/esm/index.es.js +1 -1
- package/esm/index.es.js.map +1 -1
- package/esm/src/book-components/Chat/utils/renderMarkdown.test.d.ts +1 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +2 -1
- package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +9 -398
- package/src/book-components/Chat/utils/renderMarkdown.ts +323 -8
- package/src/other/templates/getTemplatesPipelineCollection.ts +683 -879
- package/src/version.ts +2 -2
- package/src/versions.txt +2 -0
- package/umd/index.umd.js +1 -1
- package/umd/index.umd.js.map +1 -1
- package/umd/src/book-components/Chat/utils/renderMarkdown.test.d.ts +1 -0
- package/umd/src/version.d.ts +1 -1
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
+
import { isAgentsServerSqliteMode } from '../../../../database/agentsServerDatabaseMode';
|
|
2
3
|
import { resolveCurrentServerRegistryContext } from '../../../../utils/currentServerRegistryContext';
|
|
4
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
3
5
|
import { isUserGlobalAdmin } from '../../../../utils/isUserGlobalAdmin';
|
|
6
|
+
import {
|
|
7
|
+
createServerPublicUrl,
|
|
8
|
+
listEnvironmentRegisteredServers,
|
|
9
|
+
normalizeServerDomain,
|
|
10
|
+
} from '../../../../utils/serverRegistry';
|
|
4
11
|
import {
|
|
5
12
|
assertGlobalAdminAccess,
|
|
6
13
|
createManagedServer,
|
|
7
14
|
resolveManagedServerErrorStatus,
|
|
8
15
|
type CreateServerInput,
|
|
9
16
|
} from '../../../../utils/serverManagement';
|
|
17
|
+
import {
|
|
18
|
+
applyVpsRuntimeConfiguration,
|
|
19
|
+
listConfiguredVpsDomains,
|
|
20
|
+
updateConfiguredVpsDomains,
|
|
21
|
+
} from '../../../../utils/vpsConfiguration';
|
|
10
22
|
|
|
11
23
|
/**
|
|
12
24
|
* Lists all registered servers together with the server resolved from the current domain.
|
|
@@ -15,12 +27,15 @@ import {
|
|
|
15
27
|
*/
|
|
16
28
|
export async function GET() {
|
|
17
29
|
try {
|
|
18
|
-
|
|
30
|
+
if (!(await isUserAdmin())) {
|
|
31
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
32
|
+
}
|
|
19
33
|
|
|
20
34
|
const context = await resolveCurrentServerRegistryContext();
|
|
21
35
|
return NextResponse.json({
|
|
22
36
|
servers: context.registeredServers,
|
|
23
37
|
currentServerId: context.currentServer?.id ?? null,
|
|
38
|
+
canEdit: await isUserGlobalAdmin(),
|
|
24
39
|
});
|
|
25
40
|
} catch (error) {
|
|
26
41
|
return NextResponse.json(
|
|
@@ -43,6 +58,26 @@ export async function POST(request: Request) {
|
|
|
43
58
|
assertGlobalAdminAccess(await isUserGlobalAdmin());
|
|
44
59
|
|
|
45
60
|
const body = (await request.json()) as CreateServerInput;
|
|
61
|
+
if (isAgentsServerSqliteMode()) {
|
|
62
|
+
const normalizedDomain = normalizeServerDomain(body.domain);
|
|
63
|
+
if (!normalizedDomain) {
|
|
64
|
+
return NextResponse.json({ error: 'A valid domain is required.' }, { status: 400 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const existingDomains = await listConfiguredVpsDomains();
|
|
68
|
+
await updateConfiguredVpsDomains([...existingDomains, normalizedDomain]);
|
|
69
|
+
await applyVpsRuntimeConfiguration();
|
|
70
|
+
const createdServer = listEnvironmentRegisteredServers().find((server) => server.domain === normalizedDomain);
|
|
71
|
+
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{
|
|
74
|
+
server: createdServer ?? null,
|
|
75
|
+
publicUrl: createServerPublicUrl(normalizedDomain).href,
|
|
76
|
+
},
|
|
77
|
+
{ status: 201 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
const result = await createManagedServer(body);
|
|
47
82
|
|
|
48
83
|
if (!result.ok) {
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import { headers } from 'next/headers';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { redirect } from 'next/navigation';
|
|
2
4
|
import { $sideEffect } from '../../../../src/utils/organization/$sideEffect';
|
|
5
|
+
import { ForbiddenPage } from '../components/ForbiddenPage/ForbiddenPage';
|
|
3
6
|
import { HomepagePrimarySections } from '../components/Homepage/HomepagePrimarySections';
|
|
4
7
|
import { $provideServer } from '../tools/$provideServer';
|
|
5
8
|
import { isUserAdmin } from '../utils/isUserAdmin';
|
|
9
|
+
import { isUserGlobalAdmin } from '../utils/isUserGlobalAdmin';
|
|
10
|
+
import {
|
|
11
|
+
createServerPublicUrl,
|
|
12
|
+
listRegisteredServersUsingServiceRole,
|
|
13
|
+
resolveRegisteredServerByHost,
|
|
14
|
+
type ServerRecord,
|
|
15
|
+
} from '../utils/serverRegistry';
|
|
6
16
|
import { getHomePageAgents } from './_data/getHomePageAgents';
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Renders the simplified agents home page with local and federated agents.
|
|
10
20
|
*/
|
|
11
21
|
export default async function HomePage() {
|
|
12
|
-
|
|
22
|
+
const headerStore = await headers();
|
|
23
|
+
$sideEffect(/* Note: [??] This will ensure dynamic rendering of page and avoid Next.js pre-render */ headerStore);
|
|
24
|
+
|
|
25
|
+
const ipAddressRouting = await resolveIpAddressRouting(headerStore.get('host'));
|
|
26
|
+
if (ipAddressRouting === 'LOGIN') {
|
|
27
|
+
return <ForbiddenPage />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (ipAddressRouting === 'CONFIGURE') {
|
|
31
|
+
redirect('/admin/servers?setup=1');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(ipAddressRouting)) {
|
|
35
|
+
return <IpAddressDomainChooser servers={ipAddressRouting} />;
|
|
36
|
+
}
|
|
13
37
|
|
|
14
38
|
const [{ publicUrl }, isAdmin, { agents, folders, homepageMessage, currentUser }] = await Promise.all([
|
|
15
39
|
$provideServer(),
|
|
@@ -32,3 +56,79 @@ export default async function HomePage() {
|
|
|
32
56
|
</div>
|
|
33
57
|
);
|
|
34
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves special raw-IP behavior for standalone VPS access.
|
|
62
|
+
*
|
|
63
|
+
* @param host - Request host header.
|
|
64
|
+
* @returns Routing instruction or `null` when normal homepage rendering should continue.
|
|
65
|
+
*/
|
|
66
|
+
async function resolveIpAddressRouting(host: string | null): Promise<'LOGIN' | 'CONFIGURE' | ReadonlyArray<ServerRecord> | null> {
|
|
67
|
+
if (!host || !isIpAddressHost(host)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const registeredServers = await listRegisteredServersUsingServiceRole().catch(() => []);
|
|
72
|
+
if (resolveRegisteredServerByHost(host, registeredServers)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (registeredServers.length === 0) {
|
|
77
|
+
return (await isUserGlobalAdmin()) ? 'CONFIGURE' : 'LOGIN';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (registeredServers.length === 1) {
|
|
81
|
+
redirect(createServerPublicUrl(registeredServers[0]!.domain).href);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return registeredServers;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Checks whether the request host is a raw IPv4 or IPv6 address.
|
|
89
|
+
*
|
|
90
|
+
* @param host - Raw host header.
|
|
91
|
+
* @returns `true` for IP-address access.
|
|
92
|
+
*/
|
|
93
|
+
function isIpAddressHost(host: string): boolean {
|
|
94
|
+
const hostname = host
|
|
95
|
+
.trim()
|
|
96
|
+
.replace(/^\[(.+)\](?::\d+)?$/u, '$1')
|
|
97
|
+
.replace(/:\d+$/u, '');
|
|
98
|
+
|
|
99
|
+
return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname) || hostname.includes(':');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Simple domain chooser shown when a raw IP has multiple configured domains.
|
|
104
|
+
*
|
|
105
|
+
* @param props - Configured server list.
|
|
106
|
+
*/
|
|
107
|
+
function IpAddressDomainChooser(props: { readonly servers: ReadonlyArray<ServerRecord> }) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="min-h-screen bg-slate-50 px-4 py-24">
|
|
110
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
111
|
+
<div>
|
|
112
|
+
<h1 className="text-3xl font-light text-slate-900">Choose a domain</h1>
|
|
113
|
+
<p className="mt-2 text-sm text-slate-600">
|
|
114
|
+
This VPS has multiple Agents Server domains configured. Open the domain you want to use.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="grid gap-3">
|
|
118
|
+
{props.servers.map((server) => {
|
|
119
|
+
const url = createServerPublicUrl(server.domain).href;
|
|
120
|
+
return (
|
|
121
|
+
<Link
|
|
122
|
+
key={server.id}
|
|
123
|
+
href={url}
|
|
124
|
+
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-800 shadow-sm transition hover:border-blue-300 hover:text-blue-700"
|
|
125
|
+
>
|
|
126
|
+
{server.domain}
|
|
127
|
+
</Link>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -28,6 +28,7 @@ type HeaderTranslate = (key: ServerTranslationKey, variables?: Readonly<Record<s
|
|
|
28
28
|
type SystemCategoryLabel =
|
|
29
29
|
| 'My Account'
|
|
30
30
|
| 'Utilities'
|
|
31
|
+
| 'Super Admin'
|
|
31
32
|
| 'Administration'
|
|
32
33
|
| 'Monitoring & Usage'
|
|
33
34
|
| 'Integrations & Keys'
|
|
@@ -54,6 +55,7 @@ type BuildHeaderSystemMenuItemsOptions = {
|
|
|
54
55
|
const SYSTEM_CATEGORY_ICON_MAP: Record<SystemCategoryLabel, LucideIcon> = {
|
|
55
56
|
'My Account': UserRound,
|
|
56
57
|
Utilities: Wrench,
|
|
58
|
+
'Super Admin': Settings2,
|
|
57
59
|
Administration: Settings2,
|
|
58
60
|
'Monitoring & Usage': BarChart3,
|
|
59
61
|
'Integrations & Keys': KeyRound,
|
|
@@ -67,6 +69,7 @@ const SYSTEM_CATEGORY_ICON_MAP: Record<SystemCategoryLabel, LucideIcon> = {
|
|
|
67
69
|
const SYSTEM_CATEGORY_TRANSLATION_KEY_MAP: Record<SystemCategoryLabel, ServerTranslationKey> = {
|
|
68
70
|
'My Account': 'header.myAccount',
|
|
69
71
|
Utilities: 'header.utilities',
|
|
72
|
+
'Super Admin': 'header.superAdmin',
|
|
70
73
|
Administration: 'header.administration',
|
|
71
74
|
'Monitoring & Usage': 'header.monitoringAndUsage',
|
|
72
75
|
'Integrations & Keys': 'header.integrationsAndKeys',
|
|
@@ -187,16 +190,31 @@ export function buildHeaderSystemMenuItems({
|
|
|
187
190
|
];
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
const
|
|
193
|
+
const superAdminSystemItems: SubMenuItem[] = [
|
|
194
|
+
{
|
|
195
|
+
label: translate('header.servers'),
|
|
196
|
+
href: '/admin/servers',
|
|
197
|
+
isBold: true,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
label: translate('header.environmentVariables'),
|
|
201
|
+
href: '/admin/environment',
|
|
202
|
+
},
|
|
191
203
|
...(isGlobalAdmin
|
|
192
204
|
? [
|
|
193
205
|
{
|
|
194
|
-
label: translate('header.
|
|
195
|
-
href: '/admin/
|
|
196
|
-
|
|
206
|
+
label: translate('header.logs'),
|
|
207
|
+
href: '/admin/logs',
|
|
208
|
+
} as SubMenuItem,
|
|
209
|
+
{
|
|
210
|
+
label: translate('header.codeRunners'),
|
|
211
|
+
href: '/admin/code-runners',
|
|
197
212
|
} as SubMenuItem,
|
|
198
213
|
]
|
|
199
214
|
: []),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const administrationSystemItems: SubMenuItem[] = [
|
|
200
218
|
{
|
|
201
219
|
label: translate('header.models'),
|
|
202
220
|
href: '/admin/models',
|
|
@@ -311,6 +329,7 @@ export function buildHeaderSystemMenuItems({
|
|
|
311
329
|
return [
|
|
312
330
|
...createSystemCategory('My Account', userAccountSystemItems, translate),
|
|
313
331
|
...createSystemCategory('Utilities', utilitiesSystemItems, translate),
|
|
332
|
+
...createSystemCategory('Super Admin', superAdminSystemItems, translate),
|
|
314
333
|
...createSystemCategory('Administration', administrationSystemItems, translate),
|
|
315
334
|
...createSystemCategory('Monitoring & Usage', monitoringAndUsageSystemItems, translate),
|
|
316
335
|
...createSystemCategory('Integrations & Keys', integrationsAndKeysSystemItems, translate),
|
|
@@ -126,6 +126,7 @@ export const SERVER_TRANSLATION_KEYS = [
|
|
|
126
126
|
'header.changePassword',
|
|
127
127
|
'header.menuLabel',
|
|
128
128
|
'header.myAccount',
|
|
129
|
+
'header.superAdmin',
|
|
129
130
|
'header.administration',
|
|
130
131
|
'header.monitoringAndUsage',
|
|
131
132
|
'header.integrationsAndKeys',
|
|
@@ -140,6 +141,9 @@ export const SERVER_TRANSLATION_KEYS = [
|
|
|
140
141
|
'header.viewAllUsers',
|
|
141
142
|
'header.createNewUser',
|
|
142
143
|
'header.servers',
|
|
144
|
+
'header.environmentVariables',
|
|
145
|
+
'header.logs',
|
|
146
|
+
'header.codeRunners',
|
|
143
147
|
'header.models',
|
|
144
148
|
'header.openApiDocumentation',
|
|
145
149
|
'header.apiTokens',
|
|
@@ -120,6 +120,7 @@ header.logOut: Odhlásit se
|
|
|
120
120
|
header.changePassword: Změnit heslo
|
|
121
121
|
header.menuLabel: Nabídka
|
|
122
122
|
header.myAccount: Můj účet
|
|
123
|
+
header.superAdmin: Super administrace
|
|
123
124
|
header.administration: Administrace
|
|
124
125
|
header.monitoringAndUsage: Monitoring a využití
|
|
125
126
|
header.integrationsAndKeys: Integrace a klíče
|
|
@@ -134,6 +135,9 @@ header.mockedChats: Ukázkové chaty
|
|
|
134
135
|
header.viewAllUsers: Zobrazit všechny uživatele
|
|
135
136
|
header.createNewUser: Vytvořit nového uživatele
|
|
136
137
|
header.servers: Servery
|
|
138
|
+
header.environmentVariables: Proměnné prostředí
|
|
139
|
+
header.logs: Logy
|
|
140
|
+
header.codeRunners: Code runnery
|
|
137
141
|
header.models: Modely
|
|
138
142
|
header.openApiDocumentation: Dokumentace OpenAPI
|
|
139
143
|
header.apiTokens: API tokeny
|
|
@@ -122,6 +122,7 @@ header.logOut: Log out
|
|
|
122
122
|
header.changePassword: Change Password
|
|
123
123
|
header.menuLabel: Menu
|
|
124
124
|
header.myAccount: My Account
|
|
125
|
+
header.superAdmin: Super Admin
|
|
125
126
|
header.administration: Administration
|
|
126
127
|
header.monitoringAndUsage: Monitoring & Usage
|
|
127
128
|
header.integrationsAndKeys: Integrations & Keys
|
|
@@ -136,6 +137,9 @@ header.mockedChats: Mocked Chats
|
|
|
136
137
|
header.viewAllUsers: View all users
|
|
137
138
|
header.createNewUser: Create new user
|
|
138
139
|
header.servers: Servers
|
|
140
|
+
header.environmentVariables: Environment variables
|
|
141
|
+
header.logs: Logs
|
|
142
|
+
header.codeRunners: Code runners
|
|
139
143
|
header.models: Models
|
|
140
144
|
header.openApiDocumentation: OpenAPI Documentation
|
|
141
145
|
header.apiTokens: API Tokens
|
|
@@ -55,6 +55,14 @@ const getCachedProvidedServer = cache(async (): Promise<ProvidedServer> => {
|
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
if (!resolvedSqliteServer) {
|
|
58
|
+
if (isIpAddressHost(requestHost)) {
|
|
59
|
+
return {
|
|
60
|
+
id: null,
|
|
61
|
+
publicUrl: resolveFallbackPublicUrl(requestHost),
|
|
62
|
+
tablePrefix: SUPABASE_TABLE_PREFIX,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
throw new Error(`Server with host "${requestHost}" is not registered in SERVERS`);
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -156,3 +164,22 @@ function isLocalDevelopmentHost(host: string | null): boolean {
|
|
|
156
164
|
normalizedHost.startsWith('[::1]:')
|
|
157
165
|
);
|
|
158
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Checks whether the current request host is a raw IP address.
|
|
170
|
+
*
|
|
171
|
+
* @param host - Raw request host.
|
|
172
|
+
* @returns `true` when the host is IPv4 or IPv6.
|
|
173
|
+
*/
|
|
174
|
+
function isIpAddressHost(host: string | null): boolean {
|
|
175
|
+
if (!host) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const hostname = host
|
|
180
|
+
.trim()
|
|
181
|
+
.replace(/^\[(.+)\](?::\d+)?$/u, '$1')
|
|
182
|
+
.replace(/:\d+$/u, '');
|
|
183
|
+
|
|
184
|
+
return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname) || hostname.includes(':');
|
|
185
|
+
}
|
|
@@ -241,7 +241,11 @@ export function createServerPublicUrl(domain: string): URL {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
const protocol =
|
|
244
|
-
normalizedDomain.startsWith('localhost') ||
|
|
244
|
+
normalizedDomain.startsWith('localhost') ||
|
|
245
|
+
normalizedDomain.startsWith('127.0.0.1') ||
|
|
246
|
+
isIpAddressHost(normalizedDomain)
|
|
247
|
+
? 'http'
|
|
248
|
+
: 'https';
|
|
245
249
|
return new URL(`${protocol}://${normalizedDomain}`);
|
|
246
250
|
}
|
|
247
251
|
|
|
@@ -435,6 +439,21 @@ function hasHttpProtocol(value: string): boolean {
|
|
|
435
439
|
return value.startsWith('http://') || value.startsWith('https://');
|
|
436
440
|
}
|
|
437
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Checks whether a normalized host is a raw IPv4 or IPv6 address.
|
|
444
|
+
*
|
|
445
|
+
* @param host - Normalized host or host with port.
|
|
446
|
+
* @returns `true` for raw IP hosts.
|
|
447
|
+
*/
|
|
448
|
+
function isIpAddressHost(host: string): boolean {
|
|
449
|
+
const hostname = host
|
|
450
|
+
.trim()
|
|
451
|
+
.replace(/^\[(.+)\](?::\d+)?$/u, '$1')
|
|
452
|
+
.replace(/:\d+$/u, '');
|
|
453
|
+
|
|
454
|
+
return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname) || hostname.includes(':');
|
|
455
|
+
}
|
|
456
|
+
|
|
438
457
|
/**
|
|
439
458
|
* Checks whether a port is implicit for the given protocol and can be omitted.
|
|
440
459
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHmac } from 'crypto';
|
|
2
|
-
import { cookies } from 'next/headers';
|
|
2
|
+
import { cookies, headers } from 'next/headers';
|
|
3
3
|
import { cache } from 'react';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -30,6 +30,36 @@ export type SessionUser = {
|
|
|
30
30
|
readonly isGlobalAdmin?: boolean;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Request details used to decide whether the auth cookie may require HTTPS.
|
|
35
|
+
*/
|
|
36
|
+
export type SessionCookieSecurityContext = {
|
|
37
|
+
/**
|
|
38
|
+
* Current deployment mode.
|
|
39
|
+
*/
|
|
40
|
+
readonly isProduction: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Raw `Host` header.
|
|
43
|
+
*/
|
|
44
|
+
readonly host: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Raw forwarded host header emitted by the reverse proxy.
|
|
47
|
+
*/
|
|
48
|
+
readonly forwardedHost: string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Raw forwarded protocol header emitted by the reverse proxy.
|
|
51
|
+
*/
|
|
52
|
+
readonly forwardedProto: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Comma-separated configured domain list from `SERVERS`.
|
|
55
|
+
*/
|
|
56
|
+
readonly configuredServers: string | null | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Known standalone VPS public IP address.
|
|
59
|
+
*/
|
|
60
|
+
readonly publicIpAddress: string | null | undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
33
63
|
/**
|
|
34
64
|
* Signs a session payload and serializes it into the cookie token format.
|
|
35
65
|
*
|
|
@@ -77,6 +107,42 @@ export function parseSessionToken(token: string | null | undefined): SessionUser
|
|
|
77
107
|
}
|
|
78
108
|
}
|
|
79
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Decides whether the session cookie should keep the `Secure` flag for the current request.
|
|
112
|
+
*
|
|
113
|
+
* This keeps production-domain logins protected by HTTPS while allowing the standalone
|
|
114
|
+
* VPS bootstrap flow to authenticate over `http://<IP_ADDRESS>` before any domain exists.
|
|
115
|
+
*
|
|
116
|
+
* @param context - Request and deployment details used for the decision.
|
|
117
|
+
* @returns `true` when the cookie should require HTTPS.
|
|
118
|
+
*/
|
|
119
|
+
export function shouldUseSecureSessionCookieForRequest(context: SessionCookieSecurityContext): boolean {
|
|
120
|
+
if (!context.isProduction) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (parseConfiguredServers(context.configuredServers).length > 0) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const requestHost = normalizeHost(context.forwardedHost ?? context.host);
|
|
129
|
+
if (!requestHost || !isIpAddressHost(requestHost)) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const forwardedProtocol = (context.forwardedProto || '').split(',')[0]?.trim().toLowerCase() || '';
|
|
134
|
+
if (forwardedProtocol === 'https') {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const configuredPublicIpAddress = normalizeHost(context.publicIpAddress || '');
|
|
139
|
+
if (configuredPublicIpAddress && requestHost !== configuredPublicIpAddress) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
80
146
|
/**
|
|
81
147
|
* Persists the provided session payload into the signed session cookie.
|
|
82
148
|
*
|
|
@@ -84,10 +150,11 @@ export function parseSessionToken(token: string | null | undefined): SessionUser
|
|
|
84
150
|
*/
|
|
85
151
|
export async function setSession(user: SessionUser) {
|
|
86
152
|
const token = serializeSessionToken(user);
|
|
153
|
+
const secure = await shouldUseSecureSessionCookie();
|
|
87
154
|
|
|
88
155
|
(await cookies()).set(SESSION_COOKIE_NAME, token, {
|
|
89
156
|
httpOnly: true,
|
|
90
|
-
secure
|
|
157
|
+
secure,
|
|
91
158
|
path: '/',
|
|
92
159
|
maxAge: 60 * 60 * 24 * 365 * 2, // 2 years
|
|
93
160
|
});
|
|
@@ -120,3 +187,57 @@ const getCachedSession = cache(async (): Promise<SessionUser | null> => {
|
|
|
120
187
|
export async function getSession(): Promise<SessionUser | null> {
|
|
121
188
|
return getCachedSession();
|
|
122
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolves the runtime cookie security decision from the current request headers.
|
|
193
|
+
*
|
|
194
|
+
* @returns `true` when the session cookie should keep the `Secure` flag.
|
|
195
|
+
*/
|
|
196
|
+
async function shouldUseSecureSessionCookie(): Promise<boolean> {
|
|
197
|
+
const headerStore = await headers();
|
|
198
|
+
|
|
199
|
+
return shouldUseSecureSessionCookieForRequest({
|
|
200
|
+
isProduction: process.env.NODE_ENV === 'production',
|
|
201
|
+
host: headerStore.get('host'),
|
|
202
|
+
forwardedHost: headerStore.get('x-forwarded-host'),
|
|
203
|
+
forwardedProto: headerStore.get('x-forwarded-proto'),
|
|
204
|
+
configuredServers: process.env.SERVERS,
|
|
205
|
+
publicIpAddress: process.env.PTBK_PUBLIC_IP_ADDRESS,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parses the configured `SERVERS` CSV into non-empty entries.
|
|
211
|
+
*
|
|
212
|
+
* @param configuredServers - Raw environment value.
|
|
213
|
+
* @returns Normalized list of configured domains.
|
|
214
|
+
*/
|
|
215
|
+
function parseConfiguredServers(configuredServers: string | null | undefined): Array<string> {
|
|
216
|
+
return (configuredServers || '')
|
|
217
|
+
.split(',')
|
|
218
|
+
.map((server) => server.trim())
|
|
219
|
+
.filter(Boolean);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Checks whether a host string points to a raw IPv4 or IPv6 address.
|
|
224
|
+
*
|
|
225
|
+
* @param host - Host header or hostname.
|
|
226
|
+
* @returns `true` when the host is a raw IP address.
|
|
227
|
+
*/
|
|
228
|
+
function isIpAddressHost(host: string): boolean {
|
|
229
|
+
return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(host) || host.includes(':');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Removes ports and IPv6 brackets from host-like strings.
|
|
234
|
+
*
|
|
235
|
+
* @param host - Raw host header value.
|
|
236
|
+
* @returns Normalized bare hostname or IP address.
|
|
237
|
+
*/
|
|
238
|
+
function normalizeHost(host: string | null | undefined): string {
|
|
239
|
+
return (host || '')
|
|
240
|
+
.trim()
|
|
241
|
+
.replace(/^\[(.+)\](?::\d+)?$/u, '$1')
|
|
242
|
+
.replace(/:\d+$/u, '');
|
|
243
|
+
}
|