create-svc 0.1.10 → 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 (168) hide show
  1. package/README.md +46 -43
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +12 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +195 -30
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +231 -40
  14. package/src/scaffold.ts +84 -29
  15. package/src/vault.test.ts +61 -1
  16. package/src/vault.ts +77 -15
  17. package/templates/shared/.github/workflows/ci.yml +2 -1
  18. package/templates/shared/.github/workflows/deploy.yml +2 -0
  19. package/templates/shared/README.md +124 -47
  20. package/templates/shared/grafana/alerts.yaml +54 -0
  21. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  22. package/templates/shared/scripts/authctl.ts +231 -0
  23. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  24. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  25. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  26. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  27. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  28. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  29. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  30. package/templates/shared/scripts/dev.ts +22 -0
  31. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  32. package/templates/shared/scripts/local-docker.ts +63 -0
  33. package/templates/shared/scripts/local-env.ts +27 -0
  34. package/templates/shared/scripts/seed.ts +73 -0
  35. package/templates/shared/scripts/wait-for-db.ts +32 -0
  36. package/templates/shared/service.config.ts +59 -0
  37. package/templates/shared/service.yaml +24 -44
  38. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  39. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  40. package/templates/targets/workers/Makefile +33 -0
  41. package/templates/targets/workers/README.md +75 -0
  42. package/templates/targets/workers/package.json +35 -0
  43. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  44. package/templates/targets/workers/src/auth.ts +178 -0
  45. package/templates/targets/workers/src/index.ts +198 -0
  46. package/templates/targets/workers/src/storage.ts +370 -0
  47. package/templates/targets/workers/test/app.test.ts +108 -0
  48. package/templates/targets/workers/tsconfig.json +11 -0
  49. package/templates/targets/workers/wrangler.toml +24 -0
  50. package/templates/variants/bun-connectrpc/Makefile +14 -8
  51. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  52. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  53. package/templates/variants/bun-connectrpc/package.json +12 -5
  54. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  55. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  56. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  57. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  58. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  59. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  60. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  61. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  62. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  63. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  64. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  65. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  66. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  67. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  68. package/templates/variants/bun-hono/Makefile +14 -8
  69. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  70. package/templates/variants/bun-hono/package.json +12 -5
  71. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  72. package/templates/variants/bun-hono/src/auth.ts +181 -0
  73. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  74. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  75. package/templates/variants/bun-hono/src/index.ts +65 -180
  76. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  77. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  78. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  79. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  80. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  81. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  82. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  83. package/templates/variants/go-chi/Makefile +27 -11
  84. package/templates/variants/go-chi/atlas.hcl +8 -0
  85. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  86. package/templates/variants/go-chi/go.mod +1 -3
  87. package/templates/variants/go-chi/internal/app/service.go +202 -685
  88. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  89. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  90. package/templates/variants/go-chi/internal/config/config.go +27 -11
  91. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  92. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  93. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  94. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  95. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  96. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  97. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  98. package/templates/variants/go-chi/package.json +7 -1
  99. package/templates/variants/go-connectrpc/Makefile +26 -9
  100. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  101. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  102. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  103. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  104. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  105. package/templates/variants/go-connectrpc/go.mod +1 -1
  106. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  107. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  108. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  109. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  110. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  111. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  112. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  113. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  114. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  115. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  116. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  117. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  118. package/templates/variants/go-connectrpc/package.json +7 -1
  119. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  120. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  121. package/templates/root/.github/workflows/ci.yml +0 -26
  122. package/templates/root/.github/workflows/deploy.yml +0 -22
  123. package/templates/root/Dockerfile +0 -23
  124. package/templates/root/README.md +0 -69
  125. package/templates/root/buf.gen.yaml +0 -10
  126. package/templates/root/buf.yaml +0 -9
  127. package/templates/root/cmd/server/main.go +0 -44
  128. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  129. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  130. package/templates/root/go.mod +0 -10
  131. package/templates/root/internal/app/service.go +0 -152
  132. package/templates/root/internal/app/token_source.go +0 -50
  133. package/templates/root/internal/cloudflare/client.go +0 -160
  134. package/templates/root/internal/config/config.go +0 -55
  135. package/templates/root/internal/connectapi/handler.go +0 -79
  136. package/templates/root/internal/httpapi/routes.go +0 -93
  137. package/templates/root/internal/vault/client.go +0 -148
  138. package/templates/root/package.json +0 -12
  139. package/templates/root/protos/dns/v1/dns.proto +0 -58
  140. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  141. package/templates/root/scripts/cloudrun/config.ts +0 -50
  142. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  143. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  144. package/templates/root/service.yaml +0 -50
  145. package/templates/root/test/go.test.ts +0 -19
  146. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  147. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  148. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  149. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  150. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  151. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  152. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  153. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  154. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  155. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  156. package/templates/variants/bun-hono/src/storage.ts +0 -72
  157. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  158. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  159. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  160. package/templates/variants/go-chi/buf.yaml +0 -9
  161. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  162. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  163. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  164. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  165. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  166. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  167. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  168. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { createDb } from "../src/db/client";
4
+ import { WaitlistRepository } from "../src/db/repository";
5
+ import { DefaultWaitlistService } from "../src/waitlist/service";
6
+ import { createRpcService } from "../src/index";
7
+
8
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
9
+ const integrationTest = databaseUrl ? test : test.skip;
10
+
11
+ let sql: SQL | null = null;
12
+
13
+ beforeEach(async () => {
14
+ if (!databaseUrl) {
15
+ return;
16
+ }
17
+ sql = new SQL(databaseUrl);
18
+ await sql.unsafe(`
19
+ truncate table
20
+ waitlist_triggers,
21
+ waitlist_entries
22
+ restart identity cascade
23
+ `);
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await sql?.end();
28
+ sql = null;
29
+ });
30
+
31
+ integrationTest("waitlist rpc join is idempotent and records triggers", async () => {
32
+ const rpc = createRpcService(new DefaultWaitlistService(new WaitlistRepository(createDb(databaseUrl))));
33
+
34
+ const first = await rpc.joinWaitlist!({
35
+ email: "Founder@Example.com",
36
+ name: "Founder",
37
+ company: "Example Co",
38
+ source: "homepage",
39
+ } as any, undefined as never);
40
+ expect(first.created).toBe(true);
41
+ expect(first.entry!.email).toBe("founder@example.com");
42
+
43
+ const second = await rpc.joinWaitlist!({ email: "founder@example.com" } as any, undefined as never);
44
+ expect(second.created).toBe(false);
45
+ expect(second.entry!.id).toBe(first.entry!.id);
46
+
47
+ const trigger = await rpc.recordTrigger!({
48
+ type: "cron.digest",
49
+ entryId: first.entry!.id,
50
+ payloadJson: "{}",
51
+ } as any, undefined as never);
52
+ expect(trigger.trigger).toMatchObject({
53
+ type: "cron.digest",
54
+ entryId: first.entry!.id,
55
+ status: "queued",
56
+ });
57
+
58
+ const updated = await rpc.updateWaitlistEntry!({
59
+ entryId: first.entry!.id,
60
+ status: "invited",
61
+ } as any, undefined as never);
62
+ expect(updated.entry).toMatchObject({ id: first.entry!.id, status: "invited" });
63
+
64
+ const list = await rpc.listWaitlistEntries!({ status: "invited" } as any, undefined as never);
65
+ const entries = list.entries ?? [];
66
+ expect(entries).toHaveLength(1);
67
+ expect(entries[0]).toMatchObject({ id: first.entry!.id, status: "invited" });
68
+
69
+ const exported = await rpc.exportWaitlistEntries!({ status: "invited" } as any, undefined as never);
70
+ expect(exported.csv).toContain("founder@example.com");
71
+ });
@@ -1,9 +1,9 @@
1
- .PHONY: dev migrate gen lint test bootstrap deploy cleanup
1
+ .PHONY: dev migrate gen lint test create deploy dashboards auth destroy
2
2
 
3
- CLOUDRUN := npx --no-install svc-cloudrun
3
+ SERVICE := npx --no-install service
4
4
 
5
5
  dev:
6
- bun run ./src/index.ts
6
+ bun run dev
7
7
 
8
8
  migrate:
9
9
  bun run ./scripts/migrate.ts
@@ -17,11 +17,17 @@ lint:
17
17
  test:
18
18
  bun test
19
19
 
20
- bootstrap:
21
- $(CLOUDRUN) bootstrap
20
+ create:
21
+ $(SERVICE) create
22
22
 
23
23
  deploy:
24
- $(CLOUDRUN) deploy $(ARGS)
24
+ $(SERVICE) deploy $(ARGS)
25
25
 
26
- cleanup:
27
- $(CLOUDRUN) cleanup $(ARGS)
26
+ dashboards:
27
+ $(SERVICE) dashboards
28
+
29
+ auth:
30
+ $(SERVICE) auth $(ARGS)
31
+
32
+ destroy:
33
+ $(SERVICE) destroy $(ARGS)
@@ -1,63 +1,20 @@
1
- create table if not exists users (
1
+ create table if not exists waitlist_entries (
2
2
  id text primary key,
3
- username text not null unique,
4
- display_name text,
3
+ email text not null unique,
4
+ name text,
5
+ company text,
6
+ source text,
7
+ status text not null default 'joined',
5
8
  created_at timestamptz not null default now(),
6
9
  updated_at timestamptz not null default now()
7
10
  );
8
11
 
9
- create table if not exists conversations (
12
+ create table if not exists waitlist_triggers (
10
13
  id text primary key,
11
- title text,
12
- created_by_user_id text not null references users(id),
13
- deleted_at timestamptz,
14
- created_at timestamptz not null default now(),
15
- updated_at timestamptz not null default now()
16
- );
17
-
18
- create table if not exists conversation_participants (
19
- conversation_id text not null references conversations(id) on delete cascade,
20
- user_id text not null references users(id) on delete cascade,
21
- joined_at timestamptz not null default now(),
22
- primary key (conversation_id, user_id)
23
- );
24
-
25
- create table if not exists messages (
26
- id text primary key,
27
- conversation_id text not null references conversations(id) on delete cascade,
28
- user_id text not null references users(id),
29
- body text not null,
30
- edited_at timestamptz,
31
- deleted_at timestamptz,
32
- created_at timestamptz not null default now(),
33
- updated_at timestamptz not null default now()
34
- );
35
-
36
- create table if not exists attachments (
37
- id text primary key,
38
- conversation_id text not null references conversations(id) on delete cascade,
39
- message_id text references messages(id),
40
- uploaded_by_user_id text not null references users(id),
41
- storage_bucket text not null,
42
- storage_key text not null,
43
- content_type text not null,
44
- byte_size integer not null,
45
- filename text not null,
46
- status text not null,
47
- deleted_at timestamptz,
48
- created_at timestamptz not null default now(),
49
- updated_at timestamptz not null default now()
50
- );
51
-
52
- create table if not exists webhook_events (
53
- id text primary key,
54
- provider text not null,
55
- external_event_id text not null,
56
- event_type text not null,
57
- signature_valid text not null,
58
- status text not null,
14
+ type text not null,
15
+ entry_id text references waitlist_entries(id),
16
+ status text not null default 'queued',
59
17
  payload_json text not null,
60
- received_at timestamptz not null default now(),
61
- processed_at timestamptz,
62
- unique(provider, external_event_id)
18
+ created_at timestamptz not null default now(),
19
+ processed_at timestamptz
63
20
  );
@@ -3,21 +3,28 @@
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
7
  },
8
8
  "scripts": {
9
- "dev": "bun run ./src/index.ts",
9
+ "dev": "bun run ./scripts/dev.ts bun run ./src/index.ts",
10
+ "service": "bun run ./scripts/cloudrun/cli.ts",
10
11
  "migrate": "bun run ./scripts/migrate.ts",
11
12
  "gen": "bun run ./scripts/codegen.ts",
12
13
  "lint": "bunx tsc --noEmit",
13
14
  "test": "bun test",
14
- "bootstrap": "bun run ./scripts/cloudrun/cli.ts bootstrap",
15
+ "create": "bun run ./scripts/cloudrun/cli.ts create",
15
16
  "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
16
- "cleanup": "bun run ./scripts/cloudrun/cli.ts cleanup"
17
+ "dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards",
18
+ "auth": "bun run ./scripts/cloudrun/cli.ts auth",
19
+ "destroy": "bun run ./scripts/cloudrun/cli.ts destroy"
17
20
  },
18
21
  "dependencies": {
19
- "@google-cloud/storage": "^7.17.2",
22
+ "@anmho/authctl": "0.1.1",
20
23
  "@clack/prompts": "^1.2.0",
24
+ "@temporalio/activity": "1.17.1",
25
+ "@temporalio/client": "1.17.1",
26
+ "@temporalio/worker": "1.17.1",
27
+ "@temporalio/workflow": "1.17.1",
21
28
  "drizzle-orm": "^0.44.5",
22
29
  "@neondatabase/api-client": "^2.7.1",
23
30
  "hono": "^4.10.1"
@@ -1,4 +1,7 @@
1
1
  import { SQL } from "bun";
2
+ import { ensureLocalPostgres } from "./local-docker";
3
+
4
+ await ensureLocalPostgres();
2
5
 
3
6
  const databaseUrl = Bun.env.DATABASE_URL?.trim();
4
7
  if (!databaseUrl) {
@@ -7,7 +10,7 @@ if (!databaseUrl) {
7
10
 
8
11
  const client = new SQL(databaseUrl);
9
12
  await waitForDatabase(client);
10
- const migrationId = "0000_init_chat";
13
+ const migrationId = "0000_init_waitlist";
11
14
  const migrationSql = await Bun.file(new URL("../migrations/0000_init.sql", import.meta.url)).text();
12
15
 
13
16
  await client.unsafe(`create table if not exists schema_migrations (
@@ -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
+ }