@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/package.json CHANGED
@@ -1,13 +1,7 @@
1
1
  {
2
2
  "name": "@highstate/backend",
3
- "version": "0.9.37",
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.9.37",
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": "patch:@highstate/cli@npm%3A0.9.16#~/.yarn/patches/@highstate-cli-npm-0.9.16-e03b564691.patch",
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
- "gitHead": "67c620e16be8f1244181539b4799c9990a2d9449"
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 { getOrCreateBackendIdentity } from "./keyring"
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(databasePath, config.HIGHSTATE_ENCRYPTION_ENABLED, logger)
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 async function getOrCreateBackendIdentity(logger: Logger): Promise<string> {
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.debug("found existing backend identity in keyring")
63
+ logger.info("using backend identity from OS keyring")
14
64
  return entry.password
15
65
  }
16
66
 
File without changes