package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.36
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strings"
sq "github.com/Masterminds/squirrel"
"github.com/lib/pq"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/database"
coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors"
model1 "sourcecraft.dev/bigbes/sr-ht-core/model"
corewebhooks "sourcecraft.dev/bigbes/sr-ht-core/webhooks"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
"sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
"sourcecraft.dev/bigbes/sr-ht-spec/graph/model"
"sourcecraft.dev/bigbes/sr-ht-spec/search"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// CreateUserWebhook is the resolver for the createUserWebhook field.
func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
// The owner gate is the whole ACL: spec is single-owner with no OAuth
// clients and no scopes, so there is no per-event grant to check the way
// pages.sr.ht does — either you are the owner and may manage every webhook,
// or you are refused here.
if err := webhookAuthorized(ctx); err != nil {
return nil, err
}
// The schema the root resolver holds, and not core-go's server context:
// /query is served on the anonymous router, where that context does not
// exist. It is the same schema either way — the daemon builds exactly one.
if err := corewebhooks.Validate(r.schema, config.Query); err != nil {
return nil, err
}
// The derived AuthContext is AUTH_INTERNAL (coreauth maps the owner to it),
// so NewAuthConfig returns an INTERNAL config: node_id set, the OAuth columns
// nil.
ac, err := corewebhooks.NewAuthConfig(ctx)
if err != nil {
return nil, err
}
if len(config.Events) == 0 {
return nil, fmt.Errorf("must specify at least one event")
}
events := make([]string, len(config.Events))
for i, ev := range config.Events {
events[i] = ev.String()
}
u, err := url.Parse(config.URL)
if err != nil {
return nil, err
} else if u.Host == "" {
return nil, fmt.Errorf("cannot use URL without host")
} else if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("cannot use non-HTTP or HTTPS URL")
}
var sub model.UserWebhookSubscription
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
INSERT INTO gql_user_wh_sub (
created, events, url, query,
auth_method,
token_hash, grants, client_id, expires,
node_id,
user_id
) VALUES (
NOW() at time zone 'utc',
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
) RETURNING id, url, query, events, user_id;`,
pq.Array(events), config.URL, config.Query,
ac.AuthMethod,
ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2
ac.NodeID, // INTERNAL
auth.ForContext(ctx).UserID)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &sub, nil
}
// DeleteUserWebhook is the resolver for the deleteUserWebhook field.
func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
if err := webhookAuthorized(ctx); err != nil {
return nil, err
}
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
var sub model.UserWebhookSubscription
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := sq.Delete(`gql_user_wh_sub`).
PlaceholderFormat(sq.Dollar).
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
Suffix(`RETURNING id, url, query, events, user_id`).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no user webhook by ID %d found for this user", id)
}
return nil, err
}
return &sub, nil
}
// Spaces is the resolver for the spaces field.
func (r *projectResolver) Spaces(ctx context.Context, obj *model.Project) ([]*model.Space, error) {
ref := core.ProjectRef{Owner: obj.Owner, Name: obj.Name}
spaces, err := r.reader.ProjectSpaces(ctx, ref)
if err != nil {
return nil, err
}
return spaceModels(spaces), nil
}
// Spaces is the resolver for the spaces field.
func (r *queryResolver) Spaces(ctx context.Context) ([]*model.Space, error) {
spaces, err := r.reader.ListSpaces(ctx)
if err != nil {
return nil, err
}
return spaceModels(spaces), nil
}
// Space is the resolver for the space field.
func (r *queryResolver) Space(ctx context.Context, owner string, name string) (*model.Space, error) {
ref, err := spaceRef(owner, name)
if err != nil {
return nil, err
}
sp, err := r.reader.OpenSpace(ctx, ref)
if err != nil {
return nil, orNull(err)
}
return spaceModel(sp), nil
}
// Document is the resolver for the document field.
func (r *queryResolver) Document(ctx context.Context, space string, id *string, path *string, rev *string) (*model.Document, error) {
arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev)
if err != nil {
return nil, orNull(err)
}
page, found, err := address(arc, deref(id), deref(path))
if err != nil || !found {
return nil, err
}
body, ok := bodies[page.Path]
if !ok {
// The archive is built from these very bodies, so a page without one is
// a broken invariant rather than a missing document. Reporting an empty
// markdown field would be indistinguishable from an empty document.
return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved)
}
return documentModel(arc.Space, resolved, pinned, page, body), nil
}
// Documents is the resolver for the documents field.
func (r *queryResolver) Documents(ctx context.Context, space string, rev *string) ([]*model.Document, error) {
arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev)
if err != nil {
return nil, err
}
pages := arc.All()
out := make([]*model.Document, 0, len(pages))
for _, page := range pages {
body, ok := bodies[page.Path]
if !ok {
return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved)
}
out = append(out, documentModel(arc.Space, resolved, pinned, page, body))
}
return out, nil
}
// Search is the resolver for the search field.
func (r *queryResolver) Search(ctx context.Context, query string, spaces []string, limit *int, offset *int) (*model.SearchResults, error) {
q := search.Query{Text: strings.TrimSpace(query)}
// Filter polarity, which is the one thing here that is dangerous to get
// wrong — and the reason core.SpaceFilter exists rather than a slice.
// Absent means every space, which is the meta-project. A list means exactly
// those spaces, and a list that is present but empty therefore selects
// nothing at all: that is what an empty project's membership is, and a
// client passing one through must get no hits rather than the whole corpus.
// GraphQL distinguishes the two cases for free — null is not [] — so the
// argument's own shape carries the polarity that used to be a convention.
if spaces == nil {
q.Spaces = core.EverythingFilter()
} else {
refs := make([]core.SpaceRef, 0, len(spaces))
for _, s := range spaces {
ref, err := core.ParseSpaceRef(strings.TrimSpace(s))
if err != nil {
return nil, fmt.Errorf("spaces: %w", err)
}
refs = append(refs, ref)
}
q.Spaces = core.SpacesFilter(refs, nil)
}
if limit != nil {
if *limit < 0 {
return nil, fmt.Errorf("limit: %d is negative", *limit)
}
q.Limit = *limit
}
if offset != nil {
if *offset < 0 {
return nil, fmt.Errorf("offset: %d is negative", *offset)
}
q.Offset = *offset
}
res, err := r.searcher.Search(ctx, q)
if err != nil {
return nil, err
}
out := &model.SearchResults{
Total: int(res.Total),
Took: res.Took.String(),
Hits: make([]*model.SearchHit, 0, len(res.Hits)),
}
for _, h := range res.Hits {
out.Hits = append(out.Hits, &model.SearchHit{
Space: h.Space.String(),
ID: h.ID,
Rev: h.Rev,
Path: h.Path,
Anchor: h.Anchor,
Title: h.Title,
Section: h.Section,
Lang: string(h.Lang),
Score: h.Score,
Snippet: h.Snippet,
})
}
return out, nil
}
// Projects is the resolver for the projects field.
func (r *queryResolver) Projects(ctx context.Context) ([]*model.Project, error) {
projects, err := r.reader.ListProjects(ctx)
if err != nil {
return nil, err
}
out := make([]*model.Project, 0, len(projects))
for _, p := range projects {
out = append(out, projectModel(p.Ref))
}
return out, nil
}
// Project is the resolver for the project field.
func (r *queryResolver) Project(ctx context.Context, owner string, name string) (*model.Project, error) {
ref, err := projectRef(owner, name)
if err != nil {
return nil, err
}
// The meta-project is an address that resolves to a filter, not a row: it
// needs no storage, cannot be renamed or deleted, and adding a space to the
// service adds it to the meta-project by construction. So it is answered
// here rather than looked up — a lookup would report the one project that
// can never be missing as missing.
if ref.IsMeta() {
return projectModel(ref), nil
}
if _, err := r.reader.GetProject(ctx, ref); err != nil {
return nil, orNull(err)
}
return projectModel(ref), nil
}
// Proposals is the resolver for the proposals field.
func (r *queryResolver) Proposals(ctx context.Context, space string, state model.ProposalState) ([]*model.Proposal, error) {
ref, err := core.ParseSpaceRef(strings.TrimSpace(space))
if err != nil {
return nil, fmt.Errorf("space: %w", err)
}
// GraphQL has already validated the enum, so this cannot fail over a
// caller's spelling; it fails only if this schema and core disagree about
// the state machine, which is worth hearing about rather than defaulting
// past.
st, err := core.ParseProposalState(strings.ToLower(string(state)))
if err != nil {
return nil, err
}
if r.proposals == nil {
return nil, errors.New("proposals cannot be read: service/ exposes no proposal listing yet, " +
"and nothing above it may query the database directly (the write plane is Phase 3)")
}
proposals, err := r.proposals.ListProposals(ctx, ref, st)
if err != nil {
return nil, err
}
out := make([]*model.Proposal, 0, len(proposals))
for _, p := range proposals {
m, err := proposalModel(p)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, nil
}
// UserWebhooks is the resolver for the userWebhooks field.
func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *model1.Cursor) (*model.WebhookSubscriptionCursor, error) {
if err := webhookAuthorized(ctx); err != nil {
return nil, err
}
if cursor == nil {
cursor = model1.NewCursor(nil)
}
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
var subs []model.WebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
sub := (&model.UserWebhookSubscription{}).As(`sub`)
query := database.
Select(ctx, sub).
From(`gql_user_wh_sub sub`).
Where(filter)
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookSubscriptionCursor{Results: subs, Cursor: cursor}, nil
}
// UserWebhook is the resolver for the userWebhook field.
func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
if err := webhookAuthorized(ctx); err != nil {
return nil, err
}
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
var sub model.UserWebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
row := database.
Select(ctx, &sub).
From(`gql_user_wh_sub`).
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(database.Scan(ctx, &sub)...); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no user webhook by ID %d found for this user", id)
}
return nil, err
}
return &sub, nil
}
// Webhook is the resolver for the webhook field.
func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, error) {
raw, err := corewebhooks.Payload(ctx)
if err != nil {
return nil, err
}
payload, ok := raw.(model.WebhookPayload)
if !ok {
panic("Invalid webhook payload context")
}
return payload, nil
}
// ApprovedRev is the resolver for the approvedRev field.
func (r *spaceResolver) ApprovedRev(ctx context.Context, obj *model.Space) (string, error) {
// A field resolver rather than a struct field: ListSpaces deliberately does
// not open repositories, so resolving the approved head of every space in a
// listing would make the cheapest view in the service the most expensive
// one. Here it is paid for only by a query that asks for it.
sp, err := r.reader.OpenSpace(ctx, core.SpaceRef{Owner: obj.Owner, Name: obj.Name})
if err != nil {
return "", err
}
return r.reader.ResolveRev(ctx, sp, service.ApprovedRev)
}
// Deliveries is the resolver for the deliveries field.
func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *model1.Cursor) (*model.WebhookDeliveryCursor, error) {
if cursor == nil {
cursor = model1.NewCursor(nil)
}
var deliveries []*model.WebhookDelivery
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
d := (&model.WebhookDelivery{}).
WithName(`user`).
As(`delivery`)
query := database.
Select(ctx, d).
From(`gql_user_wh_delivery delivery`).
Where(`delivery.subscription_id = ?`, obj.ID)
deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookDeliveryCursor{Results: deliveries, Cursor: cursor}, nil
}
// Sample is the resolver for the sample field.
//
// It is the one field of this schema that /query cannot answer, and it says so
// rather than failing at a lower level with something a caller cannot read.
//
// The reason is core-go's, not this service's. corewebhooks.WebhookContext.Exec
// — which is what renders a sample, by running the subscriber's own stored query
// against a synthetic payload — reads the complexity limit off core-go's server
// context and panics when there is none. That context is installed by
// server.WithDefaultMiddleware on the *authenticated* router, and this endpoint
// is on the anonymous one because it authenticates with tokens.sr.ht working
// tokens rather than with meta's OAuth vocabulary. There is no exported way to
// put that value on a context; the alternatives are to fork Exec into this
// service or to let the panic surface as "internal system error", and a named
// refusal beats both.
//
// Delivery is unaffected: the webhook queue's own context comes from
// server.WithQueues, which does carry it, so a real delivery renders exactly as
// before.
func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event model.WebhookEvent) (string, error) {
switch event {
case model.WebhookEventProposalOpened,
model.WebhookEventProposalMerged,
model.WebhookEventProposalRejected:
default:
return "", fmt.Errorf("unsupported event %s", event.String())
}
return "", fmt.Errorf(
"sample is not available on this endpoint: rendering one needs core-go's server " +
"context, which exists only on the authenticated router, and spec.sr.ht serves " +
"/query on the anonymous router so that a tokens.sr.ht working token is the " +
"credential; subscribe and trigger the event to see a real delivery")
}
// Subscription is the resolver for the subscription field.
func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.WebhookDelivery) (model.WebhookSubscription, error) {
if obj.Name == "" {
panic("WebhookDelivery without name")
}
// XXX: This could use a loader but it's unlikely to be a bottleneck
var sub model.WebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
// XXX: This needs some work to generalize to other kinds of webhooks
var subscription interface {
model.WebhookSubscription
database.Model
} = nil
switch obj.Name {
case "user":
subscription = (&model.UserWebhookSubscription{}).As(`sub`)
default:
panic(fmt.Errorf("unknown webhook name %q", obj.Name))
}
// Note: No filter needed because, if we have access to the delivery,
// we also have access to the subscription.
row := database.
Select(ctx, subscription).
From(`gql_`+obj.Name+`_wh_sub sub`).
Where(`sub.id = ?`, obj.SubscriptionID).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(database.Scan(ctx, subscription)...); err != nil {
return err
}
sub = subscription
return nil
}); err != nil {
return nil, err
}
return sub, nil
}
// Mutation returns api.MutationResolver implementation.
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
// Project returns api.ProjectResolver implementation.
func (r *Resolver) Project() api.ProjectResolver { return &projectResolver{r} }
// Query returns api.QueryResolver implementation.
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
// Space returns api.SpaceResolver implementation.
func (r *Resolver) Space() api.SpaceResolver { return &spaceResolver{r} }
// UserWebhookSubscription returns api.UserWebhookSubscriptionResolver implementation.
func (r *Resolver) UserWebhookSubscription() api.UserWebhookSubscriptionResolver {
return &userWebhookSubscriptionResolver{r}
}
// WebhookDelivery returns api.WebhookDeliveryResolver implementation.
func (r *Resolver) WebhookDelivery() api.WebhookDeliveryResolver { return &webhookDeliveryResolver{r} }
type mutationResolver struct{ *Resolver }
type projectResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type spaceResolver struct{ *Resolver }
type userWebhookSubscriptionResolver struct{ *Resolver }
type webhookDeliveryResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
func (r *queryResolver) archive(ctx context.Context, space string, rev *string) (*doc.Archive, map[string][]byte, string, bool, error) {
ref, err := core.ParseSpaceRef(strings.TrimSpace(space))
if err != nil {
return nil, nil, "", false, fmt.Errorf("space: %w", err)
}
want := deref(rev)
if err := service.ValidateReadRev(want); err != nil {
return nil, nil, "", false, fmt.Errorf("rev: %w", err)
}
sp, err := r.reader.OpenSpace(ctx, ref)
if err != nil {
return nil, nil, "", false, err
}
arc, bodies, err := r.reader.Archive(ctx, sp, want)
if err != nil {
return nil, nil, "", false, err
}
return arc, bodies, arc.Rev, want != service.ApprovedRev, nil
}
func address(arc *doc.Archive, id, path string) (*doc.Page, bool, error) {
id = strings.TrimSpace(id)
path = strings.TrimSpace(path)
switch {
case id == "" && path == "":
return nil, false, errors.New("give either id or path: a document has to be addressed")
case id != "" && path != "":
return nil, false, fmt.Errorf("give either id or path, not both: %q and %q cannot both name the document wanted", id, path)
}
if path != "" {
if err := core.ValidateDocPath(path); err != nil {
return nil, false, fmt.Errorf("path: %w", err)
}
page, ok := arc.ByPath(path)
return page, ok, nil
}
if page, ok := arc.Page(id); ok {
return page, true, nil
}
// Nothing is addressed by that id. Saying only "no such document" would
// hide the one case a caller cannot otherwise diagnose: an id two documents
// claim resolves to neither of them, and the collision is invisible from
// outside the space.
var claimed []string
for _, p := range arc.All() {
if p.DocID == id {
claimed = append(claimed, p.Path)
}
}
if len(claimed) > 1 {
return nil, false, fmt.Errorf("id: %q is claimed by %d documents (%s) and resolves to none of them; address one of them by path instead",
id, len(claimed), strings.Join(claimed, ", "))
}
return nil, false, nil
}
func spaceModel(sp *service.Space) *model.Space {
return &model.Space{
Owner: sp.Ref.Owner,
Name: sp.Ref.Name,
Ref: sp.Ref.String(),
Created: sp.Created,
}
}
func spaceModels(spaces []*service.Space) []*model.Space {
out := make([]*model.Space, 0, len(spaces))
for _, sp := range spaces {
out = append(out, spaceModel(sp))
}
return out
}
func projectModel(ref core.ProjectRef) *model.Project {
return &model.Project{
Owner: ref.Owner,
Name: ref.Name,
Ref: ref.String(),
Meta: ref.IsMeta(),
}
}
func documentModel(space core.SpaceRef, rev string, pinned bool, page *doc.Page, body []byte) *model.Document {
front, _ := doc.ParseFront(body)
return &model.Document{
Space: space.String(),
ID: page.ID,
DocID: nilIfEmpty(page.DocID),
Path: page.Path,
Rev: rev,
Blob: page.Blob,
Pinned: pinned,
Title: page.Title,
Status: string(page.Status),
Section: page.Section,
Summary: page.Summary,
Type: front.Type,
Supersedes: front.Supersedes,
Tags: nonNil(page.Tags),
Owners: nonNil(front.Owners),
Markdown: string(body),
}
}
func proposalModel(p Proposal) (*model.Proposal, error) {
state, err := proposalState(p.State)
if err != nil {
return nil, err
}
m := &model.Proposal{
ID: p.ID,
Space: p.Space.String(),
Title: p.Title,
Rationale: p.Rationale,
BaseRev: p.BaseRev,
Branch: p.Branch,
State: state,
MergedRev: nilIfEmpty(p.MergedRev),
Agent: p.Agent,
AgentSession: p.AgentSession,
Created: p.Created,
Resolved: p.Resolved,
}
// Approval is empty until a proposal merges, and the difference between
// human and policy approval is one a reader must be able to see: reporting
// either value for an unmerged proposal would launder unreviewed agent
// output as blessed, or claim a review that did not happen.
if p.Approval != "" {
approval, err := proposalApproval(p.Approval)
if err != nil {
return nil, err
}
m.Approval = &approval
}
return m, nil
}
func proposalState(s core.ProposalState) (model.ProposalState, error) {
out := model.ProposalState(strings.ToUpper(string(s)))
if !out.IsValid() {
return "", fmt.Errorf("proposal state %q is not one of %v", s, model.AllProposalState)
}
return out, nil
}
func proposalApproval(a core.Approval) (model.Approval, error) {
out := model.Approval(strings.ToUpper(string(a)))
if !out.IsValid() {
return "", fmt.Errorf("approval %q is not one of %v", a, model.AllApproval)
}
return out, nil
}
func spaceRef(owner, name string) (core.SpaceRef, error) {
ref := core.SpaceRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)}
if err := core.ValidateOwner(ref.Owner); err != nil {
return core.SpaceRef{}, fmt.Errorf("owner: %w", err)
}
if err := core.ValidateSpaceName(ref.Name); err != nil {
return core.SpaceRef{}, fmt.Errorf("name: %w", err)
}
return ref, nil
}
func projectRef(owner, name string) (core.ProjectRef, error) {
ref := core.ProjectRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)}
if err := core.ValidateOwner(ref.Owner); err != nil {
return core.ProjectRef{}, fmt.Errorf("owner: %w", err)
}
if ref.IsMeta() {
return ref, nil
}
if err := core.ValidateProjectName(ref.Name); err != nil {
return core.ProjectRef{}, fmt.Errorf("name: %w", err)
}
return ref, nil
}
// webhookAuthorized permits only the instance owner, and the agents acting for
// it, to manage webhooks.
//
// The AuthContext it reads is coreauth's: the graph server derives one from the
// principal /query's credential gate already admitted, and coreauth maps the
// owner and its agents — and nobody else — to AUTH_INTERNAL. So a non-INTERNAL
// method here is a caller with no authority over this instance's webhooks and is
// refused.
//
// What it does NOT check is a grant, and that is a gap rather than a decision:
// the endpoint's gate admits authn.ActionRead, so a working token carrying only
// spec:read reaches these mutations and may manage subscriptions with them. The
// identity requirement is unchanged — the token has to belong to [sr.ht]
// owner-name or authn refuses it at the door — but the grant vocabulary has no
// entry that means "manage this service's webhooks", and inventing one here
// would be a string tokens.sr.ht has never minted and no existing token carries.
// Closing it is a vocabulary change, declared in authn beside ActionRead and
// ActionPropose, and it is deliberately not done in passing.
func webhookAuthorized(ctx context.Context) error {
if auth.ForContext(ctx).AuthMethod != auth.AUTH_INTERNAL {
return coreerrors.ErrAccessDenied
}
return nil
}
func orNull(err error) error {
if errors.Is(err, service.ErrNotFound) {
return nil
}
return err
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}