package store import ( "context" "time" "github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/auth" ) // IssueEnrolmentCode mints the code a shop PC redeems, for one site of one // client. // // The same row the provisioning command writes, hashed by the same function. // It exists as an API as well because a code is not a one-off: a PC is // replaced, reinstalled, or moved between shops, and until now every one of // those was a support ticket and an SSH session. The provisioning command // remains the bootstrap - a brand new customer has nobody to sign in as yet. // // The site id is checked against the CALLER'S client in the same statement // that inserts, so a code for another tenant's shop cannot be minted by // guessing a uuid. func (s *Store) IssueEnrolmentCode(ctx context.Context, clientID, siteID, actorID, label string, ttl time.Duration) (api.EnrolmentCode, error) { if ttl <= 0 { ttl = 7 * 24 * time.Hour } code, err := auth.NewEnrolmentCode() if err != nil { return api.EnrolmentCode{}, err } out := api.EnrolmentCode{Code: code, Label: label, SiteID: siteID} expires := time.Now().Add(ttl).UTC() err = s.pool.QueryRow(ctx, ` INSERT INTO site_enrolment_tokens (client_id, site_id, token_hash, label, expires_at, created_by) SELECT $1::uuid, si.id, $3, $4, $5, $6::uuid FROM sites si WHERE si.id = $2::uuid AND si.client_id = $1::uuid RETURNING expires_at, (SELECT name FROM sites WHERE id = $2::uuid)`, clientID, siteID, auth.HashToken(auth.NormalizeCode(code)), label, expires, nullableUUID(actorID)). Scan(&out.ExpiresAt, &out.SiteName) // pgx.ErrNoRows travels up as-is, the way RequestCheck already signals a // missing row. It means no such site FOR THIS CLIENT, and the handler turns // it into a 404: a tenant has no business learning that another tenant's // shop exists. if err != nil { return api.EnrolmentCode{}, err } return out, nil } func nullableUUID(s string) any { if s == "" { return nil } return s }