@highstate/backend-api 0.27.0 → 0.29.0

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/index.js CHANGED
@@ -1,74 +1,1016 @@
1
1
  // @bun
2
2
  // src/index.ts
3
3
  import { rm } from "fs/promises";
4
- import { InstanceServiceDefinition } from "@highstate/api/instance.v1";
5
- import { PanelServiceDefinition } from "@highstate/api/panel.v1";
6
- import { SecretServiceDefinition } from "@highstate/api/secret.v1";
7
- import { WorkerServiceDefinition } from "@highstate/api/worker.v1";
8
- import { createServer } from "nice-grpc";
4
+ import { createServer } from "http";
5
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
6
+ import {
7
+ InstanceStateService,
8
+ LibraryService,
9
+ OperationService,
10
+ PanelService,
11
+ ProjectModelService,
12
+ ProjectService,
13
+ SecretService
14
+ } from "@highstate/api/v1";
15
+ import { WorkerService } from "@highstate/api/worker.v1";
9
16
 
10
- // src/handlers/instance.ts
11
- import { instanceCustomStatusInputSchema } from "@highstate/backend/shared";
12
- import { z } from "@highstate/contract";
17
+ // src/handlers/instance-state.ts
18
+ import { instanceCustomStatusInputSchema as instanceCustomStatusInputSchema2 } from "@highstate/backend/shared";
19
+ import { z as z2 } from "@highstate/contract";
13
20
 
21
+ // src/shared/api-error.ts
22
+ import { create } from "@bufbuild/protobuf";
23
+ import { ConnectError } from "@connectrpc/connect";
24
+ import { BadRequestSchema, ErrorInfoSchema } from "@highstate/api/v1";
25
+ function createApiError(options) {
26
+ const details = [
27
+ {
28
+ desc: ErrorInfoSchema,
29
+ value: create(ErrorInfoSchema, {
30
+ reason: options.reason,
31
+ domain: "highstate.io",
32
+ metadata: options.metadata
33
+ })
34
+ }
35
+ ];
36
+ if (options.fieldViolations?.length) {
37
+ details.push({
38
+ desc: BadRequestSchema,
39
+ value: create(BadRequestSchema, {
40
+ fieldViolations: options.fieldViolations.map((violation) => ({ ...violation }))
41
+ })
42
+ });
43
+ }
44
+ return new ConnectError(options.message, options.code, undefined, details);
45
+ }
14
46
  // src/shared/authentication.ts
15
- import { ServerError, Status } from "nice-grpc-common";
16
- async function authenticate(services, context) {
17
- const token = context.metadata.get("api-key");
18
- if (!token) {
19
- throw new ServerError(Status.UNAUTHENTICATED, "No API key provided");
47
+ import { Code as Code2 } from "@connectrpc/connect";
48
+ import { ProjectLockedError, ProjectNotFoundError } from "@highstate/backend/shared";
49
+
50
+ // src/shared/authorization-header.ts
51
+ import { Code } from "@connectrpc/connect";
52
+ function parseBearerToken(authorization) {
53
+ const match = /^Bearer ([^\s]+)$/i.exec(authorization ?? "");
54
+ if (!match || [...match[1]].some((character) => character.charCodeAt(0) <= 31 || character === "\x7F")) {
55
+ throw createApiError({
56
+ message: "Invalid authorization header",
57
+ code: Code.Unauthenticated,
58
+ reason: "AUTHORIZATION_HEADER_INVALID"
59
+ });
20
60
  }
21
- const projectId = context.metadata.get("project-id");
61
+ return match[1];
62
+ }
63
+
64
+ // src/shared/authentication.ts
65
+ async function authenticateBackend(services, context) {
66
+ const token = getBearerToken(context);
67
+ const apiKey = await services.apiKeyService.getBackendApiKeyByToken(token);
68
+ const roleBindings = await services.database.backend.serviceAccountBackendRoleBinding.findMany({
69
+ where: { serviceAccountId: apiKey.serviceAccountId },
70
+ include: { role: { select: { rules: true } } }
71
+ });
72
+ return {
73
+ realm: "backend",
74
+ subject: {
75
+ type: "service-account",
76
+ serviceAccountId: apiKey.serviceAccountId,
77
+ apiKeyId: apiKey.id
78
+ },
79
+ permissions: resolveBackendPermissions(roleBindings.flatMap((binding) => binding.role.rules.map((rule) => ({
80
+ permissions: rule.permissions,
81
+ restrictions: rule.restrictions ?? []
82
+ }))), apiKey.restrictionRules)
83
+ };
84
+ }
85
+ async function authenticateProject(services, request, context) {
86
+ const projectId = request.projectId;
22
87
  if (!projectId) {
23
- throw new ServerError(Status.UNAUTHENTICATED, "No project ID provided");
88
+ throw createApiError({
89
+ message: "No project ID provided",
90
+ code: Code2.InvalidArgument,
91
+ reason: "PROJECT_ID_REQUIRED",
92
+ fieldViolations: [
93
+ {
94
+ field: "project_id",
95
+ reason: "REQUIRED",
96
+ description: "The project ID is required"
97
+ }
98
+ ]
99
+ });
100
+ }
101
+ const project = await services.database.backend.project.findUnique({
102
+ where: { id: projectId },
103
+ select: { id: true }
104
+ });
105
+ if (!project) {
106
+ throw new ProjectNotFoundError(projectId);
107
+ }
108
+ if (!await services.projectUnlockService.checkProjectUnlocked(projectId)) {
109
+ throw new ProjectLockedError(projectId);
110
+ }
111
+ const token = getBearerToken(context);
112
+ const apiKey = await services.apiKeyService.getProjectCredentialByToken(projectId, token);
113
+ const projectDatabase = await services.database.forProject(projectId);
114
+ const roleBindings = await projectDatabase.serviceAccountRoleBinding.findMany({
115
+ where: { serviceAccountId: apiKey.serviceAccountId },
116
+ include: { role: { select: { rules: true } } }
117
+ });
118
+ return {
119
+ realm: "project",
120
+ projectId,
121
+ subject: {
122
+ type: "service-account",
123
+ serviceAccountId: apiKey.serviceAccountId,
124
+ apiKeyId: apiKey.id
125
+ },
126
+ permissions: resolveProjectPermissions(roleBindings.flatMap((binding) => binding.role.rules.map((rule) => ({
127
+ permissions: rule.permissions,
128
+ restrictions: rule.restrictions ?? []
129
+ }))), apiKey.restrictionRules)
130
+ };
131
+ }
132
+ function getBearerToken(context) {
133
+ return parseBearerToken(context.requestHeader.get("authorization"));
134
+ }
135
+ function resolveBackendPermissions(grants, restrictions) {
136
+ return resolvePermissions(grants, restrictions);
137
+ }
138
+ function resolveProjectPermissions(grants, restrictions) {
139
+ return resolvePermissions(grants, restrictions);
140
+ }
141
+ function resolvePermissions(grants, restrictions) {
142
+ const restrictionByPermission = new Map(restrictions.flatMap((rule) => rule.permissions.map((permission) => [permission, rule.restrictions ?? []])));
143
+ const permissions = new Map;
144
+ for (const grant of grants) {
145
+ for (const permission of grant.permissions) {
146
+ const keyRestrictions = restrictionByPermission.get(permission);
147
+ if (restrictions.length > 0 && !keyRestrictions) {
148
+ continue;
149
+ }
150
+ const permissionGrants = permissions.get(permission) ?? [];
151
+ permissionGrants.push({ restrictions: [...grant.restrictions, ...keyRestrictions ?? []] });
152
+ permissions.set(permission, permissionGrants);
153
+ }
24
154
  }
25
- const apiKey = await services.apiKeyService.getApiKeyByToken(projectId, token);
26
- return [projectId, apiKey];
155
+ return permissions;
156
+ }
157
+ // src/shared/conversion.ts
158
+ import { create as create2, fromJson, toJson } from "@bufbuild/protobuf";
159
+ import { timestampFromDate, ValueSchema } from "@bufbuild/protobuf/wkt";
160
+ import {
161
+ ComponentKind,
162
+ ComponentSchema,
163
+ EntitySchema,
164
+ EvaluationStatus,
165
+ HubSchema,
166
+ InstanceCustomStatusSchema,
167
+ InstanceOperationStatus,
168
+ InstanceSchema,
169
+ InstanceSource,
170
+ InstanceStateSchema,
171
+ InstanceStatus,
172
+ LibrarySchema,
173
+ OperationLogSchema,
174
+ OperationPhaseSchema,
175
+ OperationPhaseType,
176
+ OperationSchema,
177
+ OperationStatus,
178
+ OperationType,
179
+ ProjectModelSchema,
180
+ ProjectSchema
181
+ } from "@highstate/api/v1";
182
+ import {
183
+ instanceCustomStatusInputSchema,
184
+ operationMetaSchema,
185
+ operationOptionsSchema,
186
+ operationPhaseSchema
187
+ } from "@highstate/backend/shared";
188
+ import {
189
+ instanceInputSchema,
190
+ instanceModelSchema,
191
+ z
192
+ } from "@highstate/contract";
193
+ var jsonValueSchema = z.lazy(() => z.union([
194
+ z.string(),
195
+ z.number(),
196
+ z.boolean(),
197
+ z.null(),
198
+ z.array(jsonValueSchema),
199
+ z.record(z.string(), jsonValueSchema)
200
+ ]));
201
+ var jsonObjectSchema = z.record(z.string(), jsonValueSchema);
202
+ function toProject(project) {
203
+ return create2(ProjectSchema, {
204
+ id: project.id,
205
+ name: project.name,
206
+ meta: project.meta,
207
+ spaceId: project.spaceId,
208
+ modelStorageId: project.modelStorageId,
209
+ libraryId: project.libraryId,
210
+ pulumiBackendId: project.pulumiBackendId,
211
+ createdAt: toTimestamp(project.createdAt),
212
+ updatedAt: toTimestamp(project.updatedAt)
213
+ });
214
+ }
215
+ function toProjectModel(model) {
216
+ return create2(ProjectModelSchema, {
217
+ instances: [...model.instances, ...model.virtualInstances, ...model.ghostInstances].map(toInstance),
218
+ hubs: model.hubs.map(toHub)
219
+ });
220
+ }
221
+ function toInstance(instance) {
222
+ return create2(InstanceSchema, {
223
+ id: instance.id,
224
+ kind: toComponentKind(instance.kind),
225
+ type: instance.type,
226
+ name: instance.name,
227
+ arguments: Object.entries(instance.args ?? {}).map(([key, value]) => ({
228
+ key,
229
+ value: fromJson(ValueSchema, value)
230
+ })),
231
+ inputs: toInstanceReferenceMap(instance.inputs),
232
+ hubInputs: Object.fromEntries(Object.entries(instance.hubInputs ?? {}).map(([key, values]) => [
233
+ key,
234
+ { values: values.map((value) => ({ hubId: value.hubId })) }
235
+ ])),
236
+ injectionInputs: (instance.injectionInputs ?? []).map((value) => ({ hubId: value.hubId })),
237
+ position: instance.position ?? undefined,
238
+ parentId: instance.parentId,
239
+ outputs: toInstanceReferenceMap(instance.outputs),
240
+ resolvedOutputs: toInstanceReferenceMap(instance.resolvedOutputs)
241
+ });
242
+ }
243
+ function fromInstance(instance) {
244
+ return instanceModelSchema.parse({
245
+ id: instance.id,
246
+ kind: fromComponentKind(instance.kind),
247
+ type: instance.type,
248
+ name: instance.name,
249
+ args: Object.fromEntries(instance.arguments.map((argument) => [
250
+ argument.key,
251
+ argument.value ? toJson(ValueSchema, argument.value) : null
252
+ ])),
253
+ inputs: fromInstanceReferenceMap(instance.inputs),
254
+ hubInputs: Object.fromEntries(Object.entries(instance.hubInputs).map(([key, list]) => [
255
+ key,
256
+ list.values.map((value) => ({ hubId: value.hubId }))
257
+ ])),
258
+ injectionInputs: instance.injectionInputs.map((value) => ({ hubId: value.hubId })),
259
+ position: instance.position
260
+ });
261
+ }
262
+ function toHub(hub) {
263
+ return create2(HubSchema, {
264
+ id: hub.id,
265
+ position: hub.position ?? undefined,
266
+ inputs: hub.inputs ?? [],
267
+ injectionInputs: (hub.injectionInputs ?? []).map((value) => ({ hubId: value.hubId }))
268
+ });
269
+ }
270
+ function fromHub(hub) {
271
+ return {
272
+ id: hub.id,
273
+ position: hub.position,
274
+ inputs: hub.inputs.map(fromInstanceReference),
275
+ injectionInputs: hub.injectionInputs.map((value) => ({ hubId: value.hubId }))
276
+ };
277
+ }
278
+ function toInstancePatch(instance, paths) {
279
+ const patch = {};
280
+ for (const path of paths) {
281
+ switch (path) {
282
+ case "arguments":
283
+ patch.args = Object.fromEntries(instance.arguments.map((argument) => [
284
+ argument.key,
285
+ argument.value ? toJson(ValueSchema, argument.value) : null
286
+ ]));
287
+ break;
288
+ case "inputs":
289
+ patch.inputs = fromInstanceReferenceMap(instance.inputs);
290
+ break;
291
+ case "hub_inputs":
292
+ patch.hubInputs = Object.fromEntries(Object.entries(instance.hubInputs).map(([key, list]) => [
293
+ key,
294
+ list.values.map((value) => ({ hubId: value.hubId }))
295
+ ]));
296
+ break;
297
+ case "injection_inputs":
298
+ patch.injectionInputs = instance.injectionInputs.map((value) => ({ hubId: value.hubId }));
299
+ break;
300
+ case "position":
301
+ patch.position = instance.position ? { x: instance.position.x, y: instance.position.y } : null;
302
+ break;
303
+ case "position.x":
304
+ patch.position = { x: instance.position?.x ?? 0 };
305
+ break;
306
+ case "position.y":
307
+ patch.position = { y: instance.position?.y ?? 0 };
308
+ break;
309
+ }
310
+ }
311
+ return patch;
312
+ }
313
+ function toHubPatch(hub, paths) {
314
+ const patch = {};
315
+ for (const path of paths) {
316
+ switch (path) {
317
+ case "position":
318
+ patch.position = hub.position ? { x: hub.position.x, y: hub.position.y } : null;
319
+ break;
320
+ case "position.x":
321
+ patch.position = { x: hub.position?.x ?? 0 };
322
+ break;
323
+ case "position.y":
324
+ patch.position = { y: hub.position?.y ?? 0 };
325
+ break;
326
+ case "inputs":
327
+ patch.inputs = hub.inputs.map(fromInstanceReference);
328
+ break;
329
+ case "injection_inputs":
330
+ patch.injectionInputs = hub.injectionInputs.map((value) => ({ hubId: value.hubId }));
331
+ break;
332
+ }
333
+ }
334
+ return patch;
335
+ }
336
+ function toInstanceState(state) {
337
+ const model = state.model ? instanceModelSchema.parse(state.model) : undefined;
338
+ return create2(InstanceStateSchema, {
339
+ id: state.id,
340
+ instanceId: state.instanceId,
341
+ status: toInstanceStatus(state.status),
342
+ source: toInstanceSource(state.source),
343
+ kind: toComponentKind(state.kind),
344
+ parentInstanceId: state.parentInstanceId ?? undefined,
345
+ evaluationState: state.evaluationState ? {
346
+ status: toEvaluationStatus(state.evaluationState.status),
347
+ message: state.evaluationState.message ?? undefined,
348
+ model: state.evaluationState.model ? toInstance(instanceModelSchema.parse(state.evaluationState.model)) : undefined,
349
+ evaluatedAt: toTimestamp(state.evaluationState.evaluatedAt)
350
+ } : undefined,
351
+ lastOperationState: state.lastOperationState ? {
352
+ operationId: state.lastOperationState.operationId,
353
+ stateId: state.lastOperationState.stateId,
354
+ status: toInstanceOperationStatus(state.lastOperationState.status),
355
+ currentResourceCount: state.lastOperationState.currentResourceCount ?? undefined,
356
+ totalResourceCount: state.lastOperationState.totalResourceCount ?? undefined,
357
+ model: toInstance(instanceModelSchema.parse(state.lastOperationState.model)),
358
+ startedAt: toNullableTimestamp(state.lastOperationState.startedAt),
359
+ finishedAt: toNullableTimestamp(state.lastOperationState.finishedAt)
360
+ } : undefined,
361
+ terminalIds: state.terminalIds ?? [],
362
+ pageIds: state.pageIds ?? [],
363
+ panelIds: state.panelIds ?? [],
364
+ secretNames: state.secretNames ?? [],
365
+ customStatuses: (state.customStatuses ?? []).map(toInstanceCustomStatus),
366
+ currentResourceCount: state.currentResourceCount ?? undefined,
367
+ hasResourceHooks: state.hasResourceHooks,
368
+ statusFields: state.statusFields ? fromJson(ValueSchema, jsonValueSchema.parse(state.statusFields)) : undefined,
369
+ model: model ? toInstance(model) : undefined
370
+ });
371
+ }
372
+ function toInstanceCustomStatus(status) {
373
+ const meta = instanceCustomStatusInputSchema.shape.meta.parse(status.meta);
374
+ return create2(InstanceCustomStatusSchema, {
375
+ name: status.name,
376
+ meta: {
377
+ title: meta.title ?? status.name,
378
+ description: meta.description,
379
+ icon: meta.icon,
380
+ iconColor: meta.iconColor
381
+ },
382
+ value: status.value,
383
+ message: status.message ?? undefined,
384
+ order: status.order,
385
+ serviceAccountId: status.serviceAccountId,
386
+ createdAt: toTimestamp(status.createdAt),
387
+ updatedAt: toTimestamp(status.updatedAt)
388
+ });
389
+ }
390
+ function toOperation(operation) {
391
+ const meta = operationMetaSchema.parse(operation.meta);
392
+ const options = operationOptionsSchema.partial().parse(operation.options);
393
+ const phases = operation.phases ? operationPhaseSchema.array().parse(operation.phases) : [];
394
+ return create2(OperationSchema, {
395
+ id: operation.id,
396
+ meta,
397
+ type: toOperationType(operation.type),
398
+ status: toOperationStatus(operation.status),
399
+ options: {
400
+ forceUpdateDependencies: options.forceUpdateDependencies ?? false,
401
+ ignoreChangedDependencies: options.ignoreChangedDependencies ?? false,
402
+ ignoreDependencies: options.ignoreDependencies ?? false,
403
+ forceUpdateChildren: options.forceUpdateChildren ?? false,
404
+ onlyDestroyGhosts: options.onlyDestroyGhosts ?? false,
405
+ firstDestroyGhosts: options.firstDestroyGhosts ?? false,
406
+ ignoreGhosts: options.ignoreGhosts ?? false,
407
+ destroyDependentInstances: options.destroyDependentInstances ?? false,
408
+ invokeDestroyTriggers: options.invokeDestroyTriggers ?? false,
409
+ deleteUnreachableResources: options.deleteUnreachableResources ?? false,
410
+ forceDeleteState: options.forceDeleteState ?? false,
411
+ allowPartialCompositeInstanceUpdate: options.allowPartialCompositeInstanceUpdate ?? false,
412
+ allowPartialCompositeInstanceDestruction: options.allowPartialCompositeInstanceDestruction ?? false,
413
+ refresh: options.refresh ?? false,
414
+ debug: options.debug ?? false
415
+ },
416
+ requestedInstanceIds: z.string().array().parse(operation.requestedInstanceIds),
417
+ phases: phases.map(toOperationPhase),
418
+ startedAt: toTimestamp(operation.startedAt),
419
+ updatedAt: toTimestamp(operation.updatedAt),
420
+ finishedAt: toNullableTimestamp(operation.finishedAt)
421
+ });
422
+ }
423
+ function toOperationPhase(phase) {
424
+ return create2(OperationPhaseSchema, {
425
+ type: toOperationPhaseType(phase.type),
426
+ instances: phase.instances
427
+ });
428
+ }
429
+ function toOperationLog(operationId, log) {
430
+ return create2(OperationLogSchema, {
431
+ id: log.id,
432
+ operationId,
433
+ stateId: log.stateId ?? undefined,
434
+ isSystem: log.isSystem ?? false,
435
+ content: log.content
436
+ });
437
+ }
438
+ function toLibrary(library) {
439
+ return create2(LibrarySchema, {
440
+ components: Object.fromEntries(Object.entries(library.components).map(([type, component]) => [type, toComponent(component)])),
441
+ entities: Object.fromEntries(Object.entries(library.entities).map(([type, entity]) => [type, toEntity(entity)]))
442
+ });
443
+ }
444
+ function toComponent(component) {
445
+ try {
446
+ return create2(ComponentSchema, {
447
+ type: component.type,
448
+ kind: toComponentKind(component.kind),
449
+ arguments: Object.fromEntries(Object.entries(component.args).map(([name, argument]) => [
450
+ name,
451
+ {
452
+ schema: jsonObjectSchema.parse(argument.schema),
453
+ required: argument.required,
454
+ meta: argument.meta
455
+ }
456
+ ])),
457
+ inputs: Object.fromEntries(Object.entries(component.inputs).map(([name, port]) => [name, toComponentPort(port)])),
458
+ outputs: Object.fromEntries(Object.entries(component.outputs).map(([name, port]) => [name, toComponentPort(port)])),
459
+ meta: component.meta,
460
+ definitionHash: component.definitionHash
461
+ });
462
+ } catch (error) {
463
+ throw new Error(`Failed to convert component "${component.type}" to an API message`, {
464
+ cause: error
465
+ });
466
+ }
467
+ }
468
+ function toEntity(entity) {
469
+ return create2(EntitySchema, {
470
+ type: entity.type,
471
+ extensions: entity.extensions ?? [],
472
+ directExtensions: entity.directExtensions ?? [],
473
+ inclusions: entity.inclusions ?? [],
474
+ directInclusions: entity.directInclusions ?? [],
475
+ meta: entity.meta,
476
+ definitionHash: entity.definitionHash
477
+ });
478
+ }
479
+ function toTimestamp(value) {
480
+ if (!Number.isFinite(value.getTime())) {
481
+ throw new Error("Cannot convert invalid date to timestamp");
482
+ }
483
+ return timestampFromDate(value);
484
+ }
485
+ function toNullableTimestamp(value) {
486
+ return value ? toTimestamp(value) : undefined;
487
+ }
488
+ function toComponentPort(port) {
489
+ return {
490
+ entityType: port.type,
491
+ fromInput: port.fromInput,
492
+ required: port.required,
493
+ multiple: port.multiple,
494
+ meta: port.meta
495
+ };
496
+ }
497
+ function toInstanceReferenceMap(values) {
498
+ return Object.fromEntries(Object.entries(values ?? {}).map(([key, references]) => [
499
+ key,
500
+ {
501
+ values: references
502
+ }
503
+ ]));
504
+ }
505
+ function fromInstanceReferenceMap(values) {
506
+ return Object.fromEntries(Object.entries(values).map(([key, list]) => [key, list.values.map(fromInstanceReference)]));
507
+ }
508
+ function fromInstanceReference(value) {
509
+ return instanceInputSchema.parse({
510
+ instanceId: value.instanceId,
511
+ output: value.output,
512
+ path: value.path
513
+ });
514
+ }
515
+ function toComponentKind(value) {
516
+ if (value === "unit")
517
+ return ComponentKind.UNIT;
518
+ if (value === "composite")
519
+ return ComponentKind.COMPOSITE;
520
+ throw new Error(`Unknown component kind "${value}"`);
521
+ }
522
+ function fromComponentKind(value) {
523
+ if (value === ComponentKind.UNIT)
524
+ return "unit";
525
+ if (value === ComponentKind.COMPOSITE)
526
+ return "composite";
527
+ throw new Error("Component kind must be specified");
528
+ }
529
+ function toInstanceStatus(value) {
530
+ const statuses = {
531
+ undeployed: InstanceStatus.UNDEPLOYED,
532
+ attempted: InstanceStatus.ATTEMPTED,
533
+ deployed: InstanceStatus.DEPLOYED,
534
+ failed: InstanceStatus.FAILED
535
+ };
536
+ const status = statuses[value];
537
+ if (status === undefined)
538
+ throw new Error(`Unknown instance status "${value}"`);
539
+ return status;
540
+ }
541
+ function toInstanceSource(value) {
542
+ if (value === "resident")
543
+ return InstanceSource.RESIDENT;
544
+ if (value === "virtual")
545
+ return InstanceSource.VIRTUAL;
546
+ throw new Error(`Unknown instance source "${value}"`);
547
+ }
548
+ function toEvaluationStatus(value) {
549
+ if (value === "evaluating")
550
+ return EvaluationStatus.EVALUATING;
551
+ if (value === "evaluated")
552
+ return EvaluationStatus.EVALUATED;
553
+ if (value === "error")
554
+ return EvaluationStatus.ERROR;
555
+ throw new Error(`Unknown evaluation status "${value}"`);
556
+ }
557
+ function toInstanceOperationStatus(value) {
558
+ const statuses = {
559
+ updating: InstanceOperationStatus.UPDATING,
560
+ processing_triggers: InstanceOperationStatus.PROCESSING_TRIGGERS,
561
+ previewing: InstanceOperationStatus.PREVIEWING,
562
+ destroying: InstanceOperationStatus.DESTROYING,
563
+ refreshing: InstanceOperationStatus.REFRESHING,
564
+ pending: InstanceOperationStatus.PENDING,
565
+ cancelling: InstanceOperationStatus.CANCELLING,
566
+ updated: InstanceOperationStatus.UPDATED,
567
+ previewed: InstanceOperationStatus.PREVIEWED,
568
+ skipped: InstanceOperationStatus.SKIPPED,
569
+ destroyed: InstanceOperationStatus.DESTROYED,
570
+ refreshed: InstanceOperationStatus.REFRESHED,
571
+ cancelled: InstanceOperationStatus.CANCELLED,
572
+ failed: InstanceOperationStatus.FAILED
573
+ };
574
+ const status = statuses[value];
575
+ if (status === undefined)
576
+ throw new Error(`Unknown instance operation status "${value}"`);
577
+ return status;
578
+ }
579
+ function fromOperationType(value) {
580
+ if (value === OperationType.UPDATE)
581
+ return "update";
582
+ if (value === OperationType.PREVIEW)
583
+ return "preview";
584
+ if (value === OperationType.DESTROY)
585
+ return "destroy";
586
+ if (value === OperationType.RECREATE)
587
+ return "recreate";
588
+ if (value === OperationType.REFRESH)
589
+ return "refresh";
590
+ throw new Error("Operation type must be specified");
591
+ }
592
+ function fromOperationPhase(phase) {
593
+ return operationPhaseSchema.parse({
594
+ type: fromOperationPhaseType(phase.type),
595
+ instances: phase.instances.map((instance) => ({
596
+ id: instance.id,
597
+ parentId: instance.parentId,
598
+ message: instance.message
599
+ }))
600
+ });
601
+ }
602
+ function toOperationType(value) {
603
+ const types = {
604
+ update: OperationType.UPDATE,
605
+ preview: OperationType.PREVIEW,
606
+ destroy: OperationType.DESTROY,
607
+ recreate: OperationType.RECREATE,
608
+ refresh: OperationType.REFRESH
609
+ };
610
+ const type = types[value];
611
+ if (type === undefined)
612
+ throw new Error(`Unknown operation type "${value}"`);
613
+ return type;
614
+ }
615
+ function toOperationStatus(value) {
616
+ const statuses = {
617
+ pending: OperationStatus.PENDING,
618
+ running: OperationStatus.RUNNING,
619
+ failing: OperationStatus.FAILING,
620
+ cancelling: OperationStatus.CANCELLING,
621
+ completed: OperationStatus.COMPLETED,
622
+ failed: OperationStatus.FAILED,
623
+ cancelled: OperationStatus.CANCELLED
624
+ };
625
+ const status = statuses[value];
626
+ if (status === undefined)
627
+ throw new Error(`Unknown operation status "${value}"`);
628
+ return status;
629
+ }
630
+ function toOperationPhaseType(value) {
631
+ const types = {
632
+ destroy: OperationPhaseType.DESTROY,
633
+ preview: OperationPhaseType.PREVIEW,
634
+ update: OperationPhaseType.UPDATE,
635
+ refresh: OperationPhaseType.REFRESH
636
+ };
637
+ const type = types[value];
638
+ if (type === undefined)
639
+ throw new Error(`Unknown operation phase type "${value}"`);
640
+ return type;
641
+ }
642
+ function fromOperationPhaseType(value) {
643
+ const types = {
644
+ [OperationPhaseType.DESTROY]: "destroy",
645
+ [OperationPhaseType.PREVIEW]: "preview",
646
+ [OperationPhaseType.UPDATE]: "update",
647
+ [OperationPhaseType.REFRESH]: "refresh"
648
+ };
649
+ const type = types[value];
650
+ if (!type)
651
+ throw new Error("Operation phase type must be specified");
652
+ return type;
27
653
  }
28
654
  // src/shared/error-handling.ts
29
- import { AccessError } from "@highstate/backend/shared";
655
+ import { create as create3 } from "@bufbuild/protobuf";
656
+ import { durationFromMs } from "@bufbuild/protobuf/wkt";
657
+ import { Code as Code3, ConnectError as ConnectError2 } from "@connectrpc/connect";
658
+ import {
659
+ BadRequestSchema as BadRequestSchema2,
660
+ ErrorInfoSchema as ErrorInfoSchema2,
661
+ PreconditionFailureSchema,
662
+ RequestInfoSchema,
663
+ RetryInfoSchema
664
+ } from "@highstate/api/v1";
665
+ import { BackendError, BackendErrorCategory } from "@highstate/backend/shared";
30
666
  import { isAbortError } from "abort-controller-x";
31
- import { ServerError as ServerError2, Status as Status2 } from "nice-grpc-common";
32
- function createErrorHandlingMiddleware(services) {
33
- return async function* errorHandlingMiddleware(call, context) {
667
+ var sensitiveMetadataKey = /credential|token|secret|encrypted|cause|stack|path/i;
668
+ function createErrorHandlingInterceptor(services) {
669
+ return (next) => async (request) => {
34
670
  try {
35
- return yield* call.next(call.request, context);
671
+ return await next(request);
36
672
  } catch (error) {
37
- if (error instanceof ServerError2 || isAbortError(error)) {
673
+ if (isAbortError(error) || error instanceof ConnectError2 && error.code === Code3.Canceled) {
38
674
  throw error;
39
675
  }
40
- if (error instanceof AccessError) {
41
- services.logger.info({ error }, "access denied");
42
- throw new ServerError2(Status2.UNAUTHENTICATED, "Access denied");
676
+ if (error instanceof BackendError) {
677
+ const code = categoryToCode(error.category);
678
+ if (code === Code3.Internal) {
679
+ services.logger.error({ error, method: request.method.name }, "unexpected backend error");
680
+ throw new ConnectError2("An unexpected error occurred", Code3.Internal);
681
+ }
682
+ throw new ConnectError2(error.message, code, undefined, backendErrorDetails(error, request));
683
+ }
684
+ if (error instanceof ConnectError2) {
685
+ throw error;
43
686
  }
44
- services.logger.error({ error }, "unexpected error");
45
- throw new ServerError2(Status2.INTERNAL, "An unexpected error occurred");
687
+ services.logger.error({ error, method: request.method.name }, "unexpected error");
688
+ throw new ConnectError2("An unexpected error occurred", Code3.Internal);
46
689
  }
47
690
  };
48
691
  }
692
+ function backendErrorDetails(error, request) {
693
+ const details = [
694
+ {
695
+ desc: ErrorInfoSchema2,
696
+ value: create3(ErrorInfoSchema2, {
697
+ reason: error.reason,
698
+ domain: "highstate.io",
699
+ metadata: Object.fromEntries(Object.entries(error.metadata).filter(([key]) => !sensitiveMetadataKey.test(key)))
700
+ })
701
+ }
702
+ ];
703
+ if (error.fieldViolations.length > 0) {
704
+ details.push({
705
+ desc: BadRequestSchema2,
706
+ value: create3(BadRequestSchema2, {
707
+ fieldViolations: error.fieldViolations.map((violation) => ({
708
+ field: toProtobufPath(violation.field),
709
+ reason: violation.reason,
710
+ description: violation.description
711
+ }))
712
+ })
713
+ });
714
+ }
715
+ if (error.preconditionViolations.length > 0) {
716
+ details.push({
717
+ desc: PreconditionFailureSchema,
718
+ value: create3(PreconditionFailureSchema, {
719
+ violations: error.preconditionViolations.map((violation) => ({ ...violation }))
720
+ })
721
+ });
722
+ }
723
+ if (error.retry && Number.isFinite(error.retry.delayMs) && error.retry.delayMs >= 0) {
724
+ details.push({
725
+ desc: RetryInfoSchema,
726
+ value: create3(RetryInfoSchema, { retryDelay: durationFromMs(error.retry.delayMs) })
727
+ });
728
+ }
729
+ const requestId = request.header.get("x-request-id")?.trim();
730
+ if (requestId) {
731
+ details.push({
732
+ desc: RequestInfoSchema,
733
+ value: create3(RequestInfoSchema, { requestId })
734
+ });
735
+ }
736
+ return details;
737
+ }
738
+ function toProtobufPath(path) {
739
+ return path.split(".").map((segment) => segment.replace(/[A-Z]/g, (character) => `_${character.toLowerCase()}`)).join(".");
740
+ }
741
+ function categoryToCode(category) {
742
+ switch (category) {
743
+ case BackendErrorCategory.InvalidArgument:
744
+ return Code3.InvalidArgument;
745
+ case BackendErrorCategory.Unauthenticated:
746
+ return Code3.Unauthenticated;
747
+ case BackendErrorCategory.PermissionDenied:
748
+ return Code3.PermissionDenied;
749
+ case BackendErrorCategory.NotFound:
750
+ return Code3.NotFound;
751
+ case BackendErrorCategory.AlreadyExists:
752
+ return Code3.AlreadyExists;
753
+ case BackendErrorCategory.FailedPrecondition:
754
+ return Code3.FailedPrecondition;
755
+ case BackendErrorCategory.Aborted:
756
+ return Code3.Aborted;
757
+ case BackendErrorCategory.Unavailable:
758
+ return Code3.Unavailable;
759
+ case BackendErrorCategory.Internal:
760
+ return Code3.Internal;
761
+ }
762
+ }
763
+ // src/shared/field-mask.ts
764
+ import { Code as Code4 } from "@connectrpc/connect";
765
+ function validateUpdateMask(mask, schema, mutablePaths) {
766
+ if (!mask || mask.paths.length === 0) {
767
+ throw fieldMaskError("update_mask", "REQUIRED", "The update mask must not be empty");
768
+ }
769
+ const paths = new Set;
770
+ for (const path of mask.paths) {
771
+ const normalized = normalizePath(schema, path);
772
+ if (!mutablePaths.has(normalized)) {
773
+ throw fieldMaskError(`update_mask.paths`, "IMMUTABLE_OR_UNKNOWN", `The path "${path}" is unknown, immutable, output-only, or traverses a collection`);
774
+ }
775
+ paths.add(normalized);
776
+ }
777
+ return [...paths];
778
+ }
779
+ function normalizePath(schema, path) {
780
+ if (!path || path === "*")
781
+ return path;
782
+ let descriptor = schema;
783
+ const normalized = [];
784
+ const segments = path.split(".");
785
+ for (const [index, segment] of segments.entries()) {
786
+ const field = descriptor.fields.find((candidate) => candidate.name === segment || candidate.localName === segment || candidate.jsonName === segment);
787
+ if (!field)
788
+ return path;
789
+ normalized.push(field.name);
790
+ if (index === segments.length - 1)
791
+ break;
792
+ if (field.fieldKind !== "message" || !field.message)
793
+ return path;
794
+ descriptor = field.message;
795
+ }
796
+ return normalized.join(".");
797
+ }
798
+ function fieldMaskError(field, reason, description) {
799
+ return createApiError({
800
+ message: "Invalid update mask",
801
+ code: Code4.InvalidArgument,
802
+ reason: "UPDATE_MASK_INVALID",
803
+ fieldViolations: [{ field, reason, description }]
804
+ });
805
+ }
806
+ // src/shared/serialization.ts
807
+ function toJsonObject(value) {
808
+ return normalizeJsonValue(value);
809
+ }
810
+ function normalizeJsonValue(value) {
811
+ if (value instanceof Date) {
812
+ return value.toISOString();
813
+ }
814
+ if (Array.isArray(value)) {
815
+ return value.map(normalizeJsonValue);
816
+ }
817
+ if (value && typeof value === "object") {
818
+ return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
819
+ if (entry === undefined) {
820
+ return [];
821
+ }
822
+ return [[key, normalizeJsonValue(entry)]];
823
+ }));
824
+ }
825
+ return value;
826
+ }
49
827
  // src/shared/validation.ts
50
- import { ServerError as ServerError3, Status as Status3 } from "nice-grpc-common";
828
+ import { Code as Code5 } from "@connectrpc/connect";
51
829
  function parseArgument(request, argumentName, schema) {
52
830
  const result = schema.safeParse(request[argumentName]);
53
831
  if (!result.success) {
54
- throw new ServerError3(Status3.INVALID_ARGUMENT, `Invalid argument "${argumentName}": ${result.error.message}`);
832
+ throw validationError(`Invalid argument "${argumentName}"`, argumentName, result.error);
55
833
  }
56
834
  return result.data;
57
835
  }
58
- // src/handlers/instance.ts
59
- function createInstanceService(services) {
836
+ function parseValue(value, name, schema) {
837
+ const result = schema.safeParse(value);
838
+ if (!result.success) {
839
+ throw validationError(`Invalid ${name}`, name, result.error);
840
+ }
841
+ return result.data;
842
+ }
843
+ function validationError(message, field, error) {
844
+ return createApiError({
845
+ message,
846
+ code: Code5.InvalidArgument,
847
+ reason: "REQUEST_INVALID",
848
+ fieldViolations: error.issues.map((issue) => ({
849
+ field: [field, ...issue.path.map(String)].join("."),
850
+ reason: issue.code.toUpperCase(),
851
+ description: issue.message
852
+ }))
853
+ });
854
+ }
855
+ // src/handlers/instance-state.ts
856
+ function createInstanceStateService(services) {
60
857
  return {
858
+ async listInstanceStates(request, context) {
859
+ const requestContext = await authenticateProject(services, request, context);
860
+ const page = await services.instanceStateService.getInstanceStates(requestContext, {
861
+ includeEvaluationState: request.includeEvaluationState,
862
+ includeLastOperationState: request.includeLastOperationState,
863
+ includeParentInstanceId: request.includeParentInstanceId,
864
+ includeExtra: request.includeExtra,
865
+ loadCustomStatuses: request.includeCustomStatuses
866
+ }, { pageSize: request.pageSize, pageToken: request.pageToken });
867
+ return { states: page.items.map(toInstanceState), nextPageToken: page.nextPageToken };
868
+ },
869
+ async getInstanceState(request, context) {
870
+ const requestContext = await authenticateProject(services, request, context);
871
+ const stateId = parseArgument(request, "stateId", z2.cuid2());
872
+ const state = await services.instanceStateService.getInstanceStateOrThrow(requestContext, stateId, {
873
+ includeEvaluationState: request.includeEvaluationState,
874
+ includeLastOperationState: request.includeLastOperationState,
875
+ includeParentInstanceId: request.includeParentInstanceId,
876
+ includeExtra: request.includeExtra,
877
+ loadCustomStatuses: request.includeCustomStatuses
878
+ });
879
+ return { state: toInstanceState(state) };
880
+ },
61
881
  async updateCustomStatus(request, context) {
62
- const [projectId, apiKey] = await authenticate(services, context);
63
- const stateId = parseArgument(request, "stateId", z.cuid2());
64
- const customStatus = parseArgument(request, "status", instanceCustomStatusInputSchema);
65
- await services.instanceStateService.updateCustomStatus(projectId, stateId, apiKey.serviceAccountId, customStatus);
66
- return {};
882
+ const requestContext = await authenticateProject(services, request, context);
883
+ const stateId = parseArgument(request, "stateId", z2.cuid2());
884
+ const customStatus = parseArgument(request, "status", instanceCustomStatusInputSchema2);
885
+ await services.instanceStateService.updateCustomStatus(requestContext, stateId, requestContext.subject.serviceAccountId, customStatus);
886
+ const state = await services.instanceStateService.getInstanceStateOrThrow(requestContext, stateId, { loadCustomStatuses: true });
887
+ const status = state.customStatuses?.find((value) => value.name === customStatus.name && value.serviceAccountId === requestContext.subject.serviceAccountId);
888
+ if (!status) {
889
+ throw new Error("Updated custom status was not returned by the backend");
890
+ }
891
+ return { status: toInstanceCustomStatus(status) };
67
892
  },
68
893
  async removeCustomStatus(request, context) {
69
- const [projectId, apiKey] = await authenticate(services, context);
70
- const stateId = parseArgument(request, "stateId", z.cuid2());
71
- await services.instanceStateService.removeCustomStatus(projectId, stateId, apiKey.serviceAccountId, request.statusName);
894
+ const requestContext = await authenticateProject(services, request, context);
895
+ const stateId = parseArgument(request, "stateId", z2.cuid2());
896
+ const statusName = parseArgument(request, "statusName", z2.string().min(1));
897
+ await services.instanceStateService.removeCustomStatus(requestContext, stateId, requestContext.subject.serviceAccountId, statusName);
898
+ return {};
899
+ }
900
+ };
901
+ }
902
+
903
+ // src/handlers/library.ts
904
+ import { ComponentKind as ComponentKind2 } from "@highstate/api/v1";
905
+ import { ComponentNotFoundError } from "@highstate/backend/shared";
906
+ import { z as z3 } from "@highstate/contract";
907
+ function createLibraryService(services) {
908
+ return {
909
+ async getLibrary(request, context) {
910
+ const requestContext = await authenticateProject(services, request, context);
911
+ const library = await services.libraryService.getLibraryModel(requestContext, context.signal);
912
+ return {
913
+ library: toLibrary({
914
+ components: { ...library.components },
915
+ entities: { ...library.entities }
916
+ })
917
+ };
918
+ },
919
+ async listComponents(request, context) {
920
+ const requestContext = await authenticateProject(services, request, context);
921
+ const page = await services.libraryService.getComponents(requestContext, { pageSize: request.pageSize, pageToken: request.pageToken }, context.signal);
922
+ return {
923
+ components: page.items.map((component) => ({
924
+ type: component.type,
925
+ kind: component.kind === "unit" ? ComponentKind2.UNIT : ComponentKind2.COMPOSITE,
926
+ meta: {
927
+ title: component.meta.title,
928
+ description: component.meta.description,
929
+ color: component.meta.color,
930
+ icon: component.meta.icon,
931
+ iconColor: component.meta.iconColor,
932
+ secondaryIcon: component.meta.secondaryIcon,
933
+ secondaryIconColor: component.meta.secondaryIconColor,
934
+ category: component.meta.category,
935
+ defaultNamePrefix: component.meta.defaultNamePrefix
936
+ }
937
+ })),
938
+ nextPageToken: page.nextPageToken
939
+ };
940
+ },
941
+ async getComponent(request, context) {
942
+ const requestContext = await authenticateProject(services, request, context);
943
+ const type = parseArgument(request, "type", z3.string().min(1));
944
+ const library = await services.libraryService.getLibraryModel(requestContext, context.signal);
945
+ const component = library.components[type];
946
+ if (!component) {
947
+ throw new ComponentNotFoundError(requestContext.projectId, type);
948
+ }
949
+ return { component: toComponent(component) };
950
+ }
951
+ };
952
+ }
953
+
954
+ // src/handlers/operation.ts
955
+ import { operationLaunchInputSchema, operationPlanInputSchema } from "@highstate/backend/shared";
956
+ import { instanceIdSchema, z as z4 } from "@highstate/contract";
957
+ function createOperationService(services) {
958
+ return {
959
+ async planOperation(request, context) {
960
+ const requestContext = await authenticateProject(services, request, context);
961
+ const input = parseValue({ ...request, projectId: requestContext.projectId, type: fromOperationType(request.type) }, "request", operationPlanInputSchema);
962
+ const phases = await services.operationManager.plan(input);
963
+ return { phases: phases.map(toOperationPhase) };
964
+ },
965
+ async launchOperation(request, context) {
966
+ const requestContext = await authenticateProject(services, request, context);
967
+ const input = parseValue({
968
+ ...request,
969
+ projectId: requestContext.projectId,
970
+ type: fromOperationType(request.type),
971
+ plan: request.plan.length > 0 ? request.plan.map(fromOperationPhase) : undefined
972
+ }, "request", operationLaunchInputSchema);
973
+ const operation = await services.operationManager.launch(input);
974
+ return { operation: toOperation(operation) };
975
+ },
976
+ async getOperation(request, context) {
977
+ const requestContext = await authenticateProject(services, request, context);
978
+ const operationId = parseValue(request.operationId, "operationId", z4.string().min(1));
979
+ const operation = await services.operationService.getOperationOrThrow(requestContext, operationId);
980
+ return { operation: toOperation(operation) };
981
+ },
982
+ async listOperations(request, context) {
983
+ const requestContext = await authenticateProject(services, request, context);
984
+ const page = await services.operationService.getOperations(requestContext, {
985
+ pageSize: request.pageSize,
986
+ pageToken: request.pageToken
987
+ });
988
+ return { operations: page.items.map(toOperation), nextPageToken: page.nextPageToken };
989
+ },
990
+ async listOperationLogs(request, context) {
991
+ const requestContext = await authenticateProject(services, request, context);
992
+ const operationId = parseValue(request.operationId, "operationId", z4.string().min(1));
993
+ const stateId = parseValue(request.stateId, "stateId", z4.cuid2().optional());
994
+ await services.operationService.getOperationOrThrow(requestContext, operationId);
995
+ const page = await services.operationService.getOperationLogs(requestContext, operationId, stateId, { pageSize: request.pageSize, pageToken: request.pageToken });
996
+ return {
997
+ logs: page.items.map((log) => toOperationLog(operationId, log)),
998
+ nextPageToken: page.nextPageToken
999
+ };
1000
+ },
1001
+ async cancelOperation(request, context) {
1002
+ const requestContext = await authenticateProject(services, request, context);
1003
+ const operationId = parseValue(request.operationId, "operationId", z4.string().min(1));
1004
+ await services.operationService.getOperationOrThrow(requestContext, operationId);
1005
+ services.operationManager.cancel(operationId);
1006
+ return {};
1007
+ },
1008
+ async cancelInstanceOperation(request, context) {
1009
+ const requestContext = await authenticateProject(services, request, context);
1010
+ const operationId = parseValue(request.operationId, "operationId", z4.string().min(1));
1011
+ const instanceId = parseValue(request.instanceId, "instanceId", instanceIdSchema);
1012
+ await services.operationService.getOperationOrThrow(requestContext, operationId);
1013
+ services.operationManager.cancelInstance(operationId, instanceId);
72
1014
  return {};
73
1015
  }
74
1016
  };
@@ -76,54 +1018,166 @@ function createInstanceService(services) {
76
1018
 
77
1019
  // src/handlers/panel.ts
78
1020
  import { panelInputSchema } from "@highstate/backend/shared";
79
- import { z as z2 } from "@highstate/contract";
1021
+ import { z as z5 } from "@highstate/contract";
80
1022
  function createPanelService(services) {
81
1023
  return {
82
1024
  async setUnitPanels(request, context) {
83
- const [projectId, apiKey] = await authenticate(services, context);
84
- const workerVersionId = parseArgument(request, "workerVersionId", z2.cuid2());
85
- const workerInstanceId = parseArgument(request, "workerInstanceId", z2.cuid2());
86
- const stateId = parseArgument(request, "stateId", z2.cuid2());
87
- const panels = parseArgument(request, "panels", z2.array(panelInputSchema));
88
- const panelIds = await services.panelService.setUnitPanels(projectId, stateId, apiKey.id, workerVersionId, panels, workerInstanceId);
1025
+ const requestContext = await authenticateProject(services, request, context);
1026
+ const workerVersionId = parseArgument(request, "workerVersionId", z5.cuid2());
1027
+ const workerInstanceId = parseArgument(request, "workerInstanceId", z5.cuid2());
1028
+ const stateId = parseArgument(request, "stateId", z5.cuid2());
1029
+ const panels = parseArgument(request, "panels", z5.array(panelInputSchema));
1030
+ const panelIds = await services.panelService.setUnitPanels(requestContext, stateId, requestContext.subject.apiKeyId, workerVersionId, panels, workerInstanceId);
89
1031
  return { panelIds };
90
1032
  }
91
1033
  };
92
1034
  }
93
1035
 
1036
+ // src/handlers/project.ts
1037
+ function createProjectService(services) {
1038
+ return {
1039
+ async getProject(request, context) {
1040
+ const requestContext = await authenticateProject(services, request, context);
1041
+ const [project, unlockState] = await Promise.all([
1042
+ services.projectService.getProjectOrThrowCore(requestContext.projectId),
1043
+ services.projectUnlockService.getProjectUnlockStateCore(requestContext.projectId)
1044
+ ]);
1045
+ return {
1046
+ project: toProject(project),
1047
+ isLocked: unlockState.type === "locked"
1048
+ };
1049
+ },
1050
+ async listProjects(request, context) {
1051
+ const requestContext = await authenticateBackend(services, context);
1052
+ const result = await services.projectService.getProjects(requestContext, {
1053
+ pageSize: request.pageSize,
1054
+ pageToken: request.pageToken
1055
+ });
1056
+ const projects = await Promise.all(result.items.map(async (project) => {
1057
+ const unlockState = await services.projectUnlockService.getProjectUnlockStateCore(project.id);
1058
+ return {
1059
+ project: toProject(project),
1060
+ isLocked: unlockState.type === "locked"
1061
+ };
1062
+ }));
1063
+ return {
1064
+ projects,
1065
+ nextPageToken: result.nextPageToken
1066
+ };
1067
+ }
1068
+ };
1069
+ }
1070
+
1071
+ // src/handlers/project-model.ts
1072
+ import {
1073
+ HubSchema as HubSchema2,
1074
+ InstanceSchema as InstanceSchema2
1075
+ } from "@highstate/api/v1";
1076
+ import { projectModelInstanceSchema } from "@highstate/backend/shared";
1077
+ import { hubModelSchema, instanceIdSchema as instanceIdSchema2, z as z6 } from "@highstate/contract";
1078
+ var instanceMutablePaths = new Set([
1079
+ "arguments",
1080
+ "inputs",
1081
+ "hub_inputs",
1082
+ "injection_inputs",
1083
+ "position",
1084
+ "position.x",
1085
+ "position.y"
1086
+ ]);
1087
+ var hubMutablePaths = new Set([
1088
+ "position",
1089
+ "position.x",
1090
+ "position.y",
1091
+ "inputs",
1092
+ "injection_inputs"
1093
+ ]);
1094
+ function createProjectModelService(services) {
1095
+ return {
1096
+ async getProjectModel(request, context) {
1097
+ const requestContext = await authenticateProject(services, request, context);
1098
+ const [model] = await services.projectModelService.getProjectModel(requestContext, {
1099
+ includeVirtualInstances: request.includeVirtualInstances,
1100
+ includeGhostInstances: request.includeGhostInstances
1101
+ });
1102
+ return { model: toProjectModel(model) };
1103
+ },
1104
+ async createNodes(request, context) {
1105
+ const requestContext = await authenticateProject(services, request, context);
1106
+ const instances = parseValue(request.instances.map(fromInstance), "instances", projectModelInstanceSchema.array());
1107
+ const hubs = parseValue(request.hubs.map(fromHub), "hubs", hubModelSchema.array());
1108
+ await services.projectService.createNodes(requestContext, instances, hubs);
1109
+ return {};
1110
+ },
1111
+ async updateInstance(request, context) {
1112
+ const requestContext = await authenticateProject(services, request, context);
1113
+ const requestInstance = parseArgument(request, "instance", z6.custom());
1114
+ const instanceId = parseArgument(requestInstance, "id", instanceIdSchema2);
1115
+ const paths = validateUpdateMask(request.updateMask, InstanceSchema2, instanceMutablePaths);
1116
+ const patch = toInstancePatch(requestInstance, paths);
1117
+ const instance = await services.projectService.updateInstance(requestContext, instanceId, patch);
1118
+ return { instance: toInstance(instance) };
1119
+ },
1120
+ async renameInstance(request, context) {
1121
+ const requestContext = await authenticateProject(services, request, context);
1122
+ const instanceId = parseArgument(request, "instanceId", instanceIdSchema2);
1123
+ const newName = parseArgument(request, "newName", z6.string().min(1));
1124
+ const instance = await services.projectService.renameInstance(requestContext, instanceId, newName);
1125
+ return { instance: toInstance(instance) };
1126
+ },
1127
+ async deleteInstance(request, context) {
1128
+ const requestContext = await authenticateProject(services, request, context);
1129
+ const instanceId = parseArgument(request, "instanceId", instanceIdSchema2);
1130
+ await services.projectService.deleteInstance(requestContext, instanceId);
1131
+ return {};
1132
+ },
1133
+ async updateHub(request, context) {
1134
+ const requestContext = await authenticateProject(services, request, context);
1135
+ const requestHub = parseArgument(request, "hub", z6.custom());
1136
+ const hubId = parseArgument(requestHub, "id", z6.cuid2());
1137
+ const paths = validateUpdateMask(request.updateMask, HubSchema2, hubMutablePaths);
1138
+ const patch = toHubPatch(requestHub, paths);
1139
+ const hub = await services.projectService.updateHub(requestContext, hubId, patch);
1140
+ return { hub: toHub(hub) };
1141
+ },
1142
+ async deleteHub(request, context) {
1143
+ const requestContext = await authenticateProject(services, request, context);
1144
+ const hubId = parseArgument(request, "hubId", z6.cuid2());
1145
+ await services.projectService.deleteHub(requestContext, hubId);
1146
+ return {};
1147
+ }
1148
+ };
1149
+ }
1150
+
94
1151
  // src/handlers/secret.ts
95
1152
  function createSecretService(services) {
96
1153
  return {
97
- async getSecretContent(_request, context) {
98
- const [_projectId] = await authenticate(services, context);
1154
+ async getSecretContent(request, context) {
1155
+ await authenticateProject(services, request, context);
99
1156
  throw new Error("Not implemented");
100
1157
  }
101
1158
  };
102
1159
  }
103
1160
 
104
1161
  // src/handlers/worker.ts
105
- import { commonObjectMetaSchema, serviceAccountMetaSchema, z as z3 } from "@highstate/contract";
106
- var workerMetaUpdatePayloadSchema = z3.union([
107
- commonObjectMetaSchema,
108
- z3.object({
109
- workerMeta: commonObjectMetaSchema,
110
- serviceAccountMeta: serviceAccountMetaSchema
111
- })
112
- ]);
1162
+ import { create as create4 } from "@bufbuild/protobuf";
1163
+ import { ConnectResponseSchema } from "@highstate/api/worker.v1";
1164
+ import { WorkerOwnershipError } from "@highstate/backend/shared";
1165
+ import { commonObjectMetaSchema, serviceAccountMetaSchema, z as z7 } from "@highstate/contract";
113
1166
  function createWorkerService(services) {
114
1167
  return {
115
1168
  async* connect(request, context) {
116
- const [projectId, apiKey] = await authenticate(services, context);
117
- const workerVersionId = parseArgument(request, "workerVersionId", z3.cuid2());
118
- const workerInstanceId = parseArgument(request, "workerInstanceId", z3.cuid2());
119
- const dataEndpoint = parseArgument(request, "dataEndpoint", z3.string().min(1));
1169
+ const requestContext = await authenticateProject(services, request, context);
1170
+ const { projectId } = requestContext;
1171
+ const workerVersionId = parseArgument(request, "workerVersionId", z7.cuid2());
1172
+ const workerInstanceId = parseArgument(request, "workerInstanceId", z7.cuid2());
1173
+ const dataEndpoint = parseArgument(request, "dataEndpoint", z7.string().min(1));
120
1174
  const database = await services.database.forProject(projectId);
121
1175
  const workerVersion = await database.workerVersion.findFirst({
122
- where: { id: workerVersionId, apiKeyId: apiKey.id },
1176
+ where: { id: workerVersionId, apiKeyId: requestContext.subject.apiKeyId },
123
1177
  select: { id: true }
124
1178
  });
125
1179
  if (!workerVersion) {
126
- throw new Error(`Worker version "${workerVersionId}" is not owned by the API key`);
1180
+ throw new WorkerOwnershipError(projectId, workerVersionId);
127
1181
  }
128
1182
  services.workerManager.assertWorkerInstance(projectId, workerVersionId, workerInstanceId);
129
1183
  services.panelEndpointManager.connect(projectId, workerVersionId, workerInstanceId, dataEndpoint);
@@ -134,15 +1188,15 @@ function createWorkerService(services) {
134
1188
  select: { stateId: true, params: true }
135
1189
  });
136
1190
  for (const registration of existingRegistrations) {
137
- yield {
1191
+ yield create4(ConnectResponseSchema, {
138
1192
  event: {
139
- $case: "unitRegistration",
1193
+ case: "unitRegistration",
140
1194
  value: {
141
1195
  stateId: registration.stateId,
142
- params: registration.params
1196
+ params: toJsonObject(registration.params)
143
1197
  }
144
1198
  }
145
- };
1199
+ });
146
1200
  }
147
1201
  const registrationStream = await services.pubsubManager.subscribe([
148
1202
  "worker-unit-registration",
@@ -151,19 +1205,19 @@ function createWorkerService(services) {
151
1205
  ]);
152
1206
  for await (const event of registrationStream) {
153
1207
  if (event.type === "registered") {
154
- yield {
1208
+ yield create4(ConnectResponseSchema, {
155
1209
  event: {
156
- $case: "unitRegistration",
157
- value: { stateId: event.stateId, params: event.params }
1210
+ case: "unitRegistration",
1211
+ value: { stateId: event.stateId, params: toJsonObject(event.params) }
158
1212
  }
159
- };
1213
+ });
160
1214
  } else if (event.type === "deregistered") {
161
- yield {
1215
+ yield create4(ConnectResponseSchema, {
162
1216
  event: {
163
- $case: "unitDeregistration",
1217
+ case: "unitDeregistration",
164
1218
  value: { stateId: event.stateId }
165
1219
  }
166
- };
1220
+ });
167
1221
  }
168
1222
  }
169
1223
  } finally {
@@ -171,38 +1225,93 @@ function createWorkerService(services) {
171
1225
  }
172
1226
  },
173
1227
  async updateWorkerVersionMeta(request, context) {
174
- const [projectId] = await authenticate(services, context);
175
- const workerVersionId = parseArgument(request, "workerVersionId", z3.string());
176
- const payload = parseArgument(request, "meta", workerMetaUpdatePayloadSchema);
177
- const workerMeta = "workerMeta" in payload ? payload.workerMeta : payload;
178
- const serviceAccountMeta = "serviceAccountMeta" in payload ? payload.serviceAccountMeta : undefined;
179
- await services.workerService.updateWorkerVersionMeta(projectId, workerVersionId, workerMeta, serviceAccountMeta);
1228
+ const requestContext = await authenticateProject(services, request, context);
1229
+ const workerVersionId = parseArgument(request, "workerVersionId", z7.string());
1230
+ const workerMeta = parseArgument(request, "workerMeta", commonObjectMetaSchema);
1231
+ const serviceAccountMeta = parseArgument(request, "serviceAccountMeta", serviceAccountMetaSchema.optional());
1232
+ await services.workerService.updateWorkerVersionMeta(requestContext, workerVersionId, workerMeta, serviceAccountMeta);
180
1233
  return {};
181
1234
  }
182
1235
  };
183
1236
  }
184
1237
 
185
1238
  // src/index.ts
186
- async function startBackedApi(services) {
187
- const server = createServer();
188
- server.use(createErrorHandlingMiddleware(services));
189
- server.add(InstanceServiceDefinition, createInstanceService(services));
190
- server.add(PanelServiceDefinition, createPanelService(services));
191
- server.add(SecretServiceDefinition, createSecretService(services));
192
- server.add(WorkerServiceDefinition, createWorkerService(services));
1239
+ function createBackendApiHandler(services, options = {}) {
1240
+ return connectNodeAdapter({
1241
+ interceptors: [createErrorHandlingInterceptor(services)],
1242
+ requestPathPrefix: options.requestPathPrefix,
1243
+ routes(router) {
1244
+ router.service(InstanceStateService, createInstanceStateService(services));
1245
+ router.service(LibraryService, createLibraryService(services));
1246
+ router.service(OperationService, createOperationService(services));
1247
+ router.service(PanelService, createPanelService(services));
1248
+ router.service(ProjectModelService, createProjectModelService(services));
1249
+ router.service(ProjectService, createProjectService(services));
1250
+ router.service(SecretService, createSecretService(services));
1251
+ router.service(WorkerService, createWorkerService(services));
1252
+ }
1253
+ });
1254
+ }
1255
+ async function startBackendApi(services, options = {}) {
193
1256
  const uid = process.geteuid?.();
194
- const sockPath = `/run/user/${uid}/highstate.sock`;
1257
+ const workerSocketPath = options.workerSocketPath ?? `/run/user/${uid}/highstate.sock`;
1258
+ const workerAddress = `unix:${workerSocketPath}`;
1259
+ const address = options.address ?? workerAddress;
1260
+ const addresses = address === workerAddress ? [workerAddress] : [workerAddress, address];
1261
+ const handler = createBackendApiHandler(services);
1262
+ const servers = addresses.map(() => createServer(handler));
195
1263
  try {
196
- await rm(sockPath, { force: true });
1264
+ await rm(workerSocketPath, { force: true });
197
1265
  } catch (error) {
198
1266
  if (!(error instanceof Error && ("code" in error) && error.code === "ENOENT")) {
199
1267
  services.logger.error({ error }, "failed to remove existing socket file");
200
1268
  }
201
1269
  }
202
- await server.listen(`unix:${sockPath}`);
203
- services.workerManager.config.HIGHSTATE_WORKER_API_PATH = sockPath;
204
- services.logger.info(`api listening at %s`, sockPath);
1270
+ try {
1271
+ for (const [index, server] of servers.entries()) {
1272
+ const serverAddress = addresses[index];
1273
+ await listen(server, serverAddress);
1274
+ services.logger.info(`api listening at "%s"`, serverAddress);
1275
+ }
1276
+ } catch (error) {
1277
+ await Promise.allSettled(servers.map(close));
1278
+ await rm(workerSocketPath, { force: true });
1279
+ throw new Error("Failed to start backend api", { cause: error });
1280
+ }
1281
+ services.workerManager.config.HIGHSTATE_WORKER_API_PATH = workerSocketPath;
1282
+ return {
1283
+ address,
1284
+ async shutdown() {
1285
+ await Promise.all(servers.map(close));
1286
+ await rm(workerSocketPath, { force: true });
1287
+ }
1288
+ };
1289
+ }
1290
+ async function listen(server, address) {
1291
+ await new Promise((resolve, reject) => {
1292
+ server.once("error", reject);
1293
+ server.once("listening", resolve);
1294
+ if (address.startsWith("unix:")) {
1295
+ server.listen(address.slice("unix:".length));
1296
+ return;
1297
+ }
1298
+ const url = new URL(address.includes("://") ? address : `http://${address}`);
1299
+ server.listen(Number(url.port), url.hostname);
1300
+ });
1301
+ }
1302
+ async function close(server) {
1303
+ await new Promise((resolve, reject) => {
1304
+ server.close((error) => {
1305
+ if (error) {
1306
+ reject(error);
1307
+ return;
1308
+ }
1309
+ resolve();
1310
+ });
1311
+ server.closeAllConnections();
1312
+ });
205
1313
  }
206
1314
  export {
207
- startBackedApi
1315
+ startBackendApi,
1316
+ createBackendApiHandler
208
1317
  };