@webwaka/core 1.3.0
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/CHANGELOG.md +70 -0
- package/dist/ai.d.ts +25 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +53 -0
- package/dist/ai.js.map +1 -0
- package/dist/core/ai/AIEngine.d.ts +69 -0
- package/dist/core/ai/AIEngine.d.ts.map +1 -0
- package/dist/core/ai/AIEngine.js +185 -0
- package/dist/core/ai/AIEngine.js.map +1 -0
- package/dist/core/auth/index.d.ts +183 -0
- package/dist/core/auth/index.d.ts.map +1 -0
- package/dist/core/auth/index.js +369 -0
- package/dist/core/auth/index.js.map +1 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.d.ts.map +1 -0
- package/dist/core/billing/index.js +91 -0
- package/dist/core/billing/index.js.map +1 -0
- package/dist/core/booking/index.d.ts +38 -0
- package/dist/core/booking/index.d.ts.map +1 -0
- package/dist/core/booking/index.js +60 -0
- package/dist/core/booking/index.js.map +1 -0
- package/dist/core/chat/index.d.ts +48 -0
- package/dist/core/chat/index.d.ts.map +1 -0
- package/dist/core/chat/index.js +83 -0
- package/dist/core/chat/index.js.map +1 -0
- package/dist/core/document/index.d.ts +41 -0
- package/dist/core/document/index.d.ts.map +1 -0
- package/dist/core/document/index.js +68 -0
- package/dist/core/document/index.js.map +1 -0
- package/dist/core/events/index.d.ts +64 -0
- package/dist/core/events/index.d.ts.map +1 -0
- package/dist/core/events/index.js +60 -0
- package/dist/core/events/index.js.map +1 -0
- package/dist/core/geolocation/index.d.ts +32 -0
- package/dist/core/geolocation/index.d.ts.map +1 -0
- package/dist/core/geolocation/index.js +50 -0
- package/dist/core/geolocation/index.js.map +1 -0
- package/dist/core/kyc/index.d.ts +37 -0
- package/dist/core/kyc/index.d.ts.map +1 -0
- package/dist/core/kyc/index.js +60 -0
- package/dist/core/kyc/index.js.map +1 -0
- package/dist/core/logger/index.d.ts +60 -0
- package/dist/core/logger/index.d.ts.map +1 -0
- package/dist/core/logger/index.js +91 -0
- package/dist/core/logger/index.js.map +1 -0
- package/dist/core/notifications/index.d.ts +41 -0
- package/dist/core/notifications/index.d.ts.map +1 -0
- package/dist/core/notifications/index.js +111 -0
- package/dist/core/notifications/index.js.map +1 -0
- package/dist/core/rbac/index.d.ts +43 -0
- package/dist/core/rbac/index.d.ts.map +1 -0
- package/dist/core/rbac/index.js +66 -0
- package/dist/core/rbac/index.js.map +1 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/kyc.d.ts +12 -0
- package/dist/kyc.d.ts.map +1 -0
- package/dist/kyc.js +2 -0
- package/dist/kyc.js.map +1 -0
- package/dist/nanoid.d.ts +8 -0
- package/dist/nanoid.d.ts.map +1 -0
- package/dist/nanoid.js +15 -0
- package/dist/nanoid.js.map +1 -0
- package/dist/ndpr.d.ts +13 -0
- package/dist/ndpr.d.ts.map +1 -0
- package/dist/ndpr.js +19 -0
- package/dist/ndpr.js.map +1 -0
- package/dist/optimistic-lock.d.ts +11 -0
- package/dist/optimistic-lock.d.ts.map +1 -0
- package/dist/optimistic-lock.js +24 -0
- package/dist/optimistic-lock.js.map +1 -0
- package/dist/payment.d.ts +41 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +116 -0
- package/dist/payment.js.map +1 -0
- package/dist/pin.d.ts +6 -0
- package/dist/pin.d.ts.map +1 -0
- package/dist/pin.js +18 -0
- package/dist/pin.js.map +1 -0
- package/dist/query-helpers.d.ts +18 -0
- package/dist/query-helpers.d.ts.map +1 -0
- package/dist/query-helpers.js +22 -0
- package/dist/query-helpers.js.map +1 -0
- package/dist/rate-limit.d.ts +13 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +16 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/sms.d.ts +23 -0
- package/dist/sms.d.ts.map +1 -0
- package/dist/sms.js +60 -0
- package/dist/sms.js.map +1 -0
- package/dist/tax.d.ts +25 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +31 -0
- package/dist/tax.js.map +1 -0
- package/package.json +99 -0
- package/src/ai.test.ts +146 -0
- package/src/ai.ts +75 -0
- package/src/core/ai/AIEngine.test.ts +386 -0
- package/src/core/ai/AIEngine.ts +281 -0
- package/src/core/auth/index.test.ts +268 -0
- package/src/core/auth/index.ts +570 -0
- package/src/core/billing/index.test.ts +154 -0
- package/src/core/billing/index.ts +132 -0
- package/src/core/booking/index.test.ts +153 -0
- package/src/core/booking/index.ts +91 -0
- package/src/core/chat/index.test.ts +159 -0
- package/src/core/chat/index.ts +130 -0
- package/src/core/document/index.test.ts +106 -0
- package/src/core/document/index.ts +99 -0
- package/src/core/events/index.test.ts +91 -0
- package/src/core/events/index.ts +91 -0
- package/src/core/geolocation/index.test.ts +70 -0
- package/src/core/geolocation/index.ts +69 -0
- package/src/core/kyc/index.test.ts +105 -0
- package/src/core/kyc/index.ts +86 -0
- package/src/core/logger/index.test.ts +110 -0
- package/src/core/logger/index.ts +127 -0
- package/src/core/notifications/index.test.ts +85 -0
- package/src/core/notifications/index.ts +136 -0
- package/src/core/rbac/index.test.ts +81 -0
- package/src/core/rbac/index.ts +85 -0
- package/src/events.test.ts +43 -0
- package/src/events.ts +23 -0
- package/src/index.test.ts +123 -0
- package/src/index.ts +97 -0
- package/src/kyc.ts +23 -0
- package/src/nanoid.test.ts +43 -0
- package/src/nanoid.ts +16 -0
- package/src/ndpr.test.ts +68 -0
- package/src/ndpr.ts +49 -0
- package/src/optimistic-lock.test.ts +75 -0
- package/src/optimistic-lock.ts +36 -0
- package/src/payment.test.ts +152 -0
- package/src/payment.ts +163 -0
- package/src/pin.test.ts +57 -0
- package/src/pin.ts +38 -0
- package/src/query-helpers.test.ts +98 -0
- package/src/query-helpers.ts +36 -0
- package/src/rate-limit.test.ts +98 -0
- package/src/rate-limit.ts +33 -0
- package/src/sms.test.ts +112 -0
- package/src/sms.ts +85 -0
- package/src/tax.test.ts +85 -0
- package/src/tax.ts +57 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @webwaka/core — Auth Module
|
|
3
|
+
* Blueprint Reference: Part 9.2 (Universal Architecture Standards — Auth & Authorization)
|
|
4
|
+
*
|
|
5
|
+
* Canonical authentication primitives for all WebWaka OS v4 Cloudflare Workers.
|
|
6
|
+
*
|
|
7
|
+
* Invariants enforced:
|
|
8
|
+
* - Build Once Use Infinitely: single implementation, all suites import from here.
|
|
9
|
+
* - tenantId ALWAYS sourced from validated JWT payload, NEVER from request headers.
|
|
10
|
+
* - CORS NEVER uses wildcard `origin: '*'` in production.
|
|
11
|
+
* - All auth/mutation endpoints MUST apply rateLimit middleware.
|
|
12
|
+
*
|
|
13
|
+
* Exports:
|
|
14
|
+
* - signJWT() — Issue a signed HS256 JWT (used by super-admin-v2 login)
|
|
15
|
+
* - verifyJWT() — Verify & decode an HS256 JWT (used by all suites)
|
|
16
|
+
* - jwtAuthMiddleware() — Hono middleware: verify token, inject user into context
|
|
17
|
+
* - requireRole() — Hono middleware factory: enforce RBAC after jwtAuthMiddleware
|
|
18
|
+
* - secureCORS() — Hono CORS middleware with environment-aware origin allowlist
|
|
19
|
+
* - rateLimit() — Hono middleware: KV-backed sliding-window rate limiter
|
|
20
|
+
* - AuthUser — Canonical user session type
|
|
21
|
+
* - JWTPayload — Canonical JWT payload type
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
25
|
+
import { cors } from 'hono/cors';
|
|
26
|
+
import { logger } from '../logger/index.js';
|
|
27
|
+
|
|
28
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface JWTPayload {
|
|
31
|
+
/** Subject — user ID */
|
|
32
|
+
sub: string;
|
|
33
|
+
/** Tenant ID — ALWAYS sourced from here, never from headers */
|
|
34
|
+
tenantId: string;
|
|
35
|
+
/** User role */
|
|
36
|
+
role: string;
|
|
37
|
+
/** Granted permissions */
|
|
38
|
+
permissions: string[];
|
|
39
|
+
/** Issued-at (Unix seconds) */
|
|
40
|
+
iat: number;
|
|
41
|
+
/** Expiry (Unix seconds) */
|
|
42
|
+
exp: number;
|
|
43
|
+
/** User email */
|
|
44
|
+
email: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Canonical user context injected into every authenticated Hono request */
|
|
48
|
+
export interface AuthUser {
|
|
49
|
+
userId: string;
|
|
50
|
+
email: string;
|
|
51
|
+
role: string;
|
|
52
|
+
tenantId: string;
|
|
53
|
+
permissions: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* User context for API key authenticated requests (B2B / third-party systems).
|
|
58
|
+
* Compatible with the 'user' context key set by jwtAuthMiddleware.
|
|
59
|
+
*/
|
|
60
|
+
export interface WakaUser {
|
|
61
|
+
id: string;
|
|
62
|
+
tenant_id: string;
|
|
63
|
+
role: string;
|
|
64
|
+
name: string;
|
|
65
|
+
phone: string;
|
|
66
|
+
operator_id: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuthEnv {
|
|
70
|
+
JWT_SECRET: string;
|
|
71
|
+
ENVIRONMENT?: string;
|
|
72
|
+
/** Optional: KV namespace for rate-limiting counters */
|
|
73
|
+
RATE_LIMIT_KV?: KVNamespace;
|
|
74
|
+
/** Optional: D1 database binding used by verifyApiKey */
|
|
75
|
+
DB?: D1Database;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── JWT Utilities ────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Encodes a string as a URL-safe Base64 string (no padding).
|
|
82
|
+
* Uses TextEncoder so any Unicode content (accented chars, CJK, Arabic, etc.)
|
|
83
|
+
* is first converted to UTF-8 bytes before btoa — avoiding InvalidCharacterError.
|
|
84
|
+
*/
|
|
85
|
+
function toBase64Url(str: string): string {
|
|
86
|
+
const bytes = new TextEncoder().encode(str);
|
|
87
|
+
let binary = '';
|
|
88
|
+
for (const byte of bytes) {
|
|
89
|
+
binary += String.fromCharCode(byte);
|
|
90
|
+
}
|
|
91
|
+
return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decodes a URL-safe Base64 string back to its original UTF-8 string.
|
|
96
|
+
* Inverse of toBase64Url — required for verifying payloads that contain Unicode.
|
|
97
|
+
*/
|
|
98
|
+
function fromBase64Url(b64url: string): string {
|
|
99
|
+
const padded = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
100
|
+
const binary = atob(padded);
|
|
101
|
+
const bytes = new Uint8Array(binary.length);
|
|
102
|
+
for (let i = 0; i < binary.length; i++) {
|
|
103
|
+
bytes[i] = binary.charCodeAt(i);
|
|
104
|
+
}
|
|
105
|
+
return new TextDecoder().decode(bytes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sign a new HS256 JWT using the Web Crypto API (Cloudflare Workers compatible).
|
|
110
|
+
* Returns a compact `header.payload.signature` string.
|
|
111
|
+
*/
|
|
112
|
+
export async function signJWT(
|
|
113
|
+
payload: Omit<JWTPayload, 'iat' | 'exp'>,
|
|
114
|
+
secret: string,
|
|
115
|
+
expiresInSeconds = 86400
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const now = Math.floor(Date.now() / 1000);
|
|
118
|
+
const fullPayload: JWTPayload = {
|
|
119
|
+
...payload,
|
|
120
|
+
iat: now,
|
|
121
|
+
exp: now + expiresInSeconds,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
125
|
+
// Header is always ASCII — btoa is fine; payload uses Unicode-safe toBase64Url
|
|
126
|
+
const headerB64 = btoa(JSON.stringify(header))
|
|
127
|
+
.replace(/=/g, '')
|
|
128
|
+
.replace(/\+/g, '-')
|
|
129
|
+
.replace(/\//g, '_');
|
|
130
|
+
const payloadB64 = toBase64Url(JSON.stringify(fullPayload));
|
|
131
|
+
|
|
132
|
+
const encoder = new TextEncoder();
|
|
133
|
+
const keyData = encoder.encode(secret);
|
|
134
|
+
const key = await crypto.subtle.importKey(
|
|
135
|
+
'raw',
|
|
136
|
+
keyData,
|
|
137
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
138
|
+
false,
|
|
139
|
+
['sign']
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const data = encoder.encode(`${headerB64}.${payloadB64}`);
|
|
143
|
+
const signatureBuffer = await crypto.subtle.sign('HMAC', key, data);
|
|
144
|
+
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer)))
|
|
145
|
+
.replace(/=/g, '')
|
|
146
|
+
.replace(/\+/g, '-')
|
|
147
|
+
.replace(/\//g, '_');
|
|
148
|
+
|
|
149
|
+
return `${headerB64}.${payloadB64}.${signatureB64}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Verify an HS256 JWT and return its decoded payload.
|
|
154
|
+
* Returns null if the token is invalid, expired, or tampered with.
|
|
155
|
+
* Uses the Web Crypto API — safe for Cloudflare Workers edge runtime.
|
|
156
|
+
*/
|
|
157
|
+
export async function verifyJWT(
|
|
158
|
+
token: string,
|
|
159
|
+
secret: string
|
|
160
|
+
): Promise<JWTPayload | null> {
|
|
161
|
+
try {
|
|
162
|
+
const parts = token.split('.');
|
|
163
|
+
if (parts.length !== 3) return null;
|
|
164
|
+
|
|
165
|
+
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
|
166
|
+
|
|
167
|
+
const encoder = new TextEncoder();
|
|
168
|
+
const keyData = encoder.encode(secret);
|
|
169
|
+
const key = await crypto.subtle.importKey(
|
|
170
|
+
'raw',
|
|
171
|
+
keyData,
|
|
172
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
173
|
+
false,
|
|
174
|
+
['verify']
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const data = encoder.encode(`${headerB64}.${payloadB64}`);
|
|
178
|
+
const signature = Uint8Array.from(
|
|
179
|
+
atob(signatureB64.replace(/-/g, '+').replace(/_/g, '/')),
|
|
180
|
+
(c) => c.charCodeAt(0)
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const valid = await crypto.subtle.verify('HMAC', key, signature, data);
|
|
184
|
+
if (!valid) return null;
|
|
185
|
+
|
|
186
|
+
const payload = JSON.parse(fromBase64Url(payloadB64)) as JWTPayload;
|
|
187
|
+
|
|
188
|
+
// Reject expired tokens
|
|
189
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
190
|
+
|
|
191
|
+
return payload;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── API Key Authentication ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Verifies an API key by hashing it with SHA-256 and looking it up in the
|
|
201
|
+
* api_keys table. Returns a WakaUser on success or null if the key is invalid.
|
|
202
|
+
* Compatible with Cloudflare Workers (Web Crypto API only).
|
|
203
|
+
*/
|
|
204
|
+
export async function verifyApiKey(
|
|
205
|
+
rawKey: string,
|
|
206
|
+
db: D1Database
|
|
207
|
+
): Promise<WakaUser | null> {
|
|
208
|
+
const encoder = new TextEncoder();
|
|
209
|
+
const data = encoder.encode(rawKey);
|
|
210
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
211
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
212
|
+
const keyHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
213
|
+
|
|
214
|
+
const row = await db
|
|
215
|
+
.prepare(
|
|
216
|
+
`SELECT ak.*, o.name as operator_name FROM api_keys ak
|
|
217
|
+
JOIN operators o ON o.id = ak.operator_id
|
|
218
|
+
WHERE ak.key_hash = ? AND ak.revoked_at IS NULL AND ak.deleted_at IS NULL`
|
|
219
|
+
)
|
|
220
|
+
.bind(keyHash)
|
|
221
|
+
.first<{
|
|
222
|
+
id: string;
|
|
223
|
+
operator_id: string;
|
|
224
|
+
scope: string;
|
|
225
|
+
operator_name: string;
|
|
226
|
+
}>();
|
|
227
|
+
|
|
228
|
+
if (!row) return null;
|
|
229
|
+
|
|
230
|
+
// Non-blocking: update last_used_at
|
|
231
|
+
db.prepare(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`)
|
|
232
|
+
.bind(Date.now(), row.id)
|
|
233
|
+
.run()
|
|
234
|
+
.catch(() => {});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
id: row.id,
|
|
238
|
+
tenant_id: row.operator_id,
|
|
239
|
+
role: row.scope === 'read_write' ? 'TENANT_ADMIN' : 'STAFF',
|
|
240
|
+
name: `api_key:${row.id}`,
|
|
241
|
+
phone: '',
|
|
242
|
+
operator_id: row.operator_id,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Hono Middleware: JWT Auth ────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
export interface JwtAuthOptions {
|
|
249
|
+
/**
|
|
250
|
+
* Routes that bypass authentication entirely.
|
|
251
|
+
* Each entry is matched as a prefix against the request path.
|
|
252
|
+
* Method defaults to '*' (any method).
|
|
253
|
+
*/
|
|
254
|
+
publicRoutes?: Array<{ method?: string; path: string }>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Hono middleware that verifies the Bearer JWT, rejects invalid/expired tokens,
|
|
259
|
+
* and injects the canonical `AuthUser` into the Hono context as `c.get('user')`.
|
|
260
|
+
*
|
|
261
|
+
* Also sets `c.get('tenantId')` from the JWT payload — NEVER from headers.
|
|
262
|
+
*
|
|
263
|
+
* Usage:
|
|
264
|
+
* app.use('/api/*', jwtAuthMiddleware({ publicRoutes: [{ path: '/health' }] }));
|
|
265
|
+
*/
|
|
266
|
+
export function jwtAuthMiddleware(
|
|
267
|
+
options: JwtAuthOptions = {}
|
|
268
|
+
): MiddlewareHandler<{ Bindings: AuthEnv }> {
|
|
269
|
+
const { publicRoutes = [] } = options;
|
|
270
|
+
|
|
271
|
+
return async (
|
|
272
|
+
c: Context<{ Bindings: AuthEnv }>,
|
|
273
|
+
next: Next
|
|
274
|
+
): Promise<Response | void> => {
|
|
275
|
+
const method = c.req.method;
|
|
276
|
+
const path = c.req.path;
|
|
277
|
+
|
|
278
|
+
// Allow public routes through without auth
|
|
279
|
+
const isPublic = publicRoutes.some(
|
|
280
|
+
(r) =>
|
|
281
|
+
path.startsWith(r.path) && (!r.method || r.method === '*' || r.method === method)
|
|
282
|
+
);
|
|
283
|
+
if (isPublic) return next();
|
|
284
|
+
|
|
285
|
+
const authHeader = c.req.header('Authorization') ?? '';
|
|
286
|
+
|
|
287
|
+
// ── API Key auth (B2B / third-party systems) ──────────────────────────────
|
|
288
|
+
if (authHeader.startsWith('ApiKey ')) {
|
|
289
|
+
const rawKey = authHeader.slice(7).trim();
|
|
290
|
+
const db = (c.env as any).DB as D1Database | undefined;
|
|
291
|
+
if (!db) {
|
|
292
|
+
return c.json({ success: false, error: 'Unauthorized: DB binding not configured' }, 401);
|
|
293
|
+
}
|
|
294
|
+
const wakaUser = await verifyApiKey(rawKey, db);
|
|
295
|
+
if (!wakaUser) {
|
|
296
|
+
return c.json({ error: 'Invalid API key' }, 401);
|
|
297
|
+
}
|
|
298
|
+
c.set('user' as never, wakaUser);
|
|
299
|
+
c.set('tenantId' as never, wakaUser.operator_id);
|
|
300
|
+
return next();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── JWT Bearer auth ───────────────────────────────────────────────────────
|
|
304
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
305
|
+
return c.json(
|
|
306
|
+
{ success: false, error: 'Unauthorized: missing or malformed Authorization header' },
|
|
307
|
+
401
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const token = authHeader.slice(7).trim();
|
|
312
|
+
if (!token) {
|
|
313
|
+
return c.json({ success: false, error: 'Unauthorized: empty token' }, 401);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const payload = await verifyJWT(token, c.env.JWT_SECRET);
|
|
317
|
+
if (!payload) {
|
|
318
|
+
return c.json(
|
|
319
|
+
{ success: false, error: 'Unauthorized: invalid or expired token' },
|
|
320
|
+
401
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const user: AuthUser = {
|
|
325
|
+
userId: payload.sub,
|
|
326
|
+
email: payload.email,
|
|
327
|
+
role: payload.role,
|
|
328
|
+
tenantId: payload.tenantId,
|
|
329
|
+
permissions: payload.permissions ?? [],
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Inject into Hono context — downstream handlers use c.get('user') and c.get('tenantId')
|
|
333
|
+
c.set('user' as never, user);
|
|
334
|
+
c.set('tenantId' as never, payload.tenantId);
|
|
335
|
+
|
|
336
|
+
return next();
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Hono Middleware: RBAC ────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Hono middleware factory that enforces role-based access control.
|
|
344
|
+
* MUST be used AFTER jwtAuthMiddleware on the same route.
|
|
345
|
+
*
|
|
346
|
+
* Usage:
|
|
347
|
+
* app.post('/api/admin/tenants', requireRole(['SUPER_ADMIN', 'TENANT_ADMIN']), handler);
|
|
348
|
+
*/
|
|
349
|
+
export function requireRole(allowedRoles: string[]): MiddlewareHandler {
|
|
350
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
351
|
+
const user = c.get('user') as AuthUser | undefined;
|
|
352
|
+
if (!user) {
|
|
353
|
+
return c.json({ success: false, error: 'Unauthorized: no authenticated user' }, 401);
|
|
354
|
+
}
|
|
355
|
+
if (!allowedRoles.includes(user.role)) {
|
|
356
|
+
return c.json(
|
|
357
|
+
{
|
|
358
|
+
success: false,
|
|
359
|
+
error: `Forbidden: requires one of [${allowedRoles.join(', ')}]`,
|
|
360
|
+
},
|
|
361
|
+
403
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return next();
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Hono middleware factory that enforces permission-based access control.
|
|
370
|
+
* SUPER_ADMIN role bypasses all permission checks.
|
|
371
|
+
* MUST be used AFTER jwtAuthMiddleware.
|
|
372
|
+
*
|
|
373
|
+
* Usage:
|
|
374
|
+
* app.delete('/api/tenants/:id', requirePermissions(['delete:tenants']), handler);
|
|
375
|
+
*/
|
|
376
|
+
export function requirePermissions(requiredPermissions: string[]): MiddlewareHandler {
|
|
377
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
378
|
+
const user = c.get('user') as AuthUser | undefined;
|
|
379
|
+
if (!user) {
|
|
380
|
+
return c.json({ success: false, error: 'Unauthorized: no authenticated user' }, 401);
|
|
381
|
+
}
|
|
382
|
+
if (user.role === 'SUPER_ADMIN') return next();
|
|
383
|
+
|
|
384
|
+
const hasAll = requiredPermissions.every((p) => user.permissions.includes(p));
|
|
385
|
+
if (!hasAll) {
|
|
386
|
+
return c.json(
|
|
387
|
+
{
|
|
388
|
+
success: false,
|
|
389
|
+
error: `Forbidden: missing required permissions [${requiredPermissions.join(', ')}]`,
|
|
390
|
+
},
|
|
391
|
+
403
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return next();
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Hono Middleware: Secure CORS ─────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
export interface SecureCORSOptions {
|
|
401
|
+
/**
|
|
402
|
+
* Allowed origins for production. Defaults to WebWaka production domains.
|
|
403
|
+
* In non-production environments, all origins are allowed.
|
|
404
|
+
*/
|
|
405
|
+
allowedOrigins?: string[];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Environment-aware CORS middleware.
|
|
410
|
+
* - Production: restricts to an explicit allowlist of WebWaka domains.
|
|
411
|
+
* - Non-production (staging/dev/local): allows all origins for developer convenience.
|
|
412
|
+
*
|
|
413
|
+
* NEVER uses `origin: '*'` in production.
|
|
414
|
+
*
|
|
415
|
+
* Usage:
|
|
416
|
+
* app.use('*', secureCORS());
|
|
417
|
+
* app.use('*', secureCORS({ allowedOrigins: ['https://app.mywebwaka.com'] }));
|
|
418
|
+
*/
|
|
419
|
+
export function secureCORS(options: SecureCORSOptions = {}): MiddlewareHandler<{ Bindings: AuthEnv }> {
|
|
420
|
+
const defaultProductionOrigins = [
|
|
421
|
+
'https://webwaka.com',
|
|
422
|
+
'https://app.webwaka.com',
|
|
423
|
+
'https://admin.webwaka.com',
|
|
424
|
+
'https://webwaka-super-admin.pages.dev',
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const allowedOrigins = options.allowedOrigins ?? defaultProductionOrigins;
|
|
428
|
+
|
|
429
|
+
return cors({
|
|
430
|
+
origin: (origin, c) => {
|
|
431
|
+
const env = (c.env as AuthEnv).ENVIRONMENT ?? 'production';
|
|
432
|
+
const isProd = env === 'production';
|
|
433
|
+
|
|
434
|
+
if (!isProd) {
|
|
435
|
+
// Allow all origins in non-production environments
|
|
436
|
+
return origin;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// In production: only allow explicitly listed origins
|
|
440
|
+
if (allowedOrigins.includes(origin)) return origin;
|
|
441
|
+
|
|
442
|
+
// Block all other origins in production
|
|
443
|
+
return null;
|
|
444
|
+
},
|
|
445
|
+
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
446
|
+
allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
|
|
447
|
+
exposeHeaders: ['X-Request-ID'],
|
|
448
|
+
maxAge: 86400,
|
|
449
|
+
credentials: true,
|
|
450
|
+
}) as MiddlewareHandler<{ Bindings: AuthEnv }>;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ─── Hono Middleware: Rate Limiting ──────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
export interface RateLimitOptions {
|
|
456
|
+
/** Maximum requests allowed per window. Default: 60 */
|
|
457
|
+
limit?: number;
|
|
458
|
+
/** Window duration in seconds. Default: 60 */
|
|
459
|
+
windowSeconds?: number;
|
|
460
|
+
/** Key prefix to namespace different rate limit buckets. Default: 'rl' */
|
|
461
|
+
keyPrefix?: string;
|
|
462
|
+
/** Custom key extractor. Default: uses CF-Connecting-IP header or 'unknown' */
|
|
463
|
+
keyExtractor?: (c: Context) => string;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export interface RateLimitEnv extends AuthEnv {
|
|
467
|
+
RATE_LIMIT_KV: KVNamespace;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* KV-backed sliding-window rate limiter for Cloudflare Workers.
|
|
472
|
+
* Requires a `RATE_LIMIT_KV` KV namespace binding.
|
|
473
|
+
*
|
|
474
|
+
* Usage (auth endpoints — strict):
|
|
475
|
+
* app.post('/auth/login', rateLimit({ limit: 10, windowSeconds: 60, keyPrefix: 'login' }), handler);
|
|
476
|
+
*
|
|
477
|
+
* Usage (general API):
|
|
478
|
+
* app.use('/api/*', rateLimit({ limit: 300, windowSeconds: 60 }));
|
|
479
|
+
*/
|
|
480
|
+
export function rateLimit(options: RateLimitOptions = {}): MiddlewareHandler<{ Bindings: RateLimitEnv }> {
|
|
481
|
+
const {
|
|
482
|
+
limit = 60,
|
|
483
|
+
windowSeconds = 60,
|
|
484
|
+
keyPrefix = 'rl',
|
|
485
|
+
keyExtractor,
|
|
486
|
+
} = options;
|
|
487
|
+
|
|
488
|
+
return async (
|
|
489
|
+
c: Context<{ Bindings: RateLimitEnv }>,
|
|
490
|
+
next: Next
|
|
491
|
+
): Promise<Response | void> => {
|
|
492
|
+
const kv = c.env.RATE_LIMIT_KV;
|
|
493
|
+
if (!kv) {
|
|
494
|
+
// If KV is not configured, fail open (do not block) but log a warning
|
|
495
|
+
logger.warn('[rateLimit] RATE_LIMIT_KV binding not configured — rate limiting disabled');
|
|
496
|
+
return next();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const clientKey = keyExtractor
|
|
500
|
+
? keyExtractor(c)
|
|
501
|
+
: (c.req.header('CF-Connecting-IP') ?? c.req.header('X-Forwarded-For') ?? 'unknown');
|
|
502
|
+
|
|
503
|
+
const windowStart = Math.floor(Date.now() / 1000 / windowSeconds);
|
|
504
|
+
const kvKey = `${keyPrefix}:${clientKey}:${windowStart}`;
|
|
505
|
+
|
|
506
|
+
let count = 0;
|
|
507
|
+
try {
|
|
508
|
+
const existing = await kv.get(kvKey);
|
|
509
|
+
count = existing ? parseInt(existing, 10) : 0;
|
|
510
|
+
} catch {
|
|
511
|
+
// Fail open on KV errors
|
|
512
|
+
return next();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (count >= limit) {
|
|
516
|
+
return c.json(
|
|
517
|
+
{
|
|
518
|
+
success: false,
|
|
519
|
+
error: `Rate limit exceeded. Maximum ${limit} requests per ${windowSeconds}s.`,
|
|
520
|
+
},
|
|
521
|
+
429
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Increment counter; set TTL to expire at end of window
|
|
526
|
+
try {
|
|
527
|
+
await kv.put(kvKey, String(count + 1), { expirationTtl: windowSeconds * 2 });
|
|
528
|
+
} catch {
|
|
529
|
+
// Fail open on KV write errors
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Attach rate limit headers
|
|
533
|
+
c.header('X-RateLimit-Limit', String(limit));
|
|
534
|
+
c.header('X-RateLimit-Remaining', String(Math.max(0, limit - count - 1)));
|
|
535
|
+
c.header('X-RateLimit-Reset', String((windowStart + 1) * windowSeconds));
|
|
536
|
+
|
|
537
|
+
return next();
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── Tenant Utilities ─────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Safely extract tenantId from the Hono context.
|
|
545
|
+
* Throws if tenantId is not present (i.e., jwtAuthMiddleware was not applied).
|
|
546
|
+
* Use this in route handlers to enforce that tenant context is always present.
|
|
547
|
+
*/
|
|
548
|
+
export function getTenantId(c: Context): string {
|
|
549
|
+
const tenantId = c.get('tenantId') as string | undefined;
|
|
550
|
+
if (!tenantId) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
'getTenantId() called without jwtAuthMiddleware — tenantId not in context'
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return tenantId;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Safely extract the authenticated user from the Hono context.
|
|
560
|
+
* Throws if user is not present.
|
|
561
|
+
*/
|
|
562
|
+
export function getAuthUser(c: Context): AuthUser {
|
|
563
|
+
const user = c.get('user') as AuthUser | undefined;
|
|
564
|
+
if (!user) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
'getAuthUser() called without jwtAuthMiddleware — user not in context'
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
return user;
|
|
570
|
+
}
|