@pantheon-systems/p1-next-sdk 0.19.0 → 0.20.1
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/dist/css-query-fetchers.js +52 -3
- package/dist/handler.js +21 -2
- package/dist/routes/datasource-context.js +5 -4
- package/dist/routes/remote-datasources-api.d.ts +1 -0
- package/dist/routes/remote-datasources-api.js +47 -5
- package/dist/verify-editor-access.d.ts +24 -0
- package/dist/verify-editor-access.js +40 -0
- package/package.json +7 -7
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getSharedP1Client, getSharedSiteId, getSharedBranchId, CSS_QUERY_ID_PREFIX, } from "@pantheon-systems/puck-css/server";
|
|
2
|
+
const FOLLOW_UP_PAGE_SIZE = 100;
|
|
3
|
+
const MAX_FOLLOW_UP_PAGES = 50;
|
|
2
4
|
const inflightQueries = new Map();
|
|
3
5
|
function listQueriesDeduped(endpoint, siteId, branchId) {
|
|
4
6
|
const key = `${siteId}:${branchId}`;
|
|
@@ -11,6 +13,55 @@ function listQueriesDeduped(endpoint, siteId, branchId) {
|
|
|
11
13
|
inflightQueries.set(key, promise);
|
|
12
14
|
return promise;
|
|
13
15
|
}
|
|
16
|
+
function documentIdOf(item) {
|
|
17
|
+
const id = item?.documentId;
|
|
18
|
+
return typeof id === "string" ? id : undefined;
|
|
19
|
+
}
|
|
20
|
+
async function getAllResults(queries, siteId, branchId, queryName, throwOnError) {
|
|
21
|
+
const firstPage = await queries.getResults(siteId, branchId, queryName, {
|
|
22
|
+
includeMetadata: true,
|
|
23
|
+
});
|
|
24
|
+
const totalCount = firstPage.totalCount;
|
|
25
|
+
if (typeof totalCount !== "number" || firstPage.items.length >= totalCount) {
|
|
26
|
+
return firstPage;
|
|
27
|
+
}
|
|
28
|
+
const items = [...firstPage.items];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
const id = documentIdOf(item);
|
|
32
|
+
if (id !== undefined)
|
|
33
|
+
seen.add(id);
|
|
34
|
+
}
|
|
35
|
+
let received = firstPage.items.length;
|
|
36
|
+
for (let page = 0; page < MAX_FOLLOW_UP_PAGES && received < totalCount; page++) {
|
|
37
|
+
let next;
|
|
38
|
+
try {
|
|
39
|
+
next = await queries.getResults(siteId, branchId, queryName, {
|
|
40
|
+
includeMetadata: true,
|
|
41
|
+
limit: FOLLOW_UP_PAGE_SIZE,
|
|
42
|
+
offset: received,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (throwOnError)
|
|
47
|
+
throw err;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (next.items.length === 0)
|
|
51
|
+
break;
|
|
52
|
+
received += next.items.length;
|
|
53
|
+
for (const item of next.items) {
|
|
54
|
+
const id = documentIdOf(item);
|
|
55
|
+
if (id !== undefined) {
|
|
56
|
+
if (seen.has(id))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(id);
|
|
59
|
+
}
|
|
60
|
+
items.push(item);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { ...firstPage, items, returnedCount: items.length };
|
|
64
|
+
}
|
|
14
65
|
export async function createCssQueryFetchers(opts) {
|
|
15
66
|
const client = opts?.client ?? getSharedP1Client();
|
|
16
67
|
const siteId = getSharedSiteId();
|
|
@@ -35,9 +86,7 @@ export async function createCssQueryFetchers(opts) {
|
|
|
35
86
|
id: `${CSS_QUERY_ID_PREFIX}${query.name}`,
|
|
36
87
|
fetch: async () => {
|
|
37
88
|
try {
|
|
38
|
-
const results = await queries
|
|
39
|
-
includeMetadata: true,
|
|
40
|
-
});
|
|
89
|
+
const results = await getAllResults(queries, siteId, branchId, query.name, throwOnError);
|
|
41
90
|
return results;
|
|
42
91
|
}
|
|
43
92
|
catch (err) {
|
package/dist/handler.js
CHANGED
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { ensureInitialized } from "@pantheon-systems/puck-css/server";
|
|
3
3
|
import { runWithAuthToken } from "@pantheon-systems/puck-css/server";
|
|
4
4
|
import { extractBearerToken } from "./auth-utils";
|
|
5
|
+
import { verifyEditorAccess } from "./verify-editor-access";
|
|
5
6
|
import { getPageData, getRemoteDatasources, getEditorContext, getDatasourceContext, getRoutes, postPublish, postRevalidate, postResolvePreview, postPreviewMeta, postRemoteDatasources, deleteRemoteDatasources, } from "./handler-actions";
|
|
6
7
|
function parseP1Segments(p1) {
|
|
7
8
|
if (p1.length === 0)
|
|
@@ -15,6 +16,24 @@ function withAuth(request, fn) {
|
|
|
15
16
|
return runWithAuthToken(token, fn);
|
|
16
17
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
|
17
18
|
}
|
|
19
|
+
async function withEditorAccess(request, opts, fn) {
|
|
20
|
+
const token = extractBearerToken(request);
|
|
21
|
+
if (!token)
|
|
22
|
+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
|
23
|
+
const access = await verifyEditorAccess({
|
|
24
|
+
token,
|
|
25
|
+
p1BaseUrl: opts.p1BaseUrl,
|
|
26
|
+
siteId: opts.p1SiteId,
|
|
27
|
+
branchId: opts.p1BranchId,
|
|
28
|
+
});
|
|
29
|
+
if (access.status === "unauthenticated") {
|
|
30
|
+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
|
31
|
+
}
|
|
32
|
+
if (access.status === "forbidden") {
|
|
33
|
+
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
|
34
|
+
}
|
|
35
|
+
return runWithAuthToken(token, fn);
|
|
36
|
+
}
|
|
18
37
|
export function createP1Handler(opts) {
|
|
19
38
|
const init = () => ensureInitialized(opts);
|
|
20
39
|
async function GET(request, { params }) {
|
|
@@ -51,7 +70,7 @@ export function createP1Handler(opts) {
|
|
|
51
70
|
if (action === "preview-meta")
|
|
52
71
|
return postPreviewMeta(request);
|
|
53
72
|
if (action === "datasources")
|
|
54
|
-
return
|
|
73
|
+
return withEditorAccess(request, opts, () => postRemoteDatasources(request));
|
|
55
74
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
|
56
75
|
}
|
|
57
76
|
async function DELETE(request, { params }) {
|
|
@@ -59,7 +78,7 @@ export function createP1Handler(opts) {
|
|
|
59
78
|
const { p1 = [] } = await params;
|
|
60
79
|
const { action } = parseP1Segments(p1);
|
|
61
80
|
if (action === "datasources")
|
|
62
|
-
return
|
|
81
|
+
return withEditorAccess(request, opts, () => deleteRemoteDatasources(request));
|
|
63
82
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
|
64
83
|
}
|
|
65
84
|
return { GET, POST, DELETE };
|
|
@@ -2,7 +2,8 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { normalizePath, ensureLeadingSlash, listRouteTemplateKeysFromDatabase, getPageEditorPreviewParams, loadRemoteDatasourceContext, createAuthenticatedClient, runWithAuthToken, } from "@pantheon-systems/puck-css/server";
|
|
3
3
|
import { extractBearerToken } from "../auth-utils";
|
|
4
4
|
import { createCssQueryFetchers } from "../css-query-fetchers";
|
|
5
|
-
const DATASOURCE_FETCH_TIMEOUT_MS =
|
|
5
|
+
const DATASOURCE_FETCH_TIMEOUT_MS = 15_000;
|
|
6
|
+
const NO_STORE = { "Cache-Control": "no-store" };
|
|
6
7
|
const inflightPreviewParams = new Map();
|
|
7
8
|
function getPreviewParamsDeduped(token, path) {
|
|
8
9
|
const key = `${token ?? ""}:${path}`;
|
|
@@ -21,7 +22,7 @@ export async function getDatasourceContext(request, options) {
|
|
|
21
22
|
const id = url.searchParams.get("id");
|
|
22
23
|
const path = rawPath === "/" ? "/" : normalizePath(ensureLeadingSlash(rawPath));
|
|
23
24
|
if (path === null) {
|
|
24
|
-
return NextResponse.json({ error: "invalid_path" }, { status: 400 });
|
|
25
|
+
return NextResponse.json({ error: "invalid_path" }, { status: 400, headers: NO_STORE });
|
|
25
26
|
}
|
|
26
27
|
const token = extractBearerToken(request);
|
|
27
28
|
const [routeTemplateKeys, savedPreviewParams] = await Promise.all([
|
|
@@ -58,8 +59,8 @@ export async function getDatasourceContext(request, options) {
|
|
|
58
59
|
if (id) {
|
|
59
60
|
const data = context[id] ?? {};
|
|
60
61
|
const failure = failures.find((f) => f.id === id);
|
|
61
|
-
return NextResponse.json(failure ? { id, data, error: { reason: failure.reason } } : { id, data });
|
|
62
|
+
return NextResponse.json(failure ? { id, data, error: { reason: failure.reason } } : { id, data }, { headers: NO_STORE });
|
|
62
63
|
}
|
|
63
64
|
const { urlParams: _urlParams, ...contexts } = context;
|
|
64
|
-
return NextResponse.json({ contexts, errors: failures });
|
|
65
|
+
return NextResponse.json({ contexts, errors: failures }, { headers: NO_STORE });
|
|
65
66
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
export declare const REDACTED_HEADER_VALUE = "[redacted]";
|
|
2
3
|
export { getRemoteDatasources as GET, postRemoteDatasources as POST, deleteRemoteDatasources as DELETE };
|
|
3
4
|
export declare function getRemoteDatasources(request: Request): Promise<NextResponse<{
|
|
4
5
|
ok: boolean;
|
|
@@ -2,6 +2,45 @@ import { revalidatePath } from "next/cache";
|
|
|
2
2
|
import { NextResponse } from "next/server";
|
|
3
3
|
import { deleteGlobalRemoteDatasource, deletePageRemoteDatasource, idConflictsForRemoteDatasourceScope, listRemoteDatasourcesForPage, normalizeRemoteDatasourceDefinition, upsertGlobalRemoteDatasource, upsertPageRemoteDatasource, } from "@pantheon-systems/puck-css/server";
|
|
4
4
|
import { normalizePath } from "@pantheon-systems/puck-css/server";
|
|
5
|
+
export const REDACTED_HEADER_VALUE = "[redacted]";
|
|
6
|
+
function redactHeaders(def) {
|
|
7
|
+
if (!def.headers)
|
|
8
|
+
return def;
|
|
9
|
+
const headers = {};
|
|
10
|
+
for (const key of Object.keys(def.headers)) {
|
|
11
|
+
headers[key] = REDACTED_HEADER_VALUE;
|
|
12
|
+
}
|
|
13
|
+
return { ...def, headers };
|
|
14
|
+
}
|
|
15
|
+
function originOf(urlTemplate) {
|
|
16
|
+
try {
|
|
17
|
+
const { origin } = new URL(urlTemplate);
|
|
18
|
+
return origin === "null" ? null : origin;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function sameOrigin(a, b) {
|
|
25
|
+
const origin = originOf(a.urlTemplate);
|
|
26
|
+
return origin !== null && origin === originOf(b.urlTemplate);
|
|
27
|
+
}
|
|
28
|
+
function restoreRedactedHeaders(definition, stored) {
|
|
29
|
+
if (!definition.headers)
|
|
30
|
+
return definition;
|
|
31
|
+
const restorable = stored !== undefined && sameOrigin(definition, stored);
|
|
32
|
+
const headers = {};
|
|
33
|
+
for (const [key, value] of Object.entries(definition.headers)) {
|
|
34
|
+
if (value !== REDACTED_HEADER_VALUE) {
|
|
35
|
+
headers[key] = value;
|
|
36
|
+
}
|
|
37
|
+
else if (restorable && stored.headers?.[key]) {
|
|
38
|
+
headers[key] = stored.headers[key];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const { headers: _omit, ...rest } = definition;
|
|
42
|
+
return Object.keys(headers).length > 0 ? { ...rest, headers } : rest;
|
|
43
|
+
}
|
|
5
44
|
export { getRemoteDatasources as GET, postRemoteDatasources as POST, deleteRemoteDatasources as DELETE };
|
|
6
45
|
export async function getRemoteDatasources(request) {
|
|
7
46
|
const url = new URL(request.url);
|
|
@@ -13,8 +52,8 @@ export async function getRemoteDatasources(request) {
|
|
|
13
52
|
return NextResponse.json({
|
|
14
53
|
ok: true,
|
|
15
54
|
path,
|
|
16
|
-
global: data.global,
|
|
17
|
-
page: data.page,
|
|
55
|
+
global: data.global.map(redactHeaders),
|
|
56
|
+
page: data.page.map(redactHeaders),
|
|
18
57
|
});
|
|
19
58
|
}
|
|
20
59
|
export async function postRemoteDatasources(request) {
|
|
@@ -33,13 +72,16 @@ export async function postRemoteDatasources(request) {
|
|
|
33
72
|
if (!path) {
|
|
34
73
|
return NextResponse.json({ ok: false, error: "invalid_path" }, { status: 400 });
|
|
35
74
|
}
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
75
|
+
const incoming = normalizeRemoteDatasourceDefinition(body.definition);
|
|
76
|
+
if (!incoming) {
|
|
38
77
|
return NextResponse.json({ ok: false, error: "invalid_definition" }, { status: 400 });
|
|
39
78
|
}
|
|
40
|
-
else if (idConflictsForRemoteDatasourceScope(scope,
|
|
79
|
+
else if (idConflictsForRemoteDatasourceScope(scope, incoming.id, path)) {
|
|
41
80
|
return NextResponse.json({ ok: false, error: "id_conflict" }, { status: 409 });
|
|
42
81
|
}
|
|
82
|
+
const existing = listRemoteDatasourcesForPage(path);
|
|
83
|
+
const stored = (scope === "global" ? existing.global : existing.page).find((d) => d.id === incoming.id);
|
|
84
|
+
const definition = restoreRedactedHeaders(incoming, stored);
|
|
43
85
|
if (scope === "global") {
|
|
44
86
|
upsertGlobalRemoteDatasource(definition);
|
|
45
87
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type EditorAccessResult = {
|
|
2
|
+
status: "allowed";
|
|
3
|
+
} | {
|
|
4
|
+
status: "unauthenticated";
|
|
5
|
+
} | {
|
|
6
|
+
status: "forbidden";
|
|
7
|
+
};
|
|
8
|
+
export interface VerifyEditorAccessOptions {
|
|
9
|
+
/** Bearer token presented by the caller, without the `Bearer ` prefix. */
|
|
10
|
+
token: string;
|
|
11
|
+
/** Base URL of the API. Defaults to the production API. */
|
|
12
|
+
p1BaseUrl?: string;
|
|
13
|
+
/** Site the caller is trying to modify. */
|
|
14
|
+
siteId?: string;
|
|
15
|
+
/** Branch to check the caller's role on. Defaults to the site's main branch. */
|
|
16
|
+
branchId?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Checks that the bearer token belongs to a caller who may edit documents on the
|
|
20
|
+
* site. A caller the API refuses, a caller without edit permission, or a site
|
|
21
|
+
* with no main branch to check against is `forbidden`; a missing token or site,
|
|
22
|
+
* or any other failure to verify, is reported as `unauthenticated`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function verifyEditorAccess(opts: VerifyEditorAccessOptions): Promise<EditorAccessResult>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getSharedBranchId } from "@pantheon-systems/puck-css/server";
|
|
2
|
+
function configuredBranchId(branchId) {
|
|
3
|
+
const trimmed = branchId?.trim();
|
|
4
|
+
if (!trimmed || trimmed === "main")
|
|
5
|
+
return undefined;
|
|
6
|
+
return trimmed;
|
|
7
|
+
}
|
|
8
|
+
export async function verifyEditorAccess(opts) {
|
|
9
|
+
try {
|
|
10
|
+
const { token, siteId } = opts;
|
|
11
|
+
if (!token || !siteId)
|
|
12
|
+
return { status: "unauthenticated" };
|
|
13
|
+
const mod = await import("@pantheon-systems/css-client");
|
|
14
|
+
const client = new mod.P1Client({
|
|
15
|
+
baseUrl: opts.p1BaseUrl ?? mod.PRODUCTION_BASE_URL,
|
|
16
|
+
authProvider: async () => `Bearer ${token}`,
|
|
17
|
+
});
|
|
18
|
+
let branchId = configuredBranchId(opts.branchId) ??
|
|
19
|
+
configuredBranchId(getSharedBranchId() ?? undefined);
|
|
20
|
+
if (!branchId) {
|
|
21
|
+
const branches = await client.branches.list(siteId);
|
|
22
|
+
branchId = branches.find((b) => b.isMain)?.id;
|
|
23
|
+
}
|
|
24
|
+
if (!branchId)
|
|
25
|
+
return { status: "forbidden" };
|
|
26
|
+
const role = await client.auth.getRole(siteId, branchId);
|
|
27
|
+
return role.permissions.canEditDocuments === true
|
|
28
|
+
? { status: "allowed" }
|
|
29
|
+
: { status: "forbidden" };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (await isForbidden(err))
|
|
33
|
+
return { status: "forbidden" };
|
|
34
|
+
return { status: "unauthenticated" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function isForbidden(err) {
|
|
38
|
+
const mod = await import("@pantheon-systems/css-client");
|
|
39
|
+
return err instanceof mod.P1ApiError && err.status === 403;
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pantheon-systems/p1-next-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"description": "Next.js SDK for P1 editor integration",
|
|
6
6
|
"publishConfig": {
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"!dist/**/*.map"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@pantheon-systems/css-client": "0.20.1",
|
|
46
|
+
"@pantheon-systems/p1-ai-chat": "0.10.0",
|
|
47
|
+
"@pantheon-systems/puck-css": "0.20.1",
|
|
45
48
|
"launchdarkly-js-client-sdk": "^3.9.3",
|
|
46
|
-
"server-only": "^0.0.1"
|
|
47
|
-
"@pantheon-systems/css-client": "0.19.0",
|
|
48
|
-
"@pantheon-systems/p1-ai-chat": "0.9.2",
|
|
49
|
-
"@pantheon-systems/puck-css": "0.19.0"
|
|
49
|
+
"server-only": "^0.0.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@pantheon-systems/pds-toolkit-react": ">=2.0.0-alpha.92",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"react-dom": ">=18.0.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
+
"@pantheon-systems/eslint-config": "0.1.0",
|
|
59
60
|
"@testing-library/dom": "^10.4.1",
|
|
60
61
|
"@testing-library/react": "^16.3.2",
|
|
61
62
|
"@types/node": "^20.19.30",
|
|
@@ -63,8 +64,7 @@
|
|
|
63
64
|
"eslint": "^9.27.0",
|
|
64
65
|
"jsdom": "^24.1.3",
|
|
65
66
|
"typescript": "^5.9.3",
|
|
66
|
-
"vitest": "^4.1.5"
|
|
67
|
-
"@pantheon-systems/eslint-config": "0.1.0"
|
|
67
|
+
"vitest": "^4.1.5"
|
|
68
68
|
},
|
|
69
69
|
"homepage": "https://github.com/pantheon-systems/p1-platform/tree/main/packages/p1-next-sdk",
|
|
70
70
|
"scripts": {
|