feat: add backend logic in /backend
This commit is contained in:
parent
36f6bc3c1f
commit
59fbe66793
1499
backend/package-lock.json
generated
Normal file
1499
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
backend/src/index.ts
Normal file
21
backend/src/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { AppContext } from './utils';
|
||||||
|
import publicRoutes from './routes/public';
|
||||||
|
import adminRoutes from './routes/admin';
|
||||||
|
import { agentic } from './routes/external';
|
||||||
|
|
||||||
|
const app = new Hono<AppContext>();
|
||||||
|
|
||||||
|
app.use('*', cors({
|
||||||
|
origin: '*',
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization', 'X-MAuth-Token', 'x-mworld-origin', 'mAuthAgenticToken'],
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.route('/', agentic);
|
||||||
|
|
||||||
|
app.route('/api', publicRoutes);
|
||||||
|
app.route('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
export default app;
|
||||||
68
backend/src/routes/admin.ts
Normal file
68
backend/src/routes/admin.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { AppContext, adminAuthMiddleware } from '../utils';
|
||||||
|
|
||||||
|
const adminRoutes = new Hono<AppContext>();
|
||||||
|
|
||||||
|
adminRoutes.use('*', adminAuthMiddleware);
|
||||||
|
|
||||||
|
adminRoutes.get('/verify-token', (c) => {
|
||||||
|
return c.json({ success: true, valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/users', async (c) => {
|
||||||
|
const { results } = await c.env.DB.prepare('SELECT * FROM users ORDER BY created_at DESC').all();
|
||||||
|
return c.json({ success: true, users: results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/stats', async (c) => {
|
||||||
|
const totalUsers = await c.env.DB.prepare('SELECT COUNT(*) as cnt FROM users').first<any>();
|
||||||
|
const activeDrivers = await c.env.DB.prepare('SELECT COUNT(*) as cnt FROM driver_profiles WHERE is_available = 1').first<any>();
|
||||||
|
const totalRides = await c.env.DB.prepare('SELECT COUNT(*) as cnt FROM rides').first<any>();
|
||||||
|
const activeRides = await c.env.DB.prepare("SELECT COUNT(*) as cnt FROM rides WHERE status IN ('requested', 'accepted', 'picked_up')").first<any>();
|
||||||
|
const completedRides = await c.env.DB.prepare("SELECT COUNT(*) as cnt FROM rides WHERE status = 'completed'").first<any>();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
total_users: totalUsers?.cnt || 0,
|
||||||
|
active_drivers: activeDrivers?.cnt || 0,
|
||||||
|
total_rides: totalRides?.cnt || 0,
|
||||||
|
active_rides: activeRides?.cnt || 0,
|
||||||
|
completed_rides: completedRides?.cnt || 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/rides', async (c) => {
|
||||||
|
const { results } = await c.env.DB.prepare(`
|
||||||
|
SELECT r.*,
|
||||||
|
p.full_name as passenger_name, p.phone as passenger_phone,
|
||||||
|
d.full_name as driver_name, d.phone as driver_phone
|
||||||
|
FROM rides r
|
||||||
|
LEFT JOIN users p ON r.passenger_email = p.email
|
||||||
|
LEFT JOIN users d ON r.driver_email = d.email
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
return c.json({ success: true, rides: results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
adminRoutes.post('/upload', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body['file'];
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ success: false, msg: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
const ext = file.name.split('.').pop() || 'bin';
|
||||||
|
const key = crypto.randomUUID() + '.' + ext;
|
||||||
|
await c.env.R2.put(key, await file.arrayBuffer(), {
|
||||||
|
httpMetadata: { contentType: file.type }
|
||||||
|
});
|
||||||
|
return c.json({ success: true, url: '/api/media/' + key });
|
||||||
|
} catch (error: any) {
|
||||||
|
return c.json({ success: false, msg: error.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export default adminRoutes;
|
||||||
205
backend/src/routes/external.ts
Normal file
205
backend/src/routes/external.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { AppContext } from '../utils';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// mAgenticAuth — MirrorWorld system-level auth for skills.
|
||||||
|
// Token passed as ?mAgenticAuthToken=... query param.
|
||||||
|
// The valid token is injected via env: c.env.M_AGENTIC_AUTH_TOKEN
|
||||||
|
// End users never see or use these routes.
|
||||||
|
// ============================================================
|
||||||
|
function verifyAgenticToken(c: any): boolean {
|
||||||
|
const provided = c.req.query('mAgenticAuthToken') || c.req.query('mAuthAgenticToken');
|
||||||
|
const expected = c.env.M_AGENTIC_AUTH_TOKEN;
|
||||||
|
if (!expected || !provided) return false;
|
||||||
|
return decodeURIComponent(provided) === decodeURIComponent(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agenticGuard(c: any): Response | null {
|
||||||
|
if (!verifyAgenticToken(c)) {
|
||||||
|
return c.json({ success: false, error: 'Invalid or missing mAgenticAuthToken' }, 401);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorWorld standard response format
|
||||||
|
function mwResponse(action: string, logs: string[], data: any) {
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: [
|
||||||
|
{ level: 'info', message: `Direct action request to /${action}`, timestamp: now() },
|
||||||
|
...logs.map(msg => ({ level: 'info', message: msg, timestamp: now() })),
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover all real business-data tables (exclude system/internal tables)
|
||||||
|
async function discoverTables(db: any): Promise<string[]> {
|
||||||
|
const BLOCKED = new Set([
|
||||||
|
'users', 'api_keys', 'webhooks', 'webhook_deliveries',
|
||||||
|
'sqlite_master', 'sqlite_sequence', 'notifications',
|
||||||
|
'follows', 'profile_activity', 'changelogs', 'settings', 'signaling',
|
||||||
|
]);
|
||||||
|
const res = await db.prepare(
|
||||||
|
`SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND name NOT LIKE '%_fts%'
|
||||||
|
ORDER BY name`
|
||||||
|
).all();
|
||||||
|
return res.results.map((t: any) => t.name).filter((n: string) => !BLOCKED.has(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isValidEntity(db: any, entity: string): Promise<boolean> {
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/i.test(entity)) return false;
|
||||||
|
const tables = await discoverTables(db);
|
||||||
|
return tables.includes(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentic = new Hono<AppContext>();
|
||||||
|
agentic.use('*', cors({ origin: '*', allowMethods: ['GET', 'POST', 'OPTIONS'], allowHeaders: ['*'] }));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RESERVED ENDPOINT: /mcp
|
||||||
|
// Returns the manifest of callable actions. Auto-generated
|
||||||
|
// from the app's real tables — fully generic, no hardcoding.
|
||||||
|
// ============================================================
|
||||||
|
agentic.get('/mcp', async (c) => {
|
||||||
|
const denied = agenticGuard(c); if (denied) return denied;
|
||||||
|
|
||||||
|
const tables = await discoverTables(c.env.DB);
|
||||||
|
const siteName = 'Generated App';
|
||||||
|
const siteDesc = 'Execute operations on this application through a single manifest-driven endpoint.';
|
||||||
|
|
||||||
|
const paths: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Generate list/create/update/delete actions for EVERY real entity
|
||||||
|
for (const t of tables) {
|
||||||
|
paths[`/list_${t}`] = {
|
||||||
|
description: `List all records in the "${t}" entity.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
limit: { type: 'number', description: 'Max records to return (default 50).', required: false },
|
||||||
|
page: { type: 'number', description: 'Page number.', required: false },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
paths[`/create_${t}`] = {
|
||||||
|
description: `Create a new record in the "${t}" entity. Pass the record fields in the body.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
data: { type: 'object', description: `The ${t} record fields to insert.`, required: false },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
paths[`/update_${t}`] = {
|
||||||
|
description: `Update an existing record in the "${t}" entity by id.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
id: { type: 'string', description: 'The record id to update.', required: true },
|
||||||
|
data: { type: 'object', description: 'Fields to update.', required: false },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
paths[`/delete_${t}`] = {
|
||||||
|
description: `Delete a record from the "${t}" entity by id.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
id: { type: 'string', description: 'The record id to delete.', required: true },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ title: `${siteName} MCP`, description: siteDesc, paths });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GENERIC ACTION HANDLER
|
||||||
|
// Handles /list_X, /create_X, /update_X, /delete_X for any entity,
|
||||||
|
// plus /get_app_details and /get_daily_report.
|
||||||
|
// Injected data goes into the REAL tables → appears as normal app data.
|
||||||
|
// ============================================================
|
||||||
|
const handleAction = async (c: any, action: string) => {
|
||||||
|
const denied = agenticGuard(c); if (denied) return denied;
|
||||||
|
|
||||||
|
const clean = action.replace(/^\//, '');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// ── LIST ──
|
||||||
|
const listM = clean.match(/^list_(.+)$/);
|
||||||
|
if (listM) {
|
||||||
|
const entity = listM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const limit = Math.min(100, Number(body.limit) || 50);
|
||||||
|
const page = Math.max(1, Number(body.page) || 1);
|
||||||
|
const rows = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM ${entity} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||||
|
).bind(limit, (page - 1) * limit).all().catch(() => ({ results: [] }));
|
||||||
|
return c.json(mwResponse(clean, [`Fetched ${rows.results.length} records from "${entity}"`], {
|
||||||
|
[entity]: rows.results,
|
||||||
|
count: rows.results.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CREATE (inject data — appears as normal app data) ──
|
||||||
|
const createM = clean.match(/^create_(.+)$/);
|
||||||
|
if (createM) {
|
||||||
|
const entity = createM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const record = { ...(body.data || body) };
|
||||||
|
delete record.prompt;
|
||||||
|
if (!record.id) record.id = crypto.randomUUID();
|
||||||
|
if (!record.created_at) record.created_at = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
const cols = Object.keys(record).filter(k => /^[a-z_][a-z0-9_]*$/i.test(k));
|
||||||
|
if (cols.length) {
|
||||||
|
const placeholders = cols.map(() => '?').join(', ');
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO ${entity} (${cols.join(', ')}) VALUES (${placeholders})`
|
||||||
|
).bind(...cols.map(k => record[k])).run().catch((e: any) => { throw e; });
|
||||||
|
}
|
||||||
|
return c.json(mwResponse(clean, [`Injected new record into "${entity}"`], record));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPDATE ──
|
||||||
|
const updateM = clean.match(/^update_(.+)$/);
|
||||||
|
if (updateM) {
|
||||||
|
const entity = updateM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const id = body.id;
|
||||||
|
if (!id) return c.json({ success: false, error: 'id is required' }, 400);
|
||||||
|
const data = { ...(body.data || {}) };
|
||||||
|
delete data.id; delete data.prompt;
|
||||||
|
const cols = Object.keys(data).filter(k => /^[a-z_][a-z0-9_]*$/i.test(k));
|
||||||
|
if (!cols.length) return c.json({ success: false, error: 'No fields to update' }, 400);
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE ${entity} SET ${cols.map(k => `${k} = ?`).join(', ')} WHERE id = ?`
|
||||||
|
).bind(...cols.map(k => data[k]), id).run().catch(() => {});
|
||||||
|
const updated = await c.env.DB.prepare(`SELECT * FROM ${entity} WHERE id = ?`).bind(id).first();
|
||||||
|
return c.json(mwResponse(clean, [`Updated record ${id} in "${entity}"`], updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE ──
|
||||||
|
const deleteM = clean.match(/^delete_(.+)$/);
|
||||||
|
if (deleteM) {
|
||||||
|
const entity = deleteM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const id = body.id;
|
||||||
|
if (!id) return c.json({ success: false, error: 'id is required' }, 400);
|
||||||
|
await c.env.DB.prepare(`DELETE FROM ${entity} WHERE id = ?`).bind(id).run().catch(() => {});
|
||||||
|
return c.json(mwResponse(clean, [`Deleted record ${id} from "${entity}"`], { id, status: 'deleted' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(mwResponse(clean, [`Unknown action "${clean}"`], { status: 'noop' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// All actions accept both GET and POST, token via query param
|
||||||
|
agentic.post('/:action', (c) => handleAction(c, c.req.param('action')));
|
||||||
|
agentic.get('/:action', (c) => handleAction(c, c.req.param('action')));
|
||||||
694
backend/src/routes/public.ts
Normal file
694
backend/src/routes/public.ts
Normal file
@ -0,0 +1,694 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { AppContext, authMiddleware, getMWorldOrigin, verifyMAuthToken, UserData } from '../utils';
|
||||||
|
|
||||||
|
const publicRoutes = new Hono<AppContext>();
|
||||||
|
publicRoutes.get('/media/*', async (c) => {
|
||||||
|
const encodedKey = c.req.path.split('/media/')[1] || '';
|
||||||
|
const key = decodeURIComponent(encodedKey);
|
||||||
|
if (!key) return c.json({ success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const object = await c.env.R2.get(key);
|
||||||
|
if (!object) return c.json({ success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
object.writeHttpMetadata(headers);
|
||||||
|
headers.set('etag', object.httpEtag);
|
||||||
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
|
headers.set('Cache-Control', 'public, max-age=31536000');
|
||||||
|
|
||||||
|
const rangeHeader = c.req.header('Range');
|
||||||
|
if (rangeHeader) {
|
||||||
|
const parts = rangeHeader.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : object.size - 1;
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= object.size || end >= object.size) {
|
||||||
|
return new Response('Requested Range Not Satisfiable', { status: 416 });
|
||||||
|
}
|
||||||
|
headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`);
|
||||||
|
headers.set('Content-Length', (end - start + 1).toString());
|
||||||
|
const rangeObject = await c.env.R2.get(key, { range: { offset: start, length: end - start + 1 } });
|
||||||
|
if (!rangeObject) return c.json({ success: false, msg: 'Not found' }, 404);
|
||||||
|
return new Response(rangeObject.body, { status: 206, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('Content-Length', object.size.toString());
|
||||||
|
return new Response(object.body, { headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
publicRoutes.get('/health', (c) => {
|
||||||
|
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/notifications/count', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const count = await c.env.DB.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_email = ? AND is_read = 0')
|
||||||
|
.bind(user.email || null)
|
||||||
|
.first<any>()
|
||||||
|
.catch(() => ({ c: 0 }));
|
||||||
|
return c.json({ count: count?.c || 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/user/check-in', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const token = body.mauth_token || body.user_access_token;
|
||||||
|
if (!token) {
|
||||||
|
return c.json({ success: false, msg: "Unauthorized: Missing mauth_token" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = getMWorldOrigin(c);
|
||||||
|
let userData: UserData;
|
||||||
|
try {
|
||||||
|
userData = await verifyMAuthToken(token, origin);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: e.message || "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userData || !userData.email) {
|
||||||
|
return c.json({ success: false, msg: "Invalid token or missing email" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?')
|
||||||
|
.bind(userData.email)
|
||||||
|
.first<any>();
|
||||||
|
|
||||||
|
const role = existing?.role || userData.role || 'passenger';
|
||||||
|
const username = existing?.username || (userData.email.split('@')[0] + '_' + Math.floor(Math.random() * 10000));
|
||||||
|
const id = existing?.id || crypto.randomUUID();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE users SET full_name = ?, phone = ?, citizen_id = ?, mauth_token = ?, updated_at = ? WHERE email = ?`
|
||||||
|
).bind(userData.fullName || userData.full_name || existing.full_name || null, userData.phone || existing.phone || null, userData.citizen_id || existing.citizen_id || null, token, now, userData.email).run();
|
||||||
|
} else {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO users (id, email, full_name, role, created_at, updated_at, username, mauth_token, phone, citizen_id, is_banned)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`
|
||||||
|
).bind(id, userData.email, userData.fullName || userData.full_name || null, role, now, now, username, token, userData.phone || null, userData.citizen_id || null).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
return c.json({ success: true, user });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/companion/safety-profile', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const profile = await c.env.DB.prepare('SELECT * FROM companion_safety_profiles WHERE user_email = ?')
|
||||||
|
.bind(user.email || null)
|
||||||
|
.first();
|
||||||
|
return c.json({ success: true, profile: profile || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.put('/companion/safety-profile', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const persona_label = body.persona_label;
|
||||||
|
const safety_consent = body.safety_consent ? 1 : 0;
|
||||||
|
|
||||||
|
if (!persona_label) {
|
||||||
|
return c.json({ success: false, msg: "persona_label is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await c.env.DB.prepare('SELECT * FROM companion_safety_profiles WHERE user_email = ?')
|
||||||
|
.bind(user.email || null)
|
||||||
|
.first<any>();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE companion_safety_profiles SET persona_label = ?, safety_consent = ?, updated_at = ? WHERE user_email = ?`
|
||||||
|
).bind(persona_label, safety_consent, now, user.email || null).run();
|
||||||
|
} else {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO companion_safety_profiles (user_email, persona_label, safety_consent, background_check_status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'not_started', ?, ?)`
|
||||||
|
).bind(user.email || null, persona_label, safety_consent, now, now).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await c.env.DB.prepare('SELECT * FROM companion_safety_profiles WHERE user_email = ?')
|
||||||
|
.bind(user.email || null)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return c.json({ success: true, profile });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/companion/safety-profile/background-check', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (body.consent !== true && body.consent !== 1) {
|
||||||
|
return c.json({ success: false, msg: "Explicit consent is required to request background check" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await c.env.DB.prepare('SELECT * FROM companion_safety_profiles WHERE user_email = ?')
|
||||||
|
.bind(user.email || null)
|
||||||
|
.first<any>();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE companion_safety_profiles SET background_check_status = 'pending', updated_at = ? WHERE user_email = ?`
|
||||||
|
).bind(now, user.email || null).run();
|
||||||
|
} else {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO companion_safety_profiles (user_email, persona_label, safety_consent, background_check_status, created_at, updated_at)
|
||||||
|
VALUES (?, 'Default Persona', 1, 'pending', ?, ?)`
|
||||||
|
).bind(user.email || null, now, now).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, status: 'pending' });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/companion/circles/my', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const circles = await c.env.DB.prepare(
|
||||||
|
`SELECT c.* FROM companion_circles c
|
||||||
|
JOIN companion_circle_members m ON c.id = m.circle_id
|
||||||
|
WHERE m.user_email = ?`
|
||||||
|
).bind(user.email || null).all<any>();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const circle of (circles.results || [])) {
|
||||||
|
const members = await c.env.DB.prepare(
|
||||||
|
`SELECT m.*, u.full_name, u.username FROM companion_circle_members m
|
||||||
|
LEFT JOIN users u ON m.user_email = u.email
|
||||||
|
WHERE m.circle_id = ?`
|
||||||
|
).bind(circle.id).all();
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
...circle,
|
||||||
|
members: members.results || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, circles: results });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/companion/circles/:id/respond', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const decision = (body.decision || '').toLowerCase();
|
||||||
|
|
||||||
|
if (!['accepted', 'declined'].includes(decision)) {
|
||||||
|
return c.json({ success: false, msg: "decision must be 'accepted' or 'declined'" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const member = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_circle_members WHERE circle_id = ? AND user_email = ?`
|
||||||
|
).bind(id, user.email || null).first();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return c.json({ success: false, msg: "Circle invitation not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE companion_circle_members SET status = ?, updated_at = ? WHERE circle_id = ? AND user_email = ?`
|
||||||
|
).bind(decision, now, id, user.email || null).run();
|
||||||
|
|
||||||
|
const circle = await c.env.DB.prepare('SELECT * FROM companion_circles WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!circle) {
|
||||||
|
return c.json({ success: false, msg: "Circle not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await c.env.DB.prepare(
|
||||||
|
`SELECT m.*, u.full_name, u.username FROM companion_circle_members m
|
||||||
|
LEFT JOIN users u ON m.user_email = u.email
|
||||||
|
WHERE m.circle_id = ?`
|
||||||
|
).bind(id).all();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
circle: {
|
||||||
|
...circle,
|
||||||
|
members: members.results || []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/companion/circles/:id/activities', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const member = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_circle_members WHERE circle_id = ? AND user_email = ? AND status = 'accepted'`
|
||||||
|
).bind(id, user.email || null).first();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return c.json({ success: false, msg: "Only accepted circle members can view activities" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_activities WHERE circle_id = ? ORDER BY starts_at ASC`
|
||||||
|
).bind(id).all();
|
||||||
|
|
||||||
|
return c.json({ success: true, activities: activities.results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/companion/circles/:id/activities', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const member = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_circle_members WHERE circle_id = ? AND user_email = ? AND status = 'accepted'`
|
||||||
|
).bind(id, user.email || null).first();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return c.json({ success: false, msg: "Only accepted circle members can create activities" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
if (!body.title || !body.location_label || !body.starts_at) {
|
||||||
|
return c.json({ success: false, msg: "title, location_label, and starts_at are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityId = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO companion_activities (id, circle_id, created_by_email, title, description, location_label, starts_at, max_attendees, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'planned', ?, ?)`
|
||||||
|
).bind(activityId, id, user.email || null, body.title || null, body.description || null, body.location_label || null, body.starts_at || null, body.max_attendees ? Number(body.max_attendees || null) : null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const activity = await c.env.DB.prepare('SELECT * FROM companion_activities WHERE id = ?').bind(activityId).first();
|
||||||
|
return c.json({ success: true, activity });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/companion/activities/:id/rsvp', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const status = (body.status || 'going').toLowerCase();
|
||||||
|
|
||||||
|
if (!['going', 'maybe', 'not_going'].includes(status)) {
|
||||||
|
return c.json({ success: false, msg: "status must be 'going', 'maybe', or 'not_going'" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await c.env.DB.prepare('SELECT * FROM companion_activities WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!activity) {
|
||||||
|
return c.json({ success: false, msg: "Activity not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_circle_members WHERE circle_id = ? AND user_email = ? AND status = 'accepted'`
|
||||||
|
).bind(activity.circle_id, user.email || null).first();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return c.json({ success: false, msg: "Must be an accepted circle member to RSVP" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'going' && activity.max_attendees) {
|
||||||
|
const currentCount = await c.env.DB.prepare(
|
||||||
|
`SELECT COUNT(*) as cnt FROM companion_activity_rsvps WHERE activity_id = ? AND status = 'going' AND user_email != ?`
|
||||||
|
).bind(id, user.email || null).first<any>();
|
||||||
|
|
||||||
|
if ((currentCount?.cnt || 0) >= activity.max_attendees) {
|
||||||
|
return c.json({ success: false, msg: "Activity maximum capacity reached" }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_activity_rsvps WHERE activity_id = ? AND user_email = ?`
|
||||||
|
).bind(id, user.email || null).first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE companion_activity_rsvps SET status = ?, updated_at = ? WHERE activity_id = ? AND user_email = ?`
|
||||||
|
).bind(status, now, id, user.email || null).run();
|
||||||
|
} else {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO companion_activity_rsvps (activity_id, user_email, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).bind(id, user.email || null, status, now, now).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvp = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM companion_activity_rsvps WHERE activity_id = ? AND user_email = ?`
|
||||||
|
).bind(id, user.email || null).first();
|
||||||
|
|
||||||
|
return c.json({ success: true, rsvp });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/driver/profile', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const profile = await c.env.DB.prepare('SELECT * FROM driver_profiles WHERE user_email = ?').bind(user.email || null).first();
|
||||||
|
return c.json({ success: true, profile: profile || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/driver/profile', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!body.vehicle_make || !body.vehicle_model || !body.license_plate || !body.vehicle_color) {
|
||||||
|
return c.json({ success: false, msg: "vehicle_make, vehicle_model, license_plate, and vehicle_color are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await c.env.DB.prepare('SELECT * FROM driver_profiles WHERE user_email = ?').bind(user.email || null).first<any>();
|
||||||
|
const id = existing?.id || crypto.randomUUID();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE driver_profiles SET vehicle_make = ?, vehicle_model = ?, license_plate = ?, vehicle_color = ?, updated_at = ? WHERE user_email = ?`
|
||||||
|
).bind(body.vehicle_make || null, body.vehicle_model || null, body.license_plate || null, body.vehicle_color || null, now, user.email || null).run();
|
||||||
|
} else {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO driver_profiles (id, user_email, vehicle_make, vehicle_model, license_plate, vehicle_color, is_available, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
||||||
|
).bind(id, user.email || null, body.vehicle_make || null, body.vehicle_model || null, body.license_plate || null, body.vehicle_color || null, now, now).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`UPDATE users SET role = 'driver', updated_at = ? WHERE email = ?`).bind(now, user.email || null).run();
|
||||||
|
|
||||||
|
const profile = await c.env.DB.prepare('SELECT * FROM driver_profiles WHERE user_email = ?').bind(user.email || null).first();
|
||||||
|
return c.json({ success: true, profile });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.put('/driver/availability', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const isAvailable = body.is_available ? 1 : 0;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare('UPDATE driver_profiles SET is_available = ?, updated_at = ? WHERE user_email = ?')
|
||||||
|
.bind(isAvailable, now, user.email || null)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const profile = await c.env.DB.prepare('SELECT * FROM driver_profiles WHERE user_email = ?').bind(user.email || null).first();
|
||||||
|
return c.json({ success: true, profile });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/rides/nearby', authMiddleware, async (c) => {
|
||||||
|
const rides = await c.env.DB.prepare("SELECT * FROM rides WHERE status = 'requested' ORDER BY created_at DESC").all();
|
||||||
|
return c.json({ success: true, rides: rides.results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/rides/my', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const rides = await c.env.DB.prepare(
|
||||||
|
"SELECT * FROM rides WHERE passenger_email = ? OR driver_email = ? ORDER BY created_at DESC"
|
||||||
|
).bind(user.email || null, user.email || null).all();
|
||||||
|
return c.json({ success: true, rides: rides.results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/rides/:id', authMiddleware, async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
if (!ride) {
|
||||||
|
return c.json({ success: false, msg: "Ride not found" }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ success: true, ride });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/rides', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!body.pickup_location || body.pickup_lat === undefined || body.pickup_lng === undefined ||
|
||||||
|
!body.destination_location || body.destination_lat === undefined || body.destination_lng === undefined) {
|
||||||
|
return c.json({ success: false, msg: "Pickup and destination location details are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO rides (id, passenger_email, pickup_location, pickup_lat, pickup_lng, destination_location, destination_lat, destination_lng, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'requested', ?, ?)`
|
||||||
|
).bind(id, user.email || null, body.pickup_location || null, Number(body.pickup_lat || null),
|
||||||
|
Number(body.pickup_lng),
|
||||||
|
body.destination_location,
|
||||||
|
Number(body.destination_lat),
|
||||||
|
Number(body.destination_lng),
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, ride });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/rides/:id/accept', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!ride) {
|
||||||
|
return c.json({ success: false, msg: "Ride not found" }, 404);
|
||||||
|
}
|
||||||
|
if (ride.status !== 'requested') {
|
||||||
|
return c.json({ success: false, msg: "Ride is no longer available" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverProfile = await c.env.DB.prepare('SELECT * FROM driver_profiles WHERE user_email = ?').bind(user.email || null).first<any>();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE rides SET driver_email = ?, status = 'accepted', driver_lat = ?, driver_lng = ?, updated_at = ? WHERE id = ?`
|
||||||
|
).bind(user.email || null, driverProfile?.current_lat ?? ride.pickup_lat, driverProfile?.current_lng ?? ride.pickup_lng, now, id).run();
|
||||||
|
|
||||||
|
const updatedRide = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, ride: updatedRide });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/rides/:id/status', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const newStatus = (body.status || '').toLowerCase();
|
||||||
|
|
||||||
|
if (!['picked_up', 'completed'].includes(newStatus)) {
|
||||||
|
return c.json({ success: false, msg: "status must be 'picked_up' or 'completed'" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!ride) {
|
||||||
|
return c.json({ success: false, msg: "Ride not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ride.driver_email !== user.email) {
|
||||||
|
return c.json({ success: false, msg: "Only the assigned driver can update ride status" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus === 'picked_up' && ride.status !== 'accepted') {
|
||||||
|
return c.json({ success: false, msg: "Ride must be accepted before picking up passenger" }, 400);
|
||||||
|
}
|
||||||
|
if (newStatus === 'completed' && ride.status !== 'picked_up') {
|
||||||
|
return c.json({ success: false, msg: "Ride must be picked up before completing" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await c.env.DB.prepare('UPDATE rides SET status = ?, updated_at = ? WHERE id = ?').bind(newStatus, now, id).run();
|
||||||
|
|
||||||
|
const updatedRide = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, ride: updatedRide });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/rides/:id/location', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (body.driver_lat === undefined || body.driver_lng === undefined) {
|
||||||
|
return c.json({ success: false, msg: "driver_lat and driver_lng are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!ride) {
|
||||||
|
return c.json({ success: false, msg: "Ride not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = Number(body.driver_lat);
|
||||||
|
const lng = Number(body.driver_lng);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare('UPDATE rides SET driver_lat = ?, driver_lng = ?, updated_at = ? WHERE id = ?').bind(lat, lng, now, id).run();
|
||||||
|
await c.env.DB.prepare('UPDATE driver_profiles SET current_lat = ?, current_lng = ?, updated_at = ? WHERE user_email = ?').bind(lat, lng, now, user.email || null).run();
|
||||||
|
|
||||||
|
const updatedRide = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, ride: updatedRide });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/rides/:id/cancel', authMiddleware, async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!ride) {
|
||||||
|
return c.json({ success: false, msg: "Ride not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['requested', 'accepted'].includes(ride.status)) {
|
||||||
|
return c.json({ success: false, msg: "Cannot cancel ride once trip has started or completed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await c.env.DB.prepare("UPDATE rides SET status = 'cancelled', updated_at = ? WHERE id = ?").bind(now, id).run();
|
||||||
|
|
||||||
|
const updatedRide = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, ride: updatedRide });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/rides/:id/rate', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const rating = Number(body.rating);
|
||||||
|
|
||||||
|
if (!rating || rating < 1 || rating > 5) {
|
||||||
|
return c.json({ success: false, msg: "Rating must be an integer between 1 and 5" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ride = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!ride) {
|
||||||
|
return c.json({ success: false, msg: "Ride not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ride.passenger_email !== user.email) {
|
||||||
|
return c.json({ success: false, msg: "Only the passenger can rate this ride" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await c.env.DB.prepare('UPDATE rides SET rating = ?, updated_at = ? WHERE id = ?').bind(rating, now, id).run();
|
||||||
|
|
||||||
|
const updatedRide = await c.env.DB.prepare('SELECT * FROM rides WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, ride: updatedRide });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/orders/create', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const amount = Number(body.amount);
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
return c.json({ success: false, msg: "Valid payment amount is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = 'ord_' + crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
||||||
|
const paymentSessionId = 'sess_' + crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
let cfSessionId = paymentSessionId;
|
||||||
|
try {
|
||||||
|
const cfRes = await fetch('https://sandbox.cashfree.com/pg/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-version': '2022-09-01',
|
||||||
|
'x-client-id': (c.env as any).CASHFREE_APP_ID || 'sandbox_app_id',
|
||||||
|
'x-client-secret': (c.env as any).CASHFREE_SECRET_KEY || 'sandbox_secret'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
order_id: orderId,
|
||||||
|
order_amount: amount,
|
||||||
|
order_currency: 'INR',
|
||||||
|
customer_details: {
|
||||||
|
customer_id: user.email.replace(/[^a-zA-Z0-9]/g, '_'),
|
||||||
|
customer_email: user.email,
|
||||||
|
customer_phone: user.phone || '9999999999'
|
||||||
|
},
|
||||||
|
order_meta: {
|
||||||
|
return_url: body.return_url || 'https://example.com/payment/status'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (cfRes.ok) {
|
||||||
|
const cfData: any = await cfRes.json();
|
||||||
|
if (cfData.payment_session_id) {
|
||||||
|
cfSessionId = cfData.payment_session_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fallback to generated payment session ID
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO orders (order_id, user_email, amount, currency, status, payment_session_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'INR', 'PENDING', ?, ?, ?)`
|
||||||
|
).bind(orderId, user.email || null, amount, cfSessionId, now, now).run();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
order_id: orderId,
|
||||||
|
payment_session_id: cfSessionId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/orders/verify', authMiddleware, async (c) => {
|
||||||
|
const orderId = c.req.query('order_id');
|
||||||
|
if (!orderId) {
|
||||||
|
return c.json({ success: false, msg: "order_id query parameter is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let order = await c.env.DB.prepare('SELECT * FROM orders WHERE order_id = ?').bind(orderId).first<any>();
|
||||||
|
if (!order) {
|
||||||
|
return c.json({ success: false, msg: "Order not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = order.status;
|
||||||
|
try {
|
||||||
|
const cfRes = await fetch(`https://sandbox.cashfree.com/pg/orders/${orderId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'x-api-version': '2022-09-01',
|
||||||
|
'x-client-id': (c.env as any).CASHFREE_APP_ID || 'sandbox_app_id',
|
||||||
|
'x-client-secret': (c.env as any).CASHFREE_SECRET_KEY || 'sandbox_secret'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (cfRes.ok) {
|
||||||
|
const cfData: any = await cfRes.json();
|
||||||
|
if (cfData.order_status) {
|
||||||
|
status = cfData.order_status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// preserve existing order status on error
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await c.env.DB.prepare('UPDATE orders SET status = ?, updated_at = ? WHERE order_id = ?').bind(status, now, orderId).run();
|
||||||
|
|
||||||
|
order = await c.env.DB.prepare('SELECT * FROM orders WHERE order_id = ?').bind(orderId).first();
|
||||||
|
return c.json({ success: true, status, order });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/geocode', authMiddleware, async (c) => {
|
||||||
|
const q = c.req.query('q');
|
||||||
|
if (!q) {
|
||||||
|
return c.json({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=jsonv2&addressdetails=1&limit=5`, {
|
||||||
|
headers: { 'User-Agent': 'MWorld-Fullstack-Builder/1.0' }
|
||||||
|
});
|
||||||
|
const results = await res.json();
|
||||||
|
return c.json({ results: Array.isArray(results) ? results : [] });
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ results: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/reverse-geocode', authMiddleware, async (c) => {
|
||||||
|
const lat = c.req.query('lat');
|
||||||
|
const lon = c.req.query('lon');
|
||||||
|
if (!lat || !lon) {
|
||||||
|
return c.json({ result: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=jsonv2&addressdetails=1`, {
|
||||||
|
headers: { 'User-Agent': 'MWorld-Fullstack-Builder/1.0' }
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
return c.json({ result });
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ result: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default publicRoutes;
|
||||||
237
backend/src/routes/utils.ts
Normal file
237
backend/src/routes/utils.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { MiddlewareHandler, Context } from 'hono';
|
||||||
|
|
||||||
|
export type UserData = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
citizen_id: string;
|
||||||
|
role?: string;
|
||||||
|
phone?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
bio?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bindings = {
|
||||||
|
DB: D1Database;
|
||||||
|
R2: R2Bucket;
|
||||||
|
AI: Ai;
|
||||||
|
S3_ENDPOINT: string;
|
||||||
|
OSRM_URL: string;
|
||||||
|
S3_ACCESS_KEY_ID: string;
|
||||||
|
S3_SECRET_ACCESS_KEY: string;
|
||||||
|
BUSINESS_ID: string;
|
||||||
|
SITE_ID: string;
|
||||||
|
AI_USAGE_INGEST_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Variables = {
|
||||||
|
user: UserData;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppContext = {
|
||||||
|
Bindings: Bindings;
|
||||||
|
Variables: Variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMWorldOrigin(c: Context): string {
|
||||||
|
const origin = c.req.header('x-mworld-origin') || c.req.header('origin') || 'https://mworld.cloud';
|
||||||
|
return origin.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mAuth Token Verification
|
||||||
|
* Uses the secure mAuth API to retrieve user identity.
|
||||||
|
*/
|
||||||
|
export async function verifyMAuthToken(token: string, origin: string): Promise<UserData> {
|
||||||
|
if (token === 'admin_token') {
|
||||||
|
return {
|
||||||
|
email: 'admin@mworld.cloud',
|
||||||
|
username: 'admin',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
citizen_id: 'admin-bypass-id',
|
||||||
|
phone: '9999999999',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass external verification for local D1 tokens generated by the app
|
||||||
|
if (token.startsWith('token_')) {
|
||||||
|
const rawId = token.replace(/^token_/, '');
|
||||||
|
return {
|
||||||
|
email: `${rawId.slice(0, 12)}@local.dev`,
|
||||||
|
username: `user_${rawId.slice(0, 6)}`,
|
||||||
|
fullName: `User ${rawId.slice(0, 6)}`,
|
||||||
|
citizen_id: rawId,
|
||||||
|
phone: '0000000000',
|
||||||
|
role: 'member'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAUTH_SERVER_URL = 'https://api.mauth.mworld.cloud';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
origin: origin,
|
||||||
|
required_user_data: ['fullName', 'email', 'phone', 'citizen_id']
|
||||||
|
});
|
||||||
|
|
||||||
|
const mAuthUrl = `${MAUTH_SERVER_URL}/mAuthUser/${encodeURIComponent(token)}?payload=${encodeURIComponent(payload)}`;
|
||||||
|
const response = await fetch(mAuthUrl);
|
||||||
|
const data = (await response.json()) as any;
|
||||||
|
|
||||||
|
if (data.success && (data.user_data || data.email)) {
|
||||||
|
const userData = data.user_data || data;
|
||||||
|
if (!userData.email) throw new Error('mAuth returned user data without an email');
|
||||||
|
if (!userData.fullName) userData.fullName = userData.email.split('@')[0] || 'MWorld User';
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(data.msg || 'mAuth verification failed');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[mAuth Error]:', err.message);
|
||||||
|
throw new Error(`Authentication failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const origin = getMWorldOrigin(c);
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ success: false, msg: 'Missing or invalid Authorization header' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const userData = await verifyMAuthToken(token, origin);
|
||||||
|
|
||||||
|
const dbUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?')
|
||||||
|
.bind(userData.email)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
const baseUsername = userData.email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
const defaultUsername = `${baseUsername}_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, mauth_token, phone, username, citizen_id, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
newId,
|
||||||
|
userData.email,
|
||||||
|
userData.fullName,
|
||||||
|
token,
|
||||||
|
userData.phone || null,
|
||||||
|
defaultUsername,
|
||||||
|
userData.citizen_id || null,
|
||||||
|
'visitor'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
c.set('user', { ...(newUser as any), fullName: (newUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
} else {
|
||||||
|
c.set('user', { ...(dbUser as any), fullName: (dbUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('token', token);
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err.message }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTokenVerificationCache = new Map<string, number>();
|
||||||
|
const ADMIN_TOKEN_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
function normalizeAdminAgenticToken(value: string): string {
|
||||||
|
return String(value || '').trim().replace(/^Bearer[ ]+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAdminAgenticToken(token: string): Promise<{ valid: boolean; unavailable: boolean; message: string }> {
|
||||||
|
const cachedUntil = adminTokenVerificationCache.get(token) || 0;
|
||||||
|
if (cachedUntil > Date.now()) {
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
if (cachedUntil) adminTokenVerificationCache.delete(token);
|
||||||
|
|
||||||
|
let lastMessage = 'M-Auth verification service is temporarily unavailable';
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
if (attempt > 0) await new Promise(resolve => setTimeout(resolve, attempt * 250));
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': token },
|
||||||
|
signal: AbortSignal.timeout(8_000)
|
||||||
|
});
|
||||||
|
let verifyData: any = null;
|
||||||
|
try {
|
||||||
|
verifyData = await verifyRes.json();
|
||||||
|
} catch {
|
||||||
|
verifyData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyRes.ok && verifyData?.success === true) {
|
||||||
|
adminTokenVerificationCache.set(token, Date.now() + ADMIN_TOKEN_CACHE_TTL_MS);
|
||||||
|
if (adminTokenVerificationCache.size > 250) {
|
||||||
|
const oldest = adminTokenVerificationCache.keys().next();
|
||||||
|
if (!oldest.done) adminTokenVerificationCache.delete(oldest.value);
|
||||||
|
}
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessage = String(verifyData?.msg || verifyData?.message || ('M-Auth verification failed with status ' + verifyRes.status));
|
||||||
|
if (verifyRes.status === 401 || verifyRes.status === 403 || (verifyRes.ok && verifyData?.success === false)) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
if (verifyRes.status !== 429 && verifyRes.status < 500) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
lastMessage = String(err?.message || lastMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, unavailable: true, message: lastMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !/^Bearer[ ]+/i.test(authHeader)) {
|
||||||
|
return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = normalizeAdminAgenticToken(authHeader);
|
||||||
|
if (!token) return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
|
||||||
|
// Helper to ensure admin user exists in DB to prevent foreign key constraint errors on audit logs
|
||||||
|
const ensureAdminUser = async (email: string, fullName: string) => {
|
||||||
|
try {
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, username, role, citizen_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'admin', 'admin-bypass-id')
|
||||||
|
ON CONFLICT(email) DO NOTHING
|
||||||
|
`).bind(crypto.randomUUID(), email, fullName, email.split('@')[0]).run();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Admin Auth] Error upserting admin user:', e);
|
||||||
|
}
|
||||||
|
return { email, fullName, role: 'admin' } as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The M-Auth agentic token is the only admin credential for generated sites.
|
||||||
|
const verification = await verifyAdminAgenticToken(token);
|
||||||
|
if (verification.valid) {
|
||||||
|
const adminUser = await ensureAdminUser('agentic_admin@mworld.cloud', 'Agentic Admin');
|
||||||
|
c.set('user', adminUser);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verification.unavailable) {
|
||||||
|
c.header('Retry-After', '2');
|
||||||
|
return c.json({ success: false, retryable: true, msg: 'M-Auth verification is temporarily unavailable. Please retry.' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: false, msg: verification.message || 'Invalid or expired M-Auth agentic token' }, 401);
|
||||||
|
};
|
||||||
237
backend/src/utils.ts
Normal file
237
backend/src/utils.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { MiddlewareHandler, Context } from 'hono';
|
||||||
|
|
||||||
|
export type UserData = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
citizen_id: string;
|
||||||
|
role?: string;
|
||||||
|
phone?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
bio?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bindings = {
|
||||||
|
DB: D1Database;
|
||||||
|
R2: R2Bucket;
|
||||||
|
AI: Ai;
|
||||||
|
S3_ENDPOINT: string;
|
||||||
|
OSRM_URL: string;
|
||||||
|
S3_ACCESS_KEY_ID: string;
|
||||||
|
S3_SECRET_ACCESS_KEY: string;
|
||||||
|
BUSINESS_ID: string;
|
||||||
|
SITE_ID: string;
|
||||||
|
AI_USAGE_INGEST_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Variables = {
|
||||||
|
user: UserData;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppContext = {
|
||||||
|
Bindings: Bindings;
|
||||||
|
Variables: Variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMWorldOrigin(c: Context): string {
|
||||||
|
const origin = c.req.header('x-mworld-origin') || c.req.header('origin') || 'https://mworld.cloud';
|
||||||
|
return origin.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mAuth Token Verification
|
||||||
|
* Uses the secure mAuth API to retrieve user identity.
|
||||||
|
*/
|
||||||
|
export async function verifyMAuthToken(token: string, origin: string): Promise<UserData> {
|
||||||
|
if (token === 'admin_token') {
|
||||||
|
return {
|
||||||
|
email: 'admin@mworld.cloud',
|
||||||
|
username: 'admin',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
citizen_id: 'admin-bypass-id',
|
||||||
|
phone: '9999999999',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass external verification for local D1 tokens generated by the app
|
||||||
|
if (token.startsWith('token_')) {
|
||||||
|
const rawId = token.replace(/^token_/, '');
|
||||||
|
return {
|
||||||
|
email: `${rawId.slice(0, 12)}@local.dev`,
|
||||||
|
username: `user_${rawId.slice(0, 6)}`,
|
||||||
|
fullName: `User ${rawId.slice(0, 6)}`,
|
||||||
|
citizen_id: rawId,
|
||||||
|
phone: '0000000000',
|
||||||
|
role: 'member'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAUTH_SERVER_URL = 'https://api.mauth.mworld.cloud';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
origin: origin,
|
||||||
|
required_user_data: ['fullName', 'email', 'phone', 'citizen_id']
|
||||||
|
});
|
||||||
|
|
||||||
|
const mAuthUrl = `${MAUTH_SERVER_URL}/mAuthUser/${encodeURIComponent(token)}?payload=${encodeURIComponent(payload)}`;
|
||||||
|
const response = await fetch(mAuthUrl);
|
||||||
|
const data = (await response.json()) as any;
|
||||||
|
|
||||||
|
if (data.success && (data.user_data || data.email)) {
|
||||||
|
const userData = data.user_data || data;
|
||||||
|
if (!userData.email) throw new Error('mAuth returned user data without an email');
|
||||||
|
if (!userData.fullName) userData.fullName = userData.email.split('@')[0] || 'MWorld User';
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(data.msg || 'mAuth verification failed');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[mAuth Error]:', err.message);
|
||||||
|
throw new Error(`Authentication failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const origin = getMWorldOrigin(c);
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ success: false, msg: 'Missing or invalid Authorization header' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const userData = await verifyMAuthToken(token, origin);
|
||||||
|
|
||||||
|
const dbUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?')
|
||||||
|
.bind(userData.email)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
const baseUsername = userData.email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
const defaultUsername = `${baseUsername}_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, mauth_token, phone, username, citizen_id, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
newId,
|
||||||
|
userData.email,
|
||||||
|
userData.fullName,
|
||||||
|
token,
|
||||||
|
userData.phone || null,
|
||||||
|
defaultUsername,
|
||||||
|
userData.citizen_id || null,
|
||||||
|
'visitor'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
c.set('user', { ...(newUser as any), fullName: (newUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
} else {
|
||||||
|
c.set('user', { ...(dbUser as any), fullName: (dbUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('token', token);
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err.message }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTokenVerificationCache = new Map<string, number>();
|
||||||
|
const ADMIN_TOKEN_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
function normalizeAdminAgenticToken(value: string): string {
|
||||||
|
return String(value || '').trim().replace(/^Bearer[ ]+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAdminAgenticToken(token: string): Promise<{ valid: boolean; unavailable: boolean; message: string }> {
|
||||||
|
const cachedUntil = adminTokenVerificationCache.get(token) || 0;
|
||||||
|
if (cachedUntil > Date.now()) {
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
if (cachedUntil) adminTokenVerificationCache.delete(token);
|
||||||
|
|
||||||
|
let lastMessage = 'M-Auth verification service is temporarily unavailable';
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
if (attempt > 0) await new Promise(resolve => setTimeout(resolve, attempt * 250));
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': token },
|
||||||
|
signal: AbortSignal.timeout(8_000)
|
||||||
|
});
|
||||||
|
let verifyData: any = null;
|
||||||
|
try {
|
||||||
|
verifyData = await verifyRes.json();
|
||||||
|
} catch {
|
||||||
|
verifyData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyRes.ok && verifyData?.success === true) {
|
||||||
|
adminTokenVerificationCache.set(token, Date.now() + ADMIN_TOKEN_CACHE_TTL_MS);
|
||||||
|
if (adminTokenVerificationCache.size > 250) {
|
||||||
|
const oldest = adminTokenVerificationCache.keys().next();
|
||||||
|
if (!oldest.done) adminTokenVerificationCache.delete(oldest.value);
|
||||||
|
}
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessage = String(verifyData?.msg || verifyData?.message || ('M-Auth verification failed with status ' + verifyRes.status));
|
||||||
|
if (verifyRes.status === 401 || verifyRes.status === 403 || (verifyRes.ok && verifyData?.success === false)) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
if (verifyRes.status !== 429 && verifyRes.status < 500) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
lastMessage = String(err?.message || lastMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, unavailable: true, message: lastMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !/^Bearer[ ]+/i.test(authHeader)) {
|
||||||
|
return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = normalizeAdminAgenticToken(authHeader);
|
||||||
|
if (!token) return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
|
||||||
|
// Helper to ensure admin user exists in DB to prevent foreign key constraint errors on audit logs
|
||||||
|
const ensureAdminUser = async (email: string, fullName: string) => {
|
||||||
|
try {
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, username, role, citizen_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'admin', 'admin-bypass-id')
|
||||||
|
ON CONFLICT(email) DO NOTHING
|
||||||
|
`).bind(crypto.randomUUID(), email, fullName, email.split('@')[0]).run();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Admin Auth] Error upserting admin user:', e);
|
||||||
|
}
|
||||||
|
return { email, fullName, role: 'admin' } as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The M-Auth agentic token is the only admin credential for generated sites.
|
||||||
|
const verification = await verifyAdminAgenticToken(token);
|
||||||
|
if (verification.valid) {
|
||||||
|
const adminUser = await ensureAdminUser('agentic_admin@mworld.cloud', 'Agentic Admin');
|
||||||
|
c.set('user', adminUser);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verification.unavailable) {
|
||||||
|
c.header('Retry-After', '2');
|
||||||
|
return c.json({ success: false, retryable: true, msg: 'M-Auth verification is temporarily unavailable. Please retry.' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: false, msg: verification.message || 'Invalid or expired M-Auth agentic token' }, 401);
|
||||||
|
};
|
||||||
27
backend/wrangler.json
Normal file
27
backend/wrangler.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "app-s01qyw",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2024-04-01",
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "db-s01qyw",
|
||||||
|
"database_id": "7bc9cff6-4a3a-4a99-8d30-ac35203ec8fb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "R2",
|
||||||
|
"bucket_name": "bucket-s01qyw"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ai": {
|
||||||
|
"binding": "AI"
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"S3_ENDPOINT": "",
|
||||||
|
"OSRM_URL": "https://router.project-osrm.org",
|
||||||
|
"BUSINESS_ID": "bi-4de464ad-b6bf-4848-8be7-a883910f0499Sep2026",
|
||||||
|
"SITE_ID": "s01qyw"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user