├── .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 |
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 |
--------------------------------------------------------------------------------