@lastshotlabs/bunshot 0.0.13 → 0.0.16
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 +2510 -1747
- package/dist/adapters/memoryAuth.d.ts +4 -0
- package/dist/adapters/memoryAuth.js +131 -2
- package/dist/adapters/mongoAuth.js +56 -0
- package/dist/adapters/sqliteAuth.d.ts +6 -0
- package/dist/adapters/sqliteAuth.js +137 -2
- package/dist/app.d.ts +77 -2
- package/dist/app.js +29 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +14 -5
- package/dist/index.js +9 -3
- package/dist/lib/appConfig.d.ts +46 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/authAdapter.d.ts +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +2 -2
- package/dist/lib/mfaChallenge.d.ts +20 -0
- package/dist/lib/mfaChallenge.js +184 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +163 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/cacheResponse.js +4 -1
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +5 -2
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +8 -0
- package/dist/models/AuthUser.js +8 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +153 -22
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +1 -0
- package/dist/routes/mfa.js +409 -0
- package/dist/routes/oauth.js +107 -16
- package/dist/server.js +9 -0
- package/dist/services/auth.d.ts +17 -5
- package/dist/services/auth.js +95 -17
- package/dist/services/mfa.d.ts +37 -0
- package/dist/services/mfa.js +276 -0
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +456 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +135 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +99 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +83 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +62 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +119 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +43 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +115 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +100 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +19 -10
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
## Extending the Context (Custom Variables)
|
|
2
|
+
|
|
3
|
+
When building a tenant app or any app that needs extra typed context variables (beyond the built-in), extend `AppEnv["Variables"]` and create a typed router factory.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// src/lib/context.ts
|
|
7
|
+
import { createRouter as coreCreateRouter, type AppEnv } from "@lastshotlabs/bunshot";
|
|
8
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
9
|
+
|
|
10
|
+
export type MyVariables = AppEnv["Variables"] & {
|
|
11
|
+
tenantId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type MyEnv = { Variables: MyVariables };
|
|
15
|
+
|
|
16
|
+
export const createRouter = () => coreCreateRouter() as unknown as OpenAPIHono<MyEnv>;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use the local `createRouter` instead of the one from the package — your routes will then have full TypeScript access to the extra variables:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// src/routes/items.ts
|
|
23
|
+
import { createRouter } from "../lib/context";
|
|
24
|
+
import { userAuth } from "@lastshotlabs/bunshot";
|
|
25
|
+
|
|
26
|
+
export const router = createRouter();
|
|
27
|
+
|
|
28
|
+
router.use("/items", userAuth);
|
|
29
|
+
|
|
30
|
+
router.get("/items", async (c) => {
|
|
31
|
+
const tenantId = c.get("tenantId"); // fully typed
|
|
32
|
+
const userId = c.get("userId"); // still available from AppEnv
|
|
33
|
+
return c.json({ tenantId, userId });
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Populate the extra variables from a global middleware:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// src/middleware/tenant.ts
|
|
41
|
+
import type { MiddlewareHandler } from "hono";
|
|
42
|
+
import type { MyEnv } from "../lib/context";
|
|
43
|
+
|
|
44
|
+
export const tenantMiddleware: MiddlewareHandler<MyEnv> = async (c, next) => {
|
|
45
|
+
const tenantId = c.req.header("x-tenant-id") ?? "default";
|
|
46
|
+
c.set("tenantId", tenantId);
|
|
47
|
+
await next();
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then register it in `createServer`:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
await createServer({
|
|
55
|
+
routesDir: import.meta.dir + "/routes",
|
|
56
|
+
app: { name: "My App", version: "1.0.0" },
|
|
57
|
+
middleware: [tenantMiddleware],
|
|
58
|
+
});
|
|
59
|
+
```
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
## Jobs (BullMQ)
|
|
2
|
+
|
|
3
|
+
> **Redis requirement**: BullMQ requires `maxmemory-policy noeviction`. Set it in `redis.conf` or via Docker:
|
|
4
|
+
> ```yaml
|
|
5
|
+
> command: redis-server --maxmemory-policy noeviction
|
|
6
|
+
> ```
|
|
7
|
+
|
|
8
|
+
Queues and workers share the existing Redis connection automatically.
|
|
9
|
+
|
|
10
|
+
### Define a queue
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// src/queues/email.ts
|
|
14
|
+
import { createQueue } from "@lastshotlabs/bunshot";
|
|
15
|
+
|
|
16
|
+
export type EmailJob = { to: string; subject: string; body: string };
|
|
17
|
+
|
|
18
|
+
export const emailQueue = createQueue<EmailJob>("email");
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Add jobs
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { emailQueue } from "../queues/email";
|
|
25
|
+
|
|
26
|
+
await emailQueue.add("send-welcome", { to: "user@example.com", subject: "Welcome", body: "..." });
|
|
27
|
+
|
|
28
|
+
// with options
|
|
29
|
+
await emailQueue.add("send-reset", payload, { delay: 5000, attempts: 3 });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Define a worker
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// src/workers/email.ts
|
|
36
|
+
import { createWorker } from "@lastshotlabs/bunshot";
|
|
37
|
+
import type { EmailJob } from "../queues/email";
|
|
38
|
+
|
|
39
|
+
export const emailWorker = createWorker<EmailJob>("email", async (job) => {
|
|
40
|
+
const { to, subject, body } = job.data;
|
|
41
|
+
// send email...
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Workers in `workersDir` are auto-discovered and registered after the server starts — no manual imports needed. Subdirectories are supported.
|
|
46
|
+
|
|
47
|
+
### Broadcasting WebSocket messages from a worker
|
|
48
|
+
|
|
49
|
+
Use `publish` to broadcast to all connected clients from inside a worker (or anywhere):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// src/workers/notify.ts
|
|
53
|
+
import { createWorker, publish } from "@lastshotlabs/bunshot";
|
|
54
|
+
import type { NotifyJob } from "../queues/notify";
|
|
55
|
+
|
|
56
|
+
export const notifyWorker = createWorker<NotifyJob>("notify", async (job) => {
|
|
57
|
+
const { text, from } = job.data;
|
|
58
|
+
publish("broadcast", { text, from, timestamp: new Date().toISOString() });
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`publish` is available after `createServer` resolves. Workers are loaded after that point, so it's always safe to use inside a worker.
|
|
63
|
+
|
|
64
|
+
### Cron / scheduled workers
|
|
65
|
+
|
|
66
|
+
Use `createCronWorker` for recurring jobs. It creates both a queue and worker, and uses BullMQ's `upsertJobScheduler` for idempotent scheduling across restarts.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// src/workers/cleanup.ts
|
|
70
|
+
import { createCronWorker } from "@lastshotlabs/bunshot/queue";
|
|
71
|
+
|
|
72
|
+
export const { worker, queue } = createCronWorker(
|
|
73
|
+
"cleanup",
|
|
74
|
+
async (job) => {
|
|
75
|
+
// runs every hour
|
|
76
|
+
await deleteExpiredRecords();
|
|
77
|
+
},
|
|
78
|
+
{ cron: "0 * * * *" } // or { every: 3_600_000 } for interval-based
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Ghost job cleanup**: When a cron worker is renamed or removed, the old scheduler persists in Redis. Bunshot handles this automatically — after all workers in `workersDir` are loaded, stale schedulers are pruned. For workers managed outside `workersDir`, call `cleanupStaleSchedulers(activeNames)` manually.
|
|
83
|
+
|
|
84
|
+
### Job status endpoint
|
|
85
|
+
|
|
86
|
+
Expose job state via REST for client-side polling (e.g., long-running uploads or exports):
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
90
|
+
|
|
91
|
+
await createServer({
|
|
92
|
+
jobs: {
|
|
93
|
+
statusEndpoint: true, // default: false
|
|
94
|
+
auth: "userAuth", // "userAuth" | "none" | MiddlewareHandler[]
|
|
95
|
+
roles: ["admin"], // require these roles (works with userAuth)
|
|
96
|
+
allowedQueues: ["export", "upload"], // whitelist — empty = nothing exposed (secure by default)
|
|
97
|
+
scopeToUser: false, // when true with userAuth, users only see their own jobs
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Auth options:**
|
|
103
|
+
- `"userAuth"` — requires an authenticated user session. Combine with `roles` for RBAC.
|
|
104
|
+
- `"none"` — no auth protection (not recommended for production).
|
|
105
|
+
- `MiddlewareHandler[]` — pass a custom middleware stack for full control, e.g. `[userAuth, requireRole("admin")]`.
|
|
106
|
+
|
|
107
|
+
#### Endpoints
|
|
108
|
+
|
|
109
|
+
| Endpoint | Purpose |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `GET /jobs` | List available queues |
|
|
112
|
+
| `GET /jobs/:queue` | List jobs in a queue (paginated, filterable by state) |
|
|
113
|
+
| `GET /jobs/:queue/:id` | Job state, progress, result, or failure reason |
|
|
114
|
+
| `GET /jobs/:queue/:id/logs` | Job logs |
|
|
115
|
+
| `GET /jobs/:queue/dead-letters` | Paginated list of DLQ jobs |
|
|
116
|
+
|
|
117
|
+
The list endpoint (`GET /jobs/:queue`) accepts `?state=waiting|active|completed|failed|delayed|paused` and `?start=0&end=19` for pagination.
|
|
118
|
+
|
|
119
|
+
### Dead Letter Queue (DLQ)
|
|
120
|
+
|
|
121
|
+
Automatically move permanently failed jobs to a DLQ for inspection and retry:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { createWorker, createDLQHandler } from "@lastshotlabs/bunshot/queue";
|
|
125
|
+
|
|
126
|
+
const emailWorker = createWorker("email", async (job) => { ... });
|
|
127
|
+
|
|
128
|
+
const { dlqQueue, retryJob } = createDLQHandler(emailWorker, "email", {
|
|
129
|
+
maxSize: 1000, // default: 1000 — oldest trimmed when exceeded
|
|
130
|
+
onDeadLetter: async (job, error) => { // optional alerting callback
|
|
131
|
+
await alertSlack(`Job ${job.id} failed: ${error.message}`);
|
|
132
|
+
},
|
|
133
|
+
preserveJobOptions: true, // default: true — retry with original delay/priority/attempts
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Retry a specific failed job
|
|
137
|
+
await retryJob("job-id-123");
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The DLQ queue is named `${sourceQueueName}-dlq` (e.g., `email-dlq`). It's automatically available via the job status endpoint if listed in `allowedQueues`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## Jobs (BullMQ)
|
|
2
|
+
|
|
3
|
+
Queue-based background jobs powered by BullMQ (requires Redis with `noeviction` policy).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// Define a queue
|
|
7
|
+
import { createQueue } from "@lastshotlabs/bunshot";
|
|
8
|
+
export const emailQueue = createQueue<{ to: string; subject: string }>("email");
|
|
9
|
+
|
|
10
|
+
// Define a worker (auto-discovered from workersDir)
|
|
11
|
+
import { createWorker } from "@lastshotlabs/bunshot";
|
|
12
|
+
export const emailWorker = createWorker("email", async (job) => { /* send email */ });
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Features include cron/scheduled workers via `createCronWorker`, dead letter queues via `createDLQHandler`, job status REST endpoints, and WebSocket broadcasting from workers via `publish`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## MongoDB Connections
|
|
2
|
+
|
|
3
|
+
MongoDB and Redis connect automatically inside `createServer` / `createApp`. Control the behavior via the `db` config object:
|
|
4
|
+
|
|
5
|
+
### Single database (default)
|
|
6
|
+
|
|
7
|
+
Both auth and app data share one server. Uses `MONGO_*` env vars.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
await createServer({
|
|
11
|
+
// ...
|
|
12
|
+
db: { mongo: "single", redis: true }, // these are the defaults — can omit db entirely
|
|
13
|
+
// app, auth, security are all optional with sensible defaults
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Separate auth database
|
|
18
|
+
|
|
19
|
+
Auth users live on a dedicated server (`MONGO_AUTH_*` env vars), app data on its own server (`MONGO_*` env vars). Useful when multiple tenant apps share one auth cluster.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
await createServer({
|
|
23
|
+
// ...
|
|
24
|
+
db: { mongo: "separate" },
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Manual connections
|
|
29
|
+
|
|
30
|
+
Set `mongo: false` and/or `redis: false` to skip auto-connect and manage connections yourself:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { connectAuthMongo, connectAppMongo, connectRedis, createServer } from "@lastshotlabs/bunshot";
|
|
34
|
+
|
|
35
|
+
await connectAuthMongo();
|
|
36
|
+
await connectAppMongo();
|
|
37
|
+
await connectRedis();
|
|
38
|
+
|
|
39
|
+
await createServer({
|
|
40
|
+
// ...
|
|
41
|
+
db: { mongo: false, redis: false },
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`AuthUser` and all built-in auth routes always use `authConnection`. Your app models use `appConnection` (see Adding Models below).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## MongoDB Connections
|
|
2
|
+
|
|
3
|
+
MongoDB and Redis connect automatically inside `createServer` / `createApp`. Control via the `db` config:
|
|
4
|
+
|
|
5
|
+
- **`mongo: "single"`** (default) — auth and app data share one server (`MONGO_*` env vars)
|
|
6
|
+
- **`mongo: "separate"`** — auth on its own server (`MONGO_AUTH_*` env vars), app data on another
|
|
7
|
+
- **`mongo: false`** — skip auto-connect, manage connections yourself via `connectAuthMongo()`, `connectAppMongo()`, `connectRedis()`
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
## Multi-Tenancy
|
|
2
|
+
|
|
3
|
+
Add multi-tenancy to your app by configuring tenant resolution. Bunshot resolves the tenant on each request and attaches `tenantId` + `tenantConfig` to the Hono context.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
await createServer({
|
|
7
|
+
tenancy: {
|
|
8
|
+
resolution: "header", // "header" | "subdomain" | "path"
|
|
9
|
+
headerName: "x-tenant-id", // default for "header" strategy
|
|
10
|
+
onResolve: async (tenantId) => { // validate + load tenant config — return null to reject
|
|
11
|
+
const tenant = await getTenant(tenantId);
|
|
12
|
+
return tenant?.config ?? null;
|
|
13
|
+
},
|
|
14
|
+
cacheTtlMs: 60_000, // LRU cache TTL for onResolve (default: 60s, 0 to disable)
|
|
15
|
+
cacheMaxSize: 500, // max cached entries (default: 500)
|
|
16
|
+
exemptPaths: ["/webhooks"], // additional paths that skip tenant resolution
|
|
17
|
+
rejectionStatus: 403, // 403 (default) or 404 when onResolve returns null
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Resolution strategies
|
|
23
|
+
|
|
24
|
+
| Strategy | How it extracts tenant ID | Example |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `"header"` | From request header (default `x-tenant-id`) | `x-tenant-id: acme` |
|
|
27
|
+
| `"subdomain"` | From first subdomain | `acme.myapp.com` → `"acme"` |
|
|
28
|
+
| `"path"` | From URL path segment (does **not** strip prefix) | `/acme/api/users` → `"acme"` |
|
|
29
|
+
|
|
30
|
+
### Default exempt paths
|
|
31
|
+
|
|
32
|
+
These paths skip tenant resolution by default: `/health`, `/docs`, `/openapi.json`, `/auth/` (auth is global — all tenants share a user pool). Add more via `exemptPaths`.
|
|
33
|
+
|
|
34
|
+
### Accessing tenant in routes
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
router.openapi(myRoute, async (c) => {
|
|
38
|
+
const tenantId = c.get("tenantId"); // string | null
|
|
39
|
+
const tenantConfig = c.get("tenantConfig"); // Record<string, unknown> | null
|
|
40
|
+
// Filter queries by tenantId, apply tenant-specific settings, etc.
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Tenant provisioning helpers
|
|
45
|
+
|
|
46
|
+
CRUD utilities for managing tenants (stored in the auth database via MongoDB):
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createTenant, getTenant, listTenants, deleteTenant } from "@lastshotlabs/bunshot";
|
|
50
|
+
|
|
51
|
+
await createTenant("acme", { displayName: "Acme Corp", config: { maxUsers: 100 } });
|
|
52
|
+
const tenant = await getTenant("acme"); // { tenantId, displayName, config, createdAt }
|
|
53
|
+
const all = await listTenants(); // active tenants only
|
|
54
|
+
await deleteTenant("acme"); // soft-delete + invalidates resolution cache
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Per-tenant namespacing
|
|
58
|
+
|
|
59
|
+
When tenant context is present, rate limits and cache keys are automatically namespaced per-tenant — no code changes needed. Each tenant gets independent rate limit buckets and cache entries.
|
|
60
|
+
|
|
61
|
+
- Rate limit keys: `t:${tenantId}:ip:${ip}` (instead of `ip:${ip}`)
|
|
62
|
+
- Cache keys: `cache:${appName}:${tenantId}:${key}` (instead of `cache:${appName}:${key}`)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## Multi-Tenancy
|
|
2
|
+
|
|
3
|
+
Opt-in via `tenancy` config. Resolves tenant ID from header, subdomain, or path segment on each request.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
await createServer({
|
|
7
|
+
tenancy: {
|
|
8
|
+
resolution: "header",
|
|
9
|
+
headerName: "x-tenant-id",
|
|
10
|
+
onResolve: async (tenantId) => { /* validate, return config or null */ },
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Auth routes are exempt (global user pool). Rate limits and cache keys are auto-namespaced per-tenant. CRUD helpers: `createTenant`, `getTenant`, `listTenants`, `deleteTenant`.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
## Social Login (OAuth)
|
|
2
|
+
|
|
3
|
+
Pass `auth.oauth.providers` to `createServer` to enable Google and/or Apple sign-in. Routes are mounted automatically for each configured provider.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
await createServer({
|
|
7
|
+
routesDir: import.meta.dir + "/routes",
|
|
8
|
+
app: { name: "My App", version: "1.0.0" },
|
|
9
|
+
auth: {
|
|
10
|
+
oauth: {
|
|
11
|
+
postRedirect: "/lobby", // where to redirect after login (default: "/")
|
|
12
|
+
providers: {
|
|
13
|
+
google: {
|
|
14
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
15
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
16
|
+
redirectUri: "https://myapp.com/auth/google/callback",
|
|
17
|
+
},
|
|
18
|
+
apple: {
|
|
19
|
+
clientId: process.env.APPLE_CLIENT_ID!, // Services ID, e.g. "com.myapp.auth"
|
|
20
|
+
teamId: process.env.APPLE_TEAM_ID!,
|
|
21
|
+
keyId: process.env.APPLE_KEY_ID!,
|
|
22
|
+
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
|
|
23
|
+
redirectUri: "https://myapp.com/auth/apple/callback",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Routes mounted automatically
|
|
32
|
+
|
|
33
|
+
| Provider | Initiate login | Callback | Link to existing account | Unlink |
|
|
34
|
+
|---|---|---|---|---|
|
|
35
|
+
| Google | `GET /auth/google` | `GET /auth/google/callback` | `GET /auth/google/link` | `DELETE /auth/google/link` |
|
|
36
|
+
| Apple | `GET /auth/apple` | `POST /auth/apple/callback` | `GET /auth/apple/link` | — |
|
|
37
|
+
|
|
38
|
+
> Apple sends its callback as a **POST** with form data. Your server must be publicly reachable and the redirect URI must be registered in the Apple developer console.
|
|
39
|
+
|
|
40
|
+
### Flow
|
|
41
|
+
|
|
42
|
+
1. Client navigates to `GET /auth/google` (or `/auth/apple`)
|
|
43
|
+
2. Package redirects to the provider's OAuth page
|
|
44
|
+
3. Provider redirects (or POSTs) back to the callback URL
|
|
45
|
+
4. Package exchanges the code, fetches the user profile, and calls `authAdapter.findOrCreateByProvider`
|
|
46
|
+
5. A session is created, the `auth-token` cookie is set, and the user is redirected to `auth.oauth.postRedirect`
|
|
47
|
+
|
|
48
|
+
### User storage
|
|
49
|
+
|
|
50
|
+
The default `mongoAuthAdapter` stores social users in `AuthUser` with a `providerIds` field (e.g. `["google:1234567890"]`). If no existing provider key is found, a new account is created — emails are never auto-linked. To connect a social identity to an existing credential account the user must explicitly use the link flow below.
|
|
51
|
+
|
|
52
|
+
**Email conflict handling:** If a user attempts to sign in via Google (or Apple) and the email returned by the provider already belongs to a credential-based account, `findOrCreateByProvider` throws `HttpError(409, ...)`. The OAuth callback catches this and redirects to `auth.oauth.postRedirect?error=<message>` so the client can display a helpful prompt (e.g. "An account with this email already exists — sign in with your password, then link Google from your account settings.").
|
|
53
|
+
|
|
54
|
+
To support social login with a custom adapter, implement `findOrCreateByProvider`:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const myAdapter: AuthAdapter = {
|
|
58
|
+
findByEmail: ...,
|
|
59
|
+
create: ...,
|
|
60
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
61
|
+
// find or upsert user by provider + providerId
|
|
62
|
+
// return { id: string }
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Linking a provider to an existing account
|
|
68
|
+
|
|
69
|
+
A logged-in user can link their account to a Google or Apple identity by navigating to the link route. This is the only way to associate a social login with an existing credential account — email matching is intentionally not done automatically.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
GET /auth/google/link (requires active session via cookie)
|
|
73
|
+
GET /auth/apple/link (requires active session via cookie)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The link flow:
|
|
77
|
+
1. User is already logged in (session cookie set)
|
|
78
|
+
2. Client navigates to `/auth/google/link`
|
|
79
|
+
3. User completes Google OAuth as normal
|
|
80
|
+
4. On callback, instead of creating a new session, the Google identity is added to their existing account
|
|
81
|
+
5. User is redirected to `auth.oauth.postRedirect?linked=google`
|
|
82
|
+
|
|
83
|
+
To support linking with a custom adapter, implement `linkProvider`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const myAdapter: AuthAdapter = {
|
|
87
|
+
// ...
|
|
88
|
+
async linkProvider(userId, provider, providerId) {
|
|
89
|
+
const key = `${provider}:${providerId}`;
|
|
90
|
+
await db.update(users)
|
|
91
|
+
.set({ providerIds: sql`array_append(provider_ids, ${key})` })
|
|
92
|
+
.where(eq(users.id, userId));
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Unlinking a provider
|
|
98
|
+
|
|
99
|
+
A logged-in user can remove a linked Google identity via:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
DELETE /auth/google/link (requires active session via cookie)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Returns `204 No Content` on success. All `google:*` entries are removed from the user's `providerIds`.
|
|
106
|
+
|
|
107
|
+
To support unlinking with a custom adapter, implement `unlinkProvider`:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const myAdapter: AuthAdapter = {
|
|
111
|
+
// ...
|
|
112
|
+
async unlinkProvider(userId, provider) {
|
|
113
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
114
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
115
|
+
const filtered = user.providerIds.filter((id: string) => !id.startsWith(`${provider}:`));
|
|
116
|
+
await db.update(users).set({ providerIds: filtered }).where(eq(users.id, userId));
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## Social Login (OAuth)
|
|
2
|
+
|
|
3
|
+
Pass `auth.oauth.providers` to enable Google and/or Apple sign-in. Routes are mounted automatically for each configured provider.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
auth: {
|
|
7
|
+
oauth: {
|
|
8
|
+
postRedirect: "/dashboard",
|
|
9
|
+
providers: {
|
|
10
|
+
google: { clientId: "...", clientSecret: "...", redirectUri: "..." },
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Auto-mounted routes per provider: initiate (`GET /auth/{provider}`), callback, link to existing account (`GET /auth/{provider}/link`), and unlink (`DELETE /auth/{provider}/link`). Supports custom adapters via `findOrCreateByProvider`, `linkProvider`, and `unlinkProvider`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
## Peer Dependencies
|
|
2
|
+
|
|
3
|
+
Bunshot declares the following as peer dependencies so you control their versions and avoid duplicate installs in your app.
|
|
4
|
+
|
|
5
|
+
### Required
|
|
6
|
+
|
|
7
|
+
These must be installed in every consuming app:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add hono zod
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Package | Required version |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `hono` | `>=4.12 <5` |
|
|
16
|
+
| `zod` | `>=4.0 <5` |
|
|
17
|
+
|
|
18
|
+
### Optional
|
|
19
|
+
|
|
20
|
+
Install only what your app actually uses:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# MongoDB auth / sessions / cache
|
|
24
|
+
bun add mongoose
|
|
25
|
+
|
|
26
|
+
# Redis sessions, cache, rate limiting, or BullMQ
|
|
27
|
+
bun add ioredis
|
|
28
|
+
|
|
29
|
+
# Background job queues
|
|
30
|
+
bun add bullmq
|
|
31
|
+
|
|
32
|
+
# MFA / TOTP
|
|
33
|
+
bun add otpauth
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Package | Required version | When you need it |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
|
|
39
|
+
| `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
|
|
40
|
+
| `bullmq` | `>=5.0 <6` | Workers / queues |
|
|
41
|
+
| `otpauth` | `>=9.0 <10` | `auth.mfa` configuration |
|
|
42
|
+
|
|
43
|
+
If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
## Quick Start
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
bun add @lastshotlabs/bunshot hono zod
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// src/index.ts
|
|
9
|
+
import { createServer } from "@lastshotlabs/bunshot";
|
|
10
|
+
|
|
11
|
+
await createServer({
|
|
12
|
+
routesDir: import.meta.dir + "/routes",
|
|
13
|
+
db: { auth: "memory", mongo: false, redis: false, sessions: "memory", cache: "memory" },
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/routes/hello.ts
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { createRoute, createRouter } from "@lastshotlabs/bunshot";
|
|
21
|
+
|
|
22
|
+
export const router = createRouter();
|
|
23
|
+
|
|
24
|
+
router.openapi(
|
|
25
|
+
createRoute({
|
|
26
|
+
method: "get",
|
|
27
|
+
path: "/hello",
|
|
28
|
+
responses: {
|
|
29
|
+
200: {
|
|
30
|
+
content: { "application/json": { schema: z.object({ message: z.string() }) } },
|
|
31
|
+
description: "Hello",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
(c) => c.json({ message: "Hello world!" }, 200)
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bun run src/index.ts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Auth, OpenAPI docs (`/docs`), health check, and WebSocket are all live. No databases required — swap `"memory"` for `"redis"` / `"mongo"` / `"sqlite"` when you're ready.
|