@sealant/api-contracts-next 0.39.0-next.708 → 0.39.0-next.710

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.
@@ -6,6 +6,7 @@ import { InferenceGroup } from "./inference.js";
6
6
  import { PackagesGroup } from "./packages.js";
7
7
  import { ProfilesGroup } from "./profiles.js";
8
8
  import { RegistriesGroup } from "./registries.js";
9
+ import { RequestRefusal } from "./request-refusal.js";
9
10
  import { RunsGroup } from "./runs.js";
10
11
  import { SessionsGroup } from "./sessions.js";
11
12
  import { SshKeysGroup } from "./ssh-keys.js";
@@ -26,6 +27,8 @@ export const ControlPlaneAPI = HttpApi.make("sealantControlPlaneApi")
26
27
  .add(RegistriesGroup.prefix("/v1/registries"))
27
28
  .add(GitHubGroup.prefix("/v1/github"))
28
29
  .add(UsersGroup.prefix("/v1/users"))
30
+ // Last, so it reaches every endpoint above (`HttpApi.middleware` skips endpoints added after it).
31
+ .middleware(RequestRefusal)
29
32
  .annotate(OpenApi.Title, "Sealant Control Plane API")
30
33
  .annotate(OpenApi.Version, "0.0.0")
31
34
  .annotate(OpenApi.Description, "Sealant control-plane HTTP API.");
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Requests the contract cannot decode, on every route of the control plane API.
3
+ *
4
+ * Effect answers such a request with an empty `400`, and the failure it reports (to the request
5
+ * log, every `ErrorReporter` and the request's tracing span) quotes the rejected input: `Expected
6
+ * array, got "<argv>"`, a whole body that arrived wrapped in an array, `JSON.parse`'s excerpt of a
7
+ * malformed body. A request body can carry a process's arguments, a script or a credential, so
8
+ * `RequestRefusal`, applied to the whole API, answers `400` `RequestRefusedError` instead, with a
9
+ * reason that names where the request is wrong and never what it held. `describeRequestIssue` words
10
+ * that reason from the decode issue alone: field names the contract declares, array positions, and
11
+ * what was expected; a key the caller chose (an `env` or `metadata` name) reads `[…]`.
12
+ */
13
+ import { Schema, SchemaIssue } from "effect";
14
+ import { HttpApiMiddleware } from "effect/unstable/httpapi";
15
+ declare const RequestRefusedError_base: Schema.Class<RequestRefusedError, Schema.TaggedStruct<"RequestRefusedError", {
16
+ /** Where the request is wrong and what was expected; never a value it held. */
17
+ readonly message: Schema.String;
18
+ }>, import("effect/Cause").YieldableError>;
19
+ export declare class RequestRefusedError extends RequestRefusedError_base {
20
+ }
21
+ declare const RequestRefusal_base: HttpApiMiddleware.ServiceClass<RequestRefusal, "@sealant/api-contracts/RequestRefusal", {
22
+ requires: never;
23
+ provides: never;
24
+ error: typeof RequestRefusedError;
25
+ clientError: never;
26
+ requiredForClient: false;
27
+ security: never;
28
+ }, HttpApiMiddleware.HttpApiMiddleware<never, typeof RequestRefusedError, never>>;
29
+ /** Applied to every endpoint of `ControlPlaneAPI`: see the module comment. */
30
+ export declare class RequestRefusal extends RequestRefusal_base {
31
+ }
32
+ /** The reason for a body that is not JSON at all (`JSON.parse` failed). */
33
+ export declare const REQUEST_BODY_NOT_JSON = "the request body is not valid JSON";
34
+ /**
35
+ * Why a request part (`body`, `query`, `path parameters`, `headers`) cannot be decoded, from its
36
+ * decode issue: up to three places, each with what was expected. Never a value the request held.
37
+ */
38
+ export declare const describeRequestIssue: (part: string, issue: SchemaIssue.Issue) => string;
39
+ export {};
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Requests the contract cannot decode, on every route of the control plane API.
3
+ *
4
+ * Effect answers such a request with an empty `400`, and the failure it reports (to the request
5
+ * log, every `ErrorReporter` and the request's tracing span) quotes the rejected input: `Expected
6
+ * array, got "<argv>"`, a whole body that arrived wrapped in an array, `JSON.parse`'s excerpt of a
7
+ * malformed body. A request body can carry a process's arguments, a script or a credential, so
8
+ * `RequestRefusal`, applied to the whole API, answers `400` `RequestRefusedError` instead, with a
9
+ * reason that names where the request is wrong and never what it held. `describeRequestIssue` words
10
+ * that reason from the decode issue alone: field names the contract declares, array positions, and
11
+ * what was expected; a key the caller chose (an `env` or `metadata` name) reads `[…]`.
12
+ */
13
+ import { Option, Schema, SchemaAST, SchemaIssue } from "effect";
14
+ import { HttpApiMiddleware } from "effect/unstable/httpapi";
15
+ export class RequestRefusedError extends Schema.TaggedErrorClass()("RequestRefusedError", {
16
+ /** Where the request is wrong and what was expected; never a value it held. */
17
+ message: Schema.String,
18
+ }, { httpApiStatus: 400 }) {
19
+ }
20
+ /** Applied to every endpoint of `ControlPlaneAPI`: see the module comment. */
21
+ export class RequestRefusal extends HttpApiMiddleware.Service()("@sealant/api-contracts/RequestRefusal", { error: RequestRefusedError }) {
22
+ }
23
+ /** The reason for a body that is not JSON at all (`JSON.parse` failed). */
24
+ export const REQUEST_BODY_NOT_JSON = "the request body is not valid JSON";
25
+ const MAX_REASONS = 3;
26
+ const expectedOf = (ast) => {
27
+ if (SchemaAST.isString(ast))
28
+ return "a string";
29
+ if (SchemaAST.isNumber(ast))
30
+ return "a number";
31
+ if (SchemaAST.isBoolean(ast))
32
+ return "a boolean";
33
+ if (SchemaAST.isArrays(ast))
34
+ return "an array";
35
+ if (SchemaAST.isObjects(ast))
36
+ return "an object";
37
+ if (SchemaAST.isNull(ast))
38
+ return "null";
39
+ if (SchemaAST.isUndefined(ast))
40
+ return undefined;
41
+ if (SchemaAST.isUnion(ast)) {
42
+ // The JSON codec widens some types into unions (a number also takes "NaN" and "Infinity"):
43
+ // what the members expect, the fixed values said only when nothing else is.
44
+ const kinds = [...new Set(ast.types.map(expectedOf))].filter((kind) => kind !== undefined);
45
+ const open = kinds.filter((kind) => kind !== "one of the allowed values");
46
+ return (open.length > 0 ? open : kinds).join(" or ") || undefined;
47
+ }
48
+ return "one of the allowed values";
49
+ };
50
+ const stringAnnotation = (annotations, key) => {
51
+ if (typeof annotations !== "object" || annotations === null || !(key in annotations)) {
52
+ return undefined;
53
+ }
54
+ const value = Reflect.get(annotations, key);
55
+ return typeof value === "string" ? value : undefined;
56
+ };
57
+ /**
58
+ * A schema author's own words for a failed check, unless they quote the rejected value (a guard
59
+ * against a future check that interpolates its input; no check in the contract does today).
60
+ */
61
+ const authoredMessage = (message, actual) => {
62
+ if (message === undefined)
63
+ return undefined;
64
+ if (typeof actual === "string" && actual.length >= 3 && message.includes(actual)) {
65
+ return undefined;
66
+ }
67
+ return message;
68
+ };
69
+ /** A path segment: a declared field by name, a position by index, anything else as `[…]`. */
70
+ const segment = (key, parent) => {
71
+ if (typeof key === "number")
72
+ return `[${key}]`;
73
+ const declared = parent !== undefined &&
74
+ SchemaAST.isObjects(parent) &&
75
+ parent.propertySignatures.some((signature) => signature.name === key);
76
+ return declared && typeof key === "string" ? `.${key}` : "[…]";
77
+ };
78
+ const leaf = (path, message) => [{ path, message }];
79
+ const reasonsOf = (issue, path, parent) => {
80
+ if (issue instanceof SchemaIssue.Composite) {
81
+ return issue.issues.flatMap((inner) => reasonsOf(inner, path, issue.ast));
82
+ }
83
+ if (issue instanceof SchemaIssue.Pointer) {
84
+ const at = issue.path.reduce((acc, key) => acc + segment(key, parent), path);
85
+ return reasonsOf(issue.issue, at, undefined);
86
+ }
87
+ if (issue instanceof SchemaIssue.Encoding) {
88
+ return reasonsOf(issue.issue, path, parent);
89
+ }
90
+ if (issue instanceof SchemaIssue.AnyOf) {
91
+ return issue.issues.length === 0
92
+ ? leaf(path, `must be ${expectedOf(issue.ast) ?? "one of the allowed values"}`)
93
+ : issue.issues.flatMap((inner) => reasonsOf(inner, path, parent));
94
+ }
95
+ if (issue instanceof SchemaIssue.Filter) {
96
+ const inner = issue.issue instanceof SchemaIssue.InvalidValue
97
+ ? stringAnnotation(issue.issue.annotations, "message")
98
+ : undefined;
99
+ const message = authoredMessage(inner ?? stringAnnotation(issue.filter.annotations, "message"), issue.actual);
100
+ if (message !== undefined)
101
+ return leaf(path, message);
102
+ const expected = stringAnnotation(issue.filter.annotations, "expected");
103
+ if (expected !== undefined)
104
+ return leaf(path, `must be ${expected}`);
105
+ return issue.issue instanceof SchemaIssue.InvalidValue
106
+ ? leaf(path, "is invalid")
107
+ : reasonsOf(issue.issue, path, parent);
108
+ }
109
+ if (issue instanceof SchemaIssue.InvalidType) {
110
+ return leaf(path, `must be ${expectedOf(issue.ast) ?? "present"}`);
111
+ }
112
+ if (issue instanceof SchemaIssue.MissingKey)
113
+ return leaf(path, "is required");
114
+ if (issue instanceof SchemaIssue.InvalidValue) {
115
+ const actual = Option.getOrUndefined(issue.actual);
116
+ return leaf(path, authoredMessage(stringAnnotation(issue.annotations, "message"), actual) ?? "is invalid");
117
+ }
118
+ if (issue instanceof SchemaIssue.OneOf)
119
+ return leaf(path, "matches more than one allowed shape");
120
+ return leaf(path, "is not allowed");
121
+ };
122
+ /**
123
+ * Why a request part (`body`, `query`, `path parameters`, `headers`) cannot be decoded, from its
124
+ * decode issue: up to three places, each with what was expected. Never a value the request held.
125
+ */
126
+ export const describeRequestIssue = (part, issue) => {
127
+ const reasons = reasonsOf(issue, "", undefined);
128
+ const shown = reasons.slice(0, MAX_REASONS).map(({ path, message }) => {
129
+ const where = path === "" ? `the request ${part}` : path.replace(/^\./, "");
130
+ // A check that names its own position (`argv[1] contains a NUL byte`) needs no prefix.
131
+ return message.startsWith(where) ? message : `${where} ${message}`;
132
+ });
133
+ const more = reasons.length - shown.length;
134
+ return more > 0 ? `${shown.join("; ")}; and ${more} more` : shown.join("; ");
135
+ };
@@ -51,10 +51,41 @@ export type WorkspaceProcessUser = typeof workspaceProcessUserSchema.Type;
51
51
  * group other than `mend`), or no such user exists yet. Nothing is started.
52
52
  */
53
53
  export declare const PROCESS_USER_UNSUPPORTED_CODE = "user-unsupported";
54
+ /** The most words a session's argv may hold, the program included. */
55
+ export declare const SESSION_ARGV_MAX_WORDS = 64;
56
+ /**
57
+ * The most UTF-8 bytes one word of a session's argv may hold, its terminating NUL not counted:
58
+ * Linux's `MAX_ARG_STRLEN` (32 pages, 128 KiB on 4 KiB pages) counts the NUL, so 131,071 bytes of
59
+ * text is the longest word `execve` takes there; one byte more fails with `E2BIG` whatever the total.
60
+ */
61
+ export declare const SESSION_ARGV_MAX_WORD_BYTES: number;
62
+ /**
63
+ * The most UTF-8 bytes a session's argv may hold in all: half of Linux's usual 2 MiB `ARG_MAX`,
64
+ * which argv shares with the environment, and far below sealantd's 8 MiB control frame.
65
+ */
66
+ export declare const SESSION_ARGV_MAX_TOTAL_BYTES: number;
67
+ /**
68
+ * Why `argv` cannot start a session, or `undefined` when it can. `argv[0]`, the program, is
69
+ * non-empty with no leading or trailing whitespace. Every later word is opaque to Sealant and may be
70
+ * any string, empty, whitespace-led or multi-line (`bash -lc "\n echo hi"`, `git commit -m ""`),
71
+ * except one with a NUL byte, which no process argument can carry, or a lone UTF-16 surrogate, which
72
+ * has no UTF-8 form and would not reach the process as sent. The reason names a word by its position
73
+ * and size, never its text, which can carry a secret: it reaches the caller in the `400` and the
74
+ * server's request log.
75
+ */
76
+ export declare const sessionArgvIssue: (argv: readonly string[]) => string | undefined;
77
+ /**
78
+ * A session's argv: the program, then its arguments, as `sessionArgvIssue` allows. One check over
79
+ * the whole array, because Effect's own length and whitespace checks quote the offending value.
80
+ */
81
+ export declare const sessionArgvSchema: Schema.$Array<Schema.String>;
54
82
  export declare const createSessionRequestSchema: Schema.Struct<{
55
83
  readonly workspaceId: Schema.String;
56
84
  readonly ownerUserId: Schema.String;
57
- /** argv[0] is the program the session runs; the rest its arguments. */
85
+ /**
86
+ * argv[0] is the program the session runs; the rest its arguments, passed to it as an argv array,
87
+ * never through a shell. See `sessionArgvIssue` for what is refused.
88
+ */
58
89
  readonly argv: Schema.$Array<Schema.String>;
59
90
  /** Working directory inside the workspace (defaults to the workspace working directory). */
60
91
  readonly cwd: Schema.optional<Schema.String>;
@@ -83,7 +114,10 @@ export type CreateSessionRequest = typeof createSessionRequestSchema.Type;
83
114
  export declare const createSessionAsUserRequestSchema: Schema.Struct<{
84
115
  readonly workspaceId: Schema.String;
85
116
  readonly ownerUserId: Schema.String;
86
- /** argv[0] is the program the session runs; the rest its arguments. */
117
+ /**
118
+ * argv[0] is the program the session runs; the rest its arguments, passed to it as an argv array,
119
+ * never through a shell. See `sessionArgvIssue` for what is refused.
120
+ */
87
121
  readonly argv: Schema.$Array<Schema.String>;
88
122
  /** Working directory inside the workspace (defaults to the workspace working directory). */
89
123
  readonly cwd: Schema.optional<Schema.String>;
@@ -309,7 +343,10 @@ export declare const SessionsGroup: HttpApiGroup.HttpApiGroup<"sessions", HttpAp
309
343
  }>>, HttpApiEndpoint.Json<typeof SessionBadRequestError | typeof SessionUnauthorizedError | typeof SessionForbiddenError | typeof SessionNotFoundError | typeof SessionBadGatewayError | typeof SessionInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"createSession", "POST", "/", HttpApiEndpoint.StringTree<never>, HttpApiEndpoint.StringTree<never>, HttpApiEndpoint.Json<Schema.Struct<{
310
344
  readonly workspaceId: Schema.String;
311
345
  readonly ownerUserId: Schema.String;
312
- /** argv[0] is the program the session runs; the rest its arguments. */
346
+ /**
347
+ * argv[0] is the program the session runs; the rest its arguments, passed to it as an argv array,
348
+ * never through a shell. See `sessionArgvIssue` for what is refused.
349
+ */
313
350
  readonly argv: Schema.$Array<Schema.String>;
314
351
  /** Working directory inside the workspace (defaults to the workspace working directory). */
315
352
  readonly cwd: Schema.optional<Schema.String>;
@@ -366,7 +403,10 @@ export declare const SessionsGroup: HttpApiGroup.HttpApiGroup<"sessions", HttpAp
366
403
  }>>, HttpApiEndpoint.Json<typeof SessionBadRequestError | typeof SessionUnauthorizedError | typeof SessionForbiddenError | typeof SessionNotFoundError | typeof SessionConflictError | typeof SessionBadGatewayError | typeof SessionInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"createSessionAsUser", "POST", "/as-user", HttpApiEndpoint.StringTree<never>, HttpApiEndpoint.StringTree<never>, HttpApiEndpoint.Json<Schema.Struct<{
367
404
  readonly workspaceId: Schema.String;
368
405
  readonly ownerUserId: Schema.String;
369
- /** argv[0] is the program the session runs; the rest its arguments. */
406
+ /**
407
+ * argv[0] is the program the session runs; the rest its arguments, passed to it as an argv array,
408
+ * never through a shell. See `sessionArgvIssue` for what is refused.
409
+ */
370
410
  readonly argv: Schema.$Array<Schema.String>;
371
411
  /** Working directory inside the workspace (defaults to the workspace working directory). */
372
412
  readonly cwd: Schema.optional<Schema.String>;
@@ -48,11 +48,77 @@ export const workspaceProcessUserSchema = Schema.String.check(Schema.isPattern(/
48
48
  * group other than `mend`), or no such user exists yet. Nothing is started.
49
49
  */
50
50
  export const PROCESS_USER_UNSUPPORTED_CODE = "user-unsupported";
51
+ /** The most words a session's argv may hold, the program included. */
52
+ export const SESSION_ARGV_MAX_WORDS = 64;
53
+ /**
54
+ * The most UTF-8 bytes one word of a session's argv may hold, its terminating NUL not counted:
55
+ * Linux's `MAX_ARG_STRLEN` (32 pages, 128 KiB on 4 KiB pages) counts the NUL, so 131,071 bytes of
56
+ * text is the longest word `execve` takes there; one byte more fails with `E2BIG` whatever the total.
57
+ */
58
+ export const SESSION_ARGV_MAX_WORD_BYTES = 128 * 1024 - 1;
59
+ /**
60
+ * The most UTF-8 bytes a session's argv may hold in all: half of Linux's usual 2 MiB `ARG_MAX`,
61
+ * which argv shares with the environment, and far below sealantd's 8 MiB control frame.
62
+ */
63
+ export const SESSION_ARGV_MAX_TOTAL_BYTES = 1024 * 1024;
64
+ const utf8Bytes = (value) => new TextEncoder().encode(value).length;
65
+ /**
66
+ * A UTF-16 surrogate with no partner (with the `u` flag a pair is one code point and never matches):
67
+ * it has no UTF-8 form, so it would reach the process as U+FFFD.
68
+ */
69
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
70
+ /**
71
+ * Why `argv` cannot start a session, or `undefined` when it can. `argv[0]`, the program, is
72
+ * non-empty with no leading or trailing whitespace. Every later word is opaque to Sealant and may be
73
+ * any string, empty, whitespace-led or multi-line (`bash -lc "\n echo hi"`, `git commit -m ""`),
74
+ * except one with a NUL byte, which no process argument can carry, or a lone UTF-16 surrogate, which
75
+ * has no UTF-8 form and would not reach the process as sent. The reason names a word by its position
76
+ * and size, never its text, which can carry a secret: it reaches the caller in the `400` and the
77
+ * server's request log.
78
+ */
79
+ export const sessionArgvIssue = (argv) => {
80
+ const program = argv[0];
81
+ if (program === undefined) {
82
+ return "argv is empty: argv[0] must name the program";
83
+ }
84
+ if (argv.length > SESSION_ARGV_MAX_WORDS) {
85
+ return `argv has ${argv.length} words; the maximum is ${SESSION_ARGV_MAX_WORDS}`;
86
+ }
87
+ if (program.length === 0 || program.trim() !== program) {
88
+ return "argv[0], the program, must be non-empty with no leading or trailing whitespace";
89
+ }
90
+ let totalBytes = 0;
91
+ for (const [index, word] of argv.entries()) {
92
+ if (word.includes("\u0000")) {
93
+ return `argv[${index}] contains a NUL byte, which no process argument can carry`;
94
+ }
95
+ if (LONE_SURROGATE.test(word)) {
96
+ return `argv[${index}] is not well-formed Unicode (a lone surrogate)`;
97
+ }
98
+ const bytes = utf8Bytes(word);
99
+ if (bytes > SESSION_ARGV_MAX_WORD_BYTES) {
100
+ return `argv[${index}] is ${bytes} bytes; the maximum per word is ${SESSION_ARGV_MAX_WORD_BYTES}`;
101
+ }
102
+ totalBytes += bytes;
103
+ }
104
+ if (totalBytes > SESSION_ARGV_MAX_TOTAL_BYTES) {
105
+ return `argv totals ${totalBytes} bytes; the maximum is ${SESSION_ARGV_MAX_TOTAL_BYTES}`;
106
+ }
107
+ return undefined;
108
+ };
109
+ /**
110
+ * A session's argv: the program, then its arguments, as `sessionArgvIssue` allows. One check over
111
+ * the whole array, because Effect's own length and whitespace checks quote the offending value.
112
+ */
113
+ export const sessionArgvSchema = Schema.Array(Schema.String).check(Schema.makeFilter(sessionArgvIssue));
51
114
  export const createSessionRequestSchema = Schema.Struct({
52
115
  workspaceId: NonEmptyString,
53
116
  ownerUserId: NonEmptyString,
54
- /** argv[0] is the program the session runs; the rest its arguments. */
55
- argv: Schema.Array(NonEmptyString).check(Schema.isNonEmpty(), Schema.isMaxLength(64)),
117
+ /**
118
+ * argv[0] is the program the session runs; the rest its arguments, passed to it as an argv array,
119
+ * never through a shell. See `sessionArgvIssue` for what is refused.
120
+ */
121
+ argv: sessionArgvSchema,
56
122
  /** Working directory inside the workspace (defaults to the workspace working directory). */
57
123
  cwd: Schema.optional(NonEmptyString),
58
124
  /** Extra environment for the PTY process (values are NOT secrets — use credentials for those). */
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./core-api/budgets.js";
9
9
  export * from "./core-api/oci-names.js";
10
10
  export * from "./core-api/registries.js";
11
11
  export * from "./core-api/runs.js";
12
+ export * from "./core-api/request-refusal.js";
12
13
  export * from "./core-api/sessions.js";
13
14
  export * from "./core-api/access-tokens.js";
14
15
  export * from "./core-api/workspaces.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./core-api/budgets.js";
9
9
  export * from "./core-api/oci-names.js";
10
10
  export * from "./core-api/registries.js";
11
11
  export * from "./core-api/runs.js";
12
+ export * from "./core-api/request-refusal.js";
12
13
  export * from "./core-api/sessions.js";
13
14
  export * from "./core-api/access-tokens.js";
14
15
  export * from "./core-api/workspaces.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/api-contracts-next",
3
- "version": "0.39.0-next.708",
3
+ "version": "0.39.0-next.710",
4
4
  "description": "Contract-first HTTP API definitions for Sealant services",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "peerDependencies": {
33
33
  "effect": ">=4.0.0-beta.85 <5"
34
34
  },
35
- "gitHead": "e8ad7f7a3f1b96333e67c980e93c49a764a3f245",
35
+ "gitHead": "5e7c33bdaf78dd6aaf0b4b64332e803f3ce8d396",
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  }