feat(api): implement caching and logging in health check endpoint (#526)

* feat(api): implement caching and logging in health check endpoint

- Added in-memory caching for health check responses to reduce load on Postgres.
- Introduced logging for error handling in health check.
- Updated response structure to exclude error details from public responses.

feat(api): enhance OAuth consent UI and scope handling

- Improved consent UI to reflect exact requested scopes and added better user guidance.
- Updated scope handling logic to ensure least-privilege access.
- Enhanced styling for better user experience and accessibility.

chore(docker): improve security and resource management in Docker setup

- Updated Docker Compose configuration to enforce read-only file systems and resource limits.
- Added health checks and logging options for better observability.
- Introduced optional Caddy reverse proxy for TLS termination.

fix(migrations): resolve ambiguity in create_company_with_owner function

- Dropped orphaned 3-arg overload of create_company_with_owner function.
- Recreated canonical 4-arg version with cash account seeding logic.
- Ensured proper permissions for function execution in Postgres.

* feat: enhance security checks for team membership in company creation
This commit is contained in:
Mattsson
2026-05-19 17:52:11 +02:00
committed by GitHub
parent 8a6ce7093e
commit 9aced4790c
15 changed files with 1155 additions and 142 deletions
+94 -18
View File
@@ -1,20 +1,81 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logger'
const log = createLogger('health')
const CACHE_TTL_MS = 5_000
type HealthBody = {
status: 'healthy' | 'unhealthy'
timestamp: string
version: string
}
type CheckResult = {
body: HealthBody
status: number
}
type CachedResult = CheckResult & { expires: number }
// In-memory cache shared across requests in the same process. Docker's
// healthcheck polls every 30 s, so the cache always returns fresh data to it,
// but a public flood (multiple requests/second) is served from RAM and never
// reaches Postgres. The cache is intentionally tiny — one entry — because the
// endpoint takes no parameters.
let cached: CachedResult | null = null
// Holds the pending check when one is in flight so concurrent cache misses
// share a single Postgres round-trip. Cleared as soon as the promise settles.
// Bounds the worst case to one DB query per CACHE_TTL_MS window regardless of
// burst arrival rate (e.g. a load-balancer replaying queued probes).
let pending: Promise<CheckResult> | null = null
/**
* GET /api/health
* Public health check endpoint (no auth required).
* Returns DB connectivity status for uptime monitoring.
*
* Error details are logged server-side only — never echoed to the response
* body, which would expose Postgres error text on a public endpoint. The
* logger receives only error.code/error.message; raw Supabase error objects
* may include schema names, table names, or query fragments that should
* never reach application logs.
*
* Results are cached for {@link CACHE_TTL_MS} so flood traffic does not
* hammer Postgres with a service-role query per request.
*/
export async function GET() {
const now = Date.now()
if (cached && cached.expires > now) {
return NextResponse.json(cached.body, { status: cached.status })
}
const inFlight = pending ?? (pending = runAndCache(now))
try {
const result = await inFlight
return NextResponse.json(result.body, { status: result.status })
} finally {
if (pending === inFlight) pending = null
}
}
async function runAndCache(now: number): Promise<CheckResult> {
const result = await runHealthCheck()
cached = { ...result, expires: now + CACHE_TTL_MS }
return result
}
async function runHealthCheck(): Promise<CheckResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: 'Missing configuration' },
{ status: 503 }
)
log.error('Missing Supabase configuration for health check')
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
try {
@@ -25,21 +86,36 @@ export async function GET() {
.limit(1)
if (error) {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: error.message },
{ status: 503 }
)
// PostgrestError is a plain object: { code, message, details, hint }.
// details/hint can contain table or column names; log only the
// operationally useful fields.
log.error('Database health check failed', {
errCode: error.code ?? null,
errMessage: error.message ?? null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0',
})
return {
body: { status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 200,
}
} catch (err) {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 503 }
)
// Caught Error instances are reduced to {name, message, code} by the
// logger's redactor; never pass the raw value lest a deep stack containing
// query strings ends up in production logs.
const e = err as { name?: unknown; message?: unknown; code?: unknown }
log.error('Health check unexpected error', {
errName: typeof e?.name === 'string' ? e.name : null,
errMessage: typeof e?.message === 'string' ? e.message : null,
errCode: typeof e?.code === 'string' ? e.code : null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
}
+363 -74
View File
@@ -200,13 +200,21 @@ export async function GET(request: Request) {
const scopeBindingValue = scopeParam ?? ''
const scopeBindingSignature = signScopeBinding(scopeBindingValue)
// The grant ceiling is the set of scopes the client requested, or
// DEFAULT_OAUTH_SCOPES when the client passed no scope param. Pre-checks
// everything in the ceiling. The POST handler enforces the same ceiling
// server-side so a tampered form can't widen the grant past what the
// client actually asked for (RFC 6749 §3.3, SOC 2 CC6.3).
// The consent UI is bounded to a server-enforced ceiling, regardless of
// what the user ticks:
//
// - Client requested specific scopes → ceiling = that set (RFC 6749 §3.3
// strict least-privilege).
// - Client passed no scope (or only the legacy `mcp` marker — the case
// Claude's connector hits today) → ceiling = DEFAULT_OAUTH_SCOPES
// (read-only). This preserves GDPR Art. 25(2) data-protection-by-default
// and keeps a server-enforced read-only guarantee for clients that
// never declared any intent. Widening the ceiling beyond
// DEFAULT_OAUTH_SCOPES requires the client to ask for it via the
// `scope` parameter.
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
const scopeCheckboxesHtml = renderScopeCheckboxes(grantCeiling, grantCeiling)
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
const scopeCheckboxesHtml = renderScopeCheckboxes(preChecked, grantCeiling)
// Render consent page
const html = `<!DOCTYPE html>
@@ -215,83 +223,351 @@ export async function GET(request: Request) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="translate" content="no">
<meta name="color-scheme" content="light">
<title>Anslut MCP-klient — ${appNameLower}</title>
<style>
:root {
--bg: hsl(0 0% 100%);
--surface: hsl(0 0% 100%);
--secondary: hsl(40 11% 89%);
--secondary-hover: hsl(40 11% 84%);
--muted: hsl(40 8% 93%);
--border: hsl(45 5% 85%);
--border-strong: hsl(45 5% 72%);
--fg: hsl(0 0% 9%);
--fg-muted: hsl(0 0% 40%);
--fg-faint: hsl(0 0% 55%);
--primary: hsl(0 0% 9%);
--primary-hover: hsl(0 0% 20%);
--warning: hsl(38 55% 50%);
--warning-bg: hsl(38 60% 96%);
--warning-border: hsl(38 45% 82%);
--warning-fg: hsl(28 60% 28%);
--warm-accent: hsl(38 45% 52%);
--ring: hsl(0 0% 9%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: flex; align-items: flex-start; justify-content: center; min-height: 100vh; padding: 2rem 1rem; }
.card { background: white; border-radius: 12px; border: 1px solid #e5e5e5; padding: 2rem; max-width: 520px; width: 100%; }
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
p { font-size: 0.875rem; color: #666; line-height: 1.5; margin-bottom: 1rem; }
.account { font-size: 0.875rem; color: #111; font-weight: 500; background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
.scopes-header { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #666; margin-bottom: 0.75rem; }
.scopes-controls { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
.scopes-controls button { padding: 0.25rem 0.625rem; font-size: 0.75rem; font-weight: 500; background: white; color: #444; border: 1px solid #e5e5e5; border-radius: 6px; cursor: pointer; }
.scopes-controls button:hover { background: #f5f5f5; }
.scope-group { border: 1px solid #ececec; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; }
.scope-group-title { font-size: 0.8125rem; font-weight: 600; color: #111; margin-bottom: 0.5rem; }
.scope-row { display: flex; gap: 0.625rem; padding: 0.375rem 0; align-items: flex-start; }
.scope-row input { margin-top: 0.1875rem; cursor: pointer; }
.scope-row label { font-size: 0.8125rem; color: #333; cursor: pointer; line-height: 1.4; }
.scope-row .scope-name { font-weight: 500; color: #111; }
.scope-row .scope-desc { color: #666; font-size: 0.75rem; display: block; margin-top: 0.125rem; }
.scope-row.write .scope-name::after { content: " · skriv"; color: #b85c2c; font-weight: 500; }
.warn { font-size: 0.75rem; color: #8b5a00; background: #fff7e6; border: 1px solid #f0d6a1; border-radius: 6px; padding: 0.625rem 0.75rem; margin: 1rem 0; line-height: 1.4; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
.actions button { flex: 1; padding: 0.625rem 1rem; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; border: 1px solid #e5e5e5; }
.allow { background: #111; color: white; border-color: #111; }
.allow:hover { background: #333; }
.deny { background: white; color: #111; }
.deny:hover { background: #f5f5f5; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: 'Geist', -apple-system, system-ui, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 4rem 1.5rem 3rem;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem;
max-width: 560px;
width: 100%;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
margin-bottom: 0.875rem;
}
.eyebrow::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warm-accent);
}
h1 {
font-family: 'Hedvig Letters Serif', Georgia, 'Times New Roman', serif;
font-size: 2rem;
font-weight: 400;
letter-spacing: -0.018em;
line-height: 1.1;
color: var(--fg);
margin-bottom: 0.625rem;
}
.lede {
font-size: 0.875rem;
color: var(--fg-muted);
line-height: 1.55;
margin-bottom: 1.5rem;
}
.account {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
margin-bottom: 1.75rem;
}
.account-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
}
.account-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--fg);
text-align: right;
word-break: break-word;
}
.scopes-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.625rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid var(--border);
}
.scopes-title {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-muted);
}
.scopes-controls {
display: flex;
gap: 0.25rem;
}
.scopes-controls button {
padding: 0.3125rem 0.625rem;
font-family: inherit;
font-size: 0.6875rem;
font-weight: 500;
color: var(--fg-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: background 150ms, color 150ms, border-color 150ms;
}
.scopes-controls button:hover {
background: var(--secondary);
color: var(--fg);
border-color: var(--border-strong);
}
.scopes-controls button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.scope-group {
padding: 0.375rem 0;
border-bottom: 1px solid var(--border);
}
.scope-group:last-of-type { border-bottom: none; padding-bottom: 0; }
.scope-group-title {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
padding: 0.625rem 0 0.25rem;
}
.scope-row {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
margin: 0 -0.5rem;
align-items: flex-start;
border-radius: 6px;
transition: background 150ms;
}
.scope-row:hover { background: var(--secondary); }
.scope-row input[type="checkbox"] {
margin-top: 0.1875rem;
width: 15px;
height: 15px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
}
.scope-row input[type="checkbox"]:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
border-radius: 2px;
}
.scope-row label {
flex: 1;
cursor: pointer;
line-height: 1.45;
}
.scope-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.scope-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--fg);
}
.scope-desc {
display: block;
margin-top: 0.1875rem;
font-size: 0.75rem;
color: var(--fg-muted);
line-height: 1.5;
}
.scope-tag {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.0625rem 0.375rem;
border-radius: 4px;
background: hsl(38 60% 92%);
color: hsl(28 65% 30%);
border: 1px solid hsl(38 45% 78%);
}
.warn {
display: flex;
gap: 0.625rem;
font-size: 0.75rem;
color: var(--warning-fg);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
margin: 1.5rem 0 0;
line-height: 1.55;
}
.warn-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
margin-top: 0.125rem;
color: var(--warning);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 1.75rem;
}
.actions button {
flex: 1;
padding: 0.6875rem 1rem;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: 1px solid;
transition: background 150ms, border-color 150ms, color 150ms;
}
.actions button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.allow {
background: var(--primary);
color: hsl(0 0% 100%);
border-color: var(--primary);
}
.allow:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
.deny {
background: var(--surface);
color: var(--fg);
border-color: var(--border-strong);
}
.deny:hover { background: var(--secondary); }
.footer {
margin-top: 1.25rem;
font-size: 0.6875rem;
color: var(--fg-faint);
text-align: center;
line-height: 1.5;
}
@media (max-width: 480px) {
body { padding: 1.5rem 1rem 2rem; }
.card { padding: 1.5rem; border-radius: 10px; }
h1 { font-size: 1.625rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
}
</style>
</head>
<body>
<div class="card">
<main class="card" role="main">
<div class="eyebrow">${appNameLower} · mcp</div>
<h1>Anslut MCP-klient</h1>
<p>En extern applikation vill ansluta till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill ge.</p>
<div class="account">${escapeHtml(companyName)}</div>
<p class="lede">En extern applikation begär åtkomst till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill bevilja.</p>
<div class="account">
<span class="account-label">Företag</span>
<span class="account-name">${escapeHtml(companyName)}</span>
</div>
<form method="POST" action="${url.pathname}${url.search}" id="consent-form">
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
<div class="scopes-header">Behörigheter</div>
<div class="scopes-controls">
<button type="button" id="select-read">Endast läs</button>
<button type="button" id="select-all">Markera alla</button>
<button type="button" id="select-none">Avmarkera alla</button>
<div class="scopes-header">
<span class="scopes-title">Behörigheter</span>
<div class="scopes-controls">
<button type="button" id="select-read">Endast läs</button>
<button type="button" id="select-all">Alla</button>
<button type="button" id="select-none">Inga</button>
</div>
</div>
${scopeCheckboxesHtml}
<div class="warn">
Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Alla skrivoperationer kräver din godkännande i ${appNameLower} innan de skrivs till databasen.
<svg class="warn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.5"/>
<path d="M8 5v3.5" stroke-linecap="round"/>
<circle cx="8" cy="11" r="0.5" fill="currentColor" stroke="none"/>
</svg>
<span>Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Varje skrivoperation kräver ditt godkännande i ${appNameLower} innan den skrivs till databasen.</span>
</div>
<div class="actions">
<button type="submit" name="consent" value="deny" class="deny">Neka</button>
<button type="submit" name="consent" value="allow" class="allow">Tillåt</button>
<button type="submit" name="consent" value="allow" class="allow">Tillåt åtkomst</button>
</div>
</form>
<script nonce="${cspNonce}">
(function() {
var form = document.getElementById('consent-form');
var boxes = form.querySelectorAll('input[name="scopes"]');
function setAll(predicate) {
boxes.forEach(function(b) { b.checked = predicate(b); });
}
document.getElementById('select-read').addEventListener('click', function() {
setAll(function(b) { return b.dataset.kind === 'read'; });
});
document.getElementById('select-all').addEventListener('click', function() {
setAll(function() { return true; });
});
document.getElementById('select-none').addEventListener('click', function() {
setAll(function() { return false; });
});
})();
</script>
</div>
<p class="footer">Du kan när som helst återkalla åtkomsten under Inställningar &rsaquo; API-nycklar.</p>
</main>
<script nonce="${cspNonce}">
(function() {
var form = document.getElementById('consent-form');
var boxes = form.querySelectorAll('input[name="scopes"]');
function setAll(predicate) {
boxes.forEach(function(b) { b.checked = predicate(b); });
}
document.getElementById('select-read').addEventListener('click', function() {
setAll(function(b) { return b.dataset.kind === 'read'; });
});
document.getElementById('select-all').addEventListener('click', function() {
setAll(function() { return true; });
});
document.getElementById('select-none').addEventListener('click', function() {
setAll(function() { return false; });
});
})();
</script>
</body>
</html>`
@@ -394,16 +670,17 @@ export async function POST(request: Request) {
//
// 1. validateScopes drops any value that isn't in API_KEY_SCOPES — guards
// against forged values from a tampered POST.
// 2. The grant must be a subset of what the client *originally asked for*
// (the `scope` querystring on the GET). Otherwise a client that
// requested only read scopes could end up with write grants because
// the user ticked extra boxes — that's a least-privilege violation
// (RFC 6749 §3.3, SOC 2 CC6.3, NIST AC-6) and removes the client's
// ability to advertise the access surface it actually intends to use.
//
// When the client didn't pass a scope param at all (parsed.scopes is
// undefined), the consent UI defaults to DEFAULT_OAUTH_SCOPES — that becomes
// the implicit ceiling for the grant.
// 2. The grant must be a subset of the ceiling derived from the client's
// original request:
// • If the client requested specific scopes, the ceiling = that set
// (RFC 6749 §3.3 strict). A client that asked for only read scopes
// can never end up with write grants, even if the user tampered
// with the form (least-privilege, SOC 2 CC6.3, NIST AC-6).
// • If the client passed no scope (or only the `mcp` marker), the
// ceiling = DEFAULT_OAUTH_SCOPES (read-only). A client that never
// declared write intent cannot receive write grants, even if the
// user tampered with the form — preserving GDPR Art. 25(2)
// data-protection-by-default.
const submittedScopes = formData.getAll('scopes').filter((s): s is string => typeof s === 'string')
const validated = validateScopes(submittedScopes)
const clientCeiling: ApiKeyScope[] = parsed.scopes ?? [...DEFAULT_OAUTH_SCOPES]
@@ -434,10 +711,11 @@ export async function POST(request: Request) {
/**
* Render the scope checkbox UI grouped by domain. Only scopes in `ceiling`
* (the client's `scope` querystring, or DEFAULT_OAUTH_SCOPES) are surfaced —
* scopes outside the ceiling are dropped from the consent UI so the user
* can't tick boxes that the POST handler would refuse anyway. Pre-checks
* every visible row by default.
* are surfaced — scopes outside the ceiling are dropped from the consent UI
* so the user can't tick boxes that the POST handler would refuse anyway.
* The ceiling is either the client's `scope` querystring (when specified)
* or DEFAULT_OAUTH_SCOPES (when the client passed no scope), matching the
* server-side enforcement in the POST handler.
*/
function renderScopeCheckboxes(
preChecked: Set<ApiKeyScope>,
@@ -479,11 +757,22 @@ function renderScopeCheckboxes(
function scopeRow(scope: ApiKeyScope, checked: boolean, kind: 'read' | 'write'): string {
const meta = API_KEY_SCOPES[scope]
const id = `scope-${scope.replace(/[^a-z0-9]/gi, '-')}`
// Labels are formatted "Område — verb" (läs/skriv/hantera/godkänn). Pull the
// prefix as the display name and only render the verb as a tag for elevated
// scopes — read-only is the implicit default and doesn't need a tag.
const [namePart, verbPart] = meta.label.split(' — ')
const displayName = namePart ?? meta.label
const tagHtml = verbPart && kind === 'write'
? `<span class="scope-tag">${escapeHtml(verbPart)}</span>`
: ''
return `
<div class="scope-row ${kind}">
<input type="checkbox" id="${id}" name="scopes" value="${escapeHtml(scope)}" data-kind="${kind}" ${checked ? 'checked' : ''}>
<label for="${id}">
<span class="scope-name">${escapeHtml(meta.label)}</span>
<span class="scope-name-row">
<span class="scope-name">${escapeHtml(displayName)}</span>
${tagHtml}
</span>
<span class="scope-desc">${escapeHtml(meta.description)}</span>
</label>
</div>