@prenta/core 7.7.0 → 7.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/api/route-helpers/auth.d.ts +131 -0
- package/dist/api/route-helpers/auth.d.ts.map +1 -0
- package/dist/api/route-helpers/auth.js +505 -0
- package/dist/api/route-helpers/auth.js.map +1 -0
- package/dist/api/route-helpers/authoring.d.ts +32 -0
- package/dist/api/route-helpers/authoring.d.ts.map +1 -0
- package/dist/api/route-helpers/authoring.js +99 -0
- package/dist/api/route-helpers/authoring.js.map +1 -0
- package/dist/api/route-helpers/forms.d.ts +46 -0
- package/dist/api/route-helpers/forms.d.ts.map +1 -0
- package/dist/api/route-helpers/forms.js +435 -0
- package/dist/api/route-helpers/forms.js.map +1 -0
- package/dist/api/route-helpers/http.d.ts +87 -0
- package/dist/api/route-helpers/http.d.ts.map +1 -0
- package/dist/api/route-helpers/http.js +286 -0
- package/dist/api/route-helpers/http.js.map +1 -0
- package/dist/api/route-helpers/media.d.ts +37 -0
- package/dist/api/route-helpers/media.d.ts.map +1 -0
- package/dist/api/route-helpers/media.js +189 -0
- package/dist/api/route-helpers/media.js.map +1 -0
- package/dist/api/route-helpers/page-shape.d.ts +31 -0
- package/dist/api/route-helpers/page-shape.d.ts.map +1 -0
- package/dist/api/route-helpers/page-shape.js +98 -0
- package/dist/api/route-helpers/page-shape.js.map +1 -0
- package/dist/api/route-helpers.d.ts +8 -358
- package/dist/api/route-helpers.d.ts.map +1 -1
- package/dist/api/route-helpers.js +7 -1592
- package/dist/api/route-helpers.js.map +1 -1
- package/dist/api/routes/seo/handlers/analysis.d.ts +4 -0
- package/dist/api/routes/seo/handlers/analysis.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/analysis.js +131 -0
- package/dist/api/routes/seo/handlers/analysis.js.map +1 -0
- package/dist/api/routes/seo/handlers/audits.d.ts +5 -0
- package/dist/api/routes/seo/handlers/audits.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/audits.js +121 -0
- package/dist/api/routes/seo/handlers/audits.js.map +1 -0
- package/dist/api/routes/seo/handlers/content.d.ts +4 -0
- package/dist/api/routes/seo/handlers/content.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/content.js +330 -0
- package/dist/api/routes/seo/handlers/content.js.map +1 -0
- package/dist/api/routes/seo/handlers/issue-helpers.d.ts +37 -0
- package/dist/api/routes/seo/handlers/issue-helpers.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issue-helpers.js +72 -0
- package/dist/api/routes/seo/handlers/issue-helpers.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-briefs.d.ts +7 -0
- package/dist/api/routes/seo/handlers/issues-briefs.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-briefs.js +341 -0
- package/dist/api/routes/seo/handlers/issues-briefs.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-fixes.d.ts +4 -0
- package/dist/api/routes/seo/handlers/issues-fixes.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-fixes.js +233 -0
- package/dist/api/routes/seo/handlers/issues-fixes.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-list.d.ts +7 -0
- package/dist/api/routes/seo/handlers/issues-list.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-list.js +191 -0
- package/dist/api/routes/seo/handlers/issues-list.js.map +1 -0
- package/dist/api/routes/seo/handlers/links.d.ts +6 -0
- package/dist/api/routes/seo/handlers/links.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/links.js +383 -0
- package/dist/api/routes/seo/handlers/links.js.map +1 -0
- package/dist/api/routes/seo/handlers/overview.d.ts +4 -0
- package/dist/api/routes/seo/handlers/overview.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/overview.js +348 -0
- package/dist/api/routes/seo/handlers/overview.js.map +1 -0
- package/dist/api/routes/seo/handlers/pages.d.ts +2 -0
- package/dist/api/routes/seo/handlers/pages.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/pages.js +38 -0
- package/dist/api/routes/seo/handlers/pages.js.map +1 -0
- package/dist/api/routes/seo/handlers/proposals.d.ts +4 -0
- package/dist/api/routes/seo/handlers/proposals.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/proposals.js +93 -0
- package/dist/api/routes/seo/handlers/proposals.js.map +1 -0
- package/dist/api/routes/seo/handlers/public-files.d.ts +6 -0
- package/dist/api/routes/seo/handlers/public-files.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/public-files.js +328 -0
- package/dist/api/routes/seo/handlers/public-files.js.map +1 -0
- package/dist/api/routes/seo/handlers/shared.d.ts +24 -0
- package/dist/api/routes/seo/handlers/shared.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/shared.js +67 -0
- package/dist/api/routes/seo/handlers/shared.js.map +1 -0
- package/dist/api/routes/seo.d.ts +5 -5
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +56 -2610
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/api/routes/users/handlers/api-keys.d.ts +4 -0
- package/dist/api/routes/users/handlers/api-keys.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/api-keys.js +164 -0
- package/dist/api/routes/users/handlers/api-keys.js.map +1 -0
- package/dist/api/routes/users/handlers/invites.d.ts +7 -0
- package/dist/api/routes/users/handlers/invites.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/invites.js +201 -0
- package/dist/api/routes/users/handlers/invites.js.map +1 -0
- package/dist/api/routes/users/handlers/users-admin.d.ts +7 -0
- package/dist/api/routes/users/handlers/users-admin.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/users-admin.js +310 -0
- package/dist/api/routes/users/handlers/users-admin.js.map +1 -0
- package/dist/api/routes/users/handlers/users-detail.d.ts +16 -0
- package/dist/api/routes/users/handlers/users-detail.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/users-detail.js +525 -0
- package/dist/api/routes/users/handlers/users-detail.js.map +1 -0
- package/dist/api/routes/users.d.ts +4 -0
- package/dist/api/routes/users.d.ts.map +1 -1
- package/dist/api/routes/users.js +38 -1177
- package/dist/api/routes/users.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @actuate-media/cms-core
|
|
2
2
|
|
|
3
|
+
## 7.7.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 23fbe86: Split route helpers and user route registration into smaller modules. HTTP behavior is unchanged.
|
|
8
|
+
|
|
9
|
+
## 7.7.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3343dac: Split SEO route registration into per-area handlers. HTTP behavior is unchanged.
|
|
14
|
+
|
|
3
15
|
## 7.7.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ActionContext } from '../../actions.js';
|
|
2
|
+
import { type TeamOptions } from '../../auth/team.js';
|
|
3
|
+
import { type ApiKeyScope } from '../../security/api-key-enhanced.js';
|
|
4
|
+
import type { PrentaDB } from '../../db/client-types.js';
|
|
5
|
+
export declare function getSessionSecret(): string;
|
|
6
|
+
export declare function isSecretMissing(): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Authenticated request context. `apiKey` is set when the request was
|
|
9
|
+
* authenticated via an `act_sk_*` API key (rather than a user session) — in
|
|
10
|
+
* that case `sessionId` is the API key id and `role` reflects either the
|
|
11
|
+
* granted `admin` scope or `'API_KEY'` for scoped keys.
|
|
12
|
+
*/
|
|
13
|
+
export type AuthSession = {
|
|
14
|
+
userId: string;
|
|
15
|
+
role: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
apiKey?: {
|
|
18
|
+
id: string;
|
|
19
|
+
scopes: ApiKeyScope;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function extractSession(request: Request): Promise<AuthSession | null>;
|
|
23
|
+
export declare function parseCookieHeader(cookieHeader: string): Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Verify a password (or "current password") posted in the X-Reauth-Password
|
|
26
|
+
* header against the authenticated user. Returns an error Response when the
|
|
27
|
+
* header is missing or the password is wrong; returns null on success.
|
|
28
|
+
*
|
|
29
|
+
* Use this for high-impact account changes (TOTP enable/disable, role change,
|
|
30
|
+
* delete user, etc.) so a stolen session alone cannot perform them.
|
|
31
|
+
*/
|
|
32
|
+
export declare function requirePasswordReauth(request: Request, userId: string): Promise<Response | null>;
|
|
33
|
+
export declare function requireAuth(request: Request): Promise<{
|
|
34
|
+
session: AuthSession;
|
|
35
|
+
error?: never;
|
|
36
|
+
} | {
|
|
37
|
+
session?: never;
|
|
38
|
+
error: Response;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* API-key collection-scope check as a plain denial reason (`null` = allowed).
|
|
42
|
+
* JWT sessions are never denied here — role checks are the route's job. Use
|
|
43
|
+
* this where the collection is only known deep inside a service (e.g. an SEO
|
|
44
|
+
* fix that writes a document) and a `Response` cannot be returned directly.
|
|
45
|
+
*/
|
|
46
|
+
export declare function apiKeyCollectionScopeDenial(session: AuthSession, collection: string, action: 'read' | 'create' | 'update' | 'delete'): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Check that the request's auth context permits `action` on `collection`.
|
|
49
|
+
* - Session-authenticated requests fall through to `requireRole` semantics:
|
|
50
|
+
* write actions require WRITE_ROLES.
|
|
51
|
+
* - API-key-authenticated requests must have an explicit scope match.
|
|
52
|
+
*/
|
|
53
|
+
export declare function requireCollectionScope(session: AuthSession, collection: string, action: 'read' | 'create' | 'update' | 'delete'): Response | null;
|
|
54
|
+
export declare function requireGlobalScope(session: AuthSession, slug: string): Response | null;
|
|
55
|
+
export declare function requireMediaScope(session: AuthSession): Response | null;
|
|
56
|
+
export declare function requireAdminScope(session: AuthSession): Response | null;
|
|
57
|
+
export declare function buildActionContext(session: {
|
|
58
|
+
userId: string;
|
|
59
|
+
role: string;
|
|
60
|
+
}, db: PrentaDB, locale?: string): ActionContext;
|
|
61
|
+
export declare const WRITE_ROLES: Set<string>;
|
|
62
|
+
export declare const ADMIN_ROLES: Set<string>;
|
|
63
|
+
export declare function requireRole(role: string, allowedRoles: Set<string>): Response | null;
|
|
64
|
+
/**
|
|
65
|
+
* Build the shared {@link TeamOptions} for the Users endpoints from config +
|
|
66
|
+
* session: the workspace domain (for external-domain detection) and the seat
|
|
67
|
+
* cap (config-provided; null = unlimited until billing exists).
|
|
68
|
+
*/
|
|
69
|
+
export declare function teamOptionsFor(session: AuthSession): TeamOptions;
|
|
70
|
+
/**
|
|
71
|
+
* DB-aware variant of {@link isEmailConfigured} — also true when a provider
|
|
72
|
+
* is configured through Settings > Email.
|
|
73
|
+
*/
|
|
74
|
+
export declare function isEmailConfiguredAsync(db?: unknown): Promise<boolean>;
|
|
75
|
+
export declare const loginLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
76
|
+
export declare const loginUnknownLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
77
|
+
/** Per-email-hash login limiter so account protection does not depend on IP. */
|
|
78
|
+
export declare const loginEmailLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
79
|
+
export declare const inviteLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
80
|
+
export declare const totpLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
81
|
+
export declare const emailTestLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
82
|
+
export declare const aiGenerateLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
83
|
+
export declare const uploadLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
84
|
+
export declare const aiAnalysisLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
85
|
+
export declare const AI_ANALYSIS_MAX_CHARS = 50000;
|
|
86
|
+
export declare const linkHealthLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
87
|
+
export declare const seoAuditLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
88
|
+
export declare const searchConsoleTestLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
89
|
+
export declare const searchSyncLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
90
|
+
export declare const digestTestSendLimiter: import("../../security/rate-limit.js").RateLimiter;
|
|
91
|
+
export declare function checkRateLimitAsync(limiter: {
|
|
92
|
+
check: (key: string) => Promise<{
|
|
93
|
+
allowed: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
}, key: string): Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
|
|
98
|
+
* then x-forwarded-for only when PRENTA_TRUST_PROXY=1). Returns 'unknown' when
|
|
99
|
+
* no trustworthy source is available — callers MUST treat that case as a hard
|
|
100
|
+
* failure for security-sensitive decisions like rate-limit keys and IP allowlists.
|
|
101
|
+
*/
|
|
102
|
+
export declare function clientIp(request: Request): string;
|
|
103
|
+
/**
|
|
104
|
+
* Compute a before/after diff of top-level scalar (string/number/boolean) keys
|
|
105
|
+
* between a stored record and an incoming patch. Used to audit settings changes
|
|
106
|
+
* without ever logging nested objects (which may hold secret blobs).
|
|
107
|
+
*/
|
|
108
|
+
export declare function diffScalarKeys(previous: Record<string, unknown>, next: Record<string, unknown>): Array<{
|
|
109
|
+
key: string;
|
|
110
|
+
from: unknown;
|
|
111
|
+
to: unknown;
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* After a successful primary-credential check, prune the user's active sessions
|
|
115
|
+
* down to the configured concurrent maximum (default 5; configurable via
|
|
116
|
+
* `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
|
|
117
|
+
* always sign in.
|
|
118
|
+
*/
|
|
119
|
+
export declare function enforceSessionLimitsForUser(d: any, userId: string): Promise<void>;
|
|
120
|
+
/** Revoke every live API key for a user. Called with session revocation. */
|
|
121
|
+
export declare function revokeUserApiKeys(d: any, userId: string): Promise<number>;
|
|
122
|
+
export declare function getAdminPath(): string;
|
|
123
|
+
/**
|
|
124
|
+
* Gate a per-document read for endpoints (e.g. SEO analysis) that fetch a
|
|
125
|
+
* document directly rather than through the collection CRUD routes. Enforces
|
|
126
|
+
* both the API-key collection scope and the collection's `access.read` rule
|
|
127
|
+
* so an AUTHOR scoped to one collection cannot read another's content.
|
|
128
|
+
* Returns a `Response` to short-circuit with, or `null` when allowed.
|
|
129
|
+
*/
|
|
130
|
+
export declare function enforceDocumentReadAccess(session: AuthSession, collection: string): Promise<Response | null>;
|
|
131
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/api/route-helpers/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { assertCollectionAccess } from '../../actions.js';
|
|
2
|
+
import { verifySession } from '../../auth/session.js';
|
|
3
|
+
import { resolveEmailSender } from '../../email/sender.js';
|
|
4
|
+
import { workspaceDomainFromUrl } from '../../auth/team.js';
|
|
5
|
+
import { getDB } from '../../db.js';
|
|
6
|
+
import { createRateLimiter } from '../../security/rate-limit.js';
|
|
7
|
+
import { bindRequestLogUser } from '../../diagnostics/logger.js';
|
|
8
|
+
import { getClientIp } from '../../security/client-ip.js';
|
|
9
|
+
import { enforceSessionLimits } from '../../security/session-limits.js';
|
|
10
|
+
import { verifyReauth } from '../../security/reauth.js';
|
|
11
|
+
import { getPrentaConfig } from '../../config/runtime.js';
|
|
12
|
+
import { hashApiKey, looksLikeApiKey, validateApiKeyScope, validateApiKeyGlobalScope, validateApiKeyMediaScope, validateApiKeyIp, } from '../../security/api-key-enhanced.js';
|
|
13
|
+
import { guardedDb } from '../guarded-db.js';
|
|
14
|
+
import { SECURITY_HEADERS } from '../security-headers.js';
|
|
15
|
+
import { logger, json, errorResponse, hasModel } from './http.js';
|
|
16
|
+
let _secretMissing = false;
|
|
17
|
+
let _secretWarningLogged = false;
|
|
18
|
+
export function getSessionSecret() {
|
|
19
|
+
const secret = process.env.CMS_SECRET ?? process.env.CMS_SESSION_SECRET ?? getPrentaConfig()?.secret;
|
|
20
|
+
if (!secret) {
|
|
21
|
+
_secretMissing = true;
|
|
22
|
+
if (!_secretWarningLogged) {
|
|
23
|
+
_secretWarningLogged = true;
|
|
24
|
+
logger.error('Missing CMS secret. Set CMS_SECRET (min 32 characters) or pass secret in prenta.config.ts. Authenticated routes return 503 until it is set.');
|
|
25
|
+
}
|
|
26
|
+
throw new Error('CMS secret not configured');
|
|
27
|
+
}
|
|
28
|
+
if (secret.length < 32) {
|
|
29
|
+
throw new Error('[Prenta CMS] CMS secret must be at least 32 characters (got ' +
|
|
30
|
+
secret.length +
|
|
31
|
+
'). ' +
|
|
32
|
+
"Generate a secure value with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"");
|
|
33
|
+
}
|
|
34
|
+
_secretMissing = false;
|
|
35
|
+
return secret;
|
|
36
|
+
}
|
|
37
|
+
export function isSecretMissing() {
|
|
38
|
+
if (_secretMissing)
|
|
39
|
+
return true;
|
|
40
|
+
try {
|
|
41
|
+
getSessionSecret();
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function extractSession(request) {
|
|
49
|
+
if (isSecretMissing())
|
|
50
|
+
return null;
|
|
51
|
+
let token;
|
|
52
|
+
const authHeader = request.headers.get('Authorization');
|
|
53
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
54
|
+
token = authHeader.slice(7);
|
|
55
|
+
}
|
|
56
|
+
if (!token) {
|
|
57
|
+
const cookieHeader = request.headers.get('cookie') ?? '';
|
|
58
|
+
const cookies = parseCookieHeader(cookieHeader);
|
|
59
|
+
token = cookies['prenta_session'];
|
|
60
|
+
}
|
|
61
|
+
if (!token)
|
|
62
|
+
return null;
|
|
63
|
+
// API key path. Keys are recognized by the `act_sk_` prefix and looked up
|
|
64
|
+
// by SHA-256 hash. We never JWT-verify these tokens — they are opaque
|
|
65
|
+
// random secrets stored as hashes in `prenta_api_keys`.
|
|
66
|
+
if (looksLikeApiKey(token)) {
|
|
67
|
+
try {
|
|
68
|
+
const d = getDB();
|
|
69
|
+
if (!hasModel(d, 'apiKey'))
|
|
70
|
+
return null;
|
|
71
|
+
const hash = await hashApiKey(token);
|
|
72
|
+
const apiKey = await d.apiKey.findUnique({ where: { keyHash: hash } });
|
|
73
|
+
if (!apiKey)
|
|
74
|
+
return null;
|
|
75
|
+
if (apiKey.revokedAt)
|
|
76
|
+
return null;
|
|
77
|
+
if (apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() < Date.now())
|
|
78
|
+
return null;
|
|
79
|
+
const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
|
|
80
|
+
? apiKey.ipRestrictions
|
|
81
|
+
: apiKey.ipRestrictions
|
|
82
|
+
? (apiKey.ipRestrictions.allow ?? null)
|
|
83
|
+
: null;
|
|
84
|
+
if (ipRestrictions && ipRestrictions.length > 0) {
|
|
85
|
+
const ip = getClientIp(request);
|
|
86
|
+
if (!validateApiKeyIp(ipRestrictions, ip))
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// Keys inherit the owner's current lifecycle + role. Suspension, lock,
|
|
90
|
+
// deactivation, or a demotion must take effect immediately — the key
|
|
91
|
+
// cannot outrank the user who minted it (CRIT-1).
|
|
92
|
+
if (!hasModel(d, 'user'))
|
|
93
|
+
return null;
|
|
94
|
+
const owner = await d.user.findUnique({
|
|
95
|
+
where: { id: apiKey.userId },
|
|
96
|
+
select: { id: true, role: true, isActive: true, suspendedAt: true, lockedAt: true },
|
|
97
|
+
});
|
|
98
|
+
if (!owner)
|
|
99
|
+
return null;
|
|
100
|
+
if (owner.isActive === false || owner.suspendedAt || owner.lockedAt)
|
|
101
|
+
return null;
|
|
102
|
+
const scopes = apiKey.scopes ?? {};
|
|
103
|
+
// Fire-and-forget lastUsedAt update; never block the request on it.
|
|
104
|
+
void d.apiKey
|
|
105
|
+
.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } })
|
|
106
|
+
.catch(() => { });
|
|
107
|
+
bindRequestLogUser(apiKey.userId);
|
|
108
|
+
return {
|
|
109
|
+
userId: apiKey.userId,
|
|
110
|
+
role: scopes.admin ? String(owner.role || 'ADMIN') : 'API_KEY',
|
|
111
|
+
sessionId: apiKey.id,
|
|
112
|
+
apiKey: { id: apiKey.id, scopes },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Session JWT path.
|
|
120
|
+
try {
|
|
121
|
+
const payload = await verifySession(token, { secret: getSessionSecret() });
|
|
122
|
+
const d = getDB();
|
|
123
|
+
if (hasModel(d, 'session')) {
|
|
124
|
+
const dbSession = await d.session.findUnique({
|
|
125
|
+
where: { id: payload.sessionId },
|
|
126
|
+
select: { revokedAt: true },
|
|
127
|
+
});
|
|
128
|
+
if (!dbSession || dbSession.revokedAt)
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (payload.userId)
|
|
132
|
+
bindRequestLogUser(payload.userId);
|
|
133
|
+
return payload;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function parseCookieHeader(cookieHeader) {
|
|
140
|
+
const cookies = {};
|
|
141
|
+
for (const pair of cookieHeader.split(';')) {
|
|
142
|
+
const [key, ...rest] = pair.split('=');
|
|
143
|
+
if (key) {
|
|
144
|
+
cookies[key.trim()] = rest.join('=').trim();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return cookies;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Verify a password (or "current password") posted in the X-Reauth-Password
|
|
151
|
+
* header against the authenticated user. Returns an error Response when the
|
|
152
|
+
* header is missing or the password is wrong; returns null on success.
|
|
153
|
+
*
|
|
154
|
+
* Use this for high-impact account changes (TOTP enable/disable, role change,
|
|
155
|
+
* delete user, etc.) so a stolen session alone cannot perform them.
|
|
156
|
+
*/
|
|
157
|
+
export async function requirePasswordReauth(request, userId) {
|
|
158
|
+
const password = request.headers.get('x-reauth-password');
|
|
159
|
+
if (!password) {
|
|
160
|
+
return new Response(JSON.stringify({
|
|
161
|
+
error: 'Re-authentication required for this action.',
|
|
162
|
+
code: 'REAUTH_REQUIRED',
|
|
163
|
+
method: 'password',
|
|
164
|
+
}), { status: 401, headers: { ...SECURITY_HEADERS } });
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const { getDB } = await import('../../db.js');
|
|
168
|
+
const ok = await verifyReauth(userId, password, 'password', getDB());
|
|
169
|
+
if (!ok) {
|
|
170
|
+
return errorResponse('Re-authentication failed.', 401);
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
logger.error('reauth verify failed', {
|
|
176
|
+
error: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
return errorResponse('Re-authentication failed.', 401);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function requireAuth(request) {
|
|
182
|
+
if (isSecretMissing()) {
|
|
183
|
+
return {
|
|
184
|
+
error: json({
|
|
185
|
+
error: 'CMS secret not configured. Set CMS_SECRET or CMS_SESSION_SECRET environment variable (min 32 characters).',
|
|
186
|
+
code: 'MISSING_SECRET',
|
|
187
|
+
}, 503),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const session = await extractSession(request);
|
|
191
|
+
if (!session) {
|
|
192
|
+
return { error: errorResponse('Unauthorized', 401) };
|
|
193
|
+
}
|
|
194
|
+
return { session };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* API-key collection-scope check as a plain denial reason (`null` = allowed).
|
|
198
|
+
* JWT sessions are never denied here — role checks are the route's job. Use
|
|
199
|
+
* this where the collection is only known deep inside a service (e.g. an SEO
|
|
200
|
+
* fix that writes a document) and a `Response` cannot be returned directly.
|
|
201
|
+
*/
|
|
202
|
+
export function apiKeyCollectionScopeDenial(session, collection, action) {
|
|
203
|
+
if (!session.apiKey)
|
|
204
|
+
return null;
|
|
205
|
+
return validateApiKeyScope(session.apiKey.scopes, collection, action)
|
|
206
|
+
? null
|
|
207
|
+
: `API key does not have permission to ${action} on collection "${collection}"`;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Check that the request's auth context permits `action` on `collection`.
|
|
211
|
+
* - Session-authenticated requests fall through to `requireRole` semantics:
|
|
212
|
+
* write actions require WRITE_ROLES.
|
|
213
|
+
* - API-key-authenticated requests must have an explicit scope match.
|
|
214
|
+
*/
|
|
215
|
+
export function requireCollectionScope(session, collection, action) {
|
|
216
|
+
if (session.apiKey) {
|
|
217
|
+
const denial = apiKeyCollectionScopeDenial(session, collection, action);
|
|
218
|
+
return denial ? errorResponse(denial, 403) : null;
|
|
219
|
+
}
|
|
220
|
+
if (action === 'read')
|
|
221
|
+
return null;
|
|
222
|
+
return requireRole(session.role, WRITE_ROLES);
|
|
223
|
+
}
|
|
224
|
+
export function requireGlobalScope(session, slug) {
|
|
225
|
+
if (session.apiKey) {
|
|
226
|
+
if (!validateApiKeyGlobalScope(session.apiKey.scopes, slug)) {
|
|
227
|
+
return errorResponse(`API key does not have permission for global "${slug}"`, 403);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
export function requireMediaScope(session) {
|
|
233
|
+
if (session.apiKey) {
|
|
234
|
+
if (!validateApiKeyMediaScope(session.apiKey.scopes)) {
|
|
235
|
+
return errorResponse('API key does not have media scope', 403);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
export function requireAdminScope(session) {
|
|
241
|
+
if (session.apiKey) {
|
|
242
|
+
if (!session.apiKey.scopes.admin) {
|
|
243
|
+
return errorResponse('API key does not have admin scope', 403);
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
248
|
+
}
|
|
249
|
+
export function buildActionContext(session, db, locale) {
|
|
250
|
+
return {
|
|
251
|
+
userId: session.userId,
|
|
252
|
+
role: session.role,
|
|
253
|
+
locale,
|
|
254
|
+
db,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
export const WRITE_ROLES = new Set(['OWNER', 'ADMIN', 'EDITOR', 'AUTHOR']);
|
|
258
|
+
export const ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
|
|
259
|
+
export function requireRole(role, allowedRoles) {
|
|
260
|
+
if (!allowedRoles.has(role)) {
|
|
261
|
+
return errorResponse('Insufficient permissions', 403);
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Build the shared {@link TeamOptions} for the Users endpoints from config +
|
|
267
|
+
* session: the workspace domain (for external-domain detection) and the seat
|
|
268
|
+
* cap (config-provided; null = unlimited until billing exists).
|
|
269
|
+
*/
|
|
270
|
+
export function teamOptionsFor(session) {
|
|
271
|
+
const cfg = getPrentaConfig();
|
|
272
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ??
|
|
273
|
+
cfg?.seo?.siteUrl ??
|
|
274
|
+
null;
|
|
275
|
+
const seatLimit = cfg?.users?.seatLimit ??
|
|
276
|
+
cfg?.admin?.seatLimit ??
|
|
277
|
+
null;
|
|
278
|
+
return {
|
|
279
|
+
workspaceDomain: workspaceDomainFromUrl(siteUrl),
|
|
280
|
+
seatLimit,
|
|
281
|
+
currentUserId: session.userId,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Whether the configured email provider can actually send mail. */
|
|
285
|
+
function isEmailConfigured() {
|
|
286
|
+
const cfg = getPrentaConfig();
|
|
287
|
+
if (cfg?.platform?.email)
|
|
288
|
+
return true;
|
|
289
|
+
return !!resolveEmailSender();
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* DB-aware variant of {@link isEmailConfigured} — also true when a provider
|
|
293
|
+
* is configured through Settings > Email.
|
|
294
|
+
*/
|
|
295
|
+
export async function isEmailConfiguredAsync(db) {
|
|
296
|
+
if (isEmailConfigured())
|
|
297
|
+
return true;
|
|
298
|
+
const { resolveEmailSenderAsync } = await import('../../email/resolve.js');
|
|
299
|
+
return Boolean(await resolveEmailSenderAsync(db));
|
|
300
|
+
}
|
|
301
|
+
export const loginLimiter = createRateLimiter({
|
|
302
|
+
maxRequests: 5,
|
|
303
|
+
windowMs: 15 * 60 * 1000,
|
|
304
|
+
failClosed: true,
|
|
305
|
+
});
|
|
306
|
+
// Fallback limiter for auth requests whose client IP can't be resolved (no
|
|
307
|
+
// trusted proxy header) — those all share ONE bucket. The strict 5/15min
|
|
308
|
+
// `loginLimiter` there would let a handful of anonymous requests lock every
|
|
309
|
+
// user out of login (see #536), so the shared "unknown" bucket gets a much
|
|
310
|
+
// higher ceiling. The real fix is resolving IPs (PRENTA_TRUST_PROXY); this
|
|
311
|
+
// just bounds abuse without turning a mis-config into a global outage.
|
|
312
|
+
export const loginUnknownLimiter = createRateLimiter({
|
|
313
|
+
maxRequests: 100,
|
|
314
|
+
windowMs: 15 * 60 * 1000,
|
|
315
|
+
failClosed: true,
|
|
316
|
+
});
|
|
317
|
+
/** Per-email-hash login limiter so account protection does not depend on IP. */
|
|
318
|
+
export const loginEmailLimiter = createRateLimiter({
|
|
319
|
+
maxRequests: 5,
|
|
320
|
+
windowMs: 15 * 60 * 1000,
|
|
321
|
+
failClosed: true,
|
|
322
|
+
});
|
|
323
|
+
// Admin-gated, but bounds invitation-email volume (and accidental loops) per
|
|
324
|
+
// admin: enough to onboard a team, low enough to deter abuse.
|
|
325
|
+
export const inviteLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
|
|
326
|
+
export const totpLimiter = createRateLimiter({
|
|
327
|
+
maxRequests: 10,
|
|
328
|
+
windowMs: 15 * 60 * 1000,
|
|
329
|
+
failClosed: true,
|
|
330
|
+
});
|
|
331
|
+
// Admin-gated test sends from Settings > Email — enough to debug a provider
|
|
332
|
+
// setup, low enough that a compromised admin session can't spray mail.
|
|
333
|
+
export const emailTestLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60 * 60 * 1000 });
|
|
334
|
+
export const aiGenerateLimiter = createRateLimiter({
|
|
335
|
+
maxRequests: 20,
|
|
336
|
+
windowMs: 60 * 60 * 1000,
|
|
337
|
+
failClosed: true,
|
|
338
|
+
});
|
|
339
|
+
// Strict per-user upload cap (security rule: uploads strict at 20/hour) — guards
|
|
340
|
+
// storage cost and optimization abuse beyond the global per-IP API bucket.
|
|
341
|
+
export const uploadLimiter = createRateLimiter({
|
|
342
|
+
maxRequests: 20,
|
|
343
|
+
windowMs: 60 * 60 * 1000,
|
|
344
|
+
failClosed: true,
|
|
345
|
+
});
|
|
346
|
+
// Deterministic (no provider) AI-adjacent routes that scan/iterate documents.
|
|
347
|
+
// Cheaper than the generation budget but still bounded against cost/DoS.
|
|
348
|
+
export const aiAnalysisLimiter = createRateLimiter({
|
|
349
|
+
maxRequests: 60,
|
|
350
|
+
windowMs: 60 * 60 * 1000,
|
|
351
|
+
failClosed: true,
|
|
352
|
+
});
|
|
353
|
+
// Upper bound on free-text inputs to the deterministic analysis routes
|
|
354
|
+
// (document body / link-candidate content) — caps memory + scan work.
|
|
355
|
+
export const AI_ANALYSIS_MAX_CHARS = 50_000;
|
|
356
|
+
export const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
|
|
357
|
+
export const seoAuditLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60 * 60 * 1000 });
|
|
358
|
+
// Admin-gated live checks against the Google Search Console API ("Test
|
|
359
|
+
// connection") — enough to debug a setup, bounded against key-guessing.
|
|
360
|
+
export const searchConsoleTestLimiter = createRateLimiter({
|
|
361
|
+
maxRequests: 10,
|
|
362
|
+
windowMs: 60 * 60 * 1000,
|
|
363
|
+
failClosed: true,
|
|
364
|
+
});
|
|
365
|
+
// Manual search-data syncs hit Google quotas and write big row sets; the
|
|
366
|
+
// nightly cron is the normal path, so the manual button is strictly bounded.
|
|
367
|
+
export const searchSyncLimiter = createRateLimiter({
|
|
368
|
+
maxRequests: 5,
|
|
369
|
+
windowMs: 60 * 60 * 1000,
|
|
370
|
+
failClosed: true,
|
|
371
|
+
});
|
|
372
|
+
// Digest test-sends dispatch real email to the caller — strictly bounded.
|
|
373
|
+
export const digestTestSendLimiter = createRateLimiter({
|
|
374
|
+
maxRequests: 5,
|
|
375
|
+
windowMs: 60 * 60 * 1000,
|
|
376
|
+
failClosed: true,
|
|
377
|
+
});
|
|
378
|
+
export async function checkRateLimitAsync(limiter, key) {
|
|
379
|
+
// Explicit, environment-gated bypass for test harnesses. Production never
|
|
380
|
+
// sets this — Vercel + the deploy guide both omit it. We deliberately do
|
|
381
|
+
// NOT key off `NODE_ENV === 'test'` because Next.js builds run with
|
|
382
|
+
// NODE_ENV=test in some CI configurations and we want rate limiting
|
|
383
|
+
// exercised by unit tests that explicitly opt in.
|
|
384
|
+
if (process.env.PRENTA_DISABLE_RATE_LIMIT === '1') {
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
const result = await limiter.check(key);
|
|
388
|
+
return result.allowed;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
|
|
392
|
+
* then x-forwarded-for only when PRENTA_TRUST_PROXY=1). Returns 'unknown' when
|
|
393
|
+
* no trustworthy source is available — callers MUST treat that case as a hard
|
|
394
|
+
* failure for security-sensitive decisions like rate-limit keys and IP allowlists.
|
|
395
|
+
*/
|
|
396
|
+
export function clientIp(request) {
|
|
397
|
+
return getClientIp(request);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Compute a before/after diff of top-level scalar (string/number/boolean) keys
|
|
401
|
+
* between a stored record and an incoming patch. Used to audit settings changes
|
|
402
|
+
* without ever logging nested objects (which may hold secret blobs).
|
|
403
|
+
*/
|
|
404
|
+
export function diffScalarKeys(previous, next) {
|
|
405
|
+
const isScalar = (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
|
|
406
|
+
const changes = [];
|
|
407
|
+
for (const key of Object.keys(next)) {
|
|
408
|
+
const to = next[key];
|
|
409
|
+
if (!isScalar(to))
|
|
410
|
+
continue;
|
|
411
|
+
const from = previous[key];
|
|
412
|
+
if (isScalar(from) && from === to)
|
|
413
|
+
continue;
|
|
414
|
+
if (!isScalar(from) && from !== undefined)
|
|
415
|
+
continue;
|
|
416
|
+
changes.push({ key, from: isScalar(from) ? from : undefined, to });
|
|
417
|
+
}
|
|
418
|
+
return changes;
|
|
419
|
+
}
|
|
420
|
+
const MAX_CONCURRENT_SESSIONS = 5;
|
|
421
|
+
/**
|
|
422
|
+
* After a successful primary-credential check, prune the user's active sessions
|
|
423
|
+
* down to the configured concurrent maximum (default 5; configurable via
|
|
424
|
+
* `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
|
|
425
|
+
* always sign in.
|
|
426
|
+
*/
|
|
427
|
+
export async function enforceSessionLimitsForUser(d, userId) {
|
|
428
|
+
if (!hasModel(d, 'session'))
|
|
429
|
+
return;
|
|
430
|
+
// Precedence: explicitly-saved Security setting (database) ▸ auth config ▸
|
|
431
|
+
// default. We read the stored value directly (rather than the parsed
|
|
432
|
+
// defaults) so an unsaved policy doesn't shadow a config-provided override.
|
|
433
|
+
let storedMax;
|
|
434
|
+
try {
|
|
435
|
+
if (hasModel(d, 'document')) {
|
|
436
|
+
const row = await d.document.findFirst({
|
|
437
|
+
where: { collection: 'settings', deletedAt: null },
|
|
438
|
+
select: { data: true },
|
|
439
|
+
});
|
|
440
|
+
const stored = row?.data?.security;
|
|
441
|
+
const v = stored?.session?.maxConcurrentSessions;
|
|
442
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
|
|
443
|
+
storedMax = v;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* fall back to config/default below */
|
|
448
|
+
}
|
|
449
|
+
const auth = getPrentaConfig()?.auth;
|
|
450
|
+
const max = storedMax !== undefined
|
|
451
|
+
? storedMax
|
|
452
|
+
: typeof auth?.maxConcurrentSessions === 'number' && auth.maxConcurrentSessions > 0
|
|
453
|
+
? auth.maxConcurrentSessions
|
|
454
|
+
: MAX_CONCURRENT_SESSIONS;
|
|
455
|
+
// 0 ⇒ unlimited: skip pruning entirely.
|
|
456
|
+
if (max <= 0)
|
|
457
|
+
return;
|
|
458
|
+
const active = await d.session.findMany({
|
|
459
|
+
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
460
|
+
select: { id: true, createdAt: true },
|
|
461
|
+
});
|
|
462
|
+
const decision = enforceSessionLimits(active.map((s) => ({ sessionId: s.id, userId, createdAt: s.createdAt })), { maxConcurrentSessions: max, strategy: 'revoke_oldest' });
|
|
463
|
+
if (decision.sessionsToRevoke.length > 0) {
|
|
464
|
+
await d.session.updateMany({
|
|
465
|
+
where: { id: { in: decision.sessionsToRevoke } },
|
|
466
|
+
data: { revokedAt: new Date() },
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Revoke every live API key for a user. Called with session revocation. */
|
|
471
|
+
export async function revokeUserApiKeys(d, userId) {
|
|
472
|
+
if (!hasModel(d, 'apiKey'))
|
|
473
|
+
return 0;
|
|
474
|
+
const result = await d.apiKey.updateMany({
|
|
475
|
+
where: { userId, revokedAt: null },
|
|
476
|
+
data: { revokedAt: new Date() },
|
|
477
|
+
});
|
|
478
|
+
return typeof result?.count === 'number' ? result.count : 0;
|
|
479
|
+
}
|
|
480
|
+
export function getAdminPath() {
|
|
481
|
+
return process.env.PRENTA_ADMIN_PATH ?? getPrentaConfig()?.admin?.path ?? '/admin';
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Gate a per-document read for endpoints (e.g. SEO analysis) that fetch a
|
|
485
|
+
* document directly rather than through the collection CRUD routes. Enforces
|
|
486
|
+
* both the API-key collection scope and the collection's `access.read` rule
|
|
487
|
+
* so an AUTHOR scoped to one collection cannot read another's content.
|
|
488
|
+
* Returns a `Response` to short-circuit with, or `null` when allowed.
|
|
489
|
+
*/
|
|
490
|
+
export async function enforceDocumentReadAccess(session, collection) {
|
|
491
|
+
const scopeErr = requireCollectionScope(session, collection, 'read');
|
|
492
|
+
if (scopeErr)
|
|
493
|
+
return scopeErr;
|
|
494
|
+
try {
|
|
495
|
+
await assertCollectionAccess(collection, 'read', buildActionContext(session, guardedDb()));
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
if (err instanceof Error && err.message.includes('Access denied')) {
|
|
499
|
+
return errorResponse('Insufficient permissions', 403);
|
|
500
|
+
}
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
//# sourceMappingURL=auth.js.map
|