@highstate/backend 0.9.26 → 0.9.28

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.
Files changed (99) hide show
  1. package/dist/{chunk-GJTMJQUW.js → chunk-QSHSXLO2.js} +18 -9
  2. package/dist/chunk-QSHSXLO2.js.map +1 -0
  3. package/dist/index.js +292 -132
  4. package/dist/index.js.map +1 -1
  5. package/dist/shared/index.js +1 -1
  6. package/package.json +3 -3
  7. package/prisma/backend/_schema/layout.prisma +3 -1
  8. package/prisma/backend/_schema/project.prisma +4 -2
  9. package/prisma/backend/_schema/unlock-method.prisma +19 -0
  10. package/prisma/backend/sqlite/migrations/{20250818082732_add_models → 20250928124105_initial_migration}/migration.sql +48 -16
  11. package/prisma/project/api-key.prisma +3 -1
  12. package/prisma/project/artifact.prisma +2 -2
  13. package/prisma/project/custom-status.prisma +1 -1
  14. package/prisma/project/layout.prisma +4 -0
  15. package/prisma/project/migrations/{20250816081310_initial → 20250928130725_initial_migration}/migration.sql +132 -46
  16. package/prisma/project/terminal.prisma +2 -2
  17. package/prisma/project/unlock-method.prisma +1 -1
  18. package/prisma/project/worker.prisma +1 -1
  19. package/src/business/backend-unlock.test.ts +133 -0
  20. package/src/business/backend-unlock.ts +76 -0
  21. package/src/business/index.ts +1 -0
  22. package/src/business/settings.test.ts +3 -2
  23. package/src/database/_generated/backend/postgresql/client.ts +9 -4
  24. package/src/database/_generated/backend/postgresql/internal/class.ts +147 -168
  25. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +127 -40
  26. package/src/database/_generated/backend/postgresql/models/BackendUnlockMethod.ts +1156 -0
  27. package/src/database/_generated/backend/postgresql/models/Project.ts +2 -2
  28. package/src/database/_generated/backend/postgresql/models/ProjectSpace.ts +7 -1
  29. package/src/database/_generated/backend/postgresql/models/UserWorkspaceLayout.ts +1067 -0
  30. package/src/database/_generated/backend/postgresql/models.ts +2 -1
  31. package/src/database/_generated/backend/sqlite/client.ts +9 -4
  32. package/src/database/_generated/backend/sqlite/internal/class.ts +146 -165
  33. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +127 -40
  34. package/src/database/_generated/backend/sqlite/models/BackendUnlockMethod.ts +1154 -0
  35. package/src/database/_generated/backend/sqlite/models/Project.ts +2 -2
  36. package/src/database/_generated/backend/sqlite/models/ProjectSpace.ts +7 -1
  37. package/src/database/_generated/backend/sqlite/models/UserWorkspaceLayout.ts +1065 -0
  38. package/src/database/_generated/backend/sqlite/models.ts +2 -1
  39. package/src/database/_generated/project/commonInputTypes.ts +26 -26
  40. package/src/database/_generated/project/internal/class.ts +7 -8
  41. package/src/database/_generated/project/internal/prismaNamespace.ts +8 -7
  42. package/src/database/_generated/project/models/ApiKey.ts +2 -0
  43. package/src/database/_generated/project/models/Artifact.ts +2 -2
  44. package/src/database/_generated/project/models/InstanceCustomStatus.ts +1 -1
  45. package/src/database/_generated/project/models/OperationLog.ts +49 -1
  46. package/src/database/_generated/project/models/UnlockMethod.ts +2 -2
  47. package/src/database/_generated/project/models/UserCompositeViewport.ts +16 -14
  48. package/src/database/_generated/project/models/UserProjectViewport.ts +11 -9
  49. package/src/database/_generated/project/models/WorkerVersion.ts +1 -5
  50. package/src/database/abstractions.ts +25 -3
  51. package/src/database/factory.ts +5 -6
  52. package/src/database/local/backend.ts +148 -18
  53. package/src/database/manager.ts +30 -2
  54. package/src/database/prisma.ts +1 -0
  55. package/src/orchestrator/operation-plan.ts +0 -19
  56. package/src/orchestrator/operation.ts +21 -4
  57. package/src/services.ts +12 -3
  58. package/src/shared/models/backend/unlock-method.ts +7 -13
  59. package/src/shared/models/errors.ts +14 -0
  60. package/src/shared/models/prisma.ts +10 -2
  61. package/src/test-utils/database.ts +34 -6
  62. package/dist/chunk-GJTMJQUW.js.map +0 -1
  63. package/prisma/backend/sqlite/migrations/20250817070609_initiial/migration.sql +0 -34
  64. package/prisma/backend/sqlite/migrations/20250817104948_add_fields/migration.sql +0 -59
  65. package/prisma/backend/sqlite/migrations/20250818083106_a/migration.sql +0 -19
  66. package/prisma/backend/sqlite/migrations/20250818101945_hi/migration.sql +0 -1
  67. package/prisma/backend/sqlite/migrations/20250819082315_a/migration.sql +0 -5
  68. package/prisma/project/migrations/20250816082523_test/migration.sql +0 -72
  69. package/prisma/project/migrations/20250818065643_update/migration.sql +0 -42
  70. package/prisma/project/migrations/20250818070758_a/migration.sql +0 -8
  71. package/prisma/project/migrations/20250818070913_a/migration.sql +0 -8
  72. package/prisma/project/migrations/20250818082720_add_motels/migration.sql +0 -11
  73. package/prisma/project/migrations/20250818112523_hello/migration.sql +0 -35
  74. package/prisma/project/migrations/20250819082305_a/migration.sql +0 -14
  75. package/prisma/project/migrations/20250819165004_add_missing_fields/migration.sql +0 -216
  76. package/prisma/project/migrations/20250819171309_a/migration.sql +0 -22
  77. package/prisma/project/migrations/20250820113949_a/migration.sql +0 -66
  78. package/prisma/project/migrations/20250820144256_b/migration.sql +0 -31
  79. package/prisma/project/migrations/20250820145547_a/migration.sql +0 -24
  80. package/prisma/project/migrations/20250820182517_b/migration.sql +0 -2
  81. package/prisma/project/migrations/20250821172324_a/migration.sql +0 -2
  82. package/prisma/project/migrations/20250822081339_a/migration.sql +0 -219
  83. package/prisma/project/migrations/20250822083742_b/migration.sql +0 -1
  84. package/prisma/project/migrations/20250822105134_boom/migration.sql +0 -1
  85. package/prisma/project/migrations/20250822141028_b/migration.sql +0 -1
  86. package/prisma/project/migrations/20250822142342_b/migration.sql +0 -16
  87. package/prisma/project/migrations/20250824072720_a/migration.sql +0 -1
  88. package/prisma/project/migrations/20250824093656_b/migration.sql +0 -21
  89. package/prisma/project/migrations/20250825082518_a/migration.sql +0 -1
  90. package/prisma/project/migrations/20250825085343_b/migration.sql +0 -1
  91. package/prisma/project/migrations/20250825091312_a/migration.sql +0 -1
  92. package/prisma/project/migrations/20250903095431_hi/migration.sql +0 -44
  93. package/prisma/project/migrations/20250903174255_a/migration.sql +0 -24
  94. package/prisma/project/migrations/20250908095205_hi/migration.sql +0 -18
  95. package/prisma/project/migrations/20250909155857_hi/migration.sql +0 -15
  96. package/prisma/project/migrations/20250921092621_b/migration.sql +0 -33
  97. package/prisma/project/migrations/20250921093911_b/migration.sql +0 -1
  98. package/src/database/_generated/backend/postgresql/models/UserWorkspaseLayout.ts +0 -1065
  99. package/src/database/_generated/backend/sqlite/models/UserWorkspaseLayout.ts +0 -1063
@@ -1,12 +1,13 @@
1
1
  import type { Logger } from "pino"
2
2
  import type { BackendDatabase } from "../prisma"
3
3
  import { randomBytes } from "node:crypto"
4
+ import { hostname } from "node:os"
4
5
  import { PrismaLibSQL } from "@prisma/adapter-libsql"
5
6
  import { armor, Decrypter, Encrypter, identityToRecipient } from "age-encryption"
6
7
  import { z } from "zod"
7
8
  import { codebaseConfig, getCodebaseHighstatePath } from "../../common"
8
9
  import { PrismaClient } from "../_generated/backend/sqlite/client"
9
- import { backendDatabaseVersion } from "../abstractions"
10
+ import { type BackendDatabaseBackend, backendDatabaseVersion } from "../abstractions"
10
11
  import { migrateDatabase } from "../migrate"
11
12
  import { ensureWellKnownEntitiesCreated } from "../well-known"
12
13
  import { getOrCreateBackendIdentity } from "./keyring"
@@ -18,6 +19,59 @@ export const localBackendDatabaseConfig = z.object({
18
19
  HIGHSTATE_ENCRYPTION_ENABLED: z.stringbool().default(true),
19
20
  })
20
21
 
22
+ /**
23
+ * Local implementation backed by a LibSQL database with optional encryption.
24
+ */
25
+ class LocalBackendDatabaseBackend implements BackendDatabaseBackend {
26
+ constructor(
27
+ readonly database: BackendDatabase,
28
+ private readonly databasePath: string,
29
+ private readonly logger: Logger,
30
+ readonly isEncryptionEnabled: boolean,
31
+ ) {}
32
+
33
+ /**
34
+ * Rewrites the encrypted master key to match the provided recipients.
35
+ *
36
+ * @param recipients AGE recipients that should retain access to the backend master key.
37
+ */
38
+ async reencryptMasterKey(recipients: string[]): Promise<void> {
39
+ if (!this.isEncryptionEnabled) {
40
+ return
41
+ }
42
+
43
+ const meta = await readMetaFile(this.databasePath)
44
+ if (!meta?.masterKey) {
45
+ this.logger.warn(
46
+ { databasePath: this.databasePath },
47
+ "backend meta file does not contain a master key; skipping re-encryption",
48
+ )
49
+ return
50
+ }
51
+
52
+ const identity = await getOrCreateBackendIdentity(this.logger)
53
+ const decrypter = new Decrypter()
54
+ decrypter.addIdentity(identity)
55
+
56
+ const plaintextMasterKey = await decrypter.decrypt(armor.decode(meta.masterKey), "text")
57
+
58
+ const encrypter = new Encrypter()
59
+ const allowedRecipients = new Set<string>(recipients)
60
+ allowedRecipients.add(await identityToRecipient(identity))
61
+
62
+ for (const recipient of allowedRecipients) {
63
+ encrypter.addRecipient(recipient)
64
+ }
65
+
66
+ const encrypted = await encrypter.encrypt(plaintextMasterKey)
67
+
68
+ await writeMetaFile(this.databasePath, {
69
+ ...meta,
70
+ masterKey: armor.encode(encrypted),
71
+ })
72
+ }
73
+ }
74
+
21
75
  async function createMasterKey(logger: Logger) {
22
76
  const identity = await getOrCreateBackendIdentity(logger)
23
77
 
@@ -30,14 +84,22 @@ async function createMasterKey(logger: Logger) {
30
84
  const encryptedMasterKey = await encrypter.encrypt(masterKey)
31
85
  const armoredMasterKey = armor.encode(encryptedMasterKey)
32
86
 
33
- return { armoredMasterKey, masterKey }
87
+ return { armoredMasterKey, masterKey, recipient }
88
+ }
89
+
90
+ type DatabaseInitializationResult = {
91
+ shouldMigrate: boolean
92
+ masterKey?: string
93
+ metaFile: DatabaseMetaFile
94
+ created: boolean
95
+ initialRecipient?: string
34
96
  }
35
97
 
36
98
  async function ensureDatabaseInitialized(
37
99
  databasePath: string,
38
100
  encryptionEnabled: boolean,
39
101
  logger: Logger,
40
- ) {
102
+ ): Promise<DatabaseInitializationResult> {
41
103
  const meta = await readMetaFile(databasePath)
42
104
 
43
105
  if (!meta) {
@@ -50,7 +112,13 @@ async function ensureDatabaseInitialized(
50
112
  masterKey: masterKey?.armoredMasterKey,
51
113
  }
52
114
 
53
- return { shouldMigrate: true, masterKey: masterKey?.masterKey, metaFile }
115
+ return {
116
+ shouldMigrate: true,
117
+ masterKey: masterKey?.masterKey,
118
+ metaFile,
119
+ created: true,
120
+ initialRecipient: masterKey?.recipient,
121
+ }
54
122
  }
55
123
 
56
124
  if (meta.version > backendDatabaseVersion) {
@@ -64,12 +132,13 @@ async function ensureDatabaseInitialized(
64
132
  shouldMigrate: meta.version < backendDatabaseVersion,
65
133
  masterKey: undefined,
66
134
  metaFile: meta,
135
+ created: false,
67
136
  }
68
137
  }
69
138
 
70
139
  if (!meta.masterKey) {
71
140
  throw new Error(
72
- `Database meta file at "${databasePath}/meta.yaml" does not contain a master key.`,
141
+ `Database meta file at "${databasePath}/backend.meta.yaml" does not contain a master key.`,
73
142
  )
74
143
  }
75
144
 
@@ -87,13 +156,21 @@ async function ensureDatabaseInitialized(
87
156
  shouldMigrate: meta.version < backendDatabaseVersion,
88
157
  masterKey,
89
158
  metaFile: meta,
159
+ created: false,
90
160
  }
91
161
  }
92
162
 
93
- export async function createLocalBackendDatabase(
163
+ /**
164
+ * Creates the local backend database backend with migrations applied.
165
+ *
166
+ * @param config Backend database configuration resolved from environment variables.
167
+ * @param logger Logger scoped to backend startup.
168
+ * @returns The backend database backend bound to the local LibSQL store.
169
+ */
170
+ export async function createLocalBackendDatabaseBackend(
94
171
  config: z.infer<typeof localBackendDatabaseConfig>,
95
172
  logger: Logger,
96
- ): Promise<BackendDatabase> {
173
+ ): Promise<BackendDatabaseBackend> {
97
174
  if (!config.HIGHSTATE_ENCRYPTION_ENABLED) {
98
175
  logger.warn("local database encryption is disabled, this is not recommended for production use")
99
176
  }
@@ -101,18 +178,14 @@ export async function createLocalBackendDatabase(
101
178
  let databasePath = config.HIGHSTATE_BACKEND_DATABASE_LOCAL_PATH
102
179
  databasePath ??= await getCodebaseHighstatePath(config, logger)
103
180
 
104
- const { shouldMigrate, masterKey, metaFile } = await ensureDatabaseInitialized(
105
- databasePath,
106
- config.HIGHSTATE_ENCRYPTION_ENABLED,
107
- logger,
108
- )
181
+ const { shouldMigrate, masterKey, metaFile, created, initialRecipient } =
182
+ await ensureDatabaseInitialized(databasePath, config.HIGHSTATE_ENCRYPTION_ENABLED, logger)
109
183
 
110
184
  const databaseUrl = `file:${databasePath}/backend.db`
111
185
 
112
186
  if (shouldMigrate) {
113
187
  await migrateDatabase(databaseUrl, "backend/sqlite", masterKey, logger)
114
188
 
115
- // update version in meta file
116
189
  await writeMetaFile(databasePath, { ...metaFile, version: backendDatabaseVersion })
117
190
  }
118
191
 
@@ -125,10 +198,67 @@ export async function createLocalBackendDatabase(
125
198
  adapter,
126
199
  })
127
200
 
128
- await ensureWellKnownEntitiesCreated(prismaClient as BackendDatabase)
129
- logger.info("database is ready")
201
+ const database = prismaClient as BackendDatabase
202
+
203
+ await ensureWellKnownEntitiesCreated(database)
204
+
205
+ const backendLogger = logger.child({ service: "LocalBackendDatabaseBackend" })
206
+
207
+ await ensureInitialUnlockMethod(database, created, initialRecipient, backendLogger)
208
+
209
+ backendLogger.info("database is ready")
210
+
211
+ return new LocalBackendDatabaseBackend(
212
+ database,
213
+ databasePath,
214
+ backendLogger,
215
+ config.HIGHSTATE_ENCRYPTION_ENABLED,
216
+ )
217
+ }
218
+
219
+ /**
220
+ * Derives the meta payload for the auto-generated backend unlock method.
221
+ *
222
+ * @param host Raw host name captured during backend initialization.
223
+ */
224
+ export function getInitialBackendUnlockMethodMeta(host: string | undefined): {
225
+ title: string
226
+ description: string
227
+ } {
228
+ const trimmed = host?.trim() ?? ""
229
+ const title = trimmed.length > 0 ? trimmed : "initial"
230
+ const description =
231
+ trimmed.length > 0
232
+ ? `Identity automatically registered for ${trimmed} when this backend database was created.`
233
+ : "Identity automatically registered when this backend database was created."
234
+
235
+ return { title, description }
236
+ }
237
+
238
+ /**
239
+ * Registers the machine that initialized the backend as the first unlock method.
240
+ */
241
+ async function ensureInitialUnlockMethod(
242
+ database: BackendDatabase,
243
+ created: boolean,
244
+ initialRecipient: string | undefined,
245
+ logger: Logger,
246
+ ): Promise<void> {
247
+ if (!created || !initialRecipient) {
248
+ return
249
+ }
250
+
251
+ const meta = getInitialBackendUnlockMethodMeta(hostname())
130
252
 
131
- // yes, we return sqlite client as postgresql client
132
- // TODO: https://github.com/prisma/prisma/issues/2443
133
- return prismaClient as BackendDatabase
253
+ await database.backendUnlockMethod.create({
254
+ data: {
255
+ recipient: initialRecipient,
256
+ meta,
257
+ },
258
+ })
259
+
260
+ logger.info(
261
+ { title: meta.title, recipient: initialRecipient },
262
+ "registered initial backend unlock method",
263
+ )
134
264
  }
@@ -5,7 +5,11 @@ import { LRUCache } from "lru-cache"
5
5
  import z from "zod"
6
6
  import { createProjectLogger } from "../common"
7
7
  import { BackendError, ProjectLockedError, ProjectNotFoundError } from "../shared"
8
- import { type ProjectDatabaseBackend, projectDatabaseVersion } from "./abstractions"
8
+ import {
9
+ type BackendDatabaseBackend,
10
+ type ProjectDatabaseBackend,
11
+ projectDatabaseVersion,
12
+ } from "./abstractions"
9
13
  import { migrateDatabase } from "./migrate"
10
14
 
11
15
  export const databaseManagerConfig = z.object({
@@ -23,6 +27,13 @@ export interface DatabaseManager {
23
27
  */
24
28
  readonly isEncryptionEnabled: boolean
25
29
 
30
+ /**
31
+ * Re-encrypts the backend master key so it can be decrypted by the provided recipients.
32
+ *
33
+ * @param recipients AGE recipients that must be able to decrypt the backend master key.
34
+ */
35
+ updateBackendUnlockRecipients(recipients: string[]): Promise<void>
36
+
26
37
  /**
27
38
  * Returns the master key of the project with the given ID.
28
39
  *
@@ -60,13 +71,17 @@ export interface DatabaseManager {
60
71
 
61
72
  export class DatabaseManagerImpl implements DatabaseManager {
62
73
  constructor(
63
- readonly backend: BackendDatabase,
74
+ private readonly backendBackend: BackendDatabaseBackend,
64
75
  private readonly projectUnlockBackend: ProjectUnlockBackend,
65
76
  private readonly projectDatabaseBackend: ProjectDatabaseBackend,
66
77
  private readonly config: z.infer<typeof databaseManagerConfig>,
67
78
  private readonly logger: Logger,
68
79
  ) {}
69
80
 
81
+ get backend(): BackendDatabase {
82
+ return this.backendBackend.database
83
+ }
84
+
70
85
  // store the master keys in memory cache for 30 seconds
71
86
  private readonly projectMasterKeys = new LRUCache<string, Buffer>({
72
87
  ttl: 30_000,
@@ -80,6 +95,19 @@ export class DatabaseManagerImpl implements DatabaseManager {
80
95
  return this.config.HIGHSTATE_ENCRYPTION_ENABLED
81
96
  }
82
97
 
98
+ /**
99
+ * Delegates backend master-key rotation to the active backend database backend.
100
+ *
101
+ * @param recipients AGE recipients that must retain access to the backend master key.
102
+ */
103
+ async updateBackendUnlockRecipients(recipients: string[]): Promise<void> {
104
+ if (!this.backendBackend.isEncryptionEnabled) {
105
+ return
106
+ }
107
+
108
+ await this.backendBackend.reencryptMasterKey(recipients)
109
+ }
110
+
83
111
  async getProjectMasterKey(projectId: string): Promise<Buffer | undefined> {
84
112
  if (!this.isEncryptionEnabled) {
85
113
  return undefined
@@ -6,6 +6,7 @@ export type BackendTransaction = Omit<BackendDatabase, runtime.ITXClientDenyList
6
6
  export type ProjectTransaction = Omit<ProjectDatabase, runtime.ITXClientDenyList>
7
7
 
8
8
  export type {
9
+ BackendUnlockMethod,
9
10
  Library,
10
11
  Project,
11
12
  ProjectSpace,
@@ -35,11 +35,6 @@ export function createOperationPlan(
35
35
  requestedInstanceIds: string[],
36
36
  options: OperationOptions,
37
37
  ): OperationPhase[] {
38
- // handle preview restrictions
39
- if (type === "preview") {
40
- validatePreviewRestrictions(context, requestedInstanceIds)
41
- }
42
-
43
38
  // initialize work state
44
39
  const workState: WorkState = {
45
40
  included: new Map(),
@@ -89,20 +84,6 @@ export function createOperationPlan(
89
84
  return createOrderedPhases(workState, context, type, options)
90
85
  }
91
86
 
92
- function validatePreviewRestrictions(
93
- context: OperationContext,
94
- requestedInstanceIds: string[],
95
- ): void {
96
- for (const instanceId of requestedInstanceIds) {
97
- const dependents = context.getDependentStates(instanceId as InstanceId)
98
- if (dependents.length > 0) {
99
- throw new Error(
100
- `Preview operation not allowed for instance ${instanceId} - has dependent instances`,
101
- )
102
- }
103
- }
104
- }
105
-
106
87
  function processInstance(
107
88
  instanceId: InstanceId,
108
89
  workState: WorkState,
@@ -649,10 +649,14 @@ export class RuntimeOperation {
649
649
  state: InstanceState,
650
650
  ): Promise<void> {
651
651
  await this.workset.updateState(update.unitId, {
652
- instanceState: {
653
- // keep "deployed" status for initially deployed instances even if the operation was failed or cancelled
654
- status: state.status === "deployed" ? "deployed" : "failed",
655
- },
652
+ instanceState:
653
+ this.operation.type === "preview"
654
+ ? // do not change instance status in preview mode
655
+ undefined
656
+ : {
657
+ // keep "deployed" status for initially deployed instances even if the operation was failed or cancelled
658
+ status: state.status === "deployed" ? "deployed" : "failed",
659
+ },
656
660
  operationState: {
657
661
  status: isAbortErrorLike(update.message) ? "cancelled" : "failed",
658
662
  finishedAt: new Date(),
@@ -664,6 +668,17 @@ export class RuntimeOperation {
664
668
  update: TypedUnitStateUpdate<"completion">,
665
669
  state: InstanceState,
666
670
  ): Promise<void> {
671
+ if (this.operation.type === "preview") {
672
+ // do not change instance status in preview mode
673
+ await this.workset.updateState(update.unitId, {
674
+ operationState: {
675
+ status: this.workset.getStableStatusByOperationPhase(),
676
+ finishedAt: new Date(),
677
+ },
678
+ })
679
+ return
680
+ }
681
+
667
682
  const instance = this.context.getInstance(update.unitId)
668
683
 
669
684
  const data: InstanceStatePatch = {
@@ -696,6 +711,8 @@ export class RuntimeOperation {
696
711
  data.dependencyOutputHash = null
697
712
  data.outputHash = null
698
713
  data.parentId = null
714
+ data.model = null
715
+ data.resolvedInputs = null
699
716
  }
700
717
 
701
718
  // update the operation state
package/src/services.ts CHANGED
@@ -4,6 +4,7 @@ import { type Logger, pino } from "pino"
4
4
  import { type ArtifactBackend, ArtifactService, createArtifactBackend } from "./artifact"
5
5
  import {
6
6
  ApiKeyService,
7
+ BackendUnlockService,
7
8
  InstanceLockService,
8
9
  InstanceStateService,
9
10
  OperationService,
@@ -19,7 +20,7 @@ import {
19
20
  import { ProjectEvaluationSubsystem } from "./business/evaluation"
20
21
  import { type Config, loadConfig } from "./config"
21
22
  import {
22
- createBackendDatabase,
23
+ createBackendDatabaseBackend,
23
24
  createProjectDatabaseBackend,
24
25
  type DatabaseManager,
25
26
  DatabaseManagerImpl,
@@ -72,6 +73,7 @@ export type Services = {
72
73
  readonly artifactBackend: ArtifactBackend
73
74
 
74
75
  // business services
76
+ readonly backendUnlockService: BackendUnlockService
75
77
  readonly instanceLockService: InstanceLockService
76
78
  readonly projectUnlockService: ProjectUnlockService
77
79
  readonly operationService: OperationService
@@ -131,6 +133,7 @@ export async function createServices({
131
133
  artifactService,
132
134
 
133
135
  // business services
136
+ backendUnlockService,
134
137
  instanceLockService,
135
138
  projectUnlockService,
136
139
  operationService,
@@ -152,11 +155,11 @@ export async function createServices({
152
155
 
153
156
  projectUnlockBackend ??= new MemoryProjectUnlockBackend()
154
157
 
155
- const backendDatabase = await createBackendDatabase(config, logger)
158
+ const backendDatabaseBackend = await createBackendDatabaseBackend(config, logger)
156
159
  const projectDatabaseBackend = await createProjectDatabaseBackend(config, logger)
157
160
 
158
161
  database ??= new DatabaseManagerImpl(
159
- backendDatabase,
162
+ backendDatabaseBackend,
160
163
  projectUnlockBackend,
161
164
  projectDatabaseBackend,
162
165
  config,
@@ -174,6 +177,11 @@ export async function createServices({
174
177
  artifactBackend ??= await createArtifactBackend(config, database, logger)
175
178
  artifactService ??= new ArtifactService(database, artifactBackend, logger)
176
179
 
180
+ backendUnlockService ??= new BackendUnlockService(
181
+ database,
182
+ logger.child({ service: "BackendUnlockService" }),
183
+ )
184
+
177
185
  secretService ??= new SecretService(
178
186
  database,
179
187
  pubsubManager,
@@ -335,6 +343,7 @@ export async function createServices({
335
343
  artifactService,
336
344
 
337
345
  // business services
346
+ backendUnlockService,
338
347
  instanceLockService,
339
348
  projectUnlockService,
340
349
  operationService,
@@ -1,20 +1,14 @@
1
1
  import { objectMetaSchema } from "@highstate/contract"
2
2
  import { z } from "zod"
3
3
 
4
- /**
5
- * Complete unlock method schema for database storage.
6
- * Contains all unlock method information including encryption details.
7
- *
8
- * Do not confuse with the project unlock method schema.
9
- */
10
- export const backendUnlockMethodSchema = z.object({
11
- id: z.string(),
12
- meta: objectMetaSchema,
4
+ export const backendUnlockMethodMetaSchema = objectMetaSchema
5
+ .pick({ title: true, description: true })
6
+ .required({ title: true })
13
7
 
14
- /**
15
- * The AGE recipient of the unlock method to use to encrypt the master key for this unlock method.
16
- */
8
+ export const backendUnlockMethodInputSchema = z.object({
9
+ meta: backendUnlockMethodMetaSchema,
17
10
  recipient: z.string(),
18
11
  })
19
12
 
20
- export type BackendUnlockMethod = z.infer<typeof backendUnlockMethodSchema>
13
+ export type BackendUnlockMethodMeta = z.infer<typeof backendUnlockMethodMetaSchema>
14
+ export type BackendUnlockMethodInput = z.infer<typeof backendUnlockMethodInputSchema>
@@ -84,3 +84,17 @@ export class WorkerVersionNotFoundError extends BackendError {
84
84
  this.name = "WorkerVersionNotFoundError"
85
85
  }
86
86
  }
87
+
88
+ export class BackendUnlockMethodNotFoundError extends BackendError {
89
+ constructor(id: string) {
90
+ super(`Backend unlock method with ID "${id}" not found.`)
91
+ this.name = "BackendUnlockMethodNotFoundError"
92
+ }
93
+ }
94
+
95
+ export class CannotDeleteLastBackendUnlockMethodError extends BackendError {
96
+ constructor() {
97
+ super(`Refused to delete the last backend unlock method.`)
98
+ this.name = "CannotDeleteLastBackendUnlockMethodError"
99
+ }
100
+ }
@@ -2,14 +2,22 @@ import type * as contract from "@highstate/contract"
2
2
  import type * as shared from "../../shared"
3
3
 
4
4
  declare global {
5
+ // Common
5
6
  namespace PrismaJson {
6
- type LibrarySpec = shared.LibrarySpec
7
7
  type CommonObjectMeta = contract.CommonObjectMeta
8
+ }
8
9
 
9
- type PulumiBackendSpec = shared.PulumiBackendSpec
10
+ // Backend
11
+ namespace PrismaJson {
12
+ type LibrarySpec = shared.LibrarySpec
13
+ type BackendUnlockMethodMeta = shared.BackendUnlockMethodMeta
10
14
 
15
+ type PulumiBackendSpec = shared.PulumiBackendSpec
11
16
  type ProjectModelStorageSpec = shared.ProjectModelStorageSpec
17
+ }
12
18
 
19
+ // Project
20
+ namespace PrismaJson {
13
21
  type InstanceId = contract.InstanceId
14
22
  type NullableInstanceId = contract.InstanceId | null
15
23
  type InstanceIds = contract.InstanceId[]
@@ -2,11 +2,13 @@ import type { Logger } from "pino"
2
2
  import type { BackendDatabase, ProjectDatabase } from "../database/prisma"
3
3
  import { constants } from "node:fs"
4
4
  import { access, mkdtemp, rm } from "node:fs/promises"
5
- import { tmpdir } from "node:os"
5
+ import { hostname, tmpdir } from "node:os"
6
6
  import { join } from "node:path"
7
7
  import { PrismaLibSQL } from "@prisma/adapter-libsql"
8
+ import { generateIdentity, identityToRecipient } from "age-encryption"
8
9
  import { type DatabaseManager, ensureWellKnownEntitiesCreated } from "../database"
9
10
  import { PrismaClient as BackendPrismaClient } from "../database/_generated/backend/sqlite/client"
11
+ import { getInitialBackendUnlockMethodMeta } from "../database/local/backend"
10
12
  import { migrateDatabase } from "../database/migrate"
11
13
  import {
12
14
  type BackendDatabase as BackendDatabaseClient,
@@ -16,16 +18,25 @@ import {
16
18
  export class TestDatabaseManager implements DatabaseManager {
17
19
  private readonly projectDatabases = new Map<string, Promise<ProjectDatabase>>()
18
20
  private readonly tempDirs: string[] = []
21
+ readonly backendUnlockRecipientUpdates: string[][] = []
19
22
 
20
23
  constructor(
21
24
  readonly backend: BackendDatabase,
22
25
  private readonly logger: Logger,
26
+ readonly isEncryptionEnabled: boolean,
23
27
  ) {}
24
28
 
25
- readonly isEncryptionEnabled = false
29
+ updateBackendUnlockRecipients(recipients: string[]): Promise<void> {
30
+ if (!this.isEncryptionEnabled) {
31
+ return Promise.resolve()
32
+ }
33
+
34
+ this.backendUnlockRecipientUpdates.push(recipients)
35
+ return Promise.resolve()
36
+ }
26
37
 
27
- getProjectMasterKey(): Promise<Buffer> {
28
- return Promise.resolve(Buffer.alloc(0))
38
+ getProjectMasterKey(): Promise<Buffer | undefined> {
39
+ return Promise.resolve(undefined)
29
40
  }
30
41
 
31
42
  setupDatabase(projectId: string): Promise<ProjectDatabase> {
@@ -55,7 +66,11 @@ export class TestDatabaseManager implements DatabaseManager {
55
66
  })
56
67
  }
57
68
 
58
- static async create(logger: Logger): Promise<TestDatabaseManager> {
69
+ static async create(
70
+ logger: Logger,
71
+ options: { isEncryptionEnabled?: boolean } = {},
72
+ ): Promise<TestDatabaseManager> {
73
+ const { isEncryptionEnabled = false } = options
59
74
  const tempRoot = await findWritableTempDir()
60
75
  const tempPath = await mkdtemp(join(tempRoot, "highstate"))
61
76
  const backendUrl = `file:${join(tempPath, "backend.db")}`
@@ -68,7 +83,20 @@ export class TestDatabaseManager implements DatabaseManager {
68
83
 
69
84
  await ensureWellKnownEntitiesCreated(backend)
70
85
 
71
- return new TestDatabaseManager(backend, logger)
86
+ if (isEncryptionEnabled) {
87
+ const identity = await generateIdentity()
88
+ const recipient = await identityToRecipient(identity)
89
+ const meta = getInitialBackendUnlockMethodMeta(hostname())
90
+
91
+ await backend.backendUnlockMethod.create({
92
+ data: {
93
+ recipient,
94
+ meta,
95
+ },
96
+ })
97
+ }
98
+
99
+ return new TestDatabaseManager(backend, logger, isEncryptionEnabled)
72
100
  }
73
101
 
74
102
  private async createTempPath(): Promise<string> {