@sealant/api-contracts-next 0.40.0-next.768 → 0.40.0-next.770

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.
@@ -7,6 +7,7 @@ import { PackagesGroup } from "./packages.js";
7
7
  import { ProfilesGroup } from "./profiles.js";
8
8
  import { RegistriesGroup } from "./registries.js";
9
9
  import { RequestRefusal } from "./request-refusal.js";
10
+ import { ResourceDomainsGroup } from "./resource-domains.js";
10
11
  import { RunsGroup } from "./runs.js";
11
12
  import { SessionsGroup } from "./sessions.js";
12
13
  import { SshKeysGroup } from "./ssh-keys.js";
@@ -27,6 +28,7 @@ export const ControlPlaneAPI = HttpApi.make("sealantControlPlaneApi")
27
28
  .add(RegistriesGroup.prefix("/v1/registries"))
28
29
  .add(GitHubGroup.prefix("/v1/github"))
29
30
  .add(UsersGroup.prefix("/v1/users"))
31
+ .add(ResourceDomainsGroup.prefix("/v1/resource-domains"))
30
32
  // Last, so it reaches every endpoint above (`HttpApi.middleware` skips endpoints added after it).
31
33
  .middleware(RequestRefusal)
32
34
  .annotate(OpenApi.Title, "Sealant Control Plane API")
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api";
3
+ import { PersonId } from "./person-id.js";
3
4
  // These literal sets mirror the control-plane DB enums (packages/db `control-plane.ts`). They are
4
5
  // INLINED here so the public contract package carries NO @sealant/db dependency — which keeps
5
6
  // consumers like @sealant/sdk free of drizzle/pg. Keep them in sync with the db schema.
@@ -17,17 +18,17 @@ export const messageResponseSchema = Schema.Struct({
17
18
  message: Schema.String,
18
19
  });
19
20
  export const githubInstallationsQuerySchema = Schema.Struct({
20
- userId: NonEmptyString,
21
+ userId: PersonId,
21
22
  });
22
23
  export const githubInstallationRepositoriesQuerySchema = Schema.Struct({
23
- userId: NonEmptyString,
24
+ userId: PersonId,
24
25
  search: Schema.optional(NonEmptyString),
25
26
  });
26
27
  export const syncGitHubInstallationQuerySchema = Schema.Struct({
27
- userId: NonEmptyString,
28
+ userId: PersonId,
28
29
  });
29
30
  export const importGitHubInstallationRequestSchema = Schema.Struct({
30
- userId: NonEmptyString,
31
+ userId: PersonId,
31
32
  externalInstallationId: NonEmptyString,
32
33
  });
33
34
  export const githubWebhookHeadersSchema = Schema.Struct({
@@ -22,6 +22,7 @@
22
22
  import { Schema } from "effect";
23
23
  import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api";
24
24
  import { BudgetExceededError } from "./budgets.js";
25
+ import { PersonId } from "./person-id.js";
25
26
  const NonEmptyString = Schema.String.check(Schema.isNonEmpty(), Schema.isTrimmed());
26
27
  /**
27
28
  * Connected-account selection — the same reference shape as workspace creation: values are account
@@ -55,7 +56,7 @@ export const inferenceToolResultSchema = Schema.Struct({
55
56
  * authenticated when it started; credential fields must be absent.
56
57
  */
57
58
  export const inferenceRespondRequestSchema = Schema.Struct({
58
- ownerUserId: NonEmptyString,
59
+ ownerUserId: PersonId,
59
60
  credentials: Schema.optional(inferenceCredentialsSchema),
60
61
  prompt: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
61
62
  system: Schema.optional(Schema.String),
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The ids that name a person, and the one rule that keeps a resource domain out of them.
3
+ *
4
+ * A resource domain (`resource-domains.ts`) is an owner that is not a person: it never holds a
5
+ * login or a provider credential and never runs a harness. Core mints every resource domain id
6
+ * with {@link RESOURCE_DOMAIN_ID_PREFIX}, and no `user` row may carry it (a database check), so
7
+ * the spelling alone tells the two apart. Every field and header that names a person (`ownerUserId`,
8
+ * `onBehalfOfUserId`, `userId`, the SSH gateway's `principalId`) is a {@link PersonId}: a domain id there is refused with a `400`
9
+ * `RequestRefusedError` while the request is decoded, before any handler runs. A test walks every
10
+ * endpoint of the control plane API and fails on a person field that is not one.
11
+ */
12
+ import { Schema } from "effect";
13
+ export declare const RESOURCE_DOMAIN_ID_PREFIX = "rdm:";
14
+ export declare const isResourceDomainId: (id: string) => boolean;
15
+ /** Why an id cannot name a person, after the field's name. Never quotes the id. */
16
+ export declare const NOT_A_PERSON = "names a resource domain, which never holds a login or a provider credential and never runs a harness";
17
+ export declare const PersonId: Schema.String;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The ids that name a person, and the one rule that keeps a resource domain out of them.
3
+ *
4
+ * A resource domain (`resource-domains.ts`) is an owner that is not a person: it never holds a
5
+ * login or a provider credential and never runs a harness. Core mints every resource domain id
6
+ * with {@link RESOURCE_DOMAIN_ID_PREFIX}, and no `user` row may carry it (a database check), so
7
+ * the spelling alone tells the two apart. Every field and header that names a person (`ownerUserId`,
8
+ * `onBehalfOfUserId`, `userId`, the SSH gateway's `principalId`) is a {@link PersonId}: a domain id there is refused with a `400`
9
+ * `RequestRefusedError` while the request is decoded, before any handler runs. A test walks every
10
+ * endpoint of the control plane API and fails on a person field that is not one.
11
+ */
12
+ import { Schema } from "effect";
13
+ export const RESOURCE_DOMAIN_ID_PREFIX = "rdm:";
14
+ export const isResourceDomainId = (id) => id.startsWith(RESOURCE_DOMAIN_ID_PREFIX);
15
+ /** Why an id cannot name a person, after the field's name. Never quotes the id. */
16
+ export const NOT_A_PERSON = "names a resource domain, which never holds a login or a provider credential and never runs a harness";
17
+ export const PersonId = Schema.String.check(Schema.isNonEmpty(), Schema.isTrimmed(), Schema.makeFilter((id) => (isResourceDomainId(id) ? NOT_A_PERSON : undefined)));
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "effect";
2
2
  import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api";
3
3
  import { connectedAccountProviderSchema, connectedAccountSummarySchema, } from "./connected-accounts.js";
4
+ import { PersonId } from "./person-id.js";
4
5
  const NonEmptyString = Schema.String.check(Schema.isNonEmpty(), Schema.isTrimmed());
5
6
  /*
6
7
  Minimal profiles surface: list a user's profiles and manage the per-provider connected-account
@@ -11,7 +12,7 @@ Bindings embed account summaries only — never secret material.
11
12
  export const profileStatusSchema = Schema.Literals(["active", "archived"]);
12
13
  export const profileSummarySchema = Schema.Struct({
13
14
  profileId: NonEmptyString,
14
- ownerUserId: NonEmptyString,
15
+ ownerUserId: PersonId,
15
16
  slug: NonEmptyString,
16
17
  name: NonEmptyString,
17
18
  description: Schema.NullOr(Schema.String),
@@ -21,7 +22,7 @@ export const profileSummarySchema = Schema.Struct({
21
22
  updatedAt: Schema.String,
22
23
  });
23
24
  export const listProfilesQuerySchema = Schema.Struct({
24
- ownerUserId: NonEmptyString,
25
+ ownerUserId: PersonId,
25
26
  });
26
27
  export const listProfilesResponseSchema = Schema.Struct({
27
28
  items: Schema.Array(profileSummarySchema),
@@ -36,7 +37,7 @@ export const listProfileCredentialBindingsResponseSchema = Schema.Struct({
36
37
  items: Schema.Array(profileCredentialBindingWithAccountSchema),
37
38
  });
38
39
  export const setProfileCredentialBindingRequestSchema = Schema.Struct({
39
- ownerUserId: NonEmptyString,
40
+ ownerUserId: PersonId,
40
41
  provider: connectedAccountProviderSchema,
41
42
  /**
42
43
  * The account to bind: its id, or `default` for the owner's chosen default for `provider` (an
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Resource domains: an owner that is a project, not a person.
3
+ *
4
+ * An application (Mend) keeps one domain per project for the things the project owns rather than
5
+ * any one person: its services, volumes and network. A domain outlives every person, and it never
6
+ * holds a login or a provider credential and never runs a harness. Its id (`rdm:<uuid>`) can never
7
+ * name a person: every person field of this API is a `PersonId`, which refuses it.
8
+ *
9
+ * `ensureResourceDomain` is idempotent on `key`, `<application>:<name>` (Mend:
10
+ * `mend:<installation>:project:<project>`): the first call creates the domain and every later or
11
+ * concurrent call answers the same one. Keys are global, and every service key may name any of
12
+ * them; the application prefix keeps applications apart by convention, not by authority.
13
+ *
14
+ * Only a service key reaches these routes. Any other bearer is refused by the transport with `401`
15
+ * before a handler runs. A deployment served without authentication (the development exception)
16
+ * serves them as it serves every other route. A request body is decoded strictly: a field the contract does not declare (an
17
+ * owner, a credential) is refused with `400` instead of being dropped.
18
+ *
19
+ * Members are the people the application lets reach the domain. A grant or a revoke carries
20
+ * `version`, the application's own number for its decision about this person, rising with each
21
+ * decision it makes (its record's revision, say). Core writes the decision only over a lower
22
+ * version. The same version with the same state is a repeat, answered with the member unchanged.
23
+ * The same version with the other state, or a lower one, is stale: `409`, carrying the decision
24
+ * that stands. So a delayed grant never undoes a newer revoke, whatever order decisions arrive in
25
+ * and however often each is retried, and the application never reads before it writes. A revoke
26
+ * keeps the member's row, with its version. One member reads at `GET …/members/:userId`, and the
27
+ * list pages by user id: `limit` (100 unless named, at most 500) and the previous page's
28
+ * `nextCursor` as `cursor`.
29
+ */
30
+ import { Schema } from "effect";
31
+ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api";
32
+ export declare const resourceDomainSchema: Schema.Struct<{
33
+ readonly resourceDomainId: Schema.String;
34
+ readonly key: Schema.String;
35
+ readonly createdAt: Schema.String;
36
+ }>;
37
+ export type ResourceDomainWire = typeof resourceDomainSchema.Type;
38
+ export declare const ensureResourceDomainRequestSchema: Schema.Struct<{
39
+ readonly key: Schema.String;
40
+ }>;
41
+ export type EnsureResourceDomainRequest = typeof ensureResourceDomainRequestSchema.Type;
42
+ export declare const ensureResourceDomainResponseSchema: Schema.Struct<{
43
+ readonly resourceDomainId: Schema.String;
44
+ readonly key: Schema.String;
45
+ readonly createdAt: Schema.String;
46
+ /** True when this call created the domain. */
47
+ readonly created: Schema.Boolean;
48
+ }>;
49
+ export type EnsureResourceDomainResponse = typeof ensureResourceDomainResponseSchema.Type;
50
+ export declare const resourceDomainMemberSchema: Schema.Struct<{
51
+ readonly userId: Schema.String;
52
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
53
+ readonly version: Schema.Int;
54
+ readonly updatedAt: Schema.String;
55
+ }>;
56
+ export type ResourceDomainMemberWire = typeof resourceDomainMemberSchema.Type;
57
+ export declare const resourceDomainMemberDecisionSchema: Schema.Struct<{
58
+ /** The application's own version of this decision, rising with each decision it makes. */
59
+ readonly version: Schema.Int;
60
+ }>;
61
+ export type ResourceDomainMemberDecisionRequest = typeof resourceDomainMemberDecisionSchema.Type;
62
+ /** At most this many members a page, and this many when the request names no `limit`. */
63
+ export declare const RESOURCE_DOMAIN_MEMBERS_PAGE: {
64
+ readonly default: 100;
65
+ readonly max: 500;
66
+ };
67
+ export declare const listResourceDomainMembersQuerySchema: Schema.Struct<{
68
+ readonly limit: Schema.optional<Schema.FiniteFromString>;
69
+ /** A previous page's `nextCursor`: the page starts after it. */
70
+ readonly cursor: Schema.optional<Schema.String>;
71
+ }>;
72
+ export type ListResourceDomainMembersQuery = typeof listResourceDomainMembersQuerySchema.Type;
73
+ export declare const listResourceDomainMembersResponseSchema: Schema.Struct<{
74
+ readonly items: Schema.$Array<Schema.Struct<{
75
+ readonly userId: Schema.String;
76
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
77
+ readonly version: Schema.Int;
78
+ readonly updatedAt: Schema.String;
79
+ }>>;
80
+ /** Present when more members follow: pass it as `cursor` for the next page. */
81
+ readonly nextCursor: Schema.optional<Schema.String>;
82
+ }>;
83
+ export type ListResourceDomainMembersResponse = typeof listResourceDomainMembersResponseSchema.Type;
84
+ declare const ResourceDomainForbiddenError_base: Schema.Class<ResourceDomainForbiddenError, Schema.TaggedStruct<"ResourceDomainForbiddenError", {
85
+ readonly message: Schema.String;
86
+ }>, import("effect/Cause").YieldableError>;
87
+ export declare class ResourceDomainForbiddenError extends ResourceDomainForbiddenError_base {
88
+ }
89
+ declare const ResourceDomainNotFoundError_base: Schema.Class<ResourceDomainNotFoundError, Schema.TaggedStruct<"ResourceDomainNotFoundError", {
90
+ readonly message: Schema.String;
91
+ }>, import("effect/Cause").YieldableError>;
92
+ export declare class ResourceDomainNotFoundError extends ResourceDomainNotFoundError_base {
93
+ }
94
+ declare const ResourceDomainPersonNotFoundError_base: Schema.Class<ResourceDomainPersonNotFoundError, Schema.TaggedStruct<"ResourceDomainPersonNotFoundError", {
95
+ readonly message: Schema.String;
96
+ }>, import("effect/Cause").YieldableError>;
97
+ /** The person a member route names does not exist (the domain does). */
98
+ export declare class ResourceDomainPersonNotFoundError extends ResourceDomainPersonNotFoundError_base {
99
+ }
100
+ declare const ResourceDomainMemberNotFoundError_base: Schema.Class<ResourceDomainMemberNotFoundError, Schema.TaggedStruct<"ResourceDomainMemberNotFoundError", {
101
+ readonly message: Schema.String;
102
+ }>, import("effect/Cause").YieldableError>;
103
+ /** The person exists, and the application has made no decision about them in the domain. */
104
+ export declare class ResourceDomainMemberNotFoundError extends ResourceDomainMemberNotFoundError_base {
105
+ }
106
+ declare const ResourceDomainMemberVersionConflictError_base: Schema.Class<ResourceDomainMemberVersionConflictError, Schema.TaggedStruct<"ResourceDomainMemberVersionConflictError", {
107
+ readonly message: Schema.String;
108
+ readonly current: Schema.Struct<{
109
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
110
+ readonly version: Schema.Int;
111
+ }>;
112
+ }>, import("effect/Cause").YieldableError>;
113
+ /**
114
+ * A decision at least as new stands, so this one changed nothing. `current` is the decision that
115
+ * stands; the message quotes nothing the request sent.
116
+ */
117
+ export declare class ResourceDomainMemberVersionConflictError extends ResourceDomainMemberVersionConflictError_base {
118
+ }
119
+ declare const ResourceDomainInternalServerError_base: Schema.Class<ResourceDomainInternalServerError, Schema.TaggedStruct<"ResourceDomainInternalServerError", {
120
+ readonly message: Schema.String;
121
+ }>, import("effect/Cause").YieldableError>;
122
+ export declare class ResourceDomainInternalServerError extends ResourceDomainInternalServerError_base {
123
+ }
124
+ export declare const ResourceDomainsGroup: HttpApiGroup.HttpApiGroup<"resourceDomains", HttpApiEndpoint.HttpApiEndpoint<"ensureResourceDomain", "POST", "/", never, never, Schema.toCodecJson<Schema.Struct<{
125
+ readonly key: Schema.String;
126
+ }>>, never, Schema.toCodecJson<Schema.Struct<{
127
+ readonly resourceDomainId: Schema.String;
128
+ readonly key: Schema.String;
129
+ readonly createdAt: Schema.String;
130
+ /** True when this call created the domain. */
131
+ readonly created: Schema.Boolean;
132
+ }>>, Schema.toCodecJson<typeof ResourceDomainForbiddenError | typeof ResourceDomainInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"getResourceDomain", "GET", "/:resourceDomainId", Schema.toCodecStringTree<Schema.Struct<{
133
+ readonly resourceDomainId: Schema.String;
134
+ }>>, never, never, never, Schema.toCodecJson<Schema.Struct<{
135
+ readonly resourceDomainId: Schema.String;
136
+ readonly key: Schema.String;
137
+ readonly createdAt: Schema.String;
138
+ }>>, Schema.toCodecJson<typeof ResourceDomainForbiddenError | typeof ResourceDomainNotFoundError | typeof ResourceDomainInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"getResourceDomainMember", "GET", "/:resourceDomainId/members/:userId", Schema.toCodecStringTree<Schema.Struct<{
139
+ readonly resourceDomainId: Schema.String;
140
+ readonly userId: Schema.String;
141
+ }>>, never, never, never, Schema.toCodecJson<Schema.Struct<{
142
+ readonly userId: Schema.String;
143
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
144
+ readonly version: Schema.Int;
145
+ readonly updatedAt: Schema.String;
146
+ }>>, Schema.toCodecJson<typeof ResourceDomainForbiddenError | typeof ResourceDomainNotFoundError | typeof ResourceDomainPersonNotFoundError | typeof ResourceDomainMemberNotFoundError | typeof ResourceDomainInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"grantResourceDomainMember", "POST", "/:resourceDomainId/members/:userId/grant", Schema.toCodecStringTree<Schema.Struct<{
147
+ readonly resourceDomainId: Schema.String;
148
+ readonly userId: Schema.String;
149
+ }>>, never, Schema.toCodecJson<Schema.Struct<{
150
+ /** The application's own version of this decision, rising with each decision it makes. */
151
+ readonly version: Schema.Int;
152
+ }>>, never, Schema.toCodecJson<Schema.Struct<{
153
+ readonly userId: Schema.String;
154
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
155
+ readonly version: Schema.Int;
156
+ readonly updatedAt: Schema.String;
157
+ }>>, Schema.toCodecJson<typeof ResourceDomainForbiddenError | typeof ResourceDomainNotFoundError | typeof ResourceDomainPersonNotFoundError | typeof ResourceDomainMemberVersionConflictError | typeof ResourceDomainInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"listResourceDomainMembers", "GET", "/:resourceDomainId/members", Schema.toCodecStringTree<Schema.Struct<{
158
+ readonly resourceDomainId: Schema.String;
159
+ }>>, Schema.toCodecStringTree<Schema.Struct<{
160
+ readonly limit: Schema.optional<Schema.FiniteFromString>;
161
+ /** A previous page's `nextCursor`: the page starts after it. */
162
+ readonly cursor: Schema.optional<Schema.String>;
163
+ }>>, never, never, Schema.toCodecJson<Schema.Struct<{
164
+ readonly items: Schema.$Array<Schema.Struct<{
165
+ readonly userId: Schema.String;
166
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
167
+ readonly version: Schema.Int;
168
+ readonly updatedAt: Schema.String;
169
+ }>>;
170
+ /** Present when more members follow: pass it as `cursor` for the next page. */
171
+ readonly nextCursor: Schema.optional<Schema.String>;
172
+ }>>, Schema.toCodecJson<typeof ResourceDomainForbiddenError | typeof ResourceDomainNotFoundError | typeof ResourceDomainInternalServerError>, never, never> | HttpApiEndpoint.HttpApiEndpoint<"revokeResourceDomainMember", "POST", "/:resourceDomainId/members/:userId/revoke", Schema.toCodecStringTree<Schema.Struct<{
173
+ readonly resourceDomainId: Schema.String;
174
+ readonly userId: Schema.String;
175
+ }>>, never, Schema.toCodecJson<Schema.Struct<{
176
+ /** The application's own version of this decision, rising with each decision it makes. */
177
+ readonly version: Schema.Int;
178
+ }>>, never, Schema.toCodecJson<Schema.Struct<{
179
+ readonly userId: Schema.String;
180
+ readonly state: Schema.Literals<readonly ["member", "revoked"]>;
181
+ readonly version: Schema.Int;
182
+ readonly updatedAt: Schema.String;
183
+ }>>, Schema.toCodecJson<typeof ResourceDomainForbiddenError | typeof ResourceDomainNotFoundError | typeof ResourceDomainPersonNotFoundError | typeof ResourceDomainMemberVersionConflictError | typeof ResourceDomainInternalServerError>, never, never>, false>;
184
+ export {};
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Resource domains: an owner that is a project, not a person.
3
+ *
4
+ * An application (Mend) keeps one domain per project for the things the project owns rather than
5
+ * any one person: its services, volumes and network. A domain outlives every person, and it never
6
+ * holds a login or a provider credential and never runs a harness. Its id (`rdm:<uuid>`) can never
7
+ * name a person: every person field of this API is a `PersonId`, which refuses it.
8
+ *
9
+ * `ensureResourceDomain` is idempotent on `key`, `<application>:<name>` (Mend:
10
+ * `mend:<installation>:project:<project>`): the first call creates the domain and every later or
11
+ * concurrent call answers the same one. Keys are global, and every service key may name any of
12
+ * them; the application prefix keeps applications apart by convention, not by authority.
13
+ *
14
+ * Only a service key reaches these routes. Any other bearer is refused by the transport with `401`
15
+ * before a handler runs. A deployment served without authentication (the development exception)
16
+ * serves them as it serves every other route. A request body is decoded strictly: a field the contract does not declare (an
17
+ * owner, a credential) is refused with `400` instead of being dropped.
18
+ *
19
+ * Members are the people the application lets reach the domain. A grant or a revoke carries
20
+ * `version`, the application's own number for its decision about this person, rising with each
21
+ * decision it makes (its record's revision, say). Core writes the decision only over a lower
22
+ * version. The same version with the same state is a repeat, answered with the member unchanged.
23
+ * The same version with the other state, or a lower one, is stale: `409`, carrying the decision
24
+ * that stands. So a delayed grant never undoes a newer revoke, whatever order decisions arrive in
25
+ * and however often each is retried, and the application never reads before it writes. A revoke
26
+ * keeps the member's row, with its version. One member reads at `GET …/members/:userId`, and the
27
+ * list pages by user id: `limit` (100 unless named, at most 500) and the previous page's
28
+ * `nextCursor` as `cursor`.
29
+ */
30
+ import { Schema } from "effect";
31
+ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api";
32
+ import { PersonId, RESOURCE_DOMAIN_ID_PREFIX } from "./person-id.js";
33
+ const RESOURCE_DOMAIN_ID_PATTERN = new RegExp(`^${RESOURCE_DOMAIN_ID_PREFIX}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`);
34
+ const RESOURCE_DOMAIN_KEY_PATTERN = /^[a-z][a-z0-9-]{0,31}:[A-Za-z0-9._:/@+=-]{1,223}$/;
35
+ const ResourceDomainId = Schema.String.check(Schema.makeFilter((id) => RESOURCE_DOMAIN_ID_PATTERN.test(id) ? undefined : "must be a resource domain id (rdm:<uuid>)"));
36
+ const ResourceDomainKey = Schema.String.check(Schema.makeFilter((key) => RESOURCE_DOMAIN_KEY_PATTERN.test(key)
37
+ ? undefined
38
+ : "must be <application>:<name>: a lower-case application name (letters, digits, '-'), a colon, then at most 223 letters, digits, '.', '_', ':', '/', '@', '+', '=' or '-'"));
39
+ export const resourceDomainSchema = Schema.Struct({
40
+ resourceDomainId: ResourceDomainId,
41
+ key: ResourceDomainKey,
42
+ createdAt: Schema.String,
43
+ });
44
+ export const ensureResourceDomainRequestSchema = Schema.Struct({ key: ResourceDomainKey });
45
+ export const ensureResourceDomainResponseSchema = Schema.Struct({
46
+ ...resourceDomainSchema.fields,
47
+ /** True when this call created the domain. */
48
+ created: Schema.Boolean,
49
+ });
50
+ const MemberVersion = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }));
51
+ const MemberState = Schema.Literals(["member", "revoked"]);
52
+ export const resourceDomainMemberSchema = Schema.Struct({
53
+ userId: PersonId,
54
+ state: MemberState,
55
+ version: MemberVersion,
56
+ updatedAt: Schema.String,
57
+ });
58
+ export const resourceDomainMemberDecisionSchema = Schema.Struct({
59
+ /** The application's own version of this decision, rising with each decision it makes. */
60
+ version: MemberVersion,
61
+ });
62
+ /** At most this many members a page, and this many when the request names no `limit`. */
63
+ export const RESOURCE_DOMAIN_MEMBERS_PAGE = { default: 100, max: 500 };
64
+ export const listResourceDomainMembersQuerySchema = Schema.Struct({
65
+ limit: Schema.optional(Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: RESOURCE_DOMAIN_MEMBERS_PAGE.max }))),
66
+ /** A previous page's `nextCursor`: the page starts after it. */
67
+ cursor: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
68
+ });
69
+ export const listResourceDomainMembersResponseSchema = Schema.Struct({
70
+ items: Schema.Array(resourceDomainMemberSchema),
71
+ /** Present when more members follow: pass it as `cursor` for the next page. */
72
+ nextCursor: Schema.optional(Schema.String),
73
+ });
74
+ export class ResourceDomainForbiddenError extends Schema.TaggedError()("ResourceDomainForbiddenError", { message: Schema.String }, { httpApiStatus: 403 }) {
75
+ }
76
+ export class ResourceDomainNotFoundError extends Schema.TaggedError()("ResourceDomainNotFoundError", { message: Schema.String }, { httpApiStatus: 404 }) {
77
+ }
78
+ /** The person a member route names does not exist (the domain does). */
79
+ export class ResourceDomainPersonNotFoundError extends Schema.TaggedError()("ResourceDomainPersonNotFoundError", { message: Schema.String }, { httpApiStatus: 404 }) {
80
+ }
81
+ /** The person exists, and the application has made no decision about them in the domain. */
82
+ export class ResourceDomainMemberNotFoundError extends Schema.TaggedError()("ResourceDomainMemberNotFoundError", { message: Schema.String }, { httpApiStatus: 404 }) {
83
+ }
84
+ /**
85
+ * A decision at least as new stands, so this one changed nothing. `current` is the decision that
86
+ * stands; the message quotes nothing the request sent.
87
+ */
88
+ export class ResourceDomainMemberVersionConflictError extends Schema.TaggedError()("ResourceDomainMemberVersionConflictError", {
89
+ message: Schema.String,
90
+ current: Schema.Struct({ state: MemberState, version: MemberVersion }),
91
+ }, { httpApiStatus: 409 }) {
92
+ }
93
+ export class ResourceDomainInternalServerError extends Schema.TaggedError()("ResourceDomainInternalServerError", { message: Schema.String }, { httpApiStatus: 500 }) {
94
+ }
95
+ const memberParams = Schema.Struct({ resourceDomainId: ResourceDomainId, userId: PersonId });
96
+ const memberErrors = [
97
+ ResourceDomainForbiddenError,
98
+ ResourceDomainNotFoundError,
99
+ ResourceDomainPersonNotFoundError,
100
+ ResourceDomainMemberVersionConflictError,
101
+ ResourceDomainInternalServerError,
102
+ ];
103
+ export const ResourceDomainsGroup = HttpApiGroup.make("resourceDomains")
104
+ .add(HttpApiEndpoint.post("ensureResourceDomain", "/", {
105
+ payload: ensureResourceDomainRequestSchema,
106
+ success: ensureResourceDomainResponseSchema,
107
+ error: [ResourceDomainForbiddenError, ResourceDomainInternalServerError],
108
+ }))
109
+ .add(HttpApiEndpoint.get("getResourceDomain", "/:resourceDomainId", {
110
+ params: Schema.Struct({ resourceDomainId: ResourceDomainId }),
111
+ success: resourceDomainSchema,
112
+ error: [
113
+ ResourceDomainForbiddenError,
114
+ ResourceDomainNotFoundError,
115
+ ResourceDomainInternalServerError,
116
+ ],
117
+ }))
118
+ .add(HttpApiEndpoint.post("grantResourceDomainMember", "/:resourceDomainId/members/:userId/grant", {
119
+ params: memberParams,
120
+ payload: resourceDomainMemberDecisionSchema,
121
+ success: resourceDomainMemberSchema,
122
+ error: memberErrors,
123
+ }))
124
+ .add(HttpApiEndpoint.post("revokeResourceDomainMember", "/:resourceDomainId/members/:userId/revoke", {
125
+ params: memberParams,
126
+ payload: resourceDomainMemberDecisionSchema,
127
+ success: resourceDomainMemberSchema,
128
+ error: memberErrors,
129
+ }))
130
+ .add(HttpApiEndpoint.get("getResourceDomainMember", "/:resourceDomainId/members/:userId", {
131
+ params: memberParams,
132
+ success: resourceDomainMemberSchema,
133
+ error: [
134
+ ResourceDomainForbiddenError,
135
+ ResourceDomainNotFoundError,
136
+ ResourceDomainPersonNotFoundError,
137
+ ResourceDomainMemberNotFoundError,
138
+ ResourceDomainInternalServerError,
139
+ ],
140
+ }))
141
+ .add(HttpApiEndpoint.get("listResourceDomainMembers", "/:resourceDomainId/members", {
142
+ params: Schema.Struct({ resourceDomainId: ResourceDomainId }),
143
+ query: listResourceDomainMembersQuerySchema,
144
+ success: listResourceDomainMembersResponseSchema,
145
+ error: [
146
+ ResourceDomainForbiddenError,
147
+ ResourceDomainNotFoundError,
148
+ ResourceDomainInternalServerError,
149
+ ],
150
+ }))
151
+ .annotate(HttpApi.PayloadParseOptions, { onExcessProperty: "error" })
152
+ .annotate(OpenApi.Description, "Owners that are projects, not people: they never hold a login or a provider credential.");
@@ -12,6 +12,7 @@
12
12
  import { Schema } from "effect";
13
13
  import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api";
14
14
  import { BudgetExceededError } from "./budgets.js";
15
+ import { PersonId } from "./person-id.js";
15
16
  const NonEmptyString = Schema.String.check(Schema.isNonEmpty(), Schema.isTrimmed());
16
17
  export const runStatusSchema = Schema.Literals([
17
18
  "queued",
@@ -46,7 +47,7 @@ export const runSchema = Schema.Struct({
46
47
  runId: NonEmptyString,
47
48
  workspaceId: NonEmptyString,
48
49
  attemptId: Schema.optional(NonEmptyString),
49
- ownerUserId: NonEmptyString,
50
+ ownerUserId: PersonId,
50
51
  harnessId: NonEmptyString,
51
52
  mode: runModeSchema,
52
53
  status: runStatusSchema,
@@ -80,7 +81,7 @@ export const runFileChangeSchema = Schema.Struct({
80
81
  export const createRunRequestSchema = Schema.Struct({
81
82
  workspaceId: NonEmptyString,
82
83
  harnessId: NonEmptyString,
83
- ownerUserId: NonEmptyString,
84
+ ownerUserId: PersonId,
84
85
  mode: Schema.optional(runModeSchema),
85
86
  prompt: Schema.optional(Schema.String),
86
87
  attemptId: Schema.optional(NonEmptyString),
@@ -96,7 +97,7 @@ export const createRunRequestSchema = Schema.Struct({
96
97
  });
97
98
  export const updateRunRequestSchema = Schema.Struct({
98
99
  /** The run's owner. Required by the control plane: an update that names no owner finds no run. */
99
- ownerUserId: Schema.optional(NonEmptyString),
100
+ ownerUserId: Schema.optional(PersonId),
100
101
  status: Schema.optional(runStatusSchema),
101
102
  exitCode: Schema.optional(Schema.Number),
102
103
  errorMessage: Schema.optional(Schema.String),
@@ -109,7 +110,7 @@ export const updateRunRequestSchema = Schema.Struct({
109
110
  });
110
111
  export const listRunsQuerySchema = Schema.Struct({
111
112
  workspaceId: Schema.optional(NonEmptyString),
112
- ownerUserId: Schema.optional(NonEmptyString),
113
+ ownerUserId: Schema.optional(PersonId),
113
114
  status: Schema.optional(runStatusSchema),
114
115
  limit: Schema.optional(NonEmptyString),
115
116
  });
@@ -136,10 +137,10 @@ export const timelineEntrySchema = Schema.Struct({
136
137
  * names no owner finds no run.
137
138
  */
138
139
  export const runOwnerQuerySchema = Schema.Struct({
139
- ownerUserId: Schema.optional(NonEmptyString),
140
+ ownerUserId: Schema.optional(PersonId),
140
141
  });
141
142
  export const getRunTimelineQuerySchema = Schema.Struct({
142
- ownerUserId: Schema.optional(NonEmptyString),
143
+ ownerUserId: Schema.optional(PersonId),
143
144
  fromSequence: Schema.optional(NonEmptyString),
144
145
  toSequence: Schema.optional(NonEmptyString),
145
146
  limit: Schema.optional(NonEmptyString),
@@ -183,7 +184,7 @@ export const runEventSchema = Schema.Struct({
183
184
  // Execution record — scrollback (byte-exact terminal output)
184
185
  // ---------------------------------------------------------------------------------------------
185
186
  export const getRunScrollbackQuerySchema = Schema.Struct({
186
- ownerUserId: Schema.optional(NonEmptyString),
187
+ ownerUserId: Schema.optional(PersonId),
187
188
  processId: NonEmptyString,
188
189
  stream: ioStreamSchema,
189
190
  atSequence: Schema.optional(NonEmptyString),
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Schema } from "effect";
17
17
  import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api";
18
+ import { PersonId } from "./person-id.js";
18
19
  const NonEmptyString = Schema.String.check(Schema.isNonEmpty(), Schema.isTrimmed());
19
20
  export const sessionStatusSchema = Schema.Literals(["starting", "running", "exited", "failed"]);
20
21
  export const sessionAuthorizationHeadersSchema = Schema.Struct({
@@ -113,7 +114,7 @@ export const sessionArgvIssue = (argv) => {
113
114
  export const sessionArgvSchema = Schema.Array(Schema.String).check(Schema.makeFilter(sessionArgvIssue));
114
115
  export const createSessionRequestSchema = Schema.Struct({
115
116
  workspaceId: NonEmptyString,
116
- ownerUserId: NonEmptyString,
117
+ ownerUserId: PersonId,
117
118
  /**
118
119
  * argv[0] is the program the session runs; the rest its arguments, passed to it as an argv array,
119
120
  * never through a shell. See `sessionArgvIssue` for what is refused.
@@ -151,7 +152,7 @@ export const sessionSchema = Schema.Struct({
151
152
  workspaceId: NonEmptyString,
152
153
  /** The interactive run recording this session; its record is the durable evidence. */
153
154
  runId: NonEmptyString,
154
- ownerUserId: NonEmptyString,
155
+ ownerUserId: PersonId,
155
156
  status: sessionStatusSchema,
156
157
  /**
157
158
  * Only `argv[0]`, the program: Sealant never stores a session's arguments, because they can carry
@@ -181,7 +182,7 @@ export const sessionSchema = Schema.Struct({
181
182
  endedAt: Schema.optional(Schema.String),
182
183
  });
183
184
  export const listSessionsQuerySchema = Schema.Struct({
184
- ownerUserId: NonEmptyString,
185
+ ownerUserId: PersonId,
185
186
  workspaceId: Schema.optional(NonEmptyString),
186
187
  status: Schema.optional(sessionStatusSchema),
187
188
  limit: Schema.optional(NonEmptyString),
@@ -190,22 +191,22 @@ export const listSessionsResponseSchema = Schema.Struct({
190
191
  items: Schema.Array(sessionSchema),
191
192
  });
192
193
  export const sessionInputRequestSchema = Schema.Struct({
193
- ownerUserId: Schema.optional(NonEmptyString),
194
+ ownerUserId: Schema.optional(PersonId),
194
195
  /** Base64-encoded keystrokes (bytes, not text — binary-safe). */
195
196
  dataBase64: NonEmptyString,
196
197
  });
197
198
  export const sessionResizeRequestSchema = Schema.Struct({
198
- ownerUserId: Schema.optional(NonEmptyString),
199
+ ownerUserId: Schema.optional(PersonId),
199
200
  cols: Schema.Int.check(Schema.isGreaterThan(0)),
200
201
  rows: Schema.Int.check(Schema.isGreaterThan(0)),
201
202
  });
202
203
  export const sessionSignalRequestSchema = Schema.Struct({
203
- ownerUserId: Schema.optional(NonEmptyString),
204
+ ownerUserId: Schema.optional(PersonId),
204
205
  /** POSIX signal number (e.g. 2 = SIGINT, 15 = SIGTERM). */
205
206
  signal: Schema.Int.check(Schema.isGreaterThan(0)),
206
207
  });
207
208
  export const closeSessionRequestSchema = Schema.Struct({
208
- ownerUserId: Schema.optional(NonEmptyString),
209
+ ownerUserId: Schema.optional(PersonId),
209
210
  });
210
211
  /**
211
212
  * Byte-exact session output by sequence range. `from` is INCLUSIVE (omit = from the beginning);
@@ -213,7 +214,7 @@ export const closeSessionRequestSchema = Schema.Struct({
213
214
  * as-recorded: redacted upstream by the daemon, PTY output stream only.
214
215
  */
215
216
  export const getSessionOutputQuerySchema = Schema.Struct({
216
- ownerUserId: Schema.optional(NonEmptyString),
217
+ ownerUserId: Schema.optional(PersonId),
217
218
  from: Schema.optional(NonEmptyString),
218
219
  limit: Schema.optional(NonEmptyString),
219
220
  });
@@ -299,7 +300,7 @@ HttpApiEndpoint.post("createSessionAsUser", "/as-user", {
299
300
  HttpApiEndpoint.get("getSession", "/:sessionId", {
300
301
  params: sessionIdParams,
301
302
  headers: sessionAuthorizationHeadersSchema,
302
- query: Schema.Struct({ ownerUserId: Schema.optional(NonEmptyString) }),
303
+ query: Schema.Struct({ ownerUserId: Schema.optional(PersonId) }),
303
304
  success: sessionSchema,
304
305
  error: [
305
306
  SessionBadRequestError,