@brainfish-ai/devdoc 0.1.29 → 0.1.31
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/ai-agents/CLAUDE.md +1 -1
- package/dist/cli/commands/create.d.ts +1 -0
- package/dist/cli/commands/create.js +140 -1
- package/dist/cli/commands/deploy.js +65 -1
- package/dist/cli/commands/domain.d.ts +21 -0
- package/dist/cli/commands/domain.js +386 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +188 -1
- package/dist/cli/index.js +32 -1
- package/package.json +1 -1
- package/renderer/app/api/domains/add/route.ts +132 -0
- package/renderer/app/api/domains/lookup/route.ts +43 -0
- package/renderer/app/api/domains/remove/route.ts +100 -0
- package/renderer/app/api/domains/status/route.ts +158 -0
- package/renderer/app/api/domains/verify/route.ts +181 -0
- package/renderer/app/api/local-assets/[...path]/route.ts +122 -0
- package/renderer/app/globals.css +9 -0
- package/renderer/app/layout.tsx +8 -1
- package/renderer/components/docs/mdx/cards.tsx +35 -7
- package/renderer/components/docs/navigation/sidebar.tsx +5 -5
- package/renderer/components/docs-header.tsx +9 -5
- package/renderer/components/docs-viewer/sidebar/right-sidebar.tsx +5 -5
- package/renderer/lib/docs/config/domain-schema.ts +260 -0
- package/renderer/lib/docs/config/index.ts +14 -0
- package/renderer/lib/storage/blob.ts +242 -4
- package/renderer/public/file.svg +0 -1
- package/renderer/public/globe.svg +0 -1
- package/renderer/public/logo.svg +0 -9
- package/renderer/public/window.svg +0 -1
|
@@ -150,10 +150,10 @@ export function DocsSidebar({
|
|
|
150
150
|
|
|
151
151
|
return (
|
|
152
152
|
<>
|
|
153
|
-
{/* Mobile overlay backdrop */}
|
|
153
|
+
{/* Mobile overlay backdrop - z-[55] to cover header (z-50) but below sidebar (z-60) */}
|
|
154
154
|
{isMobile && isOpen && (
|
|
155
155
|
<div
|
|
156
|
-
className="fixed inset-0 bg-black/50 z-
|
|
156
|
+
className="fixed inset-0 bg-black/50 z-[55] lg:hidden"
|
|
157
157
|
onClick={onClose}
|
|
158
158
|
/>
|
|
159
159
|
)}
|
|
@@ -162,9 +162,9 @@ export function DocsSidebar({
|
|
|
162
162
|
className={cn(
|
|
163
163
|
"flex flex-col border-r bg-sidebar border-sidebar-border overflow-hidden",
|
|
164
164
|
// Desktop: always visible, fixed width
|
|
165
|
-
"lg:relative lg:w-72 lg:h-full",
|
|
166
|
-
// Mobile: drawer behavior
|
|
167
|
-
"fixed inset-y-0 left-0 z-
|
|
165
|
+
"lg:relative lg:w-72 lg:h-full lg:z-auto",
|
|
166
|
+
// Mobile: drawer behavior - z-[60] to appear above header (z-50)
|
|
167
|
+
"fixed inset-y-0 left-0 z-[60] w-[280px] h-full",
|
|
168
168
|
"transform transition-transform duration-300 ease-in-out",
|
|
169
169
|
"lg:transform-none lg:translate-x-0",
|
|
170
170
|
isMobile && !isOpen && "-translate-x-full",
|
|
@@ -25,6 +25,7 @@ interface DocsLogo {
|
|
|
25
25
|
height?: number
|
|
26
26
|
light?: string
|
|
27
27
|
dark?: string
|
|
28
|
+
href?: string // Where logo links to (defaults to "/")
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// Header config from docs.json
|
|
@@ -133,7 +134,10 @@ export function DocsHeader({
|
|
|
133
134
|
</Button>
|
|
134
135
|
)}
|
|
135
136
|
|
|
136
|
-
<Link
|
|
137
|
+
<Link
|
|
138
|
+
href={docsLogo?.href || "/"}
|
|
139
|
+
className="docs-header-logo flex items-center gap-2 sm:gap-3 min-h-[44px] min-w-[44px] touch-manipulation"
|
|
140
|
+
>
|
|
137
141
|
{hasLightDarkLogos ? (
|
|
138
142
|
<>
|
|
139
143
|
{/* Light mode logo */}
|
|
@@ -142,7 +146,7 @@ export function DocsHeader({
|
|
|
142
146
|
alt={logo.alt}
|
|
143
147
|
width={logo.width}
|
|
144
148
|
height={logo.height}
|
|
145
|
-
className="w-auto dark:hidden"
|
|
149
|
+
className="w-auto dark:hidden pointer-events-none"
|
|
146
150
|
style={{ height: logo.height }}
|
|
147
151
|
priority
|
|
148
152
|
/>
|
|
@@ -152,7 +156,7 @@ export function DocsHeader({
|
|
|
152
156
|
alt={logo.alt}
|
|
153
157
|
width={logo.width}
|
|
154
158
|
height={logo.height}
|
|
155
|
-
className="w-auto hidden dark:block"
|
|
159
|
+
className="w-auto hidden dark:block pointer-events-none"
|
|
156
160
|
style={{ height: logo.height }}
|
|
157
161
|
priority
|
|
158
162
|
/>
|
|
@@ -163,7 +167,7 @@ export function DocsHeader({
|
|
|
163
167
|
alt={logo.alt}
|
|
164
168
|
width={logo.width}
|
|
165
169
|
height={logo.height}
|
|
166
|
-
className="w-auto dark:invert"
|
|
170
|
+
className="w-auto dark:invert pointer-events-none"
|
|
167
171
|
style={{ height: logo.height }}
|
|
168
172
|
priority
|
|
169
173
|
/>
|
|
@@ -171,7 +175,7 @@ export function DocsHeader({
|
|
|
171
175
|
{docsName && (
|
|
172
176
|
<>
|
|
173
177
|
<div className="hidden sm:block h-5 w-px bg-border" />
|
|
174
|
-
<span className="docs-header-title hidden sm:inline text-sm font-medium text-muted-foreground">
|
|
178
|
+
<span className="docs-header-title hidden sm:inline text-sm font-medium text-muted-foreground pointer-events-none">
|
|
175
179
|
{docsName}
|
|
176
180
|
</span>
|
|
177
181
|
</>
|
|
@@ -107,10 +107,10 @@ export function RightSidebar({
|
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
109
|
<>
|
|
110
|
-
{/* Mobile overlay backdrop */}
|
|
110
|
+
{/* Mobile overlay backdrop - z-[55] to cover header (z-50) but below panel (z-60) */}
|
|
111
111
|
{isMobile && isRightSidebarOpen && (
|
|
112
112
|
<div
|
|
113
|
-
className="docs-agent-overlay fixed inset-0 bg-black/50 z-
|
|
113
|
+
className="docs-agent-overlay fixed inset-0 bg-black/50 z-[55] lg:hidden"
|
|
114
114
|
onClick={closeRightSidebar}
|
|
115
115
|
/>
|
|
116
116
|
)}
|
|
@@ -119,9 +119,9 @@ export function RightSidebar({
|
|
|
119
119
|
className={cn(
|
|
120
120
|
"docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden",
|
|
121
121
|
// Desktop: always visible, fixed width
|
|
122
|
-
"lg:relative lg:w-96 lg:h-full",
|
|
123
|
-
// Mobile: drawer behavior from right
|
|
124
|
-
"fixed inset-y-0 right-0 z-
|
|
122
|
+
"lg:relative lg:w-96 lg:h-full lg:z-auto",
|
|
123
|
+
// Mobile: drawer behavior from right - z-[60] to appear above header (z-50)
|
|
124
|
+
"fixed inset-y-0 right-0 z-[60] w-[320px] sm:w-[360px] h-full",
|
|
125
125
|
"transform transition-transform duration-300 ease-in-out",
|
|
126
126
|
"lg:transform-none lg:translate-x-0",
|
|
127
127
|
isMobile && !isRightSidebarOpen && "translate-x-full",
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Domain Configuration Schema (domain.json)
|
|
5
|
+
*
|
|
6
|
+
* Configuration for custom domains on DevDoc projects.
|
|
7
|
+
* Each project can have ONE custom domain for free.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Domain Validation Helpers
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Valid domain pattern - allows subdomains like docs.example.com
|
|
16
|
+
* Does NOT allow:
|
|
17
|
+
* - IP addresses
|
|
18
|
+
* - Ports
|
|
19
|
+
* - Paths
|
|
20
|
+
* - Protocol prefixes
|
|
21
|
+
*/
|
|
22
|
+
const domainRegex = /^(?!-)[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reserved domains that cannot be used as custom domains
|
|
26
|
+
*/
|
|
27
|
+
const RESERVED_DOMAINS = [
|
|
28
|
+
'devdoc.sh',
|
|
29
|
+
'devdoc.io',
|
|
30
|
+
'devdoc.com',
|
|
31
|
+
'localhost',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// SEO Schema
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
const domainSeoSchema = z.object({
|
|
39
|
+
/**
|
|
40
|
+
* Canonical URL for SEO - tells search engines this is the primary URL
|
|
41
|
+
* Should match your custom domain with https://
|
|
42
|
+
*/
|
|
43
|
+
canonical: z.string().url().optional(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Settings Schema
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
const domainSettingsSchema = z.object({
|
|
51
|
+
/**
|
|
52
|
+
* Force HTTPS redirects (recommended, default: true)
|
|
53
|
+
*/
|
|
54
|
+
forceHttps: z.boolean().default(true),
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* WWW redirect preference
|
|
58
|
+
* - "www": Redirect non-www to www (example.com → www.example.com)
|
|
59
|
+
* - "non-www": Redirect www to non-www (www.example.com → example.com)
|
|
60
|
+
* - "none": No redirect, accept both
|
|
61
|
+
*/
|
|
62
|
+
wwwRedirect: z.enum(['www', 'non-www', 'none']).default('none'),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Main Domain Configuration Schema
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
export const domainConfigSchema = z.object({
|
|
70
|
+
/**
|
|
71
|
+
* JSON Schema reference (optional)
|
|
72
|
+
*/
|
|
73
|
+
$schema: z.string().optional(),
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The custom domain for this project
|
|
77
|
+
* Example: "docs.example.com" or "developer.mycompany.io"
|
|
78
|
+
*
|
|
79
|
+
* Rules:
|
|
80
|
+
* - Must be a valid domain name
|
|
81
|
+
* - Cannot be a DevDoc domain (*.devdoc.sh)
|
|
82
|
+
* - One custom domain per project (free)
|
|
83
|
+
*/
|
|
84
|
+
customDomain: z.string()
|
|
85
|
+
.min(4, 'Domain must be at least 4 characters')
|
|
86
|
+
.max(253, 'Domain must be 253 characters or less')
|
|
87
|
+
.regex(domainRegex, 'Invalid domain format. Example: docs.example.com')
|
|
88
|
+
.refine(
|
|
89
|
+
(domain) => !RESERVED_DOMAINS.some(reserved =>
|
|
90
|
+
domain === reserved || domain.endsWith(`.${reserved}`)
|
|
91
|
+
),
|
|
92
|
+
'Cannot use a reserved DevDoc domain'
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* SEO configuration for the custom domain
|
|
97
|
+
*/
|
|
98
|
+
seo: domainSeoSchema.optional(),
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Domain behavior settings
|
|
102
|
+
*/
|
|
103
|
+
settings: domainSettingsSchema.optional(),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Types
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
export type DomainConfig = z.infer<typeof domainConfigSchema>
|
|
111
|
+
export type DomainSeoConfig = z.infer<typeof domainSeoSchema>
|
|
112
|
+
export type DomainSettings = z.infer<typeof domainSettingsSchema>
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Domain Status Types (for API/storage)
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Status of a custom domain in the system
|
|
120
|
+
*/
|
|
121
|
+
export type DomainStatus =
|
|
122
|
+
| 'pending' // Domain added, awaiting DNS configuration
|
|
123
|
+
| 'dns_verified' // DNS records verified, awaiting SSL
|
|
124
|
+
| 'ssl_provisioning' // SSL certificate being provisioned
|
|
125
|
+
| 'active' // Domain is live and working
|
|
126
|
+
| 'error' // Configuration error
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Custom domain entry stored in the registry
|
|
130
|
+
*/
|
|
131
|
+
export interface CustomDomainEntry {
|
|
132
|
+
/** The custom domain (e.g., "docs.example.com") */
|
|
133
|
+
customDomain: string
|
|
134
|
+
|
|
135
|
+
/** The project slug this domain points to */
|
|
136
|
+
projectSlug: string
|
|
137
|
+
|
|
138
|
+
/** Current status of the domain */
|
|
139
|
+
status: DomainStatus
|
|
140
|
+
|
|
141
|
+
/** Vercel domain ID (from Vercel API) */
|
|
142
|
+
vercelDomainId?: string
|
|
143
|
+
|
|
144
|
+
/** TXT record value for domain ownership verification */
|
|
145
|
+
verificationToken?: string
|
|
146
|
+
|
|
147
|
+
/** When the domain was added */
|
|
148
|
+
createdAt: string
|
|
149
|
+
|
|
150
|
+
/** When DNS was verified */
|
|
151
|
+
verifiedAt?: string
|
|
152
|
+
|
|
153
|
+
/** Last time status was checked */
|
|
154
|
+
lastCheckedAt?: string
|
|
155
|
+
|
|
156
|
+
/** Error message if status is "error" */
|
|
157
|
+
errorMessage?: string
|
|
158
|
+
|
|
159
|
+
/** Domain settings from domain.json */
|
|
160
|
+
settings?: DomainSettings
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Validation Functions
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse and validate domain configuration
|
|
169
|
+
*/
|
|
170
|
+
export function parseDomainConfig(data: unknown): DomainConfig {
|
|
171
|
+
const result = domainConfigSchema.safeParse(data)
|
|
172
|
+
|
|
173
|
+
if (!result.success) {
|
|
174
|
+
const issues = result.error.issues || []
|
|
175
|
+
const errors = issues.map((e) =>
|
|
176
|
+
`${e.path.map(String).join('.')}: ${e.message}`
|
|
177
|
+
).join('\n')
|
|
178
|
+
throw new Error(`Invalid domain.json configuration:\n${errors}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result.data
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Safe parse that returns null on failure
|
|
186
|
+
*/
|
|
187
|
+
export function safeParseDomainConfig(data: unknown): DomainConfig | null {
|
|
188
|
+
const result = domainConfigSchema.safeParse(data)
|
|
189
|
+
return result.success ? result.data : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate a domain string format
|
|
194
|
+
*/
|
|
195
|
+
export function isValidDomain(domain: string): { valid: boolean; error?: string } {
|
|
196
|
+
if (!domain) {
|
|
197
|
+
return { valid: false, error: 'Domain is required' }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (domain.length < 4) {
|
|
201
|
+
return { valid: false, error: 'Domain must be at least 4 characters' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (domain.length > 253) {
|
|
205
|
+
return { valid: false, error: 'Domain must be 253 characters or less' }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!domainRegex.test(domain)) {
|
|
209
|
+
return { valid: false, error: 'Invalid domain format. Example: docs.example.com' }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check reserved domains
|
|
213
|
+
if (RESERVED_DOMAINS.some(reserved =>
|
|
214
|
+
domain === reserved || domain.endsWith(`.${reserved}`)
|
|
215
|
+
)) {
|
|
216
|
+
return { valid: false, error: 'Cannot use a reserved DevDoc domain' }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { valid: true }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Normalize a domain (lowercase, trim, remove protocol/path)
|
|
224
|
+
*/
|
|
225
|
+
export function normalizeDomain(domain: string): string {
|
|
226
|
+
let normalized = domain.toLowerCase().trim()
|
|
227
|
+
|
|
228
|
+
// Remove protocol if present
|
|
229
|
+
normalized = normalized.replace(/^https?:\/\//, '')
|
|
230
|
+
|
|
231
|
+
// Remove path if present
|
|
232
|
+
normalized = normalized.split('/')[0]
|
|
233
|
+
|
|
234
|
+
// Remove port if present
|
|
235
|
+
normalized = normalized.split(':')[0]
|
|
236
|
+
|
|
237
|
+
return normalized
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get DNS instructions for a custom domain
|
|
242
|
+
*/
|
|
243
|
+
export function getDnsInstructions(customDomain: string): {
|
|
244
|
+
cname: { name: string; value: string }
|
|
245
|
+
txt: { name: string; value: string }
|
|
246
|
+
} {
|
|
247
|
+
const parts = customDomain.split('.')
|
|
248
|
+
const subdomain = parts.length > 2 ? parts[0] : '@'
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
cname: {
|
|
252
|
+
name: subdomain === '@' ? customDomain : subdomain,
|
|
253
|
+
value: 'cname.devdoc-dns.com',
|
|
254
|
+
},
|
|
255
|
+
txt: {
|
|
256
|
+
name: `_devdoc-verify.${customDomain}`,
|
|
257
|
+
value: '', // Will be filled with actual verification token
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -17,6 +17,20 @@ export {
|
|
|
17
17
|
type ApiConfig,
|
|
18
18
|
} from './schema'
|
|
19
19
|
|
|
20
|
+
export {
|
|
21
|
+
domainConfigSchema,
|
|
22
|
+
parseDomainConfig,
|
|
23
|
+
safeParseDomainConfig,
|
|
24
|
+
isValidDomain,
|
|
25
|
+
normalizeDomain,
|
|
26
|
+
getDnsInstructions,
|
|
27
|
+
type DomainConfig,
|
|
28
|
+
type DomainSeoConfig,
|
|
29
|
+
type DomainSettings,
|
|
30
|
+
type DomainStatus,
|
|
31
|
+
type CustomDomainEntry,
|
|
32
|
+
} from './domain-schema'
|
|
33
|
+
|
|
20
34
|
export {
|
|
21
35
|
loadDocsConfig,
|
|
22
36
|
safeLoadDocsConfig,
|
|
@@ -415,15 +415,59 @@ function getApiKeyBlobPath(slug: string): string {
|
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
// =============================================================================
|
|
418
|
-
// Domain Registry - O(1) lookups for subdomains and
|
|
418
|
+
// Domain Registry - O(1) lookups for subdomains, API keys, and custom domains
|
|
419
419
|
// =============================================================================
|
|
420
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Status of a custom domain in the system
|
|
423
|
+
*/
|
|
424
|
+
export type CustomDomainStatus =
|
|
425
|
+
| 'pending' // Domain added, awaiting DNS configuration
|
|
426
|
+
| 'dns_verified' // DNS records verified, awaiting SSL
|
|
427
|
+
| 'ssl_provisioning' // SSL certificate being provisioned
|
|
428
|
+
| 'active' // Domain is live and working
|
|
429
|
+
| 'error' // Configuration error
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Custom domain entry stored in the registry
|
|
433
|
+
*/
|
|
434
|
+
export interface CustomDomainEntry {
|
|
435
|
+
/** The custom domain (e.g., "docs.example.com") */
|
|
436
|
+
customDomain: string
|
|
437
|
+
|
|
438
|
+
/** The project slug this domain points to */
|
|
439
|
+
projectSlug: string
|
|
440
|
+
|
|
441
|
+
/** Current status of the domain */
|
|
442
|
+
status: CustomDomainStatus
|
|
443
|
+
|
|
444
|
+
/** Vercel domain ID (from Vercel API) */
|
|
445
|
+
vercelDomainId?: string
|
|
446
|
+
|
|
447
|
+
/** TXT record value for domain ownership verification */
|
|
448
|
+
verificationToken?: string
|
|
449
|
+
|
|
450
|
+
/** When the domain was added */
|
|
451
|
+
createdAt: string
|
|
452
|
+
|
|
453
|
+
/** When DNS was verified */
|
|
454
|
+
verifiedAt?: string
|
|
455
|
+
|
|
456
|
+
/** Last time status was checked */
|
|
457
|
+
lastCheckedAt?: string
|
|
458
|
+
|
|
459
|
+
/** Error message if status is "error" */
|
|
460
|
+
errorMessage?: string
|
|
461
|
+
}
|
|
462
|
+
|
|
421
463
|
/**
|
|
422
464
|
* Registry structure - single file for all domains/projects
|
|
423
465
|
*/
|
|
424
466
|
export interface DomainRegistry {
|
|
425
|
-
domains: Record<string, DomainEntry>
|
|
426
|
-
|
|
467
|
+
domains: Record<string, DomainEntry> // subdomain -> entry
|
|
468
|
+
customDomains: Record<string, CustomDomainEntry> // customDomain -> entry (NEW)
|
|
469
|
+
projectToCustomDomain: Record<string, string> // projectSlug -> customDomain (reverse lookup)
|
|
470
|
+
apiKeys: Record<string, string> // apiKey -> subdomain (for O(1) key validation)
|
|
427
471
|
updatedAt: string
|
|
428
472
|
}
|
|
429
473
|
|
|
@@ -477,7 +521,13 @@ async function getRegistry(): Promise<DomainRegistry> {
|
|
|
477
521
|
}
|
|
478
522
|
|
|
479
523
|
// Return empty registry if not found
|
|
480
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
domains: {},
|
|
526
|
+
customDomains: {},
|
|
527
|
+
projectToCustomDomain: {},
|
|
528
|
+
apiKeys: {},
|
|
529
|
+
updatedAt: new Date().toISOString()
|
|
530
|
+
}
|
|
481
531
|
}
|
|
482
532
|
|
|
483
533
|
/**
|
|
@@ -555,6 +605,194 @@ export async function getDomainEntry(subdomain: string): Promise<DomainEntry | n
|
|
|
555
605
|
return registry.domains[subdomain] || null
|
|
556
606
|
}
|
|
557
607
|
|
|
608
|
+
// =============================================================================
|
|
609
|
+
// Custom Domain Management - One custom domain per project (free)
|
|
610
|
+
// =============================================================================
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Generate a verification token for domain ownership
|
|
614
|
+
*/
|
|
615
|
+
export function generateVerificationToken(): string {
|
|
616
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
617
|
+
let token = 'devdoc-verify='
|
|
618
|
+
for (let i = 0; i < 24; i++) {
|
|
619
|
+
token += chars.charAt(Math.floor(Math.random() * chars.length))
|
|
620
|
+
}
|
|
621
|
+
return token
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Check if a custom domain is already registered (O(1) lookup)
|
|
626
|
+
*/
|
|
627
|
+
export async function isCustomDomainRegistered(customDomain: string): Promise<boolean> {
|
|
628
|
+
const registry = await getRegistry()
|
|
629
|
+
return customDomain in (registry.customDomains || {})
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Check if a project already has a custom domain (O(1) lookup)
|
|
634
|
+
*/
|
|
635
|
+
export async function getProjectCustomDomain(projectSlug: string): Promise<CustomDomainEntry | null> {
|
|
636
|
+
const registry = await getRegistry()
|
|
637
|
+
const customDomain = registry.projectToCustomDomain?.[projectSlug]
|
|
638
|
+
if (!customDomain) return null
|
|
639
|
+
return registry.customDomains?.[customDomain] || null
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Get custom domain entry by domain name (O(1) lookup)
|
|
644
|
+
*/
|
|
645
|
+
export async function getCustomDomainEntry(customDomain: string): Promise<CustomDomainEntry | null> {
|
|
646
|
+
const registry = await getRegistry()
|
|
647
|
+
return registry.customDomains?.[customDomain] || null
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Look up project slug from custom domain (O(1) lookup)
|
|
652
|
+
* Used by middleware for routing
|
|
653
|
+
*/
|
|
654
|
+
export async function lookupCustomDomain(customDomain: string): Promise<CustomDomainEntry | null> {
|
|
655
|
+
const registry = await getRegistry()
|
|
656
|
+
const entry = registry.customDomains?.[customDomain]
|
|
657
|
+
|
|
658
|
+
// Only return if domain is active
|
|
659
|
+
if (entry && entry.status === 'active') {
|
|
660
|
+
return entry
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Add a custom domain to a project
|
|
668
|
+
* Returns error if project already has a custom domain (limit: 1 per project)
|
|
669
|
+
*/
|
|
670
|
+
export async function addCustomDomain(
|
|
671
|
+
projectSlug: string,
|
|
672
|
+
customDomain: string
|
|
673
|
+
): Promise<{ success: boolean; entry?: CustomDomainEntry; error?: string }> {
|
|
674
|
+
const registry = await getRegistry()
|
|
675
|
+
|
|
676
|
+
// Initialize custom domain maps if they don't exist
|
|
677
|
+
if (!registry.customDomains) {
|
|
678
|
+
registry.customDomains = {}
|
|
679
|
+
}
|
|
680
|
+
if (!registry.projectToCustomDomain) {
|
|
681
|
+
registry.projectToCustomDomain = {}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Check if project already has a custom domain (limit: 1 per project)
|
|
685
|
+
const existingDomain = registry.projectToCustomDomain[projectSlug]
|
|
686
|
+
if (existingDomain) {
|
|
687
|
+
return {
|
|
688
|
+
success: false,
|
|
689
|
+
error: `Project already has a custom domain: ${existingDomain}. Remove it first before adding a new one.`,
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Check if this domain is already registered to another project
|
|
694
|
+
if (registry.customDomains[customDomain]) {
|
|
695
|
+
return {
|
|
696
|
+
success: false,
|
|
697
|
+
error: `Domain ${customDomain} is already registered to another project.`,
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Verify the project exists
|
|
702
|
+
const projectExists = await getDomainEntry(projectSlug)
|
|
703
|
+
if (!projectExists) {
|
|
704
|
+
return {
|
|
705
|
+
success: false,
|
|
706
|
+
error: `Project ${projectSlug} not found. Deploy your project first.`,
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const now = new Date().toISOString()
|
|
711
|
+
const verificationToken = generateVerificationToken()
|
|
712
|
+
|
|
713
|
+
const entry: CustomDomainEntry = {
|
|
714
|
+
customDomain,
|
|
715
|
+
projectSlug,
|
|
716
|
+
status: 'pending',
|
|
717
|
+
verificationToken,
|
|
718
|
+
createdAt: now,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Add to both lookups
|
|
722
|
+
registry.customDomains[customDomain] = entry
|
|
723
|
+
registry.projectToCustomDomain[projectSlug] = customDomain
|
|
724
|
+
|
|
725
|
+
await saveRegistry(registry)
|
|
726
|
+
|
|
727
|
+
return { success: true, entry }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Update custom domain status
|
|
732
|
+
*/
|
|
733
|
+
export async function updateCustomDomainStatus(
|
|
734
|
+
customDomain: string,
|
|
735
|
+
status: CustomDomainStatus,
|
|
736
|
+
additionalFields?: Partial<CustomDomainEntry>
|
|
737
|
+
): Promise<boolean> {
|
|
738
|
+
const registry = await getRegistry()
|
|
739
|
+
|
|
740
|
+
if (!registry.customDomains?.[customDomain]) {
|
|
741
|
+
return false
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
registry.customDomains[customDomain] = {
|
|
745
|
+
...registry.customDomains[customDomain],
|
|
746
|
+
status,
|
|
747
|
+
lastCheckedAt: new Date().toISOString(),
|
|
748
|
+
...additionalFields,
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Set verifiedAt when transitioning to dns_verified
|
|
752
|
+
if (status === 'dns_verified' && !registry.customDomains[customDomain].verifiedAt) {
|
|
753
|
+
registry.customDomains[customDomain].verifiedAt = new Date().toISOString()
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
await saveRegistry(registry)
|
|
757
|
+
return true
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Remove a custom domain from a project
|
|
762
|
+
*/
|
|
763
|
+
export async function removeCustomDomain(
|
|
764
|
+
customDomain: string,
|
|
765
|
+
projectSlug: string
|
|
766
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
767
|
+
const registry = await getRegistry()
|
|
768
|
+
|
|
769
|
+
// Check if domain exists and belongs to this project
|
|
770
|
+
const entry = registry.customDomains?.[customDomain]
|
|
771
|
+
if (!entry) {
|
|
772
|
+
return { success: false, error: `Domain ${customDomain} not found.` }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (entry.projectSlug !== projectSlug) {
|
|
776
|
+
return { success: false, error: `Domain ${customDomain} does not belong to this project.` }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Remove from both lookups
|
|
780
|
+
delete registry.customDomains[customDomain]
|
|
781
|
+
delete registry.projectToCustomDomain[projectSlug]
|
|
782
|
+
|
|
783
|
+
await saveRegistry(registry)
|
|
784
|
+
|
|
785
|
+
return { success: true }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* List all custom domains (for admin purposes)
|
|
790
|
+
*/
|
|
791
|
+
export async function listCustomDomains(): Promise<CustomDomainEntry[]> {
|
|
792
|
+
const registry = await getRegistry()
|
|
793
|
+
return Object.values(registry.customDomains || {})
|
|
794
|
+
}
|
|
795
|
+
|
|
558
796
|
/**
|
|
559
797
|
* Generate a secure API key
|
|
560
798
|
*/
|
package/renderer/public/file.svg
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|