@anthusai/papyrus 1.14.0 → 1.16.0

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.
@@ -1,6 +1,4 @@
1
- export const DEFAULT_DELEGATION_HOURS = 8;
2
- export const MINIMUM_DELEGATION_HOURS = 1;
3
- export const MAXIMUM_DELEGATION_HOURS = 72;
1
+ export type CurationDelegationState = "pending" | "approved" | "denied" | "expired" | "revoked";
4
2
 
5
3
  export type CurationDelegationRecord = {
6
4
  delegationId: string;
@@ -8,25 +6,15 @@ export type CurationDelegationRecord = {
8
6
  grantorLabel: string | null;
9
7
  expiresAt: string;
10
8
  revokedAt: string | null;
9
+ state?: CurationDelegationState | null;
10
+ /** XXXX-XXXX, present only while the request is pending. */
11
+ userCode?: string | null;
12
+ requestedHours?: number | null;
13
+ pendingExpiresAt?: string | null;
14
+ approvedAt?: string | null;
15
+ deniedAt?: string | null;
11
16
  };
12
17
 
13
- export type DelegationInputValidation =
14
- | { ok: true; agentLabel: string; hours: number }
15
- | { ok: false; message: string };
16
-
17
- export function validateDelegationInput(agentLabelText: string, hoursText: string): DelegationInputValidation {
18
- const agentLabel = agentLabelText.trim();
19
- if (!agentLabel) return { ok: false, message: "Enter the name of the agent you are trusting." };
20
- if (/\s/.test(agentLabel)) return { ok: false, message: "The agent name cannot contain spaces. Use the name the agent passes as --agent." };
21
- const trimmedHours = hoursText.trim();
22
- if (!/^\d+$/.test(trimmedHours)) return { ok: false, message: `Enter a whole number of hours from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS}.` };
23
- const hours = Number(trimmedHours);
24
- if (hours < MINIMUM_DELEGATION_HOURS || hours > MAXIMUM_DELEGATION_HOURS) {
25
- return { ok: false, message: `A delegation lasts from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS} hours.` };
26
- }
27
- return { ok: true, agentLabel, hours };
28
- }
29
-
30
18
  export function delegationExpiryText(expiresAt: string, revokedAt: string | null, nowMilliseconds: number): string {
31
19
  if (revokedAt) return "Revoked";
32
20
  const remaining = Date.parse(expiresAt) - nowMilliseconds;
@@ -41,11 +29,6 @@ export function isDelegationActive(record: CurationDelegationRecord, nowMillisec
41
29
  return !record.revokedAt && Date.parse(record.expiresAt) > nowMilliseconds;
42
30
  }
43
31
 
44
- export function buildDelegatedCommandLine(input: { email: string; agentLabel: string; token: string; action?: "approve" | "reject" }): string {
45
- const email = input.email.trim() || "<your-email>";
46
- return `papyrus references ${input.action ?? "approve"} <reference-id> --on-behalf-of ${email} --agent ${input.agentLabel} --instruction "<the person's own words>" --delegation-token ${input.token}`;
47
- }
48
-
49
32
  export function describeDelegationError(error: unknown): string {
50
33
  const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
51
34
  const graphql = (error as { errors?: Array<{ message?: string }> } | null)?.errors?.[0]?.message ?? "";
@@ -57,3 +40,30 @@ export function describeDelegationError(error: unknown): string {
57
40
  const detail = (graphql || raw).trim();
58
41
  return detail ? `The server refused the request: ${detail}` : "Something went wrong. Nothing was changed.";
59
42
  }
43
+
44
+ /** A pending request that an agent started and nobody has approved, denied or let lapse. */
45
+ export function isPendingDelegation(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
46
+ return record.state === "pending" && Boolean(record.userCode) && Date.parse(record.pendingExpiresAt ?? record.expiresAt) > nowMilliseconds;
47
+ }
48
+
49
+ /** "9:41 left" for a request that is still open, "Expired" once its window has passed. */
50
+ export function pendingCountdownText(pendingExpiresAt: string | null | undefined, nowMilliseconds: number): string {
51
+ const remaining = Date.parse(pendingExpiresAt ?? "") - nowMilliseconds;
52
+ if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
53
+ const totalSeconds = Math.ceil(remaining / 1000);
54
+ const minutes = Math.floor(totalSeconds / 60);
55
+ const seconds = totalSeconds % 60;
56
+ return `${minutes}:${String(seconds).padStart(2, "0")} left`;
57
+ }
58
+
59
+ /** The state of a decided or lapsed delegation in words an editor reads in the list. */
60
+ export function delegationStateText(record: CurationDelegationRecord, nowMilliseconds: number): string {
61
+ if (record.state === "denied") return "Denied";
62
+ if (record.state === "pending") return pendingCountdownText(record.pendingExpiresAt ?? record.expiresAt, nowMilliseconds) === "Expired" ? "Request expired" : "Waiting for a decision";
63
+ return delegationExpiryText(record.expiresAt, record.revokedAt, nowMilliseconds);
64
+ }
65
+
66
+ export function requestedHoursText(hours: number | null | undefined): string {
67
+ if (!hours || hours <= 0) return "Duration not stated";
68
+ return `Asks for ${hours} ${hours === 1 ? "hour" : "hours"}`;
69
+ }
@@ -18,6 +18,7 @@ type FetchImplementation = (input: string, init?: RequestInit) => Promise<Respon
18
18
 
19
19
  const SESSION_PATH = "/api/auth/prospectus/session";
20
20
  const SELECTION_PATH = "/api/auth/prospectus/selection";
21
+ const ACCOUNTS_PATH = "/api/auth/prospectus/accounts";
21
22
  const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
22
23
 
23
24
  export function prospectusSignInUrl(returnPath: string): string {
@@ -62,3 +63,28 @@ export async function selectProspectusAccount(
62
63
  if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
63
64
  return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
64
65
  }
66
+
67
+ /**
68
+ * Create a publishing account for the signed-in person and select it. Each call carries a fresh idempotency key,
69
+ * so a retried request after a dropped response cannot create a second account.
70
+ */
71
+ export async function createProspectusAccount(
72
+ name: string,
73
+ fetchImplementation: FetchImplementation = fetch,
74
+ idempotencyKey: string = crypto.randomUUID(),
75
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
76
+ let response: Response;
77
+ try {
78
+ response = await fetchImplementation(ACCOUNTS_PATH, {
79
+ method: "POST",
80
+ credentials: "same-origin",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ name: name.trim() || undefined, idempotencyKey }),
83
+ });
84
+ } catch {
85
+ return { error: "The publishing account could not be created. Check your connection and try again." };
86
+ }
87
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
88
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "The publishing account could not be created." };
89
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
90
+ }
@@ -282,3 +282,48 @@ export async function handleProspectusAccountSelection(request: NextRequest, opt
282
282
  return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
283
283
  }
284
284
  }
285
+
286
+ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
+ const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
+ const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
+
290
+ export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
291
+ const runtime = await signInRuntimeFor(request, options);
292
+ if (!runtime) return signInUnavailableResponse();
293
+ if (request.headers.get("origin") !== request.nextUrl.origin) {
294
+ return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
+ }
296
+ const nowInSeconds = currentTimeInSeconds(options);
297
+ const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
298
+ if (!session) return signInFailedResponse(401, "signed_out", "Sign in first.");
299
+ const body = await request.json().catch(() => null) as { name?: unknown; idempotencyKey?: unknown } | null;
300
+ const requestedName = typeof body?.name === "string" ? body.name.trim() : "";
301
+ const accountName = requestedName || DEFAULT_PUBLISHING_ACCOUNT_NAME;
302
+ if (accountName.length > MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH) {
303
+ return signInFailedResponse(400, "account_name_too_long", "Name the account in 150 characters or fewer.");
304
+ }
305
+ const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey : "";
306
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
307
+ return signInFailedResponse(400, "idempotency_key_required", "Send a UUID idempotency key.");
308
+ }
309
+ try {
310
+ const created = await runtime.accounts.createAccount(session.accessToken, accountName, idempotencyKey);
311
+ const selection = await freshlyAuthorizedPublishingSelection(runtime, session, created.account.id, siteProductFor(options));
312
+ if (!selection) return signInFailedResponse(403, "not_a_publishing_account", "The new account does not grant publishing access on this site.");
313
+ const response = NextResponse.json(
314
+ { selectedAccountId: selection.accountId, role: selection.role, publicationCapabilities: selection.publicationCapabilities },
315
+ { status: 201, headers: NO_STORE_HEADERS },
316
+ );
317
+ const selectedSession: ProspectusSignedInSession = { ...session, selectedAccountId: selection.accountId };
318
+ response.cookies.set(
319
+ PROSPECTUS_SESSION_COOKIE,
320
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, selectedSession, session.accessTokenExpiresAt),
321
+ sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
322
+ );
323
+ return response;
324
+ } catch (error) {
325
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
326
+ console.error(`Prospectus account creation failed (${error instanceof Error ? error.name : "error"}).`);
327
+ return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
328
+ }
329
+ }
@@ -49,6 +49,7 @@ export interface ProspectusRequest {
49
49
  body?: unknown;
50
50
  humanAccessToken?: string;
51
51
  identityToken?: string;
52
+ idempotencyKey?: string;
52
53
  }
53
54
 
54
55
  export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
@@ -84,7 +85,7 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
84
85
  }
85
86
  const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
86
87
  const fetcher = options.fetcher ?? fetch;
87
- return async ({ path, method, body, humanAccessToken, identityToken }) => {
88
+ return async ({ path, method, body, humanAccessToken, identityToken, idempotencyKey }) => {
88
89
  const credentials = await runtimeCredentials();
89
90
  if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
90
91
  throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
@@ -95,6 +96,7 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
95
96
  host: url.host,
96
97
  ...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
97
98
  ...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
99
+ ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
98
100
  ...(payload ? { "content-type": "application/json" } : {}),
99
101
  };
100
102
  const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
@@ -144,6 +146,16 @@ export class ProspectusSiteAccounts {
144
146
  }) as Promise<ProspectusAccountAuthorization>;
145
147
  }
146
148
 
149
+ createAccount(humanAccessToken: string, accountName: string, idempotencyKey: string): Promise<{ account: { id: string; name: string; role: ProspectusRole } }> {
150
+ return this.transport({
151
+ path: "/v1/accounts",
152
+ method: "POST",
153
+ body: { name: accountName },
154
+ humanAccessToken,
155
+ idempotencyKey,
156
+ }) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
157
+ }
158
+
147
159
  accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
148
160
  return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
149
161
  person: ProspectusPerson | null;
package/package.json CHANGED
@@ -302,6 +302,7 @@
302
302
  "./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
303
303
  "./app/[year]/[month]/[day]/section/[sectionKey]/page": "./app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
304
304
  "./app/api/archive/editions/route": "./app/api/archive/editions/route.ts",
305
+ "./app/api/auth/prospectus/accounts/route": "./app/api/auth/prospectus/accounts/route.ts",
305
306
  "./app/api/auth/prospectus/callback/route": "./app/api/auth/prospectus/callback/route.ts",
306
307
  "./app/api/auth/prospectus/selection/route": "./app/api/auth/prospectus/selection/route.ts",
307
308
  "./app/api/auth/prospectus/session/route": "./app/api/auth/prospectus/session/route.ts",
@@ -376,7 +377,7 @@
376
377
  "optional": true
377
378
  }
378
379
  },
379
- "version": "1.14.0",
380
+ "version": "1.16.0",
380
381
  "dependencies": {
381
382
  "@aws-amplify/adapter-nextjs": "^1.7.3",
382
383
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.14.0",
2
+ "version": "1.16.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -66,6 +66,17 @@
66
66
  "revalidate": "60"
67
67
  }
68
68
  },
69
+ {
70
+ "file": "app/api/auth/prospectus/accounts/route.ts",
71
+ "hasDefault": false,
72
+ "exports": [
73
+ "POST"
74
+ ],
75
+ "config": {
76
+ "dynamic": "\"force-dynamic\"",
77
+ "runtime": "\"nodejs\""
78
+ }
79
+ },
69
80
  {
70
81
  "file": "app/api/auth/prospectus/callback/route.ts",
71
82
  "hasDefault": false,