Add the module mesh-mail-web, with its status verb (issue 1262)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
// The trusted setting `operators` (novox/hq ADR 0440 §3, settlement 5 of design 59 milestone 1's prepare step):
|
||||
// the Keycloak identities (the ID token's preferred_username) let in. Nobody by default; a setting that cannot
|
||||
// be read, is empty, or holds something not its own shape lets nobody in either, and says why (issue 1262/AC4).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env is where the mesh puts what the module reads, as the manifest's build env names it.
|
||||
type Env struct {
|
||||
Operators string // the trusted setting operators
|
||||
OIDC string // the oidc-client binding, rendered (issue 1262's oidc.json)
|
||||
Listen string // the port the mesh gave endpoint `web` (MESH_MAIL_WEB_LISTEN)
|
||||
Node string // this machine's own name (MESH_NODE), for the internal host mail.<node>.internal
|
||||
}
|
||||
|
||||
func envFromOS() Env {
|
||||
return Env{
|
||||
Operators: os.Getenv("MESH_MAIL_WEB_OPERATORS"),
|
||||
OIDC: os.Getenv("MESH_MAIL_WEB_OIDC"),
|
||||
Listen: os.Getenv("MESH_MAIL_WEB_LISTEN"),
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorsFile is the shape of the setting as the mesh writes it.
|
||||
type OperatorsFile struct {
|
||||
Operators []string `json:"operators"`
|
||||
}
|
||||
|
||||
// ReadOperators reads the operators setting (issue 1262/AC4): a setting missing, empty, or holding an unknown
|
||||
// field lets nobody in, and the reason is kept (never silently "everyone", never a panic).
|
||||
func ReadOperators(path string) ([]string, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("the module was started without its operators setting (MESH_MAIL_WEB_OPERATORS)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the operators setting cannot be read: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var f OperatorsFile
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("the operators setting is not the module's: %w", err)
|
||||
}
|
||||
return f.Operators, nil
|
||||
}
|
||||
|
||||
// Operators is the setting read fresh each time it is asked (never cached): it who may sign in, so a setting
|
||||
// that cannot be read grants nobody rather than whoever it last allowed.
|
||||
type Operators struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// Allows says whether identity (the ID token's preferred_username) is let in, and why not when it is refused.
|
||||
func (o Operators) Allows(identity string) (bool, string) {
|
||||
if identity == "" {
|
||||
return false, "no identity was given"
|
||||
}
|
||||
names, err := ReadOperators(o.Path)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
for _, n := range names {
|
||||
if n == identity {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf("%q is not in the setting operators", identity)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1262/AC4: the operators file missing, empty, or holding an unknown field lets nobody in, and says why.
|
||||
func TestOperatorsLetsNobodyInWhenTheSettingIsMissingEmptyOrUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
missing := Operators{Path: filepath.Join(dir, "does-not-exist.json")}
|
||||
if ok, why := missing.Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("missing: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty.json")
|
||||
writeFile(t, empty, ``)
|
||||
if ok, why := (Operators{Path: empty}).Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("empty: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
unknown := filepath.Join(dir, "unknown.json")
|
||||
writeFile(t, unknown, `{"operators": ["alice"], "surprise": true}`)
|
||||
if ok, why := (Operators{Path: unknown}).Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("unknown field: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
// A well-formed setting still lets nobody in who is not named.
|
||||
good := filepath.Join(dir, "good.json")
|
||||
writeFile(t, good, `{"operators": ["alice"]}`)
|
||||
if ok, _ := (Operators{Path: good}).Allows("bob"); ok {
|
||||
t.Fatalf("bob was let in by a setting naming only alice")
|
||||
}
|
||||
if ok, why := (Operators{Path: good}).Allows("alice"); !ok || why != "" {
|
||||
t.Fatalf("alice: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Also 1262/AC4: an empty operators list (the default) lets nobody in.
|
||||
func TestOperatorsDefaultEmptyListLetsNobodyIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "operators.json")
|
||||
writeFile(t, path, `{"operators": []}`)
|
||||
if ok, why := (Operators{Path: path}).Allows("anyone"); ok || why == "" {
|
||||
t.Fatalf("allowed=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// mesh-mail-web: the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
|
||||
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
|
||||
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
|
||||
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
|
||||
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
|
||||
// says on stderr; stdout is the MCP channel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
b := &Bundle{Env: envFromOS()}
|
||||
if err := run(b, os.Stdin, os.Stdout); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
|
||||
// (mirroring mesh-mail-notifier's own run).
|
||||
func run(b *Bundle, in io.Reader, out io.Writer) error {
|
||||
return stdio.ServeOn("", b.Tools(), in, out)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
|
||||
//
|
||||
// 1262/AC5: the repository's own way to run "the controller's manifest rules" is merge-check.sh, which shells
|
||||
// out to the real controller binary ("$MESH_GATE" module check modules/*/module.json, merge-check.sh:127) — not
|
||||
// something this package can call as a unit test, and this batch's brief refuses running merge-check.sh or any
|
||||
// integration test. mesh-mail's and mesh-mail-notifier's own manifest_test.go files do not run the controller
|
||||
// either: they assert the manifest's shape by hand, field by field, in Go. There is no unit-level way in this
|
||||
// repository to run the controller's actual module-check rules, so TestTheManifestSaysWhatTheCodeDoes below is
|
||||
// as far as the repo allows for AC5: it is the same hand-written shape assertion the sibling modules use, now
|
||||
// covering every field AC1 names. The real module check still runs once, for real, through the lead's
|
||||
// mesh-controller.try.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type listen struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
Type string `json:"type"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Default []string `json:"default"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
RunsAs string `json:"runs-as"`
|
||||
Claims []seat `json:"claims"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Reads []string `json:"reads"`
|
||||
Requires []string `json:"requires"`
|
||||
Contributes map[string]map[string]any `json:"contributes"`
|
||||
Binds map[string]string `json:"binds"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Listens []listen `json:"listens"`
|
||||
Config map[string]configEntry `json:"config"`
|
||||
Tools []string `json:"tools"`
|
||||
State []any `json:"state"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 1262/AC1 (and, as far as the repo allows, AC5 — see the file's own comment above).
|
||||
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
|
||||
if m.Module != "mesh-mail-web" {
|
||||
t.Fatalf("module %q", m.Module)
|
||||
}
|
||||
if m.RunsAs != "mesh-mail-web" {
|
||||
t.Fatalf("runs-as %q, want its own account", m.RunsAs)
|
||||
}
|
||||
wantClaims := []seat{{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}}
|
||||
if !reflect.DeepEqual(m.Claims, wantClaims) {
|
||||
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
|
||||
}
|
||||
wantInvokes := []string{
|
||||
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
|
||||
"seat:mail-store.send", "seat:mail-store.send-status",
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
|
||||
t.Fatalf("invokes %v, want exactly the five mail-store verbs %v", m.Invokes, wantInvokes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
|
||||
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
|
||||
}
|
||||
if !contains(m.Requires, "route") || !contains(m.Requires, "oidc-client") {
|
||||
t.Fatalf("requires %v, want route and oidc-client", m.Requires)
|
||||
}
|
||||
if len(m.Listens) != 1 || m.Listens[0].Name != "web" || m.Listens[0].From != "mesh" || m.Listens[0].Protocol != "tcp" {
|
||||
t.Fatalf("listens %+v, want exactly one, named web, from the mesh only", m.Listens)
|
||||
}
|
||||
op, ok := m.Config["operators"]
|
||||
if !ok || op.Type != "array" || !op.Trusted || len(op.Default) != 0 {
|
||||
t.Fatalf("config.operators %+v, want a trusted array defaulting to nobody", op)
|
||||
}
|
||||
// No database, no state bucket, no writable directory beyond the account's own state dir: only the
|
||||
// account (a user resource, not writable), the state directory, and the two files rendered into it.
|
||||
if len(m.State) != 0 {
|
||||
t.Fatalf("state %v, want none", m.State)
|
||||
}
|
||||
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
|
||||
if contains(m.Requires, dbish) {
|
||||
t.Fatalf("requires %v, want no database or object store", m.Requires)
|
||||
}
|
||||
}
|
||||
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
|
||||
if len(m.Resources) != len(wantResourceIDs) {
|
||||
t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
id := r["id"].(string)
|
||||
if !wantResourceIDs[id] {
|
||||
t.Fatalf("an unexpected resource %q: no writable directory beyond the state dir is wanted", id)
|
||||
}
|
||||
if id != "account" && r["type"] == "directory" && id != "state" {
|
||||
t.Fatalf("an extra writable directory %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest's own tools match the code's (mirroring mesh-mail's and mesh-mail-notifier's own check).
|
||||
var names []string
|
||||
for _, tool := range (&Bundle{}).Tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Tools, names) {
|
||||
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
||||
}
|
||||
|
||||
a := m.Build.Artifacts
|
||||
if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) {
|
||||
t.Fatalf("build %+v", m.Build)
|
||||
}
|
||||
for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} {
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == v {
|
||||
found = a[0].Env[k] == r["path"]
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the bundle reads %s at %s, which does not match resource %s's path", k, a[0].Env[k], v)
|
||||
}
|
||||
}
|
||||
if a[0].Env["MESH_MAIL_WEB_LISTEN"] != "${port:web}" {
|
||||
t.Fatalf("MESH_MAIL_WEB_LISTEN is %q, want the web endpoint's port", a[0].Env["MESH_MAIL_WEB_LISTEN"])
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
// The module's seat verbs (novox/hq ADR 0440 §8): mail-webclient.status (whether it serves, and which accounts
|
||||
// it is granted) and mail-webclient.open (issue 1283: the page's address). mail_web_health is its own health
|
||||
// tool, read-only, naming nothing of a page or an account.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Tool names, as the manifest declares them.
|
||||
const (
|
||||
StatusTool = "mail-webclient.status"
|
||||
HealthTool = "mail_web_health"
|
||||
)
|
||||
|
||||
// AccountsAsk is how the Bundle reaches seat:mail-store.accounts: the bus's own stdio.Ask, unless a test gives
|
||||
// its own fake (so a unit test needs no runtime).
|
||||
type AccountsAsk func(ctx context.Context) (json.RawMessage, error)
|
||||
|
||||
func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
|
||||
}
|
||||
|
||||
// Bundle is what the tools reach: the module's own settings, and the seat it calls.
|
||||
type Bundle struct {
|
||||
Env Env
|
||||
Accounts AccountsAsk
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
if b.Accounts != nil {
|
||||
return b.Accounts(ctx)
|
||||
}
|
||||
return sdkAccountsAsk(ctx)
|
||||
}
|
||||
|
||||
func (b *Bundle) now() time.Time {
|
||||
if b.Now != nil {
|
||||
return b.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Tools are the module's tools.
|
||||
func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: StatusTool,
|
||||
Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " +
|
||||
"seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.statusTool},
|
||||
{Name: HealthTool,
|
||||
Description: "How mesh-mail-web is: healthy once its own settings can be read, unhealthy naming why " +
|
||||
"otherwise. Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.healthTool},
|
||||
}
|
||||
}
|
||||
|
||||
// statusTool is issue 1262/AC2, AC3: mail-store's own accounts answer, read as the granted accounts; an ask
|
||||
// mail-store does not answer, or refuses, answers "it does not serve" and why, never a crash.
|
||||
func (b *Bundle) statusTool(map[string]any) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store does not answer: %v", err)}, nil
|
||||
}
|
||||
var answer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store's answer could not be read: %v", err)}, nil
|
||||
}
|
||||
names := make([]string, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
names = append(names, a.Account)
|
||||
}
|
||||
return map[string]any{"serves": true, "accounts": names}, nil
|
||||
}
|
||||
|
||||
func (b *Bundle) healthTool(map[string]any) (any, error) {
|
||||
if _, err := ReadOperators(b.Env.Operators); err != nil {
|
||||
return map[string]any{"healthy": false, "why": err.Error()}, nil
|
||||
}
|
||||
return map[string]any{"healthy": true}, nil
|
||||
}
|
||||
|
||||
// ---- schema helpers (mirroring mesh-mail's tools.go) -----------------------------------------------------------
|
||||
|
||||
func schema(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1262/AC2: mail-store answers accounts a and b; status answers that it serves, and is granted a and b.
|
||||
func TestStatusAnswersItServesAndTheGrantedAccounts(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"accounts": []map[string]any{
|
||||
{"account": "a", "kind": "imap"}, {"account": "b", "kind": "imap"},
|
||||
}})
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != true {
|
||||
t.Fatalf("serves: %v", m["serves"])
|
||||
}
|
||||
got, _ := m["accounts"].([]string)
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("accounts: %v", m["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1262/AC3: mail-store does not answer (or refuses); status answers that it does not serve, and why, and does
|
||||
// not panic.
|
||||
func TestStatusAnswersItDoesNotServeWhenMailStoreRefuses(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return nil, errors.New("refused: mesh-mail-web is not granted any account")
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != false {
|
||||
t.Fatalf("serves: %v", m["serves"])
|
||||
}
|
||||
why, _ := m["why"].(string)
|
||||
if why == "" {
|
||||
t.Fatalf("no reason given: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Same AC3, the other half: mail-store answers something this module cannot read.
|
||||
func TestStatusAnswersItDoesNotServeWhenMailStoresAnswerCannotBeRead(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"accounts": "not a list"}`), nil
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != false || m["why"] == "" {
|
||||
t.Fatalf("%v", m)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user