1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package ports declares every interface that crosses a layer, and the
4// parameter and result types of the use case interfaces.
5//
6// Outbound ports (ports.go) are what the use cases need from the outside
7// world: adapters implement them. Inbound ports (services.go) are the use
8// cases as the adapters that serve requests see them: the types of package usecase
9// implement them. Adapters and use cases depend on this package and on domain,
10// never on each other; internal/app wires the implementations. All the mocks are generated in
11// ports/mocks.
12package ports
13
14import (
15 "context"
16 "fmt"
17 "io"
18 "time"
19
20 "bnema.dev/ermite/internal/domain"
21)
22
23//go:generate go tool mockery
24
25// RepoMetaStore keeps repository metadata. It is the source of truth for
26// everything ermite owns about a repository except its Git data.
27type RepoMetaStore interface {
28 // Create stores repo. It returns domain.ErrAlreadyExists if a repository
29 // with that name exists, including when a concurrent Create wins.
30 Create(ctx context.Context, repo domain.Repo) error
31 // Get returns the repository, or domain.ErrNotFound.
32 Get(ctx context.Context, name domain.RepoName) (domain.Repo, error)
33 // List returns every repository ordered by name.
34 List(ctx context.Context) ([]domain.Repo, error)
35 // Mutate applies fn to the stored repository and writes under the version read.
36 // Concurrent writes retry fn up to a bounded number of attempts, then return
37 // domain.ErrConflict. fn must be pure and safe to call several times, and
38 // must not change Name. changed=false writes nothing. It returns the stored
39 // repository, or domain.ErrNotFound when absent. Errors from fn are returned
40 // unchanged with nothing written.
41 Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)
42 // Delete removes the repository and everything keyed to it: its merge
43 // requests, their comments and read positions, and its number sequence.
44 // A repository created again under the same name starts empty, with its
45 // numbers starting at 1. Deleting a missing one is not an error.
46 Delete(ctx context.Context, name domain.RepoName) error
47}
48
49// StagedID identifies a repository being created. It is opaque to callers.
50type StagedID string
51
52// RepoDirs is the result of listing the repository directories.
53type RepoDirs struct {
54 // Names are the directories that hold a valid repository name.
55 Names []domain.RepoName
56 // Invalid are the entries that cannot be a repository directory.
57 Invalid []string
58}
59
60// GitRepos manages bare repository directories.
61type GitRepos interface {
62 // Stage creates an empty bare repository outside the public directory
63 // and returns its identifier.
64 Stage(ctx context.Context) (StagedID, error)
65 // Publish moves a staged repository to its final place under name. It
66 // returns domain.ErrAlreadyExists if a directory for name exists; the
67 // staged repository is left in place for Discard.
68 Publish(ctx context.Context, id StagedID, name domain.RepoName) error
69 // Discard removes a staged repository. A missing one is not an error.
70 Discard(ctx context.Context, id StagedID) error
71 // PurgeStaging removes every staged repository left by a previous run.
72 PurgeStaging(ctx context.Context) error
73 // List returns the repository directories.
74 List(ctx context.Context) (RepoDirs, error)
75 // Trash moves the repository directory to the trash, where it can be
76 // recovered by hand. It returns domain.ErrNotFound if there is none.
77 Trash(ctx context.Context, name domain.RepoName) error
78 // DefaultBranch returns the branch HEAD points to (without refs/heads/)
79 // and whether that branch exists. It returns domain.ErrNotFound if there
80 // is no such repository.
81 DefaultBranch(ctx context.Context, name domain.RepoName) (branch string, exists bool, err error)
82 // HasBranch reports whether refs/heads/<branch> exists. A name that
83 // cannot be a branch does not exist. It returns domain.ErrNotFound if
84 // there is no such repository.
85 HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error)
86 // HeadBranch returns the short name of the branch HEAD points to, whether
87 // or not that branch exists, and "" when HEAD is detached. It reads no
88 // refs, so it is cheap enough for a listing. It returns
89 // domain.ErrNotFound if there is no such repository.
90 HeadBranch(ctx context.Context, name domain.RepoName) (string, error)
91 // SetDefaultBranch points HEAD to the branch, which need not exist yet.
92 SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error
93 // Maintain refreshes the commit-graph of the repository.
94 Maintain(ctx context.Context, name domain.RepoName) error
95}
96
97// RepoFreshener is implemented by a RepoReader that keeps repositories open
98// between reads. The use cases that write to a repository (a push, the
99// maintenance that follows it) call Refresh so that the reader picks the change
100// up at once instead of when its own timer says so. It is optional: reads
101// stay correct without it.
102type RepoFreshener interface {
103 // Refresh tells the reader that the repository changed on disk. It does
104 // not fail: a reader that cannot refresh finds out on its next read.
105 Refresh(ctx context.Context, name domain.RepoName)
106}
107
108// CommitQuery selects a page of history.
109type CommitQuery struct {
110 // Revision is where the history starts; the zero value is HEAD.
111 Revision domain.Revision
112 // Path restricts the history to the commits that changed this file or
113 // directory; the root means the whole history.
114 Path domain.TreePath
115 // Skip is the number of commits of the listing to leave out. A caller that
116 // pages passes the commit it got in CommitPage.Head as Revision and the
117 // number of commits already read, so that every page walks the same
118 // history even when the revision moves.
119 Skip int
120 // Limit is the number of commits wanted, 1 to MaxCommitLimit.
121 Limit int
122}
123
124// MaxCommitTimes bounds RepoReader.CommitTimes.
125const MaxCommitTimes = 100_000
126
127// MaxCommitLimit is the most commits one CommitQuery may ask for.
128const MaxCommitLimit = 1000
129
130// Validate checks the page size and the offset of the query. It returns an
131// error matching domain.ErrInvalidPageSize.
132func (q CommitQuery) Validate() error {
133 if q.Limit < 1 || q.Limit > MaxCommitLimit || q.Skip < 0 {
134 return fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
135 }
136 return nil
137}
138
139// CommitPage is a page of history, newest first.
140type CommitPage struct {
141 // Head is the commit the revision resolved to.
142 Head domain.ObjectID
143 // Commits has at most Limit commits.
144 Commits []domain.Commit
145 // HasMore tells that commits follow the last one.
146 HasMore bool
147}
148
149// RepoReader reads the Git data of repositories: refs, trees, blobs, history
150// and diffs. A repository that has no directory gives domain.ErrNotFound; so
151// do an unknown revision, an empty repository (which has no commit) and a
152// path that does not exist at the revision.
153type RepoReader interface {
154 // ListRefs returns HEAD, then the branches, then the tags, the last two
155 // ordered by name. HEAD is always listed: when its branch does not exist
156 // yet its ShortName and ids are empty.
157 ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error)
158 // ResolveRevision turns a revision into the commit it designates. A name
159 // that is both a branch and a tag means the branch.
160 ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
161 // ResolveRefPath splits a path such as "feature/x/dir/file" into the
162 // longest leading part that names a branch or a tag and the rest. When no
163 // ref matches, the first segment may be a commit id (full, or abbreviated
164 // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing
165 // matches.
166 ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error)
167 // GetTree lists the directory at path, at most domain.MaxTreeEntries
168 // entries (Tree.Truncated tells). With includeLastCommit, the first
169 // domain.MaxLastCommitEntries entries get their last commit. It returns
170 // domain.ErrNotDirectory if path names something else.
171 GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
172 // ListPaths lists the file and symlink paths of rev recursively in tree
173 // order, at most maxPaths paths and maxBytes bytes of paths (PathList.Truncated
174 // tells), skipping submodules and paths that are not addressable
175 // (domain.IsAddressablePath).
176 ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error)
177 // OpenBlob returns the description of the file at path and a reader of its
178 // content, which streams without holding the whole file in memory. The
179 // caller must close the reader. It returns domain.ErrNotFile if path names
180 // something else.
181 OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
182 // ListCommits returns a page of history.
183 ListCommits(ctx context.Context, repo domain.RepoName, q CommitQuery) (CommitPage, error)
184 // GetCommit returns a commit with its statistics and its diff against the
185 // first parent, bounded by limits.
186 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
187 // CountCommits counts the commits reachable from head.
188 CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)
189 // CommitTimes are the committer dates of the commits reachable from head
190 // committed at or after since, in no particular order; at most
191 // MaxCommitTimes, truncated tells there were more.
192 CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (times []time.Time, truncated bool, err error)
193 // LatestTag is the most recently created tag (the commit date for a
194 // lightweight tag); ok is false when the repository has no tag.
195 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
196}
197
198// HTTPBackend tells how to run `git http-backend` for one request.
199type HTTPBackend struct {
200 // Path is the git-http-backend executable.
201 Path string
202 // Dir is the working directory of the process.
203 Dir string
204 // Env is the complete environment of the process: the isolated Git
205 // environment, GIT_PROJECT_ROOT and the Git configuration of the request
206 // (GIT_CONFIG_COUNT/KEY/VALUE). The caller may append to it.
207 Env []string
208}
209
210// GitBackend provides the Git side of smart HTTP.
211type GitBackend interface {
212 // HTTPBackend returns how to run git http-backend. For a push it also
213 // enables http.receivepack and sets REMOTE_USER; a read request cannot
214 // reach receive-pack.
215 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
216}
217
218// GitArchive writes the archive of a commit.
219type GitArchive interface {
220 // Archive writes the tree of the commit as a gzip-compressed tar to w, all
221 // the paths below the directory prefix. It writes nothing and returns
222 // domain.ErrNotFound when the repository or the commit does not exist.
223 Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error
224}
225
226// TokenStore keeps the stored tokens and the index from secret hash to token.
227// The secret itself is never stored.
228type TokenStore interface {
229 // Create stores the token and its hash index in one write. It returns
230 // domain.ErrAlreadyExists if the id or the hash is taken.
231 Create(ctx context.Context, token domain.Token) error
232 // Get returns the token with that id, or domain.ErrNotFound.
233 Get(ctx context.Context, id string) (domain.Token, error)
234 // FindByHash returns the token whose secret has that hash, or
235 // domain.ErrNotFound.
236 FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)
237 // List returns every token, oldest first.
238 List(ctx context.Context) ([]domain.Token, error)
239 // Revoke removes the token and its hash index in one write, or returns
240 // domain.ErrNotFound.
241 Revoke(ctx context.Context, id string) error
242 // Touch records that the token was used at the given time. It returns
243 // domain.ErrNotFound if the token is gone.
244 Touch(ctx context.Context, id string, at time.Time) error
245}
246
247// IssueStore keeps the issues of the repositories and their comments. Issues
248// share the number sequence of the merge requests of their repository, so that
249// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
250// the repository does not exist, so that deleting a repository leaves nothing
251// for one created again under its name. *kvstore.Issues implements it.
252type IssueStore interface {
253 // Create stores the issue under the next number of its repository (the
254 // one merge requests draw from), which it sets, and returns it. A number
255 // is never given twice, to an issue or to a merge request, even by a
256 // concurrent Create.
257 Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
258 // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
259 // which state it was read at.
260 Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
261 // List returns a page of the issues of the repository in the given state
262 // (any when empty), highest number first.
263 List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
264 // ListOpenAll returns the open issues of every repository, reading only
265 // the open ones (through the same index). The order is unspecified.
266 ListOpenAll(ctx context.Context) ([]domain.Issue, error)
267 // Update replaces the stored issue and keeps the index of the open issues
268 // in step. It returns domain.ErrNotFound if there is none and
269 // domain.ErrConflict if another write changed it since issue was read.
270 Update(ctx context.Context, issue domain.Issue) error
271 // SaveComment writes a comment and the issue it belongs to in one batch: a
272 // new comment (Version 0) must not exist yet, an existing one must be
273 // unchanged since it was read, and so must issue. It returns
274 // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
275 // comment's ID for a new comment, and issue.UpdatedAt.
276 SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
277 // ListComments returns the comments of the issue, oldest first.
278 ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
279}
280
281// PendingIssueStore keeps the issues submitted through the public form until
282// the owner decides. They have no number: Approve gives one. Put and Approve
283// return domain.ErrNotFound when the repository does not exist, so that
284// deleting a repository leaves nothing for one created again under its name.
285// *kvstore.PendingIssues implements it.
286type PendingIssueStore interface {
287 // Put stores the pending issue; its id must be new.
288 Put(ctx context.Context, p domain.PendingIssue) error
289 // Get returns the pending issue, or domain.ErrNotFound.
290 Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)
291 // List returns a page of the pending issues of the repository, or of
292 // every repository when repo is zero, oldest first. A PageSize of 0
293 // means every one.
294 List(ctx context.Context, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
295 // Count returns how many pending issues there are in all repositories,
296 // reading at most domain.MaxPendingIssues+1 of them.
297 Count(ctx context.Context) (int, error)
298 // Approve takes the next number of the repository (the one merge requests
299 // and issues share), writes the issue domain.ApprovedIssue(pending, now)
300 // with that number, indexes it as open and deletes the pending record,
301 // in one batch. It returns domain.ErrNotFound when either is missing.
302 Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)
303 // Delete removes the pending issue, or returns domain.ErrNotFound.
304 Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error
305}
306
307// IssueKeyStore keeps the server key of the public issue form, from which the
308// use cases derive their subkeys.
309type IssueKeyStore interface {
310 // Key returns the 32-byte secret. The first call on an empty store
311 // generates and stores it; a restart keeps it.
312 Key(ctx context.Context) ([]byte, error)
313}
314
315// IssueBanStore keeps the bans of submitters, by the hash of their client key.
316type IssueBanStore interface {
317 // Ban bans the hash until the given time, replacing an earlier ban.
318 Ban(ctx context.Context, hash []byte, until time.Time) error
319 // Banned reports whether the hash is banned at now. An expired ban counts
320 // as not banned and is deleted.
321 Banned(ctx context.Context, hash []byte, now time.Time) (bool, error)
322}
323
324// MergeRequestStore keeps the merge requests of the repositories, their
325// comments and the read position of the reviewer. A repository has no entry
326// until its first merge request. Create, SaveComment and SetRead return
327// domain.ErrNotFound when the repository does not exist, so that deleting a
328// repository leaves nothing for one created again under its name.
329type MergeRequestStore interface {
330 // Create stores the merge request under the next number of its
331 // repository, which it sets, and returns it. Numbers start at 1 and are
332 // never reused, even by a concurrent Create.
333 Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error)
334 // Get returns the merge request, or domain.ErrNotFound. Its Version
335 // tells Update which state it was read at.
336 Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error)
337 // List returns every merge request of the repository, highest number
338 // first.
339 List(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
340 // ListOpen returns the open merge requests of the repository, highest
341 // number first, reading only the open ones (through an index).
342 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
343 // ListOpenAll returns the open merge requests of every repository,
344 // reading only the open ones (through the same index). The order is
345 // unspecified.
346 ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error)
347 // CountOpen returns the number of open merge requests of the repository
348 // from the index of keys: no request is read.
349 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
350 // OpenBySource returns the numbers of the open merge requests whose source
351 // branch is one of branches (short names), from an index of keys: a push
352 // looks up the branches it moved, whatever the number of merge requests the
353 // repository ever had. No request is read. It stops after limit numbers (a
354 // limit of 0 or less means none) and reports truncated when there are more,
355 // without counting them; the context is checked between keys, and a
356 // cancelled one ends the lookup with its error and what was found so far.
357 OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) (numbers []int64, truncated bool, err error)
358 // OpenByTarget is OpenBySource for the open merge requests whose target
359 // branch is one of branches.
360 OpenByTarget(ctx context.Context, repo domain.RepoName, branches []string, limit int) (numbers []int64, truncated bool, err error)
361 // Update replaces the stored merge request. It returns domain.ErrNotFound
362 // if there is none and domain.ErrConflict if another write changed it
363 // since mr was read.
364 Update(ctx context.Context, mr domain.MergeRequest) error
365 // ListComments returns the comments of the merge request, oldest first.
366 ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.Comment, error)
367 // GetComment returns one comment, or domain.ErrNotFound.
368 GetComment(ctx context.Context, repo domain.RepoName, number int64, id int64) (domain.Comment, error)
369 // SaveComment writes a comment and the merge request it belongs to in one
370 // batch: a new comment (Version 0) must not exist yet, an existing one
371 // must be unchanged since it was read, and so must mr. It returns
372 // domain.ErrConflict otherwise. The caller sets mr.CommentSeq and the
373 // comment's ID for a new comment, and mr.UpdatedAt.
374 SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error
375 // GetRead returns the head the reviewer last marked read, or the zero
376 // ObjectID when there is none.
377 GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error)
378 // SetRead records the head the reviewer has read. It returns
379 // domain.ErrNotFound when the repository does not exist, and
380 // domain.ErrConflict when concurrent writes to the repository keep it
381 // from landing.
382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
383 // GetMergeability returns the stored mergeability of an open merge request,
384 // or domain.ErrNotFound when there is none.
385 GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
386 // PutMergeability stores the mergeability. A Version of 0 requires that
387 // none is stored, otherwise the stored one must be unchanged since it was
388 // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
389 // request is not stored or not open, so that a late write never resurrects
390 // a record.
391 PutMergeability(ctx context.Context, m domain.Mergeability) error
392 // DeleteMergeability removes the stored mergeability; no error when absent.
393 DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
394}
395
396// MergeGit is what merge requests need from the Git repositories beyond
397// RepoReader. Every method returns domain.ErrNotFound when the repository has
398// no directory.
399type MergeGit interface {
400 // BranchHead returns the commit refs/heads/<branch> points to, or
401 // domain.ErrNotFound when there is no such branch.
402 BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error)
403 // HasCommit reports whether the repository holds that commit.
404 HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error)
405 // MergeBase returns the best common ancestor of two commits, or
406 // domain.ErrNotFound when they share no history.
407 MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error)
408 // IsAncestor reports whether ancestor is reachable from descendant, a
409 // commit being its own ancestor.
410 IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error)
411 // ListRange returns the commits reachable from head and not from exclude
412 // ("exclude..head"), newest first, at most limit; more is true when the
413 // range holds more. A zero exclude means the whole history of head.
414 ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
415 // DiffCommits returns the statistics and the diff from commit base to
416 // commit head (renames detected), bounded by limits.
417 DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error)
418 // CommitTree returns the tree of a commit.
419 CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error)
420 // WriteCommit stores a commit object, given as the text of the object,
421 // and returns its id.
422 WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error)
423 // UpdateBranch moves refs/heads/<branch> from old to new. It returns
424 // domain.ErrConflict when the branch no longer points to old.
425 UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, new domain.ObjectID) error
426 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
427 // returns domain.ErrConflict when the branch moved or is gone.
428 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
429 // MergeTree merges head into base without creating a commit. When the
430 // merge is not clean (or the commits share no history) Clean is false and
431 // ConflictPaths lists every conflicted path, unbounded: the caller bounds
432 // them (MergeTreeResult.Bounded).
433 MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
434 // CountBehind counts the commits of target that head lacks, stopping at
435 // limit: the raw count, at most limit. The caller interprets it
436 // (domain.BoundBehind).
437 CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438}
439
440// MergeabilityTrigger queues mergeability checks. It only queues the work and
441// returns.
442type MergeabilityTrigger interface {
443 // OnBranchesMoved queues the open merge requests whose source or target
444 // branch was moved or deleted by updates.
445 OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
446 // OnOpened queues a newly opened merge request.
447 OnOpened(ctx context.Context, mr domain.MergeRequest)
448}
449
450// MergeabilityReader reads the mergeability of open merge requests.
451type MergeabilityReader interface {
452 // Read returns the stored mergeability for mr and targetTip, or a
453 // "checking" one (and queues a check) when there is none or it is stale.
454 Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
455}
456
457// CommitSigner signs the commits ermite creates (ADR 0022). The private key
458// never leaves it.
459type CommitSigner interface {
460 // PublicKey returns the public key and its fingerprint.
461 PublicKey() domain.SigningPublicKey
462 // Sign signs the text of a commit object and returns the armored SSH
463 // signature (namespace "git") to put in its gpgsig header.
464 Sign(payload []byte) (string, error)
465}
466
467// SessionStore keeps what the browser sessions of the owner (ADR 0018) need
468// to survive a restart: the key that signs them and the epoch that voids them.
469type SessionStore interface {
470 // Key returns the 32-byte secret that signs the session cookies. The first
471 // call on an empty store generates and stores it; a restart keeps it.
472 Key(ctx context.Context) ([]byte, error)
473 // Epoch returns the current epoch, 0 until the first BumpEpoch. A session
474 // is valid only while its epoch is the current one.
475 Epoch(ctx context.Context) (uint64, error)
476 // BumpEpoch increments the epoch, which ends every session, and returns
477 // the new value.
478 BumpEpoch(ctx context.Context) (uint64, error)
479}
480
481// PasskeyStore keeps the owner's passkeys (ADR 0026) and the WebAuthn user
482// handle they belong to.
483type PasskeyStore interface {
484 // UserHandle returns the 32-byte WebAuthn user handle of the owner. The
485 // first call on an empty store generates and stores it; it never changes.
486 UserHandle(ctx context.Context) ([]byte, error)
487 // List returns every passkey, oldest first.
488 List(ctx context.Context) ([]domain.Passkey, error)
489 // Get returns the passkey with that credential id, or domain.ErrNotFound.
490 Get(ctx context.Context, id []byte) (domain.Passkey, error)
491 // Create stores the passkey. It returns domain.ErrAlreadyExists if the
492 // credential id is taken. It does not bound the number of passkeys: the
493 // use case checks domain.MaxPasskeys.
494 Create(ctx context.Context, p domain.Passkey) error
495 // Update replaces the stored passkey (sign count, backup state, last
496 // use), or returns domain.ErrNotFound.
497 Update(ctx context.Context, p domain.Passkey) error
498 // Delete removes the passkey, or returns domain.ErrNotFound.
499 Delete(ctx context.Context, id []byte) error
500}
501
502// PasskeyVerifier runs the WebAuthn ceremonies (ADR 0026). It hides the
503// WebAuthn library: the ceremony data it hands out is opaque to the caller,
504// which keeps it between the begin and the finish of a ceremony and passes it
505// back untouched. Every verification failure is domain.ErrUnauthenticated,
506// wrapped with a reason meant for logs, not for callers.
507type PasskeyVerifier interface {
508 // BeginRegistration starts the registration of a passkey for the owner.
509 // options is the JSON of the credential creation options for the browser
510 // ({"publicKey": {...}}); existing passkeys are excluded.
511 BeginRegistration(userHandle []byte, existing []domain.Passkey) (options []byte, ceremony []byte, err error)
512 // FinishRegistration checks the browser's response (the JSON of
513 // PublicKeyCredential.toJSON()) and returns the new passkey, without a name
514 // or creation time.
515 FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error)
516 // BeginLogin starts a discoverable login. options is the JSON of the
517 // credential request options for the browser ({"publicKey": {...}}).
518 BeginLogin() (options []byte, ceremony []byte, err error)
519 // FinishLogin checks the browser's response and returns the stored passkey
520 // updated with the sign count and backup state the response reports. lookup
521 // returns domain.ErrNotFound for an unknown credential id. A counter that
522 // does not advance is not an error here: the returned SignCount is then the
523 // stored one, and the caller, which holds the lock that orders the logins,
524 // refuses it.
525 FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)
526}
527
528// EventBus fans events out to in-process subscribers.
529type EventBus interface {
530 // Publish hands the event to every subscriber without blocking. A
531 // subscriber that cannot keep up loses events and receives a
532 // domain.EventGap.
533 Publish(ctx context.Context, ev domain.Event)
534 // Subscribe returns a channel of events published from now on. The
535 // channel is closed when ctx ends.
536 Subscribe(ctx context.Context) <-chan domain.Event
537}
538
539// Clock tells the time.
540type Clock interface {
541 Now() time.Time
542}