@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/env.ts
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
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>
|
package/src/index.test.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
})
|
package/src/index.ts
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto"
|
|
2
|
-
import { isPublicDomain, normalizeDomain, type Plan, type RoutingMode } from "./domain"
|
|
3
|
-
|
|
4
|
-
export * from "./audit"
|
|
5
|
-
export * from "./check"
|
|
6
|
-
export * from "./domain"
|
|
7
|
-
export * from "./state"
|
|
8
|
-
export * from "./runtime"
|
|
9
|
-
export * from "./install"
|
|
10
|
-
export * from "./paths"
|
|
11
|
-
export * from "./env"
|
|
12
|
-
|
|
13
|
-
export type PurchaseProvider = "envato" | "jchat"
|
|
14
|
-
export type RuntimeEnvironment = "production" | "development"
|
|
15
|
-
|
|
16
|
-
export type ConfigPayload = {
|
|
17
|
-
iss: string
|
|
18
|
-
aud: string
|
|
19
|
-
licenseId: string
|
|
20
|
-
domain: string
|
|
21
|
-
buyer: string
|
|
22
|
-
installationId: string
|
|
23
|
-
machineFingerprint: string
|
|
24
|
-
plan: Plan
|
|
25
|
-
entitlements: string[]
|
|
26
|
-
purchaseProvider: PurchaseProvider
|
|
27
|
-
environment: RuntimeEnvironment
|
|
28
|
-
routingMode: RoutingMode
|
|
29
|
-
iat: number
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export type RuntimeOptions = {
|
|
33
|
-
publicKeyPem: string
|
|
34
|
-
issuer?: string
|
|
35
|
-
audience?: string
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type RuntimeStatus = "valid" | "unreachable-fallback" | "invalid" | "unavailable"
|
|
39
|
-
|
|
40
|
-
type JwtHeader = {
|
|
41
|
-
alg: "RS256"
|
|
42
|
-
typ: "JWT"
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function isNonEmptyString(value: unknown): value is string {
|
|
50
|
-
return typeof value === "string" && value.trim().length > 0
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function decodeJsonSegment(value: string): unknown {
|
|
54
|
-
if (!value) return null
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown
|
|
57
|
-
} catch {
|
|
58
|
-
return null
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function parseHeader(token: string): JwtHeader | null {
|
|
63
|
-
const parts = token.split(".")
|
|
64
|
-
if (parts.length !== 3) return null
|
|
65
|
-
const header = decodeJsonSegment(parts[0])
|
|
66
|
-
if (!isRecord(header) || header.alg !== "RS256" || header.typ !== "JWT") return null
|
|
67
|
-
return { alg: "RS256", typ: "JWT" }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function validatePayload(value: unknown): ConfigPayload | null {
|
|
71
|
-
if (!isRecord(value)) return null
|
|
72
|
-
|
|
73
|
-
const {
|
|
74
|
-
iss,
|
|
75
|
-
aud,
|
|
76
|
-
licenseId,
|
|
77
|
-
domain,
|
|
78
|
-
buyer,
|
|
79
|
-
installationId,
|
|
80
|
-
machineFingerprint,
|
|
81
|
-
plan,
|
|
82
|
-
entitlements,
|
|
83
|
-
purchaseProvider,
|
|
84
|
-
environment,
|
|
85
|
-
routingMode,
|
|
86
|
-
iat
|
|
87
|
-
} = value
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
!isNonEmptyString(iss) ||
|
|
91
|
-
!isNonEmptyString(aud) ||
|
|
92
|
-
!isNonEmptyString(licenseId) ||
|
|
93
|
-
!isNonEmptyString(domain) ||
|
|
94
|
-
!isNonEmptyString(buyer) ||
|
|
95
|
-
!isNonEmptyString(installationId) ||
|
|
96
|
-
!isNonEmptyString(machineFingerprint) ||
|
|
97
|
-
(plan !== "standard" && plan !== "extended") ||
|
|
98
|
-
!Array.isArray(entitlements) ||
|
|
99
|
-
!entitlements.every(isNonEmptyString) ||
|
|
100
|
-
(purchaseProvider !== "envato" && purchaseProvider !== "jchat") ||
|
|
101
|
-
(environment !== "production" && environment !== "development") ||
|
|
102
|
-
(routingMode !== "path" && routingMode !== "subdomain") ||
|
|
103
|
-
typeof iat !== "number" ||
|
|
104
|
-
!Number.isSafeInteger(iat) ||
|
|
105
|
-
iat < 0
|
|
106
|
-
) {
|
|
107
|
-
return null
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let normalizedDomain
|
|
111
|
-
try {
|
|
112
|
-
normalizedDomain = normalizeDomain(domain)
|
|
113
|
-
} catch {
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
(environment === "development" && !normalizedDomain.isLocalhost) ||
|
|
119
|
-
(environment === "production" && !isPublicDomain(domain))
|
|
120
|
-
) {
|
|
121
|
-
return null
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const hasMultiSiteEntitlement = entitlements.includes("site.create.multi")
|
|
125
|
-
if (
|
|
126
|
-
(plan === "extended" && !hasMultiSiteEntitlement) ||
|
|
127
|
-
(plan === "standard" && hasMultiSiteEntitlement)
|
|
128
|
-
) {
|
|
129
|
-
return null
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
iss,
|
|
134
|
-
aud,
|
|
135
|
-
licenseId,
|
|
136
|
-
domain,
|
|
137
|
-
buyer,
|
|
138
|
-
installationId,
|
|
139
|
-
machineFingerprint,
|
|
140
|
-
plan,
|
|
141
|
-
entitlements,
|
|
142
|
-
purchaseProvider,
|
|
143
|
-
environment,
|
|
144
|
-
routingMode,
|
|
145
|
-
iat
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function parsePayload(token: string): ConfigPayload | null {
|
|
150
|
-
const parts = token.split(".")
|
|
151
|
-
if (parts.length !== 3 || !parts.every(Boolean) || !parseHeader(token)) return null
|
|
152
|
-
return validatePayload(decodeJsonSegment(parts[1]))
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function verifySignature(
|
|
156
|
-
token: string,
|
|
157
|
-
currentDomain: string,
|
|
158
|
-
options: RuntimeOptions
|
|
159
|
-
): boolean {
|
|
160
|
-
if (!token || token === "YOUR_LICENSE_KEY_PLACEHOLDER") return false
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
const parts = token.split(".")
|
|
164
|
-
if (parts.length !== 3 || !parseHeader(token)) return false
|
|
165
|
-
|
|
166
|
-
const payload = parsePayload(token)
|
|
167
|
-
if (!payload) return false
|
|
168
|
-
if (payload.iss !== (options.issuer ?? "https://license.jchat.local")) return false
|
|
169
|
-
if (payload.aud !== (options.audience ?? "jchat-chat")) return false
|
|
170
|
-
if (normalizeDomain(payload.domain).host !== normalizeDomain(currentDomain).host) {
|
|
171
|
-
return false
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const dataToVerify = Buffer.from(`${parts[0]}.${parts[1]}`)
|
|
175
|
-
const signature = Buffer.from(parts[2].replace(/-/g, "+").replace(/_/g, "/"), "base64")
|
|
176
|
-
return crypto.verify("sha256", dataToVerify, options.publicKeyPem, signature)
|
|
177
|
-
} catch {
|
|
178
|
-
return false
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function verifyEntitlement(
|
|
183
|
-
token: string,
|
|
184
|
-
currentDomain: string,
|
|
185
|
-
installationId: string,
|
|
186
|
-
machineFingerprint: string,
|
|
187
|
-
options: RuntimeOptions
|
|
188
|
-
): boolean {
|
|
189
|
-
if (!verifySignature(token, currentDomain, options)) return false
|
|
190
|
-
|
|
191
|
-
const payload = parsePayload(token)
|
|
192
|
-
if (!payload) return false
|
|
193
|
-
return (
|
|
194
|
-
payload.installationId === installationId && payload.machineFingerprint === machineFingerprint
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function hasEntitlement(
|
|
199
|
-
token: string,
|
|
200
|
-
currentDomain: string,
|
|
201
|
-
entitlement: string,
|
|
202
|
-
options: RuntimeOptions
|
|
203
|
-
): boolean {
|
|
204
|
-
if (!verifySignature(token, currentDomain, options)) return false
|
|
205
|
-
return parsePayload(token)?.entitlements?.includes(entitlement) ?? false
|
|
206
|
-
}
|
package/src/install.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { DomainScope, RoutingMode } from "./domain"
|
|
2
|
-
import type { PurchaseProvider } from "./index"
|
|
3
|
-
import { activateRegistry } from "./check"
|
|
4
|
-
import { createIdentity, type LocalState } from "./state"
|
|
5
|
-
|
|
6
|
-
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
7
|
-
|
|
8
|
-
export type LicenseVerificationResult =
|
|
9
|
-
| {
|
|
10
|
-
valid: true
|
|
11
|
-
buyer: string
|
|
12
|
-
token: string
|
|
13
|
-
domain: string
|
|
14
|
-
state: Omit<LocalState, "instanceToken" | "version">
|
|
15
|
-
}
|
|
16
|
-
| { valid: false; error: string }
|
|
17
|
-
|
|
18
|
-
export async function installAndActivate(
|
|
19
|
-
purchaseCode: string,
|
|
20
|
-
domain: string,
|
|
21
|
-
siteSlug: string | undefined,
|
|
22
|
-
domainScope: DomainScope | undefined,
|
|
23
|
-
routingMode: RoutingMode,
|
|
24
|
-
provider: PurchaseProvider = "envato",
|
|
25
|
-
fetchImpl: FetchLike = fetch
|
|
26
|
-
): Promise<LicenseVerificationResult> {
|
|
27
|
-
const installationIdentity = createIdentity()
|
|
28
|
-
try {
|
|
29
|
-
const licenseData = await activateRegistry({
|
|
30
|
-
purchaseCode,
|
|
31
|
-
domain,
|
|
32
|
-
siteSlug,
|
|
33
|
-
domainScope,
|
|
34
|
-
routingMode,
|
|
35
|
-
provider,
|
|
36
|
-
installationId: installationIdentity.installationId,
|
|
37
|
-
installationPublicKey: installationIdentity.publicKeyPem,
|
|
38
|
-
machineFingerprint: installationIdentity.machineFingerprint,
|
|
39
|
-
fetchImpl
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
if (!licenseData.success) {
|
|
43
|
-
return {
|
|
44
|
-
valid: false,
|
|
45
|
-
error: licenseData.message
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
valid: true,
|
|
51
|
-
buyer: licenseData.buyer,
|
|
52
|
-
token: licenseData.token,
|
|
53
|
-
domain: licenseData.domain,
|
|
54
|
-
state: installationIdentity
|
|
55
|
-
}
|
|
56
|
-
} catch {
|
|
57
|
-
return {
|
|
58
|
-
valid: false,
|
|
59
|
-
error: "Failed to verify license. Check your internet connection."
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
package/src/paths.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import { resolveRuntimePaths } from "./paths"
|
|
4
|
-
|
|
5
|
-
describe("resolveRuntimePaths", () => {
|
|
6
|
-
it("prefers explicit env paths", () => {
|
|
7
|
-
const paths = resolveRuntimePaths({
|
|
8
|
-
cwd: "D:/runtime",
|
|
9
|
-
env: {
|
|
10
|
-
JCHAT_UPLOADS_DIR: "/data/uploads",
|
|
11
|
-
JCHAT_ADDONS_DIR: "/data/addons",
|
|
12
|
-
JCHAT_MIGRATIONS_DIR: "/data/migrations"
|
|
13
|
-
}
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
expect(paths.uploadsDir).toBe(path.resolve("/data/uploads"))
|
|
17
|
-
expect(paths.addonBundlesDir).toBe(path.resolve("/data/addons"))
|
|
18
|
-
expect(paths.addonSourceDir).toBe(path.join(path.resolve("D:/runtime"), "addons-dev"))
|
|
19
|
-
expect(paths.migrationsDir).toBe(path.resolve("/data/migrations"))
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it("uses repo-root fallbacks when running inside the source tree", () => {
|
|
23
|
-
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..")
|
|
24
|
-
const paths = resolveRuntimePaths({
|
|
25
|
-
cwd: path.join(repoRoot, "apps", "web"),
|
|
26
|
-
env: {}
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
expect(paths.repoRoot).toBe(repoRoot)
|
|
30
|
-
expect(paths.uploadsDir).toBe(path.join(repoRoot, "uploads"))
|
|
31
|
-
expect(paths.addonBundlesDir).toBe(path.join(repoRoot, "addons"))
|
|
32
|
-
expect(paths.addonSourceDir).toBe(path.join(repoRoot, "addons-dev"))
|
|
33
|
-
expect(paths.migrationsDir).toBe(path.join(repoRoot, "packages", "db", "migrations"))
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it("falls back to runtime-relative directories outside the repo", () => {
|
|
37
|
-
const runtimeRoot = path.resolve("D:/isolated-runtime")
|
|
38
|
-
const paths = resolveRuntimePaths({
|
|
39
|
-
cwd: runtimeRoot,
|
|
40
|
-
env: {}
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
expect(paths.repoRoot).toBeNull()
|
|
44
|
-
expect(paths.uploadsDir).toBe(path.join(runtimeRoot, "uploads"))
|
|
45
|
-
expect(paths.addonBundlesDir).toBe(path.join(runtimeRoot, "addons"))
|
|
46
|
-
expect(paths.addonSourceDir).toBe(path.join(runtimeRoot, "addons-dev"))
|
|
47
|
-
expect(paths.migrationsDir).toBe(path.join(runtimeRoot, "migrations"))
|
|
48
|
-
})
|
|
49
|
-
})
|