@highstate/backend 0.9.37 → 0.11.3
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/dist/highstate.manifest.json +5 -5
- package/dist/index.js +44 -15
- package/dist/index.js.map +1 -1
- package/package.json +18 -22
- package/src/database/local/backend.ts +20 -9
- package/src/database/local/keyring.test.ts +89 -0
- package/src/database/local/keyring.ts +52 -2
- package/src/terminal/run.sh.ts +0 -0
package/package.json
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@highstate/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"highstate": {
|
|
6
|
-
"sourceHash": {
|
|
7
|
-
"mode": "manual",
|
|
8
|
-
"version": ""
|
|
9
|
-
}
|
|
10
|
-
},
|
|
11
5
|
"files": [
|
|
12
6
|
"dist",
|
|
13
7
|
"src",
|
|
@@ -29,19 +23,9 @@
|
|
|
29
23
|
"publishConfig": {
|
|
30
24
|
"access": "public"
|
|
31
25
|
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "highstate build",
|
|
34
|
-
"typecheck": "tsgo --noEmit --skipLibCheck",
|
|
35
|
-
"biome": "biome check --write --unsafe --error-on-warnings",
|
|
36
|
-
"biome:check": "biome check --error-on-warnings",
|
|
37
|
-
"test": "vitest --run",
|
|
38
|
-
"generate:project": "yarn prisma generate --schema prisma/project",
|
|
39
|
-
"generate:backend": "yarn prisma generate --schema prisma/backend/postgresql && yarn prisma generate --schema prisma/backend/sqlite",
|
|
40
|
-
"migrate": "yarn prisma migrate deploy --config dist/database/local/prisma.config.js"
|
|
41
|
-
},
|
|
42
26
|
"dependencies": {
|
|
43
27
|
"@aws-crypto/crc32": "^5.2.0",
|
|
44
|
-
"@highstate/contract": "^0.
|
|
28
|
+
"@highstate/contract": "^0.10.0",
|
|
45
29
|
"@msgpack/msgpack": "^3.1.2",
|
|
46
30
|
"@napi-rs/keyring": "^1.1.8",
|
|
47
31
|
"@noble/ciphers": "^1.3.0",
|
|
@@ -50,12 +34,12 @@
|
|
|
50
34
|
"@prisma/adapter-libsql": "^6.14.0",
|
|
51
35
|
"@prisma/client": "^6.14.0",
|
|
52
36
|
"@prisma/driver-adapter-utils": "^6.14.0",
|
|
53
|
-
"@types/node": "^22.10.1",
|
|
54
37
|
"age-encryption": "^0.2.3",
|
|
55
38
|
"ajv": "^8.17.1",
|
|
56
39
|
"ansi-colors": "^4.1.3",
|
|
57
40
|
"ansi-styles": "^6.2.1",
|
|
58
41
|
"better-lock": "^3.2.0",
|
|
42
|
+
"buffer": "^6.0.3",
|
|
59
43
|
"consola": "^3.2.3",
|
|
60
44
|
"crypto-hash": "^3.1.0",
|
|
61
45
|
"dotenv": "^16.4.7",
|
|
@@ -92,7 +76,7 @@
|
|
|
92
76
|
"@biomejs/biome": "2.2.0",
|
|
93
77
|
"@typescript/native-preview": "^7.0.0-dev.20250920.1",
|
|
94
78
|
"classic-level": "^3.0.0",
|
|
95
|
-
"highstate-cli-bootstrap": "
|
|
79
|
+
"highstate-cli-bootstrap": "npm:@highstate/cli",
|
|
96
80
|
"pino-pretty": "^13.0.0",
|
|
97
81
|
"prisma-json-types-generator": "^3.5.2",
|
|
98
82
|
"rollup": "^4.28.1",
|
|
@@ -100,5 +84,17 @@
|
|
|
100
84
|
"type-fest": "^4.41.0",
|
|
101
85
|
"vitest": "^3.2.4"
|
|
102
86
|
},
|
|
103
|
-
"
|
|
104
|
-
|
|
87
|
+
"repository": {
|
|
88
|
+
"url": "https://github.com/highstate-io/highstate"
|
|
89
|
+
},
|
|
90
|
+
"scripts": {
|
|
91
|
+
"build": "highstate build",
|
|
92
|
+
"typecheck": "tsgo --noEmit --skipLibCheck",
|
|
93
|
+
"biome": "biome check --write --unsafe --error-on-warnings",
|
|
94
|
+
"biome:check": "biome check --error-on-warnings",
|
|
95
|
+
"test": "vitest --run",
|
|
96
|
+
"generate:project": "prisma generate --schema prisma/project",
|
|
97
|
+
"generate:backend": "prisma generate --schema prisma/backend/postgresql && prisma generate --schema prisma/backend/sqlite",
|
|
98
|
+
"migrate": "prisma migrate deploy --config dist/database/local/prisma.config.js"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -10,12 +10,17 @@ import { PrismaClient } from "../_generated/backend/sqlite/client"
|
|
|
10
10
|
import { type BackendDatabaseBackend, backendDatabaseVersion } from "../abstractions"
|
|
11
11
|
import { migrateDatabase } from "../migrate"
|
|
12
12
|
import { ensureWellKnownEntitiesCreated } from "../well-known"
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type BackendIdentityConfig,
|
|
15
|
+
backendIdentityConfig,
|
|
16
|
+
getOrCreateBackendIdentity,
|
|
17
|
+
} from "./keyring"
|
|
14
18
|
import { type DatabaseMetaFile, readMetaFile, writeMetaFile } from "./meta"
|
|
15
19
|
|
|
16
20
|
export const localBackendDatabaseConfig = z.object({
|
|
17
21
|
...codebaseConfig.shape,
|
|
18
22
|
HIGHSTATE_BACKEND_DATABASE_LOCAL_PATH: z.string().optional(),
|
|
23
|
+
...backendIdentityConfig.shape,
|
|
19
24
|
HIGHSTATE_ENCRYPTION_ENABLED: z.stringbool().default(true),
|
|
20
25
|
})
|
|
21
26
|
|
|
@@ -26,6 +31,7 @@ class LocalBackendDatabaseBackend implements BackendDatabaseBackend {
|
|
|
26
31
|
constructor(
|
|
27
32
|
readonly database: BackendDatabase,
|
|
28
33
|
private readonly databasePath: string,
|
|
34
|
+
private readonly config: BackendIdentityConfig,
|
|
29
35
|
private readonly logger: Logger,
|
|
30
36
|
readonly isEncryptionEnabled: boolean,
|
|
31
37
|
) {}
|
|
@@ -49,7 +55,7 @@ class LocalBackendDatabaseBackend implements BackendDatabaseBackend {
|
|
|
49
55
|
return
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
const identity = await getOrCreateBackendIdentity(this.logger)
|
|
58
|
+
const identity = await getOrCreateBackendIdentity(this.config, this.logger)
|
|
53
59
|
const decrypter = new Decrypter()
|
|
54
60
|
decrypter.addIdentity(identity)
|
|
55
61
|
|
|
@@ -72,8 +78,8 @@ class LocalBackendDatabaseBackend implements BackendDatabaseBackend {
|
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
async function createMasterKey(logger: Logger) {
|
|
76
|
-
const identity = await getOrCreateBackendIdentity(logger)
|
|
81
|
+
async function createMasterKey(config: BackendIdentityConfig, logger: Logger) {
|
|
82
|
+
const identity = await getOrCreateBackendIdentity(config, logger)
|
|
77
83
|
|
|
78
84
|
const masterKey = randomBytes(32).toString("hex")
|
|
79
85
|
const encrypter = new Encrypter()
|
|
@@ -98,6 +104,7 @@ type DatabaseInitializationResult = {
|
|
|
98
104
|
async function ensureDatabaseInitialized(
|
|
99
105
|
databasePath: string,
|
|
100
106
|
encryptionEnabled: boolean,
|
|
107
|
+
config: BackendIdentityConfig,
|
|
101
108
|
logger: Logger,
|
|
102
109
|
): Promise<DatabaseInitializationResult> {
|
|
103
110
|
const meta = await readMetaFile(databasePath)
|
|
@@ -105,7 +112,7 @@ async function ensureDatabaseInitialized(
|
|
|
105
112
|
if (!meta) {
|
|
106
113
|
logger.info("creating new database")
|
|
107
114
|
|
|
108
|
-
const masterKey = encryptionEnabled ? await createMasterKey(logger) : undefined
|
|
115
|
+
const masterKey = encryptionEnabled ? await createMasterKey(config, logger) : undefined
|
|
109
116
|
|
|
110
117
|
const metaFile: DatabaseMetaFile = {
|
|
111
118
|
version: backendDatabaseVersion,
|
|
@@ -142,7 +149,7 @@ async function ensureDatabaseInitialized(
|
|
|
142
149
|
)
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
const identity = await getOrCreateBackendIdentity(logger)
|
|
152
|
+
const identity = await getOrCreateBackendIdentity(config, logger)
|
|
146
153
|
|
|
147
154
|
const decrypter = new Decrypter()
|
|
148
155
|
decrypter.addIdentity(identity)
|
|
@@ -150,8 +157,6 @@ async function ensureDatabaseInitialized(
|
|
|
150
157
|
const encryptedMasterKey = armor.decode(meta.masterKey)
|
|
151
158
|
const masterKey = await decrypter.decrypt(encryptedMasterKey, "text")
|
|
152
159
|
|
|
153
|
-
logger.info("loaded backend master key using OS keyring")
|
|
154
|
-
|
|
155
160
|
return {
|
|
156
161
|
shouldMigrate: meta.version < backendDatabaseVersion,
|
|
157
162
|
masterKey,
|
|
@@ -179,7 +184,12 @@ export async function createLocalBackendDatabaseBackend(
|
|
|
179
184
|
databasePath ??= await getCodebaseHighstatePath(config, logger)
|
|
180
185
|
|
|
181
186
|
const { shouldMigrate, masterKey, metaFile, created, initialRecipient } =
|
|
182
|
-
await ensureDatabaseInitialized(
|
|
187
|
+
await ensureDatabaseInitialized(
|
|
188
|
+
databasePath,
|
|
189
|
+
config.HIGHSTATE_ENCRYPTION_ENABLED,
|
|
190
|
+
config,
|
|
191
|
+
logger,
|
|
192
|
+
)
|
|
183
193
|
|
|
184
194
|
const databaseUrl = `file:${databasePath}/backend.db`
|
|
185
195
|
|
|
@@ -211,6 +221,7 @@ export async function createLocalBackendDatabaseBackend(
|
|
|
211
221
|
return new LocalBackendDatabaseBackend(
|
|
212
222
|
database,
|
|
213
223
|
databasePath,
|
|
224
|
+
config,
|
|
214
225
|
backendLogger,
|
|
215
226
|
config.HIGHSTATE_ENCRYPTION_ENABLED,
|
|
216
227
|
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import { writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { generateIdentity } from "age-encryption"
|
|
6
|
+
import pino from "pino"
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
8
|
+
import { type BackendIdentityConfig, getOrCreateBackendIdentity } from "./keyring"
|
|
9
|
+
|
|
10
|
+
const createLogger = (): Logger => pino({ level: "silent" })
|
|
11
|
+
|
|
12
|
+
describe("getOrCreateBackendIdentity", () => {
|
|
13
|
+
let tempFilePath: string
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempFilePath = join(tmpdir(), `test-identity-${Date.now()}.key`)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const { unlink } = await import("node:fs/promises")
|
|
22
|
+
await unlink(tempFilePath)
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore cleanup errors
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns identity from HIGHSTATE_BACKEND_DATABASE_IDENTITY", async () => {
|
|
29
|
+
const testIdentity = await generateIdentity()
|
|
30
|
+
const config: BackendIdentityConfig = {
|
|
31
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY: testIdentity,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const identity = await getOrCreateBackendIdentity(config, createLogger())
|
|
35
|
+
|
|
36
|
+
expect(identity).toBe(testIdentity)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("loads identity from HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH", async () => {
|
|
40
|
+
const testIdentity = await generateIdentity()
|
|
41
|
+
await writeFile(tempFilePath, testIdentity)
|
|
42
|
+
|
|
43
|
+
const config: BackendIdentityConfig = {
|
|
44
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH: tempFilePath,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const identity = await getOrCreateBackendIdentity(config, createLogger())
|
|
48
|
+
|
|
49
|
+
expect(identity).toBe(testIdentity)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("trims whitespace from file-based identity", async () => {
|
|
53
|
+
const testIdentity = await generateIdentity()
|
|
54
|
+
await writeFile(tempFilePath, ` ${testIdentity}\n\n`)
|
|
55
|
+
|
|
56
|
+
const config: BackendIdentityConfig = {
|
|
57
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH: tempFilePath,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const identity = await getOrCreateBackendIdentity(config, createLogger())
|
|
61
|
+
|
|
62
|
+
expect(identity).toBe(testIdentity)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("throws error if identity file does not exist", async () => {
|
|
66
|
+
const config: BackendIdentityConfig = {
|
|
67
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH: "/nonexistent/path/to/identity.key",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await expect(getOrCreateBackendIdentity(config, createLogger())).rejects.toThrow(
|
|
71
|
+
'Failed to read backend identity from "/nonexistent/path/to/identity.key"',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("prioritizes HIGHSTATE_BACKEND_DATABASE_IDENTITY over PATH", async () => {
|
|
76
|
+
const directIdentity = await generateIdentity()
|
|
77
|
+
const fileIdentity = await generateIdentity()
|
|
78
|
+
await writeFile(tempFilePath, fileIdentity)
|
|
79
|
+
|
|
80
|
+
const config: BackendIdentityConfig = {
|
|
81
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY: directIdentity,
|
|
82
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH: tempFilePath,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const identity = await getOrCreateBackendIdentity(config, createLogger())
|
|
86
|
+
|
|
87
|
+
expect(identity).toBe(directIdentity)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -1,16 +1,66 @@
|
|
|
1
1
|
import type { Logger } from "pino"
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
2
3
|
import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring"
|
|
3
4
|
import { generateIdentity } from "age-encryption"
|
|
5
|
+
import { z } from "zod"
|
|
4
6
|
|
|
5
7
|
const serviceName = "io.highstate.backend"
|
|
6
8
|
const accountName = "identity"
|
|
7
9
|
|
|
8
|
-
export
|
|
10
|
+
export const backendIdentityConfig = z.object({
|
|
11
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY: z.string().optional(),
|
|
12
|
+
HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH: z.string().optional(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type BackendIdentityConfig = z.infer<typeof backendIdentityConfig>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Retrieves or creates the backend identity for database encryption.
|
|
19
|
+
*
|
|
20
|
+
* The identity can be loaded from multiple sources in this priority order:
|
|
21
|
+
* 1. Environment variable HIGHSTATE_BACKEND_DATABASE_IDENTITY (direct identity string)
|
|
22
|
+
* 2. Environment variable HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH (path to identity file)
|
|
23
|
+
* 3. OS keyring (default behavior - creates new identity if not found)
|
|
24
|
+
*
|
|
25
|
+
* When using environment variables, the OS keyring is bypassed entirely.
|
|
26
|
+
* This is useful for environments where OS keyring is not available or for automation.
|
|
27
|
+
*
|
|
28
|
+
* @param config Configuration object containing optional identity environment variables.
|
|
29
|
+
* @param logger Logger instance for recording identity source and operations.
|
|
30
|
+
* @returns The AGE identity string used to decrypt the backend master key.
|
|
31
|
+
*/
|
|
32
|
+
export async function getOrCreateBackendIdentity(
|
|
33
|
+
config: BackendIdentityConfig,
|
|
34
|
+
logger: Logger,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
// priority 1: direct identity from environment variable
|
|
37
|
+
if (config.HIGHSTATE_BACKEND_DATABASE_IDENTITY) {
|
|
38
|
+
logger.info("using backend identity from HIGHSTATE_BACKEND_DATABASE_IDENTITY")
|
|
39
|
+
return config.HIGHSTATE_BACKEND_DATABASE_IDENTITY
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// priority 2: identity from file path specified in environment variable
|
|
43
|
+
if (config.HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH) {
|
|
44
|
+
try {
|
|
45
|
+
const identity = await readFile(config.HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH, "utf-8")
|
|
46
|
+
logger.info(
|
|
47
|
+
`using backend identity from path specified in HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH`,
|
|
48
|
+
)
|
|
49
|
+
return identity.trim()
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Failed to read backend identity from "${config.HIGHSTATE_BACKEND_DATABASE_IDENTITY_PATH}"`,
|
|
53
|
+
{ cause: error },
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// priority 3: OS keyring (default behavior)
|
|
9
59
|
const credentials = await findCredentialsAsync(serviceName)
|
|
10
60
|
const entry = credentials.find(entry => entry.account === accountName)
|
|
11
61
|
|
|
12
62
|
if (entry) {
|
|
13
|
-
logger.
|
|
63
|
+
logger.info("using backend identity from OS keyring")
|
|
14
64
|
return entry.password
|
|
15
65
|
}
|
|
16
66
|
|
package/src/terminal/run.sh.ts
CHANGED
|
File without changes
|