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

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/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
- })
package/src/paths.ts DELETED
@@ -1,74 +0,0 @@
1
- import fs from "node:fs"
2
- import path from "node:path"
3
-
4
- interface RuntimePathOptions {
5
- cwd?: string
6
- env?: Record<string, string | undefined>
7
- }
8
-
9
- export function resolveRuntimePaths(options: RuntimePathOptions = {}) {
10
- const runtimeRoot = path.resolve(options.cwd ?? process.cwd())
11
- const env = options.env ?? process.env
12
- const repoRoot = findRepoRoot(runtimeRoot)
13
-
14
- return {
15
- repoRoot,
16
- runtimeRoot,
17
- uploadsDir: resolveConfiguredPath(
18
- env.JCHAT_UPLOADS_DIR,
19
- runtimeRoot,
20
- repoRoot,
21
- "uploads",
22
- "uploads"
23
- ),
24
- addonBundlesDir: resolveConfiguredPath(
25
- env.JCHAT_ADDONS_DIR,
26
- runtimeRoot,
27
- repoRoot,
28
- "addons",
29
- "addons"
30
- ),
31
- addonSourceDir: repoRoot
32
- ? path.join(repoRoot, "addons-dev")
33
- : path.join(runtimeRoot, "addons-dev"),
34
- migrationsDir: resolveConfiguredPath(
35
- env.JCHAT_MIGRATIONS_DIR,
36
- runtimeRoot,
37
- repoRoot,
38
- path.join("packages", "db", "migrations"),
39
- "migrations"
40
- )
41
- }
42
- }
43
-
44
- function hasRepoMarkers(dir: string) {
45
- return (
46
- fs.existsSync(path.join(dir, "package.json")) &&
47
- fs.existsSync(path.join(dir, "apps")) &&
48
- fs.existsSync(path.join(dir, "packages"))
49
- )
50
- }
51
-
52
- function findRepoRoot(startDir: string) {
53
- let current = path.resolve(startDir)
54
-
55
- while (true) {
56
- if (hasRepoMarkers(current)) return current
57
-
58
- const parent = path.dirname(current)
59
- if (parent === current) return null
60
- current = parent
61
- }
62
- }
63
-
64
- function resolveConfiguredPath(
65
- configuredPath: string | undefined,
66
- runtimeRoot: string,
67
- repoRoot: string | null,
68
- repoRelativePath: string,
69
- runtimeRelativePath: string
70
- ) {
71
- if (configuredPath) return path.resolve(configuredPath)
72
- if (repoRoot) return path.join(repoRoot, repoRelativePath)
73
- return path.join(runtimeRoot, runtimeRelativePath)
74
- }
@@ -1,21 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import {
3
- getRuntimeStatus,
4
- isApplicationDisabled,
5
- registerInvalidationHandler,
6
- setRuntimeStatus
7
- } from "./runtime"
8
-
9
- describe("license runtime status", () => {
10
- test("disables the application and invokes the invalidation handler", () => {
11
- let shutdowns = 0
12
- registerInvalidationHandler(() => {
13
- shutdowns += 1
14
- })
15
- setRuntimeStatus("invalid", "support-1")
16
- expect(isApplicationDisabled()).toBe(true)
17
- expect(getRuntimeStatus()).toEqual({ status: "invalid", reference: "support-1" })
18
- expect(shutdowns).toBe(1)
19
- setRuntimeStatus("valid", "support-2")
20
- })
21
- })
package/src/runtime.ts DELETED
@@ -1,31 +0,0 @@
1
- import crypto from "node:crypto"
2
-
3
- export type RuntimeStatus = "valid" | "unreachable-fallback" | "invalid" | "unavailable"
4
-
5
- let runtimeStatus: RuntimeStatus = "unavailable"
6
- let supportReference = "startup"
7
- let shutdownHandler: (() => void) | undefined
8
-
9
- export function registerInvalidationHandler(handler: () => void): void {
10
- shutdownHandler = handler
11
- }
12
-
13
- export function setRuntimeStatus(
14
- status: RuntimeStatus,
15
- reference: string = crypto.randomUUID()
16
- ): void {
17
- runtimeStatus = status
18
- supportReference = reference
19
- if (status === "invalid") shutdownHandler?.()
20
- }
21
-
22
- export function getRuntimeStatus(): {
23
- status: RuntimeStatus
24
- reference: string
25
- } {
26
- return { status: runtimeStatus, reference: supportReference }
27
- }
28
-
29
- export function isApplicationDisabled(): boolean {
30
- return runtimeStatus === "invalid"
31
- }
package/src/state.test.ts DELETED
@@ -1,54 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test"
2
- import fs from "node:fs/promises"
3
- import os from "node:os"
4
- import path from "node:path"
5
- import { acquireProcessLock, createIdentity, readState, readTokenSync, writeState } from "./state"
6
-
7
- const temporaryDirectories: string[] = []
8
-
9
- async function createTemporaryDirectory(prefix: string): Promise<string> {
10
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
11
- temporaryDirectories.push(directory)
12
- return directory
13
- }
14
-
15
- describe("license state", () => {
16
- afterEach(async () => {
17
- await Promise.all(
18
- temporaryDirectories
19
- .splice(0)
20
- .map((directory) => fs.rm(directory, { recursive: true, force: true }))
21
- )
22
- })
23
-
24
- test("persists and reads a complete state atomically", async () => {
25
- const directory = await createTemporaryDirectory("jchat-runtime-")
26
- const filePath = path.join(directory, "license.json")
27
- const identity = createIdentity("machine-a")
28
- const state = { version: 1 as const, ...identity, instanceToken: "signed-license" }
29
- await writeState(state, filePath)
30
- expect(await readState(filePath)).toEqual(state)
31
- expect(readTokenSync(filePath)).toBe("signed-license")
32
- })
33
-
34
- test("rejects incomplete local state", async () => {
35
- const directory = await createTemporaryDirectory("jchat-runtime-invalid-")
36
- const filePath = path.join(directory, "license.json")
37
- await Bun.write(
38
- filePath,
39
- JSON.stringify({ version: 1, installationId: "installation-1", instanceToken: "token" })
40
- )
41
-
42
- expect(await readState(filePath)).toBeNull()
43
- })
44
-
45
- test("prevents two processes from acquiring the same local lock", async () => {
46
- const directory = await createTemporaryDirectory("jchat-runtime-lock-")
47
- const filePath = path.join(directory, "license.json")
48
- const release = await acquireProcessLock(filePath)
49
- await expect(acquireProcessLock(filePath)).rejects.toBeTruthy()
50
- await release()
51
- const secondRelease = await acquireProcessLock(filePath)
52
- await secondRelease()
53
- })
54
- })
package/src/state.ts DELETED
@@ -1,129 +0,0 @@
1
- import crypto from "node:crypto"
2
- import fsSync from "node:fs"
3
- import fs from "node:fs/promises"
4
- import os from "node:os"
5
- import path from "node:path"
6
-
7
- export type LocalState = {
8
- version: 1
9
- installationId: string
10
- privateKeyPem: string
11
- publicKeyPem: string
12
- machineFingerprint: string
13
- instanceToken: string
14
- lastSuccessfulCheckAt?: string
15
- }
16
-
17
- export const DEFAULT_STATE_PATH = path.resolve(process.cwd(), "data/instance.json")
18
-
19
- export function createMachineFingerprint(): string {
20
- return crypto
21
- .createHash("sha256")
22
- .update([os.hostname(), os.platform(), os.arch()].join("|"))
23
- .digest("hex")
24
- }
25
-
26
- export function createIdentity(machineFingerprint = createMachineFingerprint()) {
27
- const keyPair = crypto.generateKeyPairSync("ed25519")
28
- return {
29
- installationId: crypto.randomUUID(),
30
- machineFingerprint,
31
- privateKeyPem: keyPair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
32
- publicKeyPem: keyPair.publicKey.export({ type: "spki", format: "pem" }).toString()
33
- }
34
- }
35
-
36
- export function readTokenSync(filePath = DEFAULT_STATE_PATH): string {
37
- try {
38
- if (!fsSync.existsSync(filePath)) return ""
39
- const content = fsSync.readFileSync(filePath, "utf8")
40
- const parsed = JSON.parse(content) as LocalState
41
- return parsed?.instanceToken ?? ""
42
- } catch {
43
- return ""
44
- }
45
- }
46
-
47
- export async function readState(filePath = DEFAULT_STATE_PATH): Promise<LocalState | null> {
48
- try {
49
- const parsed: unknown = JSON.parse(await Bun.file(filePath).text())
50
- if (!parsed || typeof parsed !== "object") return null
51
- const state = parsed as Partial<LocalState>
52
- if (
53
- state.version !== 1 ||
54
- typeof state.installationId !== "string" ||
55
- !state.installationId ||
56
- typeof state.privateKeyPem !== "string" ||
57
- !state.privateKeyPem ||
58
- typeof state.publicKeyPem !== "string" ||
59
- !state.publicKeyPem ||
60
- typeof state.machineFingerprint !== "string" ||
61
- !state.machineFingerprint ||
62
- typeof state.instanceToken !== "string" ||
63
- !state.instanceToken ||
64
- (state.lastSuccessfulCheckAt !== undefined &&
65
- (typeof state.lastSuccessfulCheckAt !== "string" || !state.lastSuccessfulCheckAt))
66
- ) {
67
- return null
68
- }
69
- return state as LocalState
70
- } catch {
71
- return null
72
- }
73
- }
74
-
75
- export async function writeState(state: LocalState, filePath = DEFAULT_STATE_PATH): Promise<void> {
76
- const directory = path.dirname(filePath)
77
- await fs.mkdir(directory, { recursive: true })
78
- const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`
79
- await Bun.write(temporaryPath, `${JSON.stringify(state, null, 2)}\n`)
80
- await fs.rename(temporaryPath, filePath)
81
- }
82
-
83
- export async function updateInstanceToken(
84
- instanceToken: string,
85
- statePath = DEFAULT_STATE_PATH
86
- ): Promise<void> {
87
- const localState = await readState(statePath)
88
- if (!localState) throw new Error("Local license state is missing")
89
- await writeState(
90
- { ...localState, instanceToken, lastSuccessfulCheckAt: new Date().toISOString() },
91
- statePath
92
- )
93
- }
94
-
95
- export async function acquireProcessLock(
96
- statePath = DEFAULT_STATE_PATH
97
- ): Promise<() => Promise<void>> {
98
- const lockPath = `${statePath}.lock`
99
- await fs.mkdir(path.dirname(lockPath), { recursive: true })
100
- let handle
101
- try {
102
- handle = await fs.open(lockPath, "wx")
103
- } catch (error) {
104
- if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") throw error
105
- const ownerPid = Number.parseInt(
106
- await Bun.file(lockPath)
107
- .text()
108
- .catch(() => ""),
109
- 10
110
- )
111
- let ownerAlive = false
112
- if (Number.isInteger(ownerPid) && ownerPid > 0) {
113
- try {
114
- process.kill(ownerPid, 0)
115
- ownerAlive = true
116
- } catch {
117
- ownerAlive = false
118
- }
119
- }
120
- if (ownerAlive) throw new Error("Another JChat process already owns the license lock")
121
- await fs.rm(lockPath, { force: true })
122
- handle = await fs.open(lockPath, "wx")
123
- }
124
- await handle.writeFile(`${process.pid}\n`)
125
- return async () => {
126
- await handle.close()
127
- await fs.rm(lockPath, { force: true })
128
- }
129
- }