@jchat/runtime 0.0.2 → 1.0.0-alpha.1
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 +12 -0
- package/LICENSE +14 -7
- package/README.md +1 -1
- package/dist/index.d.mts +84 -1
- package/dist/index.mjs +1 -1
- package/package.json +25 -5
- package/src/audit.test.ts +25 -0
- package/src/audit.ts +25 -0
- package/src/check.test.ts +77 -0
- package/src/check.ts +202 -0
- package/src/domain.test.ts +45 -0
- package/src/domain.ts +75 -0
- package/src/env.test.ts +146 -0
- package/src/env.ts +261 -0
- package/src/index.test.ts +117 -0
- package/src/index.ts +206 -0
- package/src/install.ts +62 -0
- package/src/paths.test.ts +49 -0
- package/src/paths.ts +74 -0
- package/src/runtime.test.ts +21 -0
- package/src/runtime.ts +31 -0
- package/src/state.test.ts +54 -0
- package/src/state.ts +129 -0
|
@@ -0,0 +1,45 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
})
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { StorageType, isMainDomain } from "@jchat/shared"
|
|
2
|
+
import { replaceKeys } from "string-ts"
|
|
3
|
+
import * as z from "zod"
|
|
4
|
+
|
|
5
|
+
export function makeTypedEnv<T>(schema: { parse: (data: unknown) => T }) {
|
|
6
|
+
return (args: Record<string, unknown>) => schema.parse(replaceKeys(args, "VITE_", ""))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const ServerConfigSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
JCHAT_PORT: z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(/^\d+$/)
|
|
14
|
+
.transform(Number)
|
|
15
|
+
.refine((port) => port >= 1 && port <= 65535, {
|
|
16
|
+
message: "Port must be between 1 and 65535"
|
|
17
|
+
})
|
|
18
|
+
.refine((port) => port !== 80 && port !== 443, {
|
|
19
|
+
message: "Avoid ports 80/443 (reserved for HTTP/HTTPS)"
|
|
20
|
+
})
|
|
21
|
+
.default(3000),
|
|
22
|
+
|
|
23
|
+
JCHAT_COOKIE_SECRET: z.string().min(1),
|
|
24
|
+
|
|
25
|
+
//DATABASE CONFIG
|
|
26
|
+
JCHAT_DATABASE_URL: z
|
|
27
|
+
.url()
|
|
28
|
+
.startsWith("postgresql://")
|
|
29
|
+
.regex(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)(\?.*)?$/, {
|
|
30
|
+
message: "Must be in format: postgresql://username:password@host:port/database?schema"
|
|
31
|
+
}),
|
|
32
|
+
JCHAT_REDIS_DATABASE_URL: z
|
|
33
|
+
.url()
|
|
34
|
+
.refine((url) => url.startsWith("rediss://") || url.startsWith("redis://"), {
|
|
35
|
+
message: "Must use redis:// or rediss:// protocol"
|
|
36
|
+
})
|
|
37
|
+
.refine(
|
|
38
|
+
(url) => {
|
|
39
|
+
const pattern = /^(rediss?):\/\/(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$/
|
|
40
|
+
return pattern.test(url)
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
message: "Must be in format: rediss://default:<password>@<host>:<port>"
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
|
|
47
|
+
JCHAT_STORAGE_TYPE: z.enum(StorageType),
|
|
48
|
+
|
|
49
|
+
JCHAT_MINIO_ENDPOINT: z.string().optional(),
|
|
50
|
+
JCHAT_MINIO_BUCKET_NAME: z.string().optional(),
|
|
51
|
+
JCHAT_MINIO_ACCESS_KEY_ID: z.string().optional(),
|
|
52
|
+
JCHAT_MINIO_SECRET_ACCESS_KEY: z.string().optional(),
|
|
53
|
+
JCHAT_MINIO_PUBLIC_URL: z.string().optional(),
|
|
54
|
+
|
|
55
|
+
JCHAT_AWS_REGION: z.string().optional(),
|
|
56
|
+
JCHAT_AWS_BUCKET_NAME: z.string().optional(),
|
|
57
|
+
JCHAT_AWS_ACCESS_KEY_ID: z.string().optional(),
|
|
58
|
+
JCHAT_AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
|
59
|
+
|
|
60
|
+
JCHAT_CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
|
|
61
|
+
JCHAT_CLOUDFLARE_BUCKET_NAME: z.string().optional(),
|
|
62
|
+
JCHAT_CLOUDFLARE_ACCESS_KEY_ID: z.string().optional(),
|
|
63
|
+
JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
|
|
64
|
+
JCHAT_CLOUDFLARE_PUBLIC_URL: z.string().optional(),
|
|
65
|
+
|
|
66
|
+
JCHAT_GCS_BUCKET_NAME: z.string().optional(),
|
|
67
|
+
JCHAT_GCS_PROJECT_ID: z.string().optional(),
|
|
68
|
+
JCHAT_GCS_CLIENT_EMAIL: z.string().optional(),
|
|
69
|
+
JCHAT_GCS_PRIVATE_KEY: z.string().optional(),
|
|
70
|
+
JCHAT_GCS_KEY_FILE: z.string().optional(),
|
|
71
|
+
|
|
72
|
+
JCHAT_BOT_NAME: z.string().trim().min(3),
|
|
73
|
+
JCHAT_BOT_EMAIL: z.email(),
|
|
74
|
+
|
|
75
|
+
JCHAT_DOMAIN: z.string().optional(),
|
|
76
|
+
JCHAT_SITE_URL: z.url().optional(),
|
|
77
|
+
JCHAT_ROUTING_MODE: z.enum(["path", "subdomain"]).default("subdomain"),
|
|
78
|
+
|
|
79
|
+
JCHAT_ADDON_ENCRYPTION_KEY: z.string().min(16),
|
|
80
|
+
|
|
81
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
82
|
+
|
|
83
|
+
SYSTEM_BOOTSTRAP_COMPLETE: z.boolean().optional()
|
|
84
|
+
})
|
|
85
|
+
.superRefine((env, ctx) => {
|
|
86
|
+
if (env.JCHAT_SITE_URL) {
|
|
87
|
+
let siteHostname: string | undefined
|
|
88
|
+
try {
|
|
89
|
+
siteHostname = new URL(env.JCHAT_SITE_URL).hostname
|
|
90
|
+
} catch {}
|
|
91
|
+
|
|
92
|
+
if (siteHostname && !isMainDomain(siteHostname)) {
|
|
93
|
+
ctx.addIssue({
|
|
94
|
+
path: ["JCHAT_SITE_URL"],
|
|
95
|
+
code: "custom",
|
|
96
|
+
message: "JCHAT_SITE_URL must use the client main/root domain"
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
env.JCHAT_STORAGE_TYPE === "MINIO" &&
|
|
103
|
+
!(
|
|
104
|
+
env.JCHAT_MINIO_ENDPOINT &&
|
|
105
|
+
env.JCHAT_MINIO_BUCKET_NAME &&
|
|
106
|
+
env.JCHAT_MINIO_ACCESS_KEY_ID &&
|
|
107
|
+
env.JCHAT_MINIO_SECRET_ACCESS_KEY &&
|
|
108
|
+
env.JCHAT_MINIO_PUBLIC_URL
|
|
109
|
+
)
|
|
110
|
+
) {
|
|
111
|
+
if (!env.JCHAT_MINIO_ENDPOINT)
|
|
112
|
+
ctx.addIssue({
|
|
113
|
+
path: ["JCHAT_MINIO_ENDPOINT"],
|
|
114
|
+
code: "custom",
|
|
115
|
+
message: "JCHAT_MINIO_ENDPOINT is required when JCHAT_STORAGE_TYPE is MINIO"
|
|
116
|
+
})
|
|
117
|
+
if (!env.JCHAT_MINIO_BUCKET_NAME)
|
|
118
|
+
ctx.addIssue({
|
|
119
|
+
path: ["JCHAT_MINIO_BUCKET_NAME"],
|
|
120
|
+
code: "custom",
|
|
121
|
+
message: "JCHAT_MINIO_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is MINIO"
|
|
122
|
+
})
|
|
123
|
+
if (!env.JCHAT_MINIO_ACCESS_KEY_ID)
|
|
124
|
+
ctx.addIssue({
|
|
125
|
+
path: ["JCHAT_MINIO_ACCESS_KEY_ID"],
|
|
126
|
+
code: "custom",
|
|
127
|
+
message: "JCHAT_MINIO_ACCESS_KEY_ID is required when JCHAT_STORAGE_TYPE is MINIO"
|
|
128
|
+
})
|
|
129
|
+
if (!env.JCHAT_MINIO_SECRET_ACCESS_KEY)
|
|
130
|
+
ctx.addIssue({
|
|
131
|
+
path: ["JCHAT_MINIO_SECRET_ACCESS_KEY"],
|
|
132
|
+
code: "custom",
|
|
133
|
+
message: "JCHAT_MINIO_SECRET_ACCESS_KEY is required when JCHAT_STORAGE_TYPE is MINIO"
|
|
134
|
+
})
|
|
135
|
+
if (!env.JCHAT_MINIO_PUBLIC_URL)
|
|
136
|
+
ctx.addIssue({
|
|
137
|
+
path: ["JCHAT_MINIO_PUBLIC_URL"],
|
|
138
|
+
code: "custom",
|
|
139
|
+
message: "JCHAT_MINIO_PUBLIC_URL is required when JCHAT_STORAGE_TYPE is MINIO"
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
env.JCHAT_STORAGE_TYPE === "AWS_S3" &&
|
|
145
|
+
!(
|
|
146
|
+
env.JCHAT_AWS_REGION &&
|
|
147
|
+
env.JCHAT_AWS_ACCESS_KEY_ID &&
|
|
148
|
+
env.JCHAT_AWS_SECRET_ACCESS_KEY &&
|
|
149
|
+
env.JCHAT_AWS_BUCKET_NAME
|
|
150
|
+
)
|
|
151
|
+
) {
|
|
152
|
+
if (!env.JCHAT_AWS_REGION)
|
|
153
|
+
ctx.addIssue({
|
|
154
|
+
path: ["JCHAT_AWS_REGION"],
|
|
155
|
+
code: "custom",
|
|
156
|
+
message: "JCHAT_AWS_REGION is required when JCHAT_STORAGE_TYPE is AWS_S3"
|
|
157
|
+
})
|
|
158
|
+
if (!env.JCHAT_AWS_ACCESS_KEY_ID)
|
|
159
|
+
ctx.addIssue({
|
|
160
|
+
path: ["JCHAT_AWS_ACCESS_KEY_ID"],
|
|
161
|
+
code: "custom",
|
|
162
|
+
message: "JCHAT_AWS_ACCESS_KEY_ID is required when JCHAT_STORAGE_TYPE is AWS_S3"
|
|
163
|
+
})
|
|
164
|
+
if (!env.JCHAT_AWS_SECRET_ACCESS_KEY)
|
|
165
|
+
ctx.addIssue({
|
|
166
|
+
path: ["JCHAT_AWS_SECRET_ACCESS_KEY"],
|
|
167
|
+
code: "custom",
|
|
168
|
+
message: "JCHAT_AWS_SECRET_ACCESS_KEY is required when JCHAT_STORAGE_TYPE is AWS_S3"
|
|
169
|
+
})
|
|
170
|
+
if (!env.JCHAT_AWS_BUCKET_NAME)
|
|
171
|
+
ctx.addIssue({
|
|
172
|
+
path: ["JCHAT_AWS_BUCKET_NAME"],
|
|
173
|
+
code: "custom",
|
|
174
|
+
message: "JCHAT_AWS_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is AWS_S3"
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (
|
|
179
|
+
env.JCHAT_STORAGE_TYPE === "CLOUDFLARE_R2" &&
|
|
180
|
+
!(
|
|
181
|
+
env.JCHAT_CLOUDFLARE_ACCOUNT_ID &&
|
|
182
|
+
env.JCHAT_CLOUDFLARE_ACCESS_KEY_ID &&
|
|
183
|
+
env.JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY &&
|
|
184
|
+
env.JCHAT_CLOUDFLARE_BUCKET_NAME &&
|
|
185
|
+
env.JCHAT_CLOUDFLARE_PUBLIC_URL
|
|
186
|
+
)
|
|
187
|
+
) {
|
|
188
|
+
if (!env.JCHAT_CLOUDFLARE_ACCOUNT_ID)
|
|
189
|
+
ctx.addIssue({
|
|
190
|
+
path: ["JCHAT_CLOUDFLARE_ACCOUNT_ID"],
|
|
191
|
+
code: "custom",
|
|
192
|
+
message:
|
|
193
|
+
"JCHAT_CLOUDFLARE_ACCOUNT_ID is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
|
|
194
|
+
})
|
|
195
|
+
if (!env.JCHAT_CLOUDFLARE_ACCESS_KEY_ID)
|
|
196
|
+
ctx.addIssue({
|
|
197
|
+
path: ["JCHAT_CLOUDFLARE_ACCESS_KEY_ID"],
|
|
198
|
+
code: "custom",
|
|
199
|
+
message:
|
|
200
|
+
"JCHAT_CLOUDFLARE_ACCESS_KEY_ID is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
|
|
201
|
+
})
|
|
202
|
+
if (!env.JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY)
|
|
203
|
+
ctx.addIssue({
|
|
204
|
+
path: ["JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY"],
|
|
205
|
+
code: "custom",
|
|
206
|
+
message:
|
|
207
|
+
"JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
|
|
208
|
+
})
|
|
209
|
+
if (!env.JCHAT_CLOUDFLARE_BUCKET_NAME)
|
|
210
|
+
ctx.addIssue({
|
|
211
|
+
path: ["JCHAT_CLOUDFLARE_BUCKET_NAME"],
|
|
212
|
+
code: "custom",
|
|
213
|
+
message:
|
|
214
|
+
"JCHAT_CLOUDFLARE_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
|
|
215
|
+
})
|
|
216
|
+
if (!env.JCHAT_CLOUDFLARE_PUBLIC_URL)
|
|
217
|
+
ctx.addIssue({
|
|
218
|
+
path: ["JCHAT_CLOUDFLARE_PUBLIC_URL"],
|
|
219
|
+
code: "custom",
|
|
220
|
+
message:
|
|
221
|
+
"JCHAT_CLOUDFLARE_PUBLIC_URL is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
env.JCHAT_STORAGE_TYPE === "GCS" &&
|
|
227
|
+
!(
|
|
228
|
+
env.JCHAT_GCS_PROJECT_ID &&
|
|
229
|
+
env.JCHAT_GCS_BUCKET_NAME &&
|
|
230
|
+
env.JCHAT_GCS_CLIENT_EMAIL &&
|
|
231
|
+
env.JCHAT_GCS_PRIVATE_KEY
|
|
232
|
+
)
|
|
233
|
+
) {
|
|
234
|
+
if (!env.JCHAT_GCS_PROJECT_ID)
|
|
235
|
+
ctx.addIssue({
|
|
236
|
+
path: ["JCHAT_GCS_PROJECT_ID"],
|
|
237
|
+
code: "custom",
|
|
238
|
+
message: "JCHAT_GCS_PROJECT_ID is required when JCHAT_STORAGE_TYPE is GCS"
|
|
239
|
+
})
|
|
240
|
+
if (!env.JCHAT_GCS_BUCKET_NAME)
|
|
241
|
+
ctx.addIssue({
|
|
242
|
+
path: ["JCHAT_GCS_BUCKET_NAME"],
|
|
243
|
+
code: "custom",
|
|
244
|
+
message: "JCHAT_GCS_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is GCS"
|
|
245
|
+
})
|
|
246
|
+
if (!env.JCHAT_GCS_CLIENT_EMAIL)
|
|
247
|
+
ctx.addIssue({
|
|
248
|
+
path: ["JCHAT_GCS_CLIENT_EMAIL"],
|
|
249
|
+
code: "custom",
|
|
250
|
+
message: "JCHAT_GCS_CLIENT_EMAIL is required when JCHAT_STORAGE_TYPE is GCS"
|
|
251
|
+
})
|
|
252
|
+
if (!env.JCHAT_GCS_PRIVATE_KEY)
|
|
253
|
+
ctx.addIssue({
|
|
254
|
+
path: ["JCHAT_GCS_PRIVATE_KEY"],
|
|
255
|
+
code: "custom",
|
|
256
|
+
message: "JCHAT_GCS_PRIVATE_KEY is required when JCHAT_STORAGE_TYPE is GCS"
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
export type ServerConfig = z.infer<typeof ServerConfigSchema>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import crypto from "node:crypto"
|
|
3
|
+
import { hasEntitlement, parsePayload, verifyEntitlement, verifySignature } from "./index"
|
|
4
|
+
|
|
5
|
+
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 })
|
|
6
|
+
const publicKeyPem = keyPair.publicKey.export({ type: "spki", format: "pem" }).toString()
|
|
7
|
+
|
|
8
|
+
function createToken(
|
|
9
|
+
overrides: Record<string, unknown> = {},
|
|
10
|
+
headerOverrides: Record<string, unknown> = {}
|
|
11
|
+
) {
|
|
12
|
+
const header = Buffer.from(
|
|
13
|
+
JSON.stringify({ alg: "RS256", typ: "JWT", ...headerOverrides })
|
|
14
|
+
).toString("base64url")
|
|
15
|
+
const payload = Buffer.from(
|
|
16
|
+
JSON.stringify({
|
|
17
|
+
iss: "https://license.jchat.local",
|
|
18
|
+
aud: "jchat-chat",
|
|
19
|
+
licenseId: "license-1",
|
|
20
|
+
domain: "www.example.com:8080",
|
|
21
|
+
buyer: "Customer",
|
|
22
|
+
installationId: "install-1",
|
|
23
|
+
machineFingerprint: "machine-1",
|
|
24
|
+
plan: "extended",
|
|
25
|
+
entitlements: ["site.create.multi"],
|
|
26
|
+
purchaseProvider: "envato",
|
|
27
|
+
environment: "production",
|
|
28
|
+
routingMode: "path",
|
|
29
|
+
iat: 1,
|
|
30
|
+
...overrides
|
|
31
|
+
})
|
|
32
|
+
).toString("base64url")
|
|
33
|
+
const signingInput = `${header}.${payload}`
|
|
34
|
+
const signature = crypto
|
|
35
|
+
.sign("sha256", Buffer.from(signingInput), keyPair.privateKey)
|
|
36
|
+
.toString("base64url")
|
|
37
|
+
return `${signingInput}.${signature}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const options = { publicKeyPem }
|
|
41
|
+
|
|
42
|
+
describe("license runtime", () => {
|
|
43
|
+
test("parses and verifies a normalized perpetual public-domain license", () => {
|
|
44
|
+
const token = createToken()
|
|
45
|
+
const payload = parsePayload(token)
|
|
46
|
+
expect(payload?.licenseId).toBe("license-1")
|
|
47
|
+
expect(payload).not.toHaveProperty("exp")
|
|
48
|
+
expect(verifySignature(token, "example.com", options)).toBe(true)
|
|
49
|
+
expect(verifyEntitlement(token, "example.com:3000", "install-1", "machine-1", options)).toBe(
|
|
50
|
+
true
|
|
51
|
+
)
|
|
52
|
+
expect(hasEntitlement(token, "www.example.com", "site.create.multi", options)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("rejects forged signatures and binding mismatches", () => {
|
|
56
|
+
const token = createToken()
|
|
57
|
+
expect(verifySignature(`${token}x`, "example.com", options)).toBe(false)
|
|
58
|
+
expect(verifySignature(token, "other.example.com", options)).toBe(false)
|
|
59
|
+
expect(verifyEntitlement(token, "example.com", "other-install", "machine-1", options)).toBe(
|
|
60
|
+
false
|
|
61
|
+
)
|
|
62
|
+
expect(verifyEntitlement(token, "example.com", "install-1", "other-machine", options)).toBe(
|
|
63
|
+
false
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("rejects issuer, audience, and algorithm mismatches", () => {
|
|
68
|
+
expect(
|
|
69
|
+
verifySignature(createToken({ iss: "https://other.example" }), "example.com", options)
|
|
70
|
+
).toBe(false)
|
|
71
|
+
expect(verifySignature(createToken({ aud: "other-app" }), "example.com", options)).toBe(false)
|
|
72
|
+
expect(verifySignature(createToken({}, { alg: "HS256" }), "example.com", options)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("rejects malformed and incomplete claims", () => {
|
|
76
|
+
for (const overrides of [
|
|
77
|
+
{ licenseId: undefined },
|
|
78
|
+
{ domain: 42 },
|
|
79
|
+
{ buyer: "" },
|
|
80
|
+
{ installationId: undefined },
|
|
81
|
+
{ machineFingerprint: undefined },
|
|
82
|
+
{ plan: "multi" },
|
|
83
|
+
{ entitlements: "site.create.multi" },
|
|
84
|
+
{ purchaseProvider: "unknown" },
|
|
85
|
+
{ environment: "staging" },
|
|
86
|
+
{ routingMode: "host" },
|
|
87
|
+
{ iat: "now" }
|
|
88
|
+
]) {
|
|
89
|
+
expect(parsePayload(createToken(overrides))).toBeNull()
|
|
90
|
+
}
|
|
91
|
+
expect(parsePayload("header.invalid.signature")).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("enforces standard and extended entitlement consistency", () => {
|
|
95
|
+
const standard = createToken({ plan: "standard", entitlements: [] })
|
|
96
|
+
expect(verifyEntitlement(standard, "example.com", "install-1", "machine-1", options)).toBe(true)
|
|
97
|
+
expect(hasEntitlement(standard, "example.com", "site.create.multi", options)).toBe(false)
|
|
98
|
+
|
|
99
|
+
expect(parsePayload(createToken({ plan: "standard" }))).toBeNull()
|
|
100
|
+
expect(parsePayload(createToken({ plan: "extended", entitlements: [] }))).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("allows only vendor-issued development claims on localhost", () => {
|
|
104
|
+
const development = createToken({
|
|
105
|
+
domain: "localhost",
|
|
106
|
+
plan: "standard",
|
|
107
|
+
entitlements: [],
|
|
108
|
+
environment: "development"
|
|
109
|
+
})
|
|
110
|
+
expect(
|
|
111
|
+
verifyEntitlement(development, "www.localhost:3000", "install-1", "machine-1", options)
|
|
112
|
+
).toBe(true)
|
|
113
|
+
|
|
114
|
+
expect(parsePayload(createToken({ domain: "localhost" }))).toBeNull()
|
|
115
|
+
expect(parsePayload(createToken({ environment: "development" }))).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|