@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,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { requireRole, requirePermissions, Role, UserSession } from './index';
|
|
3
|
+
import { Context } from 'hono';
|
|
4
|
+
|
|
5
|
+
describe('CORE-6: Universal RBAC & Permissions Engine', () => {
|
|
6
|
+
|
|
7
|
+
const createMockContext = (session?: UserSession): Context => {
|
|
8
|
+
return {
|
|
9
|
+
get: vi.fn().mockReturnValue(session),
|
|
10
|
+
json: vi.fn().mockReturnValue('json-response')
|
|
11
|
+
} as unknown as Context;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let mockNext = vi.fn();
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockNext = vi.fn();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('requireRole middleware', () => {
|
|
21
|
+
it('should allow access if user has required role', async () => {
|
|
22
|
+
const c = createMockContext({ userId: '1', tenantId: 't1', role: Role.TENANT_ADMIN, permissions: [] });
|
|
23
|
+
const middleware = requireRole([Role.TENANT_ADMIN, Role.SUPER_ADMIN]);
|
|
24
|
+
|
|
25
|
+
await middleware(c, mockNext);
|
|
26
|
+
|
|
27
|
+
expect(mockNext).toHaveBeenCalled();
|
|
28
|
+
expect(c.json).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should deny access if user lacks required role', async () => {
|
|
32
|
+
const c = createMockContext({ userId: '1', tenantId: 't1', role: Role.CUSTOMER, permissions: [] });
|
|
33
|
+
const middleware = requireRole([Role.TENANT_ADMIN]);
|
|
34
|
+
|
|
35
|
+
await middleware(c, mockNext);
|
|
36
|
+
|
|
37
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
38
|
+
expect(c.json).toHaveBeenCalledWith({ error: 'Forbidden: Requires one of TENANT_ADMIN' }, 403);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should deny access if no session exists', async () => {
|
|
42
|
+
const c = createMockContext(undefined);
|
|
43
|
+
const middleware = requireRole([Role.TENANT_ADMIN]);
|
|
44
|
+
|
|
45
|
+
await middleware(c, mockNext);
|
|
46
|
+
|
|
47
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
48
|
+
expect(c.json).toHaveBeenCalledWith({ error: 'Unauthorized: No session found' }, 401);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('requirePermissions middleware', () => {
|
|
53
|
+
it('should allow access if user has all required permissions', async () => {
|
|
54
|
+
const c = createMockContext({ userId: '1', tenantId: 't1', role: Role.STAFF, permissions: ['read:users', 'write:users'] });
|
|
55
|
+
const middleware = requirePermissions(['read:users']);
|
|
56
|
+
|
|
57
|
+
await middleware(c, mockNext);
|
|
58
|
+
|
|
59
|
+
expect(mockNext).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should deny access if user is missing a required permission', async () => {
|
|
63
|
+
const c = createMockContext({ userId: '1', tenantId: 't1', role: Role.STAFF, permissions: ['read:users'] });
|
|
64
|
+
const middleware = requirePermissions(['read:users', 'write:users']);
|
|
65
|
+
|
|
66
|
+
await middleware(c, mockNext);
|
|
67
|
+
|
|
68
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
69
|
+
expect(c.json).toHaveBeenCalledWith({ error: 'Forbidden: Missing required permissions' }, 403);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should always allow SUPER_ADMIN regardless of explicit permissions', async () => {
|
|
73
|
+
const c = createMockContext({ userId: '1', tenantId: 'platform', role: Role.SUPER_ADMIN, permissions: [] });
|
|
74
|
+
const middleware = requirePermissions(['some:obscure:permission']);
|
|
75
|
+
|
|
76
|
+
await middleware(c, mockNext);
|
|
77
|
+
|
|
78
|
+
expect(mockNext).toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORE-6: Universal RBAC & Permissions Engine
|
|
3
|
+
* Blueprint Reference: Part 2 Layer 4 (Tenant Resolution & Auth)
|
|
4
|
+
*
|
|
5
|
+
* Implements granular role definitions and Hono middleware for route protection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Context, Next } from 'hono';
|
|
9
|
+
|
|
10
|
+
export enum Role {
|
|
11
|
+
SUPER_ADMIN = 'SUPER_ADMIN',
|
|
12
|
+
TENANT_ADMIN = 'TENANT_ADMIN',
|
|
13
|
+
STAFF = 'STAFF',
|
|
14
|
+
CUSTOMER = 'CUSTOMER'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UserSession {
|
|
18
|
+
userId: string;
|
|
19
|
+
tenantId: string;
|
|
20
|
+
role: Role;
|
|
21
|
+
permissions: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Middleware to enforce role-based access control on routes.
|
|
26
|
+
* @param allowedRoles Array of roles permitted to access the route.
|
|
27
|
+
*/
|
|
28
|
+
export const requireRole = (allowedRoles: Role[]) => {
|
|
29
|
+
return async (c: Context, next: Next) => {
|
|
30
|
+
const session = c.get('session') as UserSession | undefined;
|
|
31
|
+
|
|
32
|
+
if (!session) {
|
|
33
|
+
return c.json({ error: 'Unauthorized: No session found' }, 401);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!allowedRoles.includes(session.role)) {
|
|
37
|
+
return c.json({ error: `Forbidden: Requires one of ${allowedRoles.join(', ')}` }, 403);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await next();
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Middleware to enforce specific permissions.
|
|
46
|
+
* @param requiredPermissions Array of permissions required to access the route.
|
|
47
|
+
*/
|
|
48
|
+
export const requirePermissions = (requiredPermissions: string[]) => {
|
|
49
|
+
return async (c: Context, next: Next) => {
|
|
50
|
+
const session = c.get('session') as UserSession | undefined;
|
|
51
|
+
|
|
52
|
+
if (!session) {
|
|
53
|
+
return c.json({ error: 'Unauthorized: No session found' }, 401);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Super Admin bypasses permission checks
|
|
57
|
+
if (session.role === Role.SUPER_ADMIN) {
|
|
58
|
+
await next();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hasAllPermissions = requiredPermissions.every(p => session.permissions.includes(p));
|
|
63
|
+
|
|
64
|
+
if (!hasAllPermissions) {
|
|
65
|
+
return c.json({ error: `Forbidden: Missing required permissions` }, 403);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await next();
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Utility to verify JWT and extract session (Mock implementation for now)
|
|
74
|
+
* In production, this would verify the JWT signature against Cloudflare Access or custom auth.
|
|
75
|
+
*/
|
|
76
|
+
export const verifyJwt = async (token: string): Promise<UserSession | null> => {
|
|
77
|
+
// Mock implementation
|
|
78
|
+
if (token === 'mock-super-admin-token') {
|
|
79
|
+
return { userId: 'sa-1', tenantId: 'platform', role: Role.SUPER_ADMIN, permissions: ['*'] };
|
|
80
|
+
}
|
|
81
|
+
if (token === 'mock-tenant-admin-token') {
|
|
82
|
+
return { userId: 'ta-1', tenantId: 'tenant-1', role: Role.TENANT_ADMIN, permissions: ['manage_users', 'view_reports'] };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CommerceEvents } from './events';
|
|
3
|
+
import type { CommerceEventType } from './events';
|
|
4
|
+
|
|
5
|
+
describe('CommerceEvents', () => {
|
|
6
|
+
it('exports all expected event constants', () => {
|
|
7
|
+
expect(CommerceEvents.INVENTORY_UPDATED).toBe('inventory.updated');
|
|
8
|
+
expect(CommerceEvents.ORDER_CREATED).toBe('order.created');
|
|
9
|
+
expect(CommerceEvents.ORDER_READY_DELIVERY).toBe('order.ready_for_delivery');
|
|
10
|
+
expect(CommerceEvents.PAYMENT_COMPLETED).toBe('payment.completed');
|
|
11
|
+
expect(CommerceEvents.PAYMENT_REFUNDED).toBe('payment.refunded');
|
|
12
|
+
expect(CommerceEvents.SHIFT_CLOSED).toBe('shift.closed');
|
|
13
|
+
expect(CommerceEvents.CART_ABANDONED).toBe('cart.abandoned');
|
|
14
|
+
expect(CommerceEvents.SUBSCRIPTION_CHARGE).toBe('subscription.charge_due');
|
|
15
|
+
expect(CommerceEvents.DELIVERY_QUOTE).toBe('delivery.quote');
|
|
16
|
+
expect(CommerceEvents.DELIVERY_STATUS).toBe('delivery.status_changed');
|
|
17
|
+
expect(CommerceEvents.VENDOR_KYC_SUBMITTED).toBe('vendor.kyc_submitted');
|
|
18
|
+
expect(CommerceEvents.VENDOR_KYC_APPROVED).toBe('vendor.kyc_approved');
|
|
19
|
+
expect(CommerceEvents.VENDOR_KYC_REJECTED).toBe('vendor.kyc_rejected');
|
|
20
|
+
expect(CommerceEvents.STOCK_ADJUSTED).toBe('stock.adjusted');
|
|
21
|
+
expect(CommerceEvents.DISPUTE_OPENED).toBe('dispute.opened');
|
|
22
|
+
expect(CommerceEvents.DISPUTE_RESOLVED).toBe('dispute.resolved');
|
|
23
|
+
expect(CommerceEvents.PURCHASE_ORDER_RECEIVED).toBe('purchase_order.received');
|
|
24
|
+
expect(CommerceEvents.FLASH_SALE_STARTED).toBe('flash_sale.started');
|
|
25
|
+
expect(CommerceEvents.FLASH_SALE_ENDED).toBe('flash_sale.ended');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('has 19 event types', () => {
|
|
29
|
+
expect(Object.keys(CommerceEvents)).toHaveLength(19);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('all event values are dot-separated strings', () => {
|
|
33
|
+
for (const value of Object.values(CommerceEvents)) {
|
|
34
|
+
expect(value).toMatch(/^[a-z_]+\.[a-z_]+$/);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('CommerceEventType is assignable from event values', () => {
|
|
39
|
+
// Type-level test — if this compiles, the type is correct
|
|
40
|
+
const event: CommerceEventType = CommerceEvents.ORDER_CREATED;
|
|
41
|
+
expect(event).toBe('order.created');
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const CommerceEvents = {
|
|
2
|
+
INVENTORY_UPDATED: 'inventory.updated',
|
|
3
|
+
ORDER_CREATED: 'order.created',
|
|
4
|
+
ORDER_READY_DELIVERY: 'order.ready_for_delivery',
|
|
5
|
+
PAYMENT_COMPLETED: 'payment.completed',
|
|
6
|
+
PAYMENT_REFUNDED: 'payment.refunded',
|
|
7
|
+
SHIFT_CLOSED: 'shift.closed',
|
|
8
|
+
CART_ABANDONED: 'cart.abandoned',
|
|
9
|
+
SUBSCRIPTION_CHARGE: 'subscription.charge_due',
|
|
10
|
+
DELIVERY_QUOTE: 'delivery.quote',
|
|
11
|
+
DELIVERY_STATUS: 'delivery.status_changed',
|
|
12
|
+
VENDOR_KYC_SUBMITTED: 'vendor.kyc_submitted',
|
|
13
|
+
VENDOR_KYC_APPROVED: 'vendor.kyc_approved',
|
|
14
|
+
VENDOR_KYC_REJECTED: 'vendor.kyc_rejected',
|
|
15
|
+
STOCK_ADJUSTED: 'stock.adjusted',
|
|
16
|
+
DISPUTE_OPENED: 'dispute.opened',
|
|
17
|
+
DISPUTE_RESOLVED: 'dispute.resolved',
|
|
18
|
+
PURCHASE_ORDER_RECEIVED: 'purchase_order.received',
|
|
19
|
+
FLASH_SALE_STARTED: 'flash_sale.started',
|
|
20
|
+
FLASH_SALE_ENDED: 'flash_sale.ended',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type CommerceEventType = typeof CommerceEvents[keyof typeof CommerceEvents];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel export coverage test — src/index.ts
|
|
3
|
+
*
|
|
4
|
+
* This file imports from the main entry point to ensure the barrel is
|
|
5
|
+
* exercised by the coverage tool. All actual logic is tested in the
|
|
6
|
+
* individual module test files.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
// Auth
|
|
11
|
+
signJWT,
|
|
12
|
+
verifyJWT,
|
|
13
|
+
jwtAuthMiddleware,
|
|
14
|
+
verifyApiKey,
|
|
15
|
+
requireRole,
|
|
16
|
+
requirePermissions,
|
|
17
|
+
secureCORS,
|
|
18
|
+
rateLimit,
|
|
19
|
+
getTenantId,
|
|
20
|
+
getAuthUser,
|
|
21
|
+
// Tax
|
|
22
|
+
TaxEngine,
|
|
23
|
+
createTaxEngine,
|
|
24
|
+
// Payment
|
|
25
|
+
PaystackProvider,
|
|
26
|
+
createPaymentProvider,
|
|
27
|
+
// SMS
|
|
28
|
+
TermiiProvider,
|
|
29
|
+
createSmsProvider,
|
|
30
|
+
sendTermiiSms,
|
|
31
|
+
// Rate limit (KV standalone)
|
|
32
|
+
checkRateLimit,
|
|
33
|
+
// Optimistic lock
|
|
34
|
+
updateWithVersionLock,
|
|
35
|
+
// PIN
|
|
36
|
+
hashPin,
|
|
37
|
+
verifyPin,
|
|
38
|
+
// AI
|
|
39
|
+
OpenRouterClient,
|
|
40
|
+
createAiClient,
|
|
41
|
+
// Events
|
|
42
|
+
CommerceEvents,
|
|
43
|
+
// Nanoid
|
|
44
|
+
nanoid,
|
|
45
|
+
genId,
|
|
46
|
+
// Query helpers
|
|
47
|
+
parsePagination,
|
|
48
|
+
metaResponse,
|
|
49
|
+
applyTenantScope,
|
|
50
|
+
// NDPR
|
|
51
|
+
assertNdprConsent,
|
|
52
|
+
recordNdprConsent,
|
|
53
|
+
} from './index';
|
|
54
|
+
|
|
55
|
+
describe('@webwaka/core barrel exports', () => {
|
|
56
|
+
it('exports auth functions', () => {
|
|
57
|
+
expect(typeof signJWT).toBe('function');
|
|
58
|
+
expect(typeof verifyJWT).toBe('function');
|
|
59
|
+
expect(typeof jwtAuthMiddleware).toBe('function');
|
|
60
|
+
expect(typeof verifyApiKey).toBe('function');
|
|
61
|
+
expect(typeof requireRole).toBe('function');
|
|
62
|
+
expect(typeof requirePermissions).toBe('function');
|
|
63
|
+
expect(typeof secureCORS).toBe('function');
|
|
64
|
+
expect(typeof rateLimit).toBe('function');
|
|
65
|
+
expect(typeof getTenantId).toBe('function');
|
|
66
|
+
expect(typeof getAuthUser).toBe('function');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('exports tax utilities', () => {
|
|
70
|
+
expect(typeof TaxEngine).toBe('function');
|
|
71
|
+
expect(typeof createTaxEngine).toBe('function');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('exports payment utilities', () => {
|
|
75
|
+
expect(typeof PaystackProvider).toBe('function');
|
|
76
|
+
expect(typeof createPaymentProvider).toBe('function');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('exports SMS utilities', () => {
|
|
80
|
+
expect(typeof TermiiProvider).toBe('function');
|
|
81
|
+
expect(typeof createSmsProvider).toBe('function');
|
|
82
|
+
expect(typeof sendTermiiSms).toBe('function');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('exports rate limit utility', () => {
|
|
86
|
+
expect(typeof checkRateLimit).toBe('function');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('exports optimistic lock utility', () => {
|
|
90
|
+
expect(typeof updateWithVersionLock).toBe('function');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('exports PIN utilities', () => {
|
|
94
|
+
expect(typeof hashPin).toBe('function');
|
|
95
|
+
expect(typeof verifyPin).toBe('function');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('exports AI client', () => {
|
|
99
|
+
expect(typeof OpenRouterClient).toBe('function');
|
|
100
|
+
expect(typeof createAiClient).toBe('function');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('exports CommerceEvents', () => {
|
|
104
|
+
expect(CommerceEvents).toBeDefined();
|
|
105
|
+
expect(CommerceEvents.ORDER_CREATED).toBe('order.created');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('exports nanoid utilities', () => {
|
|
109
|
+
expect(typeof nanoid).toBe('function');
|
|
110
|
+
expect(typeof genId).toBe('function');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('exports query helpers', () => {
|
|
114
|
+
expect(typeof parsePagination).toBe('function');
|
|
115
|
+
expect(typeof metaResponse).toBe('function');
|
|
116
|
+
expect(typeof applyTenantScope).toBe('function');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('exports NDPR utilities', () => {
|
|
120
|
+
expect(typeof assertNdprConsent).toBe('function');
|
|
121
|
+
expect(typeof recordNdprConsent).toBe('function');
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @webwaka/core — Main Entry Point
|
|
3
|
+
* Blueprint Reference: Part 2 (Layer 4 — Tenant Resolution & Auth)
|
|
4
|
+
*
|
|
5
|
+
* Re-exports all platform primitives for consumers that import from '@webwaka/core'.
|
|
6
|
+
* For tree-shaking, consumers can also import from sub-paths:
|
|
7
|
+
* import { verifyJWT } from '@webwaka/core/auth'
|
|
8
|
+
* import { requireRole } from '@webwaka/core/rbac'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Auth (NEW — canonical, replaces all per-repo implementations) ────────────
|
|
12
|
+
export {
|
|
13
|
+
signJWT,
|
|
14
|
+
verifyJWT,
|
|
15
|
+
jwtAuthMiddleware,
|
|
16
|
+
verifyApiKey,
|
|
17
|
+
requireRole,
|
|
18
|
+
requirePermissions,
|
|
19
|
+
secureCORS,
|
|
20
|
+
rateLimit,
|
|
21
|
+
getTenantId,
|
|
22
|
+
getAuthUser,
|
|
23
|
+
type JWTPayload,
|
|
24
|
+
type AuthUser,
|
|
25
|
+
type WakaUser,
|
|
26
|
+
type AuthEnv,
|
|
27
|
+
type RateLimitEnv,
|
|
28
|
+
type JwtAuthOptions,
|
|
29
|
+
type SecureCORSOptions,
|
|
30
|
+
type RateLimitOptions as AuthRateLimitOptions,
|
|
31
|
+
} from './core/auth/index.js';
|
|
32
|
+
|
|
33
|
+
// ─── Billing ──────────────────────────────────────────────────────────────────
|
|
34
|
+
export * from './core/billing/index.js';
|
|
35
|
+
|
|
36
|
+
// ─── Logger ───────────────────────────────────────────────────────────────────
|
|
37
|
+
export * from './core/logger/index.js';
|
|
38
|
+
|
|
39
|
+
// ─── Notifications ────────────────────────────────────────────────────────────
|
|
40
|
+
export * from './core/notifications/index.js';
|
|
41
|
+
|
|
42
|
+
// ─── AI Engine ────────────────────────────────────────────────────────────────
|
|
43
|
+
export * from './core/ai/AIEngine.js';
|
|
44
|
+
|
|
45
|
+
// ─── KYC ─────────────────────────────────────────────────────────────────────
|
|
46
|
+
export * from './core/kyc/index.js';
|
|
47
|
+
|
|
48
|
+
// ─── Geolocation ─────────────────────────────────────────────────────────────
|
|
49
|
+
export * from './core/geolocation/index.js';
|
|
50
|
+
|
|
51
|
+
// ─── Document ────────────────────────────────────────────────────────────────
|
|
52
|
+
export * from './core/document/index.js';
|
|
53
|
+
|
|
54
|
+
// ─── Chat ────────────────────────────────────────────────────────────────────
|
|
55
|
+
export * from './core/chat/index.js';
|
|
56
|
+
|
|
57
|
+
// ─── Booking ─────────────────────────────────────────────────────────────────
|
|
58
|
+
export * from './core/booking/index.js';
|
|
59
|
+
|
|
60
|
+
// ─── Events ───────────────────────────────────────────────────────────────────
|
|
61
|
+
export * from './core/events/index.js';
|
|
62
|
+
|
|
63
|
+
// ─── Tax Engine ───────────────────────────────────────────────────────────────
|
|
64
|
+
export * from './tax.js';
|
|
65
|
+
|
|
66
|
+
// ─── Payment ──────────────────────────────────────────────────────────────────
|
|
67
|
+
export * from './payment.js';
|
|
68
|
+
|
|
69
|
+
// ─── SMS / OTP ────────────────────────────────────────────────────────────────
|
|
70
|
+
export * from './sms.js';
|
|
71
|
+
|
|
72
|
+
// ─── KV Rate Limiter ──────────────────────────────────────────────────────────
|
|
73
|
+
export * from './rate-limit.js';
|
|
74
|
+
|
|
75
|
+
// ─── Optimistic Lock ──────────────────────────────────────────────────────────
|
|
76
|
+
export * from './optimistic-lock.js';
|
|
77
|
+
|
|
78
|
+
// ─── PIN Hashing ──────────────────────────────────────────────────────────────
|
|
79
|
+
export * from './pin.js';
|
|
80
|
+
|
|
81
|
+
// ─── KYC Interface ────────────────────────────────────────────────────────────
|
|
82
|
+
export * from './kyc.js';
|
|
83
|
+
|
|
84
|
+
// ─── OpenRouter AI Client ────────────────────────────────────────────────────
|
|
85
|
+
export * from './ai.js';
|
|
86
|
+
|
|
87
|
+
// ─── Commerce Events ─────────────────────────────────────────────────────────
|
|
88
|
+
export * from './events.js';
|
|
89
|
+
|
|
90
|
+
// ─── Nanoid / ID Generation ───────────────────────────────────────────────────
|
|
91
|
+
export * from './nanoid.js';
|
|
92
|
+
|
|
93
|
+
// ─── Query Helpers ────────────────────────────────────────────────────────────
|
|
94
|
+
export * from './query-helpers.js';
|
|
95
|
+
|
|
96
|
+
// ─── NDPR Consent ─────────────────────────────────────────────────────────────
|
|
97
|
+
export * from './ndpr.js';
|
package/src/kyc.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface KycVerificationResult {
|
|
2
|
+
verified: boolean;
|
|
3
|
+
matchScore?: number;
|
|
4
|
+
reason?: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface IKycProvider {
|
|
9
|
+
verifyBvn(
|
|
10
|
+
bvnHash: string,
|
|
11
|
+
firstName: string,
|
|
12
|
+
lastName: string,
|
|
13
|
+
dob: string
|
|
14
|
+
): Promise<KycVerificationResult>;
|
|
15
|
+
|
|
16
|
+
verifyNin(
|
|
17
|
+
ninHash: string,
|
|
18
|
+
firstName: string,
|
|
19
|
+
lastName: string
|
|
20
|
+
): Promise<KycVerificationResult>;
|
|
21
|
+
|
|
22
|
+
verifyCac(rcNumber: string, businessName: string): Promise<KycVerificationResult>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { nanoid, genId } from './nanoid';
|
|
3
|
+
|
|
4
|
+
describe('nanoid', () => {
|
|
5
|
+
it('generates an ID of the default length (21 chars)', () => {
|
|
6
|
+
const id = nanoid();
|
|
7
|
+
expect(id).toHaveLength(21);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('generates an ID with a custom length', () => {
|
|
11
|
+
const id = nanoid('', 10);
|
|
12
|
+
expect(id).toHaveLength(10);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('prepends prefix with underscore separator', () => {
|
|
16
|
+
const id = nanoid('usr', 21);
|
|
17
|
+
expect(id.startsWith('usr_')).toBe(true);
|
|
18
|
+
// prefix + '_' + 21 chars
|
|
19
|
+
expect(id).toHaveLength(4 + 21);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('generates only alphanumeric characters in the ID part', () => {
|
|
23
|
+
const id = nanoid('', 100);
|
|
24
|
+
expect(id).toMatch(/^[A-Za-z0-9]+$/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('generates unique IDs on each call', () => {
|
|
28
|
+
const ids = new Set(Array.from({ length: 100 }, () => nanoid()));
|
|
29
|
+
expect(ids.size).toBe(100);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('genId is an alias for nanoid', () => {
|
|
33
|
+
const id = genId('test', 10);
|
|
34
|
+
expect(id.startsWith('test_')).toBe(true);
|
|
35
|
+
expect(id).toHaveLength(5 + 10);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns just the id when prefix is empty string', () => {
|
|
39
|
+
const id = nanoid('', 5);
|
|
40
|
+
expect(id).toHaveLength(5);
|
|
41
|
+
expect(id).not.toContain('_');
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/nanoid.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare Worker-compatible nanoid. Uses crypto.getRandomValues.
|
|
5
|
+
* prefix is prepended to the ID. Default length 21.
|
|
6
|
+
*/
|
|
7
|
+
export function nanoid(prefix = '', length = 21): string {
|
|
8
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
9
|
+
const id = Array.from(bytes)
|
|
10
|
+
.map((b) => CHARS[b % CHARS.length])
|
|
11
|
+
.join('');
|
|
12
|
+
return prefix ? `${prefix}_${id}` : id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Alias for nanoid — allows repos to migrate without breaking during transition. */
|
|
16
|
+
export const genId = nanoid;
|
package/src/ndpr.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { assertNdprConsent, recordNdprConsent } from './ndpr';
|
|
3
|
+
|
|
4
|
+
describe('assertNdprConsent', () => {
|
|
5
|
+
it('does not throw when ndpr_consent is true', () => {
|
|
6
|
+
expect(() => assertNdprConsent({ ndpr_consent: true })).not.toThrow();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('throws when ndpr_consent is false', () => {
|
|
10
|
+
expect(() => assertNdprConsent({ ndpr_consent: false })).toThrow('NDPR consent is required');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('throws when ndpr_consent is missing', () => {
|
|
14
|
+
expect(() => assertNdprConsent({ name: 'test' })).toThrow('NDPR consent is required');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('throws when body is null', () => {
|
|
18
|
+
expect(() => assertNdprConsent(null)).toThrow('NDPR consent is required');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws when body is a string', () => {
|
|
22
|
+
expect(() => assertNdprConsent('yes')).toThrow('NDPR consent is required');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('throws when body is a number', () => {
|
|
26
|
+
expect(() => assertNdprConsent(1)).toThrow('NDPR consent is required');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('attaches status 400 to the thrown error', () => {
|
|
30
|
+
try {
|
|
31
|
+
assertNdprConsent({});
|
|
32
|
+
} catch (err: any) {
|
|
33
|
+
expect(err.status).toBe(400);
|
|
34
|
+
expect(err.code).toBe('NDPR_CONSENT_REQUIRED');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('recordNdprConsent', () => {
|
|
40
|
+
it('calls db.prepare with INSERT OR IGNORE statement', async () => {
|
|
41
|
+
const mockRun = vi.fn().mockResolvedValue({ success: true });
|
|
42
|
+
const mockBind = vi.fn().mockReturnValue({ run: mockRun });
|
|
43
|
+
const mockPrepare = vi.fn().mockReturnValue({ bind: mockBind });
|
|
44
|
+
const mockDb = { prepare: mockPrepare } as any;
|
|
45
|
+
|
|
46
|
+
await recordNdprConsent(mockDb, 'entity-1', 'user', '1.2.3.4', 'Mozilla/5.0');
|
|
47
|
+
|
|
48
|
+
expect(mockPrepare).toHaveBeenCalledWith(
|
|
49
|
+
expect.stringContaining('INSERT OR IGNORE INTO ndpr_consent_log')
|
|
50
|
+
);
|
|
51
|
+
expect(mockBind).toHaveBeenCalled();
|
|
52
|
+
expect(mockRun).toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles null ipAddress and userAgent', async () => {
|
|
56
|
+
const mockRun = vi.fn().mockResolvedValue({ success: true });
|
|
57
|
+
const mockBind = vi.fn().mockReturnValue({ run: mockRun });
|
|
58
|
+
const mockPrepare = vi.fn().mockReturnValue({ bind: mockBind });
|
|
59
|
+
const mockDb = { prepare: mockPrepare } as any;
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
recordNdprConsent(mockDb, 'entity-2', 'organisation', null, null)
|
|
63
|
+
).resolves.not.toThrow();
|
|
64
|
+
|
|
65
|
+
const bindArgs = mockBind.mock.calls[0];
|
|
66
|
+
expect(bindArgs).toContain(null); // ipAddress null
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/ndpr.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
|
|
3
|
+
export interface NdprConsentLog {
|
|
4
|
+
id: string;
|
|
5
|
+
entity_id: string;
|
|
6
|
+
entity_type: string;
|
|
7
|
+
consented_at: number;
|
|
8
|
+
ip_address: string | null;
|
|
9
|
+
user_agent: string | null;
|
|
10
|
+
created_at: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function assertNdprConsent(body: unknown): void {
|
|
14
|
+
if (
|
|
15
|
+
typeof body !== 'object' ||
|
|
16
|
+
body === null ||
|
|
17
|
+
(body as Record<string, unknown>).ndpr_consent !== true
|
|
18
|
+
) {
|
|
19
|
+
const err = new Error('NDPR consent is required');
|
|
20
|
+
(err as any).status = 400;
|
|
21
|
+
(err as any).code = 'NDPR_CONSENT_REQUIRED';
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function recordNdprConsent(
|
|
27
|
+
db: D1Database,
|
|
28
|
+
entityId: string,
|
|
29
|
+
entityType: string,
|
|
30
|
+
ipAddress: string | null,
|
|
31
|
+
userAgent: string | null
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
await db
|
|
35
|
+
.prepare(
|
|
36
|
+
`INSERT OR IGNORE INTO ndpr_consent_log (id, entity_id, entity_type, consented_at, ip_address, user_agent, created_at)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
38
|
+
)
|
|
39
|
+
.bind(
|
|
40
|
+
`ndpr_${now}_${Array.from(crypto.getRandomValues(new Uint8Array(4))).map(b => b.toString(16).padStart(2, '0')).join('')}`,
|
|
41
|
+
entityId,
|
|
42
|
+
entityType,
|
|
43
|
+
now,
|
|
44
|
+
ipAddress,
|
|
45
|
+
userAgent,
|
|
46
|
+
now
|
|
47
|
+
)
|
|
48
|
+
.run();
|
|
49
|
+
}
|