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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
type AuthConfig = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
issuer: string;
|
|
4
|
+
audience: string;
|
|
5
|
+
jwksUrl: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type JwtHeader = {
|
|
9
|
+
alg?: string;
|
|
10
|
+
kid?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type JwtClaims = {
|
|
14
|
+
iss?: string;
|
|
15
|
+
aud?: string | string[];
|
|
16
|
+
exp?: number;
|
|
17
|
+
nbf?: number;
|
|
18
|
+
sub?: string;
|
|
19
|
+
client_id?: string;
|
|
20
|
+
scope?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Jwk = JsonWebKey & {
|
|
24
|
+
kid?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Jwks = {
|
|
28
|
+
keys: Jwk[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
const jwksCache = new Map<string, { expiresAt: number; jwks: Jwks }>();
|
|
33
|
+
|
|
34
|
+
export function authMiddleware(getConfig = authConfigFromEnv) {
|
|
35
|
+
return async (context: any, next: () => Promise<void>) => {
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
if (!config.enabled) {
|
|
38
|
+
await next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const authorization = context.req.header("authorization") ?? "";
|
|
43
|
+
const token = bearerToken(authorization);
|
|
44
|
+
if (!token) {
|
|
45
|
+
return context.json({ error: "missing bearer token", code: "unauthorized" }, 401);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await verifyAccessToken(token, config);
|
|
50
|
+
await next();
|
|
51
|
+
} catch {
|
|
52
|
+
return context.json({ error: "invalid bearer token", code: "unauthorized" }, 401);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function authConfigFromEnv(): AuthConfig {
|
|
58
|
+
return {
|
|
59
|
+
enabled: truthy(Bun.env.AUTH_ENABLED),
|
|
60
|
+
issuer: Bun.env.AUTH_ISSUER ?? "",
|
|
61
|
+
audience: Bun.env.AUTH_AUDIENCE ?? "",
|
|
62
|
+
jwksUrl: Bun.env.AUTH_JWKS_URL ?? "",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function verifyAccessToken(token: string, config: AuthConfig): Promise<JwtClaims> {
|
|
67
|
+
const parts = token.split(".");
|
|
68
|
+
if (parts.length !== 3 || !config.issuer || !config.audience || !config.jwksUrl) {
|
|
69
|
+
throw new Error("invalid auth config or token");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
73
|
+
const header = decodeJSON<JwtHeader>(encodedHeader);
|
|
74
|
+
const claims = decodeJSON<JwtClaims>(encodedPayload);
|
|
75
|
+
const jwks = await fetchJwks(config.jwksUrl);
|
|
76
|
+
const key = selectKey(jwks, header);
|
|
77
|
+
if (!key || !header.alg) {
|
|
78
|
+
throw new Error("matching jwk not found");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const algorithm = importAlgorithm(header.alg, key);
|
|
82
|
+
const cryptoKey = await crypto.subtle.importKey("jwk", key, algorithm.import, false, ["verify"]);
|
|
83
|
+
const verified = await crypto.subtle.verify(
|
|
84
|
+
algorithm.verify,
|
|
85
|
+
cryptoKey,
|
|
86
|
+
toArrayBuffer(decodeBase64Url(encodedSignature)),
|
|
87
|
+
encoder.encode(`${encodedHeader}.${encodedPayload}`)
|
|
88
|
+
);
|
|
89
|
+
if (!verified) {
|
|
90
|
+
throw new Error("bad signature");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
validateClaims(claims, config);
|
|
94
|
+
return claims;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function fetchJwks(jwksUrl: string): Promise<Jwks> {
|
|
98
|
+
const cached = jwksCache.get(jwksUrl);
|
|
99
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
100
|
+
return cached.jwks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const response = await fetch(jwksUrl);
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error(`jwks fetch failed: ${response.status}`);
|
|
106
|
+
}
|
|
107
|
+
const jwks = (await response.json()) as Jwks;
|
|
108
|
+
jwksCache.set(jwksUrl, { jwks, expiresAt: Date.now() + 5 * 60 * 1000 });
|
|
109
|
+
return jwks;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function selectKey(jwks: Jwks, header: JwtHeader): Jwk | undefined {
|
|
113
|
+
if (header.kid) {
|
|
114
|
+
return jwks.keys.find((key) => key.kid === header.kid);
|
|
115
|
+
}
|
|
116
|
+
return jwks.keys.length === 1 ? jwks.keys[0] : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function importAlgorithm(alg: string, key: JsonWebKey) {
|
|
120
|
+
if (alg === "RS256") {
|
|
121
|
+
return {
|
|
122
|
+
import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
123
|
+
verify: { name: "RSASSA-PKCS1-v1_5" },
|
|
124
|
+
} as const;
|
|
125
|
+
}
|
|
126
|
+
if (alg === "ES256" && key.crv === "P-256") {
|
|
127
|
+
return {
|
|
128
|
+
import: { name: "ECDSA", namedCurve: "P-256" },
|
|
129
|
+
verify: { name: "ECDSA", hash: "SHA-256" },
|
|
130
|
+
} as const;
|
|
131
|
+
}
|
|
132
|
+
if (alg === "EdDSA" && key.crv === "Ed25519") {
|
|
133
|
+
return {
|
|
134
|
+
import: { name: "Ed25519" },
|
|
135
|
+
verify: { name: "Ed25519" },
|
|
136
|
+
} as const;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`unsupported jwt alg: ${alg}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateClaims(claims: JwtClaims, config: AuthConfig) {
|
|
142
|
+
const now = Math.floor(Date.now() / 1000);
|
|
143
|
+
if (claims.iss !== config.issuer) {
|
|
144
|
+
throw new Error("issuer mismatch");
|
|
145
|
+
}
|
|
146
|
+
if (!audienceMatches(claims.aud, config.audience)) {
|
|
147
|
+
throw new Error("audience mismatch");
|
|
148
|
+
}
|
|
149
|
+
if (typeof claims.exp !== "number" || claims.exp <= now - 30) {
|
|
150
|
+
throw new Error("token expired");
|
|
151
|
+
}
|
|
152
|
+
if (typeof claims.nbf === "number" && claims.nbf > now + 30) {
|
|
153
|
+
throw new Error("token not active");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function audienceMatches(audience: JwtClaims["aud"], expected: string) {
|
|
158
|
+
return Array.isArray(audience) ? audience.includes(expected) : audience === expected;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function decodeJSON<T>(value: string): T {
|
|
162
|
+
return JSON.parse(new TextDecoder().decode(decodeBase64Url(value))) as T;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function decodeBase64Url(value: string): Uint8Array {
|
|
166
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
167
|
+
return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
171
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function bearerToken(value: string) {
|
|
175
|
+
const [scheme, token] = value.trim().split(/\s+/, 2);
|
|
176
|
+
return /^Bearer$/i.test(scheme) ? token : "";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function truthy(value: string | undefined) {
|
|
180
|
+
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
|
181
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import { drizzle } from "drizzle-orm/bun-sql";
|
|
3
|
+
|
|
4
|
+
export function requireDatabaseUrl() {
|
|
5
|
+
const databaseUrl = Bun.env.DATABASE_URL?.trim();
|
|
6
|
+
if (!databaseUrl) {
|
|
7
|
+
throw new Error("DATABASE_URL is required");
|
|
8
|
+
}
|
|
9
|
+
return databaseUrl;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createDb(databaseUrl = requireDatabaseUrl()) {
|
|
13
|
+
const client = new SQL(databaseUrl);
|
|
14
|
+
return drizzle({ client });
|
|
15
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { desc, eq } from "drizzle-orm";
|
|
2
|
+
import type { createDb } from "./client";
|
|
3
|
+
import { waitlistEntries, waitlistTriggers } from "./schema";
|
|
4
|
+
import type { WaitlistEntry, WaitlistTrigger } from "../waitlist/types";
|
|
5
|
+
|
|
6
|
+
type Database = ReturnType<typeof createDb>;
|
|
7
|
+
type WaitlistEntryRow = typeof waitlistEntries.$inferSelect;
|
|
8
|
+
|
|
9
|
+
type CreateEntryRecord = {
|
|
10
|
+
id: string;
|
|
11
|
+
email: string;
|
|
12
|
+
name: string | null;
|
|
13
|
+
company: string | null;
|
|
14
|
+
source: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CreateTriggerRecord = {
|
|
18
|
+
id: string;
|
|
19
|
+
type: string;
|
|
20
|
+
entryId: string | null;
|
|
21
|
+
payload: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class WaitlistRepository {
|
|
25
|
+
constructor(private readonly db: Database) {}
|
|
26
|
+
|
|
27
|
+
async createEntry(input: CreateEntryRecord): Promise<WaitlistEntry> {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const [row] = await this.db
|
|
30
|
+
.insert(waitlistEntries)
|
|
31
|
+
.values({
|
|
32
|
+
id: input.id,
|
|
33
|
+
email: input.email,
|
|
34
|
+
name: input.name,
|
|
35
|
+
company: input.company,
|
|
36
|
+
source: input.source,
|
|
37
|
+
status: "joined",
|
|
38
|
+
createdAt: now,
|
|
39
|
+
updatedAt: now,
|
|
40
|
+
})
|
|
41
|
+
.returning();
|
|
42
|
+
return toWaitlistEntry(row);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async getEntryById(entryId: string): Promise<WaitlistEntry | null> {
|
|
46
|
+
const [row] = await this.db.select().from(waitlistEntries).where(eq(waitlistEntries.id, entryId)).limit(1);
|
|
47
|
+
return row ? toWaitlistEntry(row) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getEntryByEmail(email: string): Promise<WaitlistEntry | null> {
|
|
51
|
+
const [row] = await this.db.select().from(waitlistEntries).where(eq(waitlistEntries.email, email)).limit(1);
|
|
52
|
+
return row ? toWaitlistEntry(row) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async listEntries(input: { status?: string | null; limit?: number | null } = {}): Promise<WaitlistEntry[]> {
|
|
56
|
+
const limit = clampLimit(input.limit);
|
|
57
|
+
const rows = input.status
|
|
58
|
+
? await this.db
|
|
59
|
+
.select()
|
|
60
|
+
.from(waitlistEntries)
|
|
61
|
+
.where(eq(waitlistEntries.status, input.status as WaitlistEntryRow["status"]))
|
|
62
|
+
.orderBy(desc(waitlistEntries.createdAt))
|
|
63
|
+
.limit(limit)
|
|
64
|
+
: await this.db.select().from(waitlistEntries).orderBy(desc(waitlistEntries.createdAt)).limit(limit);
|
|
65
|
+
return rows.map(toWaitlistEntry);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async updateEntryStatus(entryId: string, status: WaitlistEntryRow["status"]): Promise<WaitlistEntry | null> {
|
|
69
|
+
const [row] = await this.db
|
|
70
|
+
.update(waitlistEntries)
|
|
71
|
+
.set({
|
|
72
|
+
status,
|
|
73
|
+
updatedAt: new Date(),
|
|
74
|
+
})
|
|
75
|
+
.where(eq(waitlistEntries.id, entryId))
|
|
76
|
+
.returning();
|
|
77
|
+
return row ? toWaitlistEntry(row) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async createTrigger(input: CreateTriggerRecord): Promise<WaitlistTrigger> {
|
|
81
|
+
const [row] = await this.db
|
|
82
|
+
.insert(waitlistTriggers)
|
|
83
|
+
.values({
|
|
84
|
+
id: input.id,
|
|
85
|
+
type: input.type,
|
|
86
|
+
entryId: input.entryId,
|
|
87
|
+
status: "queued",
|
|
88
|
+
payloadJson: JSON.stringify(input.payload ?? {}),
|
|
89
|
+
createdAt: new Date(),
|
|
90
|
+
})
|
|
91
|
+
.returning();
|
|
92
|
+
return toWaitlistTrigger(row);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clampLimit(value: number | null | undefined) {
|
|
97
|
+
if (!value || !Number.isFinite(value)) {
|
|
98
|
+
return 100;
|
|
99
|
+
}
|
|
100
|
+
return Math.min(Math.max(Math.trunc(value), 1), 500);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toWaitlistEntry(row: WaitlistEntryRow): WaitlistEntry {
|
|
104
|
+
return {
|
|
105
|
+
id: row.id,
|
|
106
|
+
email: row.email,
|
|
107
|
+
name: row.name,
|
|
108
|
+
company: row.company,
|
|
109
|
+
source: row.source,
|
|
110
|
+
status: row.status,
|
|
111
|
+
createdAt: row.createdAt.toISOString(),
|
|
112
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toWaitlistTrigger(row: typeof waitlistTriggers.$inferSelect): WaitlistTrigger {
|
|
117
|
+
return {
|
|
118
|
+
id: row.id,
|
|
119
|
+
type: row.type,
|
|
120
|
+
entryId: row.entryId,
|
|
121
|
+
status: row.status,
|
|
122
|
+
payload: JSON.parse(row.payloadJson),
|
|
123
|
+
createdAt: row.createdAt.toISOString(),
|
|
124
|
+
processedAt: row.processedAt?.toISOString() ?? null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const waitlistEntries = pgTable(
|
|
4
|
+
"waitlist_entries",
|
|
5
|
+
{
|
|
6
|
+
id: text("id").primaryKey(),
|
|
7
|
+
email: text("email").notNull(),
|
|
8
|
+
name: text("name"),
|
|
9
|
+
company: text("company"),
|
|
10
|
+
source: text("source"),
|
|
11
|
+
status: text("status").$type<"joined" | "invited" | "converted" | "archived">().notNull().default("joined"),
|
|
12
|
+
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
|
13
|
+
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
|
14
|
+
},
|
|
15
|
+
(table) => [uniqueIndex("waitlist_entries_email_key").on(table.email)]
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export const waitlistTriggers = pgTable("waitlist_triggers", {
|
|
19
|
+
id: text("id").primaryKey(),
|
|
20
|
+
type: text("type").notNull(),
|
|
21
|
+
entryId: text("entry_id").references(() => waitlistEntries.id),
|
|
22
|
+
status: text("status").$type<"queued" | "processed" | "failed">().notNull().default("queued"),
|
|
23
|
+
payloadJson: text("payload_json").notNull(),
|
|
24
|
+
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
|
25
|
+
processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
|
|
26
|
+
});
|
|
@@ -1,24 +1,155 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { authMiddleware } from "./auth";
|
|
3
|
+
import { AppError, createDefaultWaitlistService, type WaitlistService } from "./waitlist/service";
|
|
4
|
+
import { startTemporalWorker } from "./temporal/worker";
|
|
2
5
|
|
|
3
|
-
export function createApp() {
|
|
6
|
+
export function createApp(service: WaitlistService) {
|
|
4
7
|
const app = new Hono();
|
|
5
8
|
|
|
6
|
-
app.get("/healthz", (context) => context.json({ status: "ok"
|
|
7
|
-
app.get("/", (context) => {
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
app.get("/healthz", (context) => context.json({ status: "ok" }));
|
|
10
|
+
app.get("/readyz", (context) => context.json({ status: "ok" }));
|
|
11
|
+
app.get("/", (context) =>
|
|
12
|
+
context.json({
|
|
10
13
|
service: "{{SERVICE_NAME}}",
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
domain: "waitlist",
|
|
15
|
+
apiOrigin: "https://api.{{SERVICE_NAME}}.anmho.com",
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
app.use("/v1/*", authMiddleware());
|
|
20
|
+
|
|
21
|
+
app.post("/v1/waitlist", async (context) => {
|
|
22
|
+
try {
|
|
23
|
+
const body = await context.req.json();
|
|
24
|
+
const result = await service.joinWaitlist({
|
|
25
|
+
email: String(body.email ?? ""),
|
|
26
|
+
name: body.name ?? null,
|
|
27
|
+
company: body.company ?? null,
|
|
28
|
+
source: body.source ?? null,
|
|
29
|
+
});
|
|
30
|
+
return context.json(result, result.created ? 201 : 200);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return writeError(context, error);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.get("/v1/waitlist", async (context) => {
|
|
37
|
+
try {
|
|
38
|
+
return context.json({ entry: await service.getWaitlistEntryByEmail(context.req.query("email") ?? "") });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return writeError(context, error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
app.get("/v1/waitlist/:entryId", async (context) => {
|
|
45
|
+
try {
|
|
46
|
+
return context.json({ entry: await service.getWaitlistEntry(context.req.param("entryId")) });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return writeError(context, error);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
app.get("/v1/admin/waitlist", async (context) => {
|
|
53
|
+
try {
|
|
54
|
+
return context.json({
|
|
55
|
+
entries: await service.listWaitlistEntries({
|
|
56
|
+
status: context.req.query("status"),
|
|
57
|
+
limit: parseOptionalNumber(context.req.query("limit")),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return writeError(context, error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.patch("/v1/admin/waitlist/:entryId", async (context) => {
|
|
66
|
+
try {
|
|
67
|
+
const body = await context.req.json();
|
|
68
|
+
return context.json({
|
|
69
|
+
entry: await service.updateWaitlistEntry({
|
|
70
|
+
entryId: context.req.param("entryId"),
|
|
71
|
+
status: String(body.status ?? ""),
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return writeError(context, error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.get("/v1/admin/waitlist/export", async (context) => {
|
|
80
|
+
try {
|
|
81
|
+
const csv = await service.exportWaitlistEntries({
|
|
82
|
+
status: context.req.query("status"),
|
|
83
|
+
limit: parseOptionalNumber(context.req.query("limit")),
|
|
84
|
+
});
|
|
85
|
+
return new Response(csv, {
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
88
|
+
"Content-Disposition": 'attachment; filename="waitlist.csv"',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return writeError(context, error);
|
|
93
|
+
}
|
|
13
94
|
});
|
|
14
95
|
|
|
96
|
+
app.post("/v1/triggers/waitlist", async (context) => {
|
|
97
|
+
try {
|
|
98
|
+
const body = await context.req.json().catch(() => ({}));
|
|
99
|
+
const trigger = await service.recordTrigger({
|
|
100
|
+
type: String(body.type ?? "manual"),
|
|
101
|
+
entryId: body.entry_id ?? body.entryId ?? null,
|
|
102
|
+
payload: body,
|
|
103
|
+
});
|
|
104
|
+
return context.json({ trigger }, 202);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return writeError(context, error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.post("/webhooks/:provider", async (context) => {
|
|
111
|
+
try {
|
|
112
|
+
const rawBody = await context.req.text();
|
|
113
|
+
const trigger = await service.recordTrigger({
|
|
114
|
+
type: `webhook.${context.req.param("provider")}`,
|
|
115
|
+
entryId: null,
|
|
116
|
+
payload: {
|
|
117
|
+
headers: Object.fromEntries(context.req.raw.headers),
|
|
118
|
+
rawBody,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return context.json({ trigger }, 202);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return writeError(context, error);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
app.get("/webhooks/:provider/health", (context) => context.json({ status: "ok", provider: context.req.param("provider") }));
|
|
128
|
+
|
|
15
129
|
return app;
|
|
16
130
|
}
|
|
17
131
|
|
|
132
|
+
function writeError(context: any, error: unknown) {
|
|
133
|
+
if (error instanceof AppError) {
|
|
134
|
+
return context.json({ error: error.message, code: error.code }, error.status);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.error(error);
|
|
138
|
+
return context.json({ error: "internal server error", code: "internal" }, 500);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseOptionalNumber(value: string | undefined) {
|
|
142
|
+
return value ? Number(value) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
18
145
|
if (import.meta.main) {
|
|
19
|
-
const
|
|
146
|
+
const temporalWorker = await startTemporalWorker();
|
|
147
|
+
if (temporalWorker) {
|
|
148
|
+
console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
20
151
|
Bun.serve({
|
|
21
|
-
port: Number(Bun.env.PORT ??
|
|
22
|
-
fetch:
|
|
152
|
+
port: Number(Bun.env.PORT ?? 3000),
|
|
153
|
+
fetch: createApp(createDefaultWaitlistService()).fetch,
|
|
23
154
|
});
|
|
24
155
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type WaitlistFollowUpInput = {
|
|
2
|
+
triggerId?: string;
|
|
3
|
+
email?: string;
|
|
4
|
+
type: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export async function recordWaitlistFollowUp(input: WaitlistFollowUpInput) {
|
|
8
|
+
return {
|
|
9
|
+
status: "queued",
|
|
10
|
+
triggerId: input.triggerId ?? null,
|
|
11
|
+
email: input.email ?? null,
|
|
12
|
+
type: input.type,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { NativeConnection, Worker } from "@temporalio/worker";
|
|
2
|
+
import * as activities from "./activities";
|
|
3
|
+
|
|
4
|
+
export function temporalWorkerEnabled() {
|
|
5
|
+
return (Bun.env.TEMPORAL_ENABLED ?? "").trim().toLowerCase() === "true";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function startTemporalWorker() {
|
|
9
|
+
if (!temporalWorkerEnabled()) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const address = Bun.env.TEMPORAL_ADDRESS || "localhost:7233";
|
|
14
|
+
const namespace = Bun.env.TEMPORAL_NAMESPACE || "default";
|
|
15
|
+
const taskQueue = Bun.env.TEMPORAL_TASK_QUEUE || "{{SERVICE_NAME}}";
|
|
16
|
+
const apiKey = Bun.env.TEMPORAL_API_KEY?.trim();
|
|
17
|
+
const connection = await NativeConnection.connect({
|
|
18
|
+
address,
|
|
19
|
+
...(apiKey ? { apiKey } : {}),
|
|
20
|
+
});
|
|
21
|
+
const worker = await Worker.create({
|
|
22
|
+
connection,
|
|
23
|
+
namespace,
|
|
24
|
+
taskQueue,
|
|
25
|
+
workflowsPath: new URL("./workflows.ts", import.meta.url).pathname,
|
|
26
|
+
activities,
|
|
27
|
+
});
|
|
28
|
+
const running = worker.run();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
taskQueue,
|
|
32
|
+
async shutdown() {
|
|
33
|
+
worker.shutdown();
|
|
34
|
+
await running.catch(() => undefined);
|
|
35
|
+
await connection.close();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { proxyActivities } from "@temporalio/workflow";
|
|
2
|
+
import type * as activities from "./activities";
|
|
3
|
+
|
|
4
|
+
const { recordWaitlistFollowUp } = proxyActivities<typeof activities>({
|
|
5
|
+
startToCloseTimeout: "1 minute",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export async function waitlistFollowUpWorkflow(input: activities.WaitlistFollowUpInput) {
|
|
9
|
+
return await recordWaitlistFollowUp(input);
|
|
10
|
+
}
|