@jchat/runtime 1.0.0-alpha.2 → 1.0.0-alpha.4

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/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
- })
package/src/env.ts DELETED
@@ -1,256 +0,0 @@
1
- import { StorageType, isMainDomain } from "@jchat/shared"
2
- import * as z from "zod"
3
-
4
- export const ServerConfigSchema = z
5
- .object({
6
- JCHAT_PORT: z
7
- .string()
8
- .regex(/^\d+$/)
9
- .transform(Number)
10
- .refine((port) => port >= 1 && port <= 65535, {
11
- message: "Port must be between 1 and 65535"
12
- })
13
- .refine((port) => port !== 80 && port !== 443, {
14
- message: "Avoid ports 80/443 (reserved for HTTP/HTTPS)"
15
- })
16
- .default(3000),
17
-
18
- JCHAT_COOKIE_SECRET: z.string().min(1),
19
-
20
- //DATABASE CONFIG
21
- JCHAT_DATABASE_URL: z
22
- .url()
23
- .startsWith("postgresql://")
24
- .regex(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)(\?.*)?$/, {
25
- message: "Must be in format: postgresql://username:password@host:port/database?schema"
26
- }),
27
- JCHAT_REDIS_DATABASE_URL: z
28
- .url()
29
- .refine((url) => url.startsWith("rediss://") || url.startsWith("redis://"), {
30
- message: "Must use redis:// or rediss:// protocol"
31
- })
32
- .refine(
33
- (url) => {
34
- const pattern = /^(rediss?):\/\/(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$/
35
- return pattern.test(url)
36
- },
37
- {
38
- message: "Must be in format: rediss://default:<password>@<host>:<port>"
39
- }
40
- ),
41
-
42
- JCHAT_STORAGE_TYPE: z.enum(StorageType),
43
-
44
- JCHAT_MINIO_ENDPOINT: z.string().optional(),
45
- JCHAT_MINIO_BUCKET_NAME: z.string().optional(),
46
- JCHAT_MINIO_ACCESS_KEY_ID: z.string().optional(),
47
- JCHAT_MINIO_SECRET_ACCESS_KEY: z.string().optional(),
48
- JCHAT_MINIO_PUBLIC_URL: z.string().optional(),
49
-
50
- JCHAT_AWS_REGION: z.string().optional(),
51
- JCHAT_AWS_BUCKET_NAME: z.string().optional(),
52
- JCHAT_AWS_ACCESS_KEY_ID: z.string().optional(),
53
- JCHAT_AWS_SECRET_ACCESS_KEY: z.string().optional(),
54
-
55
- JCHAT_CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
56
- JCHAT_CLOUDFLARE_BUCKET_NAME: z.string().optional(),
57
- JCHAT_CLOUDFLARE_ACCESS_KEY_ID: z.string().optional(),
58
- JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
59
- JCHAT_CLOUDFLARE_PUBLIC_URL: z.string().optional(),
60
-
61
- JCHAT_GCS_BUCKET_NAME: z.string().optional(),
62
- JCHAT_GCS_PROJECT_ID: z.string().optional(),
63
- JCHAT_GCS_CLIENT_EMAIL: z.string().optional(),
64
- JCHAT_GCS_PRIVATE_KEY: z.string().optional(),
65
- JCHAT_GCS_KEY_FILE: z.string().optional(),
66
-
67
- JCHAT_BOT_NAME: z.string().trim().min(3),
68
- JCHAT_BOT_EMAIL: z.email(),
69
-
70
- JCHAT_DOMAIN: z.string().optional(),
71
- JCHAT_SITE_URL: z.url().optional(),
72
- JCHAT_ROUTING_MODE: z.enum(["path", "subdomain"]).default("subdomain"),
73
-
74
- JCHAT_ADDON_ENCRYPTION_KEY: z.string().min(16),
75
-
76
- NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
77
-
78
- SYSTEM_BOOTSTRAP_COMPLETE: z.boolean().optional()
79
- })
80
- .superRefine((env, ctx) => {
81
- if (env.JCHAT_SITE_URL) {
82
- let siteHostname: string | undefined
83
- try {
84
- siteHostname = new URL(env.JCHAT_SITE_URL).hostname
85
- } catch {}
86
-
87
- if (siteHostname && !isMainDomain(siteHostname)) {
88
- ctx.addIssue({
89
- path: ["JCHAT_SITE_URL"],
90
- code: "custom",
91
- message: "JCHAT_SITE_URL must use the client main/root domain"
92
- })
93
- }
94
- }
95
-
96
- if (
97
- env.JCHAT_STORAGE_TYPE === "MINIO" &&
98
- !(
99
- env.JCHAT_MINIO_ENDPOINT &&
100
- env.JCHAT_MINIO_BUCKET_NAME &&
101
- env.JCHAT_MINIO_ACCESS_KEY_ID &&
102
- env.JCHAT_MINIO_SECRET_ACCESS_KEY &&
103
- env.JCHAT_MINIO_PUBLIC_URL
104
- )
105
- ) {
106
- if (!env.JCHAT_MINIO_ENDPOINT)
107
- ctx.addIssue({
108
- path: ["JCHAT_MINIO_ENDPOINT"],
109
- code: "custom",
110
- message: "JCHAT_MINIO_ENDPOINT is required when JCHAT_STORAGE_TYPE is MINIO"
111
- })
112
- if (!env.JCHAT_MINIO_BUCKET_NAME)
113
- ctx.addIssue({
114
- path: ["JCHAT_MINIO_BUCKET_NAME"],
115
- code: "custom",
116
- message: "JCHAT_MINIO_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is MINIO"
117
- })
118
- if (!env.JCHAT_MINIO_ACCESS_KEY_ID)
119
- ctx.addIssue({
120
- path: ["JCHAT_MINIO_ACCESS_KEY_ID"],
121
- code: "custom",
122
- message: "JCHAT_MINIO_ACCESS_KEY_ID is required when JCHAT_STORAGE_TYPE is MINIO"
123
- })
124
- if (!env.JCHAT_MINIO_SECRET_ACCESS_KEY)
125
- ctx.addIssue({
126
- path: ["JCHAT_MINIO_SECRET_ACCESS_KEY"],
127
- code: "custom",
128
- message: "JCHAT_MINIO_SECRET_ACCESS_KEY is required when JCHAT_STORAGE_TYPE is MINIO"
129
- })
130
- if (!env.JCHAT_MINIO_PUBLIC_URL)
131
- ctx.addIssue({
132
- path: ["JCHAT_MINIO_PUBLIC_URL"],
133
- code: "custom",
134
- message: "JCHAT_MINIO_PUBLIC_URL is required when JCHAT_STORAGE_TYPE is MINIO"
135
- })
136
- }
137
-
138
- if (
139
- env.JCHAT_STORAGE_TYPE === "AWS_S3" &&
140
- !(
141
- env.JCHAT_AWS_REGION &&
142
- env.JCHAT_AWS_ACCESS_KEY_ID &&
143
- env.JCHAT_AWS_SECRET_ACCESS_KEY &&
144
- env.JCHAT_AWS_BUCKET_NAME
145
- )
146
- ) {
147
- if (!env.JCHAT_AWS_REGION)
148
- ctx.addIssue({
149
- path: ["JCHAT_AWS_REGION"],
150
- code: "custom",
151
- message: "JCHAT_AWS_REGION is required when JCHAT_STORAGE_TYPE is AWS_S3"
152
- })
153
- if (!env.JCHAT_AWS_ACCESS_KEY_ID)
154
- ctx.addIssue({
155
- path: ["JCHAT_AWS_ACCESS_KEY_ID"],
156
- code: "custom",
157
- message: "JCHAT_AWS_ACCESS_KEY_ID is required when JCHAT_STORAGE_TYPE is AWS_S3"
158
- })
159
- if (!env.JCHAT_AWS_SECRET_ACCESS_KEY)
160
- ctx.addIssue({
161
- path: ["JCHAT_AWS_SECRET_ACCESS_KEY"],
162
- code: "custom",
163
- message: "JCHAT_AWS_SECRET_ACCESS_KEY is required when JCHAT_STORAGE_TYPE is AWS_S3"
164
- })
165
- if (!env.JCHAT_AWS_BUCKET_NAME)
166
- ctx.addIssue({
167
- path: ["JCHAT_AWS_BUCKET_NAME"],
168
- code: "custom",
169
- message: "JCHAT_AWS_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is AWS_S3"
170
- })
171
- }
172
-
173
- if (
174
- env.JCHAT_STORAGE_TYPE === "CLOUDFLARE_R2" &&
175
- !(
176
- env.JCHAT_CLOUDFLARE_ACCOUNT_ID &&
177
- env.JCHAT_CLOUDFLARE_ACCESS_KEY_ID &&
178
- env.JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY &&
179
- env.JCHAT_CLOUDFLARE_BUCKET_NAME &&
180
- env.JCHAT_CLOUDFLARE_PUBLIC_URL
181
- )
182
- ) {
183
- if (!env.JCHAT_CLOUDFLARE_ACCOUNT_ID)
184
- ctx.addIssue({
185
- path: ["JCHAT_CLOUDFLARE_ACCOUNT_ID"],
186
- code: "custom",
187
- message:
188
- "JCHAT_CLOUDFLARE_ACCOUNT_ID is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
189
- })
190
- if (!env.JCHAT_CLOUDFLARE_ACCESS_KEY_ID)
191
- ctx.addIssue({
192
- path: ["JCHAT_CLOUDFLARE_ACCESS_KEY_ID"],
193
- code: "custom",
194
- message:
195
- "JCHAT_CLOUDFLARE_ACCESS_KEY_ID is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
196
- })
197
- if (!env.JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY)
198
- ctx.addIssue({
199
- path: ["JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY"],
200
- code: "custom",
201
- message:
202
- "JCHAT_CLOUDFLARE_SECRET_ACCESS_KEY is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
203
- })
204
- if (!env.JCHAT_CLOUDFLARE_BUCKET_NAME)
205
- ctx.addIssue({
206
- path: ["JCHAT_CLOUDFLARE_BUCKET_NAME"],
207
- code: "custom",
208
- message:
209
- "JCHAT_CLOUDFLARE_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
210
- })
211
- if (!env.JCHAT_CLOUDFLARE_PUBLIC_URL)
212
- ctx.addIssue({
213
- path: ["JCHAT_CLOUDFLARE_PUBLIC_URL"],
214
- code: "custom",
215
- message:
216
- "JCHAT_CLOUDFLARE_PUBLIC_URL is required when JCHAT_STORAGE_TYPE is CLOUDFLARE_R2"
217
- })
218
- }
219
-
220
- if (
221
- env.JCHAT_STORAGE_TYPE === "GCS" &&
222
- !(
223
- env.JCHAT_GCS_PROJECT_ID &&
224
- env.JCHAT_GCS_BUCKET_NAME &&
225
- env.JCHAT_GCS_CLIENT_EMAIL &&
226
- env.JCHAT_GCS_PRIVATE_KEY
227
- )
228
- ) {
229
- if (!env.JCHAT_GCS_PROJECT_ID)
230
- ctx.addIssue({
231
- path: ["JCHAT_GCS_PROJECT_ID"],
232
- code: "custom",
233
- message: "JCHAT_GCS_PROJECT_ID is required when JCHAT_STORAGE_TYPE is GCS"
234
- })
235
- if (!env.JCHAT_GCS_BUCKET_NAME)
236
- ctx.addIssue({
237
- path: ["JCHAT_GCS_BUCKET_NAME"],
238
- code: "custom",
239
- message: "JCHAT_GCS_BUCKET_NAME is required when JCHAT_STORAGE_TYPE is GCS"
240
- })
241
- if (!env.JCHAT_GCS_CLIENT_EMAIL)
242
- ctx.addIssue({
243
- path: ["JCHAT_GCS_CLIENT_EMAIL"],
244
- code: "custom",
245
- message: "JCHAT_GCS_CLIENT_EMAIL is required when JCHAT_STORAGE_TYPE is GCS"
246
- })
247
- if (!env.JCHAT_GCS_PRIVATE_KEY)
248
- ctx.addIssue({
249
- path: ["JCHAT_GCS_PRIVATE_KEY"],
250
- code: "custom",
251
- message: "JCHAT_GCS_PRIVATE_KEY is required when JCHAT_STORAGE_TYPE is GCS"
252
- })
253
- }
254
- })
255
-
256
- 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
- })