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
@@ -0,0 +1,38 @@
1
+ package auth
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+ )
8
+
9
+ func TestMiddlewareRejectsProtectedPathWithoutBearerToken(t *testing.T) {
10
+ handler := Middleware(Config{
11
+ Enabled: true,
12
+ Issuer: "https://auth.anmho.com",
13
+ Audience: "api://{{SERVICE_ID}}",
14
+ JWKSURL: "https://auth.anmho.com/api/auth/jwks",
15
+ })(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
16
+ w.WriteHeader(http.StatusNoContent)
17
+ }))
18
+
19
+ response := httptest.NewRecorder()
20
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/waitlist", nil))
21
+
22
+ if response.Code != http.StatusUnauthorized {
23
+ t.Fatalf("expected 401, got %d", response.Code)
24
+ }
25
+ }
26
+
27
+ func TestMiddlewareLeavesHealthPublic(t *testing.T) {
28
+ handler := Middleware(Config{Enabled: true})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
29
+ w.WriteHeader(http.StatusNoContent)
30
+ }))
31
+
32
+ response := httptest.NewRecorder()
33
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
34
+
35
+ if response.Code != http.StatusNoContent {
36
+ t.Fatalf("expected health to pass through, got %d", response.Code)
37
+ }
38
+ }
@@ -1,17 +1,48 @@
1
1
  package config
2
2
 
3
- import "os"
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "strings"
7
+ )
4
8
 
5
9
  type Config struct {
6
- Port string
7
- DatabaseURL string
10
+ Port string
11
+ DatabaseURL string
12
+ TemporalEnabled bool
13
+ TemporalAddress string
14
+ TemporalNamespace string
15
+ TemporalTaskQueue string
16
+ TemporalAPIKey string
17
+ AuthEnabled bool
18
+ AuthIssuer string
19
+ AuthAudience string
20
+ AuthJWKSURL string
8
21
  }
9
22
 
10
23
  func Load() (Config, error) {
11
- return Config{
12
- Port: envOr("PORT", "8080"),
13
- DatabaseURL: envOr("DATABASE_URL", ""),
14
- }, nil
24
+ cfg := Config{
25
+ Port: envOr("PORT", "8080"),
26
+ DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
27
+ TemporalEnabled: envBool("TEMPORAL_ENABLED"),
28
+ TemporalAddress: envOr("TEMPORAL_ADDRESS", "localhost:7233"),
29
+ TemporalNamespace: envOr("TEMPORAL_NAMESPACE", "default"),
30
+ TemporalTaskQueue: envOr("TEMPORAL_TASK_QUEUE", "{{SERVICE_NAME}}"),
31
+ TemporalAPIKey: strings.TrimSpace(os.Getenv("TEMPORAL_API_KEY")),
32
+ AuthEnabled: envBool("AUTH_ENABLED"),
33
+ AuthIssuer: envOr("AUTH_ISSUER", "{{AUTH_ISSUER}}"),
34
+ AuthAudience: envOr("AUTH_AUDIENCE", "{{AUTH_AUDIENCE}}"),
35
+ AuthJWKSURL: envOr("AUTH_JWKS_URL", "{{AUTH_JWKS_URL}}"),
36
+ }
37
+ if cfg.DatabaseURL == "" {
38
+ return Config{}, errors.New("DATABASE_URL is required")
39
+ }
40
+ return cfg, nil
41
+ }
42
+
43
+ func envBool(key string) bool {
44
+ value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
45
+ return value == "1" || value == "true" || value == "yes" || value == "on"
15
46
  }
16
47
 
17
48
  func envOr(key string, fallback string) string {
@@ -3,6 +3,7 @@ package httpapi
3
3
  import (
4
4
  "encoding/json"
5
5
  "errors"
6
+ "io"
6
7
  "net/http"
7
8
  "strconv"
8
9
  "strings"
@@ -12,67 +13,154 @@ import (
12
13
  "{{MODULE_PATH}}/internal/app"
13
14
  )
14
15
 
15
- func RegisterRoutes(router chi.Router, service *app.DNSService) {
16
+ func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
16
17
  router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
17
18
  writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
18
19
  })
20
+ router.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
21
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
22
+ })
23
+ router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
24
+ writeJSON(w, http.StatusOK, map[string]string{
25
+ "service": "{{SERVICE_NAME}}",
26
+ "domain": "waitlist",
27
+ "apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
28
+ })
29
+ })
30
+
31
+ router.Post("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
32
+ var input app.JoinWaitlistInput
33
+ if err := decodeJSON(request, &input); err != nil {
34
+ writeError(w, err)
35
+ return
36
+ }
37
+ result, err := service.JoinWaitlist(request.Context(), input)
38
+ if err != nil {
39
+ writeError(w, err)
40
+ return
41
+ }
42
+ status := http.StatusOK
43
+ if result.Created {
44
+ status = http.StatusCreated
45
+ }
46
+ writeJSON(w, status, result)
47
+ })
48
+
49
+ router.Get("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
50
+ entry, err := service.GetEntryByEmail(request.Context(), request.URL.Query().Get("email"))
51
+ if err != nil {
52
+ writeError(w, err)
53
+ return
54
+ }
55
+ writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
56
+ })
57
+
58
+ router.Get("/v1/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
59
+ entry, err := service.GetEntry(request.Context(), chi.URLParam(request, "entryID"))
60
+ if err != nil {
61
+ writeError(w, err)
62
+ return
63
+ }
64
+ writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
65
+ })
66
+
67
+ router.Get("/v1/admin/waitlist", func(w http.ResponseWriter, request *http.Request) {
68
+ entries, err := service.ListEntries(request.Context(), app.ListWaitlistEntriesInput{
69
+ Status: request.URL.Query().Get("status"),
70
+ Limit: optionalInt(request.URL.Query().Get("limit")),
71
+ })
72
+ if err != nil {
73
+ writeError(w, err)
74
+ return
75
+ }
76
+ writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
77
+ })
78
+
79
+ router.Get("/v1/admin/waitlist/export", func(w http.ResponseWriter, request *http.Request) {
80
+ csv, err := service.ExportEntries(request.Context(), app.ListWaitlistEntriesInput{
81
+ Status: request.URL.Query().Get("status"),
82
+ Limit: optionalInt(request.URL.Query().Get("limit")),
83
+ })
84
+ if err != nil {
85
+ writeError(w, err)
86
+ return
87
+ }
88
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
89
+ w.Header().Set("Content-Disposition", `attachment; filename="waitlist.csv"`)
90
+ w.WriteHeader(http.StatusOK)
91
+ _, _ = w.Write([]byte(csv))
92
+ })
93
+
94
+ router.Patch("/v1/admin/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
95
+ var input app.UpdateWaitlistEntryInput
96
+ if err := decodeJSON(request, &input); err != nil {
97
+ writeError(w, err)
98
+ return
99
+ }
100
+ input.EntryID = chi.URLParam(request, "entryID")
101
+ entry, err := service.UpdateEntry(request.Context(), input)
102
+ if err != nil {
103
+ writeError(w, err)
104
+ return
105
+ }
106
+ writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
107
+ })
19
108
 
20
- router.Route("/v1/dns/records", func(r chi.Router) {
21
- r.Get("/", func(w http.ResponseWriter, request *http.Request) {
22
- records, err := service.ListRecords(request.Context())
23
- if err != nil {
24
- writeError(w, err)
25
- return
26
- }
27
- writeJSON(w, http.StatusOK, map[string]any{"records": records})
109
+ router.Post("/v1/triggers/waitlist", func(w http.ResponseWriter, request *http.Request) {
110
+ var payload map[string]any
111
+ if err := decodeOptionalJSON(request, &payload); err != nil {
112
+ writeError(w, err)
113
+ return
114
+ }
115
+ trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
116
+ Type: stringValue(payload, "type", "manual"),
117
+ EntryID: stringValue(payload, "entry_id", ""),
118
+ Payload: payload,
28
119
  })
120
+ if err != nil {
121
+ writeError(w, err)
122
+ return
123
+ }
124
+ writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
125
+ })
29
126
 
30
- r.Post("/", func(w http.ResponseWriter, request *http.Request) {
31
- var input app.CreateRecordInput
32
- if err := decodeJSON(request, &input); err != nil {
33
- writeError(w, err)
34
- return
35
- }
36
-
37
- record, err := service.CreateRecord(request.Context(), input)
38
- if err != nil {
39
- writeError(w, err)
40
- return
41
- }
42
- writeJSON(w, http.StatusCreated, map[string]any{"record": record})
127
+ router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
128
+ rawBody, err := io.ReadAll(request.Body)
129
+ if err != nil {
130
+ writeError(w, err)
131
+ return
132
+ }
133
+ trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
134
+ Type: "webhook." + chi.URLParam(request, "provider"),
135
+ Payload: map[string]any{
136
+ "headers": request.Header,
137
+ "rawBody": string(rawBody),
138
+ },
43
139
  })
140
+ if err != nil {
141
+ writeError(w, err)
142
+ return
143
+ }
144
+ writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
145
+ })
44
146
 
45
- r.Route("/{recordID}", func(r chi.Router) {
46
- r.Put("/", func(w http.ResponseWriter, request *http.Request) {
47
- var input app.UpdateRecordInput
48
- if err := decodeJSON(request, &input); err != nil {
49
- writeError(w, err)
50
- return
51
- }
52
-
53
- record, err := service.UpdateRecord(request.Context(), chi.URLParam(request, "recordID"), input)
54
- if err != nil {
55
- writeError(w, err)
56
- return
57
- }
58
- writeJSON(w, http.StatusOK, map[string]any{"record": record})
59
- })
60
-
61
- r.Delete("/", func(w http.ResponseWriter, request *http.Request) {
62
- if err := service.DeleteRecord(request.Context(), chi.URLParam(request, "recordID")); err != nil {
63
- writeError(w, err)
64
- return
65
- }
66
- w.WriteHeader(http.StatusNoContent)
67
- })
147
+ router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
148
+ writeJSON(w, http.StatusOK, map[string]string{
149
+ "status": "ok",
150
+ "provider": chi.URLParam(request, "provider"),
68
151
  })
69
152
  })
70
153
  }
71
154
 
72
155
  func decodeJSON(request *http.Request, out any) error {
73
156
  defer request.Body.Close()
157
+ return json.NewDecoder(request.Body).Decode(out)
158
+ }
74
159
 
75
- if err := json.NewDecoder(request.Body).Decode(out); err != nil {
160
+ func decodeOptionalJSON(request *http.Request, out any) error {
161
+ defer request.Body.Close()
162
+ decoder := json.NewDecoder(request.Body)
163
+ if err := decoder.Decode(out); err != nil && !errors.Is(err, io.EOF) {
76
164
  return err
77
165
  }
78
166
  return nil
@@ -85,9 +173,44 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
85
173
  }
86
174
 
87
175
  func writeError(w http.ResponseWriter, err error) {
176
+ var appErr *app.AppError
177
+ if errors.As(err, &appErr) {
178
+ writeJSON(w, appErr.Status, map[string]string{
179
+ "error": appErr.Error(),
180
+ "code": appErr.Code,
181
+ })
182
+ return
183
+ }
184
+
88
185
  status := http.StatusInternalServerError
89
- if errors.Is(err, strconv.ErrSyntax) || strings.Contains(strings.ToLower(err.Error()), "json") {
186
+ if strings.Contains(strings.ToLower(err.Error()), "json") {
90
187
  status = http.StatusBadRequest
91
188
  }
92
189
  writeJSON(w, status, map[string]string{"error": err.Error()})
93
190
  }
191
+
192
+ func stringValue(values map[string]any, key string, fallback string) string {
193
+ if values == nil {
194
+ return fallback
195
+ }
196
+ value, ok := values[key]
197
+ if !ok && key == "entry_id" {
198
+ value, ok = values["entryId"]
199
+ }
200
+ if !ok {
201
+ return fallback
202
+ }
203
+ text, ok := value.(string)
204
+ if !ok {
205
+ return fallback
206
+ }
207
+ return strings.TrimSpace(text)
208
+ }
209
+
210
+ func optionalInt(value string) int {
211
+ parsed, err := strconv.Atoi(strings.TrimSpace(value))
212
+ if err != nil {
213
+ return 0
214
+ }
215
+ return parsed
216
+ }
@@ -0,0 +1,199 @@
1
+ package httpapi
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "os"
11
+ "strings"
12
+ "testing"
13
+
14
+ "github.com/go-chi/chi/v5"
15
+ _ "github.com/jackc/pgx/v5/stdlib"
16
+
17
+ "{{MODULE_PATH}}/internal/app"
18
+ )
19
+
20
+ func TestWaitlistJoinIsIdempotentAndRecordsTriggers(t *testing.T) {
21
+ databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
22
+ if databaseURL == "" {
23
+ t.Skip("DATABASE_URL is required for integration test")
24
+ }
25
+
26
+ db, err := app.OpenDatabase(context.Background(), databaseURL)
27
+ if err != nil {
28
+ t.Fatalf("open database: %v", err)
29
+ }
30
+ t.Cleanup(func() { _ = db.Close() })
31
+
32
+ if _, err := db.ExecContext(context.Background(), `
33
+ truncate table
34
+ waitlist_triggers,
35
+ waitlist_entries
36
+ restart identity cascade`); err != nil {
37
+ t.Fatalf("truncate tables: %v", err)
38
+ }
39
+
40
+ service := app.NewWaitlistService(db)
41
+ router := chi.NewRouter()
42
+ RegisterRoutes(router, service)
43
+ server := httptest.NewServer(router)
44
+ t.Cleanup(server.Close)
45
+
46
+ first := joinWaitlist(t, server.URL, "Founder@Example.com")
47
+ if first.Created != true {
48
+ t.Fatal("expected first join to create entry")
49
+ }
50
+ if first.Entry.Email != "founder@example.com" {
51
+ t.Fatalf("expected normalized email, got %s", first.Entry.Email)
52
+ }
53
+
54
+ second := joinWaitlist(t, server.URL, "founder@example.com")
55
+ if second.Created {
56
+ t.Fatal("expected second join to be idempotent")
57
+ }
58
+ if second.Entry.ID != first.Entry.ID {
59
+ t.Fatalf("expected same entry id, got %s and %s", first.Entry.ID, second.Entry.ID)
60
+ }
61
+
62
+ trigger := recordTrigger(t, server.URL, first.Entry.ID)
63
+ if trigger.Trigger.Type != "cron.digest" {
64
+ t.Fatalf("expected cron.digest trigger, got %s", trigger.Trigger.Type)
65
+ }
66
+ if trigger.Trigger.EntryID != first.Entry.ID {
67
+ t.Fatalf("expected trigger entry id %s, got %s", first.Entry.ID, trigger.Trigger.EntryID)
68
+ }
69
+
70
+ updated := updateEntry(t, server.URL, first.Entry.ID, "invited")
71
+ if updated.Entry.Status != "invited" {
72
+ t.Fatalf("expected invited status, got %s", updated.Entry.Status)
73
+ }
74
+
75
+ list := listEntries(t, server.URL, "invited")
76
+ if len(list.Entries) != 1 || list.Entries[0].ID != first.Entry.ID {
77
+ t.Fatalf("expected one invited entry %s, got %+v", first.Entry.ID, list.Entries)
78
+ }
79
+
80
+ exported := exportEntries(t, server.URL, "invited")
81
+ if !strings.Contains(exported, "founder@example.com") {
82
+ t.Fatalf("expected exported csv to contain email, got %s", exported)
83
+ }
84
+ }
85
+
86
+ type joinResponse struct {
87
+ Entry app.WaitlistEntry `json:"entry"`
88
+ Created bool `json:"created"`
89
+ }
90
+
91
+ type triggerResponse struct {
92
+ Trigger app.WaitlistTrigger `json:"trigger"`
93
+ }
94
+
95
+ type entryResponse struct {
96
+ Entry app.WaitlistEntry `json:"entry"`
97
+ }
98
+
99
+ type listResponse struct {
100
+ Entries []app.WaitlistEntry `json:"entries"`
101
+ }
102
+
103
+ func joinWaitlist(t *testing.T, baseURL string, email string) joinResponse {
104
+ t.Helper()
105
+ body := bytes.NewBufferString(`{"email":` + quoteJSON(email) + `,"name":"Founder","company":"Example Co","source":"homepage"}`)
106
+ response, err := http.Post(baseURL+"/v1/waitlist", "application/json", body)
107
+ if err != nil {
108
+ t.Fatalf("join waitlist: %v", err)
109
+ }
110
+ defer response.Body.Close()
111
+ if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusOK {
112
+ t.Fatalf("expected success, got %d", response.StatusCode)
113
+ }
114
+ var payload joinResponse
115
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
116
+ t.Fatalf("decode join response: %v", err)
117
+ }
118
+ return payload
119
+ }
120
+
121
+ func recordTrigger(t *testing.T, baseURL string, entryID string) triggerResponse {
122
+ t.Helper()
123
+ body := bytes.NewBufferString(`{"type":"cron.digest","entry_id":` + quoteJSON(entryID) + `}`)
124
+ response, err := http.Post(baseURL+"/v1/triggers/waitlist", "application/json", body)
125
+ if err != nil {
126
+ t.Fatalf("record trigger: %v", err)
127
+ }
128
+ defer response.Body.Close()
129
+ if response.StatusCode != http.StatusAccepted {
130
+ t.Fatalf("expected 202, got %d", response.StatusCode)
131
+ }
132
+ var payload triggerResponse
133
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
134
+ t.Fatalf("decode trigger response: %v", err)
135
+ }
136
+ return payload
137
+ }
138
+
139
+ func updateEntry(t *testing.T, baseURL string, entryID string, status string) entryResponse {
140
+ t.Helper()
141
+ body := bytes.NewBufferString(`{"status":` + quoteJSON(status) + `}`)
142
+ request, err := http.NewRequest(http.MethodPatch, baseURL+"/v1/admin/waitlist/"+entryID, body)
143
+ if err != nil {
144
+ t.Fatalf("build update entry request: %v", err)
145
+ }
146
+ request.Header.Set("Content-Type", "application/json")
147
+ response, err := http.DefaultClient.Do(request)
148
+ if err != nil {
149
+ t.Fatalf("update entry: %v", err)
150
+ }
151
+ defer response.Body.Close()
152
+ if response.StatusCode != http.StatusOK {
153
+ t.Fatalf("expected 200, got %d", response.StatusCode)
154
+ }
155
+ var payload entryResponse
156
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
157
+ t.Fatalf("decode update response: %v", err)
158
+ }
159
+ return payload
160
+ }
161
+
162
+ func listEntries(t *testing.T, baseURL string, status string) listResponse {
163
+ t.Helper()
164
+ response, err := http.Get(baseURL + "/v1/admin/waitlist?status=" + status)
165
+ if err != nil {
166
+ t.Fatalf("list entries: %v", err)
167
+ }
168
+ defer response.Body.Close()
169
+ if response.StatusCode != http.StatusOK {
170
+ t.Fatalf("expected 200, got %d", response.StatusCode)
171
+ }
172
+ var payload listResponse
173
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
174
+ t.Fatalf("decode list response: %v", err)
175
+ }
176
+ return payload
177
+ }
178
+
179
+ func exportEntries(t *testing.T, baseURL string, status string) string {
180
+ t.Helper()
181
+ response, err := http.Get(baseURL + "/v1/admin/waitlist/export?status=" + status)
182
+ if err != nil {
183
+ t.Fatalf("export entries: %v", err)
184
+ }
185
+ defer response.Body.Close()
186
+ if response.StatusCode != http.StatusOK {
187
+ t.Fatalf("expected 200, got %d", response.StatusCode)
188
+ }
189
+ raw, err := io.ReadAll(response.Body)
190
+ if err != nil {
191
+ t.Fatalf("read export response: %v", err)
192
+ }
193
+ return string(raw)
194
+ }
195
+
196
+ func quoteJSON(value string) string {
197
+ encoded, _ := json.Marshal(value)
198
+ return string(encoded)
199
+ }
@@ -0,0 +1,27 @@
1
+ package temporalapp
2
+
3
+ import "context"
4
+
5
+ type WaitlistFollowUpInput struct {
6
+ TriggerID string
7
+ Email string
8
+ Type string
9
+ }
10
+
11
+ type WaitlistFollowUpResult struct {
12
+ Status string
13
+ TriggerID string
14
+ Email string
15
+ Type string
16
+ }
17
+
18
+ type Activities struct{}
19
+
20
+ func (a *Activities) RecordWaitlistFollowUp(ctx context.Context, input WaitlistFollowUpInput) (WaitlistFollowUpResult, error) {
21
+ return WaitlistFollowUpResult{
22
+ Status: "queued",
23
+ TriggerID: input.TriggerID,
24
+ Email: input.Email,
25
+ Type: input.Type,
26
+ }, nil
27
+ }
@@ -0,0 +1,42 @@
1
+ package temporalapp
2
+
3
+ import (
4
+ "go.temporal.io/sdk/client"
5
+ "go.temporal.io/sdk/worker"
6
+ )
7
+
8
+ type WorkerConfig struct {
9
+ Address string
10
+ Namespace string
11
+ TaskQueue string
12
+ APIKey string
13
+ }
14
+
15
+ func StartWorker(cfg WorkerConfig) (func(), error) {
16
+ options := client.Options{
17
+ HostPort: cfg.Address,
18
+ Namespace: cfg.Namespace,
19
+ }
20
+ if cfg.APIKey != "" {
21
+ options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
22
+ }
23
+
24
+ temporalClient, err := client.Dial(options)
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+
29
+ temporalWorker := worker.New(temporalClient, cfg.TaskQueue, worker.Options{})
30
+ temporalWorker.RegisterWorkflow(WaitlistFollowUpWorkflow)
31
+ temporalWorker.RegisterActivity(&Activities{})
32
+
33
+ if err := temporalWorker.Start(); err != nil {
34
+ temporalClient.Close()
35
+ return nil, err
36
+ }
37
+
38
+ return func() {
39
+ temporalWorker.Stop()
40
+ temporalClient.Close()
41
+ }, nil
42
+ }
@@ -0,0 +1,18 @@
1
+ package temporalapp
2
+
3
+ import (
4
+ "time"
5
+
6
+ "go.temporal.io/sdk/workflow"
7
+ )
8
+
9
+ func WaitlistFollowUpWorkflow(ctx workflow.Context, input WaitlistFollowUpInput) (WaitlistFollowUpResult, error) {
10
+ options := workflow.ActivityOptions{
11
+ StartToCloseTimeout: time.Minute,
12
+ }
13
+ ctx = workflow.WithActivityOptions(ctx, options)
14
+
15
+ var result WaitlistFollowUpResult
16
+ err := workflow.ExecuteActivity(ctx, "RecordWaitlistFollowUp", input).Get(ctx, &result)
17
+ return result, err
18
+ }
@@ -0,0 +1,20 @@
1
+ create table if not exists waitlist_entries (
2
+ id text primary key,
3
+ email text not null unique,
4
+ name text,
5
+ company text,
6
+ source text,
7
+ status text not null default 'joined',
8
+ created_at timestamptz not null default now(),
9
+ updated_at timestamptz not null default now()
10
+ );
11
+
12
+ create table if not exists waitlist_triggers (
13
+ id text primary key,
14
+ type text not null,
15
+ entry_id text references waitlist_entries(id),
16
+ status text not null default 'queued',
17
+ payload_json text not null,
18
+ created_at timestamptz not null default now(),
19
+ processed_at timestamptz
20
+ );
@@ -0,0 +1,2 @@
1
+ h1:D9GgvtEf0xxnRDaTb/C3cPtRbncVXsK6Ef0KYQuLMRw=
2
+ 0000_init.sql h1:dBpLGyoeZNRpjT6RvQXiqx3p2ICAChKy2jVqCIYuWQg=
@@ -3,9 +3,15 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "bin": {
6
- "svc-cloudrun": "./scripts/cloudrun/cli.ts"
6
+ "service": "./scripts/cloudrun/cli.ts"
7
+ },
8
+ "scripts": {
9
+ "service": "bun run ./scripts/cloudrun/cli.ts",
10
+ "auth": "bun run ./scripts/cloudrun/cli.ts auth",
11
+ "dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards"
7
12
  },
8
13
  "dependencies": {
14
+ "@anmho/authctl": "0.1.1",
9
15
  "@clack/prompts": "^1.2.0",
10
16
  "@neondatabase/api-client": "^2.7.1"
11
17
  }
@@ -1,7 +1,10 @@
1
1
  import { expect, test } from "bun:test";
2
2
 
3
3
  test("go test ./...", { timeout: 60_000 }, () => {
4
- const result = Bun.spawnSync(["go", "test", "./..."], {
4
+ const go = Bun.which("go");
5
+ expect(go, "go must be installed and available on PATH").toBeString();
6
+
7
+ const result = Bun.spawnSync([go, "test", "./..."], {
5
8
  cwd: process.cwd(),
6
9
  stdout: "pipe",
7
10
  stderr: "pipe",