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.
- package/README.md +46 -43
- package/bin/create-service.mjs +2 -0
- package/package.json +12 -9
- package/src/cli.test.ts +28 -10
- package/src/cli.ts +195 -30
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +1 -0
- package/src/naming.ts +23 -0
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +17 -4
- package/src/profiles.ts +2 -5
- package/src/scaffold.test.ts +231 -40
- package/src/scaffold.ts +84 -29
- package/src/vault.test.ts +61 -1
- package/src/vault.ts +77 -15
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +124 -47
- 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 +14 -5
- package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +11 -4
- package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
- package/templates/shared/scripts/cloudrun/lib.ts +174 -41
- package/templates/shared/scripts/cloudrun/neon.ts +45 -0
- 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 -44
- 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/Makefile +14 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-connectrpc/package.json +12 -5
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
- package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
- package/templates/variants/bun-connectrpc/src/index.ts +76 -176
- 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 +4 -4
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-hono/Makefile +14 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-hono/package.json +12 -5
- package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/repository.ts +68 -421
- package/templates/variants/bun-hono/src/db/schema.ts +15 -64
- package/templates/variants/bun-hono/src/index.ts +65 -180
- 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 +72 -41
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/go-chi/Makefile +27 -11
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +21 -10
- package/templates/variants/go-chi/go.mod +1 -3
- package/templates/variants/go-chi/internal/app/service.go +202 -685
- 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 +27 -11
- package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
- 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 +12 -55
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-connectrpc/Makefile +26 -9
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
- package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
- 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 +1 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
- 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 +27 -11
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
- 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 +12 -55
- 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/scripts/cloudrun/integrations.ts +0 -111
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
- package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
- package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
- package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
- package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
- package/templates/variants/bun-hono/src/chat/service.ts +0 -384
- package/templates/variants/bun-hono/src/chat/types.ts +0 -142
- package/templates/variants/bun-hono/src/storage.ts +0 -72
- package/templates/variants/bun-hono/src/webhooks.ts +0 -35
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
- package/templates/variants/go-chi/buf.gen.yaml +0 -12
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
- 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
|
|
1
|
+
.PHONY: dev migrate gen lint test create deploy dashboards auth destroy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SERVICE := npx --no-install service
|
|
4
4
|
|
|
5
5
|
dev:
|
|
6
|
-
bun run
|
|
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
|
-
|
|
21
|
-
$(
|
|
20
|
+
create:
|
|
21
|
+
$(SERVICE) create
|
|
22
22
|
|
|
23
23
|
deploy:
|
|
24
|
-
$(
|
|
24
|
+
$(SERVICE) deploy $(ARGS)
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
$(
|
|
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
|
|
1
|
+
create table if not exists waitlist_entries (
|
|
2
2
|
id text primary key,
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
12
|
+
create table if not exists waitlist_triggers (
|
|
10
13
|
id text primary key,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
15
|
+
"create": "bun run ./scripts/cloudrun/cli.ts create",
|
|
15
16
|
"deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
|
|
16
|
-
"
|
|
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
|
-
"@
|
|
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 = "
|
|
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
|
+
}
|