@highstate/backend-api 0.26.0 → 0.28.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/src/index.ts CHANGED
@@ -1,37 +1,143 @@
1
1
  import type { Services } from "@highstate/backend"
2
2
  import { rm } from "node:fs/promises"
3
- import { InstanceServiceDefinition } from "@highstate/api/instance.v1"
4
- import { PanelServiceDefinition } from "@highstate/api/panel.v1"
5
- import { SecretServiceDefinition } from "@highstate/api/secret.v1"
6
- import { WorkerServiceDefinition } from "@highstate/api/worker.v1"
7
- import { createServer } from "nice-grpc"
8
- import { createInstanceService } from "./handlers/instance"
3
+ import { createServer, type RequestListener, type Server } from "node:http"
4
+ import { connectNodeAdapter } from "@connectrpc/connect-node"
5
+ import {
6
+ InstanceStateService,
7
+ LibraryService,
8
+ OperationService,
9
+ PanelService,
10
+ ProjectModelService,
11
+ ProjectService,
12
+ SecretService,
13
+ } from "@highstate/api/v1"
14
+ import { WorkerService } from "@highstate/api/worker.v1"
15
+ import { createInstanceStateService } from "./handlers/instance-state"
16
+ import { createLibraryService } from "./handlers/library"
17
+ import { createOperationService } from "./handlers/operation"
9
18
  import { createPanelService } from "./handlers/panel"
19
+ import { createProjectService } from "./handlers/project"
20
+ import { createProjectModelService } from "./handlers/project-model"
10
21
  import { createSecretService } from "./handlers/secret"
11
22
  import { createWorkerService } from "./handlers/worker"
12
- import { createErrorHandlingMiddleware } from "./shared"
23
+ import { createErrorHandlingInterceptor } from "./shared"
13
24
 
14
- export async function startBackedApi(services: Services) {
15
- const server = createServer()
16
- server.use(createErrorHandlingMiddleware(services))
25
+ export type BackendApiOptions = {
26
+ address?: string
27
+ workerSocketPath?: string
28
+ }
29
+
30
+ export type BackendApiHandlerOptions = {
31
+ requestPathPrefix?: string
32
+ }
17
33
 
18
- server.add(InstanceServiceDefinition, createInstanceService(services))
19
- server.add(PanelServiceDefinition, createPanelService(services))
20
- server.add(SecretServiceDefinition, createSecretService(services))
21
- server.add(WorkerServiceDefinition, createWorkerService(services))
34
+ export type BackendApi = {
35
+ address: string
36
+ shutdown(): Promise<void>
37
+ }
38
+
39
+ /**
40
+ * Creates a Node.js handler for the public Highstate Connect API.
41
+ *
42
+ * @param services The backend services used by the API handlers.
43
+ * @param options The API handler options.
44
+ * @returns A Node.js request listener serving the public API.
45
+ */
46
+ export function createBackendApiHandler(
47
+ services: Services,
48
+ options: BackendApiHandlerOptions = {},
49
+ ): RequestListener {
50
+ return connectNodeAdapter({
51
+ interceptors: [createErrorHandlingInterceptor(services)],
52
+ requestPathPrefix: options.requestPathPrefix,
53
+ routes(router) {
54
+ router.service(InstanceStateService, createInstanceStateService(services))
55
+ router.service(LibraryService, createLibraryService(services))
56
+ router.service(OperationService, createOperationService(services))
57
+ router.service(PanelService, createPanelService(services))
58
+ router.service(ProjectModelService, createProjectModelService(services))
59
+ router.service(ProjectService, createProjectService(services))
60
+ router.service(SecretService, createSecretService(services))
61
+ router.service(WorkerService, createWorkerService(services))
62
+ },
63
+ })
64
+ }
22
65
 
66
+ /**
67
+ * Starts the public Highstate Connect API.
68
+ *
69
+ * @param services The backend services used by the API handlers.
70
+ * @param options The API listen and worker socket options.
71
+ * @returns The running API and its lifecycle controls.
72
+ */
73
+ export async function startBackendApi(
74
+ services: Services,
75
+ options: BackendApiOptions = {},
76
+ ): Promise<BackendApi> {
23
77
  const uid = process.geteuid?.()
24
- const sockPath = `/run/user/${uid}/highstate.sock`
78
+ const workerSocketPath = options.workerSocketPath ?? `/run/user/${uid}/highstate.sock`
79
+ const workerAddress = `unix:${workerSocketPath}`
80
+ const address = options.address ?? workerAddress
81
+ const addresses = address === workerAddress ? [workerAddress] : [workerAddress, address]
82
+ const handler = createBackendApiHandler(services)
83
+ const servers = addresses.map(() => createServer(handler))
25
84
 
26
85
  try {
27
- await rm(sockPath, { force: true })
86
+ await rm(workerSocketPath, { force: true })
28
87
  } catch (error) {
29
88
  if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
30
89
  services.logger.error({ error }, "failed to remove existing socket file")
31
90
  }
32
91
  }
33
92
 
34
- await server.listen(`unix:${sockPath}`)
35
- services.workerManager.config.HIGHSTATE_WORKER_API_PATH = sockPath
36
- services.logger.info(`api listening at %s`, sockPath)
93
+ try {
94
+ for (const [index, server] of servers.entries()) {
95
+ const serverAddress = addresses[index]!
96
+ await listen(server, serverAddress)
97
+ services.logger.info(`api listening at "%s"`, serverAddress)
98
+ }
99
+ } catch (error) {
100
+ await Promise.allSettled(servers.map(close))
101
+ await rm(workerSocketPath, { force: true })
102
+ throw new Error("Failed to start backend api", { cause: error })
103
+ }
104
+
105
+ services.workerManager.config.HIGHSTATE_WORKER_API_PATH = workerSocketPath
106
+
107
+ return {
108
+ address,
109
+ async shutdown() {
110
+ await Promise.all(servers.map(close))
111
+ await rm(workerSocketPath, { force: true })
112
+ },
113
+ }
114
+ }
115
+
116
+ async function listen(server: Server, address: string): Promise<void> {
117
+ await new Promise<void>((resolve, reject) => {
118
+ server.once("error", reject)
119
+ server.once("listening", resolve)
120
+
121
+ if (address.startsWith("unix:")) {
122
+ server.listen(address.slice("unix:".length))
123
+ return
124
+ }
125
+
126
+ const url = new URL(address.includes("://") ? address : `http://${address}`)
127
+ server.listen(Number(url.port), url.hostname)
128
+ })
129
+ }
130
+
131
+ async function close(server: Server): Promise<void> {
132
+ await new Promise<void>((resolve, reject) => {
133
+ server.close(error => {
134
+ if (error) {
135
+ reject(error)
136
+ return
137
+ }
138
+
139
+ resolve()
140
+ })
141
+ server.closeAllConnections()
142
+ })
37
143
  }
@@ -0,0 +1,42 @@
1
+ import type { GenMessage } from "@bufbuild/protobuf/codegenv2"
2
+ import { create, type Message } from "@bufbuild/protobuf"
3
+ import { type Code, ConnectError } from "@connectrpc/connect"
4
+ import { BadRequestSchema, ErrorInfoSchema } from "@highstate/api/v1"
5
+
6
+ export type ApiFieldViolation = {
7
+ field: string
8
+ reason: string
9
+ description: string
10
+ }
11
+
12
+ type OutgoingDetail = { desc: GenMessage<Message>; value: Message }
13
+
14
+ export function createApiError(options: {
15
+ message: string
16
+ code: Code
17
+ reason: string
18
+ metadata?: Record<string, string>
19
+ fieldViolations?: readonly ApiFieldViolation[]
20
+ }): ConnectError {
21
+ const details: OutgoingDetail[] = [
22
+ {
23
+ desc: ErrorInfoSchema,
24
+ value: create(ErrorInfoSchema, {
25
+ reason: options.reason,
26
+ domain: "highstate.io",
27
+ metadata: options.metadata,
28
+ }),
29
+ },
30
+ ]
31
+
32
+ if (options.fieldViolations?.length) {
33
+ details.push({
34
+ desc: BadRequestSchema,
35
+ value: create(BadRequestSchema, {
36
+ fieldViolations: options.fieldViolations.map(violation => ({ ...violation })),
37
+ }),
38
+ })
39
+ }
40
+
41
+ return new ConnectError(options.message, options.code, undefined, details)
42
+ }
@@ -1,21 +1,181 @@
1
- import type { ApiKey, Services } from "@highstate/backend"
2
- import { type CallContext, ServerError, Status } from "nice-grpc-common"
1
+ import type {
2
+ ApiKey,
3
+ BackendApiKey,
4
+ BackendRequestContext,
5
+ ProjectRequestContext,
6
+ ResolvedPermissionGrant,
7
+ ResolvedPermissionRestriction,
8
+ ResolvedPermissions,
9
+ Services,
10
+ } from "@highstate/backend"
11
+ import type {
12
+ BackendPermission,
13
+ BackendPermissionRestriction,
14
+ ProjectPermission,
15
+ ProjectPermissionRestriction,
16
+ } from "@highstate/backend/shared"
17
+ import { Code, type HandlerContext } from "@connectrpc/connect"
18
+ import { ProjectLockedError, ProjectNotFoundError } from "@highstate/backend/shared"
19
+ import { createApiError } from "./api-error"
20
+ import { parseBearerToken } from "./authorization-header"
3
21
 
4
- export async function authenticate(
22
+ type ServiceAccountSubject = Extract<
23
+ ProjectRequestContext["subject"],
24
+ { type: "service-account" }
25
+ > & { apiKeyId: string }
26
+
27
+ type BackendAuthorizationContext = Omit<BackendRequestContext, "subject"> & {
28
+ subject: ServiceAccountSubject
29
+ }
30
+
31
+ type ProjectAuthorizationContext = Omit<ProjectRequestContext, "subject"> & {
32
+ subject: ServiceAccountSubject
33
+ }
34
+
35
+ export async function authenticateBackend(
5
36
  services: Services,
6
- context: CallContext,
7
- ): Promise<[projectId: string, apiKey: ApiKey]> {
8
- const token = context.metadata.get("api-key")
9
- if (!token) {
10
- throw new ServerError(Status.UNAUTHENTICATED, "No API key provided")
37
+ context: HandlerContext,
38
+ ): Promise<BackendAuthorizationContext> {
39
+ const token = getBearerToken(context)
40
+ const apiKey = await services.apiKeyService.getBackendApiKeyByToken(token)
41
+ const roleBindings = await services.database.backend.serviceAccountBackendRoleBinding.findMany({
42
+ where: { serviceAccountId: apiKey.serviceAccountId },
43
+ include: { role: { select: { rules: true } } },
44
+ })
45
+
46
+ return {
47
+ realm: "backend",
48
+ subject: {
49
+ type: "service-account",
50
+ serviceAccountId: apiKey.serviceAccountId,
51
+ apiKeyId: apiKey.id,
52
+ },
53
+ permissions: resolveBackendPermissions(
54
+ roleBindings.flatMap(binding =>
55
+ binding.role.rules.map(rule => ({
56
+ permissions: rule.permissions,
57
+ restrictions: rule.restrictions ?? [],
58
+ })),
59
+ ),
60
+ apiKey.restrictionRules,
61
+ ),
11
62
  }
63
+ }
12
64
 
13
- const projectId = context.metadata.get("project-id")
65
+ export async function authenticateProject(
66
+ services: Services,
67
+ request: { projectId: string },
68
+ context: HandlerContext,
69
+ ): Promise<ProjectAuthorizationContext> {
70
+ const projectId = request.projectId
14
71
  if (!projectId) {
15
- throw new ServerError(Status.UNAUTHENTICATED, "No project ID provided")
72
+ throw createApiError({
73
+ message: "No project ID provided",
74
+ code: Code.InvalidArgument,
75
+ reason: "PROJECT_ID_REQUIRED",
76
+ fieldViolations: [
77
+ {
78
+ field: "project_id",
79
+ reason: "REQUIRED",
80
+ description: "The project ID is required",
81
+ },
82
+ ],
83
+ })
84
+ }
85
+
86
+ const project = await services.database.backend.project.findUnique({
87
+ where: { id: projectId },
88
+ select: { id: true },
89
+ })
90
+
91
+ if (!project) {
92
+ throw new ProjectNotFoundError(projectId)
93
+ }
94
+
95
+ if (!(await services.projectUnlockService.checkProjectUnlocked(projectId))) {
96
+ throw new ProjectLockedError(projectId)
16
97
  }
17
98
 
18
- const apiKey = await services.apiKeyService.getApiKeyByToken(projectId, token)
99
+ const token = getBearerToken(context)
100
+ const apiKey = await services.apiKeyService.getProjectCredentialByToken(projectId, token)
101
+ const projectDatabase = await services.database.forProject(projectId)
102
+ const roleBindings = await projectDatabase.serviceAccountRoleBinding.findMany({
103
+ where: { serviceAccountId: apiKey.serviceAccountId },
104
+ include: { role: { select: { rules: true } } },
105
+ })
106
+
107
+ return {
108
+ realm: "project",
109
+ projectId,
110
+ subject: {
111
+ type: "service-account",
112
+ serviceAccountId: apiKey.serviceAccountId,
113
+ apiKeyId: apiKey.id,
114
+ },
115
+ permissions: resolveProjectPermissions(
116
+ roleBindings.flatMap(binding =>
117
+ binding.role.rules.map(rule => ({
118
+ permissions: rule.permissions,
119
+ restrictions: rule.restrictions ?? [],
120
+ })),
121
+ ),
122
+ apiKey.restrictionRules,
123
+ ),
124
+ }
125
+ }
126
+
127
+ function getBearerToken(context: HandlerContext): string {
128
+ return parseBearerToken(context.requestHeader.get("authorization"))
129
+ }
130
+
131
+ function resolveBackendPermissions(
132
+ grants: readonly {
133
+ permissions: readonly BackendPermission[]
134
+ restrictions: readonly BackendPermissionRestriction[]
135
+ }[],
136
+ restrictions: BackendApiKey["restrictionRules"],
137
+ ): ResolvedPermissions<BackendPermission, BackendPermissionRestriction> {
138
+ return resolvePermissions(grants, restrictions)
139
+ }
140
+
141
+ function resolveProjectPermissions(
142
+ grants: readonly {
143
+ permissions: readonly ProjectPermission[]
144
+ restrictions: readonly ProjectPermissionRestriction[]
145
+ }[],
146
+ restrictions: ApiKey["restrictionRules"],
147
+ ): ResolvedPermissions<ProjectPermission, ProjectPermissionRestriction> {
148
+ return resolvePermissions(grants, restrictions)
149
+ }
150
+
151
+ function resolvePermissions<TPermission extends string, TRestriction>(
152
+ grants: readonly {
153
+ permissions: readonly TPermission[]
154
+ restrictions: readonly ResolvedPermissionRestriction<TRestriction>[]
155
+ }[],
156
+ restrictions: readonly {
157
+ permissions: readonly TPermission[]
158
+ restrictions?: readonly ResolvedPermissionRestriction<TRestriction>[]
159
+ }[],
160
+ ): ResolvedPermissions<TPermission, TRestriction> {
161
+ const restrictionByPermission = new Map(
162
+ restrictions.flatMap(rule =>
163
+ rule.permissions.map(permission => [permission, rule.restrictions ?? []] as const),
164
+ ),
165
+ )
166
+ const permissions = new Map<TPermission, ResolvedPermissionGrant<TRestriction>[]>()
167
+ for (const grant of grants) {
168
+ for (const permission of grant.permissions) {
169
+ const keyRestrictions = restrictionByPermission.get(permission)
170
+ if (restrictions.length > 0 && !keyRestrictions) {
171
+ continue
172
+ }
173
+
174
+ const permissionGrants = permissions.get(permission) ?? []
175
+ permissionGrants.push({ restrictions: [...grant.restrictions, ...(keyRestrictions ?? [])] })
176
+ permissions.set(permission, permissionGrants)
177
+ }
178
+ }
19
179
 
20
- return [projectId, apiKey]
180
+ return permissions
21
181
  }
@@ -0,0 +1,36 @@
1
+ import { Code } from "@connectrpc/connect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { parseBearerToken } from "./authorization-header"
4
+
5
+ describe("parseBearerToken", () => {
6
+ it("accepts a case-insensitive Bearer scheme without normalizing the token", () => {
7
+ expect(parseBearerToken("Bearer hcp_key_secret")).toBe("hcp_key_secret")
8
+ expect(parseBearerToken("BEARER hcb_KEY_secret")).toBe("hcb_KEY_secret")
9
+ })
10
+
11
+ it.each([
12
+ null,
13
+ "",
14
+ "hcp_key_secret",
15
+ "Basic hcp_key_secret",
16
+ "Bearer",
17
+ "Bearer ",
18
+ "Bearer token",
19
+ "Bearer token ",
20
+ " Bearer token",
21
+ "Bearer token value",
22
+ "Bearer\ttoken",
23
+ "Bearer\ntoken",
24
+ "Bearer token\n",
25
+ "Bearer token\u0000",
26
+ "Bearer token\u00a0value",
27
+ `Bearer ${"a".repeat(10_000)} value`,
28
+ ])("rejects malformed authorization header %s", authorization => {
29
+ expect(() => parseBearerToken(authorization)).toThrowError(
30
+ expect.objectContaining({
31
+ code: Code.Unauthenticated,
32
+ rawMessage: "Invalid authorization header",
33
+ }),
34
+ )
35
+ })
36
+ })
@@ -0,0 +1,24 @@
1
+ import { Code } from "@connectrpc/connect"
2
+ import { createApiError } from "./api-error"
3
+
4
+ /**
5
+ * Extracts a token from an HTTP Bearer authorization value.
6
+ *
7
+ * @param authorization The Authorization header value.
8
+ * @returns The unmodified Bearer token.
9
+ */
10
+ export function parseBearerToken(authorization: string | null): string {
11
+ const match = /^Bearer ([^\s]+)$/i.exec(authorization ?? "")
12
+ if (
13
+ !match ||
14
+ [...match[1]!].some(character => character.charCodeAt(0) <= 0x1f || character === "\x7f")
15
+ ) {
16
+ throw createApiError({
17
+ message: "Invalid authorization header",
18
+ code: Code.Unauthenticated,
19
+ reason: "AUTHORIZATION_HEADER_INVALID",
20
+ })
21
+ }
22
+
23
+ return match[1]!
24
+ }
@@ -0,0 +1,122 @@
1
+ import { create } from "@bufbuild/protobuf"
2
+ import { timestampDate } from "@bufbuild/protobuf/wkt"
3
+ import { ComponentKind, InstanceSchema } from "@highstate/api/v1"
4
+ import { describe, expect, it } from "vitest"
5
+ import {
6
+ fromInstance,
7
+ toInstance,
8
+ toInstancePatch,
9
+ toNullableTimestamp,
10
+ toProjectModel,
11
+ toTimestamp,
12
+ } from "./conversion"
13
+
14
+ describe("resource conversion", () => {
15
+ it("converts instance dynamic values and references in both directions", () => {
16
+ const backend = {
17
+ id: "example.component.v1:main" as const,
18
+ kind: "unit" as const,
19
+ type: "example.component.v1" as const,
20
+ name: "main" as const,
21
+ args: { enabled: true, nested: { value: 1 } },
22
+ inputs: {
23
+ input: [{ instanceId: "example.source.v1:source" as const, output: "value" }],
24
+ },
25
+ position: { x: 10, y: 20 },
26
+ }
27
+
28
+ const api = toInstance(backend)
29
+
30
+ expect(api.$typeName).toBe("io.highstate.v1.Instance")
31
+ expect(api.kind).toBe(ComponentKind.UNIT)
32
+ expect(fromInstance(api)).toEqual({
33
+ ...backend,
34
+ hubInputs: {},
35
+ injectionInputs: [],
36
+ })
37
+ })
38
+
39
+ it("rejects an unspecified component kind", () => {
40
+ const instance = create(InstanceSchema, {
41
+ id: "example.component.v1:main",
42
+ type: "example.component.v1",
43
+ name: "main",
44
+ })
45
+
46
+ expect(() => fromInstance(instance)).toThrow("Component kind must be specified")
47
+ })
48
+
49
+ it("maps whole and nested position masks to backend patches", () => {
50
+ const instance = create(InstanceSchema, {
51
+ id: "example.component.v1:main",
52
+ kind: ComponentKind.UNIT,
53
+ type: "example.component.v1",
54
+ name: "main",
55
+ position: { x: 10, y: 20 },
56
+ })
57
+
58
+ expect(toInstancePatch(instance, ["position.x"])).toEqual({ position: { x: 10 } })
59
+ expect(toInstancePatch(instance, ["position"])).toEqual({ position: { x: 10, y: 20 } })
60
+ expect(toInstancePatch(create(InstanceSchema, instance), ["position"])).toEqual({
61
+ position: { x: 10, y: 20 },
62
+ })
63
+ })
64
+
65
+ it("clears a whole optional position", () => {
66
+ const instance = create(InstanceSchema, {
67
+ id: "example.component.v1:main",
68
+ kind: ComponentKind.UNIT,
69
+ type: "example.component.v1",
70
+ name: "main",
71
+ })
72
+
73
+ expect(toInstancePatch(instance, ["position"])).toEqual({ position: null })
74
+ })
75
+
76
+ it("converts valid and nullable dates", () => {
77
+ const date = new Date("2026-08-24T12:34:56.789Z")
78
+
79
+ expect(timestampDate(toTimestamp(date))).toEqual(date)
80
+ expect(toNullableTimestamp(null)).toBeUndefined()
81
+ })
82
+
83
+ it("rejects invalid dates", () => {
84
+ expect(() => toTimestamp(new Date(Number.NaN))).toThrow(
85
+ "Cannot convert invalid date to timestamp",
86
+ )
87
+ })
88
+
89
+ it("flattens requested virtual and ghost instances into the API project model", () => {
90
+ const instance = {
91
+ id: "example.component.v1:resident" as const,
92
+ kind: "unit" as const,
93
+ type: "example.component.v1" as const,
94
+ name: "resident" as const,
95
+ }
96
+ const model = toProjectModel({
97
+ instances: [instance],
98
+ virtualInstances: [
99
+ {
100
+ ...instance,
101
+ id: "example.component.v1:virtual",
102
+ name: "virtual",
103
+ parentId: instance.id,
104
+ },
105
+ ],
106
+ ghostInstances: [
107
+ {
108
+ ...instance,
109
+ id: "example.component.v1:ghost",
110
+ name: "ghost",
111
+ },
112
+ ],
113
+ hubs: [],
114
+ })
115
+
116
+ expect(model.instances.map(value => value.id)).toEqual([
117
+ "example.component.v1:resident",
118
+ "example.component.v1:virtual",
119
+ "example.component.v1:ghost",
120
+ ])
121
+ })
122
+ })