1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ports
4
5import (
6 "context"
7 "errors"
8 "io"
9 "time"
10
11 "bnema.dev/ermite/internal/domain"
12)
13
14// The interfaces below are the use cases as the inbound adapters (the API,
15// Git over HTTP, the web) and the composition root see them. The types of package
16// usecase implement them, each assertion being made in usecase; the mocks in
17// ports/mocks stand in for them in the tests of the adapters.
18
19// AuthService turns the secret of a request into a principal. It returns
20// domain.ErrUnauthenticated for a secret that is not valid. *usecase.Auth
21// implements it.
22type AuthService interface {
23 Authenticate(ctx context.Context, secret string) (domain.Principal, error)
24}
25
26// Revalidator confirms that a principal obtained earlier is still
27// authorized. *usecase.Auth implements it.
28type Revalidator interface {
29 Revalidate(ctx context.Context, p domain.Principal) error
30}
31
32// RepoLookup is the read side of a single repository. A repository the
33// principal may not read is domain.ErrNotFound. *usecase.Repos implements it, as
34// part of RepoService.
35type RepoLookup interface {
36 Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
37}
38
39// RepoService is the repository use cases the API serves. *usecase.Repos
40// implements it.
41type RepoService interface {
42 RepoLookup
43 ListPage(ctx context.Context, p domain.Principal, size int, token string) (RepoPage, error)
44 View(ctx context.Context, repo domain.Repo) (RepoView, error)
45 Create(ctx context.Context, p domain.Principal, params CreateRepoParams) (domain.Repo, error)
46 Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateRepoParams) (domain.Repo, error)
47 Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error
48}
49
50// TokenService is the access token use cases the API serves. *usecase.Tokens
51// implements it.
52type TokenService interface {
53 Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error)
54 ListPage(ctx context.Context, p domain.Principal, size int, token string) (TokenPage, error)
55 Revoke(ctx context.Context, p domain.Principal, id string) error
56}
57
58// GitHubAPIService is the subset of GitHub's REST API that ermite serves under
59// /-/github/api, for the actions that run on it (ADR 0025, CI-2). owner is the
60// owner of the URL: only ERMITE_OWNER names repositories, any other owner is
61// domain.ErrNotFound, as is a repository the principal may not read.
62// *usecase.CI implements it.
63type GitHubAPIService interface {
64 // GetRepo returns the JSON of GET /repos/{owner}/{repo}.
65 GetRepo(ctx context.Context, p domain.Principal, owner, repo string) ([]byte, error)
66 // HashAlgorithm returns the object format of the repository, "sha1".
67 HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error)
68 // Tarball resolves ref (a branch, a tag or a commit id; empty means the
69 // default branch) and writes the gzip-compressed tar of the commit to the
70 // writer that begin returns; the archive token of an action (see
71 // domain.RepoTokenPrincipal) gets the commit it was issued for and no other.
72 // begin is called once, with the file name of the
73 // archive, before the first byte and not at all when the ref does not
74 // resolve (domain.ErrNotFound).
75 Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error
76 // TarballName is Tarball for a HEAD request: the same access and the same
77 // resolution of ref, then the file name, without producing the archive.
78 TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error)
79 // ListPullFiles answers GET /repos/{owner}/{repo}/pulls/{number}/files: the
80 // JSON array of the files of page (1-based) of perPage files (1 to 100) of
81 // the merge request, and the Link header value ("" for a single page). A
82 // public repository is read by anyone; a private one by a principal that
83 // reads it. The job token of the repository needs pull-requests: read; one
84 // that reads the repository without it gets domain.ErrForbidden (GitHub: 403
85 // "Resource not accessible by integration").
86 ListPullFiles(ctx context.Context, p domain.Principal, owner, repo string, number int64, page, perPage int) (body []byte, link string, err error)
87}
88
89// BrowseService is the read side of a repository's content. *usecase.Browse
90// implements it.
91type BrowseService interface {
92 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)
93 ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
94 GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
99 // RepoStats counts the commits of the branch whose head is given, and
100 // names the latest tag.
101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
102}
103
104// EventService streams the events of the forge. *usecase.Events implements it.
105type EventService interface {
106 Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error
107}
108
109// MergeRequestService is the merge request use cases. *usecase.MergeRequests
110// implements it.
111type MergeRequestService interface {
112 // Compare queries repository history; it stays here because this service owns the merge-git port.
113 Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error)
114 // PushSummary queries repository history; it stays here because this service owns the merge-git port.
115 PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error)
116 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
117 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
118 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
119 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)
120 EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error)
121 DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error)
122 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)
123 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
124 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)
125 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
126 // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
127 MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128 // EnableAutoMerge sets an open merge request to merge itself once its
129 // checks pass; enabling again replaces the mode and the head.
130 EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params EnableAutoMergeParams) (domain.MergeRequest, error)
131 // CancelAutoMerge turns auto-merge off on an open merge request.
132 CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
133 // CountOpen is the number of open merge requests of a repository the
134 // principal can read. It reads no Git: the tab of every page shows it.
135 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
136 // Mergeability is the mergeability of an open merge request, "checking"
137 // until the server has a result for the current tips; zero for a request
138 // that is not open. It reads no diff: the live pages call it on each change.
139 Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)
140}
141
142// IssueService is the issue use cases. *usecase.Issues implements it.
143type IssueService interface {
144 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error)
145 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error)
146 // Get returns the issue with its comments, oldest first.
147 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error)
148 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)
149 Resolve(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
150 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
151 Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
152}
153
154// IssueModerationService is the owner's moderation of the pending issues.
155// Every call needs owner rights and answers domain.ErrNotFound otherwise.
156// *usecase.IssueModeration implements it.
157type IssueModerationService interface {
158 // ListPending lists the pending issues of a repository, or of every
159 // repository when repo is zero, oldest first.
160 ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
161 // Approve opens the pending issue under a new number.
162 Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)
163 // Reject deletes the pending issue and, with ban, bans its submitter.
164 Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error
165}
166
167// IssueChallenge is what the public form needs to submit: a signed token, the
168// proof-of-work to solve and the timing.
169type IssueChallenge struct {
170 // Token is the signed challenge, sent back untouched.
171 Token string
172 // Nonce is the base64url of the 16 random bytes the browser hashes:
173 // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty
174 // leading zero bits.
175 Nonce string
176 // Difficulty is the number of leading zero bits required.
177 Difficulty int
178 // FormToken is a hidden field of the form, sent back untouched.
179 FormToken string
180 // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent.
181 IssuedAt, MinSubmitAt, ExpiresAt time.Time
182}
183
184// IssueSubmission is what the browser sends back.
185type IssueSubmission struct {
186 Token string
187 // Solution is the decimal counter that solves the proof of work.
188 Solution string
189 FormToken string
190 // Honeypot must be empty: a field only a bot fills.
191 Honeypot string
192 Title, Body, Author string
193}
194
195// IssueSubmissionService is the anonymous issue submission. Both calls answer
196// domain.ErrNotFound for a repository that is unknown, not public or without
197// public issues. Submit refuses with an error matching
198// domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused,
199// ErrSubmissionInvalid) or with a *domain.TextError the user can fix.
200// addr is the client key (clientlimit.Key of the address).
201// *usecase.IssueSubmissions implements it.
202type IssueSubmissionService interface {
203 Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error)
204 Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error
205}
206
207// IssueNumberResolver tells whether a number of a repository names an issue or
208// a merge request, for the callers that follow a "#n" reference. *usecase.Issues
209// implements it.
210type IssueNumberResolver interface {
211 // Kind needs the access of reading the repository and answers
212 // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
213 // requests are resolved even when issues are off.
214 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
215}
216
217// InboxService is the owner's inbox: what is open across every repository.
218// *usecase.Inbox implements it.
219type InboxService interface {
220 // Overview needs owner rights and answers domain.ErrNotFound to anyone
221 // else. The filter, when not zero, restricts the lists to that repository.
222 Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error)
223}
224
225// SigningKeyService tells which key signs the commits of the forge.
226// *usecase.SigningKey implements it.
227type SigningKeyService interface {
228 Info() domain.SigningKeyInfo
229}
230
231// SessionService is the owner's web session use cases the API serves.
232// *usecase.Sessions implements it.
233type SessionService interface {
234 CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error)
235 SignOutEverywhere(ctx context.Context, p domain.Principal) error
236}
237
238// ErrLinkRejected is what WebSessionService.Login returns for a link that is
239// unknown, expired or already used.
240var ErrLinkRejected = errors.New("login link rejected")
241
242// WebSessionService is the session side of the use cases, as the web needs
243// it. The composition root implements it over *usecase.Sessions.
244type WebSessionService interface {
245 // LoginLinkValid reports whether the link is pending, without using it.
246 LoginLinkValid(nonce string) bool
247 // Login uses the link and returns the session token and how long it
248 // lasts, or ErrLinkRejected.
249 Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error)
250 // Verify checks a session token. When it is valid it returns the owner,
251 // the principal the web passes to the use cases for that request; when it
252 // is not, ok is false and err nil. An error means the check could not be
253 // made (the request then runs as anonymous).
254 Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error)
255}
256
257// PasskeyService is the owner's passkey use cases the API serves.
258// *usecase.Passkeys implements it. Registering a passkey and signing in with
259// one are browser ceremonies and are not part of it.
260type PasskeyService interface {
261 // List returns the passkeys, oldest first.
262 List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
263 // Remove deletes the passkey with that credential id, or returns
264 // domain.ErrNotFound, and signs every browser out.
265 Remove(ctx context.Context, p domain.Principal, id []byte) error
266}
267
268// WebPasskeyService is the passkey side of the use cases, as the web needs
269// it: the API's List and Remove, and the browser ceremonies. The composition
270// root implements it over *usecase.Passkeys. A ceremony is the opaque
271// identifier BeginLogin and BeginRegistration return; options is the JSON the
272// browser passes to the WebAuthn API.
273type WebPasskeyService interface {
274 PasskeyService
275 // BeginLogin starts a sign-in. Anonymous callers may. client is the client
276 // key of the request (clientlimit.Key of the address the failure limiter
277 // uses); it bounds how many sign-ins a client keeps waiting.
278 BeginLogin(ctx context.Context, client string) (ceremony string, options []byte, err error)
279 // FinishLogin checks the browser's response and returns the session token
280 // and how long it lasts, or ErrLinkRejected for any refusal.
281 FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)
282 // BeginRegistration starts adding a passkey. Only the owner may. name is
283 // the name the passkey will get (empty means the default): a name that
284 // breaks the rules is refused here, before any ceremony starts, and is the
285 // one FinishRegistration stores.
286 BeginRegistration(ctx context.Context, p domain.Principal, name string) (ceremony string, options []byte, err error)
287 // FinishRegistration checks the browser's response and stores the passkey
288 // under the name given to BeginRegistration.
289 FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, response []byte) error
290}
291
292// PushService opens a repository for a push: it is domain.ErrUnauthenticated
293// for an anonymous caller, domain.ErrNotFound for a repository the caller may
294// not write and cannot see (or that is missing and may not be created),
295// domain.ErrForbidden for one it can see but not write. With create, a
296// missing repository the principal may write is created. *usecase.Pushes
297// implements it.
298type PushService interface {
299 Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error
300}
301
302// PushHookService is what the hook socket asks of the use cases.
303// *usecase.Pushes implements it.
304type PushHookService interface {
305 // Check decides a push before Git applies it; an error matching
306 // domain.ErrRejected refuses it, any other error refuses it as a failure.
307 // objects are the objects of the push in progress (the quarantine of git).
308 Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects PushObjects) error
309 // Record handles an accepted push.
310 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
311}
312
313// MaintenanceScheduler queues repository maintenance. *usecase.Maintainer
314// implements it.
315type MaintenanceScheduler interface {
316 Schedule(ctx context.Context, name domain.RepoName)
317}
318
319// HookTokens hands out the per-request tokens the hooks of a push present
320// back to ermite, and the socket they present them on. The Git HTTP adapter
321// implements it.
322type HookTokens interface {
323 // Register binds a new token to the repository and principal of a push.
324 // release revokes it and must be called when the request ends.
325 Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (token string, release func())
326 // Socket is the path of the hook socket.
327 Socket() string
328}
329
330// FailureLimiter counts the failed authentications per client. The adapters
331// that authenticate share one, so that a client has one budget of failures.
332// *clientlimit.Limiter implements it.
333type FailureLimiter interface {
334 Blocked(ctx context.Context, addr string) (bool, time.Duration)
335 Fail(ctx context.Context, addr string)
336}
337
338// SubmissionLimiter limits the submissions of the public issue form per
339// client. *clientlimit.SubmissionLimiter implements it.
340type SubmissionLimiter interface {
341 // Reserve atomically checks the limits for addr and, if it may submit,
342 // counts a submission. When it may not, retryAfter says when to retry.
343 // release refunds the reservation (only when the submission was not
344 // stored); it is never nil and safe to call once.
345 Reserve(ctx context.Context, addr string) (ok bool, retryAfter time.Duration, release func())
346 // Recent returns the submissions of the last hour from addr and from all
347 // clients.
348 Recent(ctx context.Context, addr string) (perAddr, global int)
349}
350
351// SecretPage is one page of secrets.
352type SecretPage struct {
353 Secrets []domain.Secret
354 NextPageToken string
355}
356
357// VariablePage is one page of variables.
358type VariablePage struct {
359 Variables []domain.Variable
360 NextPageToken string
361}
362
363// EnvironmentPage is one page of environments.
364type EnvironmentPage struct {
365 Environments []domain.Environment
366 NextPageToken string
367}
368
369// SecretService is the secret use cases the API serves. They need the
370// secrets:write scope, never on a token limited to branches. Values are never
371// returned. *usecase.CISettings implements it.
372type SecretService interface {
373 SetSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Secret, error)
374 ListSecrets(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (SecretPage, error)
375 DeleteSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error
376}
377
378// VariableService is the variable use cases the API serves, with the access
379// rules of SecretService. *usecase.CISettings implements it.
380type VariableService interface {
381 SetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Variable, error)
382 GetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) (domain.Variable, error)
383 ListVariables(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (VariablePage, error)
384 DeleteVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error
385}
386
387// EnvironmentService is the environment use cases the API serves, with the
388// access rules of SecretService. *usecase.CISettings implements it.
389type EnvironmentService interface {
390 CreateEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) (domain.Environment, error)
391 ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error)
392 DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error
393}
394
395// GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow
396// GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it.
397type GitHubReleaseAPI interface {
398 CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error)
399 GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error)
400 ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error)
401 UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error)
402 DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error
403 ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error)
404 UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error)
405 GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error)
406 UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error)
407 DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error
408 // AssetByID resolves metadata for a download through ReleaseService.
409 AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error)
410}