Changes
25 changed files (+917/-20)
-
-
@@ -5,7 +5,6 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/tmaxmax/go-sse v0.8.0/go.mod h1:HLoxqxdH+7oSUItjtnpxjzJedfr/+Rrm/dNWBcTxJFM= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
-
@@ -16,6 +15,8 @@ golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
-
-
-
@@ -4,17 +4,26 @@package core import ( "crypto/rand" "database/sql" "log/slog" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" "pocka.jp/x/yamori/backend/crypto" "pocka.jp/x/yamori/backend/migrations" eventsV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" workspaceEvent "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" ) type Core struct { DB *sql.DB Logger *slog.Logger } func Init(db *sql.DB) (*Core, error) { err := migrations.Run(db, []migrations.Migration{ func New(db *sql.DB, logger *slog.Logger) (*Core, error) { err := migrations.Run(db, logger, []migrations.Migration{ migrations.Migration001{}, }) if err != nil {
-
@@ -22,6 +31,77 @@ return nil, err} return &Core{ DB: db, DB: db, Logger: logger, }, nil } // TODO: Allow sepcifying admin creation password func (core *Core) Init() error { return core.generateAdminCreationPassword() } func (core *Core) generateAdminCreationPassword() error { tx, err := core.DB.Begin() if err != nil { return err } defer tx.Rollback() pw, err := projection.GetAdminCreationPassword(tx) if err != nil { return err } workspace, err := projection.GetWorkspace(tx) if err != nil { return err } if err := event.UpdateProjections(tx, pw, workspace); err != nil { return err } if pw.Projection.Password != nil { return nil } if workspace.Projection.NumberOfAdmins != nil && *workspace.Projection.NumberOfAdmins > 0 { return nil } core.Logger.Debug("Generating admin creation password...") password := rand.Text() hash, salt := crypto.SaltAndHashPassword([]byte(password)) err = event.AppendEvents(tx, []*eventsV1.Event{ { Event: &eventsV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminCreationPasswordGenerated{ AdminCreationPasswordGenerated: &workspaceEvent.AdminCreationPasswordGenerated{ PasswordHash: hash, PasswordSalt: salt, }, }, }, }, }, }) if err != nil { return err } if err := tx.Commit(); err != nil { return err } core.Logger.Info( "Generated admin creation password. Use this for initial admin user creation.", "admin_creation_password", password, ) return nil }
-
-
-
@@ -0,0 +1,105 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package event import ( "database/sql" "slices" "google.golang.org/protobuf/proto" eventsV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" ) type Event struct { Seq uint64 Payload *eventsV1.Event } func AppendEvents(tx *sql.Tx, events []*eventsV1.Event) error { stmt, err := tx.Prepare("INSERT INTO events (payload) VALUES (?)") if err != nil { return nil } for _, event := range events { b, err := proto.Marshal(event) if err != nil { return err } if _, err = stmt.Exec(b); err != nil { return err } } return nil } func listEvents(tx *sql.Tx, startSeq uint64) ([]Event, error) { rows, err := tx.Query("SELECT seq, payload FROM events WHERE seq >= ? ORDER BY seq ASC", startSeq) if err != nil { return nil, err } ret := make([]Event, 0) for rows.Next() { var seq uint64 var payload []byte if err := rows.Scan(&seq, &payload); err != nil { return nil, err } var event eventsV1.Event if err := proto.Unmarshal(payload, &event); err != nil { return nil, err } ret = append(ret, Event{ Seq: seq, Payload: &event, }) } return ret, nil } type Projection interface { EventSeq() *uint64 Update(events []Event) SaveSnapshot(tx *sql.Tx) error } func UpdateProjections(tx *sql.Tx, projections ...Projection) error { seqs := make([]uint64, 0, len(projections)) for _, p := range projections { seq := p.EventSeq() if seq == nil { continue } seqs = append(seqs, *seq) } startSeq := uint64(0) if len(seqs) > 0 { startSeq = slices.Min(seqs) } events, err := listEvents(tx, startSeq) if err != nil { return err } for _, p := range projections { p.Update(events) if err := p.SaveSnapshot(tx); err != nil { return err } } return nil }
-
-
-
@@ -0,0 +1,90 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "database/sql" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" ) type AdminCreationPassword struct { hasSnapshot bool eventSeq *uint64 Projection *workspace.AdminCreationPassword } func NewAdminCreationPassword() *AdminCreationPassword { return &AdminCreationPassword{ hasSnapshot: false, eventSeq: nil, Projection: &workspace.AdminCreationPassword{ Password: nil, }, } } func GetAdminCreationPassword(tx *sql.Tx) (*AdminCreationPassword, error) { payload, seq, err := snapshot.GetLatest[workspace.AdminCreationPassword](tx) if err != nil { return nil, err } if payload == nil { return NewAdminCreationPassword(), nil } return &AdminCreationPassword{ hasSnapshot: true, eventSeq: &seq, Projection: payload, }, nil } func (p *AdminCreationPassword) EventSeq() *uint64 { return p.eventSeq } func (p *AdminCreationPassword) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for i := range events { if p.eventSeq != nil && events[i].Seq <= *p.eventSeq { continue } ev := events[i].Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch ev := ev.Event.(type) { case *workspaceEventsv1.Event_AdminCreationPasswordGenerated: p.Projection.Password = &workspace.AdminCreationPassword_Password{ Hash: ev.AdminCreationPasswordGenerated.PasswordHash, Salt: ev.AdminCreationPasswordGenerated.PasswordSalt, } case *workspaceEventsv1.Event_AdminCreationPasswordExpired: p.Projection.Password = nil } p.eventSeq = &events[i].Seq } } func (p *AdminCreationPassword) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, p.Projection, *p.eventSeq) }
-
-
-
@@ -0,0 +1,88 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "database/sql" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" ) type Workspace struct { hasSnapshot bool eventSeq *uint64 Projection *workspace.Workspace } func NewWorkspace() *Workspace { return &Workspace{ hasSnapshot: false, eventSeq: nil, Projection: &workspace.Workspace{ NumberOfAdmins: proto.Uint32(0), }, } } func GetWorkspace(tx *sql.Tx) (*Workspace, error) { payload, seq, err := snapshot.GetLatest[workspace.Workspace](tx) if err != nil { return nil, err } if payload == nil { return NewWorkspace(), nil } return &Workspace{ hasSnapshot: true, eventSeq: &seq, Projection: payload, }, nil } func (p *Workspace) EventSeq() *uint64 { return p.eventSeq } func (p *Workspace) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for i := range events { if p.eventSeq != nil && events[i].Seq <= *p.eventSeq { continue } ev := events[i].Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch ev.Event.(type) { case *workspaceEventsv1.Event_AdminAccessGranted: p.Projection.NumberOfAdmins = proto.Uint32(*p.Projection.NumberOfAdmins + 1) case *workspaceEventsv1.Event_AdminAccessRevoked: p.Projection.NumberOfAdmins = proto.Uint32(max(0, *p.Projection.NumberOfAdmins-1)) } p.eventSeq = &events[i].Seq } } func (p *Workspace) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, p.Projection, *p.eventSeq) }
-
-
-
@@ -0,0 +1,73 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package snapshot import ( "database/sql" "google.golang.org/protobuf/proto" ) // Zig に戻りたい...。 // https://konradreiche.com/blog/a-generic-protobuf-reader-with-go-type-parameters/ type ProtoMessage[T any] interface { proto.Message *T } func GetLatest[T any, P ProtoMessage[T]](tx *sql.Tx) (P, uint64, error) { var ret P = new(T) stmt, err := tx.Prepare(` SELECT event_seq, payload FROM snapshots LEFT JOIN projections ON snapshots.projection_id = projections.id WHERE projection_name = ? ORDER BY event_seq DESC LIMIT 1 `) if err != nil { return nil, 0, err } row := stmt.QueryRow(ret.ProtoReflect().Descriptor().FullName()) var seq uint64 var payload []byte err = row.Scan(&seq, &payload) if err == sql.ErrNoRows { return nil, 0, nil } if err != nil { return nil, 0, err } if err := proto.Unmarshal(payload, ret); err != nil { return nil, 0, err } return ret, seq, nil } func Save(tx *sql.Tx, msg proto.Message, seq uint64) error { name := msg.ProtoReflect().Descriptor().FullName() payload, err := proto.Marshal(msg) if err != nil { return err } _, err = tx.Exec(` INSERT INTO projections (projection_name) SELECT ? WHERE NOT EXISTS (SELECT id FROM projections WHERE projection_name = ?); `, name, name) if err != nil { return err } _, err = tx.Exec(` INSERT INTO snapshots (event_seq, projection_id, payload) SELECT ?, id, ? FROM projections WHERE projection_name = ? LIMIT 1; `, seq, payload, name) return err }
-
-
-
@@ -0,0 +1,26 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package crypto import ( "crypto/rand" "golang.org/x/crypto/argon2" ) func HashPassword(password []byte, salt []byte) []byte { // RFC 推奨のパラメータ (Go のドキュメントに書いてあるもの) return argon2.IDKey(password, salt, 1, 64*1024, 4, 32) } // SaltAndHashPassword は自動生成したソルトでパスワードをハッシュし、 // 生成されたハッシュとソルトを返す。 func SaltAndHashPassword(password []byte) ([]byte, []byte) { salt := make([]byte, 32) // ドキュメントに書いてあるとおり `rand.Read()` はエラーを返さない。 rand.Read(salt) return HashPassword(password, salt), salt }
-
-
-
@@ -20,7 +20,9 @@ github.com/mattn/go-isatty v0.0.20 // indirectgithub.com/ncruces/go-strftime v0.1.9 // indirect github.com/nlepage/go-js-promise v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect go.akshayshah.org/memhttp v0.1.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.14.0 // indirect modernc.org/libc v1.62.1 // indirect
-
-
-
@@ -17,8 +17,14 @@ github.com/nlepage/go-wasm-http-server/v2 v2.2.1 h1:4tzhSb3HKQ3Ykt2TPfqEnmcPfw8n1E8agv4OzAyckr8=github.com/nlepage/go-wasm-http-server/v2 v2.2.1/go.mod h1:r8j7cEOeUqNp+c+C52sNuWaFTvvT/cNqIwBuEtA36HA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= go.akshayshah.org/memhttp v0.1.0 h1:Enf7JeZnm+A8iRur0FYvs4ZjWa1VVMc2gG4EirG+aNE= go.akshayshah.org/memhttp v0.1.0/go.mod h1:Q1A5oqQfj2tZFRzpw0HRmmZAMzw8f3AxqOe55Afn1d8= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
-
-
@@ -6,25 +6,31 @@import ( "database/sql" "fmt" "log" "log/slog" ) func Run(db *sql.DB, migrations []Migration) error { func Run(db *sql.DB, logger *slog.Logger, migrations []Migration) error { logger.Debug("Starting migration process") var currentVersion uint row := db.QueryRow("PRAGMA user_version") if err := row.Scan(¤tVersion); err != nil { logger.Error("Failed to get current schema version", "error", err) return err } log.Printf("Current schema version is %d", currentVersion) logger.Debug("Got current schema version", "version", currentVersion) for _, m := range migrations { v := m.Version() logger := logger.With("version", v) if v <= currentVersion { logger.Debug("Skipping already applied migration") continue } log.Printf("Running migration v%d...", v) logger.Debug("Running migration") tx, err := db.Begin() if err != nil {
-
@@ -32,11 +38,12 @@ return err} if err := m.Run(tx); err != nil { log.Printf("Failed to run migration v%d: %s", v, err) logger.Error("Failed to run migration", "error", err) return tx.Rollback() } if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", v)); err != nil { logger.Error("Failed to update database schema version", "error", err) return err } currentVersion = v
-
@@ -45,8 +52,10 @@ if err := tx.Commit(); err != nil {return err } log.Printf("Completed migration v%d", v) logger.Info("Completed migration") } logger.Debug("Migration process completed") return nil }
-
-
-
@@ -1,13 +1,16 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package main import ( "database/sql" _ "embed" "log" "log/slog" "net/http" "os" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c"
-
@@ -17,15 +20,25 @@_ "modernc.org/sqlite" ) // TODO: エラーコードを定義する func main() { // TODO: CLI 引数で JSONL ロガーと charmbracelet/log を選べるようにする logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { log.Fatal(err) logger.Error("Failed to open database", "error", err) os.Exit(2) } core, err := core.Init(db) core, err := core.New(db, logger) if err != nil { log.Fatal(err) logger.Error("Failed to create core instance", "error", err) os.Exit(3) } if err := core.Init(); err != nil { logger.Error("Failed to prepare application core", "error", err) os.Exit(4) } mux := services.Mux(core)
-
-
-
@@ -4,13 +4,22 @@package workspace import ( "bytes" "context" "crypto/rand" "net/http" "strings" "connectrpc.com/connect" "github.com/google/uuid" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" "pocka.jp/x/yamori/backend/crypto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" workspaceEvent "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" workspaceV2connect "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect"
-
@@ -80,10 +89,160 @@return connect.NewResponse(&res), nil } // TODO: 通常の追加フローも書く func (s *Service) CreateUser( ctx context.Context, req *connect.Request[workspaceV2.CreateUserRequest], ) (*connect.Response[workspaceV2.CreateUserResponse], error) { name := req.Msg.GetName() if name == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("name"), }, }, }), nil } // TODO: SystemError 以外にする if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Name cannot have heading or trailing spaces"), }, }, }), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { displayName = name } password := req.Msg.GetPassword() if password == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("password"), }, }, }), nil } // TODO: SystemError 以外にする if len(password) <= 8 { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Password has to be longer than or equals to 8 bytes"), }, }, }), nil } // TODO: チェックが煩雑なので CreateUser と別のエンドポイントにする initialAdminPassword := req.Msg.GetInitialAdminPassword() if initialAdminPassword != "" { tx, err := s.core.DB.Begin() if err != nil { return nil, err } defer tx.Rollback() ws, err := projection.GetWorkspace(tx) if err != nil { return nil, err } pw, err := projection.GetAdminCreationPassword(tx) if err != nil { return nil, err } if err := event.UpdateProjections(tx, ws, pw); err != nil { return nil, err } if pw.Projection.Password == nil { tx.Commit() return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } hash := crypto.HashPassword([]byte(initialAdminPassword), pw.Projection.Password.Salt) if !bytes.Equal(hash, pw.Projection.Password.Hash) { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } id, err := uuid.NewRandom() if err != nil { return nil, err } keyID := make([]byte, 32) rand.Read(keyID) err = event.AppendEvents(tx, []*eventV1.Event{ { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminCreationPasswordExpired{ AdminCreationPasswordExpired: &workspaceEvent.AdminCreationPasswordExpired{}, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserCreated{ UserCreated: &workspaceEvent.UserCreated{ Id: proto.String(id.String()), Name: proto.String(name), DisplayName: proto.String(displayName), KeyId: keyID, }, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminAccessGranted{ AdminAccessGranted: &workspaceEvent.AdminAccessGranted{ UserId: proto.String(id.String()), }, }, }, }, }, }) if err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_Ok{ Ok: nil, }, }), nil } res := workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_SystemError{ SystemError: &errorV1.SystemError{
-
-
-
@@ -0,0 +1,73 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "database/sql" "io" "log/slog" "reflect" "testing" "connectrpc.com/connect" "go.akshayshah.org/memhttp" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/services" _ "modernc.org/sqlite" ) func TestRejectsInvalidAdminCreationPassword(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } if err := core.Init(); err != nil { t.Fatal(err) } server, err := memhttp.New(services.Mux(core)) if err != nil { t.Fatal(err) } client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("alice"), DisplayName: proto.String("Alice"), Password: proto.String("Alice's password"), InitialAdminPassword: proto.String("^- w -^"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.CreateUserResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } }
-
-
-
@@ -7,7 +7,8 @@ package mainimport ( "database/sql" "log" "log/slog" "os" wasmhttp "github.com/nlepage/go-wasm-http-server/v2"
-
@@ -17,22 +18,33 @@_ "github.com/matrix-org/go-sqlite3-js" ) // TODO: console のメソッドを使ったロガーを実装する func main() { logger := slog.Default() db, err := sql.Open("sqlite3", "yamori.wasm") if err != nil { log.Fatal(err) logger.Error("Failed to open database", "error", err) os.Exit(2) } core, err := core.Init(db) core, err := core.New(db, slog.Default()) if err != nil { log.Fatal(err) logger.Error("Failed to create core instance", "error", err) os.Exit(3) } if err := core.Init(); err != nil { logger.Error("Failed to prepare application core", "error", err) os.Exit(4) } mux := services.Mux(core) _, err = wasmhttp.Serve(mux) if err != nil { log.Fatal(err) logger.Error("Failed to start worker server", "error", err) os.Exit(5) } // サーバがすぐ終了するのを防ぐ。 go-wasm-http-server の README には
-
-
-
@@ -0,0 +1,11 @@<!-- SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only --> # `yamori.backend` バックエンドが利用する内部スキーマ。 他のパッケージから利用されることは想定されていない。 このディレクトリ内で定義されたメッセージは認証や認可に関わるデータも含まれているため、バックエンド外に公開させてはならない。
-
-
-
@@ -0,0 +1,16 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.v1; import "yamori/backend/events/workspace/v1/event.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/v1"; message Event { oneof event { yamori.backend.events.workspace.v1.Event workspace_event = 1; } }
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminAccessGranted { string user_id = 1; }
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminAccessRevoked { string user_id = 1; }
-
-
-
@@ -0,0 +1,10 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminCreationPasswordExpired {}
-
-
-
@@ -0,0 +1,13 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminCreationPasswordGenerated { bytes password_hash = 1; bytes password_salt = 2; }
-
-
-
@@ -0,0 +1,26 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; import "yamori/backend/events/workspace/v1/admin_access_granted.proto"; import "yamori/backend/events/workspace/v1/admin_access_revoked.proto"; import "yamori/backend/events/workspace/v1/admin_creation_password_expired.proto"; import "yamori/backend/events/workspace/v1/admin_creation_password_generated.proto"; import "yamori/backend/events/workspace/v1/password_login_configured.proto"; import "yamori/backend/events/workspace/v1/user_created.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message Event { oneof event { UserCreated user_created = 1; AdminAccessGranted admin_access_granted = 2; AdminAccessRevoked admin_access_revoked = 3; PasswordLoginConfigured password_login_configured = 4; AdminCreationPasswordGenerated admin_creation_password_generated = 5; AdminCreationPasswordExpired admin_creation_password_expired = 6; } }
-
-
-
@@ -0,0 +1,14 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message PasswordLoginConfigured { string user_id = 1; bytes password_hash = 2; bytes password_salt = 3; }
-
-
-
@@ -0,0 +1,16 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserCreated { string id = 1; string name = 2; string display_name = 3; // ユーザ固有の権限キーの生成に用いるバイト列。 bytes key_id = 4; }
-
-
-
@@ -0,0 +1,18 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; message AdminCreationPassword { message Password { bytes hash = 1; bytes salt = 2; } // 存在しない場合もあるため空値を表現できるメッセージにしている。 Password password = 1; }
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; message Workspace { uint32 number_of_admins = 1; }
-