v2: Session-Login & Rollen, Premium-Admin, Visual-Block-Builder, KI-/MCP-API
- Auth-Umbau: Session-Login (signiertes HMAC-Cookie, scrypt-Hashing) statt Basic-Auth; users-/audit-Tabellen, Initial-Owner aus ENV, Rate-Limit, konfigurierbarer ADMIN_PATH (Middleware-Rewrite), Rollen-Gate (owner/redaktion/versand), Nutzerverwaltung, Audit-Log, Login/Logout/Konto-Seiten. - Premium-Pass: Command-Palette (Cmd-K), Toasts, Account-Menue, aufgewertetes Dashboard (KPI-Trend+Sparkline, Aktivitaets-Feed, Schnellaktionen), schoene Empty-States. - Block-Builder: pages.blocks, Vollbild-Editor (Liste/Live-Vorschau/Settings, Desktop/Mobil), 10 Block-Typen, Storefront-BlockRenderer auf /seite/[slug], Save-Endpoint. - KI-Editierbarkeit: token-gesicherte /api/admin/* (CRUD), Manifest /api/admin + /ai-admin.txt, MCP-Server unter mcp/ (14 Tools). - Docs: README + .env.example + mcp/README aktualisiert.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// hd-commerce — Token-gesicherte Admin-JSON-API (für KI/MCP).
|
||||
// Bearer-Token aus ENV HDC_API_TOKEN (getrennt von der Session-Auth).
|
||||
import * as store from './store.js';
|
||||
import { BLOCK_TYPES } from './blocks.js';
|
||||
|
||||
export function json(obj, status = 200) {
|
||||
return new Response(JSON.stringify(obj, null, 2), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
|
||||
}
|
||||
export function authOk(request) {
|
||||
const token = (process.env.HDC_API_TOKEN || '').trim();
|
||||
if (!token) return false; // ohne konfiguriertes Token bleibt die API gesperrt
|
||||
const hdr = request.headers.get('authorization') || '';
|
||||
const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||
return !!m && m[1].trim() === token;
|
||||
}
|
||||
|
||||
// ---- Ressourcen-Definitionen für das Manifest ----
|
||||
export const RESOURCES = {
|
||||
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
|
||||
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
|
||||
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
|
||||
popups: { rw: true, fields: ['title', 'type', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort'] },
|
||||
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
|
||||
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'items[]', 'address', 'created_at'] },
|
||||
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
|
||||
};
|
||||
|
||||
export function listResource(name) {
|
||||
switch (name) {
|
||||
case 'products': return store.listProducts();
|
||||
case 'pages': return store.listPages();
|
||||
case 'slides': return store.listSlides();
|
||||
case 'popups': return store.listPopups();
|
||||
case 'orders': return store.listOrders();
|
||||
case 'customers': return store.listCustomers();
|
||||
case 'settings': return store.getSettings();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
export function getResource(name, id) {
|
||||
switch (name) {
|
||||
case 'products': return store.getProductById(id);
|
||||
case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
|
||||
case 'slides': return store.getSlideById(id);
|
||||
case 'popups': return store.getPopupById(id);
|
||||
case 'orders': return store.getOrderById(id);
|
||||
case 'customers': return store.getCustomerById(id);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel.
|
||||
export function upsertResource(name, body) {
|
||||
if (name === 'products') {
|
||||
if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; }
|
||||
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } }
|
||||
const id = store.createProduct(body); return store.getProductById(id);
|
||||
}
|
||||
if (name === 'pages') {
|
||||
if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); }
|
||||
if (body.slug) { const ex = store.getPageBySlug(body.slug); if (ex) { store.updatePage(ex.id, { ...ex, ...body }); return store.getPageById(ex.id); } }
|
||||
const id = store.createPage(body); return store.getPageById(id);
|
||||
}
|
||||
if (name === 'slides') {
|
||||
if (body.id) { store.updateSlide(body.id, body); return store.getSlideById(body.id); }
|
||||
const id = store.createSlide(body); return store.getSlideById(id);
|
||||
}
|
||||
if (name === 'popups') {
|
||||
if (body.id) { store.updatePopup(body.id, body); return store.getPopupById(body.id); }
|
||||
const id = store.createPopup(body); return store.getPopupById(id);
|
||||
}
|
||||
if (name === 'settings') {
|
||||
const entries = body && typeof body === 'object' ? Object.entries(body) : [];
|
||||
for (const [k, v] of entries) store.setSetting(k, v);
|
||||
return store.getSettings();
|
||||
}
|
||||
throw new Error('Ressource nicht schreibbar: ' + name);
|
||||
}
|
||||
|
||||
export function deleteResource(name, id) {
|
||||
switch (name) {
|
||||
case 'products': store.deleteProduct(id); return true;
|
||||
case 'pages': store.deletePage(id); return true;
|
||||
case 'slides': store.deleteSlide(id); return true;
|
||||
case 'popups': store.deletePopup(id); return true;
|
||||
default: throw new Error('Ressource nicht löschbar: ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePageBlocks(id, blocks) { store.updatePageBlocks(id, blocks); return store.getPageById(id); }
|
||||
export function recordAudit(o) { store.recordAudit(o); }
|
||||
export function blockTypes() { return BLOCK_TYPES.map(b => ({ key: b.key, label: b.label, fields: b.fields.map(f => ({ name: f.name, type: f.type })) })); }
|
||||
|
||||
export function manifest(origin) {
|
||||
const ep = [];
|
||||
ep.push({ method: 'GET', path: '/api/admin', desc: 'Dieses Manifest' });
|
||||
for (const [name, def] of Object.entries(RESOURCES)) {
|
||||
ep.push({ method: 'GET', path: `/api/admin/${name}`, desc: `Liste ${name}` });
|
||||
ep.push({ method: 'GET', path: `/api/admin/${name}/{id}`, desc: `Einzelnes ${name}` });
|
||||
if (def.rw) {
|
||||
ep.push({ method: 'POST', path: `/api/admin/${name}`, desc: `Upsert ${name} (id oder slug => Update, sonst Create)` });
|
||||
if (name !== 'settings') ep.push({ method: 'DELETE', path: `/api/admin/${name}/{id}`, desc: `Löschen ${name}` });
|
||||
}
|
||||
}
|
||||
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
|
||||
return {
|
||||
name: 'hd-commerce Admin API',
|
||||
version: '2.0.0',
|
||||
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
|
||||
base_url: origin || '',
|
||||
resources: RESOURCES,
|
||||
block_types: blockTypes(),
|
||||
endpoints: ep,
|
||||
notes: [
|
||||
'Preise in Cent (priceCents/total_cents).',
|
||||
'orders und customers sind nur lesbar.',
|
||||
'settings ist eine Key/Value-Map; POST mit beliebigen Keys aktualisiert sie.',
|
||||
'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.',
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user