@pantheon-systems/p1-next-sdk 0.20.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/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 withAuth(request, () => postRemoteDatasources(request));
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 withAuth(request, () => deleteRemoteDatasources(request));
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 };
@@ -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 definition = normalizeRemoteDatasourceDefinition(body.definition);
37
- if (!definition) {
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, definition.id, path)) {
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.20.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
- "launchdarkly-js-client-sdk": "^3.9.3",
46
- "server-only": "^0.0.1",
47
- "@pantheon-systems/css-client": "0.20.0",
45
+ "@pantheon-systems/css-client": "0.20.1",
48
46
  "@pantheon-systems/p1-ai-chat": "0.10.0",
49
- "@pantheon-systems/puck-css": "0.20.0"
47
+ "@pantheon-systems/puck-css": "0.20.1",
48
+ "launchdarkly-js-client-sdk": "^3.9.3",
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": {