diff --git a/src/app.js b/src/app.js
index 17b15c44..93019c29 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1238,7 +1238,7 @@ systemStructureContext().then((site) => {
for (let systemRoute in systemRouteRegistry[systemMethod]) {
const systemRoutePath = `${systemApiV1BasePath}${systemRoute}`;
const systemRouteParser = getSystemV1RouteParser(systemMethod, systemRoute);
- const systemRouteHandler = (req, res, next) => {
+ const systemRouteHandler = async (req, res, next) => {
const op = req.route.path.replace(systemApiV1BasePath, '');
const rMethod = req.method.toLowerCase();
if (!validateSystemV1RouteAccess(req, op)) {
@@ -1263,6 +1263,12 @@ systemStructureContext().then((site) => {
if (!enforceSystemApiUserTokenPolicy(req, res, op, rMethod, basicAuth)) {
return;
}
+ // F2/Q14+F3: enforce site-token policy for 'authenticated-site'
+ // system routes (e.g. provider-search) so the router returns a
+ // consistent 403 envelope instead of the handler doing the check.
+ if (!await enforceSystemApiSiteTokenPolicy(req, res, op, rMethod, basicAuth)) {
+ return;
+ }
return systemRouteRegistry[rMethod][op](req, res, next);
}
// D1b status-code parity (matches site API + PHP SystemApiSecurity):
@@ -1280,7 +1286,7 @@ systemStructureContext().then((site) => {
data: { message: 'Authentication required' },
});
};
- const siteScopedSystemRouteHandler = (req, res, next) => {
+ const siteScopedSystemRouteHandler = async (req, res, next) => {
const op = req.route.path.replace(
`/${HAXCMS.sitesDirectory}/*${systemApiV1BasePath}`,
'',
@@ -1308,6 +1314,12 @@ systemStructureContext().then((site) => {
if (!enforceSystemApiUserTokenPolicy(req, res, op, rMethod, basicAuth)) {
return;
}
+ // F2/Q14+F3: enforce site-token policy for 'authenticated-site'
+ // system routes (e.g. provider-search) so the router returns a
+ // consistent 403 envelope instead of the handler doing the check.
+ if (!await enforceSystemApiSiteTokenPolicy(req, res, op, rMethod, basicAuth)) {
+ return;
+ }
return systemRouteRegistry[rMethod][op](req, res, next);
}
// D1b status-code parity (matches site API + PHP SystemApiSecurity):
@@ -1826,6 +1838,53 @@ function enforceSystemApiUserTokenPolicy(req, res, op, method, basicAuth) {
}
return true;
}
+// F2/Q14+F3: site-token enforcement for 'authenticated-site' system routes
+// (e.g. provider-search). Mirrors the site API's 'authenticated-site' policy
+// but runs in the system route handler so the 403 envelope is consistent
+// with the rest of the system API. The handler may still do semantic
+// siteName validation (e.g. generateAppStore) — this function only gates on
+// token presence + validity against the resolved siteName + userName.
+async function enforceSystemApiSiteTokenPolicy(req, res, op, method, basicAuth) {
+ const policy = getSystemApiRouteAuthPolicy(op, method);
+ if (policy !== 'authenticated-site') {
+ return true;
+ }
+ const siteToken = getRequestHeaderValue(req, 'x-haxcms-site-token');
+ if (siteToken === '') {
+ res.status(403).json({
+ status: 403,
+ data: { message: 'X-HAXCMS-Site-Token header is required for this endpoint' },
+ });
+ return false;
+ }
+ const userName = resolveSystemApiAuthenticatedUserName(req, basicAuth);
+ if (userName === '') {
+ res.status(403).json({
+ status: 403,
+ data: { message: 'Unable to resolve authenticated user context' },
+ });
+ return false;
+ }
+ const siteName = await resolveSiteApiRequestSiteName(req, {
+ userName: userName,
+ siteToken: siteToken,
+ });
+ if (!siteName) {
+ res.status(403).json({
+ status: 403,
+ data: { message: 'Unable to resolve site token context' },
+ });
+ return false;
+ }
+ if (!HAXCMS.validateRequestToken(siteToken, `${userName}:${siteName}`)) {
+ res.status(403).json({
+ status: 403,
+ data: { message: 'Invalid X-HAXCMS-Site-Token header' },
+ });
+ return false;
+ }
+ return true;
+}
function assertSiteApiMutationRoutesAreSecured(routeRegistry = null) {
const registry =
routeRegistry && typeof routeRegistry === 'object' ? routeRegistry : {};
diff --git a/src/lib/SystemRoutesMap.js b/src/lib/SystemRoutesMap.js
index 77f6dda0..7ac94bd2 100644
--- a/src/lib/SystemRoutesMap.js
+++ b/src/lib/SystemRoutesMap.js
@@ -245,12 +245,6 @@ addRouteHandler(
'skeletons/:skeletonName',
settingsRoutes.getSkeleton,
);
-addRouteHandler(
- SystemRoutesMap,
- 'post',
- 'skeletons/:skeletonName',
- settingsRoutes.getSkeleton,
-);
addRouteHandler(
SystemRoutesMap,
'patch',
@@ -301,7 +295,6 @@ const SystemV1OpenRoutes = [
'session/connection-settings',
'session/connection-test',
'integrations/app-store',
- 'integrations/app-store/providers/:provider/search',
'',
'openapi',
'openapi.json',
diff --git a/src/openapi/site-spec.yaml b/src/openapi/site-spec.yaml
index 0209cb23..65cc0102 100644
--- a/src/openapi/site-spec.yaml
+++ b/src/openapi/site-spec.yaml
@@ -1214,6 +1214,7 @@ paths:
- $ref: "#/components/parameters/FilterExtension"
- $ref: "#/components/parameters/FilterStartsWith"
- $ref: "#/components/parameters/FilterNameContains"
+ - $ref: "#/components/parameters/FileName"
- $ref: "#/components/parameters/PageLimit"
- $ref: "#/components/parameters/PageOffset"
- $ref: "#/components/parameters/Sort"
@@ -2188,6 +2189,16 @@ components:
required: false
schema:
type: string
+ FileName:
+ name: filename
+ in: query
+ required: false
+ schema:
+ type: string
+ description: >
+ Substring filter applied to both the file relative path and the file
+ name. When supplied, only files whose path or name contains the value
+ (case-insensitive) are returned.
FilterKind:
name: filter.kind
in: query
diff --git a/src/openapi/system-spec.yaml b/src/openapi/system-spec.yaml
index 0a3fa3c4..cfe4ef21 100644
--- a/src/openapi/system-spec.yaml
+++ b/src/openapi/system-spec.yaml
@@ -117,8 +117,11 @@ paths:
- session
operationId: sessionAuthGet
summary: Validate a JWT and return authenticated session details
- security:
- - bearerAuth: []
+ description: >
+ Anonymous login probe. Returns session details when a valid JWT is
+ supplied and a minimal unauthenticated response otherwise. No bearer
+ auth is required to reach this endpoint.
+ security: []
responses:
"200":
description: Authenticated session details
@@ -135,8 +138,11 @@ paths:
- session
operationId: sessionAuthPost
summary: Validate a JWT and return authenticated session details
- security:
- - bearerAuth: []
+ description: >
+ Anonymous login probe. Returns session details when a valid JWT is
+ supplied (body or query) and a minimal unauthenticated response
+ otherwise. No bearer auth is required to reach this endpoint.
+ security: []
requestBody:
required: false
content:
@@ -564,10 +570,20 @@ paths:
description: >
Proxies a search request to a registered app-store provider (e.g.
product-card, media, etc.) and returns the provider's response payload.
- Moved to the system API in D38; documented here per D57.
+ Moved to the system API in D38; documented here per D57. Requires a
+ valid bearer JWT and site token so only authenticated site contexts
+ can broker upstream provider searches.
parameters:
- $ref: "#/components/parameters/AppStoreProvider"
- security: []
+ - name: siteName
+ in: query
+ required: true
+ schema:
+ type: string
+ description: Site machine name used to validate site token scope
+ security:
+ - bearerAuth: []
+ siteTokenHeader: []
responses:
"200":
description: Provider search response payload
@@ -788,19 +804,20 @@ paths:
post:
tags:
- settings
- operationId: saveApiKeysPost
- summary: Update API key settings
+ operationId: getApiKeysPost
+ summary: Return configured API keys and provider statuses (read alias of GET)
+ description: >
+ Read-only alias of GET /configuration/api-keys. POST is accepted for
+ callers that prefer POST over GET but does not write; use PATCH to
+ update API key settings. Single-user deployment assumption: the
+ NodeJS backend does not model an admin/superUser tier; these settings
+ are written by the single authenticated dashboard user.
security:
- bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ApiKeysSettings"
+ userTokenHeader: []
responses:
"200":
- description: API key update response
+ description: API key settings
content:
application/json:
schema:
@@ -812,6 +829,11 @@ paths:
- settings
operationId: saveApiKeysPatch
summary: Update API key settings
+ description: >
+ Write operation — updates API key settings. Single-user deployment
+ assumption: the NodeJS backend does not model an admin/superUser
+ tier; settings are written by the single authenticated dashboard
+ user.
security:
- bearerAuth: []
userTokenHeader: []
@@ -851,19 +873,20 @@ paths:
post:
tags:
- settings
- operationId: saveMediaSettingsPost
- summary: Update media and upload configuration
+ operationId: getMediaSettingsPost
+ summary: Return media and upload configuration (read alias of GET)
+ description: >
+ Read-only alias of GET /configuration/media. POST is accepted for
+ callers that prefer POST over GET but does not write; use PATCH to
+ update media settings. Single-user deployment assumption: the
+ NodeJS backend does not model an admin/superUser tier; these settings
+ are written by the single authenticated dashboard user.
security:
- bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/MediaSettings"
+ userTokenHeader: []
responses:
"200":
- description: Media settings update response
+ description: Media settings
content:
application/json:
schema:
@@ -875,6 +898,11 @@ paths:
- settings
operationId: saveMediaSettingsPatch
summary: Update media and upload configuration
+ description: >
+ Write operation — updates media and upload configuration. Single-user
+ deployment assumption: the NodeJS backend does not model an
+ admin/superUser tier; settings are written by the single authenticated
+ dashboard user.
security:
- bearerAuth: []
userTokenHeader: []
@@ -899,6 +927,12 @@ paths:
- settings
operationId: schemaFileOperation
summary: Perform schema file operation in system configuration storage
+ description: >
+ Write operation — performs schema file operations (rename, delete,
+ upload) in system configuration storage. Single-user deployment
+ assumption: the NodeJS backend does not model an admin/superUser
+ tier; operations are performed by the single authenticated dashboard
+ user.
security:
- bearerAuth: []
userTokenHeader: []
@@ -925,6 +959,7 @@ paths:
summary: Return available system blocks
security:
- bearerAuth: []
+ userTokenHeader: []
responses:
"200":
description: Available block list
@@ -937,19 +972,20 @@ paths:
post:
tags:
- settings
- operationId: saveEnabledBlocksPost
- summary: Update enabled block configuration using the blocks collection endpoint
+ operationId: systemBlocksPost
+ summary: Return available system blocks (read alias of GET)
+ description: >
+ Read-only alias of GET /blocks. POST is accepted for callers that
+ prefer POST over GET but does not write; use PATCH to update enabled
+ block configuration. Single-user deployment assumption: the NodeJS
+ backend does not model an admin/superUser tier; these settings are
+ written by the single authenticated dashboard user.
security:
- bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EnabledCollectionSettings"
+ userTokenHeader: []
responses:
"200":
- description: Enabled collection update response
+ description: Available block list
content:
application/json:
schema:
@@ -961,6 +997,11 @@ paths:
- settings
operationId: saveEnabledBlocksPatch
summary: Update enabled block configuration using the blocks collection endpoint
+ description: >
+ Write operation — updates enabled block configuration. Single-user
+ deployment assumption: the NodeJS backend does not model an
+ admin/superUser tier; settings are written by the single authenticated
+ dashboard user.
security:
- bearerAuth: []
userTokenHeader: []
@@ -987,6 +1028,7 @@ paths:
summary: Return available system skeletons
security:
- bearerAuth: []
+ userTokenHeader: []
responses:
"200":
description: Available skeleton list
@@ -1000,27 +1042,23 @@ paths:
tags:
- settings
operationId: systemSkeletonsPost
- summary: Upload a skeleton resource or perform skeleton collection operations
+ summary: Return available system skeletons (read alias of GET)
+ description: >
+ Read-only alias of GET /skeletons. POST is accepted for callers that
+ prefer POST over GET but does not write; use PATCH to update enabled
+ skeleton configuration. Single-user deployment assumption: the NodeJS
+ backend does not model an admin/superUser tier; these settings are
+ written by the single authenticated dashboard user.
security:
- bearerAuth: []
- requestBody:
- required: false
- content:
- multipart/form-data:
- schema:
- $ref: "#/components/schemas/SkeletonUploadRequest"
- application/json:
- schema:
- $ref: "#/components/schemas/SkeletonMutationRequest"
+ userTokenHeader: []
responses:
"200":
- description: Skeleton resource operation response
+ description: Available skeleton list
content:
application/json:
schema:
$ref: "#/components/schemas/ApiEnvelope"
- "400":
- $ref: "#/components/responses/BadRequest"
"403":
$ref: "#/components/responses/Forbidden"
patch:
@@ -1029,6 +1067,11 @@ paths:
operationId: saveEnabledSkeletonsPatch
summary: Update enabled skeleton configuration using the skeletons collection
endpoint
+ description: >
+ Write operation — updates enabled skeleton configuration. Single-user
+ deployment assumption: the NodeJS backend does not model an
+ admin/superUser tier; settings are written by the single authenticated
+ dashboard user.
security:
- bearerAuth: []
userTokenHeader: []
@@ -1161,6 +1204,7 @@ paths:
summary: Return available system themes
security:
- bearerAuth: []
+ userTokenHeader: []
responses:
"200":
description: Available theme list
@@ -1173,19 +1217,20 @@ paths:
post:
tags:
- settings
- operationId: saveEnabledThemesPost
- summary: Update enabled theme configuration using the themes collection endpoint
+ operationId: systemThemesPost
+ summary: Return available system themes (read alias of GET)
+ description: >
+ Read-only alias of GET /themes. POST is accepted for callers that
+ prefer POST over GET but does not write; use PATCH to update enabled
+ theme configuration. Single-user deployment assumption: the NodeJS
+ backend does not model an admin/superUser tier; these settings are
+ written by the single authenticated dashboard user.
security:
- bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EnabledCollectionSettings"
+ userTokenHeader: []
responses:
"200":
- description: Enabled collection update response
+ description: Available theme list
content:
application/json:
schema:
@@ -1197,6 +1242,11 @@ paths:
- settings
operationId: saveEnabledThemesPatch
summary: Update enabled theme configuration using the themes collection endpoint
+ description: >
+ Write operation — updates enabled theme configuration. Single-user
+ deployment assumption: the NodeJS backend does not model an
+ admin/superUser tier; settings are written by the single authenticated
+ dashboard user.
security:
- bearerAuth: []
userTokenHeader: []
@@ -2448,13 +2498,30 @@ components:
type: string
LoginRequest:
type: object
+ description: >
+ Login credentials or JWT revalidation token. When username/password are
+ supplied the server validates them and issues a fresh JWT. When jwt is
+ supplied (body or query) the server revalidates the existing token and
+ returns it if still valid, enabling a revalidate-without-credentials
+ login flow.
properties:
+ username:
+ type: string
+ description: Username (primary field name accepted by v1 handlers)
+ password:
+ type: string
+ description: Password
u:
type: string
- description: Username
+ description: Legacy username alias (v0 compatibility)
p:
type: string
- description: Password
+ description: Legacy password alias (v0 compatibility)
+ jwt:
+ type: string
+ description: >
+ Existing JWT to revalidate. When supplied (body or query) the
+ server validates the token and returns it if still valid.
additionalProperties: true
SiteActionRequest:
type: object
diff --git a/src/siteRoutes/v1/blocks.js b/src/siteRoutes/v1/blocks.js
index c43e0fdc..38e299a7 100644
--- a/src/siteRoutes/v1/blocks.js
+++ b/src/siteRoutes/v1/blocks.js
@@ -340,7 +340,11 @@ async function listBlocks(req, res) {
const filterTag = String(getQueryValue(req, 'filter.tag', '') || '')
.trim()
.toLowerCase();
- const filteredItems = applyItemFilters(getOrderedItems(site), req, site);
+ // A4: enforce anon-visibility so anon callers don't see usage from
+ // unpublished/hidden items
+ const filteredItems = applyItemFilters(getOrderedItems(site), req, site, {
+ enforceAnonymousVisibility: true,
+ });
const usage = await collectCustomElementUsage(site, filteredItems);
const wcMap = HAXCMS.getWCRegistryJson(site);
const autoloader = getAutoloaderList();
@@ -419,7 +423,11 @@ async function blockDetail(req, res) {
const apiBasePath = getApiBasePath(req);
const include = getCsvQuery(req, 'include');
const fields = getCsvQuery(req, 'fields');
- const orderedItems = getOrderedItems(site);
+ // A4: enforce anon-visibility so anon callers don't see usage details from
+ // unpublished/hidden items
+ const orderedItems = applyItemFilters(getOrderedItems(site), req, site, {
+ enforceAnonymousVisibility: true,
+ });
const wcMap = HAXCMS.getWCRegistryJson(site);
const enabledBlocks = await readEnabledBlocksSetting();
const enabledBlockSet = new Set(Array.isArray(enabledBlocks) ? enabledBlocks : []);
@@ -489,7 +497,11 @@ async function blockUsage(req, res) {
}
const apiBasePath = getApiBasePath(req);
const fields = getCsvQuery(req, 'fields');
- const filteredItems = applyItemFilters(getOrderedItems(site), req, site);
+ // A4: enforce anon-visibility so anon callers don't see usage from
+ // unpublished/hidden items
+ const filteredItems = applyItemFilters(getOrderedItems(site), req, site, {
+ enforceAnonymousVisibility: true,
+ });
const usageTotals = await collectCustomElementUsage(site, filteredItems);
const wcMap = HAXCMS.getWCRegistryJson(site);
if (!isKnownBlockTag(webcomponentName, wcMap, usageTotals)) {
diff --git a/src/siteRoutes/v1/content.js b/src/siteRoutes/v1/content.js
index b84309f9..f5217013 100644
--- a/src/siteRoutes/v1/content.js
+++ b/src/siteRoutes/v1/content.js
@@ -19,6 +19,7 @@ const {
ensureRequestBodyObject,
getRequestHeaderValue,
getSiteNameFromResolvedSite,
+ escapeHtmlValue,
} = require('./siteRouteUtils.js');
const saveNodeRoute = require('./routes/saveNode.js');
const siteSearchRoute = require('./routes/siteSearch.js');
@@ -57,8 +58,14 @@ function buildConcatHtml(records = []) {
const sections = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
+ // E5: escape record.id and record.title to prevent output-injection from
+ // authored titles; record.body is intentional HTML and must not be escaped
+ const safeId = escapeHtmlValue(record.id || '');
+ const safeTitle = escapeHtmlValue(
+ record.title || record.slug || record.id || 'Untitled',
+ );
sections.push(
- `${record.title || record.slug || record.id || 'Untitled'}
${record.body || ''}`,
+ `${safeTitle}
${record.body || ''}`,
);
}
return sections.join('\n');
diff --git a/src/siteRoutes/v1/exports.js b/src/siteRoutes/v1/exports.js
index 5d0cb272..8341853e 100644
--- a/src/siteRoutes/v1/exports.js
+++ b/src/siteRoutes/v1/exports.js
@@ -746,8 +746,8 @@ async function siteExport(req, res) {
status: 400,
data: {
message: `Unsupported site export format "${format}"`,
+ supportedFormats: SITE_EXPORT_FORMATS,
},
- supportedFormats: SITE_EXPORT_FORMATS,
})
}
const ancestor = getQueryValue(req, 'filter.ancestor', '')
@@ -797,9 +797,14 @@ async function siteExport(req, res) {
if (format === 'html') {
try {
const html = await buildSiteExportHtml(site, ancestor, magic)
- res.status(200)
- res.setHeader('Content-Type', 'text/html; charset=utf-8')
- return res.send(html)
+ // E4: send html export as a file download with Content-Disposition
+ // (mirrors PHP sendFileDownload / other binary export formats)
+ return sendDownloadResponse(
+ res,
+ Buffer.from(html),
+ 'text/html; charset=utf-8',
+ `${getSiteExportFileBaseName(site)}.html`,
+ )
}
catch (e) {
return res.status(500).json({
@@ -871,8 +876,8 @@ async function itemExport(req, res) {
status: 400,
data: {
message: `Unsupported item export format "${format}"`,
+ supportedFormats: ITEM_EXPORT_FORMATS,
},
- supportedFormats: ITEM_EXPORT_FORMATS,
})
}
const apiBasePath = getApiBasePath(req)
@@ -1020,8 +1025,8 @@ async function siteExportMutation(req, res) {
status: 400,
data: {
message: `Unsupported site export format "${format}"`,
+ supportedFormats: SITE_EXPORT_FORMATS,
},
- supportedFormats: SITE_EXPORT_FORMATS,
});
}
const exportDetails = buildSiteExportDetails(site, apiBasePath, format);
diff --git a/src/siteRoutes/v1/files.js b/src/siteRoutes/v1/files.js
index c821f7e7..04482019 100644
--- a/src/siteRoutes/v1/files.js
+++ b/src/siteRoutes/v1/files.js
@@ -138,7 +138,8 @@ function getDateCreatedValue(entryStats) {
if (createdMs <= 0) {
return 0;
}
- return Math.round(createdMs);
+ // E1: dateCreated in SECONDS (matches metadata.updated), not milliseconds
+ return Math.floor(createdMs / 1000);
}
function getSiteNameForFileUuid(site) {
diff --git a/src/siteRoutes/v1/views.js b/src/siteRoutes/v1/views.js
index 4e343942..19a0f1a7 100644
--- a/src/siteRoutes/v1/views.js
+++ b/src/siteRoutes/v1/views.js
@@ -13,6 +13,8 @@ const {
normalizeTagList,
getItemContent,
sendFormattedResponse,
+ isAnonymousSiteApiRequest,
+ isItemVisibleToAnonymous,
} = require('./siteRouteUtils.js');
function normalizeStoredViews(site, apiBasePath) {
@@ -149,7 +151,11 @@ async function resolveViewResults(view, site, req, apiBasePath) {
: 'items';
if (source === 'tags') {
const tagMap = {};
- const items = getOrderedItems(site);
+ let items = getOrderedItems(site);
+ // A4: anon callers must not see tags from unpublished/hidden items
+ if (isAnonymousSiteApiRequest(req)) {
+ items = items.filter((item) => isItemVisibleToAnonymous(item));
+ }
for (let i = 0; i < items.length; i++) {
const item = items[i];
const tags = normalizeTagList(item && item.metadata ? item.metadata.tags : []);
@@ -157,10 +163,13 @@ async function resolveViewResults(view, site, req, apiBasePath) {
tagMap[tags[t]] = (tagMap[tags[t]] || 0) + 1;
}
}
- return Object.keys(tagMap).map((tag) => ({
+ let tagRecords = Object.keys(tagMap).map((tag) => ({
tag,
count: tagMap[tag],
}));
+ // E6: honor ?sort for tags source (default '-count'), matching PHP
+ tagRecords = sortRecords(tagRecords, getQueryValue(req, 'sort', ''), '-count');
+ return tagRecords;
}
if (source === 'search') {
const query =
@@ -170,7 +179,11 @@ async function resolveViewResults(view, site, req, apiBasePath) {
return [];
}
const queryLower = query.toLowerCase();
- const items = getOrderedItems(site);
+ let items = getOrderedItems(site);
+ // A4: anon callers must not search unpublished/hidden items
+ if (isAnonymousSiteApiRequest(req)) {
+ items = items.filter((item) => isItemVisibleToAnonymous(item));
+ }
const results = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
@@ -187,7 +200,11 @@ async function resolveViewResults(view, site, req, apiBasePath) {
}
let items = getOrderedItems(site);
items = applyViewQueryFilters(items, view.query);
- items = applyItemFilters(items, req, site);
+ // A4: pass anon-visibility enforcement so anon callers don't see
+ // unpublished/hidden items (same as /v1/search and /v1/items)
+ items = applyItemFilters(items, req, site, {
+ enforceAnonymousVisibility: true,
+ });
let records = items.map((item) => itemToSummary(item, apiBasePath));
const viewSort =
view && view.query && view.query.sort ? String(view.query.sort) : 'order';
diff --git a/src/systemRoutes/v1/routes/archiveSite.js b/src/systemRoutes/v1/routes/archiveSite.js
index afd5dcf8..f3930597 100644
--- a/src/systemRoutes/v1/routes/archiveSite.js
+++ b/src/systemRoutes/v1/routes/archiveSite.js
@@ -35,21 +35,35 @@ const { HAXCMS } = require('../../../lib/HAXCMS.js');
* )
* )
*/
- async function archiveSite(req, res) {
+ async function archiveSite(req, res) {
let site = await HAXCMS.loadSite(req.body['site']['name']);
if (site.name) {
// create archived directory in this tree if it doesn't exist already
if (!fs.existsSync(HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory)) {
fs.mkdirSync(HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory);
}
+ // D5: uniquify collided archive names (name-1, name-2…) so a bare
+ // rename doesn't fail when the destination already exists. Mirrors
+ // PHP routes/archiveSite.php:49-71.
+ const baseArchiveName = site.manifest.metadata.site.name;
+ let archivedName = baseArchiveName;
+ let counter = 1;
+ while (
+ fs.existsSync(
+ HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory + '/' + archivedName,
+ )
+ ) {
+ archivedName = baseArchiveName + '-' + counter;
+ counter++;
+ }
await fs.rename(
- HAXCMS.HAXCMS_ROOT + HAXCMS.sitesDirectory + '/' + site.manifest.metadata.site.name,
- HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory + '/' + site.manifest.metadata.site.name);
+ HAXCMS.HAXCMS_ROOT + HAXCMS.sitesDirectory + '/' + baseArchiveName,
+ HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory + '/' + archivedName);
res.send({
status: 200,
data: {
name: site.name,
- archivedName: site.name,
+ archivedName: archivedName,
detail: 'Site archived',
},
});
diff --git a/src/systemRoutes/v1/routes/connectionSettings.js b/src/systemRoutes/v1/routes/connectionSettings.js
index c6e279c3..f1382e93 100644
--- a/src/systemRoutes/v1/routes/connectionSettings.js
+++ b/src/systemRoutes/v1/routes/connectionSettings.js
@@ -136,6 +136,11 @@ function resolveSystemOperationPath(
* )
*/
async function connectionSettings(req, res) {
+ // D6: token/JWT-bearing JS — prevent any intermediary from caching
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ res.setHeader('Pragma', 'no-cache');
+ res.setHeader('Expires', '0');
+ res.setHeader('Surrogate-Control', 'no-store');
res.setHeader('Content-Type', 'application/javascript');
const isDashboardRequest = (
HAXCMS &&
diff --git a/src/systemRoutes/v1/routes/downloadSite.js b/src/systemRoutes/v1/routes/downloadSite.js
index f099ff96..fad0b6bd 100644
--- a/src/systemRoutes/v1/routes/downloadSite.js
+++ b/src/systemRoutes/v1/routes/downloadSite.js
@@ -123,7 +123,15 @@ function zipDirectory(sourceDir, outPath) {
return new Promise((resolve, reject) => {
archive
- .directory(sourceDir, false)
+ .glob('**/*', {
+ cwd: sourceDir,
+ ignore: [
+ 'node_modules/**',
+ 'node_modules',
+ '.git/**',
+ '.git',
+ ],
+ })
.on('error', err => reject(err))
.pipe(stream)
;
diff --git a/src/systemRoutes/v1/routes/getUserData.js b/src/systemRoutes/v1/routes/getUserData.js
index 57917aaf..31b5c2b6 100644
--- a/src/systemRoutes/v1/routes/getUserData.js
+++ b/src/systemRoutes/v1/routes/getUserData.js
@@ -16,20 +16,6 @@ const { HAXCMS } = require('../../../lib/HAXCMS.js');
* )
* )
*/
-function getUserTokenFromHeader(req) {
- if (!req || !req.headers || typeof req.headers !== 'object') {
- return '';
- }
- const rawValue = req.headers['x-haxcms-user-token'];
- if (Array.isArray(rawValue)) {
- return rawValue.length > 0 ? String(rawValue[0] || '').trim() : '';
- }
- if (typeof rawValue === 'string') {
- return rawValue.trim();
- }
- return '';
-}
-
function getUserData(req, res) {
const returnData = {
status: 200,
diff --git a/src/systemRoutes/v1/routes/login.js b/src/systemRoutes/v1/routes/login.js
index 0429ff3a..367ebab6 100644
--- a/src/systemRoutes/v1/routes/login.js
+++ b/src/systemRoutes/v1/routes/login.js
@@ -20,14 +20,25 @@ function loginRoute(req, res) {
if (retryAfterSeconds > 0) {
res.set('Retry-After', String(retryAfterSeconds));
}
- return res.sendStatus(429);
+ // D2: JSON D1 envelope (was sendStatus plain-text)
+ return res.status(429).json({
+ status: 429,
+ data: {
+ message:
+ 'Too many failed login attempts. Please try again later.',
+ },
+ });
}
// test if this is a valid user login
if (!HAXCMS.testLogin(u, p, true)) {
if (settings.enabled) {
registerFailedAttempt(entry, now, settings);
}
- return res.sendStatus(403);
+ // D2/Q8: login failure returns 401 (was 403) with JSON D1 envelope
+ return res.status(401).json({
+ status: 401,
+ data: { message: 'Invalid username or password' },
+ });
}
clearTrackerEntry(attemptKey);
// set a refresh_token COOKIE that will ship w/ all calls automatically
@@ -52,10 +63,18 @@ function loginRoute(req, res) {
jwt: valid,
});
}
- return res.sendStatus(403);
+ // D2/Q8: JWT revalidate failure returns 401 (was 403) with JSON envelope
+ return res.status(401).json({
+ status: 401,
+ data: { message: 'Invalid or expired token' },
+ });
}
else {
- res.sendStatus(403);
+ // D2/Q8: no credentials supplied returns 401 (was 403) with JSON envelope
+ res.status(401).json({
+ status: 401,
+ data: { message: 'Authentication required' },
+ });
}
}