@jchat/runtime 1.0.0-alpha.1 → 1.0.0-alpha.11
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/CHANGELOG.md +50 -0
- package/LICENSE +18 -122
- package/README.md +6 -4
- package/dist/index.d.mts +75 -26
- package/dist/index.mjs +1 -1
- package/package.json +6 -6
- package/src/audit.test.ts +0 -25
- package/src/audit.ts +0 -25
- package/src/check.test.ts +0 -77
- package/src/check.ts +0 -202
- package/src/domain.test.ts +0 -45
- package/src/domain.ts +0 -75
- package/src/env.test.ts +0 -146
- package/src/env.ts +0 -261
- package/src/index.test.ts +0 -117
- package/src/index.ts +0 -206
- package/src/install.ts +0 -62
- package/src/paths.test.ts +0 -49
- package/src/paths.ts +0 -74
- package/src/runtime.test.ts +0 -21
- package/src/runtime.ts +0 -31
- package/src/state.test.ts +0 -54
- package/src/state.ts +0 -129
package/src/audit.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
|
|
4
|
-
export type AuditEvent = "check_success" | "check_unreachable" | "check_rejected"
|
|
5
|
-
|
|
6
|
-
function redact(value: string | undefined): string | undefined {
|
|
7
|
-
if (!value) return undefined
|
|
8
|
-
return value.length <= 8 ? "redacted" : `${value.slice(0, 4)}…${value.slice(-4)}`
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function recordAudit(
|
|
12
|
-
event: AuditEvent,
|
|
13
|
-
details: { domain?: string; installationId?: string; reference?: string },
|
|
14
|
-
filePath = path.resolve(process.cwd(), "data/license-events.log")
|
|
15
|
-
): Promise<void> {
|
|
16
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
17
|
-
const line = JSON.stringify({
|
|
18
|
-
at: new Date().toISOString(),
|
|
19
|
-
event,
|
|
20
|
-
domain: redact(details.domain),
|
|
21
|
-
installationId: redact(details.installationId),
|
|
22
|
-
reference: redact(details.reference)
|
|
23
|
-
})
|
|
24
|
-
await fs.appendFile(filePath, `${line}\n`, "utf8")
|
|
25
|
-
}
|
package/src/check.test.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { activateRegistry, checkRegistry } from "./check"
|
|
3
|
-
|
|
4
|
-
describe("license check", () => {
|
|
5
|
-
test("activates through the default registry endpoint", async () => {
|
|
6
|
-
let request: Request | undefined
|
|
7
|
-
const result = await activateRegistry({
|
|
8
|
-
purchaseCode: "purchase-code",
|
|
9
|
-
domain: "www.example.com:8080",
|
|
10
|
-
provider: "envato",
|
|
11
|
-
routingMode: "path",
|
|
12
|
-
installationId: "installation-1",
|
|
13
|
-
installationPublicKey: "public-key",
|
|
14
|
-
machineFingerprint: "machine-1",
|
|
15
|
-
fetchImpl: async (input, init) => {
|
|
16
|
-
request = new Request(input, init)
|
|
17
|
-
return Response.json({ success: true, buyer: "Buyer", token: "signed-token" })
|
|
18
|
-
}
|
|
19
|
-
})
|
|
20
|
-
expect(result).toEqual({
|
|
21
|
-
success: true,
|
|
22
|
-
buyer: "Buyer",
|
|
23
|
-
token: "signed-token",
|
|
24
|
-
domain: "example.com"
|
|
25
|
-
})
|
|
26
|
-
expect(request?.url).toBe("http://localhost:4000/api/activate")
|
|
27
|
-
expect((await request?.json())?.domain).toBe("example.com")
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
test("uses the local registry endpoint by default", async () => {
|
|
31
|
-
let requestedUrl = ""
|
|
32
|
-
await checkRegistry({
|
|
33
|
-
token: "local-token",
|
|
34
|
-
domain: "example.com",
|
|
35
|
-
routingMode: "path",
|
|
36
|
-
installationId: "installation-1",
|
|
37
|
-
machineFingerprint: "machine-1",
|
|
38
|
-
localTokenValidator: () => true,
|
|
39
|
-
fetchImpl: async (input) => {
|
|
40
|
-
requestedUrl = String(input)
|
|
41
|
-
return new Response("down", { status: 503 })
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
expect(requestedUrl).toBe("http://localhost:4000/api/check")
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test("falls back to the local token on server errors", async () => {
|
|
48
|
-
const result = await checkRegistry({
|
|
49
|
-
token: "local-token",
|
|
50
|
-
domain: "example.com",
|
|
51
|
-
routingMode: "path",
|
|
52
|
-
registryEndpoint: "https://license.example",
|
|
53
|
-
installationId: "installation-1",
|
|
54
|
-
machineFingerprint: "machine-1",
|
|
55
|
-
localTokenValidator: () => true,
|
|
56
|
-
fetchImpl: async () => new Response("down", { status: 503 })
|
|
57
|
-
})
|
|
58
|
-
expect(result).toEqual({
|
|
59
|
-
status: "unreachable-fallback",
|
|
60
|
-
token: "local-token",
|
|
61
|
-
error: "HTTP 503"
|
|
62
|
-
})
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test("fails closed on an explicit client error", async () => {
|
|
66
|
-
const result = await checkRegistry({
|
|
67
|
-
token: "local-token",
|
|
68
|
-
domain: "example.com",
|
|
69
|
-
routingMode: "path",
|
|
70
|
-
registryEndpoint: "https://license.example",
|
|
71
|
-
installationId: "installation-1",
|
|
72
|
-
machineFingerprint: "machine-1",
|
|
73
|
-
fetchImpl: async () => new Response("invalid", { status: 403 })
|
|
74
|
-
})
|
|
75
|
-
expect(result.status).toBe("invalid")
|
|
76
|
-
})
|
|
77
|
-
})
|
package/src/check.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto"
|
|
2
|
-
import { recordAudit } from "./audit"
|
|
3
|
-
import { normalizeDomain } from "./domain"
|
|
4
|
-
import { parsePayload, verifyEntitlement, type RuntimeOptions } from "./index"
|
|
5
|
-
import { setRuntimeStatus } from "./runtime"
|
|
6
|
-
|
|
7
|
-
const DEFAULT_REGISTRY_ENDPOINT = Buffer.from("aHR0cDovL2xvY2FsaG9zdDo0MDAw", "base64").toString(
|
|
8
|
-
"utf8"
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
export type CheckResult =
|
|
12
|
-
| { status: "valid"; token: string }
|
|
13
|
-
| { status: "unreachable-fallback"; token: string; error: string }
|
|
14
|
-
| { status: "invalid"; error: string; reference: string }
|
|
15
|
-
|
|
16
|
-
export type RegistryProvider = "envato" | "jchat"
|
|
17
|
-
export type RegistryRoutingMode = "path" | "subdomain"
|
|
18
|
-
|
|
19
|
-
export type ActivationInput = {
|
|
20
|
-
purchaseCode: string
|
|
21
|
-
domain: string
|
|
22
|
-
siteSlug?: string
|
|
23
|
-
domainScope?: "current-host" | "parent-domain"
|
|
24
|
-
provider: RegistryProvider
|
|
25
|
-
routingMode: RegistryRoutingMode
|
|
26
|
-
installationId: string
|
|
27
|
-
installationPublicKey: string
|
|
28
|
-
machineFingerprint: string
|
|
29
|
-
registryEndpoint?: string
|
|
30
|
-
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type ActivationResult =
|
|
34
|
-
| { success: true; buyer: string; token: string; domain: string }
|
|
35
|
-
| { success: false; message: string }
|
|
36
|
-
|
|
37
|
-
export async function activateRegistry(input: ActivationInput): Promise<ActivationResult> {
|
|
38
|
-
try {
|
|
39
|
-
const response = await (input.fetchImpl ?? fetch)(
|
|
40
|
-
`${input.registryEndpoint ?? DEFAULT_REGISTRY_ENDPOINT}/api/activate`,
|
|
41
|
-
{
|
|
42
|
-
method: "POST",
|
|
43
|
-
headers: { "Content-Type": "application/json" },
|
|
44
|
-
body: JSON.stringify({
|
|
45
|
-
purchaseCode: input.purchaseCode,
|
|
46
|
-
domain: normalizeDomain(input.domain).host,
|
|
47
|
-
siteSlug: input.siteSlug,
|
|
48
|
-
domainScope: input.domainScope,
|
|
49
|
-
provider: input.provider,
|
|
50
|
-
routingMode: input.routingMode,
|
|
51
|
-
installationId: input.installationId,
|
|
52
|
-
installationPublicKey: input.installationPublicKey,
|
|
53
|
-
machineFingerprint: input.machineFingerprint
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
)
|
|
57
|
-
const body = (await response.json()) as {
|
|
58
|
-
success?: boolean
|
|
59
|
-
buyer?: string
|
|
60
|
-
token?: string
|
|
61
|
-
domain?: string
|
|
62
|
-
message?: string
|
|
63
|
-
}
|
|
64
|
-
if (response.ok === false || !body.success || !body.token) {
|
|
65
|
-
return {
|
|
66
|
-
success: false,
|
|
67
|
-
message:
|
|
68
|
-
body.message ??
|
|
69
|
-
"This license is already active on another domain. Please deactivate it first."
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
success: true,
|
|
74
|
-
buyer: body.buyer ?? "Customer",
|
|
75
|
-
token: body.token,
|
|
76
|
-
domain: body.domain ?? normalizeDomain(input.domain).host
|
|
77
|
-
}
|
|
78
|
-
} catch {
|
|
79
|
-
return {
|
|
80
|
-
success: false,
|
|
81
|
-
message: "Failed to connect to the central registry. Please try again later."
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export type CheckInput = {
|
|
87
|
-
token: string
|
|
88
|
-
domain: string
|
|
89
|
-
routingMode: RegistryRoutingMode
|
|
90
|
-
installationId: string
|
|
91
|
-
machineFingerprint: string
|
|
92
|
-
registryEndpoint?: string
|
|
93
|
-
runtimeOptions?: RuntimeOptions
|
|
94
|
-
auditPath?: string
|
|
95
|
-
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
96
|
-
localTokenValidator?: (
|
|
97
|
-
token: string,
|
|
98
|
-
domain: string,
|
|
99
|
-
installationId: string,
|
|
100
|
-
machineFingerprint: string
|
|
101
|
-
) => boolean
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export async function checkRegistry(input: CheckInput): Promise<CheckResult> {
|
|
105
|
-
const fetchImpl = input.fetchImpl ?? fetch
|
|
106
|
-
const localTokenValidator =
|
|
107
|
-
input.localTokenValidator ??
|
|
108
|
-
((token, domain, installationId, machineFingerprint) =>
|
|
109
|
-
input.runtimeOptions
|
|
110
|
-
? verifyEntitlement(token, domain, installationId, machineFingerprint, input.runtimeOptions)
|
|
111
|
-
: false)
|
|
112
|
-
|
|
113
|
-
const reject = (domain: string, error: string): CheckResult => {
|
|
114
|
-
const reference = crypto.randomUUID()
|
|
115
|
-
setRuntimeStatus("invalid", reference)
|
|
116
|
-
void recordAudit("check_rejected", { domain, reference }, input.auditPath)
|
|
117
|
-
return { status: "invalid", error, reference }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const useLocalFallback = (error: string): CheckResult => {
|
|
121
|
-
const normalizedDomain = normalizeDomain(input.domain).host
|
|
122
|
-
if (
|
|
123
|
-
!localTokenValidator(
|
|
124
|
-
input.token,
|
|
125
|
-
normalizedDomain,
|
|
126
|
-
input.installationId,
|
|
127
|
-
input.machineFingerprint
|
|
128
|
-
)
|
|
129
|
-
) {
|
|
130
|
-
return reject(normalizedDomain, "Local license validation failed.")
|
|
131
|
-
}
|
|
132
|
-
setRuntimeStatus("unreachable-fallback")
|
|
133
|
-
void recordAudit("check_unreachable", { domain: normalizedDomain }, input.auditPath)
|
|
134
|
-
return { status: "unreachable-fallback", token: input.token, error }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const normalizedDomain = normalizeDomain(input.domain).host
|
|
139
|
-
const registryEndpoint = input.registryEndpoint ?? DEFAULT_REGISTRY_ENDPOINT
|
|
140
|
-
const response = await fetchImpl(`${registryEndpoint}/api/check`, {
|
|
141
|
-
method: "POST",
|
|
142
|
-
headers: { "Content-Type": "application/json" },
|
|
143
|
-
body: JSON.stringify({
|
|
144
|
-
token: input.token,
|
|
145
|
-
domain: normalizedDomain,
|
|
146
|
-
routingMode: input.routingMode,
|
|
147
|
-
installationId: input.installationId,
|
|
148
|
-
machineFingerprint: input.machineFingerprint
|
|
149
|
-
})
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
if (!response.ok) {
|
|
153
|
-
if (response.status >= 500) return useLocalFallback(`HTTP ${response.status}`)
|
|
154
|
-
return reject(normalizedDomain, "License rejected by jchat service.")
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const body = (await response.json()) as { success?: boolean; token?: string }
|
|
158
|
-
if (!body.success || !body.token) {
|
|
159
|
-
return reject(normalizedDomain, "License rejected by jchat service.")
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const isValid = input.runtimeOptions
|
|
163
|
-
? verifyEntitlement(
|
|
164
|
-
body.token,
|
|
165
|
-
normalizedDomain,
|
|
166
|
-
input.installationId,
|
|
167
|
-
input.machineFingerprint,
|
|
168
|
-
input.runtimeOptions
|
|
169
|
-
)
|
|
170
|
-
: false
|
|
171
|
-
if (!isValid && !input.localTokenValidator) {
|
|
172
|
-
return reject(normalizedDomain, "License rejected by jchat service.")
|
|
173
|
-
}
|
|
174
|
-
if (
|
|
175
|
-
input.localTokenValidator &&
|
|
176
|
-
!input.localTokenValidator(
|
|
177
|
-
body.token,
|
|
178
|
-
normalizedDomain,
|
|
179
|
-
input.installationId,
|
|
180
|
-
input.machineFingerprint
|
|
181
|
-
)
|
|
182
|
-
) {
|
|
183
|
-
return reject(normalizedDomain, "License rejected by jchat service.")
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const payload = parsePayload(body.token)
|
|
187
|
-
if (
|
|
188
|
-
!payload ||
|
|
189
|
-
payload.installationId !== input.installationId ||
|
|
190
|
-
payload.machineFingerprint !== input.machineFingerprint ||
|
|
191
|
-
payload.routingMode !== input.routingMode
|
|
192
|
-
) {
|
|
193
|
-
return reject(normalizedDomain, "License binding rejected by jchat service.")
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
setRuntimeStatus("valid")
|
|
197
|
-
void recordAudit("check_success", { domain: normalizedDomain }, input.auditPath)
|
|
198
|
-
return { status: "valid", token: body.token }
|
|
199
|
-
} catch {
|
|
200
|
-
return useLocalFallback("License service unreachable.")
|
|
201
|
-
}
|
|
202
|
-
}
|
package/src/domain.test.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import {
|
|
3
|
-
isAllowedConfiguredDomain,
|
|
4
|
-
isPublicDomain,
|
|
5
|
-
normalizeDomain,
|
|
6
|
-
resolveActivationDomain
|
|
7
|
-
} from "./domain"
|
|
8
|
-
|
|
9
|
-
describe("instance domain", () => {
|
|
10
|
-
test("normalizes and classifies public, local, and configured domains", () => {
|
|
11
|
-
expect(normalizeDomain("HTTPS://WWW.Example.com.:8080")).toEqual({
|
|
12
|
-
host: "example.com",
|
|
13
|
-
isLocalhost: false
|
|
14
|
-
})
|
|
15
|
-
expect(normalizeDomain("localhost:3000")).toEqual({
|
|
16
|
-
host: "localhost",
|
|
17
|
-
isLocalhost: true
|
|
18
|
-
})
|
|
19
|
-
expect(isPublicDomain("example.com")).toBe(true)
|
|
20
|
-
expect(isPublicDomain("localhost")).toBe(false)
|
|
21
|
-
expect(isPublicDomain("192.0.2.10")).toBe(false)
|
|
22
|
-
expect(isPublicDomain("chat.local")).toBe(false)
|
|
23
|
-
expect(isPublicDomain("chat.test")).toBe(false)
|
|
24
|
-
expect(isAllowedConfiguredDomain("chat.example.com")).toBe(true)
|
|
25
|
-
expect(isAllowedConfiguredDomain("localhost:3000")).toBe(true)
|
|
26
|
-
expect(isAllowedConfiguredDomain("chat.test")).toBe(false)
|
|
27
|
-
expect(isAllowedConfiguredDomain("")).toBe(false)
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
test("resolves current-host and parent-domain activation scopes", () => {
|
|
31
|
-
expect(resolveActivationDomain("demo.example.com", "extended", "path")).toBe("demo.example.com")
|
|
32
|
-
expect(resolveActivationDomain("chat.example.com", "standard", "subdomain")).toBe(
|
|
33
|
-
"chat.example.com"
|
|
34
|
-
)
|
|
35
|
-
expect(resolveActivationDomain("chat.example.com", "extended", "subdomain")).toBe(
|
|
36
|
-
"chat.example.com"
|
|
37
|
-
)
|
|
38
|
-
expect(
|
|
39
|
-
resolveActivationDomain("chat.example.com", "extended", "subdomain", "parent-domain")
|
|
40
|
-
).toBe("example.com")
|
|
41
|
-
expect(
|
|
42
|
-
resolveActivationDomain("chat.example.co.uk", "extended", "subdomain", "parent-domain")
|
|
43
|
-
).toBe("example.co.uk")
|
|
44
|
-
})
|
|
45
|
-
})
|
package/src/domain.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import net from "node:net"
|
|
2
|
-
|
|
3
|
-
export type Plan = "standard" | "extended"
|
|
4
|
-
export type RoutingMode = "path" | "subdomain"
|
|
5
|
-
export type DomainScope = "current-host" | "parent-domain"
|
|
6
|
-
|
|
7
|
-
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
|
8
|
-
const RESERVED_SUFFIXES = [".local", ".test", ".invalid", ".example"]
|
|
9
|
-
|
|
10
|
-
export type NormalizedDomain = {
|
|
11
|
-
host: string
|
|
12
|
-
isLocalhost: boolean
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Normalize the host used for instance binding; ports and www are not significant. */
|
|
16
|
-
export function normalizeDomain(value: string): NormalizedDomain {
|
|
17
|
-
const raw = value.trim()
|
|
18
|
-
if (!raw) throw new Error("Instance domain is required")
|
|
19
|
-
|
|
20
|
-
const url = new URL(raw.includes("://") ? raw : `https://${raw}`)
|
|
21
|
-
const hostname = url.hostname.toLowerCase().replace(/\.$/, "")
|
|
22
|
-
const host = hostname === "www.localhost" ? "localhost" : hostname.replace(/^www\./, "")
|
|
23
|
-
if (!host || host.includes("@") || /\s/.test(host)) {
|
|
24
|
-
throw new Error("Invalid instance domain")
|
|
25
|
-
}
|
|
26
|
-
return { host, isLocalhost: LOCAL_HOSTS.has(host) }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function isPublicDomain(value: string): boolean {
|
|
30
|
-
const { host, isLocalhost } = normalizeDomain(value)
|
|
31
|
-
return (
|
|
32
|
-
!isLocalhost &&
|
|
33
|
-
net.isIP(host) === 0 &&
|
|
34
|
-
host.includes(".") &&
|
|
35
|
-
!RESERVED_SUFFIXES.some((suffix) => host.endsWith(suffix))
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function isAllowedConfiguredDomain(value: string): boolean {
|
|
40
|
-
try {
|
|
41
|
-
const normalized = normalizeDomain(value)
|
|
42
|
-
return normalized.isLocalhost || isPublicDomain(value)
|
|
43
|
-
} catch {
|
|
44
|
-
return false
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function getRootDomain(value: string): string | null {
|
|
49
|
-
const { host, isLocalhost } = normalizeDomain(value)
|
|
50
|
-
if (isLocalhost || net.isIP(host) !== 0) return null
|
|
51
|
-
|
|
52
|
-
const parts = host.split(".")
|
|
53
|
-
if (parts.length <= 2) return host
|
|
54
|
-
|
|
55
|
-
const secondLevelTlds = new Set(["co", "com", "org", "net", "gov", "edu", "ac", "sch"])
|
|
56
|
-
const topLevel = parts.at(-1) ?? ""
|
|
57
|
-
const secondLevel = parts.at(-2) ?? ""
|
|
58
|
-
if (secondLevelTlds.has(secondLevel) && topLevel.length === 2 && parts.length >= 3) {
|
|
59
|
-
return parts.slice(-3).join(".")
|
|
60
|
-
}
|
|
61
|
-
return parts.slice(-2).join(".")
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function resolveActivationDomain(
|
|
65
|
-
host: string,
|
|
66
|
-
plan: Plan,
|
|
67
|
-
routingMode: RoutingMode,
|
|
68
|
-
domainScope: DomainScope = "current-host"
|
|
69
|
-
): string {
|
|
70
|
-
const normalizedHost = normalizeDomain(host).host
|
|
71
|
-
if (routingMode === "subdomain" && plan === "extended" && domainScope === "parent-domain") {
|
|
72
|
-
return getRootDomain(normalizedHost) ?? normalizedHost
|
|
73
|
-
}
|
|
74
|
-
return normalizedHost
|
|
75
|
-
}
|
package/src/env.test.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { ServerConfigSchema } from "./env"
|
|
3
|
-
|
|
4
|
-
const BASE = {
|
|
5
|
-
JCHAT_PORT: "3000",
|
|
6
|
-
JCHAT_COOKIE_SECRET: "secret",
|
|
7
|
-
JCHAT_DATABASE_URL: "postgresql://user:pass@localhost:5432/jchat",
|
|
8
|
-
JCHAT_REDIS_DATABASE_URL: "redis://default:pass@localhost:6379",
|
|
9
|
-
JCHAT_STORAGE_TYPE: "LOCAL",
|
|
10
|
-
JCHAT_BOT_NAME: "Bot",
|
|
11
|
-
JCHAT_BOT_EMAIL: "bot@jchat.com",
|
|
12
|
-
JCHAT_ADDON_ENCRYPTION_KEY: "1234567890123456",
|
|
13
|
-
JCHAT_DOMAIN: "localhost"
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe("ServerConfigSchema", () => {
|
|
17
|
-
it("parses the base environment and coerces the port", () => {
|
|
18
|
-
const result = ServerConfigSchema.safeParse(BASE)
|
|
19
|
-
|
|
20
|
-
expect(result.success).toBe(true)
|
|
21
|
-
if (result.success) expect(result.data.JCHAT_PORT).toBe(3000)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it("defaults the omitted port to 3000", () => {
|
|
25
|
-
const { JCHAT_PORT: _, ...environment } = BASE
|
|
26
|
-
const result = ServerConfigSchema.safeParse(environment)
|
|
27
|
-
|
|
28
|
-
expect(result.success).toBe(true)
|
|
29
|
-
if (result.success) expect(result.data.JCHAT_PORT).toBe(3000)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it("rejects malformed, out-of-range, and reserved ports", () => {
|
|
33
|
-
for (const port of ["abc", "0", "65536", "80", "443"]) {
|
|
34
|
-
expect(ServerConfigSchema.safeParse({ ...BASE, JCHAT_PORT: port }).success).toBe(false)
|
|
35
|
-
}
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it("validates PostgreSQL and Redis connection URLs", () => {
|
|
39
|
-
const cases: Array<[Record<string, string>, boolean]> = [
|
|
40
|
-
[{ JCHAT_DATABASE_URL: "mysql://user:pass@localhost:3306/db" }, false],
|
|
41
|
-
[{ JCHAT_DATABASE_URL: "postgresql://no-port" }, false],
|
|
42
|
-
[{ JCHAT_REDIS_DATABASE_URL: "rediss://default:pass@localhost:6380" }, true],
|
|
43
|
-
[{ JCHAT_REDIS_DATABASE_URL: "http://localhost:6379" }, false],
|
|
44
|
-
[{ JCHAT_REDIS_DATABASE_URL: "redis://default:pass@localhost" }, false]
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
for (const [override, expected] of cases) {
|
|
48
|
-
expect(ServerConfigSchema.safeParse({ ...BASE, ...override }).success).toBe(expected)
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it("accepts only a full main-domain public site origin", () => {
|
|
53
|
-
const cases: Array<[string, boolean]> = [
|
|
54
|
-
["https://example.com", true],
|
|
55
|
-
["example.com", false],
|
|
56
|
-
["https://www.example.com", false]
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
for (const [siteUrl, expected] of cases) {
|
|
60
|
-
expect(ServerConfigSchema.safeParse({ ...BASE, JCHAT_SITE_URL: siteUrl }).success).toBe(
|
|
61
|
-
expected
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it("allows a missing domain during initial installation", () => {
|
|
67
|
-
const { JCHAT_DOMAIN: _, ...environment } = BASE
|
|
68
|
-
const result = ServerConfigSchema.safeParse(environment)
|
|
69
|
-
|
|
70
|
-
expect(result.success).toBe(true)
|
|
71
|
-
if (result.success) expect(result.data.JCHAT_DOMAIN).toBeUndefined()
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it("accepts root and nested domains in supported routing modes", () => {
|
|
75
|
-
const cases = [
|
|
76
|
-
{ JCHAT_DOMAIN: "example.com" },
|
|
77
|
-
{ JCHAT_DOMAIN: "chat.example.com", JCHAT_ROUTING_MODE: "path" },
|
|
78
|
-
{ JCHAT_DOMAIN: "chat.example.com", JCHAT_ROUTING_MODE: "subdomain" }
|
|
79
|
-
]
|
|
80
|
-
|
|
81
|
-
for (const override of cases) {
|
|
82
|
-
expect(ServerConfigSchema.safeParse({ ...BASE, ...override }).success).toBe(true)
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it("requires provider-specific storage settings", () => {
|
|
87
|
-
const providers = [
|
|
88
|
-
{
|
|
89
|
-
type: "MINIO",
|
|
90
|
-
config: {
|
|
91
|
-
JCHAT_MINIO_ENDPOINT: "http://localhost:9000",
|
|
92
|
-
JCHAT_MINIO_BUCKET_NAME: "bucket",
|
|
93
|
-
JCHAT_MINIO_ACCESS_KEY_ID: "key",
|
|
94
|
-
JCHAT_MINIO_SECRET_ACCESS_KEY: "secret",
|
|
95
|
-
JCHAT_MINIO_PUBLIC_URL: "https://files.example.com"
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
type: "AWS_S3",
|
|
100
|
-
config: {
|
|
101
|
-
JCHAT_AWS_REGION: "us-east-1",
|
|
102
|
-
JCHAT_AWS_BUCKET_NAME: "bucket",
|
|
103
|
-
JCHAT_AWS_ACCESS_KEY_ID: "key",
|
|
104
|
-
JCHAT_AWS_SECRET_ACCESS_KEY: "secret"
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
type: "CLOUDFLARE_R2",
|
|
109
|
-
config: {
|
|
110
|
-
JCHAT_CLOUDFLARE_ACCOUNT_ID: "account",
|
|
111
|
-
JCHAT_CLOUDFLARE_BUCKET_NAME: "bucket",
|
|
112
|
-
JCHAT_CLOUDFLARE_ACCESS_KEY_ID: "key",
|
|
113
|
-
JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY: "secret",
|
|
114
|
-
JCHAT_CLOUDFLARE_PUBLIC_URL: "https://cdn.example.com"
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
type: "GCS",
|
|
119
|
-
config: {
|
|
120
|
-
JCHAT_GCS_PROJECT_ID: "project",
|
|
121
|
-
JCHAT_GCS_BUCKET_NAME: "bucket",
|
|
122
|
-
JCHAT_GCS_CLIENT_EMAIL: "service@example.iam.gserviceaccount.com",
|
|
123
|
-
JCHAT_GCS_PRIVATE_KEY: "-----BEGIN RSA PRIVATE KEY-----"
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
] as const
|
|
127
|
-
|
|
128
|
-
for (const provider of providers) {
|
|
129
|
-
expect(
|
|
130
|
-
ServerConfigSchema.safeParse({
|
|
131
|
-
...BASE,
|
|
132
|
-
JCHAT_STORAGE_TYPE: provider.type
|
|
133
|
-
}).success
|
|
134
|
-
).toBe(false)
|
|
135
|
-
expect(
|
|
136
|
-
ServerConfigSchema.safeParse({
|
|
137
|
-
...BASE,
|
|
138
|
-
JCHAT_STORAGE_TYPE: provider.type,
|
|
139
|
-
...provider.config
|
|
140
|
-
}).success
|
|
141
|
-
).toBe(true)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
expect(ServerConfigSchema.safeParse({ ...BASE, JCHAT_STORAGE_TYPE: "FTP" }).success).toBe(false)
|
|
145
|
-
})
|
|
146
|
-
})
|