@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.
@@ -1,27 +1,140 @@
1
+ import type { GenMessage } from "@bufbuild/protobuf/codegenv2"
1
2
  import type { Services } from "@highstate/backend"
2
- import { AccessError } from "@highstate/backend/shared"
3
+ import { create, type Message } from "@bufbuild/protobuf"
4
+ import { durationFromMs } from "@bufbuild/protobuf/wkt"
5
+ import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"
6
+ import {
7
+ BadRequestSchema,
8
+ ErrorInfoSchema,
9
+ PreconditionFailureSchema,
10
+ RequestInfoSchema,
11
+ RetryInfoSchema,
12
+ } from "@highstate/api/v1"
13
+ import { BackendError, BackendErrorCategory } from "@highstate/backend/shared"
3
14
  import { isAbortError } from "abort-controller-x"
4
- import { type CallContext, ServerError, type ServerMiddlewareCall, Status } from "nice-grpc-common"
5
15
 
6
- export function createErrorHandlingMiddleware(services: Services) {
7
- return async function* errorHandlingMiddleware<TRequest, TResponse>(
8
- call: ServerMiddlewareCall<TRequest, TResponse>,
9
- context: CallContext,
10
- ) {
16
+ const sensitiveMetadataKey = /credential|token|secret|encrypted|cause|stack|path/i
17
+
18
+ type OutgoingDetail = { desc: GenMessage<Message>; value: Message }
19
+
20
+ /**
21
+ * Creates the interceptor that translates backend failures into API errors.
22
+ *
23
+ * @param services The backend services used for error logging.
24
+ * @returns An interceptor that translates backend failures into API errors.
25
+ */
26
+ export function createErrorHandlingInterceptor(services: Services): Interceptor {
27
+ return next => async request => {
11
28
  try {
12
- return yield* call.next(call.request, context)
29
+ return await next(request)
13
30
  } catch (error) {
14
- if (error instanceof ServerError || isAbortError(error)) {
31
+ if (isAbortError(error) || (error instanceof ConnectError && error.code === Code.Canceled)) {
15
32
  throw error
16
33
  }
17
34
 
18
- if (error instanceof AccessError) {
19
- services.logger.info({ error }, "access denied")
20
- throw new ServerError(Status.UNAUTHENTICATED, "Access denied")
35
+ if (error instanceof BackendError) {
36
+ const code = categoryToCode(error.category)
37
+ if (code === Code.Internal) {
38
+ services.logger.error({ error, method: request.method.name }, "unexpected backend error")
39
+ throw new ConnectError("An unexpected error occurred", Code.Internal)
40
+ }
41
+
42
+ throw new ConnectError(error.message, code, undefined, backendErrorDetails(error, request))
43
+ }
44
+
45
+ if (error instanceof ConnectError) {
46
+ throw error
21
47
  }
22
48
 
23
- services.logger.error({ error }, "unexpected error")
24
- throw new ServerError(Status.INTERNAL, "An unexpected error occurred")
49
+ services.logger.error({ error, method: request.method.name }, "unexpected error")
50
+ throw new ConnectError("An unexpected error occurred", Code.Internal)
25
51
  }
26
52
  }
27
53
  }
54
+
55
+ function backendErrorDetails(
56
+ error: BackendError,
57
+ request: Parameters<Parameters<Interceptor>[0]>[0],
58
+ ): OutgoingDetail[] {
59
+ const details: OutgoingDetail[] = [
60
+ {
61
+ desc: ErrorInfoSchema,
62
+ value: create(ErrorInfoSchema, {
63
+ reason: error.reason,
64
+ domain: "highstate.io",
65
+ metadata: Object.fromEntries(
66
+ Object.entries(error.metadata).filter(([key]) => !sensitiveMetadataKey.test(key)),
67
+ ),
68
+ }),
69
+ },
70
+ ]
71
+
72
+ if (error.fieldViolations.length > 0) {
73
+ details.push({
74
+ desc: BadRequestSchema,
75
+ value: create(BadRequestSchema, {
76
+ fieldViolations: error.fieldViolations.map(violation => ({
77
+ field: toProtobufPath(violation.field),
78
+ reason: violation.reason,
79
+ description: violation.description,
80
+ })),
81
+ }),
82
+ })
83
+ }
84
+
85
+ if (error.preconditionViolations.length > 0) {
86
+ details.push({
87
+ desc: PreconditionFailureSchema,
88
+ value: create(PreconditionFailureSchema, {
89
+ violations: error.preconditionViolations.map(violation => ({ ...violation })),
90
+ }),
91
+ })
92
+ }
93
+
94
+ if (error.retry && Number.isFinite(error.retry.delayMs) && error.retry.delayMs >= 0) {
95
+ details.push({
96
+ desc: RetryInfoSchema,
97
+ value: create(RetryInfoSchema, { retryDelay: durationFromMs(error.retry.delayMs) }),
98
+ })
99
+ }
100
+
101
+ const requestId = request.header.get("x-request-id")?.trim()
102
+ if (requestId) {
103
+ details.push({
104
+ desc: RequestInfoSchema,
105
+ value: create(RequestInfoSchema, { requestId }),
106
+ })
107
+ }
108
+
109
+ return details
110
+ }
111
+
112
+ function toProtobufPath(path: string): string {
113
+ return path
114
+ .split(".")
115
+ .map(segment => segment.replace(/[A-Z]/g, character => `_${character.toLowerCase()}`))
116
+ .join(".")
117
+ }
118
+
119
+ function categoryToCode(category: BackendErrorCategory): Code {
120
+ switch (category) {
121
+ case BackendErrorCategory.InvalidArgument:
122
+ return Code.InvalidArgument
123
+ case BackendErrorCategory.Unauthenticated:
124
+ return Code.Unauthenticated
125
+ case BackendErrorCategory.PermissionDenied:
126
+ return Code.PermissionDenied
127
+ case BackendErrorCategory.NotFound:
128
+ return Code.NotFound
129
+ case BackendErrorCategory.AlreadyExists:
130
+ return Code.AlreadyExists
131
+ case BackendErrorCategory.FailedPrecondition:
132
+ return Code.FailedPrecondition
133
+ case BackendErrorCategory.Aborted:
134
+ return Code.Aborted
135
+ case BackendErrorCategory.Unavailable:
136
+ return Code.Unavailable
137
+ case BackendErrorCategory.Internal:
138
+ return Code.Internal
139
+ }
140
+ }
@@ -0,0 +1,37 @@
1
+ import { create } from "@bufbuild/protobuf"
2
+ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"
3
+ import { ConnectError } from "@connectrpc/connect"
4
+ import { BadRequestSchema, InstanceSchema } from "@highstate/api/v1"
5
+ import { describe, expect, it } from "vitest"
6
+ import { validateUpdateMask } from "./field-mask"
7
+
8
+ const mutablePaths = new Set(["arguments", "position", "position.x", "position.y"])
9
+
10
+ describe("validateUpdateMask", () => {
11
+ it("normalizes JSON and TypeScript field names to Protobuf paths", () => {
12
+ const paths = validateUpdateMask(
13
+ create(FieldMaskSchema, { paths: ["position.x", "arguments"] }),
14
+ InstanceSchema,
15
+ mutablePaths,
16
+ )
17
+
18
+ expect(paths).toEqual(["position.x", "arguments"])
19
+ })
20
+
21
+ it.each([
22
+ { paths: [] },
23
+ { paths: ["*"] },
24
+ { paths: ["id"] },
25
+ { paths: ["inputs.values"] },
26
+ { paths: ["arguments.value"] },
27
+ ])("rejects invalid paths %j", ({ paths }) => {
28
+ try {
29
+ validateUpdateMask(create(FieldMaskSchema, { paths }), InstanceSchema, mutablePaths)
30
+ expect.fail("Expected update mask validation to fail")
31
+ } catch (error) {
32
+ expect(error).toBeInstanceOf(ConnectError)
33
+ const connectError = error as ConnectError
34
+ expect(connectError.findDetails(BadRequestSchema)).toHaveLength(1)
35
+ }
36
+ })
37
+ })
@@ -0,0 +1,81 @@
1
+ import type { Message } from "@bufbuild/protobuf"
2
+ import type { GenMessage } from "@bufbuild/protobuf/codegenv2"
3
+ import type { FieldMask } from "@bufbuild/protobuf/wkt"
4
+ import { Code, type ConnectError } from "@connectrpc/connect"
5
+ import { createApiError } from "./api-error"
6
+
7
+ /**
8
+ * Validates and normalizes a request update mask.
9
+ *
10
+ * @param mask The update mask to validate.
11
+ * @param schema The protobuf schema used to normalize field names.
12
+ * @param mutablePaths The set of fields that may be updated.
13
+ * @returns The normalized mutable field paths.
14
+ */
15
+ export function validateUpdateMask<T extends Message>(
16
+ mask: FieldMask | undefined,
17
+ schema: GenMessage<T>,
18
+ mutablePaths: ReadonlySet<string>,
19
+ ): string[] {
20
+ if (!mask || mask.paths.length === 0) {
21
+ throw fieldMaskError("update_mask", "REQUIRED", "The update mask must not be empty")
22
+ }
23
+
24
+ const paths = new Set<string>()
25
+ for (const path of mask.paths) {
26
+ const normalized = normalizePath(schema, path)
27
+ if (!mutablePaths.has(normalized)) {
28
+ throw fieldMaskError(
29
+ `update_mask.paths`,
30
+ "IMMUTABLE_OR_UNKNOWN",
31
+ `The path "${path}" is unknown, immutable, output-only, or traverses a collection`,
32
+ )
33
+ }
34
+ paths.add(normalized)
35
+ }
36
+
37
+ return [...paths]
38
+ }
39
+
40
+ function normalizePath<T extends Message>(schema: GenMessage<T>, path: string): string {
41
+ if (!path || path === "*") return path
42
+
43
+ let descriptor = schema
44
+ const normalized: string[] = []
45
+ const segments = path.split(".")
46
+
47
+ for (const [index, segment] of segments.entries()) {
48
+ const field = descriptor.fields.find(
49
+ candidate =>
50
+ candidate.name === segment ||
51
+ candidate.localName === segment ||
52
+ candidate.jsonName === segment,
53
+ )
54
+ if (!field) return path
55
+
56
+ normalized.push(field.name)
57
+ if (index === segments.length - 1) break
58
+ if (field.fieldKind !== "message" || !field.message) return path
59
+
60
+ descriptor = field.message as GenMessage<T>
61
+ }
62
+
63
+ return normalized.join(".")
64
+ }
65
+
66
+ /**
67
+ * Creates an API error describing an invalid update-mask field.
68
+ *
69
+ * @param field The field containing the violation.
70
+ * @param reason The reason the field is invalid.
71
+ * @param description The human-readable explanation of the violation.
72
+ * @returns The API error describing the invalid field.
73
+ */
74
+ export function fieldMaskError(field: string, reason: string, description: string): ConnectError {
75
+ return createApiError({
76
+ message: "Invalid update mask",
77
+ code: Code.InvalidArgument,
78
+ reason: "UPDATE_MASK_INVALID",
79
+ fieldViolations: [{ field, reason, description }],
80
+ })
81
+ }
@@ -1,3 +1,8 @@
1
+ export * from "./api-error"
1
2
  export * from "./authentication"
3
+ export * from "./authorization-header"
4
+ export * from "./conversion"
2
5
  export * from "./error-handling"
6
+ export * from "./field-mask"
7
+ export * from "./serialization"
3
8
  export * from "./validation"
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { toJsonObject } from "./serialization"
3
+
4
+ describe("toJsonObject", () => {
5
+ it("normalizes dates and removes undefined values", () => {
6
+ expect(
7
+ toJsonObject({
8
+ createdAt: new Date("2026-08-22T12:00:00.000Z"),
9
+ omitted: undefined,
10
+ nested: [{ value: 1 }],
11
+ }),
12
+ ).toEqual({
13
+ createdAt: "2026-08-22T12:00:00.000Z",
14
+ nested: [{ value: 1 }],
15
+ })
16
+ })
17
+ })
@@ -0,0 +1,35 @@
1
+ import type { JsonObject } from "@bufbuild/protobuf"
2
+
3
+ /**
4
+ * Converts a structured value into a protobuf JSON object.
5
+ *
6
+ * @param value The structured value to convert.
7
+ * @returns The corresponding protobuf JSON object.
8
+ */
9
+ export function toJsonObject(value: object): JsonObject {
10
+ return normalizeJsonValue(value) as JsonObject
11
+ }
12
+
13
+ function normalizeJsonValue(value: unknown): unknown {
14
+ if (value instanceof Date) {
15
+ return value.toISOString()
16
+ }
17
+
18
+ if (Array.isArray(value)) {
19
+ return value.map(normalizeJsonValue)
20
+ }
21
+
22
+ if (value && typeof value === "object") {
23
+ return Object.fromEntries(
24
+ Object.entries(value).flatMap(([key, entry]) => {
25
+ if (entry === undefined) {
26
+ return []
27
+ }
28
+
29
+ return [[key, normalizeJsonValue(entry)]]
30
+ }),
31
+ )
32
+ }
33
+
34
+ return value
35
+ }
@@ -1,6 +1,15 @@
1
1
  import type { z } from "zod"
2
- import { ServerError, Status } from "nice-grpc-common"
2
+ import { Code } from "@connectrpc/connect"
3
+ import { createApiError } from "./api-error"
3
4
 
5
+ /**
6
+ * Parses and validates a named request argument.
7
+ *
8
+ * @param request The request containing the argument.
9
+ * @param argumentName The name of the argument to validate.
10
+ * @param schema The schema used to validate the argument.
11
+ * @returns The validated argument value.
12
+ */
4
13
  export function parseArgument<
5
14
  TRequest,
6
15
  TArgumentName extends string & keyof TRequest,
@@ -8,11 +17,42 @@ export function parseArgument<
8
17
  >(request: TRequest, argumentName: TArgumentName, schema: TSchema): z.infer<TSchema> {
9
18
  const result = schema.safeParse(request[argumentName])
10
19
  if (!result.success) {
11
- throw new ServerError(
12
- Status.INVALID_ARGUMENT,
13
- `Invalid argument "${argumentName}": ${result.error.message}`,
14
- )
20
+ throw validationError(`Invalid argument "${argumentName}"`, argumentName, result.error)
15
21
  }
16
22
 
17
23
  return result.data
18
24
  }
25
+
26
+ /**
27
+ * Parses and validates an arbitrary value.
28
+ *
29
+ * @param value The value to validate.
30
+ * @param name The name used in validation errors.
31
+ * @param schema The schema used to validate the value.
32
+ * @returns The validated value.
33
+ */
34
+ export function parseValue<TSchema extends z.ZodType>(
35
+ value: unknown,
36
+ name: string,
37
+ schema: TSchema,
38
+ ): z.infer<TSchema> {
39
+ const result = schema.safeParse(value)
40
+ if (!result.success) {
41
+ throw validationError(`Invalid ${name}`, name, result.error)
42
+ }
43
+
44
+ return result.data
45
+ }
46
+
47
+ function validationError(message: string, field: string, error: z.ZodError) {
48
+ return createApiError({
49
+ message,
50
+ code: Code.InvalidArgument,
51
+ reason: "REQUEST_INVALID",
52
+ fieldViolations: error.issues.map(issue => ({
53
+ field: [field, ...issue.path.map(String)].join("."),
54
+ reason: issue.code.toUpperCase(),
55
+ description: issue.message,
56
+ })),
57
+ })
58
+ }
@@ -1,44 +0,0 @@
1
- import type { InstanceServiceImplementation } from "@highstate/api/instance.v1"
2
- import type { Services } from "@highstate/backend"
3
- import { instanceCustomStatusInputSchema } from "@highstate/backend/shared"
4
- import { z } from "@highstate/contract"
5
- import { authenticate, parseArgument } from "../shared"
6
-
7
- export function createInstanceService(services: Services): InstanceServiceImplementation {
8
- return {
9
- async updateCustomStatus(request, context) {
10
- const [projectId, apiKey] = await authenticate(services, context)
11
-
12
- // TODO: validate instance access
13
-
14
- const stateId = parseArgument(request, "stateId", z.cuid2())
15
- const customStatus = parseArgument(request, "status", instanceCustomStatusInputSchema)
16
-
17
- await services.instanceStateService.updateCustomStatus(
18
- projectId,
19
- stateId,
20
- apiKey.serviceAccountId,
21
- customStatus,
22
- )
23
-
24
- return {}
25
- },
26
-
27
- async removeCustomStatus(request, context) {
28
- const [projectId, apiKey] = await authenticate(services, context)
29
-
30
- // TODO: validate instance access
31
-
32
- const stateId = parseArgument(request, "stateId", z.cuid2())
33
-
34
- await services.instanceStateService.removeCustomStatus(
35
- projectId,
36
- stateId,
37
- apiKey.serviceAccountId,
38
- request.statusName,
39
- )
40
-
41
- return {}
42
- },
43
- }
44
- }