├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── app ├── config.go ├── run.go └── util.go ├── buf.gen.yaml ├── buf.work.yaml ├── client ├── client.gen.go ├── client.go └── oapicodegen │ ├── client-with-responses.tmpl │ ├── client.tmpl │ ├── gen.go │ └── oapi-codegen-config.yaml ├── cmd ├── devserver │ ├── main.go │ └── util.go ├── pgtool │ ├── Dockerfile │ └── main.go └── proxy │ ├── Dockerfile │ └── main.go ├── command ├── broker_websocket_handler.go ├── broker_websocket_handler_test.go ├── commander.go ├── commands_test.go ├── consumer_pool.go ├── proxy.go ├── proxy_http_handler.go ├── proxy_http_handler_test.go ├── proxy_http_types.go ├── stream_consumer.go ├── stream_http_handler.go ├── stream_http_handler_test.go ├── stream_http_types.go ├── stream_websocket_handler.go ├── stream_websocket_handler_test.go ├── stream_websocket_types.go ├── subscriber.go ├── types.go └── util.go ├── coro-api-spec.yaml ├── docs └── config.md ├── embedns ├── embedded_nats.go ├── embedded_nats_test.go └── resolver.go ├── encrypt ├── aes.go ├── aes_test.go └── encrypter.go ├── entity ├── account.go ├── account_test.go ├── fake_repo.go ├── helpers_test.go ├── http_auth.go ├── http_handler.go ├── http_handler_test.go ├── http_middleware.go ├── http_paginators.go ├── http_types.go ├── id.go ├── namespace.go ├── nkey.go ├── nkey_test.go ├── operator.go ├── operator_test.go ├── resolver.go ├── store.go ├── store_test.go ├── type.go ├── user.go └── user_test.go ├── errtag ├── codes.go ├── tag.go ├── tag_test.go └── types.go ├── examples ├── quickstart │ ├── README.md │ ├── config.yaml │ └── docker-compose.yaml └── scaling │ ├── README.md │ ├── config_broker_1.yaml │ ├── config_broker_2.yaml │ ├── config_controller.yaml │ ├── config_ui.yaml │ ├── docker-compose.yaml │ └── nginx.conf ├── go.mod ├── go.sum ├── img ├── logo-dark.png └── logo-light.png ├── internal ├── constants │ └── constants.go ├── testutil │ ├── fakes.go │ ├── freeport.go │ ├── http.go │ ├── kv.go │ ├── random.go │ └── testdata │ │ └── certs │ │ ├── README.md │ │ ├── ca-cert.pem │ │ ├── ca-cert.srl │ │ ├── ca-key.pem │ │ ├── client-cert.pem │ │ ├── client-key.pem │ │ ├── client.csr │ │ ├── server-cert.pem │ │ ├── server-key.pem │ │ └── server.csr └── valgoutil │ ├── error.go │ ├── error_test.go │ ├── validators.go │ └── validators_test.go ├── local_config.yaml ├── log ├── keys.go ├── logger.go └── logger_test.go ├── main.go ├── natsutil └── connect.go ├── postgres ├── conn.go ├── entity_repo.go ├── entity_repo_test.go ├── errors.go ├── gen.go ├── marshal.go ├── migrate.go ├── migrations │ ├── 0001_tables.up.sql │ ├── 0002_delete_entity_functions.up.sql │ └── fs.go ├── operator_token_rw.go ├── operator_token_rw_test.go ├── queries │ ├── account.sql │ ├── namespace.sql │ ├── nkey.sql │ ├── operator.sql │ ├── operator_token.sql │ └── user.sql ├── sqlc.yaml ├── sqlc │ ├── account.sql.go │ ├── db.go │ ├── models.go │ ├── namespace.sql.go │ ├── nkey.sql.go │ ├── operator.sql.go │ ├── operator_token.sql.go │ ├── querier.go │ └── user.sql.go └── tx.go ├── proto ├── buf.yaml ├── command │ └── v1 │ │ └── message.proto └── gen │ └── command │ └── v1 │ └── message.pb.go ├── server ├── middleware.go ├── server.go ├── server_test.go ├── types.go └── util.go ├── syncutil └── map.go ├── tests └── cluster_commands_test.go ├── tkn ├── fake.go ├── issuer.go ├── issuer_test.go ├── operator_issuer.go └── operator_issuer_test.go └── tx └── tx.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | build/ 4 | tmp/ 5 | local/ 6 | 7 | # goreleaser 8 | dist/ 9 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | concurrency: 4 5 | tests: false 6 | modules-download-mode: readonly 7 | 8 | output: 9 | formats: 10 | text: 11 | colors: true 12 | print-issued-lines: true 13 | print-linter-name: true 14 | path: stdout 15 | 16 | linters: 17 | enable: 18 | - errcheck 19 | - forbidigo 20 | - staticcheck 21 | - govet 22 | - exhaustive 23 | - ineffassign 24 | - misspell 25 | - unparam 26 | - unused 27 | - iface 28 | - importas 29 | - goconst 30 | - testifylint 31 | - whitespace 32 | 33 | settings: 34 | forbidigo: 35 | forbid: 36 | - pattern: ^fmt\.Print(f|ln)?$ 37 | misspell: 38 | locale: US 39 | unparam: 40 | check-exported: false 41 | prealloc: 42 | simple: true 43 | range-loops: true 44 | for-loops: true 45 | 46 | exclusions: 47 | rules: 48 | - linters: 49 | - errcheck 50 | source: "^\\s*defer\\s+" 51 | 52 | formatters: 53 | settings: 54 | gofmt: 55 | rewrite-rules: 56 | - pattern: 'interface{}' 57 | replacement: 'any' 58 | - pattern: 'a[b:len(a)]' 59 | replacement: 'a[b:]' 60 | 61 | goimports: 62 | local-prefixes: 63 | - github.com/coro-sh/coro -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go generate ./... 7 | 8 | builds: 9 | - id: coro 10 | dir: . 11 | binary: coro 12 | env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - darwin 17 | - windows 18 | goarch: 19 | - amd64 20 | - arm64 21 | - id: coro-proxy-agent 22 | dir: cmd/proxy 23 | binary: coro-proxy-agent 24 | env: 25 | - CGO_ENABLED=0 26 | goos: 27 | - linux 28 | - darwin 29 | - windows 30 | goarch: 31 | - amd64 32 | - arm64 33 | - id: coro-pgtool 34 | dir: cmd/pgtool 35 | binary: coro-pgtool 36 | env: 37 | - CGO_ENABLED=0 38 | goos: 39 | - linux 40 | - darwin 41 | - windows 42 | goarch: 43 | - amd64 44 | - arm64 45 | archives: 46 | - formats: [ tar.gz ] 47 | # this name template makes the OS and Arch compatible with the results of `uname`. 48 | name_template: >- 49 | {{ .ProjectName }}_ 50 | {{- title .Os }}_ 51 | {{- if eq .Arch "amd64" }}x86_64 52 | {{- else if eq .Arch "386" }}i386 53 | {{- else }}{{ .Arch }}{{ end }} 54 | {{- if .Arm }}v{{ .Arm }}{{ end }} 55 | # use zip for windows archives 56 | format_overrides: 57 | - goos: windows 58 | formats: [ zip ] 59 | 60 | changelog: 61 | sort: asc 62 | filters: 63 | exclude: 64 | - "^docs:" 65 | - "^test:" 66 | 67 | dockers: 68 | - id: coro 69 | image_templates: 70 | - "corosh/coro:{{ .Tag }}" 71 | - "corosh/coro:latest" 72 | dockerfile: Dockerfile 73 | - id: coro-proxy-agent 74 | image_templates: 75 | - "corosh/coro-proxy-agent:{{ .Tag }}" 76 | - "corosh/coro-proxy-agent:latest" 77 | dockerfile: cmd/proxy/Dockerfile 78 | - id: coro-pgtool 79 | image_templates: 80 | - "corosh/coro-pgtool:{{ .Tag }}" 81 | - "corosh/coro-pgtool:latest" 82 | dockerfile: cmd/pgtool/Dockerfile 83 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox AS builder 2 | RUN mkdir /apptemp 3 | 4 | FROM scratch 5 | ENTRYPOINT ["/coro"] 6 | COPY coro / 7 | # Broker service will create a temp NATS resolver config file in /tmp on startup 8 | COPY --from=builder /apptemp /tmp 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Functional Source License, Version 1.1, ALv2 Future License 2 | 3 | ## Abbreviation 4 | 5 | FSL-1.1-ALv2 6 | 7 | ## Notice 8 | 9 | Copyright 2025 10 | 11 | ## Terms and Conditions 12 | 13 | ### Licensor ("We") 14 | 15 | The party offering the Software under these Terms and Conditions. 16 | 17 | ### The Software 18 | 19 | The "Software" is each version of the software that we make available under 20 | these Terms and Conditions, as indicated by our inclusion of these Terms and 21 | Conditions with the Software. 22 | 23 | ### License Grant 24 | 25 | Subject to your compliance with this License Grant and the Patents, 26 | Redistribution and Trademark clauses below, we hereby grant you the right to 27 | use, copy, modify, create derivative works, publicly perform, publicly display 28 | and redistribute the Software for any Permitted Purpose identified below. 29 | 30 | ### Permitted Purpose 31 | 32 | A Permitted Purpose is any purpose other than a Competing Use. A Competing Use 33 | means making the Software available to others in a commercial product or 34 | service that: 35 | 36 | 1. substitutes for the Software; 37 | 38 | 2. substitutes for any other product or service we offer using the Software 39 | that exists as of the date we make the Software available; or 40 | 41 | 3. offers the same or substantially similar functionality as the Software. 42 | 43 | Permitted Purposes specifically include using the Software: 44 | 45 | 1. for your internal use and access; 46 | 47 | 2. for non-commercial education; 48 | 49 | 3. for non-commercial research; and 50 | 51 | 4. in connection with professional services that you provide to a licensee 52 | using the Software in accordance with these Terms and Conditions. 53 | 54 | ### Patents 55 | 56 | To the extent your use for a Permitted Purpose would necessarily infringe our 57 | patents, the license grant above includes a license under our patents. If you 58 | make a claim against any party that the Software infringes or contributes to 59 | the infringement of any patent, then your patent license to the Software ends 60 | immediately. 61 | 62 | ### Redistribution 63 | 64 | The Terms and Conditions apply to all copies, modifications and derivatives of 65 | the Software. 66 | 67 | If you redistribute any copies, modifications or derivatives of the Software, 68 | you must include a copy of or a link to these Terms and Conditions and not 69 | remove any copyright notices provided in or with the Software. 70 | 71 | ### Disclaimer 72 | 73 | THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR 74 | IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR 75 | PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. 76 | 77 | IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE 78 | SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, 79 | EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. 80 | 81 | ### Trademarks 82 | 83 | Except for displaying the License Details and identifying us as the origin of 84 | the Software, you have no right under these Terms and Conditions to use our 85 | trademarks, trade names, service marks or product names. 86 | 87 | ## Grant of Future License 88 | 89 | We hereby irrevocably grant you an additional license to use the Software under 90 | the Apache License, Version 2.0 that is effective on the second anniversary of 91 | the date we make the Software available. On or after that date, you may use the 92 | Software under the Apache License, Version 2.0, in which case the following 93 | will apply: 94 | 95 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 96 | this file except in compliance with the License. 97 | 98 | You may obtain a copy of the License at 99 | 100 | http://www.apache.org/licenses/LICENSE-2.0 101 | 102 | Unless required by applicable law or agreed to in writing, software distributed 103 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 104 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 105 | specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROTO_FILES := $(wildcard proto/*) 2 | BUF_VERSION := 1.54.0 3 | 4 | .PHONY:run 5 | run: 6 | go run main.go --service all --config local/config_all.yaml 7 | 8 | .PHONY: test 9 | unit: 10 | go test ./... -race -count=1 11 | 12 | # SQLC 13 | 14 | .PHONY: sqlc-gen 15 | sqlc-gen: 16 | go generate postgres/gen.go 17 | 18 | 19 | # Postgres 20 | 21 | start-postgres: 22 | docker run --name coro-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:17 23 | go run cmd/pgtool/main.go --user postgres --password postgres init 24 | 25 | stop-postgres: 26 | docker stop coro-postgres && docker rm -f coro-postgres 27 | 28 | # Buf 29 | 30 | .PHONY: buf-format 31 | buf-format: $(PROTO_FILES) 32 | docker run -v $$(pwd):/srv -w /srv bufbuild/buf:$(BUF_VERSION) format -w 33 | 34 | .PHONY: buf-lint 35 | buf-lint: $(PROTO_FILES) 36 | docker run -v $$(pwd):/srv -w /srv bufbuild/buf:$(BUF_VERSION) lint 37 | 38 | .PHONY: buf-gen 39 | buf-gen: $(PROTO_FILES) buf-format buf-lint 40 | rm -rf **/gen/ 41 | docker run -v $$(pwd):/srv -w /srv bufbuild/buf:$(BUF_VERSION) generate 42 | 43 | # OpenAPI 44 | 45 | .PHONY: client-gen 46 | client-gen: 47 | go generate -run "oapi-codegen" ./client/oapicodegen/gen.go 48 | 49 | # Dev Server 50 | 51 | .PHONY: dev-server 52 | # Usage: make dev-server CORS_ORIGINS="http://localhost:8080 http://localhost:5173" 53 | dev-server: 54 | go run ./cmd/devserver $(foreach origin,$(CORS_ORIGINS),-cors-origin $(origin)) 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | 5 | 6 | Coro logo 7 | 8 |

9 |
10 | 11 | **Coro** is a platform that makes it simple to issue and manage Operators, Accounts, and Users 12 | for [NATS](https://nats.io) servers. 13 | 14 | It is distributed as a single binary but consists of modular services that can be deployed independently: 15 | 16 | - **Controller**: Issues and manages Operators, Accounts, and Users. 17 | - **Broker**: Facilitates messaging between the **Controller** and connected Operator NATS servers. 18 | - **UI**: Web user interface for the **Controller**. 19 | 20 | In addition to its core services, Coro provides the following tools: 21 | 22 | - **`pgtool`**: A CLI tool for initializing and managing the Coro Postgres database. 23 | - **`proxy-agent`**: A **Proxy Agent** that connects Operator NATS servers to the **Broker**. 24 | 25 | ## Getting started 26 | 27 | ### Demo 28 | 29 | https://github.com/user-attachments/assets/63bdafb0-a45f-4494-a13f-699f7f46b14b 30 | 31 | ### Quickstart 32 | 33 | The fastest way to get started is by running Coro in the all-in-one mode. Follow 34 | the [quickstart example](examples/quickstart) to run Coro using Docker Compose. 35 | 36 | ### Scaling 37 | 38 | The quickstart example runs an all-in-one Coro server using flag `-service=all`. For a high-availability setup, you can 39 | run the **Controller**, **Broker**, and **UI** services separately, allowing you to scale them independently. 40 | 41 | See the [scaling example](examples/scaling/) for a simple Docker and Nginx based setup. 42 | 43 | ### Configuration 44 | 45 | Refer to the [configuration guide](docs/config.md) for a full list of configuration options. 46 | 47 | ## Disclaimer 48 | 49 | Coro is under active development and may undergo significant changes. While it is available for exploration and testing, 50 | it is not recommended for production use at this time. Features may be incomplete, and breaking changes may occur as the 51 | platform is improved. 52 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | managed: 3 | enabled: true 4 | go_package_prefix: 5 | default: github.com/coro-sh/coro/proto/gen 6 | except: 7 | - buf.build/googleapis/googleapis 8 | 9 | plugins: 10 | # Go 11 | - plugin: buf.build/protocolbuffers/go:v1.36.6 12 | out: proto/gen 13 | opt: 14 | - paths=source_relative 15 | - plugin: buf.build/connectrpc/go:v1.18.1 16 | out: proto/gen 17 | opt: paths=source_relative 18 | -------------------------------------------------------------------------------- /buf.work.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | directories: 3 | - proto 4 | -------------------------------------------------------------------------------- /client/oapicodegen/gen.go: -------------------------------------------------------------------------------- 1 | //go:generate go tool oapi-codegen -config oapi-codegen-config.yaml ../../coro-api-spec.yaml 2 | package oapicodegen 3 | -------------------------------------------------------------------------------- /client/oapicodegen/oapi-codegen-config.yaml: -------------------------------------------------------------------------------- 1 | package: client 2 | generate: 3 | models: true 4 | client: true 5 | output: ../client.gen.go 6 | output-options: 7 | client-type-name: oapiClient 8 | user-templates: 9 | client.tmpl: client.tmpl 10 | client-with-responses.tmpl: client-with-responses.tmpl -------------------------------------------------------------------------------- /cmd/pgtool/Dockerfile: -------------------------------------------------------------------------------- 1 | # Used by goreleaser 2 | FROM scratch 3 | ENTRYPOINT ["/coro-pgtool"] 4 | COPY coro-pgtool / 5 | -------------------------------------------------------------------------------- /cmd/proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | # Used by goreleaser 2 | FROM scratch 3 | ENTRYPOINT ["/coro-proxy-agent"] 4 | COPY coro-proxy-agent / 5 | -------------------------------------------------------------------------------- /command/broker_websocket_handler_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/coro-sh/coro/entity" 15 | "github.com/coro-sh/coro/internal/testutil" 16 | commandv1 "github.com/coro-sh/coro/proto/gen/command/v1" 17 | "github.com/coro-sh/coro/server" 18 | "github.com/coro-sh/coro/tkn" 19 | ) 20 | 21 | func TestWebsocketForwardsCommandsAndReplies(t *testing.T) { 22 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout) 23 | defer cancel() 24 | 25 | store := entity.NewStore(new(testutil.FakeTxer), entity.NewFakeEntityRepository(t)) 26 | op, sysAcc, sysUsr := setupEntities(ctx, t, store) 27 | tknIss := tkn.NewOperatorIssuer(tkn.NewFakeOperatorTokenReadWriter(t), tkn.OperatorTokenTypeProxy) 28 | token, err := tknIss.Generate(ctx, op.ID) 29 | require.NoError(t, err) 30 | 31 | acc, err := entity.NewAccount(testutil.RandName(), op) 32 | require.NoError(t, err) 33 | accData, err := acc.Data() 34 | require.NoError(t, err) 35 | 36 | brokerNats := startEmbeddedNATS(t, op, sysAcc, "broker_nats") 37 | defer brokerNats.Shutdown() 38 | 39 | brokerHandler, err := NewBrokerWebSocketHandler(sysUsr, brokerNats, tknIss, store) 40 | require.NoError(t, err) 41 | 42 | srv, err := server.NewServer(testutil.GetFreePort(t)) 43 | require.NoError(t, err) 44 | srv.Register(brokerHandler) 45 | 46 | go srv.Start() 47 | err = srv.WaitHealthy(10, time.Millisecond) 48 | require.NoError(t, err) 49 | defer srv.Stop(ctx) 50 | 51 | subbed := make(chan struct{}) 52 | stopSub := make(chan struct{}) 53 | 54 | wg := new(sync.WaitGroup) 55 | go func() { 56 | wg.Add(1) 57 | defer wg.Done() 58 | 59 | wsURL := fmt.Sprintf("%s/api%s/broker", srv.WebsSocketAddress(), entity.VersionPath) 60 | sub, err := NewCommandSubscriber(ctx, wsURL, token) 61 | require.NoError(t, err) 62 | defer sub.Unsubscribe() 63 | 64 | sub.Subscribe(ctx, func(msg *commandv1.PublishMessage, replier SubscriptionReplier) error { 65 | gotReq := msg.GetRequest() 66 | assert.NotEmpty(t, msg.Id) 67 | assert.NotEmpty(t, msg.CommandReplyInbox) 68 | assert.Equal(t, accData.JWT, string(gotReq.Data)) 69 | assert.Equal(t, fmt.Sprintf(accClaimsUpdateSubjectFormat, accData.PublicKey), gotReq.Subject) 70 | 71 | wantReply, err := json.Marshal(accountUpdateReplyMessage{ 72 | Data: natsAccountMessageData{ 73 | Code: 200, 74 | Account: accData.PublicKey, 75 | Message: "jwt updated", 76 | }, 77 | }) 78 | require.NoError(t, err) 79 | 80 | return replier(ctx, &commandv1.ReplyMessage{ 81 | Id: msg.Id, 82 | Inbox: msg.CommandReplyInbox, 83 | Data: wantReply, 84 | }) 85 | }) 86 | close(subbed) 87 | 88 | recvCtx(t, ctx, stopSub) 89 | }() 90 | 91 | commander, err := NewCommander(brokerNats.ClientURL(), sysUsr) 92 | require.NoError(t, err) 93 | 94 | recvCtx(t, ctx, subbed) 95 | 96 | require.Equal(t, int64(1), brokerHandler.NumConnections()) 97 | 98 | status, err := commander.Ping(ctx, op.ID) 99 | require.NoError(t, err) 100 | assert.True(t, status.Connected) 101 | assert.Positive(t, *status.ConnectTime) 102 | 103 | err = commander.NotifyAccountClaimsUpdate(ctx, acc) 104 | require.NoError(t, err) 105 | 106 | close(stopSub) 107 | wg.Wait() 108 | require.Equal(t, int64(0), brokerHandler.NumConnections()) 109 | 110 | } 111 | 112 | func recvCtx[T any](t *testing.T, ctx context.Context, ch <-chan T) T { 113 | select { 114 | case got := <-ch: 115 | return got 116 | case <-ctx.Done(): 117 | require.Fail(t, "subscriber timeout") 118 | } 119 | return *new(T) 120 | } 121 | -------------------------------------------------------------------------------- /command/consumer_pool.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/nats-io/nats.go/jetstream" 9 | 10 | "github.com/coro-sh/coro/errtag" 11 | "github.com/coro-sh/coro/natsutil" 12 | "github.com/coro-sh/coro/syncutil" 13 | ) 14 | 15 | const ( 16 | maxConsumerIdleHeartbeat = 30 * time.Second 17 | consumerHeartbeatInterval = maxConsumerIdleHeartbeat / 2 18 | ) 19 | 20 | var errConsumerNotFound = errtag.NewTagged[errtag.NotFound]("consumer not found") 21 | 22 | type consumerEntry struct { 23 | heartbeats chan struct{} 24 | stop chan struct{} 25 | } 26 | 27 | type ConsumerPool struct { 28 | natsURL string 29 | consumers *syncutil.Map[string, *consumerEntry] 30 | maxIdleHeartbeat time.Duration 31 | } 32 | 33 | func NewConsumerPool(natsURL string) *ConsumerPool { 34 | return &ConsumerPool{ 35 | natsURL: natsURL, 36 | consumers: syncutil.NewMap[string, *consumerEntry](), 37 | maxIdleHeartbeat: maxConsumerIdleHeartbeat, 38 | } 39 | } 40 | 41 | func (c *ConsumerPool) StartConsumer( 42 | ctx context.Context, 43 | streamName string, 44 | startSeq uint64, 45 | consumerID string, 46 | userJWT string, 47 | userSeed string, 48 | handler func(jsMsg jetstream.Msg, cerr error), 49 | ) error { 50 | if c.consumers.Len() >= maxConcurrentConsumers { 51 | return fmt.Errorf("max concurrent jetstream consumers reached (%d): stop a consumer before trying again", maxConcurrentConsumers) 52 | } 53 | 54 | consumerNC, err := natsutil.Connect(c.natsURL, userJWT, userSeed) 55 | if err != nil { 56 | return fmt.Errorf("connect to nats server using consumer creds: %w", err) 57 | } 58 | 59 | js, err := jetstream.New(consumerNC) 60 | if err != nil { 61 | return fmt.Errorf("create consumer jetstream client: %w", err) 62 | } 63 | 64 | jsc, err := js.CreateOrUpdateConsumer(ctx, streamName, jetstream.ConsumerConfig{ 65 | DeliverPolicy: jetstream.DeliverByStartSequencePolicy, 66 | AckPolicy: jetstream.AckNonePolicy, 67 | OptStartSeq: startSeq, 68 | MaxDeliver: 1, // safeguard: shouldn't matter since we are using ack none policy 69 | }) 70 | if err != nil { 71 | return fmt.Errorf("create jetstream consumer: %w", err) 72 | } 73 | 74 | cctx, err := jsc.Consume(func(msg jetstream.Msg) { 75 | handler(msg, nil) 76 | }, jetstream.PullMaxMessages(100)) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | consumer := &consumerEntry{ 82 | heartbeats: make(chan struct{}, 1), 83 | stop: make(chan struct{}), 84 | } 85 | 86 | c.consumers.Set(consumerID, consumer) 87 | 88 | go func() { 89 | defer func() { 90 | c.consumers.Delete(consumerID) 91 | cctx.Stop() 92 | consumerNC.Close() 93 | }() 94 | for { 95 | select { 96 | case <-consumer.heartbeats: 97 | // consumer is still active - nop 98 | case <-consumer.stop: 99 | return 100 | case <-time.After(c.maxIdleHeartbeat): 101 | handler(nil, fmt.Errorf("consumer idle heartbeat timeout exceeded (%s)", c.maxIdleHeartbeat)) 102 | return 103 | } 104 | } 105 | }() 106 | 107 | return nil 108 | } 109 | 110 | func (c *ConsumerPool) SendConsumerHeartbeat(consumerID string) error { 111 | consumer, ok := c.consumers.Get(consumerID) 112 | if !ok { 113 | return errConsumerNotFound 114 | } 115 | select { 116 | case consumer.heartbeats <- struct{}{}: 117 | default: 118 | } 119 | return nil 120 | } 121 | 122 | func (c *ConsumerPool) StopConsumer(consumerID string) error { 123 | consumer, ok := c.consumers.Get(consumerID) 124 | if !ok { 125 | return errConsumerNotFound 126 | } 127 | close(consumer.stop) 128 | return nil 129 | } 130 | 131 | func (c *ConsumerPool) StopAll() error { 132 | for _, consumer := range c.consumers.Keys() { 133 | if err := c.StopConsumer(consumer); err != nil { 134 | return err 135 | } 136 | } 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /command/proxy_http_handler.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/labstack/echo/v4" 10 | 11 | "github.com/coro-sh/coro/entity" 12 | "github.com/coro-sh/coro/log" 13 | "github.com/coro-sh/coro/server" 14 | "github.com/coro-sh/coro/tkn" 15 | ) 16 | 17 | type OperatorReader interface { 18 | // ReadOperator reads an Operator by its ID. 19 | ReadOperator(ctx context.Context, id entity.OperatorID) (*entity.Operator, error) 20 | } 21 | 22 | type Pinger interface { 23 | // Ping checks if Operator's have an open proxy connection a NATS server. 24 | Ping(ctx context.Context, operatorID entity.OperatorID) (entity.OperatorNATSStatus, error) 25 | } 26 | 27 | // ProxyHTTPHandler handles proxy related HTTP requests. 28 | type ProxyHTTPHandler struct { 29 | iss *tkn.OperatorIssuer 30 | opReader OperatorReader 31 | pinger Pinger 32 | } 33 | 34 | // NewProxyHTTPHandler creates a new ProxyHTTPHandler. 35 | func NewProxyHTTPHandler(iss *tkn.OperatorIssuer, operators OperatorReader, pinger Pinger) *ProxyHTTPHandler { 36 | return &ProxyHTTPHandler{ 37 | iss: iss, 38 | opReader: operators, 39 | pinger: pinger, 40 | } 41 | } 42 | 43 | func (h *ProxyHTTPHandler) Register(g *echo.Group) { 44 | v1 := g.Group(entity.VersionPath) 45 | v1.POST(fmt.Sprintf("/namespaces/:%s/operators/:%s/proxy/token", entity.PathParamNamespaceID, entity.PathParamOperatorID), h.GenerateProxyToken) 46 | v1.GET(fmt.Sprintf("/namespaces/:%s/operators/:%s/proxy/status", entity.PathParamNamespaceID, entity.PathParamOperatorID), h.GetProxyStatus) 47 | } 48 | 49 | // GenerateProxyToken handles POST requests to generate a Proxy token for an 50 | // Operator, which is used to authorize a NATS proxy WebSocket connection. 51 | // Only one token can be active at a given time. Generating a new a token wil 52 | // overwrite and invalidate any pre-existing token. 53 | func (h *ProxyHTTPHandler) GenerateProxyToken(c echo.Context) error { 54 | ctx := c.Request().Context() 55 | 56 | req, err := server.BindRequest[GenerateProxyTokenRequest](c) 57 | if err != nil { 58 | return err 59 | } 60 | opID := entity.MustParseID[entity.OperatorID](req.OperatorID) 61 | c.Set(log.KeyOperatorID, opID) 62 | 63 | op, err := h.opReader.ReadOperator(ctx, opID) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if err = entity.VerifyEntityNamespace(c, op); err != nil { 69 | return err 70 | } 71 | 72 | token, err := h.iss.Generate(ctx, opID) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return server.SetResponse(c, http.StatusCreated, &GenerateProxyTokenResponse{ 78 | Token: token, 79 | }) 80 | } 81 | 82 | // GetProxyStatus handles GET requests to check if an Operator has an active 83 | // Proxy connection open between the Broker WebSocket server and the Operator's 84 | // NATS server. 85 | func (h *ProxyHTTPHandler) GetProxyStatus(c echo.Context) error { 86 | ctx := c.Request().Context() 87 | 88 | req, err := server.BindRequest[GetProxyStatusRequest](c) 89 | if err != nil { 90 | return err 91 | } 92 | opID := entity.MustParseID[entity.OperatorID](req.OperatorID) 93 | c.Set(log.KeyOperatorID, opID) 94 | 95 | op, err := h.opReader.ReadOperator(ctx, opID) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | if err = entity.VerifyEntityNamespace(c, op); err != nil { 101 | return err 102 | } 103 | 104 | pctx, cancel := context.WithTimeout(ctx, 5*time.Second) 105 | defer cancel() 106 | 107 | opStatus, err := h.pinger.Ping(pctx, op.ID) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | return server.SetResponse(c, http.StatusOK, opStatus) 113 | } 114 | -------------------------------------------------------------------------------- /command/proxy_http_handler_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/coro-sh/coro/entity" 13 | "github.com/coro-sh/coro/internal/testutil" 14 | "github.com/coro-sh/coro/server" 15 | "github.com/coro-sh/coro/tkn" 16 | ) 17 | 18 | func TestHTTPHandler_GenerateToken(t *testing.T) { 19 | ctx := context.Background() 20 | 21 | tknIssuer := tkn.NewOperatorIssuer(tkn.NewFakeOperatorTokenReadWriter(t), tkn.OperatorTokenTypeProxy) 22 | entityStore := entity.NewStore(new(testutil.FakeTxer), entity.NewFakeEntityRepository(t)) 23 | 24 | srv, err := server.NewServer(testutil.GetFreePort(t), server.WithMiddleware( 25 | entity.NamespaceContextMiddleware(), 26 | )) 27 | require.NoError(t, err) 28 | srv.Register(NewProxyHTTPHandler(tknIssuer, entityStore, new(pingerStub))) 29 | go srv.Start() 30 | err = srv.WaitHealthy(10, time.Millisecond) 31 | require.NoError(t, err) 32 | 33 | op, err := entity.NewOperator(testutil.RandName(), entity.NewID[entity.NamespaceID]()) 34 | require.NoError(t, err) 35 | err = entityStore.CreateOperator(ctx, op) 36 | require.NoError(t, err) 37 | 38 | url := fmt.Sprintf("%s%s%s/namespaces/%s/operators/%s/proxy/token", srv.Address(), server.DefaultPathPrefix, entity.VersionPath, op.NamespaceID, op.ID) 39 | 40 | res := testutil.Post[server.Response[GenerateProxyTokenResponse]](t, url, nil) 41 | got := res.Data 42 | 43 | opID, err := tknIssuer.Verify(ctx, got.Token) 44 | require.NoError(t, err) 45 | assert.Equal(t, op.ID, opID) 46 | } 47 | 48 | func TestHTTPHandler_GetStatus(t *testing.T) { 49 | ctx := context.Background() 50 | 51 | tknIssuer := tkn.NewOperatorIssuer(tkn.NewFakeOperatorTokenReadWriter(t), tkn.OperatorTokenTypeProxy) 52 | entityStore := entity.NewStore(new(testutil.FakeTxer), entity.NewFakeEntityRepository(t)) 53 | 54 | srv, err := server.NewServer(testutil.GetFreePort(t), server.WithMiddleware( 55 | entity.NamespaceContextMiddleware(), 56 | )) 57 | require.NoError(t, err) 58 | srv.Register(NewProxyHTTPHandler(tknIssuer, entityStore, new(pingerStub))) 59 | go srv.Start() 60 | err = srv.WaitHealthy(10, time.Millisecond) 61 | require.NoError(t, err) 62 | 63 | op, err := entity.NewOperator(testutil.RandName(), entity.NewID[entity.NamespaceID]()) 64 | require.NoError(t, err) 65 | err = entityStore.CreateOperator(ctx, op) 66 | require.NoError(t, err) 67 | 68 | url := fmt.Sprintf("%s%s%s/namespaces/%s/operators/%s/proxy/status", srv.Address(), server.DefaultPathPrefix, entity.VersionPath, op.NamespaceID, op.ID) 69 | 70 | res := testutil.Get[server.Response[GetProxyStatusResponse]](t, url) 71 | got := res.Data 72 | 73 | assert.True(t, got.Connected) 74 | } 75 | 76 | type pingerStub struct{} 77 | 78 | func (p *pingerStub) Ping(_ context.Context, _ entity.OperatorID) (entity.OperatorNATSStatus, error) { 79 | connectTime := time.Now().Unix() 80 | return entity.OperatorNATSStatus{ 81 | Connected: true, 82 | ConnectTime: &connectTime, 83 | }, nil 84 | } 85 | -------------------------------------------------------------------------------- /command/proxy_http_types.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/cohesivestack/valgo" 5 | 6 | "github.com/coro-sh/coro/entity" 7 | ) 8 | 9 | type GenerateProxyTokenRequest struct { 10 | OperatorID string `param:"operator_id" json:"-"` 11 | } 12 | 13 | func (r GenerateProxyTokenRequest) Validate() error { 14 | return valgo.In("params", 15 | valgo.Is(entity.IDValidator[entity.OperatorID](r.OperatorID, entity.PathParamOperatorID)), 16 | ).Error() 17 | } 18 | 19 | type GenerateProxyTokenResponse struct { 20 | Token string `json:"token"` 21 | } 22 | 23 | type GetProxyStatusRequest struct { 24 | OperatorID string `param:"operator_id" json:"-"` 25 | } 26 | 27 | func (r GetProxyStatusRequest) Validate() error { 28 | return valgo.In("params", 29 | valgo.Is(entity.IDValidator[entity.OperatorID](r.OperatorID, entity.PathParamOperatorID)), 30 | ).Error() 31 | } 32 | 33 | type GetProxyStatusResponse struct { 34 | Connected bool `json:"connected"` 35 | } 36 | -------------------------------------------------------------------------------- /command/stream_consumer.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/nats-io/nats.go" 9 | "google.golang.org/protobuf/proto" 10 | 11 | "github.com/coro-sh/coro/entity" 12 | "github.com/coro-sh/coro/log" 13 | commandv1 "github.com/coro-sh/coro/proto/gen/command/v1" 14 | ) 15 | 16 | type streamConsumer struct { 17 | nc *nats.Conn 18 | operatorID entity.OperatorID 19 | consumerUser *entity.User 20 | streamName string 21 | 22 | started bool 23 | consumerID string 24 | replySub *nats.Subscription 25 | logger log.Logger 26 | } 27 | 28 | func newStreamConsumer(nc *nats.Conn, operatorID entity.OperatorID, consumerUser *entity.User, streamName string) *streamConsumer { 29 | return &streamConsumer{ 30 | nc: nc, 31 | operatorID: operatorID, 32 | consumerUser: consumerUser, 33 | streamName: streamName, 34 | consumerID: NewStreamConsumerID().String(), 35 | logger: log.NewLogger(), 36 | } 37 | } 38 | 39 | func (c *streamConsumer) ID() string { 40 | return c.consumerID 41 | } 42 | 43 | func (c *streamConsumer) Start(startSeq uint64, handler func(msg *commandv1.ReplyMessage)) error { 44 | if c.started { 45 | return errors.New("consumer already started") 46 | } 47 | if startSeq == 0 { 48 | startSeq = 1 49 | } 50 | 51 | msg := &commandv1.PublishMessage{ 52 | Id: NewMessageID().String(), 53 | CommandReplyInbox: nats.NewInbox(), 54 | Command: &commandv1.PublishMessage_StartStreamConsumer{ 55 | StartStreamConsumer: &commandv1.PublishMessage_CommandStartStreamConsumer{ 56 | UserCreds: &commandv1.Credentials{ 57 | Jwt: c.consumerUser.JWT(), 58 | Seed: string(c.consumerUser.NKey().Seed), 59 | }, 60 | ConsumerId: c.consumerID, 61 | StreamName: c.streamName, 62 | StartSequence: startSeq, 63 | }, 64 | }, 65 | } 66 | msgb, err := proto.Marshal(msg) 67 | if err != nil { 68 | return fmt.Errorf("marshal message: %w", err) 69 | } 70 | 71 | c.replySub, err = c.nc.Subscribe(msg.CommandReplyInbox, func(msg *nats.Msg) { 72 | replyMsg := &commandv1.ReplyMessage{} 73 | if err := proto.Unmarshal(msg.Data, replyMsg); err != nil { 74 | c.logger.Error("failed to unmarshal reply message", "error", err) 75 | return 76 | } 77 | handler(replyMsg) 78 | }) 79 | if err != nil { 80 | return fmt.Errorf("subscribe consumer reply inbox: %w", err) 81 | } 82 | 83 | if err = c.nc.PublishRequest(getOperatorSubject(c.operatorID), msg.CommandReplyInbox, msgb); err != nil { 84 | err = fmt.Errorf("publish message: %w", err) 85 | return errors.Join(err, c.replySub.Unsubscribe()) 86 | } 87 | 88 | c.started = true 89 | return nil 90 | } 91 | 92 | func (c *streamConsumer) SendHeartbeat(ctx context.Context) error { 93 | if !c.started { 94 | return errors.New("consumer not started") 95 | } 96 | _, err := command(ctx, c.nc, c.operatorID, &commandv1.PublishMessage{ 97 | Id: NewMessageID().String(), 98 | CommandReplyInbox: NewMessageID().String(), 99 | Command: &commandv1.PublishMessage_SendStreamConsumerHeartbeat{ 100 | SendStreamConsumerHeartbeat: &commandv1.PublishMessage_CommandSendStreamConsumerHeartbeat{ 101 | ConsumerId: c.consumerID, 102 | }, 103 | }, 104 | }) 105 | return err 106 | } 107 | 108 | func (c *streamConsumer) Stop(ctx context.Context) error { 109 | if !c.started { 110 | return nil 111 | } 112 | if c.replySub != nil { 113 | if err := c.replySub.Unsubscribe(); err != nil { 114 | return fmt.Errorf("unsubscribe consumer reply inbox: %w", err) 115 | } 116 | c.replySub = nil 117 | } 118 | 119 | msgb, err := command(ctx, c.nc, c.operatorID, &commandv1.PublishMessage{ 120 | Id: NewMessageID().String(), 121 | CommandReplyInbox: NewMessageID().String(), 122 | Command: &commandv1.PublishMessage_StopStreamConsumer{ 123 | StopStreamConsumer: &commandv1.PublishMessage_CommandStopStreamConsumer{ 124 | ConsumerId: c.consumerID, 125 | }, 126 | }, 127 | }) 128 | if err != nil { 129 | return err 130 | } 131 | replyMsg := &commandv1.ReplyMessage{} 132 | if err := proto.Unmarshal(msgb, replyMsg); err != nil { 133 | return fmt.Errorf("unmarshal reply message: %w", err) 134 | } 135 | 136 | if replyMsg.Error != nil && *replyMsg.Error != errConsumerNotFound.Error() { 137 | return fmt.Errorf("reply: %s", *replyMsg.Error) 138 | } 139 | 140 | c.started = false 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /command/stream_http_types.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/cohesivestack/valgo" 5 | 6 | "github.com/coro-sh/coro/entity" 7 | ) 8 | 9 | type ListStreamsRequest struct { 10 | AccountID string `param:"account_id" json:"-"` 11 | } 12 | 13 | func (r ListStreamsRequest) Validate() error { 14 | return valgo.In("params", valgo.Is( 15 | entity.IDValidator[entity.AccountID](r.AccountID, entity.PathParamAccountID), 16 | )).Error() 17 | } 18 | 19 | type GetStreamRequest struct { 20 | AccountID string `param:"account_id" json:"-"` 21 | StreamName string `param:"stream_name" json:"-"` 22 | } 23 | 24 | func (r GetStreamRequest) Validate() error { 25 | return valgo.In("params", valgo.Is( 26 | entity.IDValidator[entity.AccountID](r.AccountID, entity.PathParamAccountID), 27 | valgo.String(r.StreamName, "stream_name").Not().Blank(), 28 | )).Error() 29 | } 30 | 31 | type FetchStreamMessagesRequest struct { 32 | AccountID string `param:"account_id" json:"-"` 33 | StreamName string `param:"stream_name" json:"-"` 34 | StartSequence uint64 `query:"start_sequence"` 35 | BatchSize uint32 `query:"batch_size"` 36 | } 37 | 38 | func (r FetchStreamMessagesRequest) Validate() error { 39 | v := valgo.New() 40 | v.In("params", valgo.Is( 41 | entity.IDValidator[entity.AccountID](r.AccountID, entity.PathParamAccountID), 42 | valgo.String(r.StreamName, "stream_name").Not().Blank(), 43 | )) 44 | v.In("query_params", valgo.Is( 45 | valgo.Uint32(r.BatchSize, "batch_size").LessOrEqualTo(1000), 46 | )) 47 | return v.Error() 48 | } 49 | 50 | type GetStreamMessageContentRequest struct { 51 | AccountID string `param:"account_id" json:"-"` 52 | StreamName string `param:"stream_name" json:"-"` 53 | Sequence uint64 `param:"stream_sequence"` 54 | } 55 | 56 | func (r GetStreamMessageContentRequest) Validate() error { 57 | return valgo.In("params", 58 | valgo.Is( 59 | entity.IDValidator[entity.AccountID](r.AccountID, entity.PathParamAccountID), 60 | valgo.String(r.StreamName, "stream_name").Not().Blank(), 61 | valgo.Uint64(r.Sequence, "stream_sequence").GreaterOrEqualTo(1), 62 | ), 63 | ).Error() 64 | } 65 | 66 | type StreamResponse struct { 67 | Name string `json:"name"` 68 | Subjects []string `json:"subjects"` 69 | MessageCount uint64 `json:"message_count"` 70 | ConsumerCount int `json:"consumer_count"` 71 | CreateTime int64 `json:"create_time"` // unix milli 72 | } 73 | -------------------------------------------------------------------------------- /command/stream_websocket_handler_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/coder/websocket" 11 | "github.com/coder/websocket/wsjson" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "google.golang.org/protobuf/proto" 15 | 16 | "github.com/coro-sh/coro/entity" 17 | "github.com/coro-sh/coro/internal/testutil" 18 | commandv1 "github.com/coro-sh/coro/proto/gen/command/v1" 19 | "github.com/coro-sh/coro/server" 20 | ) 21 | 22 | func TestStreamWebSocketHandler_HandleConsume(t *testing.T) { 23 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) 24 | defer cancel() 25 | 26 | op, err := entity.NewOperator(testutil.RandName(), entity.NewID[entity.NamespaceID]()) 27 | require.NoError(t, err) 28 | acc, err := entity.NewAccount(testutil.RandName(), op) 29 | store := entity.NewStore(new(testutil.FakeTxer), entity.NewFakeEntityRepository(t)) 30 | err = store.CreateAccount(ctx, acc) 31 | require.NoError(t, err) 32 | 33 | const numMsgs = 10 34 | pubMsgCh := make(chan *commandv1.ReplyMessage, numMsgs) 35 | consumerStarter := newStreamerStub(nil, pubMsgCh) 36 | 37 | handler := NewStreamWebSocketHandler(store, consumerStarter) 38 | 39 | srv, err := server.NewServer(testutil.GetFreePort(t), server.WithMiddleware(entity.NamespaceContextMiddleware())) 40 | require.NoError(t, err) 41 | srv.Register(handler) 42 | go srv.Start() 43 | err = srv.WaitHealthy(10, time.Millisecond) 44 | require.NoError(t, err) 45 | defer srv.Stop(ctx) 46 | 47 | streamName := testutil.RandName() 48 | 49 | url := fmt.Sprintf("%s/api%s/namespaces/%s/accounts/%s/streams/%s/consume", srv.WebsSocketAddress(), entity.VersionPath, acc.NamespaceID, acc.ID, streamName) 50 | ws, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ 51 | HTTPClient: http.DefaultClient, 52 | Subprotocols: []string{streamWebSocketSubprotocol}, 53 | }) 54 | require.NoError(t, err) 55 | require.Eventually(t, func() bool { 56 | return int64(1) == handler.NumConnections() 57 | }, 100*time.Millisecond, 5*time.Millisecond) 58 | 59 | // first reply will have empty data to indicate consumer has started 60 | pubMsgCh <- &commandv1.ReplyMessage{ 61 | Id: NewMessageID().String(), 62 | Inbox: testutil.RandName(), 63 | Data: nil, 64 | } 65 | 66 | wantConsumerMsgs := make(chan *commandv1.StreamConsumerMessage, numMsgs) 67 | go func() { 68 | for i := 0; i < numMsgs; i++ { 69 | streamSeq := uint64(i + 1) 70 | cmsg := &commandv1.StreamConsumerMessage{ 71 | StreamSequence: streamSeq, 72 | MessagesPending: uint64(numMsgs) - streamSeq, 73 | Timestamp: time.Now().Unix(), 74 | } 75 | data, err := proto.Marshal(cmsg) 76 | require.NoError(t, err) 77 | 78 | pubMsgCh <- &commandv1.ReplyMessage{ 79 | Id: NewMessageID().String(), 80 | Inbox: testutil.RandName(), 81 | Data: data, 82 | } 83 | wantConsumerMsgs <- cmsg 84 | } 85 | }() 86 | 87 | for i := 0; i < numMsgs; i++ { 88 | var res server.Response[*commandv1.StreamConsumerMessage] 89 | err = wsjson.Read(ctx, ws, &res) 90 | require.NoError(t, err) 91 | want := recvCtx(t, ctx, wantConsumerMsgs) 92 | require.True(t, proto.Equal(want, res.Data)) 93 | } 94 | 95 | assert.Len(t, wantConsumerMsgs, 0) // no more expected messages 96 | 97 | err = ws.Close(websocket.StatusNormalClosure, "") 98 | require.NoError(t, err) 99 | 100 | // wait for websocket closed on the server side 101 | time.Sleep(5 * time.Millisecond) 102 | require.Eventually(t, func() bool { 103 | return int64(0) == handler.NumConnections() 104 | }, 100*time.Millisecond, 5*time.Millisecond) 105 | } 106 | -------------------------------------------------------------------------------- /command/stream_websocket_types.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/cohesivestack/valgo" 5 | 6 | "github.com/coro-sh/coro/entity" 7 | ) 8 | 9 | type StartStreamConsumerRequest struct { 10 | AccountID string `param:"account_id" json:"-"` 11 | StreamName string `param:"stream_name" json:"-"` 12 | StartSequence uint64 `query:"start_sequence"` 13 | } 14 | 15 | func (r StartStreamConsumerRequest) Validate() error { 16 | return valgo.In("params", valgo.Is( 17 | entity.IDValidator[entity.AccountID](r.AccountID, entity.PathParamAccountID), 18 | valgo.String(r.StreamName, "stream_name").Not().Blank(), 19 | )).Error() 20 | } 21 | 22 | type StreamConsumerMessage struct { 23 | } 24 | -------------------------------------------------------------------------------- /command/types.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "time" 5 | 6 | "go.jetify.com/typeid" 7 | ) 8 | 9 | type UserCreds struct { 10 | JWT string `json:"jwt"` 11 | Seed string `json:"seed"` 12 | } 13 | 14 | type messagePrefix struct{} 15 | 16 | func (messagePrefix) Prefix() string { return "msg" } 17 | 18 | type MessageID struct { 19 | typeid.TypeID[messagePrefix] 20 | } 21 | 22 | func NewMessageID() MessageID { 23 | return typeid.Must(typeid.New[MessageID]()) 24 | } 25 | 26 | type streamConsumerPrefix struct{} 27 | 28 | func (streamConsumerPrefix) Prefix() string { return "cons" } 29 | 30 | type StreamConsumerID struct { 31 | typeid.TypeID[streamConsumerPrefix] 32 | } 33 | 34 | func NewStreamConsumerID() StreamConsumerID { 35 | return typeid.Must(typeid.New[StreamConsumerID]()) 36 | } 37 | 38 | type natsAccountMessageData struct { 39 | Code int `json:"code"` 40 | Account string `json:"account"` 41 | Message string `json:"message"` 42 | } 43 | 44 | type accountUpdateReplyMessage struct { 45 | Data natsAccountMessageData `json:"data"` 46 | } 47 | 48 | type pingReplyMessage struct { 49 | Server struct { 50 | Name string `json:"name"` 51 | Host string `json:"host"` 52 | ID string `json:"id"` 53 | Cluster string `json:"cluster"` 54 | Ver string `json:"ver"` 55 | Jetstream bool `json:"jetstream"` 56 | Flags int `json:"flags"` 57 | Seq int `json:"seq"` 58 | Time time.Time `json:"time"` 59 | } `json:"server"` 60 | Statsz struct { 61 | Start time.Time `json:"start"` 62 | Mem int `json:"mem"` 63 | Cores int `json:"cores"` 64 | CPU float32 `json:"cpu"` 65 | Connections int `json:"connections"` 66 | TotalConnections int `json:"total_connections"` 67 | ActiveAccounts int `json:"active_accounts"` 68 | Subscriptions int `json:"subscriptions"` 69 | Sent struct { 70 | Msgs int `json:"msgs"` 71 | Bytes int `json:"bytes"` 72 | } `json:"sent"` 73 | Received struct { 74 | Msgs int `json:"msgs"` 75 | Bytes int `json:"bytes"` 76 | } `json:"received"` 77 | SlowConsumers int `json:"slow_consumers"` 78 | Routes []struct { 79 | Rid int `json:"rid"` 80 | Name string `json:"name"` 81 | Sent struct { 82 | Msgs int `json:"msgs"` 83 | Bytes int `json:"bytes"` 84 | } `json:"sent"` 85 | Received struct { 86 | Msgs int `json:"msgs"` 87 | Bytes int `json:"bytes"` 88 | } `json:"received"` 89 | Pending int `json:"pending"` 90 | } `json:"routes"` 91 | ActiveServers int `json:"active_servers"` 92 | } `json:"statsz"` 93 | } 94 | -------------------------------------------------------------------------------- /command/util.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "errors" 5 | 6 | commandv1 "github.com/coro-sh/coro/proto/gen/command/v1" 7 | ) 8 | 9 | func getOperationType(msg *commandv1.PublishMessage) (string, error) { 10 | switch { 11 | case msg.GetRequest() != nil: 12 | return "OPERATION_REQUEST", nil 13 | case msg.GetListStream() != nil: 14 | return "OPERATION_LIST_STREAMS", nil 15 | case msg.GetGetStream() != nil: 16 | return "OPERATION_GET_STREAM", nil 17 | case msg.GetFetchStreamMessages() != nil: 18 | return "OPERATION_FETCH_STREAM_MESSAGES", nil 19 | case msg.GetGetStreamMessageContent() != nil: 20 | return "OPERATION_GET_STREAM_MESSAGE_CONTENT", nil 21 | case msg.GetStartStreamConsumer() != nil: 22 | return "OPERATION_START_STREAM_CONSUMER", nil 23 | case msg.GetStopStreamConsumer() != nil: 24 | return "OPERATION_STOP_STREAM_CONSUMER", nil 25 | case msg.GetSendStreamConsumerHeartbeat() != nil: 26 | return "OPERATION_SEND_STREAM_CONSUMER_HEARTBEAT", nil 27 | default: 28 | return "", errors.New("message does not contain an operation") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /embedns/embedded_nats.go: -------------------------------------------------------------------------------- 1 | package embedns 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "net/url" 10 | "os" 11 | "strconv" 12 | 13 | "github.com/nats-io/nats-server/v2/server" 14 | 15 | "github.com/coro-sh/coro/entity" 16 | ) 17 | 18 | type ResolverConfig struct { 19 | Operator *entity.Operator 20 | SystemAccount *entity.Account 21 | } 22 | 23 | type ClusterConfig struct { 24 | ClusterName string 25 | ClusterHostPort string 26 | Routes []*url.URL 27 | } 28 | 29 | type TLSConfig struct { 30 | CertFile string 31 | KeyFile string 32 | CACertFile string 33 | } 34 | 35 | // EmbeddedNATSConfig represents the configuration for an embedded NATS server 36 | // with clustering and TLS/mTLS support. 37 | type EmbeddedNATSConfig struct { 38 | NodeName string 39 | Resolver ResolverConfig 40 | Cluster *ClusterConfig 41 | TLS *TLSConfig 42 | } 43 | 44 | func NewEmbeddedNATS(cfg EmbeddedNATSConfig) (*server.Server, error) { 45 | if cfg.Resolver.Operator == nil { 46 | return nil, errors.New("resolver operator required") 47 | } 48 | if cfg.Resolver.SystemAccount == nil { 49 | return nil, errors.New("resolver system account required") 50 | } 51 | 52 | cfgContent, err := newInMemResolverConfig(cfg.Resolver.Operator, cfg.Resolver.SystemAccount) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | cfgFile, err := os.CreateTemp("", "embedded-nats-*.conf") 58 | if err != nil { 59 | return nil, fmt.Errorf("create embedded nats temp config file: %w", err) 60 | } 61 | defer os.Remove(cfgFile.Name()) 62 | 63 | cfgFileName := cfgFile.Name() 64 | if err = cfgFile.Close(); err != nil { 65 | return nil, fmt.Errorf("close embedded nats temp config file: %w", err) 66 | } 67 | 68 | if err = os.WriteFile(cfgFileName, []byte(cfgContent), 0666); err != nil { 69 | return nil, fmt.Errorf("write embedded nats temp config file: %w", err) 70 | } 71 | 72 | opts, err := server.ProcessConfigFile(cfgFileName) 73 | if err != nil { 74 | return nil, fmt.Errorf("process embedded nats config file: %w", err) 75 | } 76 | 77 | opts.ServerName = cfg.NodeName 78 | opts.Host = server.DEFAULT_HOST 79 | opts.Port = server.RANDOM_PORT 80 | opts.NoSigs = true 81 | 82 | if cfg.TLS != nil { 83 | tlsConfig, err := configureTLS(cfg.TLS) 84 | if err != nil { 85 | return nil, err 86 | } 87 | opts.TLSConfig = tlsConfig 88 | opts.TLS = true 89 | } 90 | 91 | if cfg.Cluster != nil { 92 | clusterHost, clusterPortStr, err := net.SplitHostPort(cfg.Cluster.ClusterHostPort) 93 | if err != nil { 94 | return nil, fmt.Errorf("invalid cluster host/port: %w", err) 95 | } 96 | 97 | clusterPort, err := strconv.Atoi(clusterPortStr) 98 | if err != nil { 99 | return nil, fmt.Errorf("invalid cluster port: %w", err) 100 | } 101 | 102 | opts.Routes = cfg.Cluster.Routes 103 | opts.Cluster = server.ClusterOpts{ 104 | Name: cfg.Cluster.ClusterName, 105 | Host: clusterHost, 106 | Port: clusterPort, 107 | } 108 | } 109 | 110 | return server.NewServer(opts) 111 | } 112 | 113 | func configureTLS(cfg *TLSConfig) (*tls.Config, error) { 114 | cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) 115 | if err != nil { 116 | return nil, fmt.Errorf("load cert and key: %w", err) 117 | } 118 | 119 | tlsConfig := &tls.Config{ 120 | Certificates: []tls.Certificate{cert}, 121 | } 122 | 123 | if cfg.CACertFile != "" { 124 | caCert, err := os.ReadFile(cfg.CACertFile) 125 | if err != nil { 126 | return nil, fmt.Errorf("read ca cert: %w", err) 127 | } 128 | 129 | caCertPool := x509.NewCertPool() 130 | if !caCertPool.AppendCertsFromPEM(caCert) { 131 | return nil, errors.New("failed to append ca cert") 132 | } 133 | 134 | tlsConfig.ClientCAs = caCertPool 135 | tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert 136 | } 137 | 138 | return tlsConfig, nil 139 | } 140 | -------------------------------------------------------------------------------- /embedns/embedded_nats_test.go: -------------------------------------------------------------------------------- 1 | package embedns 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/nats-io/nats.go" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/coro-sh/coro/entity" 15 | "github.com/coro-sh/coro/internal/testutil" 16 | ) 17 | 18 | const ( 19 | serverCertFile = "../internal/testutil/testdata/certs/server-cert.pem" 20 | serverKeyFile = "../internal/testutil/testdata/certs/server-key.pem" 21 | clientCertFile = "../internal/testutil/testdata/certs/client-cert.pem" 22 | clientKeyFile = "../internal/testutil/testdata/certs/client-key.pem" 23 | caCertFile = "../internal/testutil/testdata/certs/ca-cert.pem" 24 | ) 25 | 26 | func TestNewEmbeddedNATS(t *testing.T) { 27 | clientCert, err := tls.LoadX509KeyPair(clientCertFile, clientKeyFile) 28 | require.NoError(t, err) 29 | caCert, err := os.ReadFile(caCertFile) 30 | require.NoError(t, err) 31 | caCertPool := x509.NewCertPool() 32 | require.True(t, caCertPool.AppendCertsFromPEM(caCert)) 33 | 34 | tests := []struct { 35 | name string 36 | serverTLS *TLSConfig 37 | connOpts []nats.Option 38 | }{ 39 | { 40 | name: "insecure server", 41 | serverTLS: nil, 42 | }, 43 | { 44 | name: "server with tls", 45 | serverTLS: &TLSConfig{ 46 | CertFile: serverCertFile, 47 | KeyFile: serverKeyFile, 48 | }, 49 | connOpts: []nats.Option{ 50 | nats.Secure(&tls.Config{ 51 | InsecureSkipVerify: true, 52 | }), 53 | }, 54 | }, 55 | { 56 | name: "server with mtls", 57 | serverTLS: &TLSConfig{ 58 | CertFile: serverCertFile, 59 | KeyFile: serverKeyFile, 60 | CACertFile: caCertFile, 61 | }, 62 | connOpts: []nats.Option{ 63 | nats.Secure(&tls.Config{ 64 | Certificates: []tls.Certificate{clientCert}, 65 | RootCAs: caCertPool, 66 | InsecureSkipVerify: false, 67 | }), 68 | }, 69 | }, 70 | } 71 | 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | op, err := entity.NewOperator(testutil.RandName(), entity.NewID[entity.NamespaceID]()) 75 | require.NoError(t, err) 76 | sysAcc, sysUsr, err := op.SetNewSystemAccountAndUser() 77 | require.NoError(t, err) 78 | 79 | cfg := EmbeddedNATSConfig{ 80 | NodeName: "test_node", 81 | Resolver: ResolverConfig{ 82 | Operator: op, 83 | SystemAccount: sysAcc, 84 | }, 85 | } 86 | 87 | if tt.serverTLS != nil { 88 | cfg.TLS = tt.serverTLS 89 | } 90 | 91 | srv, err := NewEmbeddedNATS(cfg) 92 | require.NoError(t, err) 93 | srv.Start() 94 | defer srv.Shutdown() 95 | 96 | ok := srv.ReadyForConnections(3 * time.Second) 97 | require.True(t, ok, "nats unhealthy") 98 | 99 | defaultOpts := []nats.Option{nats.UserJWTAndSeed(sysUsr.JWT(), string(sysUsr.NKey().Seed))} 100 | opts := append(defaultOpts, tt.connOpts...) 101 | 102 | nc, err := nats.Connect(srv.ClientURL(), opts...) 103 | require.NoError(t, err) 104 | defer nc.Close() 105 | 106 | verifyClientConn(t, nc) 107 | }) 108 | } 109 | } 110 | 111 | func verifyClientConn(t *testing.T, nc *nats.Conn) { 112 | msgChan := make(chan *nats.Msg, 1) 113 | sub, err := nc.ChanSubscribe("test.subject", msgChan) 114 | require.NoError(t, err) 115 | defer sub.Unsubscribe() 116 | 117 | require.NoError(t, nc.Publish("test.subject", []byte("hello world"))) 118 | select { 119 | case msg := <-msgChan: 120 | assert.Equal(t, "hello world", string(msg.Data)) 121 | case <-time.After(2 * time.Second): 122 | t.Fatal("did not receive message") 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /embedns/resolver.go: -------------------------------------------------------------------------------- 1 | package embedns 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/coro-sh/coro/entity" 8 | ) 9 | 10 | const inMemResolverTemplate = `# Operator named %s 11 | operator: %s 12 | 13 | # System Account named %s 14 | system_account: %s 15 | 16 | # Configuration of the nats based resolver 17 | resolver: MEMORY 18 | 19 | # Preload the nats based resolver with the system account jwt. 20 | # This only applies to the system account. Therefore other account jwt are not included here. 21 | resolver_preload: { 22 | # Later changes to the system account take precedence over the system account jwt listed below. 23 | %s: %s 24 | } 25 | ` 26 | 27 | func newInMemResolverConfig(op *entity.Operator, sysAcc *entity.Account) (string, error) { 28 | opData, err := op.Data() 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | ok, err := sysAcc.IsSystemAccount() 34 | if err != nil { 35 | return "", fmt.Errorf("check system account: %w", err) 36 | } else if !ok { 37 | return "", errors.New("resolver system account is not a system account") 38 | } 39 | 40 | sysAccData, err := sysAcc.Data() 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | cfgContent := fmt.Sprintf( 46 | inMemResolverTemplate, 47 | opData.Name, op.JWT, sysAccData.Name, sysAccData.PublicKey, sysAccData.PublicKey, sysAcc.JWT, 48 | ) 49 | 50 | return cfgContent, nil 51 | } 52 | -------------------------------------------------------------------------------- /encrypt/aes.go: -------------------------------------------------------------------------------- 1 | package encrypt 2 | 3 | import ( 4 | "context" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "errors" 9 | "io" 10 | ) 11 | 12 | var ( 13 | errorAESKeyLength = errors.New("invalid key length (must be 16, 24, or 32 bytes)") 14 | errorAESCiphertextLength = errors.New("invalid ciphertext length") 15 | ) 16 | 17 | var _ Encrypter = (*AES)(nil) 18 | 19 | // AES provides encryption and decryption using the AES-GCM mode. 20 | type AES struct { 21 | key []byte 22 | } 23 | 24 | // NewAES creates a new AES instance with the provided key. 25 | // The key must be one of the following lengths: 16, 24, or 32 bytes, 26 | // corresponding to AES-128, AES-192, and AES-256, respectively. 27 | func NewAES(key string) (*AES, error) { 28 | switch len(key) { 29 | default: 30 | return nil, errorAESKeyLength 31 | case 16, 24, 32: 32 | break 33 | } 34 | return &AES{ 35 | key: []byte(key), 36 | }, nil 37 | } 38 | 39 | // Encrypt encrypts the given plaintext using AES-GCM. 40 | // The context parameter is ignored but is included for interface compliance. 41 | // The resulting ciphertext includes the nonce prepended to the encrypted data. 42 | func (a *AES) Encrypt(_ context.Context, plaintext []byte) ([]byte, error) { 43 | block, err := aes.NewCipher(a.key) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | gcm, err := cipher.NewGCM(block) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | nonce := make([]byte, gcm.NonceSize()) 54 | if _, err = io.ReadFull(rand.Reader, nonce); err != nil { 55 | return nil, err 56 | } 57 | 58 | ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) 59 | return ciphertext, nil 60 | } 61 | 62 | // Decrypt decrypts the given ciphertext using AES-GCM. 63 | // The context parameter is ignored but is included for interface compliance. 64 | // The ciphertext must include the nonce as its prefix. 65 | func (a *AES) Decrypt(_ context.Context, ciphertext []byte) ([]byte, error) { 66 | block, err := aes.NewCipher(a.key) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | gcm, err := cipher.NewGCM(block) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if len(ciphertext) < gcm.NonceSize() { 77 | return nil, errorAESCiphertextLength 78 | } 79 | 80 | nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():] 81 | return gcm.Open(nil, nonce, ciphertext, nil) 82 | } 83 | -------------------------------------------------------------------------------- /encrypt/aes_test.go: -------------------------------------------------------------------------------- 1 | package encrypt 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/coro-sh/coro/internal/testutil" 11 | ) 12 | 13 | func TestNewAES(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | key string 17 | wantErr bool 18 | }{ 19 | { 20 | name: "valid AES-128 key", 21 | key: "1234567890123456", // 16 bytes 22 | }, 23 | { 24 | name: "valid AES-192 key", 25 | key: "123456789012345678901234", // 24 bytes 26 | }, 27 | { 28 | name: "valid AES-256 key", 29 | key: "12345678901234567890123456789012", // 32 bytes 30 | }, 31 | { 32 | name: "key length too short", 33 | key: "short_key", // < 16 bytes 34 | wantErr: true, 35 | }, 36 | { 37 | name: "key length too long", 38 | key: "short_key", // > 32 bytes 39 | wantErr: true, 40 | }, 41 | { 42 | name: "Empty Key", 43 | key: "", // Empty key 44 | wantErr: true, 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | got, err := NewAES(tt.key) 51 | if tt.wantErr { 52 | assert.ErrorIs(t, err, errorAESKeyLength) 53 | assert.Nil(t, got) 54 | return 55 | } 56 | assert.NoError(t, err) 57 | assert.NotNil(t, got) 58 | }) 59 | } 60 | } 61 | 62 | func TestAES_EncryptDecrypt(t *testing.T) { 63 | aes, err := NewAES("1234567890123456") 64 | require.NoError(t, err) 65 | 66 | plaintext := []byte(testutil.RandString(100)) 67 | 68 | ciphertext, err := aes.Encrypt(context.Background(), plaintext) 69 | require.NoError(t, err) 70 | assert.NotEmpty(t, ciphertext) 71 | 72 | decrypted, err := aes.Decrypt(context.Background(), ciphertext) 73 | require.NoError(t, err) 74 | 75 | assert.Equal(t, plaintext, decrypted) 76 | } 77 | 78 | func TestAES_DecryptInvalidCiphertext(t *testing.T) { 79 | tests := []struct { 80 | name string 81 | ciphertext []byte 82 | wantErr string 83 | }{ 84 | { 85 | name: "Empty Ciphertext", 86 | ciphertext: []byte{}, 87 | wantErr: errorAESCiphertextLength.Error(), 88 | }, 89 | { 90 | name: "Short Ciphertext (no nonce)", 91 | ciphertext: []byte{1, 2, 3, 4}, 92 | wantErr: errorAESCiphertextLength.Error(), 93 | }, 94 | { 95 | name: "Tampered Ciphertext", 96 | ciphertext: append(make([]byte, 12), []byte("tampered data")...), 97 | wantErr: "cipher: message authentication failed", 98 | }, 99 | } 100 | 101 | for _, tt := range tests { 102 | t.Run(tt.name, func(t *testing.T) { 103 | aesObj, err := NewAES("1234567890123456") 104 | require.NoError(t, err) 105 | 106 | _, err = aesObj.Decrypt(context.Background(), tt.ciphertext) 107 | assert.Error(t, err, tt.wantErr) 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /encrypt/encrypter.go: -------------------------------------------------------------------------------- 1 | package encrypt 2 | 3 | import "context" 4 | 5 | // Encrypter defines an interface for encryption and decryption mechanisms. 6 | // Implementations of this interface must support securely encrypting plaintext 7 | // and decrypting ciphertext, optionally considering context for cancellation or 8 | // deadlines. 9 | type Encrypter interface { 10 | // Encrypt returns the encrypted ciphertext. 11 | Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) 12 | // Decrypt returns the decrypted plaintext. 13 | Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) 14 | } 15 | -------------------------------------------------------------------------------- /entity/helpers_test.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/coro-sh/coro/encrypt" 13 | "github.com/coro-sh/coro/internal/testutil" 14 | "github.com/coro-sh/coro/log" 15 | "github.com/coro-sh/coro/server" 16 | ) 17 | 18 | const ( 19 | testTimeout = 5 * time.Second 20 | apiPrefix = "/api/v1" 21 | stubNotifConnectTime int64 = 1738931738 22 | ) 23 | 24 | type TestFixture struct { 25 | Server *server.Server 26 | Store *Store 27 | stop func() 28 | } 29 | 30 | func SetupTestFixture(t *testing.T) *TestFixture { 31 | t.Helper() 32 | 33 | aesEnc, err := encrypt.NewAES(testutil.RandString(32)) 34 | require.NoError(t, err) 35 | txer := new(testutil.FakeTxer) 36 | store := NewStore(txer, NewFakeEntityRepository(t), WithEncryption(aesEnc)) 37 | 38 | logger := log.NewLogger(log.WithDevelopment()) 39 | srv, err := server.NewServer(testutil.GetFreePort(t), 40 | server.WithLogger(logger), 41 | server.WithMiddleware(NamespaceContextMiddleware()), 42 | ) 43 | require.NoError(t, err) 44 | srv.Register(NewHTTPHandler(txer, store, WithCommander(new(commanderStub)))) 45 | 46 | go srv.Start() 47 | err = srv.WaitHealthy(10, time.Millisecond) 48 | require.NoError(t, err) 49 | 50 | return &TestFixture{ 51 | Server: srv, 52 | Store: store, 53 | stop: func() { 54 | srv.Stop(context.Background()) 55 | }, 56 | } 57 | } 58 | 59 | func (t *TestFixture) NamespacesURL() string { 60 | return t.URLForPath(fmt.Sprintf("%s/namespaces", apiPrefix)) 61 | } 62 | 63 | func (t *TestFixture) NamespaceURL(namespaceID NamespaceID) string { 64 | return fmt.Sprintf("%s/%s", t.NamespacesURL(), namespaceID) 65 | } 66 | 67 | func (t *TestFixture) OperatorsURL(namespaceID NamespaceID) string { 68 | return t.NamespaceURL(namespaceID) + "/operators" 69 | } 70 | 71 | func (t *TestFixture) OperatorURL(namespaceID NamespaceID, operatorID OperatorID) string { 72 | return fmt.Sprintf("%s/%s", t.OperatorsURL(namespaceID), operatorID) 73 | } 74 | 75 | func (t *TestFixture) OperatorNATSConfigURL(namespaceID NamespaceID, operatorID OperatorID) string { 76 | return fmt.Sprintf("%s/nats-config", t.OperatorURL(namespaceID, operatorID)) 77 | } 78 | 79 | func (t *TestFixture) OperatorAccountsURL(namespaceID NamespaceID, operatorID OperatorID) string { 80 | return fmt.Sprintf("%s/accounts", t.OperatorURL(namespaceID, operatorID)) 81 | } 82 | 83 | func (t *TestFixture) AccountsURL(namespaceID NamespaceID, operatorID OperatorID) string { 84 | return fmt.Sprintf("%s/accounts", t.OperatorURL(namespaceID, operatorID)) 85 | } 86 | 87 | func (t *TestFixture) AccountURL(namespaceID NamespaceID, accountID AccountID) string { 88 | return fmt.Sprintf("%s/accounts/%s", t.NamespaceURL(namespaceID), accountID) 89 | } 90 | 91 | func (t *TestFixture) AccountUsersURL(namespaceID NamespaceID, accountID AccountID) string { 92 | return fmt.Sprintf("%s/users", t.AccountURL(namespaceID, accountID)) 93 | } 94 | 95 | func (t *TestFixture) UsersURL(namespaceID NamespaceID, accountID AccountID) string { 96 | return fmt.Sprintf("%s/users", t.AccountURL(namespaceID, accountID)) 97 | } 98 | 99 | func (t *TestFixture) UserURL(namespaceID NamespaceID, userID UserID) string { 100 | return fmt.Sprintf("%s/users/%s", t.NamespaceURL(namespaceID), userID) 101 | } 102 | 103 | func (t *TestFixture) UserCredsURL(namespaceID NamespaceID, userID UserID) string { 104 | return fmt.Sprintf("%s/creds", t.UserURL(namespaceID, userID)) 105 | } 106 | 107 | func (t *TestFixture) OperatorJwtURL(operatorPubKey string) string { 108 | return t.URLForPath(fmt.Sprintf("%s/jwt/operators/%s", apiPrefix, operatorPubKey)) 109 | } 110 | 111 | func (t *TestFixture) AccountJwtURL(operatorPubKey string, accountPubKey string) string { 112 | return fmt.Sprintf("%s/accounts/%s", t.OperatorJwtURL(operatorPubKey), accountPubKey) 113 | } 114 | 115 | func (t *TestFixture) AccountsJwtURL(operatorPubKey string) string { 116 | return fmt.Sprintf("%s/accounts", t.OperatorJwtURL(operatorPubKey)) 117 | } 118 | 119 | func (t *TestFixture) URLForPath(path string) string { 120 | path = "/" + strings.TrimPrefix(path, "/") 121 | return t.Server.Address() + path 122 | } 123 | 124 | func (t *TestFixture) Stop() { 125 | t.stop() 126 | } 127 | 128 | type commanderStub struct{} 129 | 130 | func (n *commanderStub) NotifyAccountClaimsUpdate(_ context.Context, _ *Account) error { 131 | return nil 132 | } 133 | 134 | func (n *commanderStub) Ping(_ context.Context, _ OperatorID) (OperatorNATSStatus, error) { 135 | connectTime := stubNotifConnectTime 136 | return OperatorNATSStatus{ 137 | Connected: true, 138 | ConnectTime: &connectTime, 139 | }, nil 140 | } 141 | -------------------------------------------------------------------------------- /entity/http_auth.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | 6 | "github.com/coro-sh/coro/errtag" 7 | "github.com/coro-sh/coro/internal/constants" 8 | ) 9 | 10 | type NamespaceIDGetter interface { 11 | GetNamespaceID() NamespaceID 12 | } 13 | 14 | func VerifyEntityNamespace(c echo.Context, entity NamespaceIDGetter) error { 15 | nsID, err := NamespaceIDFromContext(c) 16 | if err != nil { 17 | return err 18 | } 19 | if nsID != entity.GetNamespaceID() { 20 | return errtag.NewTagged[errtag.Unauthorized]("entity namespace id mismatch") 21 | } 22 | return nil 23 | } 24 | 25 | func VerifyPublicAccount(acc *Account) error { 26 | accData, err := acc.Data() 27 | if err != nil { 28 | return err 29 | } 30 | if constants.IsReservedAccountName(accData.Name) { 31 | return errtag.NewTagged[errtag.Unauthorized]("account is reserved for internal use only") 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func VerifyPublicUser(usr *User) error { 38 | usrData, err := usr.Data() 39 | if err != nil { 40 | return err 41 | } 42 | if constants.IsReservedUserName(usrData.Name) { 43 | return errtag.NewTagged[errtag.Unauthorized]("user is reserved for internal use only") 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /entity/http_middleware.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/cohesivestack/valgo" 8 | "github.com/labstack/echo/v4" 9 | 10 | "github.com/coro-sh/coro/errtag" 11 | "github.com/coro-sh/coro/log" 12 | ) 13 | 14 | const namespaceContextKey = "req_namespace" 15 | 16 | // NamespaceContextMiddleware extracts the NamespaceID from the request path and 17 | // sets it in the handler context. 18 | func NamespaceContextMiddleware() echo.MiddlewareFunc { 19 | return func(next echo.HandlerFunc) echo.HandlerFunc { 20 | return func(c echo.Context) error { 21 | if !isNamespacePath(c) { 22 | return next(c) // skip if path is not scoped to a namespace 23 | } 24 | nsIDStr := c.Param(PathParamNamespaceID) 25 | if err := valgo.In("params", valgo.Is(IDValidator[NamespaceID](nsIDStr, "namespace_id"))).Error(); err != nil { 26 | return err 27 | } 28 | nsID := MustParseID[NamespaceID](nsIDStr) 29 | c.Set(namespaceContextKey, nsID) 30 | c.Set(log.KeyNamespaceID, nsID) 31 | return next(c) 32 | } 33 | } 34 | } 35 | 36 | // InternalNamespaceMiddleware is a middleware to prevent access to the 37 | // internal namespace. 38 | func InternalNamespaceMiddleware(internalNamespaceID NamespaceID) echo.MiddlewareFunc { 39 | return func(next echo.HandlerFunc) echo.HandlerFunc { 40 | return func(c echo.Context) error { 41 | if !isNamespacePath(c) { 42 | return next(c) // skip if path is not scoped to a namespace 43 | } 44 | nsID, err := NamespaceIDFromContext(c) 45 | if err != nil { 46 | return err 47 | } 48 | if nsID == internalNamespaceID { 49 | return errtag.NewTagged[errtag.Unauthorized]("namespace is reserved for internal use only") 50 | } 51 | 52 | return next(c) 53 | } 54 | } 55 | } 56 | 57 | func NamespaceIDFromContext(c echo.Context) (NamespaceID, error) { 58 | nsID, ok := c.Get(namespaceContextKey).(NamespaceID) 59 | if !ok { 60 | return NamespaceID{}, errtag.NewTagged[errtag.InvalidArgument]("namespace id not found in context", errtag.WithMsg("Namespace ID not found in request path")) 61 | } 62 | return nsID, nil 63 | } 64 | 65 | func isNamespacePath(c echo.Context) bool { 66 | return strings.HasPrefix(c.Path(), fmt.Sprintf("/api%s/namespaces/:%s", VersionPath, PathParamNamespaceID)) 67 | } 68 | -------------------------------------------------------------------------------- /entity/id.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "go.jetify.com/typeid" 4 | 5 | // ID is a generic identifier interface that combines all entity ID types 6 | // (NamespaceID, OperatorID, AccountID, UserID). 7 | type ID interface { 8 | NamespaceID | OperatorID | AccountID | UserID 9 | typeid.Subtype 10 | IsZero() bool 11 | } 12 | 13 | // NewID creates a new instance of the specified ID type. It panics if the ID 14 | // cannot be generated. 15 | func NewID[T ID, PT typeid.SubtypePtr[T]]() T { 16 | return typeid.Must(typeid.New[T, PT]()) 17 | } 18 | 19 | // ParseID parses a string representation of an ID into the specified ID type. 20 | func ParseID[T ID, PT typeid.SubtypePtr[T]](id string) (T, error) { 21 | return typeid.Parse[T, PT](id) 22 | } 23 | 24 | // MustParseID parses a string representation of an ID into the specified ID 25 | // type and panics if it cannot be parsed. 26 | func MustParseID[T ID, PT typeid.SubtypePtr[T]](id string) T { 27 | return typeid.Must(ParseID[T, PT](id)) 28 | } 29 | -------------------------------------------------------------------------------- /entity/namespace.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "go.jetify.com/typeid" 4 | 5 | type namespacePrefix struct{} 6 | 7 | func (namespacePrefix) Prefix() string { return "ns" } 8 | 9 | // NamespaceID is the unique identifier for a Namespace. 10 | type NamespaceID struct { 11 | typeid.TypeID[namespacePrefix] 12 | } 13 | 14 | type Namespace struct { 15 | ID NamespaceID `json:"id"` 16 | Name string `json:"name"` 17 | } 18 | 19 | // NewNamespace creates a new Namespace. 20 | func NewNamespace(name string) *Namespace { 21 | return &Namespace{ 22 | ID: NewID[NamespaceID](), 23 | Name: name, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /entity/nkey.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/nats-io/nkeys" 8 | ) 9 | 10 | type NkeyData struct { 11 | ID string // Unique identifier (entity ID). 12 | Seed []byte // The seed used to generate the key pair. 13 | SigningKey bool // Indicates if the key is a signing key. 14 | Type Type // The type of the key. 15 | } 16 | 17 | // Nkey represents a NATS key pair. 18 | type Nkey struct { 19 | NkeyData 20 | kp nkeys.KeyPair // The nkeys.KeyPair associated with the Nkey. 21 | } 22 | 23 | // NewNkey creates a new Nkey by generating the appropriate key pair for the type. 24 | func NewNkey(id string, nkeyType Type, signingKey bool) (*Nkey, error) { 25 | var kp nkeys.KeyPair 26 | var err error 27 | 28 | switch nkeyType { 29 | case TypeOperator: 30 | kp, err = nkeys.CreateOperator() 31 | case TypeAccount: 32 | kp, err = nkeys.CreateAccount() 33 | case TypeUser: 34 | kp, err = nkeys.CreateUser() 35 | case TypeUnspecified, typeEnd: 36 | fallthrough 37 | default: 38 | err = errors.New("invalid nkey type") 39 | } 40 | 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | seed, err := kp.Seed() 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | nkeyPair, err := nkeys.FromSeed(seed) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return &Nkey{ 56 | NkeyData: NkeyData{ 57 | ID: id, 58 | Type: nkeyType, 59 | Seed: seed, 60 | SigningKey: signingKey, 61 | }, 62 | kp: nkeyPair, 63 | }, nil 64 | } 65 | 66 | // NewNkeyFromData creates a new Nkey from existing NkeyData. 67 | func NewNkeyFromData(data NkeyData) (*Nkey, error) { 68 | nkeyPair, err := nkeys.FromSeed(data.Seed) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return &Nkey{ 73 | NkeyData: data, 74 | kp: nkeyPair, 75 | }, nil 76 | } 77 | 78 | // KeyPair returns the nkeys.KeyPair associated with the Nkey. 79 | func (n *Nkey) KeyPair() nkeys.KeyPair { 80 | return n.kp 81 | } 82 | 83 | // Validate checks if the Nkey is valid. 84 | func (n *Nkey) Validate() error { 85 | _, err := nkeys.FromSeed(n.Seed) 86 | if err != nil { 87 | return fmt.Errorf("invalid seed: %w", err) 88 | } 89 | 90 | kpSeed, err := n.KeyPair().Seed() 91 | if err != nil { 92 | return fmt.Errorf("failed to get seed from key pair: %w", err) 93 | } 94 | 95 | if string(n.Seed) != string(kpSeed) { 96 | return fmt.Errorf("seed mismatch") 97 | } 98 | 99 | if n.Type <= TypeUnspecified || n.Type >= typeEnd { 100 | return errors.New("invalid nkey type") 101 | } 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /entity/nkey_test.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNkeyType_String(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | nkeyType Type 14 | expected string 15 | }{ 16 | { 17 | name: "Unspecified", 18 | nkeyType: TypeUnspecified, 19 | expected: "unspecified", 20 | }, 21 | { 22 | name: "Operator", 23 | nkeyType: TypeOperator, 24 | expected: "operator", 25 | }, 26 | { 27 | name: "Account", 28 | nkeyType: TypeAccount, 29 | expected: "account", 30 | }, 31 | { 32 | name: "User", 33 | nkeyType: TypeUser, 34 | expected: "user", 35 | }, 36 | { 37 | name: "Invalid", 38 | nkeyType: Type(999), 39 | expected: "unspecified", 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | assert.Equal(t, tt.expected, tt.nkeyType.String()) 46 | }) 47 | } 48 | } 49 | 50 | func TestNewNkey(t *testing.T) { 51 | tests := []struct { 52 | name string 53 | nkeyType Type 54 | expectErr bool 55 | }{ 56 | { 57 | name: "valid operator", 58 | nkeyType: TypeOperator, 59 | }, 60 | { 61 | name: "valid account", 62 | nkeyType: TypeAccount, 63 | }, 64 | { 65 | name: "valid user", 66 | nkeyType: TypeUser, 67 | }, 68 | { 69 | name: "invalid entity type", 70 | nkeyType: TypeUnspecified, 71 | expectErr: true, 72 | }, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | testNewNkey := func(signingKey bool) { 78 | id := "nkey1" 79 | got, err := NewNkey(id, tt.nkeyType, signingKey) 80 | if tt.expectErr { 81 | assert.Error(t, err) 82 | assert.Nil(t, got) 83 | } else { 84 | require.NoError(t, err) 85 | require.NotNil(t, got) 86 | assert.Equal(t, id, got.ID) 87 | assert.Equal(t, tt.nkeyType, got.Type) 88 | assert.Equal(t, signingKey, got.SigningKey) 89 | assert.NotNil(t, got.KeyPair()) 90 | 91 | kp := got.KeyPair() 92 | assert.NotNil(t, kp) 93 | seed, err := kp.Seed() 94 | assert.NoError(t, err) 95 | assert.Equal(t, got.Seed, seed) 96 | } 97 | } 98 | 99 | testNewNkey(false) 100 | testNewNkey(true) 101 | }) 102 | } 103 | } 104 | 105 | func TestNewNkeyFromData(t *testing.T) { 106 | want, err := NewNkey("nkey1", TypeOperator, false) 107 | require.NoError(t, err) 108 | 109 | got, err := NewNkeyFromData(want.NkeyData) 110 | require.NoError(t, err) 111 | assert.Equal(t, want, got) 112 | } 113 | -------------------------------------------------------------------------------- /entity/operator_test.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nats-io/jwt/v2" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/coro-sh/coro/internal/testutil" 11 | ) 12 | 13 | func TestNewOperator(t *testing.T) { 14 | name := testutil.RandName() 15 | got, err := NewOperator(name, NewID[NamespaceID]()) 16 | require.NoError(t, err) 17 | 18 | assert.False(t, got.ID.IsZero()) 19 | assert.NotEmpty(t, got.JWT) 20 | assert.NotNil(t, got.NKey()) 21 | assert.NotNil(t, got.SigningKey()) 22 | 23 | claims, err := got.Claims() 24 | require.NoError(t, err) 25 | assert.True(t, claims.StrictSigningKeyUsage) 26 | assert.Equal(t, name, claims.Name) 27 | 28 | nkPubKey, err := got.NKey().KeyPair().PublicKey() 29 | require.NoError(t, err) 30 | assert.Equal(t, nkPubKey, claims.Subject) 31 | 32 | skPubKey, err := got.SigningKey().KeyPair().PublicKey() 33 | require.NoError(t, err) 34 | assert.True(t, claims.SigningKeys.Contains(skPubKey)) 35 | 36 | data, err := got.Data() 37 | require.NoError(t, err) 38 | assert.Equal(t, name, data.Name) 39 | assert.NotEmpty(t, claims.Subject) 40 | } 41 | 42 | func TestNewOperatorFromJWT(t *testing.T) { 43 | operator, err := NewOperator(testutil.RandName(), NewID[NamespaceID]()) 44 | require.NoError(t, err) 45 | 46 | t.Run("valid data", func(t *testing.T) { 47 | data, err := operator.Data() 48 | require.NoError(t, err) 49 | got, err := NewOperatorFromData(operator.NKey(), operator.SigningKey(), data) 50 | require.NoError(t, err) 51 | require.Equal(t, operator, got) 52 | }) 53 | 54 | t.Run("invalid jwt", func(t *testing.T) { 55 | data, err := operator.Data() 56 | require.NoError(t, err) 57 | data.JWT = "invalid" 58 | _, err = NewOperatorFromData(operator.NKey(), operator.SigningKey(), data) 59 | require.Error(t, err) 60 | }) 61 | } 62 | 63 | func TestOperator_SetName(t *testing.T) { 64 | op, err := NewOperator(testutil.RandName(), NewID[NamespaceID]()) 65 | require.NoError(t, err) 66 | 67 | err = op.SetName("foo") 68 | require.NoError(t, err) 69 | 70 | data, err := op.Data() 71 | require.NoError(t, err) 72 | assert.Equal(t, "foo", data.Name) 73 | } 74 | 75 | func TestOperator_SetSystemAccount(t *testing.T) { 76 | op, err := NewOperator(testutil.RandName(), NewID[NamespaceID]()) 77 | require.NoError(t, err) 78 | 79 | acc, err := NewAccount("test_account", op) 80 | require.NoError(t, err) 81 | accData, err := acc.Data() 82 | require.NoError(t, err) 83 | 84 | err = op.SetSystemAccount(acc) 85 | require.NoError(t, err) 86 | claims, err := op.Claims() 87 | require.NoError(t, err) 88 | require.Equal(t, accData.PublicKey, claims.SystemAccount) 89 | } 90 | 91 | func TestOperator_Validate(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | modify func(*Operator) 95 | isValid bool 96 | }{ 97 | { 98 | name: "valid operator", 99 | modify: func(op *Operator) {}, 100 | isValid: true, 101 | }, 102 | { 103 | name: "invalid nkey", 104 | modify: func(op *Operator) { 105 | op.nk.Type = TypeAccount 106 | }, 107 | isValid: false, 108 | }, 109 | { 110 | name: "invalid signing key", 111 | modify: func(op *Operator) { 112 | op.sk.Type = TypeAccount 113 | }, 114 | isValid: false, 115 | }, 116 | { 117 | name: "invalid claims", 118 | modify: func(op *Operator) { 119 | op.JWT = "invalid" 120 | }, 121 | isValid: false, 122 | }, 123 | { 124 | name: "claims signing key mismatch", 125 | modify: func(op *Operator) { 126 | claims, err := jwt.DecodeOperatorClaims(op.JWT) 127 | require.NoError(t, err) 128 | claims.SigningKeys = jwt.StringList{} 129 | op.JWT, err = claims.Encode(op.SigningKey().KeyPair()) 130 | require.NoError(t, err) 131 | }, 132 | isValid: false, 133 | }, 134 | } 135 | 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | op, err := NewOperator(testutil.RandName(), NewID[NamespaceID]()) 139 | require.NoError(t, err) 140 | 141 | tt.modify(op) 142 | 143 | err = op.Validate() 144 | if tt.isValid { 145 | assert.NoError(t, err) 146 | } else { 147 | assert.Error(t, err) 148 | } 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /entity/resolver.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | const DefaultResolverDir = "./data" 9 | 10 | const dirResolverConfigTemplate = `# Operator named %s 11 | operator: %s 12 | 13 | # System Account named %s 14 | system_account: %s 15 | 16 | jetstream { 17 | store_dir: "%s/js" 18 | max_mem: 0 19 | max_file: 10GB 20 | } 21 | 22 | # Configuration of the nats based resolver 23 | resolver { 24 | type: full 25 | # Directory in which the account jwt will be stored 26 | dir: '%s/jwt' 27 | # In order to support jwt deletion, set to true 28 | # If the resolver type is full delete will rename the jwt. 29 | # This is to allow manual restoration in case of inadvertent deletion. 30 | # To restore a jwt, remove the added suffix .delete and restart or send a reload signal. 31 | # To free up storage you must manually delete files with the suffix .delete. 32 | allow_delete: false 33 | # Interval at which a nats-server with a nats based account resolver will compare 34 | # it's state with one random nats based account resolver in the cluster and if needed, 35 | # exchange jwt and converge on the same set of jwt. 36 | interval: "2m" 37 | # Timeout for lookup requests in case an account does not exist locally. 38 | timeout: "1.9s" 39 | } 40 | 41 | # Preload the nats based resolver with the system account jwt. 42 | # This only applies to the system account. Therefore other account jwt are not included here. 43 | # 44 | # To populate the resolver with additional accounts or account updates: 45 | # 46 | # Coro notifications: 47 | # 1) Establish a connection between Coro and your nats server using the Coro Proxy Agent. 48 | # 2) Account changes made via Coro will automatically be sent to your nats server. 49 | # 50 | # NSC tool: 51 | # 1) Import the operator of your nats cluster into nsc. 52 | # 3) make sure that your operator has the account server URL pointing at your nats servers. 53 | # The url must start with: "nats://" 54 | # nsc edit operator --account-jwt-server-url nats://localhost:4222 55 | # 3) push your accounts using: nsc push --all 56 | # The argument to push -u is optional if your account server url is set as described. 57 | # 3) to prune accounts use: nsc push --prune 58 | # In order to enable prune you must set above allow_delete to true 59 | resolver_preload: { 60 | # Later changes to the system account take precedence over the system account jwt listed below. 61 | %s: %s 62 | } 63 | ` 64 | 65 | // NewDirResolverConfig creates a configuration for a directory resolver. 66 | // The returned string can be written to a file and used to start a NATS server. 67 | func NewDirResolverConfig(op *Operator, sysAcc *Account, dirpath string) (string, error) { 68 | opData, err := op.Data() 69 | if err != nil { 70 | return "", err 71 | } 72 | 73 | ok, err := sysAcc.IsSystemAccount() 74 | if err != nil { 75 | return "", fmt.Errorf("check system account: %w", err) 76 | } else if !ok { 77 | return "", errors.New("resolver system account is not a system account") 78 | } 79 | 80 | sysAccData, err := sysAcc.Data() 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | cfgContent := fmt.Sprintf( 86 | dirResolverConfigTemplate, 87 | opData.Name, op.JWT, sysAccData.Name, sysAccData.PublicKey, dirpath, dirpath, sysAccData.PublicKey, sysAcc.JWT, 88 | ) 89 | 90 | return cfgContent, nil 91 | } 92 | -------------------------------------------------------------------------------- /entity/type.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "fmt" 4 | 5 | // Type represents the entity type. 6 | type Type int 7 | 8 | const ( 9 | TypeUnspecified Type = iota 10 | TypeOperator 11 | TypeAccount 12 | TypeUser 13 | typeEnd 14 | ) 15 | 16 | var typeNames = map[Type]string{ 17 | TypeUnspecified: "unspecified", 18 | TypeOperator: "operator", 19 | TypeAccount: "account", 20 | TypeUser: "user", 21 | } 22 | 23 | // String returns the string representation of the Type. 24 | // Defaults to "unspecified" if unrecognized. 25 | func (t Type) String() string { 26 | if name, ok := typeNames[t]; ok { 27 | return name 28 | } 29 | return typeNames[TypeUnspecified] 30 | } 31 | 32 | type Entity interface { 33 | Namespace | Operator | Account | User 34 | } 35 | 36 | func GetTypeName[T Entity]() string { 37 | var t T 38 | switch v := any(t).(type) { 39 | case Namespace: 40 | return "namespace" 41 | case Operator: 42 | return "operator" 43 | case Account: 44 | return "account" 45 | case User: 46 | return "user" 47 | default: 48 | panic(fmt.Sprintf("failed to get entity type name: unknown entity type %T", v)) 49 | } 50 | } 51 | 52 | // GetTypeNameFromID returns the type name of the ID. 53 | func GetTypeNameFromID[T ID]() string { 54 | var t T 55 | switch v := any(t).(type) { 56 | case NamespaceID: 57 | return "namespace" 58 | case OperatorID: 59 | return "operator" 60 | case AccountID: 61 | return "account" 62 | case UserID: 63 | return "user" 64 | default: 65 | panic(fmt.Sprintf("failed to get entity type name from id: unknown id type %T", v)) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /errtag/codes.go: -------------------------------------------------------------------------------- 1 | package errtag 2 | 3 | import "net/http" 4 | 5 | type codeInternal struct{} 6 | 7 | func (codeInternal) Code() int { return http.StatusInternalServerError } 8 | 9 | type codeUnauthorized struct{} 10 | 11 | func (codeUnauthorized) Code() int { return http.StatusUnauthorized } 12 | 13 | type codeBadRequest struct{} 14 | 15 | func (codeBadRequest) Code() int { return http.StatusBadRequest } 16 | 17 | type codeNotFound struct{} 18 | 19 | func (codeNotFound) Code() int { return http.StatusNotFound } 20 | 21 | type codeConflict struct{} 22 | 23 | func (codeConflict) Code() int { return http.StatusConflict } 24 | -------------------------------------------------------------------------------- /errtag/tag.go: -------------------------------------------------------------------------------- 1 | package errtag 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | type Option func(m *tagMeta) 10 | 11 | func WithMsg(message string) Option { 12 | return func(t *tagMeta) { 13 | t.msg = message 14 | } 15 | } 16 | func WithMsgf(format string, a ...any) Option { 17 | return func(t *tagMeta) { 18 | t.msg = fmt.Sprintf(format, a...) 19 | } 20 | } 21 | 22 | func WithDetails(details ...string) Option { 23 | return func(t *tagMeta) { 24 | t.details = details 25 | } 26 | } 27 | 28 | type Tagger interface { 29 | error 30 | Code() int 31 | Msg() string 32 | Details() []string 33 | } 34 | 35 | type TaggerPtr[T any] interface { 36 | *T 37 | init(cause error, opts ...Option) 38 | } 39 | 40 | func Tag[T Tagger, TP TaggerPtr[T]](cause error, opts ...Option) T { 41 | var t T 42 | TP(&t).init(cause, opts...) 43 | return t 44 | } 45 | 46 | func NewTagged[T Tagger, TP TaggerPtr[T]](cause string, opts ...Option) error { 47 | var t T 48 | TP(&t).init(errors.New(cause), opts...) 49 | return t 50 | } 51 | 52 | type Coder interface { 53 | Code() int 54 | } 55 | 56 | type ErrorTag[C Coder] struct { 57 | tagMeta 58 | } 59 | 60 | type tagMeta struct { 61 | cause error 62 | msg string 63 | details []string 64 | } 65 | 66 | func (t ErrorTag[C]) Error() string { 67 | if t.cause == nil { 68 | return t.Msg() 69 | } 70 | return t.cause.Error() 71 | } 72 | 73 | func (t ErrorTag[C]) Cause() error { 74 | return t.cause 75 | } 76 | 77 | func (t ErrorTag[C]) Unwrap() error { 78 | return t.cause 79 | } 80 | 81 | func (t ErrorTag[C]) Code() int { 82 | var c C 83 | return c.Code() 84 | } 85 | 86 | func (t ErrorTag[C]) Msg() string { 87 | if t.msg == "" { 88 | return http.StatusText(t.Code()) 89 | } 90 | return t.msg 91 | } 92 | 93 | func (t ErrorTag[C]) Details() []string { 94 | return t.details 95 | } 96 | 97 | func (t *ErrorTag[C]) init(cause error, opts ...Option) { 98 | t.cause = cause 99 | for _, opt := range opts { 100 | opt(&t.tagMeta) 101 | } 102 | } 103 | 104 | func HasTag[T Tagger](err error) bool { 105 | _, ok := AsTag[T](err) 106 | return ok 107 | } 108 | 109 | func AsTag[T Tagger](err error) (T, bool) { 110 | var out T 111 | if err == nil { 112 | return out, false 113 | } 114 | ok := errors.As(err, &out) 115 | return out, ok 116 | } 117 | -------------------------------------------------------------------------------- /errtag/tag_test.go: -------------------------------------------------------------------------------- 1 | package errtag 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestWithMsg(t *testing.T) { 13 | var meta tagMeta 14 | opt := WithMsg("custom message") 15 | opt(&meta) 16 | 17 | assert.Equal(t, "custom message", meta.msg) 18 | } 19 | 20 | func TestWithMsgf(t *testing.T) { 21 | var meta tagMeta 22 | opt := WithMsgf("formatted %s", "message") 23 | opt(&meta) 24 | 25 | assert.Equal(t, "formatted message", meta.msg) 26 | } 27 | 28 | func TestWithDetails(t *testing.T) { 29 | var meta tagMeta 30 | opt := WithDetails("detail1", "detail2") 31 | opt(&meta) 32 | 33 | assert.Equal(t, []string{"detail1", "detail2"}, meta.details) 34 | } 35 | 36 | func TestTag(t *testing.T) { 37 | err := errors.New("cause error") 38 | tag := Tag[NotFound, *NotFound](err, WithMsg("not found"), WithDetails("detail")) 39 | 40 | require.NotNil(t, tag) 41 | assert.Equal(t, http.StatusNotFound, tag.Code()) 42 | assert.Equal(t, "not found", tag.Msg()) 43 | assert.Equal(t, "cause error", tag.Error()) 44 | assert.Equal(t, []string{"detail"}, tag.Details()) 45 | } 46 | 47 | func TestNewTagged(t *testing.T) { 48 | taggedErr := NewTagged[Unauthorized, *Unauthorized]("unauthorized access", WithMsg("unauthorized")) 49 | require.NotNil(t, taggedErr) 50 | 51 | asUnauthorized, ok := AsTag[Unauthorized](taggedErr) 52 | require.True(t, ok) 53 | assert.Equal(t, http.StatusUnauthorized, asUnauthorized.Code()) 54 | assert.Equal(t, "unauthorized", asUnauthorized.Msg()) 55 | assert.Equal(t, "unauthorized access", asUnauthorized.Error()) 56 | } 57 | -------------------------------------------------------------------------------- /errtag/types.go: -------------------------------------------------------------------------------- 1 | package errtag 2 | 3 | type Unknown struct{ ErrorTag[codeInternal] } 4 | 5 | type Unauthorized struct{ ErrorTag[codeUnauthorized] } 6 | 7 | type InvalidArgument struct{ ErrorTag[codeBadRequest] } 8 | 9 | type NotFound struct{ ErrorTag[codeNotFound] } 10 | 11 | type Conflict struct{ ErrorTag[codeConflict] } 12 | -------------------------------------------------------------------------------- /examples/quickstart/README.md: -------------------------------------------------------------------------------- 1 | # Quickstart example 2 | 3 | This folder contains a minimal setup to quickly explore Coro. 4 | The setup runs the all-in-one mode that combines all 5 | services into a single process. 6 | 7 | ## Steps 8 | 9 | 1. Spin up the environment with Docker Compose to start the following services: 10 | 11 | - **Postgres** initialized with `coro-pgtool` 12 | - **Coro** (all-in-one mode) 13 | 14 | ```shell 15 | docker compose -p coro up -d 16 | ``` 17 | 18 | 2. Open http://localhost:5400 in your browser. 19 | 3. Create a new Operator and open it. 20 | 4. Head to the `NATS` tab and follow the instructions on how to set up a NATS server and Coro Proxy Agent. 21 | - Use the following flags when running the Proxy Agent: 22 | - `--token ` 23 | - `--nats-url nats://host.docker.internal:4222` 24 | - `--broker-url ws://host.docker.internal:5400/api/v1/broker` 25 | 5. Once your NATS server is connected, create a new Account and User. 26 | 6. Open the User, head to the `Connect` tab, and download the User's credentials file 27 | 7. Connect to the NATS server using the credentials. 28 | 8. Teardown environment. 29 | ```shell 30 | docker compose -p coro down -v 31 | ``` 32 | -------------------------------------------------------------------------------- /examples/quickstart/config.yaml: -------------------------------------------------------------------------------- 1 | port: 5400 2 | logger: 3 | level: info 4 | structured: true 5 | encryptionSecretKey: "00deaa689d7b85e4a68d416678e206cb" 6 | postgres: 7 | hostPort: "db:5432" 8 | user: postgres 9 | password: postgres 10 | -------------------------------------------------------------------------------- /examples/quickstart/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:17 4 | environment: 5 | POSTGRES_DB: postgres 6 | POSTGRES_USER: postgres 7 | POSTGRES_PASSWORD: postgres 8 | POSTGRES_PORT: 5432 9 | ports: 10 | - "5432:5432" 11 | user: postgres 12 | healthcheck: 13 | test: [ "CMD-SHELL", "pg_isready -U postgres -d postgres -h localhost" ] 14 | interval: 10s 15 | timeout: 5s 16 | retries: 10 17 | networks: 18 | - example 19 | 20 | coro-pgtool: 21 | depends_on: 22 | db: 23 | condition: service_healthy 24 | image: corosh/coro-pgtool 25 | ports: 26 | - "5400:5400" 27 | command: [ "--user", "postgres", "--password", "postgres", "--host", "db", "--port", "5432", "init" ] 28 | networks: 29 | - example 30 | 31 | coro: 32 | depends_on: 33 | db: 34 | condition: service_healthy 35 | coro-pgtool: 36 | condition: service_completed_successfully 37 | image: corosh/coro 38 | ports: 39 | - "5400:5400" 40 | volumes: 41 | - "./config.yaml:/config.yaml:ro" 42 | command: [ "--service", "all", "--config", "config.yaml" ] 43 | networks: 44 | - example 45 | 46 | networks: 47 | example: 48 | driver: bridge 49 | 50 | volumes: 51 | nats-config: -------------------------------------------------------------------------------- /examples/scaling/README.md: -------------------------------------------------------------------------------- 1 | # Scaling example 2 | 3 | This folder provides a minimal setup to explore Coro's scaling capabilities. It demonstrates how to run multiple 4 | **Controller** service replicas and a **Broker** cluster behind an **Nginx** load balancer. 5 | 6 | Each **Broker** node includes an embedded NATS server, which enables communication between nodes to form a cluster. 7 | 8 | ## Steps 9 | 10 | 1. Spin up the environment with Docker Compose to start the following services: 11 | 12 | - **Postgres** (database) 13 | - **Coro Controller** (2 replicas) 14 | - **Coro Broker** cluster (2 nodes) 15 | - **Coro UI** 16 | - **Nginx** (load balancer) 17 | 18 | ```shell 19 | docker compose -p coro-scaling up -d 20 | ``` 21 | 2. Open http://localhost:8400 in your browser. 22 | 3. Create a new Operator and open it. 23 | 4. Head to the `NATS` tab and follow the instructions on how to set up a NATS server and Coro Proxy Agent. 24 | - Use the following flags when running the Proxy Agent: 25 | - `--token ` 26 | - `--nats-url nats://host.docker.internal:4222` 27 | - `--broker-url ws://host.docker.internal:8080/broker-svc/api/v1/broker` 28 | - Nginx will take care of routing the Proxy Agent's WebSocket connection to one of the Broker 29 | cluster nodes. 30 | 5. Once your NATS server is connected, create a new Account. 31 | 6. A successful Account creation confirms that: 32 | - One of the Controller service replicas handled the API request. 33 | - The request triggered a notification in one of the Broker cluster nodes. 34 | - The Proxy Agent received and forwarded the notification to the connected NATS server. 35 | 7. Teardown environment. 36 | ```shell 37 | docker compose -p coro-scaling down -v 38 | ``` 39 | -------------------------------------------------------------------------------- /examples/scaling/config_broker_1.yaml: -------------------------------------------------------------------------------- 1 | port: 6400 2 | logger: 3 | level: info 4 | structured: true 5 | encryptionSecretKey: "00deaa689d7b85e4a68d416678e206cb" 6 | postgres: 7 | hostPort: "db:5432" 8 | user: postgres 9 | password: postgres 10 | embeddedNats: 11 | hostPort: 0.0.0.0:5222 12 | -------------------------------------------------------------------------------- /examples/scaling/config_broker_2.yaml: -------------------------------------------------------------------------------- 1 | port: 6400 2 | logger: 3 | level: info 4 | structured: true 5 | encryptionSecretKey: "00deaa689d7b85e4a68d416678e206cb" 6 | postgres: 7 | hostPort: "db:5432" 8 | user: postgres 9 | password: postgres 10 | embeddedNats: 11 | hostPort: 0.0.0.0:6222 12 | nodeRoutes: 13 | - "nats://broker-node-1:5222" 14 | -------------------------------------------------------------------------------- /examples/scaling/config_controller.yaml: -------------------------------------------------------------------------------- 1 | port: 5400 2 | logger: 3 | level: info 4 | structured: true 5 | encryptionSecretKey: "00deaa689d7b85e4a68d416678e206cb" 6 | postgres: 7 | hostPort: "db:5432" 8 | user: postgres 9 | password: postgres 10 | broker: 11 | natsURLs: 12 | - "nats://broker-node-1:5222" 13 | - "nats://broker-node-2:6222" # node 2 is optional (only included as a safeguard in the event that node 1 is unavailable) 14 | -------------------------------------------------------------------------------- /examples/scaling/config_ui.yaml: -------------------------------------------------------------------------------- 1 | port: 8400 2 | logger: 3 | level: info 4 | structured: true 5 | apiAddress: http://nginx:80/controller-svc 6 | -------------------------------------------------------------------------------- /examples/scaling/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:17 4 | environment: 5 | POSTGRES_DB: postgres 6 | POSTGRES_USER: postgres 7 | POSTGRES_PASSWORD: postgres 8 | POSTGRES_PORT: 5432 9 | ports: 10 | - "5432:5432" 11 | user: postgres 12 | healthcheck: 13 | test: [ "CMD-SHELL", "pg_isready -U postgres -d postgres -h localhost" ] 14 | interval: 10s 15 | timeout: 5s 16 | retries: 10 17 | networks: 18 | - example 19 | 20 | coro-pgtool: 21 | depends_on: 22 | db: 23 | condition: service_healthy 24 | image: corosh/coro-pgtool 25 | ports: 26 | - "5400:5400" 27 | command: [ "--user", "postgres", "--password", "postgres", "--host", "db", "--port", "5432", "init" ] 28 | networks: 29 | - example 30 | 31 | controller-server: 32 | depends_on: 33 | db: 34 | condition: service_healthy 35 | coro-pgtool: 36 | condition: service_completed_successfully 37 | image: corosh/coro 38 | deploy: 39 | replicas: 2 40 | expose: 41 | - "5400" 42 | volumes: 43 | - "./config_controller.yaml:/config_controller.yaml:ro" 44 | command: [ "--service", "controller", "--config", "config_controller.yaml" ] 45 | networks: 46 | - example 47 | 48 | broker-node-1: 49 | depends_on: 50 | db: 51 | condition: service_healthy 52 | coro-pgtool: 53 | condition: service_completed_successfully 54 | image: corosh/coro 55 | ports: 56 | - "5222:5222" # embedded nats server 57 | expose: 58 | - "6400" 59 | volumes: 60 | - "./config_broker_1.yaml:/config_broker_1.yaml:ro" 61 | command: [ "--service", "broker", "--config", "config_broker_1.yaml" ] 62 | networks: 63 | - example 64 | 65 | broker-node-2: 66 | depends_on: 67 | db: 68 | condition: service_healthy 69 | coro-pgtool: 70 | condition: service_completed_successfully 71 | image: corosh/coro 72 | ports: 73 | - "6222:6222" # embedded nats server 74 | expose: 75 | - "6400" 76 | volumes: 77 | - "./config_broker_2.yaml:/config_broker_2.yaml:ro" 78 | command: [ "--service", "broker", "--config", "config_broker_2.yaml" ] 79 | networks: 80 | - example 81 | 82 | ui: 83 | image: corosh/coro 84 | ports: 85 | - "8400:8400" 86 | volumes: 87 | - "./config_ui.yaml:/config_ui.yaml:ro" 88 | command: [ "--service", "ui", "--config", "config_ui.yaml" ] 89 | networks: 90 | - example 91 | 92 | nginx: 93 | image: nginx:latest 94 | depends_on: 95 | controller-server: 96 | condition: service_started 97 | broker-node-1: 98 | condition: service_started 99 | broker-node-2: 100 | condition: service_started 101 | volumes: 102 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 103 | ports: 104 | - "8080:80" 105 | networks: 106 | - example 107 | 108 | networks: 109 | example: 110 | driver: bridge 111 | 112 | volumes: 113 | nats-config: -------------------------------------------------------------------------------- /examples/scaling/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | upstream controller_servers { 9 | # Docker's internal DNS will resolve 'controller_server' to scaled replicas 10 | # (e.g., controller-server-1, controller-server-2) 11 | server controller-server:5400; 12 | } 13 | 14 | upstream broker_servers { 15 | server broker-node-1:6400; 16 | server broker-node-2:6400; 17 | } 18 | 19 | server { 20 | listen 80; 21 | 22 | # Location for Controller service requests 23 | location /controller-svc/ { 24 | proxy_pass http://controller_servers/; 25 | } 26 | 27 | # Location for Broker service requests 28 | location /broker-svc/ { 29 | proxy_pass http://broker_servers/; 30 | 31 | # WebSocket 32 | proxy_http_version 1.1; 33 | proxy_set_header Upgrade $http_upgrade; 34 | proxy_set_header Connection "upgrade"; 35 | proxy_read_timeout 60s; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/coro-sh/coro 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/caarlos0/env/v11 v11.3.1 7 | github.com/cenkalti/backoff/v4 v4.3.0 8 | github.com/coder/websocket v1.8.13 9 | github.com/cohesivestack/valgo v0.4.1 10 | github.com/coro-sh/coro-ui-server v0.0.2 11 | github.com/golang-migrate/migrate/v4 v4.18.3 12 | github.com/google/uuid v1.6.0 13 | github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 14 | github.com/jackc/pgx/v5 v5.7.5 15 | github.com/labstack/echo/v4 v4.13.4 16 | github.com/lmittmann/tint v1.1.0 17 | github.com/nats-io/jwt/v2 v2.7.4 18 | github.com/nats-io/nats-server/v2 v2.11.3 19 | github.com/nats-io/nats.go v1.42.0 20 | github.com/nats-io/nkeys v0.4.11 21 | github.com/oapi-codegen/runtime v1.1.1 22 | github.com/stretchr/testify v1.10.0 23 | github.com/urfave/cli/v2 v2.27.6 24 | go.jetify.com/typeid v1.3.0 25 | golang.org/x/crypto v0.38.0 26 | golang.org/x/sync v0.14.0 27 | golang.org/x/text v0.25.0 28 | google.golang.org/protobuf v1.36.6 29 | gopkg.in/yaml.v3 v3.0.1 30 | ) 31 | 32 | require ( 33 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 34 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 35 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 36 | github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect 37 | github.com/fsnotify/fsnotify v1.6.0 // indirect 38 | github.com/getkin/kin-openapi v0.132.0 // indirect 39 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 40 | github.com/go-openapi/swag v0.23.0 // indirect 41 | github.com/gofrs/uuid/v5 v5.3.1 // indirect 42 | github.com/google/go-cmp v0.7.0 // indirect 43 | github.com/google/go-tpm v0.9.3 // indirect 44 | github.com/hashicorp/errwrap v1.1.0 // indirect 45 | github.com/hashicorp/go-multierror v1.1.1 // indirect 46 | github.com/jackc/pgpassfile v1.0.0 // indirect 47 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 48 | github.com/jackc/puddle/v2 v2.2.2 // indirect 49 | github.com/josharian/intern v1.0.0 // indirect 50 | github.com/klauspost/compress v1.18.0 // indirect 51 | github.com/labstack/gommon v0.4.2 // indirect 52 | github.com/lib/pq v1.10.9 // indirect 53 | github.com/mailru/easyjson v0.7.7 // indirect 54 | github.com/mattn/go-colorable v0.1.14 // indirect 55 | github.com/mattn/go-isatty v0.0.20 // indirect 56 | github.com/minio/highwayhash v1.0.3 // indirect 57 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 58 | github.com/nats-io/nuid v1.0.1 // indirect 59 | github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect 60 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 61 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 62 | github.com/perimeterx/marshmallow v1.1.5 // indirect 63 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 64 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 65 | github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect 66 | github.com/valyala/bytebufferpool v1.0.0 // indirect 67 | github.com/valyala/fasttemplate v1.2.2 // indirect 68 | github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect 69 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 70 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 71 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 72 | go.uber.org/atomic v1.11.0 // indirect 73 | golang.org/x/mod v0.24.0 // indirect 74 | golang.org/x/net v0.40.0 // indirect 75 | golang.org/x/sys v0.33.0 // indirect 76 | golang.org/x/time v0.11.0 // indirect 77 | golang.org/x/tools v0.33.0 // indirect 78 | gopkg.in/yaml.v2 v2.4.0 // indirect 79 | ) 80 | 81 | tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen 82 | -------------------------------------------------------------------------------- /img/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coro-sh/coro/5d334a3035568d1ff9cc51934be3ebe7f8a4a29e/img/logo-dark.png -------------------------------------------------------------------------------- /img/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coro-sh/coro/5d334a3035568d1ff9cc51934be3ebe7f8a4a29e/img/logo-light.png -------------------------------------------------------------------------------- /internal/constants/constants.go: -------------------------------------------------------------------------------- 1 | // Package constants defines a collection of names and keywords that have 2 | // special meanings within the context of the app. These values are used to 3 | // enforce naming constraints, avoid conflicts, and ensure consistency across 4 | // various components of the system. This package centralizes these constants to 5 | // maintain a single source of truth for all identifiers, making it easier to 6 | // update and reference them throughout the codebase. 7 | package constants 8 | 9 | const ( 10 | AppName = "coro" 11 | AppNameUpper = "CORO" 12 | SysAccountName = "SYS" 13 | SysUserName = "sys" 14 | BrokerOperatorName = AppName + "_broker" 15 | InternalNamespaceName = AppName + "_internal" 16 | DefaultNamespaceName = "default" 17 | ) 18 | 19 | func IsReservedNamespaceName(name string) bool { 20 | return name == InternalNamespaceName 21 | } 22 | 23 | func IsReservedOperatorName(name string) bool { 24 | return name == BrokerOperatorName 25 | } 26 | 27 | func IsReservedAccountName(name string) bool { 28 | return name == SysAccountName 29 | } 30 | 31 | func IsReservedUserName(name string) bool { 32 | return name == SysUserName 33 | } 34 | -------------------------------------------------------------------------------- /internal/testutil/fakes.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/coro-sh/coro/tx" 7 | ) 8 | 9 | type FakeTx struct{} 10 | 11 | func (t *FakeTx) Commit(_ context.Context) error { return nil } 12 | 13 | func (t *FakeTx) Rollback(_ context.Context) error { return nil } 14 | 15 | type FakeTxer struct{} 16 | 17 | func (t *FakeTxer) BeginTx(_ context.Context) (tx.Tx, error) { return &FakeTx{}, nil } 18 | -------------------------------------------------------------------------------- /internal/testutil/freeport.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "runtime" 7 | "strconv" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | const LocalIP = "127.0.0.1" 14 | 15 | // GetFreePort returns a TCP port that is available to listen on, for the 16 | // local host 127.0.0.1. 17 | // 18 | // Copied from: 19 | // https://github.com/temporalio/cli/blob/main/temporalcli/devserver/freeport.go 20 | // 21 | // This works by binding a new TCP socket on port 0, which requests the OS to 22 | // allocate a free port. There is no strict guarantee that the port will remain 23 | // available after this function returns, but it should be safe to assume that 24 | // a given port will not be allocated again to any process on this machine 25 | // within a few seconds. 26 | // 27 | // On Unix-based systems, binding to the port returned by this function requires 28 | // setting the `SO_REUSEADDR` socket option (Go already does that by default, 29 | // but other languages may not); otherwise, the OS may fail with a message such 30 | // as "address already in use". Windows default behavior is already appropriate 31 | // in this regard; on that platform, `SO_REUSEADDR` has a different meaning and 32 | // should not be set (setting it may have unpredictable consequences). 33 | func GetFreePort(t *testing.T) int { 34 | l, err := net.Listen("tcp", LocalIP+":0") 35 | require.NoError(t, err) 36 | defer l.Close() 37 | port := l.Addr().(*net.TCPAddr).Port 38 | 39 | // On Linux and some BSD variants, ephemeral ports are randomized, and may 40 | // consequently repeat within a short time frame after the listening end 41 | // has been closed. To avoid this, we make a connection to the port, then 42 | // close that connection from the server's side (this is very important), 43 | // which puts the connection in TIME_WAIT state for some time (by default, 44 | // 60s on Linux). While it remains in that state, the OS will not reallocate 45 | // that port number for bind(:0) syscalls, yet we are not prevented from 46 | // explicitly binding to it (thanks to SO_REUSEADDR). 47 | // 48 | // On macOS and Windows, the above technique is not necessary, as the OS 49 | // allocates ephemeral ports sequentially, meaning a port number will only 50 | // be reused after the entire range has been exhausted. Quite the opposite, 51 | // given that these OSes use a significantly smaller range for ephemeral 52 | // ports, making an extra connection just to reserve a port might actually 53 | // be harmful (by hastening ephemeral port exhaustion). 54 | if runtime.GOOS != "darwin" && runtime.GOOS != "windows" { 55 | // DialTCP(..., l.Addr()) might fail if machine has IPv6 support, but 56 | // isn't fully configured (e.g. doesn't have a loopback interface bound 57 | // to ::1). For safety, rebuild address form the original host instead. 58 | tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", LocalIP, port)) 59 | require.NoError(t, err, "error resolving address") 60 | r, err := net.DialTCP("tcp", nil, tcpAddr) 61 | require.NoError(t, err, "failed to dial tcp") 62 | c, err := l.Accept() 63 | require.NoError(t, err, "failed to accept connection") 64 | // Closing the socket from the server side 65 | require.NoError(t, c.Close()) 66 | defer func() { 67 | require.NoError(t, r.Close()) 68 | }() 69 | } 70 | 71 | return port 72 | } 73 | 74 | func GetFreeHostPort(t *testing.T) string { 75 | return LocalIP + ":" + strconv.Itoa(GetFreePort(t)) 76 | } 77 | -------------------------------------------------------------------------------- /internal/testutil/http.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/labstack/echo/v4" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | var DefaultClient = &http.Client{ 16 | Transport: &http.Transport{MaxIdleConnsPerHost: 1}, 17 | Timeout: 10 * time.Second, 18 | } 19 | 20 | func Get[R any](t *testing.T, url string) R { 21 | t.Helper() 22 | httpRes, err := DefaultClient.Get(url) 23 | require.NoError(t, err) 24 | defer httpRes.Body.Close() 25 | 26 | handleHTTPErr(t, httpRes, http.MethodGet, url) 27 | 28 | var res R 29 | err = json.NewDecoder(httpRes.Body).Decode(&res) 30 | require.NoError(t, err) 31 | return res 32 | } 33 | 34 | func GetText(t *testing.T, url string) string { 35 | t.Helper() 36 | httpRes, err := DefaultClient.Get(url) 37 | require.NoError(t, err) 38 | defer httpRes.Body.Close() 39 | 40 | handleHTTPErr(t, httpRes, http.MethodGet, url) 41 | 42 | body, err := io.ReadAll(httpRes.Body) 43 | require.NoError(t, err) 44 | return string(body) 45 | } 46 | 47 | func Post[R any](t *testing.T, url string, req any) R { 48 | t.Helper() 49 | reqJSON, err := json.Marshal(req) 50 | require.NoError(t, err) 51 | 52 | httpRes, err := DefaultClient.Post(url, echo.MIMEApplicationJSON, bytes.NewReader(reqJSON)) 53 | require.NoError(t, err) 54 | defer httpRes.Body.Close() 55 | 56 | handleHTTPErr(t, httpRes, http.MethodPost, url) 57 | 58 | var res R 59 | err = json.NewDecoder(httpRes.Body).Decode(&res) 60 | require.NoError(t, err) 61 | return res 62 | } 63 | 64 | func Put[R any](t *testing.T, url string, req any) R { 65 | t.Helper() 66 | reqJSON, err := json.Marshal(req) 67 | require.NoError(t, err) 68 | 69 | putReq, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(reqJSON)) 70 | require.NoError(t, err) 71 | putReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 72 | 73 | httpRes, err := DefaultClient.Do(putReq) 74 | require.NoError(t, err) 75 | defer httpRes.Body.Close() 76 | 77 | handleHTTPErr(t, httpRes, http.MethodPut, url) 78 | 79 | var res R 80 | err = json.NewDecoder(httpRes.Body).Decode(&res) 81 | require.NoError(t, err) 82 | return res 83 | } 84 | 85 | func Delete(t *testing.T, url string) { 86 | t.Helper() 87 | deleteReq, err := http.NewRequest(http.MethodDelete, url, nil) 88 | require.NoError(t, err) 89 | 90 | httpRes, err := DefaultClient.Do(deleteReq) 91 | require.NoError(t, err) 92 | defer httpRes.Body.Close() 93 | 94 | handleHTTPErr(t, httpRes, http.MethodDelete, url) 95 | } 96 | 97 | func handleHTTPErr(t *testing.T, httpRes *http.Response, method string, url string) { 98 | t.Helper() 99 | if httpRes.StatusCode < 200 || httpRes.StatusCode >= 300 { 100 | body, _ := io.ReadAll(httpRes.Body) 101 | require.Failf(t, "http error", "%s %s\nStatus: %s\nBody: %s", method, url, httpRes.Status, string(body)) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /internal/testutil/kv.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "sync" 7 | "testing" 8 | ) 9 | 10 | type KVOption[K comparable, V any] func(r *KV[K, V]) 11 | 12 | type KVIndex[V any] struct { 13 | keyFn func(value V) string 14 | data map[string]V 15 | } 16 | 17 | func WithIndex[K comparable, V any](name string, keyFn func(value V) string) KVOption[K, V] { 18 | return func(r *KV[K, V]) { 19 | r.indexes[name] = &KVIndex[V]{ 20 | keyFn: keyFn, 21 | data: map[string]V{}, 22 | } 23 | } 24 | } 25 | 26 | // KV is key value data structure to create fake data stores with such as 27 | // store.FakeEntityReadWriter. 28 | type KV[K comparable, V any] struct { 29 | mu *sync.RWMutex 30 | data map[K]V 31 | indexes map[string]*KVIndex[V] 32 | } 33 | 34 | func NewKV[K comparable, V any](t *testing.T, opts ...KVOption[K, V]) *KV[K, V] { 35 | t.Helper() 36 | init := &KV[K, V]{ 37 | mu: new(sync.RWMutex), 38 | data: map[K]V{}, 39 | indexes: map[string]*KVIndex[V]{}, 40 | } 41 | for _, opt := range opts { 42 | opt(init) 43 | } 44 | return init 45 | } 46 | 47 | func (k *KV[K, V]) Put(key K, value V) { 48 | k.mu.Lock() 49 | defer k.mu.Unlock() 50 | k.data[key] = value 51 | for _, idx := range k.indexes { 52 | idxKey := idx.keyFn(value) 53 | idx.data[idxKey] = value 54 | } 55 | } 56 | 57 | func (k *KV[K, V]) Get(key K) (V, bool) { 58 | k.mu.RLock() 59 | defer k.mu.RUnlock() 60 | v, ok := k.data[key] 61 | return v, ok 62 | } 63 | 64 | func (k *KV[K, V]) GetByIndex(index string, key string) (V, bool) { 65 | k.mu.RLock() 66 | defer k.mu.RUnlock() 67 | var v V 68 | 69 | idx, ok := k.indexes[index] 70 | if !ok { 71 | return v, false 72 | } 73 | 74 | v, ok = idx.data[key] 75 | return v, ok 76 | } 77 | 78 | func (k *KV[K, V]) Delete(key K) { 79 | k.mu.Lock() 80 | defer k.mu.Unlock() 81 | value, ok := k.data[key] 82 | if !ok { 83 | return 84 | } 85 | delete(k.data, key) 86 | for _, idx := range k.indexes { 87 | idxKey := idx.keyFn(value) 88 | delete(idx.data, idxKey) 89 | } 90 | } 91 | 92 | func (k *KV[K, V]) Range(fn func(key K, val V) bool) { 93 | for key, val := range k.data { 94 | k.mu.RLock() 95 | cont := fn(key, val) 96 | k.mu.RUnlock() 97 | if !cont { 98 | return 99 | } 100 | } 101 | } 102 | 103 | type PageFilter struct { 104 | Size int32 105 | Cursor any 106 | } 107 | 108 | // List returns a list of values from the KV store in descending order. 109 | // The filter is used to paginate the results returned. Skippers can optionally 110 | // be provided to skip values from the list. 111 | func (k *KV[K, V]) List(filter PageFilter, skippers ...func(K, V) bool) []V { 112 | k.mu.RLock() 113 | defer k.mu.RUnlock() 114 | 115 | var keys []K 116 | 117 | k.Range(func(key K, val V) bool { 118 | for _, skipper := range skippers { 119 | if skipper(key, val) { 120 | return true 121 | } 122 | } 123 | keys = append(keys, key) 124 | return true 125 | }) 126 | 127 | // Sort the keys to ensure deterministic iteration 128 | sort.Slice(keys, func(i, j int) bool { 129 | return fmt.Sprintf("%v", keys[i]) < fmt.Sprintf("%v", keys[j]) 130 | }) 131 | 132 | var result []V 133 | size := filter.Size 134 | 135 | count := int32(0) 136 | for _, key := range keys { 137 | if filter.Cursor != nil && fmt.Sprintf("%v", key) < fmt.Sprintf("%v", filter.Cursor) { 138 | // Skip until the cursor is passed 139 | continue 140 | } 141 | 142 | value, ok := k.Get(key) 143 | if !ok { 144 | continue 145 | } 146 | result = append(result, value) 147 | count++ 148 | 149 | if size > 0 && count >= size { 150 | // Stop if size is reached 151 | break 152 | } 153 | } 154 | 155 | return result 156 | } 157 | -------------------------------------------------------------------------------- /internal/testutil/random.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | ) 7 | 8 | var leftNames = []string{ 9 | "brave", "calm", "eager", "gentle", "kind", "proud", "quiet", "sharp", "wise", "zealous", 10 | "bold", "clever", "curious", "daring", "focused", "graceful", "humble", "jolly", "lively", "merry", 11 | "patient", "quick", "resourceful", "steady", "thoughtful", "trusty", "vivid", "witty", "zesty", "cheerful", 12 | } 13 | 14 | var rightNames = []string{ 15 | "builder", "creator", "dreamer", "explorer", "friend", "helper", "leader", "maker", "seeker", "thinker", 16 | "artisan", "pathfinder", "innovator", "navigator", "observer", "planner", "storyteller", "strategist", "tinkerer", "visionary", 17 | "adventurer", "collaborator", "discoverer", "engineer", "fixer", "pioneer", "scholar", "traveler", "watcher", "worker", 18 | } 19 | 20 | func RandName() string { 21 | left := leftNames[rand.Intn(len(leftNames))] 22 | right := rightNames[rand.Intn(len(rightNames))] 23 | return left + "_" + right + "_" + strconv.Itoa(rand.Intn(50)) 24 | } 25 | 26 | func RandString(length int) string { 27 | const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 28 | b := make([]byte, length) 29 | for i := range b { 30 | b[i] = charset[rand.Intn(len(charset))] 31 | } 32 | return string(b) 33 | } 34 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/README.md: -------------------------------------------------------------------------------- 1 | ## Test Certificates 2 | 3 | 1. Create a Certificate Authority (CA) Certificate and Key: 4 | 5 | ```bash 6 | # Generate the CA private key 7 | openssl genrsa -out ca-key.pem 4096 8 | 9 | # Create the CA self-signed certificate 10 | openssl req -x509 -new -nodes -key ca-key.pem -sha256 -days 3650 -out ca-cert.pem \ 11 | -subj "/CN=MyCA" 12 | ``` 13 | 14 | 2. Generate the Server Certificate and Key: 15 | 16 | ```bash 17 | # Generate the server private key 18 | openssl genrsa -out server-key.pem 4096 19 | 20 | # Create a certificate signing request (CSR) for the server 21 | openssl req -new -key server-key.pem -out server.csr \ 22 | -subj "/CN=localhost" 23 | 24 | # Sign the server CSR with the CA certificate to create the server certificate 25 | openssl x509 -req -in server.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial \ 26 | -out server-cert.pem -days 365 -sha256 \ 27 | -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1,IP:0.0.0.0\nextendedKeyUsage=serverAuth") 28 | ``` 29 | 30 | 3. Generate the Client Certificate: 31 | 32 | ```bash 33 | # Generate the client private key 34 | openssl genrsa -out client-key.pem 4096 35 | 36 | # Create a certificate signing request (CSR) for the client 37 | openssl req -new -key client-key.pem -out client.csr \ 38 | -subj "/CN=client" 39 | 40 | # Sign the client CSR with the CA certificate to create the client certificate 41 | openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial \ 42 | -out client-cert.pem -days 365 -sha256 \ 43 | -extfile <(printf "extendedKeyUsage=clientAuth") 44 | ``` 45 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/ca-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE/zCCAuegAwIBAgIUYHj6+2h0y00f2Fyo1PQHG3SBlRIwDQYJKoZIhvcNAQEL 3 | BQAwDzENMAsGA1UEAwwETXlDQTAeFw0yNTAxMTcwMjUxMTNaFw0zNTAxMTUwMjUx 4 | MTNaMA8xDTALBgNVBAMMBE15Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK 5 | AoICAQC2sjgwOMXoZsxsuetmMSePjIefoxnIEVP3Yv5W4ZxPsB8gY2ta11EkTaYz 6 | K97qo57vfo896uEVbfXiixhrxS83BhzIsSOMi3nRIZ5VCnvF+5j767Zp5wsjf7/k 7 | u3yew3p8N1NhQawr7Qu+YhjfxiKP9cwSkDgFN9SRGSDiVlnunfKRp8+M0hkc/6+V 8 | fR4HfIojGoX+S0XwNmpbmd+7lJ/9p46RAmp4BpWUYJ0+7CvkH5eUDEdVPLwInLPf 9 | qkOrGjOoPskgSpvnYnc1VLA29TCFGbKOAcNzJn97q/Aat5NBuGRPEDrePu3e0Kkz 10 | MeOxODhfjNwqgaKPanixiuVbKNmrGOnruDQNrVkrlVPKtiJac85pzoX9fvUcDBJa 11 | vLi1yNQfUuQtBDozacYCmdG13vEpsHplzo2dWHmahwGCn6whzuhK4mT9YHGzvFv8 12 | xQjxILclzj/fRzkBQiJ+F4e5lUBpdYt0zTEYBL5+LYcsxkF0ArIMyEWHNq3/qPdi 13 | pixMnKCT8uXAjCCB59KfCQKMrpM55yOOhDxUmkZjxHr2y+fb8zg0fK6KQxcrO4ts 14 | VFAALQM9pztLhp3NaoI22Pf0+9MLZtk5Hdj5gdhqiSgmeis5+T9mP706z1xOLXBU 15 | PGPuRMR8bP///CUbV1thTnYKCIFTT+cpgu+TKfkhKhCzZg9fqQIDAQABo1MwUTAd 16 | BgNVHQ4EFgQUuWAPSl0rej2gBymf3p0mjfcB67owHwYDVR0jBBgwFoAUuWAPSl0r 17 | ej2gBymf3p0mjfcB67owDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC 18 | AgEAawmmMfsG5pbNkweA2aM//rCeu3Clhv+ZIqs8tR0zxAXyvvds+tH3XAiQ9dOy 19 | Lg+ikWeHpUcZz07oRasFGMTjvpqV/ApNwYv7LjbLtg5FF2FwAKUQWHtmveDcNKCX 20 | uYlf6llAtbd+tj5iVhKF7S00Gsrg8+VPTtpWaA3c+lmyItb6f2VBfmCvo2jX6WEx 21 | BpKmxKIf5rNyhFtd6QZHfiagUqZMmfzRa/LWkwQdPaIt9D01sdcEnPSwCkCT30lz 22 | o8w07NLyeOG5xvNhxu9Xp7G5gxmEJ3c9Xl6PsCwmOJVfTACoLPEvGbrTbfHbrekQ 23 | qck3vD5fmPfw9r/36Kx5SnqKmrtD/a4/vvLdPTWRoEBr81sYFDRs6gxHkwiy5S2N 24 | 2cXGXp3bhwblUfb7wvdN+Guv6Nbt+LQir7S6npTGA5goFWwc+phU4m9hBBC/sFcP 25 | 7YKTgcvieCbpVrz/LauHAZ9m7ryVJOYrDOxmf06ZkRCWzHtTf5kOl7eAC6Zq6vpL 26 | ly0XXD6VtoXp/LgrAex5ZemfUgQpg8DYa/ztYqEmwfchZbSvSntFmCVoknZK0PaT 27 | Oodg4YOjz/4rDsvIs8JJSIFt/8i6D9AI21YyrHY00uuXdELyQYIa6A8z85kkPXCd 28 | 6WbJUTaqlFfVIh+Axv/kixLQV0KobwbaTx+OsyODRspM1fE= 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/ca-cert.srl: -------------------------------------------------------------------------------- 1 | 69D616ECF9A082AFE38FABE49478C0726F6FED30 2 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/ca-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | O4XcZg3CtUPBeRFle0siV8Sfaq+CRA== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/client-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFBTCCAu2gAwIBAgIUadYW7Pmggq/jj6vklHjAcm9v7S8wDQYJKoZIhvcNAQEL 3 | BQAwDzENMAsGA1UEAwwETXlDQTAeFw0yNTAxMTcwMjUxMjFaFw0yNjAxMTcwMjUx 4 | MjFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC 5 | AgoCggIBAJP41GTHEJtLemPn83H8W1AsqJ6770rL8dSQ9SXqPDhSmrt1Va7K4etX 6 | jJsZshOr4H/IyAGBNNVB/Qt+LeXhu94maiM86dbajkBqFchv430B515RJglxwYmc 7 | 3SkqYiVadYkOUCldgXEcA//7NTVmv2kIvfytjDE3tAMiz8QfxZCm7TL59W3+D52+ 8 | WK7WURsUSvykVGHwpALidO8pWnQ6wdKM4EU8ZKPrvNIrBCVGh1tqe4lFpa2FYwsB 9 | PMMXxrhv4S+nrTN3aQ1w6RggogZLgOM2aUrLTgk5iyoJCe/2U/9CReCX0zIeTPN/ 10 | mPXrqbDfGnKn4XuQn50qPCUgXyn74xRmAhNU+jmb4D+uG57dof80PtGGppqG9IOi 11 | DQXSfw5/Jx6SVgbXi2EdHuORFsjhnhLVIuk9zLZaNt024kTdecptwqtoNlzYa4pS 12 | WI1xE32LdYaqb/+uUlAOOFTkEZpsMUkT8qpiEoiTkAhIrcNNC+zl9Qe0zdDJ1P1a 13 | oh+zN+frsheOxcXAX/Qx0iPtVW+8xKQdSmC8WNQpS6ElWI1vBLqJyqFDB0M2uvmz 14 | xA32XTMarIxNqqhiPW1J9wnwL0qeG7WgAuXcfLSExAVlFNKvau8BHxvn8xh8+WCt 15 | ZukwBcks4H/2slH4qaNzKljBNRsWSXf2ZTB2OQJZPu9jdamEcKVhAgMBAAGjVzBV 16 | MBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBR1yMBml4m/wJQhXnjdB67i 17 | WrJdizAfBgNVHSMEGDAWgBS5YA9KXSt6PaAHKZ/enSaN9wHrujANBgkqhkiG9w0B 18 | AQsFAAOCAgEAGfUx6mJGlolxjnSDnLpELwLKlcscn4G5Gbz7Ls6kSPX8kRL77wS+ 19 | u7Fcw0JsL8MZL42fvwR3t1WXn1tP4lbI6OQVNzqj6BJRDAgadVeraa5Y9fQl3lBs 20 | nhivfpGiQ1vpbtq54WYrYqRo16CA9557Xi4+7kgW3SM+wY/1/QOK211f40mIH3do 21 | ZIBGlAawHhyFE89gaRh++qf3jfqoK5xKGZbV8bi9dBtZUBWgg6zkVF/uVUo0iF1W 22 | AK0DTDFmqmr5Hi/37TLdDW5obTaDuddS1YRlB6FkNGzpxZYBavZ01tWt/A2S2D+8 23 | pyCrH4t48sXstAb9/6Mgz5bZtLQI3PBZMKfQCjxVKwOrMuaekgyTgYloACPlzPOL 24 | BD6xZDJa62Uimz+u8TqaNDRvd4+iWZYSCCv4vh+eO0YjfRl5yTqCNNTzjw2KoHTu 25 | y/7ZXsVa6B3RfWeDdgMnIQVJkvmXymt0XQ1JSWy4wVQ4OW0+IP8OjOGu0zfPDaiT 26 | bf38mJl2f4uqXcGEtgQIHmUUIbIdrdLyjuwxN0s9W4iXLer5oUUKi+LBEYzuR0bI 27 | 0gThF0akGT/Mx6+DhBflfAhm7GJzVQ1bJgSub9lZdRI3xZWtQadafKjaWutmeYDx 28 | UHpidFTIcivRUjluR1694iWvFSgzE8krDiyZLVzF02zJz77PMsT7TbE= 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/client-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | V+fuc3pP+dbc/O6QBvY0+zGNnlSxFik= 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/client.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIEVjCCAj4CAQAwETEPMA0GA1UEAwwGY2xpZW50MIICIjANBgkqhkiG9w0BAQEF 3 | AAOCAg8AMIICCgKCAgEAk/jUZMcQm0t6Y+fzcfxbUCyonrvvSsvx1JD1Jeo8OFKa 4 | u3VVrsrh61eMmxmyE6vgf8jIAYE01UH9C34t5eG73iZqIzzp1tqOQGoVyG/jfQHn 5 | XlEmCXHBiZzdKSpiJVp1iQ5QKV2BcRwD//s1NWa/aQi9/K2MMTe0AyLPxB/FkKbt 6 | Mvn1bf4Pnb5YrtZRGxRK/KRUYfCkAuJ07yladDrB0ozgRTxko+u80isEJUaHW2p7 7 | iUWlrYVjCwE8wxfGuG/hL6etM3dpDXDpGCCiBkuA4zZpSstOCTmLKgkJ7/ZT/0JF 8 | 4JfTMh5M83+Y9eupsN8acqfhe5CfnSo8JSBfKfvjFGYCE1T6OZvgP64bnt2h/zQ+ 9 | 0Yammob0g6INBdJ/Dn8nHpJWBteLYR0e45EWyOGeEtUi6T3Mtlo23TbiRN15ym3C 10 | q2g2XNhrilJYjXETfYt1hqpv/65SUA44VOQRmmwxSRPyqmISiJOQCEitw00L7OX1 11 | B7TN0MnU/VqiH7M35+uyF47FxcBf9DHSI+1Vb7zEpB1KYLxY1ClLoSVYjW8EuonK 12 | oUMHQza6+bPEDfZdMxqsjE2qqGI9bUn3CfAvSp4btaAC5dx8tITEBWUU0q9q7wEf 13 | G+fzGHz5YK1m6TAFySzgf/ayUfipo3MqWME1GxZJd/ZlMHY5Alk+72N1qYRwpWEC 14 | AwEAAaAAMA0GCSqGSIb3DQEBCwUAA4ICAQArj6oEmvWuFLOwSjMaRydIIW8Q2JF6 15 | 0jS2odnyHvenogZNtYerocRpek4CmrqMASjMPbVsAqenqXZvSLpZFy0jChXqtfzr 16 | uCEcoTEhBnkOqRS1tJ1pwvH5MXoGkqKrDTeCZkXPRLPg+YFPgRLJroW8O9Zj6zSy 17 | 17Z6hmYuC4UJGtGO9RmJ/OJLE132UGITSlXGKVC4SpFYNnd33p0BltcI5GgtXCYW 18 | XILc26/95D4/NV0FpY+jEFOkgbvl5p9iwD7kRXeyBF1YWFRtmFnJ7tQN957kLaRu 19 | xSvjFTUYVApML7SKiK8Lmk2CwEDPdq1+4e4++xgrGGNQ+x18vDwavtGvinfTHpa0 20 | ietL7k7rKmTGw3CjioZhwXCJainXCA2mh3CHTvFH1QRi/YNg+vls1GjML4trwZEu 21 | aFbTiBOrTHst7rwYN/dYHbjNsOynLkBFiIvedYlS9FPCxvGbQzkGpzLFIMgMZlLp 22 | 0+zlyMM2eSedwP9lIApEFeYYdSPPGdaeGiCsAEtMALHwZqvOd1ec1nu9Lp1lJ2NU 23 | MI3rnUy1NeAOSAUsKjdcRH5jFXQkhHw5w1cPJNpnU3Eu5i2sGASFuTvF33vH0awp 24 | BwuHz2r8bQpnF0Lom1UU1XsBMzE9CqGkfEWvpgznxAKN8SDx4DmPbBulitu6Hzwq 25 | 7PmXphGz1oXUWA== 26 | -----END CERTIFICATE REQUEST----- 27 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/server-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFPjCCAyagAwIBAgIUadYW7Pmggq/jj6vklHjAcm9v7TAwDQYJKoZIhvcNAQEL 3 | BQAwDzENMAsGA1UEAwwETXlDQTAeFw0yNTAxMTcwMjU1NDJaFw0yNjAxMTcwMjU1 4 | NDJaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIP 5 | ADCCAgoCggIBAMFeKGEYR2SURCfg6Ho5S3s3uATZxs+v0srZYXb6CmeFlMZYui7y 6 | +Dmbpd2JGFSI0k+NLWVvsTckbH94N718ZdVSqjhz5Gt53x56W5w4r2Zo4syuw4+C 7 | MONKBCr/TzalWj46m1TOuHA3CDJ20dUGeKSKUgOWY/CfHepuW6nx/8JoYpWma1rC 8 | NjpfBtvY6xuNAA/5Ju0P3cDsejru39VPZ8DFAxmIvIAZXLEfJUF0+B85s9+n4IXg 9 | 7CTLktCvPNwAgEJg0T4wzMSLtQZFTwbDZLnqvRNqPTqeBX/0Oe49ocw/udU2M8ff 10 | KC+VR5PP3CfAAOAVPM68LcHfSVJSy9lcXLHsy8fTnoYkETKGc3HZGxgqbNMfjd+f 11 | 69lyMGkLVXFePj9elrO15i7DqzwblKNhqHCpjyUKTHWJ7Z4uaR4sEbVdbAHyZIe7 12 | V3x3+5bxX4BH18pqU1LNIn+aWukEDrNbn2OINjHUVjHVRQLlRR21mFfNoWP401KR 13 | PbDdaewprUSO89xPYsdtF4VXy7OhFh4D0iA3BTFUrPGIId4dR3r9SDc+8gUHZzfK 14 | E1nGNz0wctVfZfdY7STZJdTOTDtofcp8MVm0EPeK6bYK8cWGj0YZq7vu4U5oMRU6 15 | A5mjfGfYsRsc2P/0gkGG6t3+kmQbtSNxn5M1tLswwmjlir2rEvoHy/ULAgMBAAGj 16 | gYwwgYkwMgYDVR0RBCswKYIJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAA 17 | AAABhwQAAAAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0GA1UdDgQWBBTIgZ7ZboH7 18 | YLymOKHVqB536WaysDAfBgNVHSMEGDAWgBS5YA9KXSt6PaAHKZ/enSaN9wHrujAN 19 | BgkqhkiG9w0BAQsFAAOCAgEAoVf9WlSt3vn41ax8sfCydlBs6hFJzf2X6yQ78dar 20 | 3mKQJRnt1Fc9ZwFdnvdszE/ocLbOWoFHfXJqmffP/EXJEDaV72yoXs8u98Sv4Ofe 21 | iXjJOyff25MJaAwVQE8B/m8jdESyey1MziHUe3PGctrql30yrXbDMHyWN9BEFYV+ 22 | BJUNEdq9LUNSTnYbkuvilJ1jmyBxyR7OByhPlQPqe0yTovqvJkncevwRxo5xveSs 23 | v2zAPNv2jkbny91/jgq6VFATWZdf6KF6aG13nY4tEJTm/FmnED763w0E+mM3Fwtp 24 | zOVPDk+d5v1nK1z66QP3CIfCm8aMtDu/PYEwSPEmGDqThBsOmqJSDV4NR290J7xY 25 | 40WGVtuPZ1CUzj7eU14ccivRqFqxIyfm44+MALw/67kWR207O9roHTYbzgU9t8Dv 26 | AlUzFuT5VNzdSHAdDrHcq3WHnsuFKfqufpUv+nEjPLisXBR2W72vsSxFy66vwWv7 27 | 3A+da+B7+uc36ujX8HWYyEfByBn9EzyUmvpjlUrlsFWOMEcMUWDZW/jvmLiBq9Y8 28 | a95+IFIFLDJWmRgNcaXo8Zs7N+6gWDorFTAaraJH8idCtmDXzsUWbGmv4pRuLN8g 29 | zeh6i819VrQb3wPUsK8l69Er9DZeMJde7YQdA4D0KreDBucTNdv7wEgIECSD/zKH 30 | lbY= 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/server-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | YE4+kDLxM0j6qY0SJgzrVnKC5FJn4A== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /internal/testutil/testdata/certs/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIEWTCCAkECAQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0B 3 | AQEFAAOCAg8AMIICCgKCAgEAwV4oYRhHZJREJ+DoejlLeze4BNnGz6/SytlhdvoK 4 | Z4WUxli6LvL4OZul3YkYVIjST40tZW+xNyRsf3g3vXxl1VKqOHPka3nfHnpbnDiv 5 | ZmjizK7Dj4Iw40oEKv9PNqVaPjqbVM64cDcIMnbR1QZ4pIpSA5Zj8J8d6m5bqfH/ 6 | wmhilaZrWsI2Ol8G29jrG40AD/km7Q/dwOx6Ou7f1U9nwMUDGYi8gBlcsR8lQXT4 7 | Hzmz36fgheDsJMuS0K883ACAQmDRPjDMxIu1BkVPBsNkueq9E2o9Op4Ff/Q57j2h 8 | zD+51TYzx98oL5VHk8/cJ8AA4BU8zrwtwd9JUlLL2VxcsezLx9OehiQRMoZzcdkb 9 | GCps0x+N35/r2XIwaQtVcV4+P16Ws7XmLsOrPBuUo2GocKmPJQpMdYntni5pHiwR 10 | tV1sAfJkh7tXfHf7lvFfgEfXympTUs0if5pa6QQOs1ufY4g2MdRWMdVFAuVFHbWY 11 | V82hY/jTUpE9sN1p7CmtRI7z3E9ix20XhVfLs6EWHgPSIDcFMVSs8Ygh3h1Hev1I 12 | Nz7yBQdnN8oTWcY3PTBy1V9l91jtJNkl1M5MO2h9ynwxWbQQ94rptgrxxYaPRhmr 13 | u+7hTmgxFToDmaN8Z9ixGxzY//SCQYbq3f6SZBu1I3GfkzW0uzDCaOWKvasS+gfL 14 | 9QsCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4ICAQCTVuIwu2hKG+7z+gCXsX7oCWw0 15 | xYH39p4lqWGCprVvMCrIjUZUpOYVT0yC4xX96GL2XSNBrbZqdZyv+/3y0xlQvnQD 16 | SMGbAbzkYLFpODFUC7HtYDOKcXxJ2C3yDVXENAeoN8fBNfCcHOPLrbElY0VojjfZ 17 | TlSt45gOvc0MohvEz/FLizmx9sma+7w9ZBmwNvZRmRiFUwniVPhmhfHkhIjOEaZ0 18 | B+0vVyz7AhMvj7yRpk896FXZjllxqAfE1f3vJF1pmcflqZqtqplCHKTKOLSQxSCj 19 | QnINvb1CHVinX5x0OI0doSfvdZOzuyLwROn71CihRwuXHUVkmMuy6VcIJAItfvYS 20 | Eh+8eQH5qP7cAVD4VULrEtFr9A+Ty+KsYYAc+H18tgai9gJzz2NHuPyGNRdm2UEN 21 | Ms5yl3qSZU3U42cofc5YW5XKuNX0WZHgIsNdk2KHqRFWKwee6GYPaKmpKbvJjh/K 22 | ytRPl68XDIIED1/krw7IUXwQ+F30Msr5MmQYL5QghdIkAP3Ub/AiRL+P8Qdagm1C 23 | Tr3rRIgDmK3TVsW1OXZiFw+tyncP+nZVSa4lO+xD4QFJuIdNvBoR3UElAmYa9wvc 24 | H0+PfcsO7aK8XadujF/Z6Xssgcw7bpKdrdxPOwtUqWL7GWjMpAtGmPPZ3amK2OTi 25 | PQqQnhyD+QWwZamCEQ== 26 | -----END CERTIFICATE REQUEST----- 27 | -------------------------------------------------------------------------------- /internal/valgoutil/error.go: -------------------------------------------------------------------------------- 1 | package valgoutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/cohesivestack/valgo" 8 | ) 9 | 10 | func GetDetails(err *valgo.Error) []string { 11 | if err == nil || err.Errors() == nil { 12 | return []string{} 13 | } 14 | 15 | var details []string 16 | for _, v := range err.Errors() { 17 | detail := fmt.Sprintf("%s: %s", v.Name(), fmt.Sprintf("[%s]", strings.Join(v.Messages(), ", "))) 18 | details = append(details, detail) 19 | } 20 | 21 | return details 22 | } 23 | -------------------------------------------------------------------------------- /internal/valgoutil/error_test.go: -------------------------------------------------------------------------------- 1 | package valgoutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cohesivestack/valgo" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetDetails(t *testing.T) { 11 | err := valgo.Is(valgo.Int(-1, "foo").EqualTo(100, "error_1").Or().InSlice([]int{100}, "error_2")).Error() 12 | got := GetDetails(err.(*valgo.Error)) 13 | assert.Len(t, got, 1) 14 | 15 | // GetDetails iterates a map which has nondeterministic order 16 | wantOneOf := []string{ 17 | "foo: [error_1, error_2]", 18 | "foo: [error_2, error_1]", 19 | } 20 | 21 | assert.Contains(t, wantOneOf, got[0]) 22 | } 23 | -------------------------------------------------------------------------------- /internal/valgoutil/validators.go: -------------------------------------------------------------------------------- 1 | package valgoutil 2 | 3 | import ( 4 | "net" 5 | "net/url" 6 | 7 | "github.com/cohesivestack/valgo" 8 | ) 9 | 10 | func HostPortValidator(hostPort string, nameAndTitle ...string) valgo.Validator { 11 | return valgo.String(hostPort, nameAndTitle...).Passing(func(hp string) bool { 12 | return isValidHostPort(hp) 13 | }, "must be a network address of the form 'host:port'") 14 | } 15 | 16 | func URLValidator(rawURL string, nameAndTitle ...string) valgo.Validator { 17 | return valgo.String(rawURL, nameAndTitle...).Passing(func(rawURL string) bool { 18 | return isValidURL(rawURL) 19 | }, "must be a valid URL") 20 | } 21 | 22 | func CORSValidator(origin string, nameAndTitle ...string) valgo.Validator { 23 | return valgo.String(origin, nameAndTitle...).Passing(func(origin string) bool { 24 | return isValidHostPort(origin) || isValidURL(origin) 25 | }, "must be a valid URL") 26 | } 27 | 28 | func NonEmptySliceValidator[T any](items []T, nameAndTitle ...string) valgo.Validator { 29 | return valgo.Any(items, nameAndTitle...).Passing(func(v any) bool { 30 | return len(v.([]T)) > 0 31 | }, "{{title}} must not be empty") 32 | } 33 | 34 | func isValidHostPort(hostPort string) bool { 35 | _, _, err := net.SplitHostPort(hostPort) 36 | return err == nil 37 | } 38 | 39 | func isValidURL(rawURL string) bool { 40 | parsedURL, err := url.ParseRequestURI(rawURL) 41 | if err != nil { 42 | return false 43 | } 44 | 45 | switch parsedURL.Scheme { 46 | case "http", "https", "ws", "wss", "nats": 47 | default: 48 | return false 49 | } 50 | 51 | return parsedURL.Host != "" 52 | } 53 | -------------------------------------------------------------------------------- /internal/valgoutil/validators_test.go: -------------------------------------------------------------------------------- 1 | package valgoutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cohesivestack/valgo" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestHostPortValidator(t *testing.T) { 11 | ok := valgo.Is(HostPortValidator("invalid", "foo")).Valid() 12 | assert.False(t, ok) 13 | } 14 | 15 | func TestNonEmptySliceValidator(t *testing.T) { 16 | ok := valgo.Is(NonEmptySliceValidator[string]([]string{}, "foo")).Valid() 17 | assert.False(t, ok) 18 | } 19 | 20 | func TestURLValidator(t *testing.T) { 21 | ok := valgo.Is(URLValidator("invalid.com", "foo")).Valid() 22 | assert.False(t, ok) 23 | } 24 | -------------------------------------------------------------------------------- /local_config.yaml: -------------------------------------------------------------------------------- 1 | port: 5400 2 | logger: 3 | level: info 4 | structured: true 5 | encryptionSecretKey: "00deaa689d7b85e4a68d416678e206cb" 6 | postgres: 7 | hostPort: "localhost:5432" 8 | user: postgres 9 | password: postgres 10 | -------------------------------------------------------------------------------- /log/keys.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | const ( 4 | KeyService = "service" 5 | KeyComponent = "component" 6 | 7 | KeyNamespaceID = "namespace.id" 8 | 9 | KeyOperatorID = "operator.id" 10 | KeyOperatorPublicKey = "operator.public_key" 11 | 12 | KeyAccountID = "account.id" 13 | KeyAccountPublicKey = "account.public_key" 14 | KeySystemAccountID = "system_account.id" 15 | 16 | KeyUserID = "user.id" 17 | KeySystemUserID = "system_user.id" 18 | 19 | KeyStreamName = "stream.name" 20 | KeyConsumerStartSequence = "consumer.start_sequence" 21 | KeyConsumerFetchBatchSize = "consumer.fetch_batch_size" 22 | 23 | KeyBrokerMessageID = "broker_message.id" 24 | KeyBrokerMessageReplyInbox = "broker_message.reply_inbox" 25 | KeyBrokerMessageOperation = "broker_message.operation" 26 | KeyBrokerMessageRequestSubject = "broker_message.request.subject" 27 | ) 28 | 29 | var HTTPKeys = []string{ 30 | KeyNamespaceID, 31 | 32 | KeyOperatorID, 33 | KeyOperatorPublicKey, 34 | 35 | KeyAccountID, 36 | KeyAccountPublicKey, 37 | KeySystemAccountID, 38 | 39 | KeyUserID, 40 | KeySystemUserID, 41 | 42 | KeyStreamName, 43 | KeyConsumerStartSequence, 44 | KeyConsumerFetchBatchSize, 45 | 46 | KeyBrokerMessageID, 47 | KeyBrokerMessageRequestSubject, 48 | KeyBrokerMessageReplyInbox, 49 | } 50 | -------------------------------------------------------------------------------- /log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/lmittmann/tint" 10 | ) 11 | 12 | // LoggerOption configures a Logger. 13 | type LoggerOption func(opts *loggerOptions) 14 | 15 | // WithLevel sets the logging level for the Logger. 16 | func WithLevel(level slog.Level) LoggerOption { 17 | return func(opts *loggerOptions) { 18 | opts.level = level 19 | } 20 | } 21 | 22 | // WithDevelopment configures the Logger for development mode with human-readable 23 | // output. 24 | func WithDevelopment() LoggerOption { 25 | return func(opts *loggerOptions) { 26 | opts.handlerFunc = func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { 27 | return tint.NewHandler(w, &tint.Options{Level: opts.Level}) 28 | } 29 | } 30 | } 31 | 32 | // WithNop configures a no-operation Logger that discards all log messages. 33 | func WithNop() LoggerOption { 34 | return func(opts *loggerOptions) { 35 | opts.handlerFunc = func(_ io.Writer, hopts *slog.HandlerOptions) slog.Handler { 36 | return slog.NewJSONHandler(io.Discard, hopts) 37 | } 38 | } 39 | } 40 | 41 | type loggerOptions struct { 42 | level slog.Level 43 | handlerFunc func(w io.Writer, opts *slog.HandlerOptions) slog.Handler 44 | } 45 | 46 | // Logger defines the interface for structured logging. 47 | type Logger interface { 48 | Log(ctx context.Context, level slog.Level, msg string, args ...any) 49 | Info(msg string, args ...any) 50 | Debug(msg string, args ...any) 51 | Warn(msg string, args ...any) 52 | Error(msg string, args ...any) 53 | With(args ...any) Logger 54 | } 55 | 56 | // NewLogger creates a new Logger instance with the specified options. 57 | func NewLogger(opts ...LoggerOption) Logger { 58 | options := loggerOptions{ 59 | level: slog.LevelInfo, 60 | handlerFunc: func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { 61 | return slog.NewJSONHandler(w, opts) 62 | }, 63 | } 64 | 65 | for _, opt := range opts { 66 | opt(&options) 67 | } 68 | 69 | return &logger{ 70 | Logger: slog.New(options.handlerFunc(os.Stdout, &slog.HandlerOptions{ 71 | Level: options.level, 72 | })), 73 | } 74 | } 75 | 76 | type logger struct { 77 | *slog.Logger 78 | } 79 | 80 | // With returns a new Logger with the specified arguments. 81 | func (l *logger) With(args ...any) Logger { 82 | return &logger{l.Logger.With(args...)} 83 | } 84 | 85 | func ParseLevel(level string) (slog.Level, bool) { 86 | switch level { 87 | case "debug": 88 | return slog.LevelDebug, true 89 | case "info": 90 | return slog.LevelInfo, true 91 | case "warn": 92 | return slog.LevelWarn, true 93 | case "error": 94 | return slog.LevelError, true 95 | } 96 | return -1, false 97 | } 98 | -------------------------------------------------------------------------------- /log/logger_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "log/slog" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestNewLogger(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | opts []LoggerOption 19 | }{ 20 | { 21 | name: "default logger", 22 | opts: []LoggerOption{}, 23 | }, 24 | { 25 | name: "development logger", 26 | opts: []LoggerOption{WithDevelopment()}, 27 | }, 28 | { 29 | name: "nop logger", 30 | opts: []LoggerOption{WithNop()}, 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | l := NewLogger() 37 | require.NotNil(t, l) 38 | }) 39 | } 40 | } 41 | 42 | func TestLogger(t *testing.T) { 43 | var buf bytes.Buffer 44 | l := NewLogger( 45 | WithLevel(slog.LevelDebug), 46 | func(opts *loggerOptions) { 47 | opts.handlerFunc = func(_ io.Writer, opts *slog.HandlerOptions) slog.Handler { 48 | return slog.NewJSONHandler(&buf, opts) 49 | } 50 | }, 51 | ) 52 | 53 | wantMsg, wantKey1, wantVal1, wantKey2, wantVal2 := "lorem ipsum", "key1", "val1", "key2", "val2" 54 | l = l.With(wantKey1, wantVal1) 55 | 56 | tests := []struct { 57 | name string 58 | wantLevel slog.Level 59 | logFunc func(msg string, args ...any) 60 | }{ 61 | { 62 | name: "info level", 63 | wantLevel: slog.LevelInfo, 64 | logFunc: l.Info, 65 | }, 66 | { 67 | name: "debug level", 68 | wantLevel: slog.LevelDebug, 69 | logFunc: l.Debug, 70 | }, 71 | { 72 | name: "warn level", 73 | wantLevel: slog.LevelWarn, 74 | logFunc: l.Warn, 75 | }, 76 | { 77 | name: "error level", 78 | wantLevel: slog.LevelError, 79 | logFunc: l.Error, 80 | }, 81 | } 82 | 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | buf.Reset() 86 | tt.logFunc(wantMsg, wantKey2, wantVal2) 87 | 88 | var gotLog testLog 89 | err := json.Unmarshal(buf.Bytes(), &gotLog) 90 | require.NoError(t, err) 91 | 92 | assert.Equal(t, tt.wantLevel.String(), gotLog.Level) 93 | assert.Equal(t, wantMsg, gotLog.Msg) 94 | assert.Equal(t, wantVal1, gotLog.Key1) 95 | assert.Equal(t, wantVal2, gotLog.Key2) 96 | _, err = time.Parse(time.RFC3339, gotLog.Time) 97 | assert.NoError(t, err) 98 | }) 99 | } 100 | } 101 | 102 | func TestWithNop(t *testing.T) { 103 | l := NewLogger(WithNop()) 104 | 105 | require.NotNil(t, l) 106 | 107 | // Attempt to log 108 | l.Info("this should not log") 109 | l.Debug("this should also not log") 110 | l.Error("errors should not appear") 111 | 112 | // No assertions required as NOP logger discards logs 113 | } 114 | 115 | func TestParseLevel(t *testing.T) { 116 | tests := []struct { 117 | name string 118 | input string 119 | wantLevel slog.Level 120 | wantOK bool 121 | }{ 122 | { 123 | name: "debug level", 124 | input: "debug", 125 | wantLevel: slog.LevelDebug, 126 | wantOK: true, 127 | }, 128 | { 129 | name: "info level", 130 | input: "info", 131 | wantLevel: slog.LevelInfo, 132 | wantOK: true, 133 | }, 134 | { 135 | name: "warn level", 136 | input: "warn", 137 | wantLevel: slog.LevelWarn, 138 | wantOK: true, 139 | }, 140 | { 141 | name: "error level", 142 | input: "error", 143 | wantLevel: slog.LevelError, 144 | wantOK: true, 145 | }, 146 | { 147 | name: "invalid level", 148 | input: "invalid", 149 | wantLevel: -1, 150 | wantOK: false, 151 | }, 152 | } 153 | 154 | for _, tt := range tests { 155 | t.Run(tt.name, func(t *testing.T) { 156 | level, ok := ParseLevel(tt.input) 157 | assert.Equal(t, tt.wantLevel, level) 158 | assert.Equal(t, tt.wantOK, ok) 159 | }) 160 | } 161 | } 162 | 163 | type testLog struct { 164 | Time string `json:"time"` 165 | Level string `json:"level"` 166 | Msg string `json:"msg"` 167 | Key1 string `json:"key1"` 168 | Key2 string `json:"key2"` 169 | } 170 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | 11 | "github.com/cohesivestack/valgo" 12 | "github.com/urfave/cli/v2" 13 | 14 | "github.com/coro-sh/coro/app" 15 | "github.com/coro-sh/coro/log" 16 | ) 17 | 18 | const ( 19 | serviceTypeAll string = "all" 20 | serviceTypeUI string = "ui" 21 | serviceTypeAllBackend string = "backend" 22 | serviceTypeController string = "controller" 23 | serviceTypeBroker string = "broker" 24 | ) 25 | 26 | var serviceTypes = []string{serviceTypeAll, serviceTypeUI, serviceTypeAllBackend, serviceTypeController, serviceTypeBroker} 27 | 28 | func main() { 29 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) 30 | defer cancel() 31 | 32 | cliApp := cli.NewApp() 33 | cliApp.Name = "coro" 34 | cliApp.Usage = "Coro" 35 | 36 | cliApp.Flags = []cli.Flag{ 37 | &cli.StringFlag{ 38 | Name: "service", 39 | Aliases: []string{"s"}, 40 | Value: "", 41 | Usage: fmt.Sprintf("[required] service to start (%s)", strings.Join(serviceTypes, ", ")), 42 | }, 43 | &cli.StringFlag{ 44 | Name: "config", 45 | Aliases: []string{"c"}, 46 | Value: "", 47 | Usage: "path to yaml config file (required if not using env vars)", 48 | }, 49 | } 50 | 51 | logger := log.NewLogger() 52 | 53 | cliApp.Commands = []*cli.Command{ 54 | { 55 | Name: "run", 56 | Usage: "[default] runs the service", 57 | Action: func(c *cli.Context) error { 58 | f := parseFlags(c) 59 | switch f.service { 60 | case serviceTypeAll: 61 | var cfg app.AllConfig 62 | app.LoadConfig(f.configFile, &cfg) 63 | logger = loggerFromConfig(cfg.Logger).With(log.KeyService, serviceTypeAll) 64 | return app.RunAll(ctx, logger, cfg, true) 65 | case serviceTypeUI: 66 | var cfg app.UIConfig 67 | app.LoadConfig(f.configFile, &cfg) 68 | logger = loggerFromConfig(cfg.Logger).With(log.KeyService, serviceTypeUI) 69 | return app.RunUI(ctx, logger, cfg) 70 | case serviceTypeAllBackend: 71 | var cfg app.AllConfig 72 | app.LoadConfig(f.configFile, &cfg) 73 | logger = loggerFromConfig(cfg.Logger).With(log.KeyService, serviceTypeAllBackend) 74 | return app.RunAll(ctx, logger, cfg, false) 75 | case serviceTypeController: 76 | var cfg app.ControllerConfig 77 | app.LoadConfig(f.configFile, &cfg) 78 | logger = loggerFromConfig(cfg.Logger).With(log.KeyService, serviceTypeController) 79 | return app.RunController(ctx, logger, cfg) 80 | case serviceTypeBroker: 81 | var cfg app.BrokerConfig 82 | app.LoadConfig(f.configFile, &cfg) 83 | logger = loggerFromConfig(cfg.Logger).With(log.KeyService, serviceTypeBroker) 84 | return app.RunBroker(ctx, logger, cfg) 85 | default: 86 | return fmt.Errorf("invalid service type: %s", f.service) 87 | } 88 | }, 89 | }, 90 | } 91 | 92 | cliApp.DefaultCommand = "run" 93 | 94 | if err := cliApp.RunContext(ctx, os.Args); err != nil { 95 | logger.Error("failed to start service", "error", err) 96 | os.Exit(1) 97 | } 98 | } 99 | 100 | type flags struct { 101 | service string 102 | configFile string 103 | } 104 | 105 | func (c flags) validate() *valgo.Validation { 106 | svcTypeTemplate := fmt.Sprintf("must be one of [%s] or left empty", strings.Join(serviceTypes, ", ")) 107 | return valgo.Is(valgo.String(c.service, "service").InSlice(serviceTypes, svcTypeTemplate)) 108 | } 109 | 110 | func parseFlags(c *cli.Context) flags { 111 | f := flags{ 112 | service: c.String("service"), 113 | configFile: c.String("config"), 114 | } 115 | exitOnInvalidFlags(c, f.validate()) 116 | return f 117 | } 118 | 119 | func exitOnInvalidFlags(c *cli.Context, v *valgo.Validation) { 120 | if v.Error() == nil { 121 | return 122 | } 123 | fmt.Fprintln(os.Stderr, "Flag errors:") 124 | 125 | for _, verr := range v.Error().(*valgo.Error).Errors() { 126 | fmt.Fprintf(os.Stderr, " %s: %s\n", verr.Name(), strings.Join(verr.Messages(), ",")) 127 | } 128 | 129 | fmt.Fprintln(os.Stdout) //nolint:errcheck 130 | cli.ShowAppHelpAndExit(c, 1) 131 | } 132 | 133 | func loggerFromConfig(cfg app.LoggerConfig) log.Logger { 134 | level, ok := log.ParseLevel(cfg.Level) 135 | if !ok { 136 | level = slog.LevelInfo 137 | } 138 | opts := []log.LoggerOption{log.WithLevel(level)} 139 | if !cfg.Structured { 140 | opts = append(opts, log.WithDevelopment()) 141 | } 142 | return log.NewLogger(opts...) 143 | } 144 | -------------------------------------------------------------------------------- /natsutil/connect.go: -------------------------------------------------------------------------------- 1 | package natsutil 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "time" 7 | 8 | "github.com/nats-io/nats.go" 9 | ) 10 | 11 | type connectOptions struct { 12 | tls *tls.Config 13 | } 14 | 15 | type ConnectOption func(opts *connectOptions) 16 | 17 | func WithTLS(tls *tls.Config) ConnectOption { 18 | return func(opts *connectOptions) { 19 | opts.tls = tls 20 | } 21 | } 22 | 23 | func Connect(url string, jwt string, seed string, opts ...ConnectOption) (*nats.Conn, error) { 24 | const ( 25 | natsConnectTimeout = 20 * time.Second 26 | natsConnectAttempts = 10 27 | ) 28 | 29 | var connOpts connectOptions 30 | for _, opt := range opts { 31 | opt(&connOpts) 32 | } 33 | 34 | connectedCh := make(chan struct{}) 35 | 36 | natsOpts := []nats.Option{ 37 | nats.UserJWTAndSeed(jwt, seed), 38 | nats.RetryOnFailedConnect(true), 39 | nats.MaxReconnects(natsConnectAttempts), 40 | nats.CustomReconnectDelay(func(_ int) time.Duration { 41 | return natsConnectTimeout / time.Duration(natsConnectAttempts) 42 | }), 43 | nats.ConnectHandler(func(_ *nats.Conn) { 44 | close(connectedCh) 45 | }), 46 | } 47 | if connOpts.tls != nil { 48 | natsOpts = append(natsOpts, nats.Secure(connOpts.tls)) 49 | } 50 | 51 | // Connect to the NATS server 52 | nc, err := nats.Connect(url, natsOpts...) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | select { 58 | case <-connectedCh: 59 | case <-time.After(natsConnectTimeout): 60 | nc.Close() 61 | return nil, errors.New("nats connect timeout") 62 | } 63 | 64 | return nc, nil 65 | } 66 | -------------------------------------------------------------------------------- /postgres/conn.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | "github.com/cenkalti/backoff/v4" 13 | "github.com/jackc/pgx/v5/pgxpool" 14 | 15 | "github.com/coro-sh/coro/internal/constants" 16 | ) 17 | 18 | const ( 19 | AppDBName = constants.AppName 20 | pgRetryInterval = 2 * time.Second 21 | pgMaxRetries = 5 22 | ) 23 | 24 | type DialOption func(opts *dialOpts) 25 | 26 | type TLSConfig struct { 27 | CertFile string // Path to the client certificate file. 28 | KeyFile string // Path to the client key file. 29 | CACertFile string // Path to the CA certificate file. 30 | InsecureSkipVerify bool // Allows skipping TLS certificate verification. 31 | } 32 | 33 | func WithTLS(tls TLSConfig) DialOption { 34 | return func(opts *dialOpts) { 35 | opts.tls = &tls 36 | } 37 | } 38 | 39 | type dialOpts struct { 40 | tls *TLSConfig 41 | } 42 | 43 | func Dial(ctx context.Context, username string, password string, hostPort string, database string, opts ...DialOption) (*pgxpool.Pool, error) { 44 | var options dialOpts 45 | for _, opt := range opts { 46 | opt(&options) 47 | } 48 | 49 | url := fmt.Sprintf("postgres://%s:%s@%s/%s", username, password, hostPort, database) 50 | 51 | cfg, err := pgxpool.ParseConfig(url) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if options.tls != nil { 57 | tlsConfig := &tls.Config{ 58 | InsecureSkipVerify: options.tls.InsecureSkipVerify, 59 | } 60 | 61 | if options.tls.CertFile != "" && options.tls.KeyFile != "" { 62 | cert, err := tls.LoadX509KeyPair(options.tls.CertFile, options.tls.KeyFile) 63 | if err != nil { 64 | return nil, fmt.Errorf("load client certificate/key: %w", err) 65 | } 66 | tlsConfig.Certificates = []tls.Certificate{cert} 67 | } 68 | 69 | if options.tls.CACertFile != "" { 70 | var err error 71 | tlsConfig.RootCAs, err = loadCACert(options.tls.CACertFile) 72 | if err != nil { 73 | return nil, err 74 | } 75 | } 76 | 77 | cfg.ConnConfig.TLSConfig = tlsConfig 78 | } 79 | 80 | pool, err := pgxpool.NewWithConfig(ctx, cfg) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if err = waitHealthy(ctx, pool); err != nil { 86 | return nil, err 87 | } 88 | 89 | return pool, nil 90 | } 91 | 92 | func loadCACert(caCertFile string) (*x509.CertPool, error) { 93 | caCert, err := os.ReadFile(caCertFile) 94 | if err != nil { 95 | return nil, fmt.Errorf("read ca certificate: %w", err) 96 | } 97 | 98 | caCertPool := x509.NewCertPool() 99 | if !caCertPool.AppendCertsFromPEM(caCert) { 100 | return nil, errors.New("failed to append ca certificate") 101 | } 102 | 103 | return caCertPool, nil 104 | } 105 | 106 | func waitHealthy(ctx context.Context, pool *pgxpool.Pool) error { 107 | pingFn := func() error { 108 | ctx, cancel := context.WithTimeout(ctx, time.Second) 109 | defer cancel() 110 | return pool.Ping(ctx) 111 | } 112 | bo := backoff.WithMaxRetries(backoff.NewConstantBackOff(pgRetryInterval), pgMaxRetries) 113 | if err := backoff.Retry(pingFn, bo); err != nil { 114 | return fmt.Errorf("postgres connection unhealthy: %w", err) 115 | } 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /postgres/errors.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/text/cases" 7 | "golang.org/x/text/language" 8 | 9 | "github.com/coro-sh/coro/entity" 10 | "github.com/coro-sh/coro/errtag" 11 | ) 12 | 13 | type NkeyNotFound struct{ errtag.NotFound } 14 | 15 | func (NkeyNotFound) Msg() string { return "Nkey not found" } 16 | 17 | func (e NkeyNotFound) Unwrap() error { 18 | return errtag.Tag[errtag.NotFound](e.Cause()) 19 | } 20 | 21 | type SigningKeyNotFound struct{ errtag.NotFound } 22 | 23 | func (SigningKeyNotFound) Msg() string { return "Signing key not found" } 24 | 25 | func (e SigningKeyNotFound) Unwrap() error { 26 | return errtag.Tag[errtag.NotFound](e.Cause()) 27 | } 28 | 29 | type EntityNotFound[T entity.Entity] struct{ errtag.NotFound } 30 | 31 | func (EntityNotFound[T]) Msg() string { 32 | return getTypeName[T]() + " not found" 33 | } 34 | 35 | func (e EntityNotFound[T]) Unwrap() error { 36 | return errtag.Tag[errtag.NotFound](e.Cause()) 37 | } 38 | 39 | type EntityConflict[T entity.Entity] struct{ errtag.Conflict } 40 | 41 | func (EntityConflict[T]) Msg() string { 42 | return getTypeName[T]() + " conflict" 43 | } 44 | 45 | func (e EntityConflict[T]) Unwrap() error { 46 | return errtag.Tag[errtag.Conflict](e.Cause()) 47 | } 48 | 49 | func getTypeName[T entity.Entity]() string { 50 | typeName := entity.GetTypeName[T]() 51 | caser := cases.Title(language.English) 52 | return caser.String(strings.ToLower(typeName)) 53 | } 54 | -------------------------------------------------------------------------------- /postgres/gen.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate 2 | package postgres 3 | -------------------------------------------------------------------------------- /postgres/marshal.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "github.com/coro-sh/coro/entity" 5 | "github.com/coro-sh/coro/postgres/sqlc" 6 | ) 7 | 8 | func unmarshalNamespace(in *sqlc.Namespace) *entity.Namespace { 9 | return &entity.Namespace{ 10 | ID: entity.MustParseID[entity.NamespaceID](in.ID), 11 | Name: in.Name, 12 | } 13 | } 14 | 15 | func unmarshalOperator(in *sqlc.Operator) entity.OperatorData { 16 | return entity.OperatorData{ 17 | OperatorIdentity: entity.OperatorIdentity{ 18 | ID: entity.MustParseID[entity.OperatorID](in.ID), 19 | NamespaceID: entity.MustParseID[entity.NamespaceID](in.NamespaceID), 20 | JWT: in.Jwt, 21 | }, 22 | Name: in.Name, 23 | PublicKey: in.PublicKey, 24 | } 25 | } 26 | 27 | func unmarshalAccount(in *sqlc.Account) entity.AccountData { 28 | return entity.AccountData{ 29 | AccountIdentity: entity.AccountIdentity{ 30 | ID: entity.MustParseID[entity.AccountID](in.ID), 31 | NamespaceID: entity.MustParseID[entity.NamespaceID](in.NamespaceID), 32 | OperatorID: entity.MustParseID[entity.OperatorID](in.OperatorID), 33 | JWT: in.Jwt, 34 | }, 35 | Name: in.Name, 36 | PublicKey: in.PublicKey, 37 | UserJWTDuration: in.UserJwtDuration, 38 | } 39 | } 40 | 41 | func unmarshalUser(in *sqlc.User) entity.UserData { 42 | return entity.UserData{ 43 | UserIdentity: entity.UserIdentity{ 44 | ID: entity.MustParseID[entity.UserID](in.ID), 45 | NamespaceID: entity.MustParseID[entity.NamespaceID](in.NamespaceID), 46 | OperatorID: entity.MustParseID[entity.OperatorID](in.OperatorID), 47 | AccountID: entity.MustParseID[entity.AccountID](in.AccountID), 48 | }, 49 | Name: in.Name, 50 | JWT: in.Jwt, 51 | JWTDuration: in.JwtDuration, 52 | } 53 | } 54 | 55 | func unmarshalUserJWTIssuances(in *sqlc.UserJwtIssuance) entity.UserJWTIssuance { 56 | return entity.UserJWTIssuance{ 57 | IssueTime: in.IssueTime, 58 | ExpireTime: in.ExpireTime, 59 | } 60 | } 61 | 62 | func marshalNkeyType(in entity.Type) sqlc.NkeyType { 63 | switch in { 64 | case entity.TypeOperator: 65 | return sqlc.NkeyTypeOperator 66 | case entity.TypeAccount: 67 | return sqlc.NkeyTypeAccount 68 | case entity.TypeUser: 69 | return sqlc.NkeyTypeUser 70 | case entity.TypeUnspecified: 71 | fallthrough 72 | default: 73 | return "" 74 | } 75 | } 76 | 77 | func unmarshalNkeyType(in sqlc.NkeyType) entity.Type { 78 | switch in { 79 | case sqlc.NkeyTypeOperator: 80 | return entity.TypeOperator 81 | case sqlc.NkeyTypeAccount: 82 | return entity.TypeAccount 83 | case sqlc.NkeyTypeUser: 84 | return entity.TypeUser 85 | default: 86 | return entity.TypeUnspecified 87 | } 88 | } 89 | 90 | func unmarshalNkey(in *sqlc.Nkey) entity.NkeyData { 91 | return entity.NkeyData{ 92 | ID: in.ID, 93 | Type: unmarshalNkeyType(in.Type), 94 | Seed: in.Seed, 95 | SigningKey: false, 96 | } 97 | } 98 | 99 | func unmarshalSigningKey(in *sqlc.SigningKey) entity.NkeyData { 100 | return entity.NkeyData{ 101 | ID: in.ID, 102 | Type: unmarshalNkeyType(in.Type), 103 | Seed: in.Seed, 104 | SigningKey: true, 105 | } 106 | } 107 | 108 | func unmarshalList[T any, U any](in []T, unmarshaler func(in T) U) []U { 109 | out := make([]U, len(in)) 110 | for i := range in { 111 | out[i] = unmarshaler(in[i]) 112 | } 113 | return out 114 | } 115 | -------------------------------------------------------------------------------- /postgres/migrate.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | 7 | "github.com/golang-migrate/migrate/v4" 8 | "github.com/golang-migrate/migrate/v4/database/postgres" 9 | _ "github.com/golang-migrate/migrate/v4/source/file" // register the file source driver 10 | "github.com/golang-migrate/migrate/v4/source/iofs" 11 | "github.com/jackc/pgx/v5/pgxpool" 12 | "github.com/jackc/pgx/v5/stdlib" 13 | ) 14 | 15 | type migrationOptions struct { 16 | version *uint 17 | } 18 | 19 | type MigrateOption func(opts *migrationOptions) 20 | 21 | func WithMigrationVersion(version uint) MigrateOption { 22 | return func(opts *migrationOptions) { 23 | opts.version = &version 24 | } 25 | } 26 | 27 | func MigrateDatabase(pool *pgxpool.Pool, fsys fs.FS, opts ...MigrateOption) error { 28 | var mopts migrationOptions 29 | for _, opt := range opts { 30 | opt(&mopts) 31 | } 32 | 33 | sd, err := iofs.New(fsys, ".") 34 | if err != nil { 35 | return err 36 | } 37 | defer sd.Close() 38 | 39 | db := stdlib.OpenDBFromPool(pool) 40 | defer db.Close() 41 | 42 | driver, err := postgres.WithInstance(db, new(postgres.Config)) 43 | if err != nil { 44 | return err 45 | } 46 | defer driver.Close() 47 | 48 | m, err := migrate.NewWithInstance("iofs", sd, "postgres", driver) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if mopts.version != nil { 54 | err = m.Migrate(*mopts.version) 55 | } else { 56 | err = m.Up() 57 | } 58 | 59 | if err != nil && !errors.Is(err, migrate.ErrNoChange) { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /postgres/migrations/0001_tables.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE nkey_type AS ENUM ('operator', 'account', 'user'); 2 | 3 | CREATE TABLE namespace 4 | ( 5 | id TEXT NOT NULL PRIMARY KEY, 6 | name TEXT NOT NULL UNIQUE 7 | ); 8 | 9 | CREATE TABLE operator 10 | ( 11 | id TEXT NOT NULL PRIMARY KEY, 12 | namespace_id TEXT NOT NULL REFERENCES namespace (id) ON DELETE CASCADE, 13 | name TEXT NOT NULL, 14 | public_key TEXT NOT NULL UNIQUE, 15 | jwt TEXT NOT NULL, 16 | UNIQUE (namespace_id, name) 17 | ); 18 | 19 | CREATE TABLE account 20 | ( 21 | id TEXT NOT NULL PRIMARY KEY, 22 | namespace_id TEXT NOT NULL REFERENCES namespace (id) ON DELETE CASCADE, 23 | operator_id TEXT NOT NULL REFERENCES operator (id) ON DELETE CASCADE, 24 | name TEXT NOT NULL, 25 | public_key TEXT NOT NULL UNIQUE, 26 | jwt TEXT NOT NULL, 27 | user_jwt_duration INTERVAL, 28 | UNIQUE (operator_id, name) 29 | ); 30 | 31 | CREATE TABLE "user" 32 | ( 33 | id TEXT NOT NULL PRIMARY KEY, 34 | namespace_id TEXT NOT NULL REFERENCES namespace (id) ON DELETE CASCADE, 35 | operator_id TEXT NOT NULL REFERENCES operator (id) ON DELETE CASCADE, 36 | account_id TEXT NOT NULL REFERENCES account (id) ON DELETE CASCADE, 37 | name TEXT NOT NULL, 38 | jwt TEXT NOT NULL, 39 | jwt_duration INTERVAL, 40 | UNIQUE (operator_id, account_id, name) 41 | ); 42 | 43 | CREATE TABLE user_jwt_issuances 44 | ( 45 | user_id TEXT NOT NULL REFERENCES "user" (id) ON DELETE CASCADE, 46 | issue_time BIGINT NOT NULL, -- unix 47 | expire_time BIGINT NOT NULL -- unix 48 | ); 49 | 50 | CREATE TABLE nkey 51 | ( 52 | id TEXT NOT NULL PRIMARY KEY, 53 | type nkey_type NOT NULL, 54 | seed BYTEA NOT NULL 55 | ); 56 | 57 | CREATE TABLE signing_key 58 | ( 59 | id TEXT NOT NULL PRIMARY KEY REFERENCES nkey (id) ON DELETE CASCADE, 60 | type nkey_type NOT NULL, 61 | seed BYTEA NOT NULL 62 | ); 63 | 64 | CREATE TABLE operator_token 65 | ( 66 | operator_id TEXT NOT NULL REFERENCES operator (id) ON DELETE CASCADE, 67 | type TEXT NOT NULL, 68 | token TEXT NOT NULL UNIQUE, 69 | PRIMARY KEY (operator_id, type) 70 | ); 71 | -------------------------------------------------------------------------------- /postgres/migrations/0002_delete_entity_functions.up.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION delete_namespace_and_nkeys(namespace_id TEXT) 2 | RETURNS VOID AS 3 | $$ 4 | BEGIN 5 | -- delete user nkeys 6 | DELETE 7 | FROM nkey 8 | WHERE id IN (SELECT id FROM "user" u WHERE u.namespace_id = delete_namespace_and_nkeys.namespace_id); 9 | 10 | -- delete account nkeys 11 | DELETE 12 | FROM nkey 13 | WHERE id IN (SELECT id FROM account a WHERE a.namespace_id = delete_namespace_and_nkeys.namespace_id); 14 | 15 | -- delete operator nkeys 16 | DELETE 17 | FROM nkey 18 | WHERE id IN (SELECT id FROM operator o WHERE o.namespace_id = delete_namespace_and_nkeys.namespace_id); 19 | 20 | -- delete the namespace (cascading operators, accounts, and users) 21 | DELETE FROM namespace WHERE id = delete_namespace_and_nkeys.namespace_id; 22 | 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION delete_operator_and_nkeys(operator_id TEXT) 27 | RETURNS VOID AS 28 | $$ 29 | BEGIN 30 | -- delete user nkeys 31 | DELETE 32 | FROM nkey 33 | WHERE id IN (SELECT id FROM "user" u WHERE u.operator_id = delete_operator_and_nkeys.operator_id); 34 | 35 | -- delete account nkeys 36 | DELETE 37 | FROM nkey 38 | WHERE id IN (SELECT id FROM account a WHERE a.operator_id = delete_operator_and_nkeys.operator_id); 39 | 40 | -- delete the operator nkey 41 | DELETE FROM nkey WHERE id = delete_operator_and_nkeys.operator_id; 42 | 43 | -- delete the operator (cascading accounts and users) 44 | DELETE FROM operator WHERE id = delete_operator_and_nkeys.operator_id; 45 | END; 46 | $$ LANGUAGE plpgsql; 47 | 48 | CREATE OR REPLACE FUNCTION delete_account_and_nkeys(account_id TEXT) 49 | RETURNS VOID AS 50 | $$ 51 | BEGIN 52 | -- delete user nkeys 53 | DELETE 54 | FROM nkey 55 | WHERE id IN (SELECT id FROM "user" u WHERE u.account_id = delete_account_and_nkeys.account_id); 56 | 57 | -- delete the account nkey 58 | DELETE FROM nkey WHERE id = delete_account_and_nkeys.account_id; 59 | 60 | -- delete the account (cascading users) 61 | DELETE FROM account WHERE id = delete_account_and_nkeys.account_id; 62 | END; 63 | $$ LANGUAGE plpgsql; 64 | 65 | CREATE OR REPLACE FUNCTION delete_user_and_nkey(user_id TEXT) 66 | RETURNS VOID AS 67 | $$ 68 | BEGIN 69 | -- delete the user nkey 70 | DELETE FROM nkey WHERE id = delete_user_and_nkey.user_id; 71 | 72 | -- delete the user 73 | DELETE FROM "user" WHERE id = delete_user_and_nkey.user_id; 74 | END; 75 | $$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- /postgres/migrations/fs.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import "embed" 4 | 5 | //go:embed *.sql 6 | var FS embed.FS 7 | -------------------------------------------------------------------------------- /postgres/operator_token_rw.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/jackc/pgx/v5" 8 | 9 | "github.com/coro-sh/coro/entity" 10 | "github.com/coro-sh/coro/errtag" 11 | "github.com/coro-sh/coro/postgres/sqlc" 12 | "github.com/coro-sh/coro/tkn" 13 | ) 14 | 15 | var _ tkn.OperatorTokenReadWriter = (*OperatorTokenReadWriter)(nil) 16 | 17 | type OperatorTokenReadWriter struct { 18 | db *sqlc.Queries 19 | } 20 | 21 | func NewOperatorTokenReadWriter(dbtx sqlc.DBTX) *OperatorTokenReadWriter { 22 | return &OperatorTokenReadWriter{ 23 | db: sqlc.New(dbtx), 24 | } 25 | } 26 | 27 | func (o *OperatorTokenReadWriter) Read(ctx context.Context, tokenType tkn.OperatorTokenType, operatorID entity.OperatorID) (string, error) { 28 | token, err := o.db.ReadOperatorToken(ctx, sqlc.ReadOperatorTokenParams{ 29 | OperatorID: operatorID.String(), 30 | Type: string(tokenType), 31 | }) 32 | if err != nil { 33 | if errors.Is(err, pgx.ErrNoRows) { 34 | return "", errtag.Tag[errtag.NotFound](err, errtag.WithMsg("Operator token not found")) 35 | } 36 | return "", err 37 | } 38 | return token, nil 39 | } 40 | 41 | func (o *OperatorTokenReadWriter) Write(ctx context.Context, tokenType tkn.OperatorTokenType, operatorID entity.OperatorID, hashedToken string) error { 42 | return o.db.UpsertOperatorToken(ctx, sqlc.UpsertOperatorTokenParams{ 43 | OperatorID: operatorID.String(), 44 | Type: string(tokenType), 45 | Token: hashedToken, 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /postgres/operator_token_rw_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package postgres 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/coro-sh/coro/internal/testutil" 13 | "github.com/coro-sh/coro/tkn" 14 | ) 15 | 16 | func TestOperatorTokenReadWriter_ReadWrite(t *testing.T) { 17 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 18 | defer cancel() 19 | 20 | // Setup operator prerequisite 21 | db := setupTestDB(t) 22 | repo := NewEntityRepository(db) 23 | 24 | ns := genNamespace() 25 | err := repo.CreateNamespace(ctx, ns) 26 | require.NoError(t, err) 27 | 28 | opData := genOperatorData(ns) 29 | err = repo.CreateOperator(ctx, opData) 30 | require.NoError(t, err) 31 | 32 | prepo := NewOperatorTokenReadWriter(db) 33 | wantTkn := testutil.RandString(30) 34 | 35 | err = prepo.Write(ctx, tkn.OperatorTokenTypeProxy, opData.ID, wantTkn) 36 | require.NoError(t, err) 37 | 38 | got, err := prepo.Read(ctx, tkn.OperatorTokenTypeProxy, opData.ID) 39 | require.NoError(t, err) 40 | assert.Equal(t, wantTkn, got) 41 | } 42 | -------------------------------------------------------------------------------- /postgres/queries/account.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateAccount :exec 2 | INSERT INTO account (id, namespace_id, operator_id, name, public_key, jwt, user_jwt_duration) 3 | VALUES ($1, $2, $3, $4, $5, $6, $7); 4 | 5 | -- name: UpdateAccount :exec 6 | UPDATE account 7 | SET name = $2, 8 | jwt = $3, 9 | user_jwt_duration = $4 10 | WHERE id = $1; 11 | 12 | -- name: ReadAccount :one 13 | SELECT * 14 | FROM account 15 | WHERE id = $1; 16 | 17 | -- name: ReadAccountByPublicKey :one 18 | SELECT * 19 | FROM account 20 | WHERE public_key = $1; 21 | 22 | -- name: ListAccounts :many 23 | SELECT * 24 | FROM account 25 | WHERE operator_id = $1 26 | AND (sqlc.narg('cursor')::TEXT IS NULL OR id <= sqlc.narg('cursor')::TEXT) 27 | AND name != 'SYS' 28 | ORDER BY id DESC 29 | LIMIT @size; 30 | 31 | -- name: DeleteAccount :exec 32 | SELECT delete_account_and_nkeys($1); -------------------------------------------------------------------------------- /postgres/queries/namespace.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateNamespace :exec 2 | INSERT INTO namespace (id, name) 3 | VALUES ($1, $2); 4 | 5 | -- name: ReadNamespaceByName :one 6 | SELECT * 7 | FROM namespace 8 | WHERE name = $1; 9 | 10 | -- name: ReadNamespace :one 11 | SELECT * 12 | FROM namespace 13 | WHERE id = $1; 14 | 15 | -- name: ListNamespaces :many 16 | SELECT * 17 | FROM namespace 18 | WHERE (sqlc.narg('cursor')::TEXT IS NULL OR id <= sqlc.narg('cursor')::TEXT) 19 | AND name != 'coro_internal' 20 | ORDER BY id DESC 21 | LIMIT @size; 22 | 23 | -- name: DeleteNamespace :exec 24 | SELECT delete_namespace_and_nkeys($1); 25 | -------------------------------------------------------------------------------- /postgres/queries/nkey.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateNkey :exec 2 | INSERT INTO nkey (id, type, seed) 3 | VALUES ($1, $2, $3); 4 | 5 | -- name: ReadNkey :one 6 | SELECT * 7 | FROM nkey 8 | WHERE id = $1; 9 | 10 | -- name: CreateSigningKey :exec 11 | INSERT INTO signing_key (id, type, seed) 12 | VALUES ($1, $2, $3); 13 | 14 | -- name: ReadSigningKey :one 15 | SELECT * 16 | FROM signing_key 17 | WHERE id = $1; 18 | -------------------------------------------------------------------------------- /postgres/queries/operator.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateOperator :exec 2 | INSERT INTO operator (id, namespace_id, name, public_key, jwt) 3 | VALUES ($1, $2, $3, $4, $5); 4 | 5 | -- name: UpdateOperator :exec 6 | UPDATE operator 7 | SET name = $2, 8 | jwt = $3 9 | WHERE id = $1; 10 | 11 | -- name: ReadOperator :one 12 | SELECT * 13 | FROM operator 14 | WHERE id = $1; 15 | 16 | -- name: ReadOperatorByPublicKey :one 17 | SELECT * 18 | FROM operator 19 | WHERE public_key = $1; 20 | 21 | -- name: ReadOperatorByName :one 22 | SELECT * 23 | FROM operator 24 | WHERE name = $1; 25 | 26 | -- name: ListOperators :many 27 | SELECT * 28 | FROM operator 29 | WHERE namespace_id = $1 30 | AND (sqlc.narg('cursor')::TEXT IS NULL OR id <= sqlc.narg('cursor')::TEXT) 31 | ORDER BY id DESC LIMIT @size; 32 | 33 | -- name: DeleteOperator :exec 34 | SELECT delete_operator_and_nkeys($1); -------------------------------------------------------------------------------- /postgres/queries/operator_token.sql: -------------------------------------------------------------------------------- 1 | -- name: UpsertOperatorToken :exec 2 | INSERT INTO operator_token (operator_id, type, token) 3 | VALUES ($1, $2, $3) 4 | ON CONFLICT (operator_id, type) 5 | DO UPDATE SET token = excluded.token; 6 | 7 | -- name: ReadOperatorToken :one 8 | SELECT operator_token.token 9 | FROM operator_token 10 | WHERE operator_id = $1 11 | AND type = $2; 12 | -------------------------------------------------------------------------------- /postgres/queries/user.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateUser :exec 2 | INSERT INTO "user" (id, namespace_id, operator_id, account_id, name, jwt, jwt_duration) 3 | VALUES ($1, $2, $3, $4, $5, $6, $7); 4 | 5 | -- name: UpdateUser :exec 6 | UPDATE "user" 7 | SET name = $2, 8 | jwt = $3, 9 | jwt_duration = $4 10 | WHERE id = $1; 11 | 12 | -- name: ReadUser :one 13 | SELECT * 14 | FROM "user" 15 | WHERE id = $1; 16 | 17 | -- name: ReadUserByName :one 18 | SELECT * 19 | FROM "user" 20 | WHERE operator_id = $1 21 | AND account_id = $2 22 | AND name = $3; 23 | 24 | -- name: ListUsers :many 25 | SELECT * 26 | FROM "user" 27 | WHERE account_id = $1 28 | AND (sqlc.narg('cursor')::TEXT IS NULL OR id <= sqlc.narg('cursor')::TEXT) 29 | ORDER BY id DESC 30 | LIMIT @size; 31 | 32 | -- name: DeleteUser :exec 33 | SELECT delete_user_and_nkey($1); 34 | 35 | -- name: CreateUserJWTIssuance :exec 36 | INSERT INTO user_jwt_issuances (user_id, issue_time, expire_time) 37 | VALUES ($1, $2, $3); 38 | 39 | -- name: ListUserJWTIssuances :many 40 | SELECT * 41 | FROM user_jwt_issuances 42 | WHERE user_id = $1 43 | AND (sqlc.narg('cursor')::BIGINT IS NULL OR issue_time <= sqlc.narg('cursor')::BIGINT) 44 | ORDER BY issue_time DESC 45 | LIMIT @size; 46 | -------------------------------------------------------------------------------- /postgres/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - engine: "postgresql" 4 | queries: "queries" 5 | schema: "migrations" 6 | gen: 7 | go: 8 | sql_package: "pgx/v5" 9 | out: "sqlc" 10 | emit_all_enum_values: true 11 | emit_enum_valid_method: true 12 | emit_pointers_for_null_types: true 13 | emit_interface: true 14 | emit_result_struct_pointers: true 15 | emit_json_tags: true 16 | overrides: 17 | go: 18 | overrides: 19 | - db_type: "pg_catalog.timestamp" 20 | go_type: 21 | type: "time.Time" 22 | - db_type: "pg_catalog.timestamp" 23 | nullable: true 24 | go_type: 25 | import: "time" 26 | type: "Time" 27 | pointer: true 28 | - db_type: "pg_catalog.interval" 29 | go_type: 30 | type: "time.Duration" 31 | - db_type: "pg_catalog.interval" 32 | nullable: true 33 | go_type: 34 | import: "time" 35 | type: "Duration" 36 | pointer: true -------------------------------------------------------------------------------- /postgres/sqlc/account.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | // source: account.sql 5 | 6 | package sqlc 7 | 8 | import ( 9 | "context" 10 | 11 | "time" 12 | ) 13 | 14 | const createAccount = `-- name: CreateAccount :exec 15 | INSERT INTO account (id, namespace_id, operator_id, name, public_key, jwt, user_jwt_duration) 16 | VALUES ($1, $2, $3, $4, $5, $6, $7) 17 | ` 18 | 19 | type CreateAccountParams struct { 20 | ID string `json:"id"` 21 | NamespaceID string `json:"namespace_id"` 22 | OperatorID string `json:"operator_id"` 23 | Name string `json:"name"` 24 | PublicKey string `json:"public_key"` 25 | Jwt string `json:"jwt"` 26 | UserJwtDuration *time.Duration `json:"user_jwt_duration"` 27 | } 28 | 29 | func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) error { 30 | _, err := q.db.Exec(ctx, createAccount, 31 | arg.ID, 32 | arg.NamespaceID, 33 | arg.OperatorID, 34 | arg.Name, 35 | arg.PublicKey, 36 | arg.Jwt, 37 | arg.UserJwtDuration, 38 | ) 39 | return err 40 | } 41 | 42 | const deleteAccount = `-- name: DeleteAccount :exec 43 | SELECT delete_account_and_nkeys($1) 44 | ` 45 | 46 | func (q *Queries) DeleteAccount(ctx context.Context, accountID string) error { 47 | _, err := q.db.Exec(ctx, deleteAccount, accountID) 48 | return err 49 | } 50 | 51 | const listAccounts = `-- name: ListAccounts :many 52 | SELECT id, namespace_id, operator_id, name, public_key, jwt, user_jwt_duration 53 | FROM account 54 | WHERE operator_id = $1 55 | AND ($2::TEXT IS NULL OR id <= $2::TEXT) 56 | AND name != 'SYS' 57 | ORDER BY id DESC 58 | LIMIT $3 59 | ` 60 | 61 | type ListAccountsParams struct { 62 | OperatorID string `json:"operator_id"` 63 | Cursor *string `json:"cursor"` 64 | Size int32 `json:"size"` 65 | } 66 | 67 | func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]*Account, error) { 68 | rows, err := q.db.Query(ctx, listAccounts, arg.OperatorID, arg.Cursor, arg.Size) 69 | if err != nil { 70 | return nil, err 71 | } 72 | defer rows.Close() 73 | var items []*Account 74 | for rows.Next() { 75 | var i Account 76 | if err := rows.Scan( 77 | &i.ID, 78 | &i.NamespaceID, 79 | &i.OperatorID, 80 | &i.Name, 81 | &i.PublicKey, 82 | &i.Jwt, 83 | &i.UserJwtDuration, 84 | ); err != nil { 85 | return nil, err 86 | } 87 | items = append(items, &i) 88 | } 89 | if err := rows.Err(); err != nil { 90 | return nil, err 91 | } 92 | return items, nil 93 | } 94 | 95 | const readAccount = `-- name: ReadAccount :one 96 | SELECT id, namespace_id, operator_id, name, public_key, jwt, user_jwt_duration 97 | FROM account 98 | WHERE id = $1 99 | ` 100 | 101 | func (q *Queries) ReadAccount(ctx context.Context, id string) (*Account, error) { 102 | row := q.db.QueryRow(ctx, readAccount, id) 103 | var i Account 104 | err := row.Scan( 105 | &i.ID, 106 | &i.NamespaceID, 107 | &i.OperatorID, 108 | &i.Name, 109 | &i.PublicKey, 110 | &i.Jwt, 111 | &i.UserJwtDuration, 112 | ) 113 | return &i, err 114 | } 115 | 116 | const readAccountByPublicKey = `-- name: ReadAccountByPublicKey :one 117 | SELECT id, namespace_id, operator_id, name, public_key, jwt, user_jwt_duration 118 | FROM account 119 | WHERE public_key = $1 120 | ` 121 | 122 | func (q *Queries) ReadAccountByPublicKey(ctx context.Context, publicKey string) (*Account, error) { 123 | row := q.db.QueryRow(ctx, readAccountByPublicKey, publicKey) 124 | var i Account 125 | err := row.Scan( 126 | &i.ID, 127 | &i.NamespaceID, 128 | &i.OperatorID, 129 | &i.Name, 130 | &i.PublicKey, 131 | &i.Jwt, 132 | &i.UserJwtDuration, 133 | ) 134 | return &i, err 135 | } 136 | 137 | const updateAccount = `-- name: UpdateAccount :exec 138 | UPDATE account 139 | SET name = $2, 140 | jwt = $3, 141 | user_jwt_duration = $4 142 | WHERE id = $1 143 | ` 144 | 145 | type UpdateAccountParams struct { 146 | ID string `json:"id"` 147 | Name string `json:"name"` 148 | Jwt string `json:"jwt"` 149 | UserJwtDuration *time.Duration `json:"user_jwt_duration"` 150 | } 151 | 152 | func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) error { 153 | _, err := q.db.Exec(ctx, updateAccount, 154 | arg.ID, 155 | arg.Name, 156 | arg.Jwt, 157 | arg.UserJwtDuration, 158 | ) 159 | return err 160 | } 161 | -------------------------------------------------------------------------------- /postgres/sqlc/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | 5 | package sqlc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /postgres/sqlc/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | 5 | package sqlc 6 | 7 | import ( 8 | "database/sql/driver" 9 | "fmt" 10 | 11 | "time" 12 | ) 13 | 14 | type NkeyType string 15 | 16 | const ( 17 | NkeyTypeOperator NkeyType = "operator" 18 | NkeyTypeAccount NkeyType = "account" 19 | NkeyTypeUser NkeyType = "user" 20 | ) 21 | 22 | func (e *NkeyType) Scan(src interface{}) error { 23 | switch s := src.(type) { 24 | case []byte: 25 | *e = NkeyType(s) 26 | case string: 27 | *e = NkeyType(s) 28 | default: 29 | return fmt.Errorf("unsupported scan type for NkeyType: %T", src) 30 | } 31 | return nil 32 | } 33 | 34 | type NullNkeyType struct { 35 | NkeyType NkeyType `json:"nkey_type"` 36 | Valid bool `json:"valid"` // Valid is true if NkeyType is not NULL 37 | } 38 | 39 | // Scan implements the Scanner interface. 40 | func (ns *NullNkeyType) Scan(value interface{}) error { 41 | if value == nil { 42 | ns.NkeyType, ns.Valid = "", false 43 | return nil 44 | } 45 | ns.Valid = true 46 | return ns.NkeyType.Scan(value) 47 | } 48 | 49 | // Value implements the driver Valuer interface. 50 | func (ns NullNkeyType) Value() (driver.Value, error) { 51 | if !ns.Valid { 52 | return nil, nil 53 | } 54 | return string(ns.NkeyType), nil 55 | } 56 | 57 | func (e NkeyType) Valid() bool { 58 | switch e { 59 | case NkeyTypeOperator, 60 | NkeyTypeAccount, 61 | NkeyTypeUser: 62 | return true 63 | } 64 | return false 65 | } 66 | 67 | func AllNkeyTypeValues() []NkeyType { 68 | return []NkeyType{ 69 | NkeyTypeOperator, 70 | NkeyTypeAccount, 71 | NkeyTypeUser, 72 | } 73 | } 74 | 75 | type Account struct { 76 | ID string `json:"id"` 77 | NamespaceID string `json:"namespace_id"` 78 | OperatorID string `json:"operator_id"` 79 | Name string `json:"name"` 80 | PublicKey string `json:"public_key"` 81 | Jwt string `json:"jwt"` 82 | UserJwtDuration *time.Duration `json:"user_jwt_duration"` 83 | } 84 | 85 | type Namespace struct { 86 | ID string `json:"id"` 87 | Name string `json:"name"` 88 | } 89 | 90 | type Nkey struct { 91 | ID string `json:"id"` 92 | Type NkeyType `json:"type"` 93 | Seed []byte `json:"seed"` 94 | } 95 | 96 | type Operator struct { 97 | ID string `json:"id"` 98 | NamespaceID string `json:"namespace_id"` 99 | Name string `json:"name"` 100 | PublicKey string `json:"public_key"` 101 | Jwt string `json:"jwt"` 102 | } 103 | 104 | type OperatorToken struct { 105 | OperatorID string `json:"operator_id"` 106 | Type string `json:"type"` 107 | Token string `json:"token"` 108 | } 109 | 110 | type SigningKey struct { 111 | ID string `json:"id"` 112 | Type NkeyType `json:"type"` 113 | Seed []byte `json:"seed"` 114 | } 115 | 116 | type User struct { 117 | ID string `json:"id"` 118 | NamespaceID string `json:"namespace_id"` 119 | OperatorID string `json:"operator_id"` 120 | AccountID string `json:"account_id"` 121 | Name string `json:"name"` 122 | Jwt string `json:"jwt"` 123 | JwtDuration *time.Duration `json:"jwt_duration"` 124 | } 125 | 126 | type UserJwtIssuance struct { 127 | UserID string `json:"user_id"` 128 | IssueTime int64 `json:"issue_time"` 129 | ExpireTime int64 `json:"expire_time"` 130 | } 131 | -------------------------------------------------------------------------------- /postgres/sqlc/namespace.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | // source: namespace.sql 5 | 6 | package sqlc 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const createNamespace = `-- name: CreateNamespace :exec 13 | INSERT INTO namespace (id, name) 14 | VALUES ($1, $2) 15 | ` 16 | 17 | type CreateNamespaceParams struct { 18 | ID string `json:"id"` 19 | Name string `json:"name"` 20 | } 21 | 22 | func (q *Queries) CreateNamespace(ctx context.Context, arg CreateNamespaceParams) error { 23 | _, err := q.db.Exec(ctx, createNamespace, arg.ID, arg.Name) 24 | return err 25 | } 26 | 27 | const deleteNamespace = `-- name: DeleteNamespace :exec 28 | SELECT delete_namespace_and_nkeys($1) 29 | ` 30 | 31 | func (q *Queries) DeleteNamespace(ctx context.Context, namespaceID string) error { 32 | _, err := q.db.Exec(ctx, deleteNamespace, namespaceID) 33 | return err 34 | } 35 | 36 | const listNamespaces = `-- name: ListNamespaces :many 37 | SELECT id, name 38 | FROM namespace 39 | WHERE ($1::TEXT IS NULL OR id <= $1::TEXT) 40 | AND name != 'coro_internal' 41 | ORDER BY id DESC 42 | LIMIT $2 43 | ` 44 | 45 | type ListNamespacesParams struct { 46 | Cursor *string `json:"cursor"` 47 | Size int32 `json:"size"` 48 | } 49 | 50 | func (q *Queries) ListNamespaces(ctx context.Context, arg ListNamespacesParams) ([]*Namespace, error) { 51 | rows, err := q.db.Query(ctx, listNamespaces, arg.Cursor, arg.Size) 52 | if err != nil { 53 | return nil, err 54 | } 55 | defer rows.Close() 56 | var items []*Namespace 57 | for rows.Next() { 58 | var i Namespace 59 | if err := rows.Scan(&i.ID, &i.Name); err != nil { 60 | return nil, err 61 | } 62 | items = append(items, &i) 63 | } 64 | if err := rows.Err(); err != nil { 65 | return nil, err 66 | } 67 | return items, nil 68 | } 69 | 70 | const readNamespace = `-- name: ReadNamespace :one 71 | SELECT id, name 72 | FROM namespace 73 | WHERE id = $1 74 | ` 75 | 76 | func (q *Queries) ReadNamespace(ctx context.Context, id string) (*Namespace, error) { 77 | row := q.db.QueryRow(ctx, readNamespace, id) 78 | var i Namespace 79 | err := row.Scan(&i.ID, &i.Name) 80 | return &i, err 81 | } 82 | 83 | const readNamespaceByName = `-- name: ReadNamespaceByName :one 84 | SELECT id, name 85 | FROM namespace 86 | WHERE name = $1 87 | ` 88 | 89 | func (q *Queries) ReadNamespaceByName(ctx context.Context, name string) (*Namespace, error) { 90 | row := q.db.QueryRow(ctx, readNamespaceByName, name) 91 | var i Namespace 92 | err := row.Scan(&i.ID, &i.Name) 93 | return &i, err 94 | } 95 | -------------------------------------------------------------------------------- /postgres/sqlc/nkey.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | // source: nkey.sql 5 | 6 | package sqlc 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const createNkey = `-- name: CreateNkey :exec 13 | INSERT INTO nkey (id, type, seed) 14 | VALUES ($1, $2, $3) 15 | ` 16 | 17 | type CreateNkeyParams struct { 18 | ID string `json:"id"` 19 | Type NkeyType `json:"type"` 20 | Seed []byte `json:"seed"` 21 | } 22 | 23 | func (q *Queries) CreateNkey(ctx context.Context, arg CreateNkeyParams) error { 24 | _, err := q.db.Exec(ctx, createNkey, arg.ID, arg.Type, arg.Seed) 25 | return err 26 | } 27 | 28 | const createSigningKey = `-- name: CreateSigningKey :exec 29 | INSERT INTO signing_key (id, type, seed) 30 | VALUES ($1, $2, $3) 31 | ` 32 | 33 | type CreateSigningKeyParams struct { 34 | ID string `json:"id"` 35 | Type NkeyType `json:"type"` 36 | Seed []byte `json:"seed"` 37 | } 38 | 39 | func (q *Queries) CreateSigningKey(ctx context.Context, arg CreateSigningKeyParams) error { 40 | _, err := q.db.Exec(ctx, createSigningKey, arg.ID, arg.Type, arg.Seed) 41 | return err 42 | } 43 | 44 | const readNkey = `-- name: ReadNkey :one 45 | SELECT id, type, seed 46 | FROM nkey 47 | WHERE id = $1 48 | ` 49 | 50 | func (q *Queries) ReadNkey(ctx context.Context, id string) (*Nkey, error) { 51 | row := q.db.QueryRow(ctx, readNkey, id) 52 | var i Nkey 53 | err := row.Scan(&i.ID, &i.Type, &i.Seed) 54 | return &i, err 55 | } 56 | 57 | const readSigningKey = `-- name: ReadSigningKey :one 58 | SELECT id, type, seed 59 | FROM signing_key 60 | WHERE id = $1 61 | ` 62 | 63 | func (q *Queries) ReadSigningKey(ctx context.Context, id string) (*SigningKey, error) { 64 | row := q.db.QueryRow(ctx, readSigningKey, id) 65 | var i SigningKey 66 | err := row.Scan(&i.ID, &i.Type, &i.Seed) 67 | return &i, err 68 | } 69 | -------------------------------------------------------------------------------- /postgres/sqlc/operator.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | // source: operator.sql 5 | 6 | package sqlc 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const createOperator = `-- name: CreateOperator :exec 13 | INSERT INTO operator (id, namespace_id, name, public_key, jwt) 14 | VALUES ($1, $2, $3, $4, $5) 15 | ` 16 | 17 | type CreateOperatorParams struct { 18 | ID string `json:"id"` 19 | NamespaceID string `json:"namespace_id"` 20 | Name string `json:"name"` 21 | PublicKey string `json:"public_key"` 22 | Jwt string `json:"jwt"` 23 | } 24 | 25 | func (q *Queries) CreateOperator(ctx context.Context, arg CreateOperatorParams) error { 26 | _, err := q.db.Exec(ctx, createOperator, 27 | arg.ID, 28 | arg.NamespaceID, 29 | arg.Name, 30 | arg.PublicKey, 31 | arg.Jwt, 32 | ) 33 | return err 34 | } 35 | 36 | const deleteOperator = `-- name: DeleteOperator :exec 37 | SELECT delete_operator_and_nkeys($1) 38 | ` 39 | 40 | func (q *Queries) DeleteOperator(ctx context.Context, operatorID string) error { 41 | _, err := q.db.Exec(ctx, deleteOperator, operatorID) 42 | return err 43 | } 44 | 45 | const listOperators = `-- name: ListOperators :many 46 | SELECT id, namespace_id, name, public_key, jwt 47 | FROM operator 48 | WHERE namespace_id = $1 49 | AND ($2::TEXT IS NULL OR id <= $2::TEXT) 50 | ORDER BY id DESC LIMIT $3 51 | ` 52 | 53 | type ListOperatorsParams struct { 54 | NamespaceID string `json:"namespace_id"` 55 | Cursor *string `json:"cursor"` 56 | Size int32 `json:"size"` 57 | } 58 | 59 | func (q *Queries) ListOperators(ctx context.Context, arg ListOperatorsParams) ([]*Operator, error) { 60 | rows, err := q.db.Query(ctx, listOperators, arg.NamespaceID, arg.Cursor, arg.Size) 61 | if err != nil { 62 | return nil, err 63 | } 64 | defer rows.Close() 65 | var items []*Operator 66 | for rows.Next() { 67 | var i Operator 68 | if err := rows.Scan( 69 | &i.ID, 70 | &i.NamespaceID, 71 | &i.Name, 72 | &i.PublicKey, 73 | &i.Jwt, 74 | ); err != nil { 75 | return nil, err 76 | } 77 | items = append(items, &i) 78 | } 79 | if err := rows.Err(); err != nil { 80 | return nil, err 81 | } 82 | return items, nil 83 | } 84 | 85 | const readOperator = `-- name: ReadOperator :one 86 | SELECT id, namespace_id, name, public_key, jwt 87 | FROM operator 88 | WHERE id = $1 89 | ` 90 | 91 | func (q *Queries) ReadOperator(ctx context.Context, id string) (*Operator, error) { 92 | row := q.db.QueryRow(ctx, readOperator, id) 93 | var i Operator 94 | err := row.Scan( 95 | &i.ID, 96 | &i.NamespaceID, 97 | &i.Name, 98 | &i.PublicKey, 99 | &i.Jwt, 100 | ) 101 | return &i, err 102 | } 103 | 104 | const readOperatorByName = `-- name: ReadOperatorByName :one 105 | SELECT id, namespace_id, name, public_key, jwt 106 | FROM operator 107 | WHERE name = $1 108 | ` 109 | 110 | func (q *Queries) ReadOperatorByName(ctx context.Context, name string) (*Operator, error) { 111 | row := q.db.QueryRow(ctx, readOperatorByName, name) 112 | var i Operator 113 | err := row.Scan( 114 | &i.ID, 115 | &i.NamespaceID, 116 | &i.Name, 117 | &i.PublicKey, 118 | &i.Jwt, 119 | ) 120 | return &i, err 121 | } 122 | 123 | const readOperatorByPublicKey = `-- name: ReadOperatorByPublicKey :one 124 | SELECT id, namespace_id, name, public_key, jwt 125 | FROM operator 126 | WHERE public_key = $1 127 | ` 128 | 129 | func (q *Queries) ReadOperatorByPublicKey(ctx context.Context, publicKey string) (*Operator, error) { 130 | row := q.db.QueryRow(ctx, readOperatorByPublicKey, publicKey) 131 | var i Operator 132 | err := row.Scan( 133 | &i.ID, 134 | &i.NamespaceID, 135 | &i.Name, 136 | &i.PublicKey, 137 | &i.Jwt, 138 | ) 139 | return &i, err 140 | } 141 | 142 | const updateOperator = `-- name: UpdateOperator :exec 143 | UPDATE operator 144 | SET name = $2, 145 | jwt = $3 146 | WHERE id = $1 147 | ` 148 | 149 | type UpdateOperatorParams struct { 150 | ID string `json:"id"` 151 | Name string `json:"name"` 152 | Jwt string `json:"jwt"` 153 | } 154 | 155 | func (q *Queries) UpdateOperator(ctx context.Context, arg UpdateOperatorParams) error { 156 | _, err := q.db.Exec(ctx, updateOperator, arg.ID, arg.Name, arg.Jwt) 157 | return err 158 | } 159 | -------------------------------------------------------------------------------- /postgres/sqlc/operator_token.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | // source: operator_token.sql 5 | 6 | package sqlc 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const readOperatorToken = `-- name: ReadOperatorToken :one 13 | SELECT operator_token.token 14 | FROM operator_token 15 | WHERE operator_id = $1 16 | AND type = $2 17 | ` 18 | 19 | type ReadOperatorTokenParams struct { 20 | OperatorID string `json:"operator_id"` 21 | Type string `json:"type"` 22 | } 23 | 24 | func (q *Queries) ReadOperatorToken(ctx context.Context, arg ReadOperatorTokenParams) (string, error) { 25 | row := q.db.QueryRow(ctx, readOperatorToken, arg.OperatorID, arg.Type) 26 | var token string 27 | err := row.Scan(&token) 28 | return token, err 29 | } 30 | 31 | const upsertOperatorToken = `-- name: UpsertOperatorToken :exec 32 | INSERT INTO operator_token (operator_id, type, token) 33 | VALUES ($1, $2, $3) 34 | ON CONFLICT (operator_id, type) 35 | DO UPDATE SET token = excluded.token 36 | ` 37 | 38 | type UpsertOperatorTokenParams struct { 39 | OperatorID string `json:"operator_id"` 40 | Type string `json:"type"` 41 | Token string `json:"token"` 42 | } 43 | 44 | func (q *Queries) UpsertOperatorToken(ctx context.Context, arg UpsertOperatorTokenParams) error { 45 | _, err := q.db.Exec(ctx, upsertOperatorToken, arg.OperatorID, arg.Type, arg.Token) 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /postgres/sqlc/querier.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | 5 | package sqlc 6 | 7 | import ( 8 | "context" 9 | ) 10 | 11 | type Querier interface { 12 | CreateAccount(ctx context.Context, arg CreateAccountParams) error 13 | CreateNamespace(ctx context.Context, arg CreateNamespaceParams) error 14 | CreateNkey(ctx context.Context, arg CreateNkeyParams) error 15 | CreateOperator(ctx context.Context, arg CreateOperatorParams) error 16 | CreateSigningKey(ctx context.Context, arg CreateSigningKeyParams) error 17 | CreateUser(ctx context.Context, arg CreateUserParams) error 18 | CreateUserJWTIssuance(ctx context.Context, arg CreateUserJWTIssuanceParams) error 19 | DeleteAccount(ctx context.Context, accountID string) error 20 | DeleteNamespace(ctx context.Context, namespaceID string) error 21 | DeleteOperator(ctx context.Context, operatorID string) error 22 | DeleteUser(ctx context.Context, userID string) error 23 | ListAccounts(ctx context.Context, arg ListAccountsParams) ([]*Account, error) 24 | ListNamespaces(ctx context.Context, arg ListNamespacesParams) ([]*Namespace, error) 25 | ListOperators(ctx context.Context, arg ListOperatorsParams) ([]*Operator, error) 26 | ListUserJWTIssuances(ctx context.Context, arg ListUserJWTIssuancesParams) ([]*UserJwtIssuance, error) 27 | ListUsers(ctx context.Context, arg ListUsersParams) ([]*User, error) 28 | ReadAccount(ctx context.Context, id string) (*Account, error) 29 | ReadAccountByPublicKey(ctx context.Context, publicKey string) (*Account, error) 30 | ReadNamespace(ctx context.Context, id string) (*Namespace, error) 31 | ReadNamespaceByName(ctx context.Context, name string) (*Namespace, error) 32 | ReadNkey(ctx context.Context, id string) (*Nkey, error) 33 | ReadOperator(ctx context.Context, id string) (*Operator, error) 34 | ReadOperatorByName(ctx context.Context, name string) (*Operator, error) 35 | ReadOperatorByPublicKey(ctx context.Context, publicKey string) (*Operator, error) 36 | ReadOperatorToken(ctx context.Context, arg ReadOperatorTokenParams) (string, error) 37 | ReadSigningKey(ctx context.Context, id string) (*SigningKey, error) 38 | ReadUser(ctx context.Context, id string) (*User, error) 39 | ReadUserByName(ctx context.Context, arg ReadUserByNameParams) (*User, error) 40 | UpdateAccount(ctx context.Context, arg UpdateAccountParams) error 41 | UpdateOperator(ctx context.Context, arg UpdateOperatorParams) error 42 | UpdateUser(ctx context.Context, arg UpdateUserParams) error 43 | UpsertOperatorToken(ctx context.Context, arg UpsertOperatorTokenParams) error 44 | } 45 | 46 | var _ Querier = (*Queries)(nil) 47 | -------------------------------------------------------------------------------- /postgres/tx.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/jackc/pgx/v5" 8 | 9 | "github.com/coro-sh/coro/postgres/sqlc" 10 | "github.com/coro-sh/coro/tx" 11 | ) 12 | 13 | type PGXTxer interface { 14 | BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) 15 | } 16 | 17 | var _ tx.Txer = (*Txer)(nil) 18 | 19 | type Txer struct { 20 | pgxTxer PGXTxer 21 | } 22 | 23 | func NewTxer(pgxTxer PGXTxer) *Txer { 24 | return &Txer{ 25 | pgxTxer: pgxTxer, 26 | } 27 | } 28 | 29 | func (t *Txer) BeginTx(ctx context.Context) (tx.Tx, error) { 30 | return t.pgxTxer.BeginTx(ctx, pgx.TxOptions{}) 31 | } 32 | 33 | func initWithTx[T any](txn tx.Tx, initFn func(dbtx sqlc.DBTX) T) (T, error) { 34 | var t T 35 | 36 | pgxTx, ok := txn.(pgx.Tx) 37 | if !ok { 38 | return t, errors.New("tx does not implement pgx.Tx") 39 | } 40 | 41 | return initFn(pgxTx), nil 42 | } 43 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | deps: 3 | - buf.build/googleapis/googleapis 4 | lint: 5 | use: 6 | - STANDARD 7 | breaking: 8 | use: 9 | - PACKAGE 10 | -------------------------------------------------------------------------------- /server/types.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Response[T any] struct { 9 | Data T `json:"data"` 10 | } 11 | 12 | type ResponseList[T any] struct { 13 | Data []T `json:"data"` 14 | NextPageCursor *string `json:"next_page_cursor,omitempty"` 15 | } 16 | 17 | type ResponseError struct { 18 | Error HTTPError `json:"error"` 19 | } 20 | 21 | type HTTPError struct { 22 | Code int `json:"-"` 23 | Internal string `json:"-"` 24 | Message string `json:"message"` 25 | Details []string `json:"details,omitempty"` 26 | } 27 | 28 | func (e HTTPError) Error() string { 29 | if len(e.Details) > 0 { 30 | return fmt.Sprintf("%s: %s", e.Message, strings.Join(e.Details, "; ")) 31 | } 32 | return e.Message 33 | } 34 | 35 | type HealthResponse struct { 36 | Status string `json:"status"` 37 | } 38 | -------------------------------------------------------------------------------- /server/util.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type validatable interface { 10 | Validate() error 11 | } 12 | 13 | func BindRequest[T validatable](c echo.Context) (T, error) { 14 | var req T 15 | if err := c.Bind(&req); err != nil { 16 | return req, err 17 | } 18 | if err := req.Validate(); err != nil { 19 | return req, err 20 | } 21 | return req, nil 22 | } 23 | 24 | func SetResponse[T any](c echo.Context, code int, data T) error { 25 | return c.JSON(code, &Response[T]{ 26 | Data: data, 27 | }) 28 | } 29 | 30 | func SetResponseList[T any](c echo.Context, code int, data []T, nextCursor string) error { 31 | res := &ResponseList[T]{ 32 | Data: data, 33 | } 34 | if nextCursor != "" { 35 | b64Cursor := base64.URLEncoding.EncodeToString([]byte(nextCursor)) 36 | res.NextPageCursor = &b64Cursor 37 | } 38 | return c.JSON(code, res) 39 | } 40 | 41 | func SetResponseError(c echo.Context, code int, err HTTPError) error { 42 | return c.JSON(code, &ResponseError{ 43 | Error: err, 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /syncutil/map.go: -------------------------------------------------------------------------------- 1 | package syncutil 2 | 3 | import "sync" 4 | 5 | // Map is a generic, thread-safe map. 6 | type Map[K comparable, V any] struct { 7 | mu sync.RWMutex 8 | data map[K]V 9 | } 10 | 11 | // NewMap creates a new concurrent-safe map. 12 | func NewMap[K comparable, V any]() *Map[K, V] { 13 | return &Map[K, V]{ 14 | data: make(map[K]V), 15 | } 16 | } 17 | 18 | // Set inserts or updates a value. 19 | func (m *Map[K, V]) Set(key K, value V) { 20 | m.mu.Lock() 21 | defer m.mu.Unlock() 22 | m.data[key] = value 23 | } 24 | 25 | // Get retrieves a value and whether it was found. 26 | func (m *Map[K, V]) Get(key K) (V, bool) { 27 | m.mu.RLock() 28 | defer m.mu.RUnlock() 29 | val, ok := m.data[key] 30 | return val, ok 31 | } 32 | 33 | // Delete removes a key from the map. 34 | func (m *Map[K, V]) Delete(key K) { 35 | m.mu.Lock() 36 | defer m.mu.Unlock() 37 | delete(m.data, key) 38 | } 39 | 40 | // Len returns the number of entries. 41 | func (m *Map[K, V]) Len() int { 42 | m.mu.RLock() 43 | defer m.mu.RUnlock() 44 | return len(m.data) 45 | } 46 | 47 | // Keys returns a slice of all keys. 48 | func (m *Map[K, V]) Keys() []K { 49 | m.mu.RLock() 50 | defer m.mu.RUnlock() 51 | keys := make([]K, 0, len(m.data)) 52 | for k := range m.data { 53 | keys = append(keys, k) 54 | } 55 | return keys 56 | } 57 | -------------------------------------------------------------------------------- /tkn/fake.go: -------------------------------------------------------------------------------- 1 | package tkn 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/coro-sh/coro/entity" 9 | "github.com/coro-sh/coro/errtag" 10 | "github.com/coro-sh/coro/internal/testutil" 11 | ) 12 | 13 | var errFakeNotFound = errtag.NewTagged[errtag.NotFound]("not found") 14 | 15 | var _ OperatorTokenReadWriter = (*FakeOperatorTokenReadWriter)(nil) 16 | 17 | type FakeOperatorTokenReadWriter struct { 18 | tokenIndexes *testutil.KV[OperatorTokenType, *testutil.KV[entity.OperatorID, string]] 19 | } 20 | 21 | func NewFakeOperatorTokenReadWriter(t *testing.T) *FakeOperatorTokenReadWriter { 22 | t.Helper() 23 | indexes := testutil.NewKV[OperatorTokenType, *testutil.KV[entity.OperatorID, string]](t) 24 | indexes.Put(OperatorTokenTypeProxy, testutil.NewKV[entity.OperatorID, string](t)) 25 | return &FakeOperatorTokenReadWriter{ 26 | tokenIndexes: indexes, 27 | } 28 | } 29 | 30 | func (rw *FakeOperatorTokenReadWriter) Read(_ context.Context, tokenType OperatorTokenType, operatorID entity.OperatorID) (string, error) { 31 | index, ok := rw.tokenIndexes.Get(tokenType) 32 | if !ok { 33 | return "", errors.New("index not found") 34 | } 35 | tkn, ok := index.Get(operatorID) 36 | if !ok { 37 | return "", errtag.Tag[errtag.NotFound](errFakeNotFound) 38 | } 39 | return tkn, nil 40 | } 41 | 42 | func (rw *FakeOperatorTokenReadWriter) Write(_ context.Context, tokenType OperatorTokenType, operatorID entity.OperatorID, hashedToken string) error { 43 | index, ok := rw.tokenIndexes.Get(tokenType) 44 | if !ok { 45 | return errors.New("index not found") 46 | } 47 | index.Put(operatorID, hashedToken) 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /tkn/issuer.go: -------------------------------------------------------------------------------- 1 | package tkn 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/subtle" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "strings" 10 | 11 | "golang.org/x/crypto/argon2" 12 | ) 13 | 14 | const ( 15 | defaultTokenLength = 38 16 | alphanumericChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 17 | ) 18 | 19 | type Option func(iss *Issuer) 20 | 21 | // WithPepper adds the pepper (secret) to tokens before hashing. 22 | func WithPepper(pepper []byte) Option { 23 | return func(iss *Issuer) { 24 | iss.pepper = pepper 25 | } 26 | } 27 | 28 | type Issuer struct { 29 | pepper []byte 30 | } 31 | 32 | func NewIssuer(opts ...Option) *Issuer { 33 | var iss Issuer 34 | for _, opt := range opts { 35 | opt(&iss) 36 | } 37 | return &iss 38 | } 39 | 40 | type GenerateOption func(opts *generateOptions) 41 | 42 | // Config holds configuration parameters for token generation and hashing. 43 | type generateOptions struct { 44 | length int // Length of the random part of the token 45 | prefix string // Prefix to prepend to the token 46 | } 47 | 48 | // WithLength sets the length of the random part of the token. 49 | func WithLength(length int) GenerateOption { 50 | return func(opts *generateOptions) { 51 | opts.length = length 52 | } 53 | } 54 | 55 | // WithPrefix sets the prefix to prepend to the token. 56 | func WithPrefix(prefix string) GenerateOption { 57 | return func(opts *generateOptions) { 58 | opts.prefix = prefix 59 | } 60 | } 61 | 62 | // Generate generates a secure random token from the specified Config. 63 | func (i *Issuer) Generate(opts ...GenerateOption) (string, error) { 64 | options := generateOptions{ 65 | length: defaultTokenLength, // 226 bits of entropy 66 | prefix: "", 67 | } 68 | for _, opt := range opts { 69 | opt(&options) 70 | } 71 | 72 | length := options.length 73 | 74 | var sb strings.Builder 75 | sb.Grow(length) 76 | charSetLen := len(alphanumericChars) 77 | for i := 0; i < length; i++ { 78 | idx, err := randInt(charSetLen) 79 | if err != nil { 80 | return "", err 81 | } 82 | sb.WriteByte(alphanumericChars[idx]) 83 | } 84 | 85 | return options.prefix + sb.String(), nil 86 | } 87 | 88 | // Hash hashes a token using Argon2id. 89 | func (i *Issuer) Hash(token string) (string, error) { 90 | salt := make([]byte, 16) // 128-bit salt 91 | if _, err := rand.Read(salt); err != nil { 92 | return "", fmt.Errorf("failed to generate salt: %v", err) 93 | } 94 | 95 | tokenBytes := []byte(token) 96 | if i.pepper != nil { 97 | tokenBytes = append(tokenBytes, i.pepper...) 98 | } 99 | 100 | hash := argon2.IDKey(tokenBytes, salt, 1, 64*1024, 4, 32) 101 | return encodeHash(salt, hash), nil 102 | } 103 | 104 | // Verify verifies a token against a hashed token. 105 | func (i *Issuer) Verify(token string, hashedToken string) (bool, error) { 106 | salt, hash, err := decodeHash(hashedToken) 107 | if err != nil { 108 | return false, err 109 | } 110 | 111 | tokenBytes := []byte(token) 112 | if i.pepper != nil { 113 | tokenBytes = append(tokenBytes, i.pepper...) 114 | } 115 | 116 | newHash := argon2.IDKey(tokenBytes, salt, 1, 64*1024, 4, 32) 117 | return subtle.ConstantTimeCompare(newHash, hash) == 1, nil 118 | } 119 | 120 | func encodeHash(salt, hash []byte) string { 121 | return base64.RawStdEncoding.EncodeToString(salt) + ":" + base64.RawStdEncoding.EncodeToString(hash) 122 | } 123 | 124 | func decodeHash(encodedHash string) (salt, hash []byte, err error) { 125 | parts := strings.SplitN(encodedHash, ":", 2) 126 | if len(parts) != 2 { 127 | return nil, nil, errors.New("invalid hashed token format") 128 | } 129 | salt, err = base64.RawStdEncoding.DecodeString(parts[0]) 130 | if err != nil { 131 | return nil, nil, fmt.Errorf("failed to decode salt: %v", err) 132 | } 133 | hash, err = base64.RawStdEncoding.DecodeString(parts[1]) 134 | if err != nil { 135 | return nil, nil, fmt.Errorf("failed to decode hash: %v", err) 136 | } 137 | return salt, hash, nil 138 | } 139 | 140 | func randInt(limit int) (int, error) { 141 | if limit <= 0 { 142 | return 0, errors.New("max must be positive") 143 | } 144 | b := make([]byte, 1) 145 | _, err := rand.Read(b) 146 | if err != nil { 147 | return 0, err 148 | } 149 | return int(b[0]) % limit, nil 150 | } 151 | -------------------------------------------------------------------------------- /tkn/issuer_test.go: -------------------------------------------------------------------------------- 1 | package tkn 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | const ( 12 | testPrefix = "prefix_" 13 | testToken = "test_token" 14 | testInvalidToken = "invalid_token" 15 | testPepper = "secret_pepper" 16 | ) 17 | 18 | func TestIssuer_Generate(t *testing.T) { 19 | iss := NewIssuer() 20 | 21 | tests := []struct { 22 | name string 23 | options []GenerateOption 24 | wantLength int 25 | wantPrefix string 26 | }{ 27 | { 28 | name: "default generation", 29 | options: nil, 30 | wantLength: defaultTokenLength, 31 | wantPrefix: "", 32 | }, 33 | { 34 | name: "with custom length", 35 | options: []GenerateOption{WithLength(50)}, 36 | wantLength: 50, 37 | wantPrefix: "", 38 | }, 39 | { 40 | name: "with prefix", 41 | options: []GenerateOption{WithPrefix("prefix_")}, 42 | wantLength: len("prefix_") + defaultTokenLength, 43 | wantPrefix: testPrefix, 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | token, err := iss.Generate(tt.options...) 50 | require.NoError(t, err) 51 | assert.Len(t, token, tt.wantLength) 52 | assert.True(t, strings.HasPrefix(token, tt.wantPrefix)) 53 | }) 54 | } 55 | } 56 | 57 | func TestIssuer_Hash(t *testing.T) { 58 | tests := []struct { 59 | name string 60 | pepper []byte 61 | }{ 62 | { 63 | name: "hash token without pepper", 64 | pepper: nil, 65 | }, 66 | { 67 | name: "hash token with pepper", 68 | pepper: []byte(testPepper), 69 | }, 70 | } 71 | 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | iss := NewIssuer() 75 | if tt.pepper != nil { 76 | iss = NewIssuer(WithPepper(tt.pepper)) 77 | } 78 | 79 | token := testToken 80 | hashedToken, err := iss.Hash(token) 81 | require.NoError(t, err) 82 | assert.NotEmpty(t, hashedToken) 83 | }) 84 | } 85 | } 86 | 87 | func TestIssuer_HashVerify(t *testing.T) { 88 | tests := []struct { 89 | name string 90 | token string 91 | hashedToken string 92 | verifyToken string 93 | wantValid bool 94 | pepper []byte 95 | }{ 96 | { 97 | name: "valid token without pepper", 98 | token: testToken, 99 | verifyToken: testToken, 100 | wantValid: true, 101 | pepper: nil, 102 | }, 103 | { 104 | name: "invalid token without pepper", 105 | token: testToken, 106 | verifyToken: testInvalidToken, 107 | wantValid: false, 108 | pepper: nil, 109 | }, 110 | { 111 | name: "valid token with pepper", 112 | token: testToken, 113 | verifyToken: testToken, 114 | wantValid: true, 115 | pepper: []byte(testPepper), 116 | }, 117 | { 118 | name: "invalid token with pepper", 119 | token: testToken, 120 | verifyToken: testInvalidToken, 121 | wantValid: false, 122 | pepper: []byte(testPepper), 123 | }, 124 | } 125 | 126 | for _, tt := range tests { 127 | t.Run(tt.name, func(t *testing.T) { 128 | iss := NewIssuer() 129 | if tt.pepper != nil { 130 | iss = NewIssuer(WithPepper(tt.pepper)) 131 | } 132 | 133 | hashedToken, err := iss.Hash(tt.token) 134 | require.NoError(t, err) 135 | 136 | valid, err := iss.Verify(tt.verifyToken, hashedToken) 137 | require.NoError(t, err) 138 | assert.Equal(t, tt.wantValid, valid) 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tkn/operator_issuer.go: -------------------------------------------------------------------------------- 1 | package tkn 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/coro-sh/coro/entity" 9 | "github.com/coro-sh/coro/errtag" 10 | ) 11 | 12 | type OperatorTokenType string 13 | 14 | const OperatorTokenTypeProxy OperatorTokenType = "pxy" 15 | 16 | type OperatorTokenReadWriter interface { 17 | Read(ctx context.Context, tokenType OperatorTokenType, operatorID entity.OperatorID) (string, error) 18 | Write(ctx context.Context, tokenType OperatorTokenType, operatorID entity.OperatorID, hashedToken string) error 19 | } 20 | 21 | type OperatorIssuer struct { 22 | iss *Issuer 23 | rw OperatorTokenReadWriter 24 | tknType OperatorTokenType 25 | } 26 | 27 | func NewOperatorIssuer(rw OperatorTokenReadWriter, tknType OperatorTokenType) *OperatorIssuer { 28 | return &OperatorIssuer{ 29 | iss: NewIssuer(), 30 | rw: rw, 31 | tknType: tknType, 32 | } 33 | } 34 | 35 | func (o *OperatorIssuer) Generate(ctx context.Context, operatorID entity.OperatorID) (string, error) { 36 | token, err := o.iss.Generate(WithPrefix(fmt.Sprintf("%s_%s", string(o.tknType), operatorID.Suffix()))) 37 | if err != nil { 38 | return "", fmt.Errorf("generate proxy token: %w", err) 39 | } 40 | 41 | hashed, err := o.iss.Hash(token) 42 | if err != nil { 43 | return "", fmt.Errorf("hash proxy token: %w", err) 44 | } 45 | 46 | if err = o.rw.Write(ctx, o.tknType, operatorID, hashed); err != nil { 47 | return "", fmt.Errorf("write proxy token: %w", err) 48 | } 49 | 50 | return token, nil 51 | } 52 | 53 | func (o *OperatorIssuer) Verify(ctx context.Context, token string) (id entity.OperatorID, err error) { 54 | var zeroID entity.OperatorID 55 | tokenTypePrefix := string(o.tknType) + "_" 56 | 57 | if !strings.HasPrefix(token, tokenTypePrefix) { 58 | return zeroID, errtag.NewTagged[errtag.Unauthorized]("unrecognized token") 59 | } 60 | 61 | opIDSuffixLen := len(zeroID.Suffix()) 62 | if len(token) < opIDSuffixLen { 63 | return zeroID, errtag.NewTagged[errtag.Unauthorized]("invalid token length") 64 | } 65 | 66 | opIDSuffix := strings.TrimPrefix(token, tokenTypePrefix)[:opIDSuffixLen] 67 | opIDStr := fmt.Sprintf("%s_%s", zeroID.Prefix(), opIDSuffix) 68 | operatorID, err := entity.ParseID[entity.OperatorID](opIDStr) 69 | if err != nil { 70 | return zeroID, errtag.Tag[errtag.Unauthorized](fmt.Errorf("parse operator id: %w", err)) 71 | } 72 | 73 | hashed, err := o.rw.Read(ctx, o.tknType, operatorID) 74 | if err != nil { 75 | if errtag.HasTag[errtag.NotFound](err) { 76 | return zeroID, errtag.Tag[errtag.Unauthorized](err) 77 | } 78 | return zeroID, err 79 | } 80 | 81 | ok, err := o.iss.Verify(token, hashed) 82 | if err != nil { 83 | return zeroID, errtag.Tag[errtag.Unauthorized](err) 84 | } 85 | if !ok { 86 | return zeroID, errtag.NewTagged[errtag.Unauthorized]("token mismatch") 87 | } 88 | 89 | return operatorID, nil 90 | } 91 | -------------------------------------------------------------------------------- /tkn/operator_issuer_test.go: -------------------------------------------------------------------------------- 1 | package tkn 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/coro-sh/coro/entity" 11 | ) 12 | 13 | func TestOperatorIssuer(t *testing.T) { 14 | ctx := context.Background() 15 | rw := NewFakeOperatorTokenReadWriter(t) 16 | issuer := NewOperatorIssuer(rw, OperatorTokenTypeProxy) 17 | opID := entity.NewID[entity.OperatorID]() 18 | 19 | token, err := issuer.Generate(ctx, opID) 20 | require.NoError(t, err) 21 | assert.NotEmpty(t, token) 22 | 23 | gotHashed, err := rw.Read(ctx, OperatorTokenTypeProxy, opID) 24 | require.NoError(t, err) 25 | assert.NotEmpty(t, gotHashed) 26 | 27 | gotOpID, err := issuer.Verify(ctx, token) 28 | require.NoError(t, err) 29 | assert.Equal(t, opID, gotOpID) 30 | } 31 | -------------------------------------------------------------------------------- /tx/tx.go: -------------------------------------------------------------------------------- 1 | package tx 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type Txer interface { 9 | BeginTx(ctx context.Context) (Tx, error) 10 | } 11 | 12 | type Tx interface { 13 | Commit(ctx context.Context) error 14 | Rollback(ctx context.Context) error 15 | } 16 | 17 | // Handle is intended for deferred execution to correctly handle a transaction if 18 | // a failure occurs. Arg `err` must a pointer to the error returned by the caller. 19 | func Handle(ctx context.Context, tx Tx, err *error) { 20 | if r := recover(); r != nil { 21 | if rErr := tx.Rollback(ctx); rErr != nil { 22 | panic(fmt.Errorf("panic: %v; failed to rollback transaction: %w", r, rErr)) 23 | } 24 | panic(r) 25 | } 26 | 27 | if err != nil { 28 | baseErr := *err 29 | if baseErr != nil { 30 | if rErr := tx.Rollback(ctx); rErr != nil { 31 | *err = fmt.Errorf("%w; failed to rollback transaction: %w", baseErr, rErr) 32 | } 33 | return 34 | } 35 | } 36 | 37 | if cErr := tx.Commit(ctx); cErr != nil { 38 | *err = fmt.Errorf("failed to commit transaction: %w", cErr) 39 | } 40 | } 41 | --------------------------------------------------------------------------------