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.
Files changed (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -1
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. 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
- "sync"
11
+ "net/mail"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/jmoiron/sqlx"
7
16
  )
8
17
 
9
- type Record struct {
10
- ID string `json:"id"`
11
- Type string `json:"type"`
12
- Name string `json:"name"`
13
- Content string `json:"content"`
14
- TTL int `json:"ttl"`
15
- Proxied bool `json:"proxied"`
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 CreateRecordInput struct {
19
- Type string `json:"type"`
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
- Content string `json:"content"`
22
- TTL int `json:"ttl"`
23
- Proxied bool `json:"proxied"`
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 UpdateRecordInput struct {
51
+ type RecordTriggerInput struct {
27
52
  Type string `json:"type"`
28
- Name string `json:"name"`
29
- Content string `json:"content"`
30
- TTL int `json:"ttl"`
31
- Proxied bool `json:"proxied"`
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 DNSService struct {
35
- mu sync.RWMutex
36
- nextID int
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
- func NewDNSService() *DNSService {
41
- return &DNSService{
42
- nextID: 1,
43
- records: map[string]Record{},
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 *DNSService) ListRecords(ctx context.Context) ([]Record, error) {
48
- _ = ctx
49
- s.mu.RLock()
50
- defer s.mu.RUnlock()
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
- out := make([]Record, 0, len(s.records))
53
- for _, record := range s.records {
54
- out = append(out, record)
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
- return out, nil
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 *DNSService) CreateRecord(ctx context.Context, input CreateRecordInput) (Record, error) {
60
- _ = ctx
61
- s.mu.Lock()
62
- defer s.mu.Unlock()
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
- record := Record{
65
- ID: fmt.Sprintf("%d", s.nextID),
66
- Type: input.Type,
67
- Name: input.Name,
68
- Content: input.Content,
69
- TTL: input.TTL,
70
- Proxied: input.Proxied,
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
- s.nextID += 1
74
- s.records[record.ID] = record
75
- return record, nil
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 *DNSService) UpdateRecord(ctx context.Context, id string, input UpdateRecordInput) (Record, error) {
79
- _ = ctx
80
- s.mu.Lock()
81
- defer s.mu.Unlock()
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
- record, ok := s.records[id]
84
- if !ok {
85
- return Record{}, fmt.Errorf("record %s not found", id)
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
- record.Type = input.Type
89
- record.Name = input.Name
90
- record.Content = input.Content
91
- record.TTL = input.TTL
92
- record.Proxied = input.Proxied
93
- s.records[id] = record
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
- return record, nil
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
- func (s *DNSService) DeleteRecord(ctx context.Context, id string) error {
99
- _ = ctx
100
- s.mu.Lock()
101
- defer s.mu.Unlock()
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
- if _, ok := s.records[id]; !ok {
104
- return fmt.Errorf("record %s not found", id)
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
- delete(s.records, id)
108
- return nil
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
+ }