Claude/analyze test coverage 9wv6 y#60
Conversation
- Add /login page with email/password authentication - Add /portal/* authenticated route tree with all modules: dashboard, services, cybersecurity (incidents/monitoring/compliance), real-estate (deals/documents), wealth (investments/retirement/qfs), legal (poa/estate), requests, users, audit - Add src/lib/auth with session management, RBAC, and preview users - Add src/lib/audit with file-based audit logging - Add portal API routes: auth/login, auth/logout, auth/session, portal/dashboard, portal/audit/export - Add middleware.ts protecting /portal/* with auth + RBAC - Add portal components: Sidebar, Topbar, Toast https://claude.ai/code/session_01SBUMqqQjy1X39Pwdg5oZmo
…e routes - Add 17 dashboard→portal permanent redirects - Add /privacy, /terms, /security convenience redirects - Preserves all existing public route redirects https://claude.ai/code/session_01SBUMqqQjy1X39Pwdg5oZmo
- Remove package-lock.json (pnpm-only workflow) - Add package-lock.json and data/audit-log.json to .gitignore - pnpm-lock.yaml already tracked https://claude.ai/code/session_01SBUMqqQjy1X39Pwdg5oZmo
…audit for denied routes - Extract PortalRole/PortalSession types to src/lib/auth/types.ts so client components (Sidebar) don't pull in node:crypto via session.ts - Fix logout route to derive redirect URL from request.url instead of hardcoded localhost fallback - Add denied-route audit logging when middleware redirects to /portal?denied=1 - Add metadata exports for /login and /portal layouts - Update all auth module imports to use types.ts for type-only imports https://claude.ai/code/session_01SBUMqqQjy1X39Pwdg5oZmo
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Reviewer's GuideImplements a preview-mode, role-based authenticated client portal under /portal with login/logout, middleware-based session + RBAC enforcement, local file–backed audit logging (including CSV export API), session/dashboard APIs, and a suite of mock data–driven portal pages; also wires legacy dashboard/legal URLs to the new portal routes via Next.js redirects. Sequence diagram for portal login with session cookie and audit loggingsequenceDiagram
actor User
participant LoginPage as LoginPage_next_app
participant AuthAPI as API_auth_login
participant AuthUsers as auth_users
participant Session as auth_session
participant Audit as audit_module
participant Browser as Browser_cookies
User->>LoginPage: Submit email,password
LoginPage->>AuthAPI: POST /api/auth/login { email,password }
AuthAPI->>AuthUsers: authenticatePortalUser(email,password)
alt valid_credentials
AuthUsers-->>AuthAPI: user
AuthAPI->>Session: createPortalToken(sessionData)
Session-->>AuthAPI: token
AuthAPI->>Audit: writeAuditEntry(login_success)
Audit-->>AuthAPI: AuditEntry
AuthAPI->>Browser: Set-Cookie gem_portal_session=token
AuthAPI-->>LoginPage: 200 { success,role,name }
LoginPage->>Browser: router.push(nextUrl)
LoginPage->>Browser: router.refresh()
else invalid_credentials
AuthUsers-->>AuthAPI: null
AuthAPI->>Audit: writeAuditEntry(login_failed)
Audit-->>AuthAPI: AuditEntry
AuthAPI-->>LoginPage: 401 { message Invalid credentials }
LoginPage-->>User: Show error Authentication failed
end
Sequence diagram for protected portal route access with RBAC and denied feedbacksequenceDiagram
actor User
participant Browser
participant Middleware as Next_middleware
participant Session as auth_session
participant RBAC as auth_rbac
participant PortalLayout as Portal_layout
participant PortalPage as Portal_page
participant Audit as audit_module
participant Toast as Toast_component
User->>Browser: Navigate to /portal/some_route
Browser->>Middleware: HTTP request /portal/some_route
Middleware->>Session: readPortalToken(cookie)
Session-->>Middleware: session or null
alt no_session
Middleware->>Browser: Redirect /login?next=/portal/some_route
else has_session
Middleware->>RBAC: isRouteAllowed(pathname,session.role)
alt route_allowed
RBAC-->>Middleware: true
Middleware->>Browser: NextResponse.next()
Browser->>PortalLayout: Render /portal layout
PortalLayout->>Session: getCurrentPortalSession()
Session-->>PortalLayout: session
PortalLayout->>PortalPage: Render child page
else route_denied
RBAC-->>Middleware: false
Middleware->>Browser: Redirect /portal?denied=1
Browser->>PortalLayout: Render /portal?denied=1
PortalLayout->>Session: getCurrentPortalSession()
Session-->>PortalLayout: session
PortalLayout->>PortalPage: Render portal dashboard
PortalPage->>Audit: writeAuditEntry(route_denied)
Audit-->>PortalPage: AuditEntry
PortalLayout->>Toast: Render Toast
Toast-->>User: Show Access denied toast
end
end
Entity relationship diagram for portal users and audit entrieserDiagram
PortalUser {
string id
string email
string name
string role
string passwordHash
boolean isActive
}
AuditEntry {
string id
string timestamp
string actorUserId
string actorEmail
string action
string target
string result
json meta
}
PortalUser ||--o{ AuditEntry : generates
Class diagram for portal auth and audit modulesclassDiagram
class PortalSession {
string userId
string email
string name
PortalRole role
number exp
}
class PortalUser {
string id
string email
string name
PortalRole role
string passwordHash
boolean isActive
}
class AuditEntry {
string id
string timestamp
string actorUserId
string actorEmail
string action
string target
Record_meta meta
string result
}
class AuthTypes {
<<module>>
PortalRole
PortalSession
}
class AuthSession {
<<module>>
+string PORTAL_COOKIE
+string authSecret()
+string sign(payload)
+string createPortalToken(session)
+PortalSession readPortalToken(token)
+PortalSession getCurrentPortalSession()
}
class AuthUsers {
<<module>>
+PortalUser[] previewUsers
+PortalUser authenticatePortalUser(email,password)
+PortalUser[] listPortalUsers()
+PortalUser getPortalUser(id)
}
class AuthRBAC {
<<module>>
+Record_routePermissions routePermissions
+boolean isRouteAllowed(pathname,role)
+NavItem[] getAllowedNavItems(role)
}
class AuditModule {
<<module>>
+string AUDIT_FILE
+AuditEntry writeAuditEntry(entry)
+AuditEntry[] getAuditEntries(opts)
+AuditEntry[] exportAuditLog()
}
class NavItem {
string href
string label
PortalRole[] roles
}
class MiddlewareModule {
<<middleware>>
+middleware(request)
+config matcher
}
class ApiAuthLoginRoute {
<<route>>
+POST(request)
}
class ApiAuthLogoutRoute {
<<route>>
+POST(request)
}
class ApiAuthSessionRoute {
<<route>>
+GET()
}
class ApiPortalDashboardRoute {
<<route>>
+GET()
}
class ApiPortalAuditExportRoute {
<<route>>
+GET()
}
AuthTypes <|-- PortalSession
AuthTypes <|-- PortalRole
AuthSession --> PortalSession
AuthSession --> PortalRole
AuthUsers --> PortalUser
AuthUsers --> PortalRole
AuthRBAC --> PortalRole
AuthRBAC --> NavItem
AuditModule --> AuditEntry
MiddlewareModule --> AuthSession
MiddlewareModule --> AuthRBAC
ApiAuthLoginRoute --> AuthUsers
ApiAuthLoginRoute --> AuthSession
ApiAuthLoginRoute --> AuditModule
ApiAuthLogoutRoute --> AuthSession
ApiAuthLogoutRoute --> AuditModule
ApiAuthSessionRoute --> AuthSession
ApiPortalDashboardRoute --> AuthSession
ApiPortalAuditExportRoute --> AuthSession
ApiPortalAuditExportRoute --> AuditModule
Architecture flow diagram for portal routing, middleware, and APIsflowchart LR
subgraph BrowserLayer[Browser]
B_User["User browser"]
end
subgraph NextRouter[Next_js_routing]
R_Redirects["next.config redirects<br>/dashboard -> /portal<br>/privacy -> /legal/privacy-policy"]
R_Middleware["middleware<br>protect /portal/*"]
end
subgraph PortalApp[Portal_application]
L_LoginPage["/login page"]
L_PortalLayout["/portal layout<br>Topbar + Sidebar + Toast"]
P_Pages["Portal child pages<br>Dashboard, Services, Cybersecurity,<br>RealEstate, Wealth, Legal,<br>Requests, Users, Audit"]
end
subgraph AuthLib[Auth_library]
A_Session["auth_session<br>createPortalToken<br>readPortalToken<br>getCurrentPortalSession"]
A_RBAC["auth_rbac<br>isRouteAllowed<br>getAllowedNavItems"]
A_Users["auth_users<br>previewUsers store"]
end
subgraph AuditLib[Audit_library]
AU_Module["audit_module<br>writeAuditEntry<br>getAuditEntries<br>exportAuditLog"]
AU_File["audit-log.json<br>local file store"]
end
subgraph ApiRoutes[API_routes]
API_Login["POST /api/auth/login"]
API_Logout["POST /api/auth/logout"]
API_Session["GET /api/auth/session"]
API_Dashboard["GET /api/portal/dashboard"]
API_AuditExport["GET /api/portal/audit/export"]
end
B_User --> R_Redirects
R_Redirects --> R_Middleware
R_Middleware --> L_LoginPage
R_Middleware --> L_PortalLayout
L_PortalLayout --> P_Pages
B_User --> API_Login
B_User --> API_Logout
B_User --> API_Session
B_User --> API_Dashboard
B_User --> API_AuditExport
API_Login --> A_Users
API_Login --> A_Session
API_Login --> AU_Module
API_Logout --> A_Session
API_Logout --> AU_Module
API_Session --> A_Session
API_Dashboard --> A_Session
API_AuditExport --> A_Session
API_AuditExport --> AU_Module
AU_Module --> AU_File
R_Middleware --> A_Session
R_Middleware --> A_RBAC
L_PortalLayout --> A_Session
P_Pages --> AU_Module
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- In
readPortalToken,crypto.timingSafeEqualwill throw if the provided signature length differs from the expected one; consider checking the lengths first and early-returningnullto avoid runtime errors on malformed tokens. - The CSV export in
src/app/api/portal/audit/export/route.tsjoins raw values with commas without quoting or escaping, which will break if any field (e.g.,targetoraction) contains commas, newlines, or quotes; wrapping and escaping fields according to CSV rules would make the export more robust. - The file-based audit log in
src/lib/audit/index.tsuses synchronous fs operations and no locking, which can behave poorly under concurrent requests or in serverless environments; switching to async I/O and/or a more concurrency-friendly storage mechanism (even for preview mode) would make this more reliable.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `readPortalToken`, `crypto.timingSafeEqual` will throw if the provided signature length differs from the expected one; consider checking the lengths first and early-returning `null` to avoid runtime errors on malformed tokens.
- The CSV export in `src/app/api/portal/audit/export/route.ts` joins raw values with commas without quoting or escaping, which will break if any field (e.g., `target` or `action`) contains commas, newlines, or quotes; wrapping and escaping fields according to CSV rules would make the export more robust.
- The file-based audit log in `src/lib/audit/index.ts` uses synchronous fs operations and no locking, which can behave poorly under concurrent requests or in serverless environments; switching to async I/O and/or a more concurrency-friendly storage mechanism (even for preview mode) would make this more reliable.
## Individual Comments
### Comment 1
<location path="src/app/api/portal/audit/export/route.ts" line_range="22-25" />
<code_context>
+ result: 'success',
+ });
+
+ const header = 'id,timestamp,actorEmail,action,target,result\n';
+ const rows = entries.map((e) =>
+ [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result].join(',')
+ ).join('\n');
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Escape CSV fields to handle commas, quotes, or newlines safely in audit data.
Using `join(',')` means any commas, quotes, or newlines in fields (now or in future fields like `meta`) will corrupt the CSV and can enable CSV injection when opened in spreadsheets.
Wrap each field with proper CSV escaping instead, for example:
```ts
const escape = (value: string) => {
const v = String(value).replace(/"/g, '""');
return `"${v}"`;
};
const header = 'id,timestamp,actorEmail,action,target,result\n';
const rows = entries
.map((e) => [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result]
.map(escape)
.join(','))
.join('\n');
```
This makes the CSV robust and safer to consume in spreadsheet tools.
```suggestion
const escapeCsv = (value: unknown) => {
const v = String(value).replace(/"/g, '""');
return `"${v}"`;
};
const header = 'id,timestamp,actorEmail,action,target,result\n';
const rows = entries
.map((e) =>
[e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result]
.map(escapeCsv)
.join(',')
)
.join('\n');
```
</issue_to_address>
### Comment 2
<location path="src/app/portal/page.tsx" line_range="4-10" />
<code_context>
+
+export default async function PortalDashboard({
+ searchParams,
+}: {
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
+}) {
+ const session = await getCurrentPortalSession();
</code_context>
<issue_to_address>
**suggestion:** Type `searchParams` as the concrete object instead of a Promise to better match Next’s route signature.
Here `searchParams` is typed as `Promise<...>` and then awaited, but Next.js route components receive it as a plain object. This doesn’t break runtime behavior, but it misrepresents the API and can cause type-checking friction.
Type it as a plain object instead, e.g.:
```ts
export default async function PortalDashboard({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
// use searchParams directly
}
```
```suggestion
export default async function PortalDashboard({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
const session = await getCurrentPortalSession();
const params = searchParams;
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| const header = 'id,timestamp,actorEmail,action,target,result\n'; | ||
| const rows = entries.map((e) => | ||
| [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result].join(',') | ||
| ).join('\n'); |
There was a problem hiding this comment.
🚨 suggestion (security): Escape CSV fields to handle commas, quotes, or newlines safely in audit data.
Using join(',') means any commas, quotes, or newlines in fields (now or in future fields like meta) will corrupt the CSV and can enable CSV injection when opened in spreadsheets.
Wrap each field with proper CSV escaping instead, for example:
const escape = (value: string) => {
const v = String(value).replace(/"/g, '""');
return `"${v}"`;
};
const header = 'id,timestamp,actorEmail,action,target,result\n';
const rows = entries
.map((e) => [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result]
.map(escape)
.join(','))
.join('\n');This makes the CSV robust and safer to consume in spreadsheet tools.
| const header = 'id,timestamp,actorEmail,action,target,result\n'; | |
| const rows = entries.map((e) => | |
| [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result].join(',') | |
| ).join('\n'); | |
| const escapeCsv = (value: unknown) => { | |
| const v = String(value).replace(/"/g, '""'); | |
| return `"${v}"`; | |
| }; | |
| const header = 'id,timestamp,actorEmail,action,target,result\n'; | |
| const rows = entries | |
| .map((e) => | |
| [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result] | |
| .map(escapeCsv) | |
| .join(',') | |
| ) | |
| .join('\n'); |
| if (!encoded || !signature) return null; | ||
|
|
||
| const expected = sign(encoded); | ||
| if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { |
There was a problem hiding this comment.
🔴 timingSafeEqual crashes on malformed token signatures with mismatched buffer lengths
crypto.timingSafeEqual() at src/lib/auth/session.ts:32 throws RangeError: Input buffers must have the same byte length when the signature portion of the token is not exactly 64 hex characters. The HMAC-SHA256 expected digest is always 64 hex chars, but an attacker-supplied or corrupted cookie can have a signature of any length. Since there's no try/catch around this call in readPortalToken, the exception propagates to all callers: the middleware (middleware.ts:15), getCurrentPortalSession (session.ts:48), and every route handler using it. This results in a 500 Internal Server Error for any /portal/* request when the gem_portal_session cookie contains a syntactically valid (has a .) but non-standard-length signature.
| if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { | |
| const sigBuf = Buffer.from(signature); | |
| const expBuf = Buffer.from(expected); | |
| if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { |
Was this helpful? React with 👍 or 👎 to provide feedback.
| <aside className="bg-slate-900 border border-slate-800 rounded-xl p-3 h-fit sticky top-20"> | ||
| <nav className="space-y-1"> | ||
| {items.map((item) => { | ||
| const isActive = pathname === item.href || pathname.startsWith(item.href + '/'); |
There was a problem hiding this comment.
🟡 Sidebar 'Dashboard' nav item is always highlighted as active on every portal sub-page
In src/components/portal/Sidebar.tsx:20, the isActive check is pathname === item.href || pathname.startsWith(item.href + '/'). For the Dashboard item with href='/portal', this means pathname.startsWith('/portal/') is true for every portal sub-route (e.g., /portal/cybersecurity, /portal/audit). As a result, the Dashboard link always has the active styling alongside whichever other nav item is actually active, making the navigation state misleading.
| const isActive = pathname === item.href || pathname.startsWith(item.href + '/'); | |
| const isActive = item.href === '/portal' ? pathname === '/portal' : (pathname === item.href || pathname.startsWith(item.href + '/')); |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 63648f4091
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!encoded || !signature) return null; | ||
|
|
||
| const expected = sign(encoded); | ||
| if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { |
There was a problem hiding this comment.
Guard timingSafeEqual against mismatched signature lengths
readPortalToken passes user-controlled cookie data directly into crypto.timingSafeEqual, which throws if the two buffers have different lengths. A malformed gem_portal_session cookie (truncated/extended signature) will therefore raise instead of returning null, causing middleware/API auth checks to fail with 500 rather than treating the request as unauthenticated.
Useful? React with 👍 / 👎.
|
|
||
| const header = 'id,timestamp,actorEmail,action,target,result\n'; | ||
| const rows = entries.map((e) => | ||
| [e.id, e.timestamp, e.actorEmail || '', e.action, e.target, e.result].join(',') |
There was a problem hiding this comment.
Escape audit CSV fields before concatenating rows
The export endpoint emits CSV rows by joining raw values with commas, but actorEmail is attacker-controlled via failed login attempts and can include commas, newlines, or spreadsheet formulas. That allows a malicious login payload to poison exported files (row/column corruption) and can trigger formula execution when an admin opens the CSV in Excel/Sheets.
Useful? React with 👍 / 👎.
| }); | ||
| } | ||
|
|
||
| const response = NextResponse.redirect(new URL('/login', request.url)); |
There was a problem hiding this comment.
Use See Other redirect for logout POST handler
NextResponse.redirect defaults to HTTP 307, which preserves the POST method. Submitting the logout form therefore redirects as POST /login instead of GET /login, which can surface a 405 page after logout on standard page routes; returning a 303/302 avoids this by forcing a GET navigation.
Useful? React with 👍 / 👎.
- Replace filesystem audit logging (fs/path) with in-memory store - Replace middleware with edge-safe version (no crypto/fs imports) - Add metadata export to src/app/login/page.tsx and src/app/portal/page.tsx - Extract login form into client component so page.tsx can be a server component with metadata - tsconfig path alias @/* -> src/* already correct - Auth already reads process.env.PORTAL_AUTH_SECRET https://claude.ai/code/session_01SBUMqqQjy1X39Pwdg5oZmo
| import type { NextRequest } from "next/server"; | ||
|
|
||
| export function middleware(req: NextRequest) { | ||
| const session = req.cookies.get("portal_session") ?? req.cookies.get("gem_portal_session"); |
There was a problem hiding this comment.
🔴 Middleware checks non-existent cookie name 'portal_session', allowing trivial auth guard bypass
The middleware at middleware.ts:5 checks for "portal_session" first via the ?? operator, but the actual cookie set by the login API is "gem_portal_session" (defined as PORTAL_COOKIE in src/lib/auth/session.ts:7). A cookie named "portal_session" is never set by the application. An unauthenticated user can set a dummy portal_session=anything cookie in their browser, which makes the ?? short-circuit with a truthy cookie object, bypassing the middleware's redirect-to-login guard entirely. While the server-side layout performs a secondary session check, this renders the middleware protection ineffective.
| const session = req.cookies.get("portal_session") ?? req.cookies.get("gem_portal_session"); | |
| const session = req.cookies.get("gem_portal_session"); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| export default async function PortalLayout({ children }: { children: ReactNode }) { | ||
| const session = await getCurrentPortalSession(); | ||
|
|
||
| if (!session) { | ||
| redirect('/login'); | ||
| } |
There was a problem hiding this comment.
🔴 RBAC route permissions are defined but never enforced — any authenticated user can access admin-only pages
The isRouteAllowed function in src/lib/auth/rbac.ts:24 defines a role-based permission matrix (e.g., /portal/audit and /portal/users are admin-only), but it is never called anywhere in the codebase — not in the middleware, not in the portal layout, and not in individual page components. This means any authenticated user (including viewer role) can directly access admin-only pages like /portal/audit (which displays the full audit log) and /portal/users (which lists all users). The sidebar correctly hides these links via getAllowedNavItems, but the routes themselves are unprotected.
Prompt for agents
In src/app/portal/layout.tsx, after obtaining the session (line 16), add an RBAC check before rendering children. Import isRouteAllowed from @/lib/auth/rbac and the headers function to get the current pathname (or pass it another way). If isRouteAllowed(pathname, session.role) returns false, redirect the user to /portal?denied=1. Alternatively, add this check in the middleware.ts by decoding and validating the token and calling isRouteAllowed there. Every portal sub-page that has restricted access in the routePermissions map (e.g., /portal/audit, /portal/users, /portal/cybersecurity, /portal/wealth/qfs) must be protected.
Was this helpful? React with 👍 or 👎 to provide feedback.
- Replace audit logger with in-memory store (no fs/path imports) - Simplify middleware to edge-safe cookie check (no crypto/db imports) - Add baseUrl to tsconfig.json, normalize @/* path alias - Set metadata title to "Client Portal" on login and portal pages - Add compress: true, poweredByHeader: false to next.config.ts - Update all audit callers to new audit()/listAuditEvents() API https://claude.ai/code/session_01SBUMqqQjy1X39Pwdg5oZmo
| event.preventDefault(); | ||
| setLoading(true); | ||
| setError(null); | ||
| const nextUrl = searchParams.get('next') || '/portal'; |
There was a problem hiding this comment.
🔴 Open redirect via unvalidated next query parameter on login
The login form reads searchParams.get('next') and passes it directly to router.push(nextUrl) without any validation. An attacker can craft a login link like /login?next=https://evil.com to redirect users to a malicious site after they authenticate. The next parameter should be validated to ensure it's a relative path starting with /.
| const nextUrl = searchParams.get('next') || '/portal'; | |
| const rawNext = searchParams.get('next') || '/portal'; | |
| const nextUrl = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/portal'; |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary by Sourcery
Introduce a preview-mode authenticated client portal with role-based access control, audit logging, and supporting UI/navigation for multiple portal sections.
New Features:
Enhancements: