create-svc 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -16
- package/bin/create-service.mjs +2 -0
- package/package.json +19 -11
- package/src/cli.test.ts +46 -7
- package/src/cli.ts +282 -84
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +5 -2
- package/src/naming.ts +32 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +18 -26
- package/src/profiles.ts +25 -0
- package/src/scaffold.test.ts +320 -18
- package/src/scaffold.ts +154 -28
- package/src/vault.test.ts +94 -10
- package/src/vault.ts +81 -18
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +217 -29
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +21 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
- package/templates/shared/scripts/cloudrun/lib.ts +232 -123
- package/templates/shared/scripts/cloudrun/neon.ts +127 -13
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -1
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +17 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-connectrpc/package.json +25 -1
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
- package/templates/variants/bun-connectrpc/src/index.ts +194 -22
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +17 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-hono/package.json +21 -1
- package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +126 -0
- package/templates/variants/bun-hono/src/db/schema.ts +26 -0
- package/templates/variants/bun-hono/src/index.ts +141 -10
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +90 -5
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +30 -10
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +25 -13
- package/templates/variants/go-chi/go.mod +3 -2
- package/templates/variants/go-chi/internal/app/service.go +279 -70
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +38 -7
- package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +29 -8
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/buf.gen.yaml +0 -10
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -1,109 +1,318 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
4
5
|
"context"
|
|
6
|
+
"database/sql"
|
|
7
|
+
"encoding/csv"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
5
10
|
"fmt"
|
|
6
|
-
"
|
|
11
|
+
"net/mail"
|
|
12
|
+
"strings"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/jmoiron/sqlx"
|
|
7
16
|
)
|
|
8
17
|
|
|
9
|
-
type
|
|
10
|
-
ID
|
|
11
|
-
|
|
12
|
-
Name
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
type WaitlistEntry struct {
|
|
19
|
+
ID string `json:"id" db:"id"`
|
|
20
|
+
Email string `json:"email" db:"email"`
|
|
21
|
+
Name string `json:"name,omitempty" db:"name"`
|
|
22
|
+
Company string `json:"company,omitempty" db:"company"`
|
|
23
|
+
Source string `json:"source,omitempty" db:"source"`
|
|
24
|
+
Status string `json:"status" db:"status"`
|
|
25
|
+
CreatedAt string `json:"created_at" db:"created_at"`
|
|
26
|
+
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
type
|
|
19
|
-
|
|
29
|
+
type WaitlistTrigger struct {
|
|
30
|
+
ID string `json:"id" db:"id"`
|
|
31
|
+
Type string `json:"type" db:"type"`
|
|
32
|
+
EntryID string `json:"entry_id,omitempty" db:"entry_id"`
|
|
33
|
+
Status string `json:"status" db:"status"`
|
|
34
|
+
Payload any `json:"payload"`
|
|
35
|
+
CreatedAt string `json:"created_at" db:"created_at"`
|
|
36
|
+
ProcessedAt string `json:"processed_at,omitempty" db:"processed_at"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type JoinWaitlistInput struct {
|
|
40
|
+
Email string `json:"email"`
|
|
20
41
|
Name string `json:"name"`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
Company string `json:"company"`
|
|
43
|
+
Source string `json:"source"`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type JoinWaitlistResult struct {
|
|
47
|
+
Entry WaitlistEntry `json:"entry"`
|
|
48
|
+
Created bool `json:"created"`
|
|
24
49
|
}
|
|
25
50
|
|
|
26
|
-
type
|
|
51
|
+
type RecordTriggerInput struct {
|
|
27
52
|
Type string `json:"type"`
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
EntryID string `json:"entry_id"`
|
|
54
|
+
Payload any `json:"payload"`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ListWaitlistEntriesInput struct {
|
|
58
|
+
Status string `json:"status"`
|
|
59
|
+
Limit int `json:"limit"`
|
|
32
60
|
}
|
|
33
61
|
|
|
34
|
-
type
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
records map[string]Record
|
|
62
|
+
type UpdateWaitlistEntryInput struct {
|
|
63
|
+
EntryID string `json:"entry_id"`
|
|
64
|
+
Status string `json:"status"`
|
|
38
65
|
}
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
type AppError struct {
|
|
68
|
+
Status int
|
|
69
|
+
Code string
|
|
70
|
+
Err error
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func (e *AppError) Error() string { return e.Err.Error() }
|
|
74
|
+
|
|
75
|
+
type WaitlistService struct {
|
|
76
|
+
db *sqlx.DB
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func OpenDatabase(ctx context.Context, databaseURL string) (*sqlx.DB, error) {
|
|
80
|
+
if strings.TrimSpace(databaseURL) == "" {
|
|
81
|
+
return nil, errors.New("DATABASE_URL is required")
|
|
82
|
+
}
|
|
83
|
+
return sqlx.ConnectContext(ctx, "pgx", databaseURL)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func NewWaitlistService(db *sqlx.DB) *WaitlistService {
|
|
87
|
+
return &WaitlistService{db: db}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func (s *WaitlistService) JoinWaitlist(ctx context.Context, input JoinWaitlistInput) (JoinWaitlistResult, error) {
|
|
91
|
+
email, err := normalizeEmail(input.Email)
|
|
92
|
+
if err != nil {
|
|
93
|
+
return JoinWaitlistResult{}, err
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
existing, err := s.GetEntryByEmail(ctx, email)
|
|
97
|
+
if err == nil {
|
|
98
|
+
return JoinWaitlistResult{Entry: existing, Created: false}, nil
|
|
99
|
+
}
|
|
100
|
+
var appErr *AppError
|
|
101
|
+
if !errors.As(err, &appErr) || appErr.Code != "entry_not_found" {
|
|
102
|
+
return JoinWaitlistResult{}, err
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
id := fmt.Sprintf("entry_%d", time.Now().UnixNano())
|
|
106
|
+
var entry WaitlistEntry
|
|
107
|
+
err = s.db.GetContext(ctx, &entry, `
|
|
108
|
+
insert into waitlist_entries (id, email, name, company, source, status)
|
|
109
|
+
values ($1, $2, nullif($3, ''), nullif($4, ''), nullif($5, ''), 'joined')
|
|
110
|
+
returning id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
|
|
111
|
+
`, id, email, strings.TrimSpace(input.Name), strings.TrimSpace(input.Company), strings.TrimSpace(input.Source))
|
|
112
|
+
if err != nil {
|
|
113
|
+
return JoinWaitlistResult{}, err
|
|
44
114
|
}
|
|
115
|
+
|
|
116
|
+
if _, err := s.RecordTrigger(ctx, RecordTriggerInput{
|
|
117
|
+
Type: "waitlist.joined",
|
|
118
|
+
EntryID: entry.ID,
|
|
119
|
+
Payload: map[string]any{"email": entry.Email, "source": entry.Source},
|
|
120
|
+
}); err != nil {
|
|
121
|
+
return JoinWaitlistResult{}, err
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return JoinWaitlistResult{Entry: entry, Created: true}, nil
|
|
45
125
|
}
|
|
46
126
|
|
|
47
|
-
func (s *
|
|
48
|
-
|
|
49
|
-
s.
|
|
50
|
-
|
|
127
|
+
func (s *WaitlistService) GetEntry(ctx context.Context, entryID string) (WaitlistEntry, error) {
|
|
128
|
+
var entry WaitlistEntry
|
|
129
|
+
err := s.db.GetContext(ctx, &entry, `
|
|
130
|
+
select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
|
|
131
|
+
from waitlist_entries
|
|
132
|
+
where id = $1`, strings.TrimSpace(entryID))
|
|
133
|
+
if err != nil {
|
|
134
|
+
return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
|
|
135
|
+
}
|
|
136
|
+
return entry, nil
|
|
137
|
+
}
|
|
51
138
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
139
|
+
func (s *WaitlistService) GetEntryByEmail(ctx context.Context, rawEmail string) (WaitlistEntry, error) {
|
|
140
|
+
email, err := normalizeEmail(rawEmail)
|
|
141
|
+
if err != nil {
|
|
142
|
+
return WaitlistEntry{}, err
|
|
55
143
|
}
|
|
56
|
-
|
|
144
|
+
var entry WaitlistEntry
|
|
145
|
+
err = s.db.GetContext(ctx, &entry, `
|
|
146
|
+
select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
|
|
147
|
+
from waitlist_entries
|
|
148
|
+
where email = $1`, email)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
|
|
151
|
+
}
|
|
152
|
+
return entry, nil
|
|
57
153
|
}
|
|
58
154
|
|
|
59
|
-
func (s *
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
155
|
+
func (s *WaitlistService) ListEntries(ctx context.Context, input ListWaitlistEntriesInput) ([]WaitlistEntry, error) {
|
|
156
|
+
limit := clampLimit(input.Limit)
|
|
157
|
+
status := strings.TrimSpace(input.Status)
|
|
158
|
+
var entries []WaitlistEntry
|
|
159
|
+
var err error
|
|
160
|
+
if status != "" {
|
|
161
|
+
status, err = normalizeStatus(status)
|
|
162
|
+
if err != nil {
|
|
163
|
+
return nil, err
|
|
164
|
+
}
|
|
165
|
+
err = s.db.SelectContext(ctx, &entries, `
|
|
166
|
+
select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
|
|
167
|
+
from waitlist_entries
|
|
168
|
+
where status = $1
|
|
169
|
+
order by created_at desc
|
|
170
|
+
limit $2`, status, limit)
|
|
171
|
+
} else {
|
|
172
|
+
err = s.db.SelectContext(ctx, &entries, `
|
|
173
|
+
select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
|
|
174
|
+
from waitlist_entries
|
|
175
|
+
order by created_at desc
|
|
176
|
+
limit $1`, limit)
|
|
177
|
+
}
|
|
178
|
+
if err != nil {
|
|
179
|
+
return nil, err
|
|
180
|
+
}
|
|
181
|
+
return entries, nil
|
|
182
|
+
}
|
|
63
183
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
184
|
+
func (s *WaitlistService) UpdateEntry(ctx context.Context, input UpdateWaitlistEntryInput) (WaitlistEntry, error) {
|
|
185
|
+
entryID := strings.TrimSpace(input.EntryID)
|
|
186
|
+
if entryID == "" {
|
|
187
|
+
return WaitlistEntry{}, &AppError{Status: 400, Code: "invalid_entry_id", Err: errors.New("entry id is required")}
|
|
188
|
+
}
|
|
189
|
+
status, err := normalizeStatus(input.Status)
|
|
190
|
+
if err != nil {
|
|
191
|
+
return WaitlistEntry{}, err
|
|
192
|
+
}
|
|
193
|
+
var entry WaitlistEntry
|
|
194
|
+
err = s.db.GetContext(ctx, &entry, `
|
|
195
|
+
update waitlist_entries
|
|
196
|
+
set status = $2, updated_at = now()
|
|
197
|
+
where id = $1
|
|
198
|
+
returning id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
|
|
199
|
+
`, entryID, status)
|
|
200
|
+
if err != nil {
|
|
201
|
+
return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
|
|
71
202
|
}
|
|
203
|
+
return entry, nil
|
|
204
|
+
}
|
|
72
205
|
|
|
73
|
-
|
|
74
|
-
s.
|
|
75
|
-
|
|
206
|
+
func (s *WaitlistService) ExportEntries(ctx context.Context, input ListWaitlistEntriesInput) (string, error) {
|
|
207
|
+
entries, err := s.ListEntries(ctx, input)
|
|
208
|
+
if err != nil {
|
|
209
|
+
return "", err
|
|
210
|
+
}
|
|
211
|
+
var buffer bytes.Buffer
|
|
212
|
+
writer := csv.NewWriter(&buffer)
|
|
213
|
+
_ = writer.Write([]string{"id", "email", "name", "company", "source", "status", "created_at", "updated_at"})
|
|
214
|
+
for _, entry := range entries {
|
|
215
|
+
_ = writer.Write([]string{entry.ID, entry.Email, entry.Name, entry.Company, entry.Source, entry.Status, entry.CreatedAt, entry.UpdatedAt})
|
|
216
|
+
}
|
|
217
|
+
writer.Flush()
|
|
218
|
+
return buffer.String(), writer.Error()
|
|
76
219
|
}
|
|
77
220
|
|
|
78
|
-
func (s *
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
221
|
+
func (s *WaitlistService) RecordTrigger(ctx context.Context, input RecordTriggerInput) (WaitlistTrigger, error) {
|
|
222
|
+
triggerType := strings.TrimSpace(input.Type)
|
|
223
|
+
if triggerType == "" {
|
|
224
|
+
return WaitlistTrigger{}, &AppError{Status: 400, Code: "invalid_trigger_type", Err: errors.New("trigger type is required")}
|
|
225
|
+
}
|
|
82
226
|
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
|
|
227
|
+
entryID := strings.TrimSpace(input.EntryID)
|
|
228
|
+
if entryID != "" {
|
|
229
|
+
if _, err := s.GetEntry(ctx, entryID); err != nil {
|
|
230
|
+
return WaitlistTrigger{}, err
|
|
231
|
+
}
|
|
86
232
|
}
|
|
87
233
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
234
|
+
payloadBytes, err := json.Marshal(input.Payload)
|
|
235
|
+
if err != nil {
|
|
236
|
+
return WaitlistTrigger{}, err
|
|
237
|
+
}
|
|
238
|
+
if string(payloadBytes) == "null" {
|
|
239
|
+
payloadBytes = []byte("{}")
|
|
240
|
+
}
|
|
94
241
|
|
|
95
|
-
|
|
242
|
+
id := fmt.Sprintf("trg_%d", time.Now().UnixNano())
|
|
243
|
+
var row waitlistTriggerRow
|
|
244
|
+
err = s.db.GetContext(ctx, &row, `
|
|
245
|
+
insert into waitlist_triggers (id, type, entry_id, status, payload_json)
|
|
246
|
+
values ($1, $2, nullif($3, ''), 'queued', $4)
|
|
247
|
+
returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, created_at::text, coalesce(processed_at::text, '') as processed_at
|
|
248
|
+
`, id, triggerType, entryID, string(payloadBytes))
|
|
249
|
+
if err != nil {
|
|
250
|
+
return WaitlistTrigger{}, err
|
|
251
|
+
}
|
|
252
|
+
return row.toTrigger()
|
|
96
253
|
}
|
|
97
254
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
255
|
+
type waitlistTriggerRow struct {
|
|
256
|
+
ID string `db:"id"`
|
|
257
|
+
Type string `db:"type"`
|
|
258
|
+
EntryID string `db:"entry_id"`
|
|
259
|
+
Status string `db:"status"`
|
|
260
|
+
PayloadJSON string `db:"payload_json"`
|
|
261
|
+
CreatedAt string `db:"created_at"`
|
|
262
|
+
ProcessedAt string `db:"processed_at"`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func (r waitlistTriggerRow) toTrigger() (WaitlistTrigger, error) {
|
|
266
|
+
var payload any
|
|
267
|
+
if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
|
|
268
|
+
return WaitlistTrigger{}, err
|
|
269
|
+
}
|
|
270
|
+
return WaitlistTrigger{
|
|
271
|
+
ID: r.ID,
|
|
272
|
+
Type: r.Type,
|
|
273
|
+
EntryID: r.EntryID,
|
|
274
|
+
Status: r.Status,
|
|
275
|
+
Payload: payload,
|
|
276
|
+
CreatedAt: r.CreatedAt,
|
|
277
|
+
ProcessedAt: r.ProcessedAt,
|
|
278
|
+
}, nil
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func normalizeEmail(value string) (string, error) {
|
|
282
|
+
email := strings.ToLower(strings.TrimSpace(value))
|
|
283
|
+
if email == "" {
|
|
284
|
+
return "", &AppError{Status: 400, Code: "invalid_email", Err: errors.New("valid email is required")}
|
|
285
|
+
}
|
|
286
|
+
parsed, err := mail.ParseAddress(email)
|
|
287
|
+
if err != nil || parsed.Address != email {
|
|
288
|
+
return "", &AppError{Status: 400, Code: "invalid_email", Err: errors.New("valid email is required")}
|
|
289
|
+
}
|
|
290
|
+
return email, nil
|
|
291
|
+
}
|
|
102
292
|
|
|
103
|
-
|
|
104
|
-
|
|
293
|
+
func normalizeStatus(value string) (string, error) {
|
|
294
|
+
status := strings.ToLower(strings.TrimSpace(value))
|
|
295
|
+
switch status {
|
|
296
|
+
case "joined", "invited", "converted", "archived":
|
|
297
|
+
return status, nil
|
|
298
|
+
default:
|
|
299
|
+
return "", &AppError{Status: 400, Code: "invalid_status", Err: errors.New("status must be one of joined, invited, converted, archived")}
|
|
105
300
|
}
|
|
301
|
+
}
|
|
106
302
|
|
|
107
|
-
|
|
108
|
-
|
|
303
|
+
func clampLimit(value int) int {
|
|
304
|
+
if value <= 0 {
|
|
305
|
+
return 100
|
|
306
|
+
}
|
|
307
|
+
if value > 500 {
|
|
308
|
+
return 500
|
|
309
|
+
}
|
|
310
|
+
return value
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
func notFoundIfNoRows(err error, code string, message string) error {
|
|
314
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
315
|
+
return &AppError{Status: 404, Code: code, Err: errors.New(message)}
|
|
316
|
+
}
|
|
317
|
+
return err
|
|
109
318
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto"
|
|
6
|
+
"crypto/ecdsa"
|
|
7
|
+
"crypto/ed25519"
|
|
8
|
+
"crypto/elliptic"
|
|
9
|
+
"crypto/rsa"
|
|
10
|
+
"crypto/sha256"
|
|
11
|
+
"encoding/base64"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"errors"
|
|
14
|
+
"math/big"
|
|
15
|
+
"net/http"
|
|
16
|
+
"strings"
|
|
17
|
+
"sync"
|
|
18
|
+
"time"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type Config struct {
|
|
22
|
+
Enabled bool
|
|
23
|
+
Issuer string
|
|
24
|
+
Audience string
|
|
25
|
+
JWKSURL string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type jwksCache struct {
|
|
29
|
+
mu sync.Mutex
|
|
30
|
+
expiresAt time.Time
|
|
31
|
+
keys []jwk
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type jwtHeader struct {
|
|
35
|
+
Alg string `json:"alg"`
|
|
36
|
+
Kid string `json:"kid"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type jwtClaims struct {
|
|
40
|
+
Issuer string `json:"iss"`
|
|
41
|
+
Audience json.RawMessage `json:"aud"`
|
|
42
|
+
ExpiresAt int64 `json:"exp"`
|
|
43
|
+
NotBefore int64 `json:"nbf"`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type jwksDocument struct {
|
|
47
|
+
Keys []jwk `json:"keys"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type jwk struct {
|
|
51
|
+
Kty string `json:"kty"`
|
|
52
|
+
Kid string `json:"kid"`
|
|
53
|
+
Alg string `json:"alg"`
|
|
54
|
+
N string `json:"n"`
|
|
55
|
+
E string `json:"e"`
|
|
56
|
+
Crv string `json:"crv"`
|
|
57
|
+
X string `json:"x"`
|
|
58
|
+
Y string `json:"y"`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func Middleware(cfg Config) func(http.Handler) http.Handler {
|
|
62
|
+
cache := &jwksCache{}
|
|
63
|
+
return func(next http.Handler) http.Handler {
|
|
64
|
+
return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
65
|
+
if !cfg.Enabled || publicPath(request) {
|
|
66
|
+
next.ServeHTTP(w, request)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
token := bearerToken(request.Header.Get("Authorization"))
|
|
70
|
+
if token == "" || verifyToken(request.Context(), token, cfg, cache) != nil {
|
|
71
|
+
writeUnauthorized(w)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
next.ServeHTTP(w, request)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func publicPath(request *http.Request) bool {
|
|
80
|
+
path := request.URL.Path
|
|
81
|
+
return path == "/" || path == "/healthz" || path == "/readyz" || strings.HasPrefix(path, "/webhooks/")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func verifyToken(ctx context.Context, token string, cfg Config, cache *jwksCache) error {
|
|
85
|
+
if cfg.Issuer == "" || cfg.Audience == "" || cfg.JWKSURL == "" {
|
|
86
|
+
return errors.New("auth config is incomplete")
|
|
87
|
+
}
|
|
88
|
+
parts := strings.Split(token, ".")
|
|
89
|
+
if len(parts) != 3 {
|
|
90
|
+
return errors.New("token must have three parts")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var header jwtHeader
|
|
94
|
+
if err := decodeJSON(parts[0], &header); err != nil {
|
|
95
|
+
return err
|
|
96
|
+
}
|
|
97
|
+
var claims jwtClaims
|
|
98
|
+
if err := decodeJSON(parts[1], &claims); err != nil {
|
|
99
|
+
return err
|
|
100
|
+
}
|
|
101
|
+
key, err := cache.key(ctx, cfg.JWKSURL, header.Kid)
|
|
102
|
+
if err != nil {
|
|
103
|
+
return err
|
|
104
|
+
}
|
|
105
|
+
if err := verifySignature(header.Alg, key, []byte(parts[0]+"."+parts[1]), parts[2]); err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
return validateClaims(claims, cfg)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func (cache *jwksCache) key(ctx context.Context, jwksURL string, kid string) (jwk, error) {
|
|
112
|
+
cache.mu.Lock()
|
|
113
|
+
defer cache.mu.Unlock()
|
|
114
|
+
|
|
115
|
+
if time.Now().After(cache.expiresAt) {
|
|
116
|
+
request, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURL, nil)
|
|
117
|
+
if err != nil {
|
|
118
|
+
return jwk{}, err
|
|
119
|
+
}
|
|
120
|
+
response, err := http.DefaultClient.Do(request)
|
|
121
|
+
if err != nil {
|
|
122
|
+
return jwk{}, err
|
|
123
|
+
}
|
|
124
|
+
defer response.Body.Close()
|
|
125
|
+
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
126
|
+
return jwk{}, errors.New("jwks fetch failed")
|
|
127
|
+
}
|
|
128
|
+
var document jwksDocument
|
|
129
|
+
if err := json.NewDecoder(response.Body).Decode(&document); err != nil {
|
|
130
|
+
return jwk{}, err
|
|
131
|
+
}
|
|
132
|
+
cache.keys = document.Keys
|
|
133
|
+
cache.expiresAt = time.Now().Add(5 * time.Minute)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if kid == "" && len(cache.keys) == 1 {
|
|
137
|
+
return cache.keys[0], nil
|
|
138
|
+
}
|
|
139
|
+
for _, key := range cache.keys {
|
|
140
|
+
if key.Kid == kid {
|
|
141
|
+
return key, nil
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return jwk{}, errors.New("jwk not found")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func verifySignature(alg string, key jwk, signingInput []byte, encodedSignature string) error {
|
|
148
|
+
signature, err := base64.RawURLEncoding.DecodeString(encodedSignature)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return err
|
|
151
|
+
}
|
|
152
|
+
digest := sha256.Sum256(signingInput)
|
|
153
|
+
switch alg {
|
|
154
|
+
case "RS256":
|
|
155
|
+
publicKey, err := rsaPublicKey(key)
|
|
156
|
+
if err != nil {
|
|
157
|
+
return err
|
|
158
|
+
}
|
|
159
|
+
return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature)
|
|
160
|
+
case "ES256":
|
|
161
|
+
publicKey, err := ecdsaPublicKey(key)
|
|
162
|
+
if err != nil {
|
|
163
|
+
return err
|
|
164
|
+
}
|
|
165
|
+
if len(signature) != 64 {
|
|
166
|
+
return errors.New("invalid ES256 signature")
|
|
167
|
+
}
|
|
168
|
+
r := new(big.Int).SetBytes(signature[:32])
|
|
169
|
+
s := new(big.Int).SetBytes(signature[32:])
|
|
170
|
+
if !ecdsa.Verify(publicKey, digest[:], r, s) {
|
|
171
|
+
return errors.New("invalid ES256 signature")
|
|
172
|
+
}
|
|
173
|
+
return nil
|
|
174
|
+
case "EdDSA":
|
|
175
|
+
publicKey, err := ed25519PublicKey(key)
|
|
176
|
+
if err != nil {
|
|
177
|
+
return err
|
|
178
|
+
}
|
|
179
|
+
if !ed25519.Verify(publicKey, signingInput, signature) {
|
|
180
|
+
return errors.New("invalid EdDSA signature")
|
|
181
|
+
}
|
|
182
|
+
return nil
|
|
183
|
+
default:
|
|
184
|
+
return errors.New("unsupported jwt alg")
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func rsaPublicKey(key jwk) (*rsa.PublicKey, error) {
|
|
189
|
+
n, err := base64.RawURLEncoding.DecodeString(key.N)
|
|
190
|
+
if err != nil {
|
|
191
|
+
return nil, err
|
|
192
|
+
}
|
|
193
|
+
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
|
|
194
|
+
if err != nil {
|
|
195
|
+
return nil, err
|
|
196
|
+
}
|
|
197
|
+
e := 0
|
|
198
|
+
for _, b := range eBytes {
|
|
199
|
+
e = e<<8 + int(b)
|
|
200
|
+
}
|
|
201
|
+
return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: e}, nil
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func ecdsaPublicKey(key jwk) (*ecdsa.PublicKey, error) {
|
|
205
|
+
if key.Crv != "P-256" {
|
|
206
|
+
return nil, errors.New("unsupported ecdsa curve")
|
|
207
|
+
}
|
|
208
|
+
x, err := base64.RawURLEncoding.DecodeString(key.X)
|
|
209
|
+
if err != nil {
|
|
210
|
+
return nil, err
|
|
211
|
+
}
|
|
212
|
+
y, err := base64.RawURLEncoding.DecodeString(key.Y)
|
|
213
|
+
if err != nil {
|
|
214
|
+
return nil, err
|
|
215
|
+
}
|
|
216
|
+
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}, nil
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func ed25519PublicKey(key jwk) (ed25519.PublicKey, error) {
|
|
220
|
+
if key.Crv != "Ed25519" {
|
|
221
|
+
return nil, errors.New("unsupported eddsa curve")
|
|
222
|
+
}
|
|
223
|
+
x, err := base64.RawURLEncoding.DecodeString(key.X)
|
|
224
|
+
if err != nil {
|
|
225
|
+
return nil, err
|
|
226
|
+
}
|
|
227
|
+
if len(x) != ed25519.PublicKeySize {
|
|
228
|
+
return nil, errors.New("invalid Ed25519 public key")
|
|
229
|
+
}
|
|
230
|
+
return ed25519.PublicKey(x), nil
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func validateClaims(claims jwtClaims, cfg Config) error {
|
|
234
|
+
now := time.Now().Unix()
|
|
235
|
+
if claims.Issuer != cfg.Issuer {
|
|
236
|
+
return errors.New("issuer mismatch")
|
|
237
|
+
}
|
|
238
|
+
if !audienceMatches(claims.Audience, cfg.Audience) {
|
|
239
|
+
return errors.New("audience mismatch")
|
|
240
|
+
}
|
|
241
|
+
if claims.ExpiresAt == 0 || claims.ExpiresAt <= now-30 {
|
|
242
|
+
return errors.New("token expired")
|
|
243
|
+
}
|
|
244
|
+
if claims.NotBefore != 0 && claims.NotBefore > now+30 {
|
|
245
|
+
return errors.New("token not active")
|
|
246
|
+
}
|
|
247
|
+
return nil
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func audienceMatches(raw json.RawMessage, expected string) bool {
|
|
251
|
+
var single string
|
|
252
|
+
if err := json.Unmarshal(raw, &single); err == nil {
|
|
253
|
+
return single == expected
|
|
254
|
+
}
|
|
255
|
+
var many []string
|
|
256
|
+
if err := json.Unmarshal(raw, &many); err == nil {
|
|
257
|
+
for _, audience := range many {
|
|
258
|
+
if audience == expected {
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func decodeJSON(encoded string, out any) error {
|
|
267
|
+
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
|
268
|
+
if err != nil {
|
|
269
|
+
return err
|
|
270
|
+
}
|
|
271
|
+
return json.Unmarshal(payload, out)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func bearerToken(value string) string {
|
|
275
|
+
fields := strings.Fields(value)
|
|
276
|
+
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
|
|
277
|
+
return ""
|
|
278
|
+
}
|
|
279
|
+
return fields[1]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
func writeUnauthorized(w http.ResponseWriter) {
|
|
283
|
+
w.Header().Set("Content-Type", "application/json")
|
|
284
|
+
w.WriteHeader(http.StatusUnauthorized)
|
|
285
|
+
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
286
|
+
"error": "invalid bearer token",
|
|
287
|
+
"code": "unauthorized",
|
|
288
|
+
})
|
|
289
|
+
}
|