@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/CHANGELOG.md +106 -0
- package/LICENSE +18 -122
- package/README.md +6 -4
- package/dist/index.d.mts +124 -65
- package/dist/index.mjs +1 -1
- package/package.json +21 -13
- 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 -256
- 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/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
|
-
}
|
package/src/runtime.test.ts
DELETED
|
@@ -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
|
-
}
|