@anthusai/papyrus 1.13.0 → 1.15.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.
- package/amplify/data/resource.js +64 -0
- package/amplify/data/resource.ts +70 -0
- package/amplify/data/schema.js +64 -0
- package/amplify/data/schema.ts +70 -0
- package/amplify/functions/category-action/handler.ts +91 -9
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +50 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +31 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/components/administration-delegations-panel.tsx +52 -100
- package/components/curation-delegation-client.ts +46 -23
- package/components/news-desk-taxonomy-client.ts +12 -0
- package/components/newsroom-board-view.tsx +3 -5
- package/components/newsroom-newsprint-board.css +5 -54
- package/components/newsroom-organizer.css +21 -0
- package/components/newsroom-references-view.tsx +47 -5
- package/components/prospectus-account-chooser.tsx +34 -1
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/topic-steering-workspace.tsx +39 -1
- package/lib/curation-delegation-helpers.ts +35 -25
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/prospectus-account-client.ts +26 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/site-section-suggestions.ts +120 -0
- package/package.json +6 -3
- package/routes.manifest.json +12 -1
- package/components/board-card-excerpts.tsx +0 -61
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXPIRED_REQUEST_GRANTOR_SUB,
|
|
3
|
+
MAXIMUM_PENDING_DELEGATION_REQUESTS,
|
|
4
|
+
PENDING_DELEGATION_MINUTES,
|
|
5
|
+
PENDING_REQUEST_GRANTOR_SUB,
|
|
6
|
+
delegationExpiry,
|
|
7
|
+
delegationState,
|
|
8
|
+
formatUserCode,
|
|
9
|
+
newUserCode,
|
|
10
|
+
normalizeUserCode,
|
|
11
|
+
type CurationDelegation,
|
|
12
|
+
} from "./curation-delegation"
|
|
13
|
+
|
|
14
|
+
export type DelegationStore = {
|
|
15
|
+
read(delegationId: string): Promise<CurationDelegation | null>
|
|
16
|
+
write(delegation: CurationDelegation, now: string, created: boolean): Promise<void>
|
|
17
|
+
listPending(): Promise<CurationDelegation[]>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SignedInPerson = { sub: string; label: string; isAdmin: boolean }
|
|
21
|
+
|
|
22
|
+
const DELEGATION_ID_PATTERN = /^curation-delegation-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
23
|
+
const TOKEN_HASH_PATTERN = /^[0-9a-f]{64}$/
|
|
24
|
+
const MAXIMUM_AGENT_LABEL_LENGTH = 80
|
|
25
|
+
const USER_CODE_ATTEMPTS = 50
|
|
26
|
+
|
|
27
|
+
export function delegationResult(delegation: CurationDelegation, now: string) {
|
|
28
|
+
const state = delegationState(delegation, now)
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
delegationId: delegation.delegationId,
|
|
32
|
+
token: null,
|
|
33
|
+
agentLabel: delegation.agentLabel,
|
|
34
|
+
grantorLabel: delegation.grantorLabel || null,
|
|
35
|
+
expiresAt: delegation.expiresAt,
|
|
36
|
+
revokedAt: delegation.revokedAt,
|
|
37
|
+
state,
|
|
38
|
+
userCode: state === "pending" && delegation.userCode ? formatUserCode(delegation.userCode) : null,
|
|
39
|
+
requestedHours: delegation.requestedHours ?? null,
|
|
40
|
+
pendingExpiresAt: delegation.pendingExpiresAt ?? null,
|
|
41
|
+
approvedAt: delegation.approvedAt ?? null,
|
|
42
|
+
deniedAt: delegation.deniedAt ?? null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function livePendingRequests(store: DelegationStore, now: string): Promise<CurationDelegation[]> {
|
|
47
|
+
const live: CurationDelegation[] = []
|
|
48
|
+
for (const candidate of await store.listPending()) {
|
|
49
|
+
if (delegationState(candidate, now) === "pending") {
|
|
50
|
+
live.push(candidate)
|
|
51
|
+
} else if (candidate.grantorSub === PENDING_REQUEST_GRANTOR_SUB) {
|
|
52
|
+
await store.write({ ...candidate, grantorSub: EXPIRED_REQUEST_GRANTOR_SUB }, now, false)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return live
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function requestDelegation(
|
|
59
|
+
store: DelegationStore,
|
|
60
|
+
input: { delegationId: string; tokenHash: string; agentLabel: string; hours: number | null | undefined },
|
|
61
|
+
now: string,
|
|
62
|
+
) {
|
|
63
|
+
if (!DELEGATION_ID_PATTERN.test(input.delegationId)) {
|
|
64
|
+
throw new Error("The delegation id must look like curation-delegation-<uuid>.")
|
|
65
|
+
}
|
|
66
|
+
if (!TOKEN_HASH_PATTERN.test(input.tokenHash)) throw new Error("The token hash must be 64 lowercase hexadecimal characters.")
|
|
67
|
+
const agentLabel = input.agentLabel.trim()
|
|
68
|
+
if (!agentLabel) throw new Error("Name the agent asking for the delegation.")
|
|
69
|
+
if (agentLabel.length > MAXIMUM_AGENT_LABEL_LENGTH) throw new Error("The agent name is too long.")
|
|
70
|
+
const requestedHours = input.hours ?? 8
|
|
71
|
+
delegationExpiry(now, requestedHours)
|
|
72
|
+
if (await store.read(input.delegationId)) throw new Error("That delegation id is already in use.")
|
|
73
|
+
const live = await livePendingRequests(store, now)
|
|
74
|
+
if (live.length >= MAXIMUM_PENDING_DELEGATION_REQUESTS) {
|
|
75
|
+
throw new Error("Too many delegation requests are waiting for approval. Try again later.")
|
|
76
|
+
}
|
|
77
|
+
const takenCodes = new Set(live.map((request) => request.userCode))
|
|
78
|
+
let userCode = newUserCode()
|
|
79
|
+
for (let attempt = 0; takenCodes.has(userCode); attempt += 1) {
|
|
80
|
+
if (attempt >= USER_CODE_ATTEMPTS) throw new Error("Could not allocate a unique approval code. Try again.")
|
|
81
|
+
userCode = newUserCode()
|
|
82
|
+
}
|
|
83
|
+
const pendingExpiresAt = new Date(Date.parse(now) + PENDING_DELEGATION_MINUTES * 60_000).toISOString()
|
|
84
|
+
const delegation: CurationDelegation = {
|
|
85
|
+
delegationId: input.delegationId,
|
|
86
|
+
tokenHash: input.tokenHash,
|
|
87
|
+
grantorSub: PENDING_REQUEST_GRANTOR_SUB,
|
|
88
|
+
grantorLabel: "",
|
|
89
|
+
agentLabel,
|
|
90
|
+
createdAt: now,
|
|
91
|
+
expiresAt: pendingExpiresAt,
|
|
92
|
+
revokedAt: null,
|
|
93
|
+
userCode,
|
|
94
|
+
requestedHours,
|
|
95
|
+
pendingExpiresAt,
|
|
96
|
+
approvedAt: null,
|
|
97
|
+
deniedAt: null,
|
|
98
|
+
}
|
|
99
|
+
await store.write(delegation, now, true)
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
delegationId: delegation.delegationId,
|
|
103
|
+
userCode: formatUserCode(userCode),
|
|
104
|
+
pendingExpiresAt,
|
|
105
|
+
requestedHours,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function findLivePendingByCode(store: DelegationStore, typedCode: string, now: string): Promise<CurationDelegation> {
|
|
110
|
+
const code = normalizeUserCode(typedCode)
|
|
111
|
+
const found = code ? (await livePendingRequests(store, now)).find((request) => request.userCode === code) : undefined
|
|
112
|
+
if (!found) throw new Error("No pending delegation request has that code. It may have expired or already been decided.")
|
|
113
|
+
return found
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function approveDelegation(store: DelegationStore, person: SignedInPerson, typedCode: string, now: string) {
|
|
117
|
+
const pending = await findLivePendingByCode(store, typedCode, now)
|
|
118
|
+
const approved: CurationDelegation = {
|
|
119
|
+
...pending,
|
|
120
|
+
grantorSub: person.sub,
|
|
121
|
+
grantorLabel: person.label,
|
|
122
|
+
createdAt: now,
|
|
123
|
+
expiresAt: delegationExpiry(now, pending.requestedHours),
|
|
124
|
+
approvedAt: now,
|
|
125
|
+
userCode: null,
|
|
126
|
+
}
|
|
127
|
+
await store.write(approved, now, false)
|
|
128
|
+
return delegationResult(approved, now)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function denyDelegation(store: DelegationStore, person: SignedInPerson, typedCode: string, now: string) {
|
|
132
|
+
const pending = await findLivePendingByCode(store, typedCode, now)
|
|
133
|
+
const denied: CurationDelegation = { ...pending, grantorSub: person.sub, grantorLabel: person.label, deniedAt: now, userCode: null }
|
|
134
|
+
await store.write(denied, now, false)
|
|
135
|
+
return delegationResult(denied, now)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function delegationStatus(store: DelegationStore, delegationId: string, now: string) {
|
|
139
|
+
const delegation = await store.read(delegationId)
|
|
140
|
+
if (!delegation) throw new Error("That delegation was not found.")
|
|
141
|
+
return {
|
|
142
|
+
delegationId: delegation.delegationId,
|
|
143
|
+
state: delegationState(delegation, now),
|
|
144
|
+
expiresAt: delegation.expiresAt,
|
|
145
|
+
grantorLabel: delegation.grantorLabel || null,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto"
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto"
|
|
2
2
|
|
|
3
3
|
export const CURATION_DELEGATION_PAYLOAD_KIND = "curation-delegation"
|
|
4
4
|
export const CURATION_DELEGATION_OWNER_TYPE = "curationDelegation"
|
|
@@ -14,11 +14,18 @@ export type CurationDelegation = {
|
|
|
14
14
|
createdAt: string
|
|
15
15
|
expiresAt: string
|
|
16
16
|
revokedAt: string | null
|
|
17
|
+
userCode?: string | null
|
|
18
|
+
requestedHours?: number | null
|
|
19
|
+
pendingExpiresAt?: string | null
|
|
20
|
+
approvedAt?: string | null
|
|
21
|
+
deniedAt?: string | null
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
export type DelegationState = "pending" | "approved" | "denied" | "expired" | "revoked"
|
|
25
|
+
|
|
19
26
|
export type DelegationVerdict =
|
|
20
27
|
| { ok: true }
|
|
21
|
-
| { ok: false; reason: "unknown-token" | "wrong-agent" | "revoked" | "expired" }
|
|
28
|
+
| { ok: false; reason: "unknown-token" | "wrong-agent" | "revoked" | "not-approved" | "expired" }
|
|
22
29
|
|
|
23
30
|
export function hashDelegationToken(token: string): string {
|
|
24
31
|
return createHash("sha256").update(token, "utf8").digest("hex")
|
|
@@ -53,6 +60,10 @@ export function delegationVerdict(
|
|
|
53
60
|
return { ok: false, reason: "wrong-agent" }
|
|
54
61
|
}
|
|
55
62
|
if (delegation.revokedAt) return { ok: false, reason: "revoked" }
|
|
63
|
+
const state = delegationState(delegation, presented.now)
|
|
64
|
+
if (state === "pending" || state === "denied" || (state === "expired" && delegation.userCode && !delegation.approvedAt)) {
|
|
65
|
+
return { ok: false, reason: "not-approved" }
|
|
66
|
+
}
|
|
56
67
|
if (Date.parse(delegation.expiresAt) <= Date.parse(presented.now)) return { ok: false, reason: "expired" }
|
|
57
68
|
return { ok: true }
|
|
58
69
|
}
|
|
@@ -62,6 +73,43 @@ export function delegationRefusalMessage(reason: Exclude<DelegationVerdict, { ok
|
|
|
62
73
|
"unknown-token": "That delegation token is not recognized.",
|
|
63
74
|
"wrong-agent": "That delegation was granted to a different agent.",
|
|
64
75
|
"revoked": "That delegation was revoked.",
|
|
76
|
+
"not-approved": "That delegation has not been approved by a person. Ask them to approve the code in the Newsroom.",
|
|
65
77
|
"expired": "That delegation has expired. Ask the person for a new one.",
|
|
66
78
|
}[reason]
|
|
67
79
|
}
|
|
80
|
+
|
|
81
|
+
export const PENDING_DELEGATION_MINUTES = 10
|
|
82
|
+
export const MAXIMUM_PENDING_DELEGATION_REQUESTS = 5
|
|
83
|
+
export const PENDING_REQUEST_GRANTOR_SUB = "pending-device-authorization"
|
|
84
|
+
export const EXPIRED_REQUEST_GRANTOR_SUB = "expired-device-authorization"
|
|
85
|
+
export const USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
86
|
+
export const USER_CODE_LENGTH = 8
|
|
87
|
+
|
|
88
|
+
export function newUserCode(): string {
|
|
89
|
+
let code = ""
|
|
90
|
+
for (let position = 0; position < USER_CODE_LENGTH; position += 1) {
|
|
91
|
+
code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]
|
|
92
|
+
}
|
|
93
|
+
return code
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatUserCode(code: string): string {
|
|
97
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function normalizeUserCode(typed: string | null | undefined): string | null {
|
|
101
|
+
const stripped = (typed ?? "").toUpperCase().replace(/[\s-]/g, "")
|
|
102
|
+
if (stripped.length !== USER_CODE_LENGTH) return null
|
|
103
|
+
for (const character of stripped) if (!USER_CODE_ALPHABET.includes(character)) return null
|
|
104
|
+
return stripped
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function delegationState(delegation: CurationDelegation, now: string): DelegationState {
|
|
108
|
+
if (delegation.revokedAt) return "revoked"
|
|
109
|
+
if (delegation.deniedAt) return "denied"
|
|
110
|
+
if (delegation.userCode && !delegation.approvedAt) {
|
|
111
|
+
const pendingEnd = delegation.pendingExpiresAt ?? delegation.expiresAt
|
|
112
|
+
return Date.parse(pendingEnd) <= Date.parse(now) ? "expired" : "pending"
|
|
113
|
+
}
|
|
114
|
+
return Date.parse(delegation.expiresAt) <= Date.parse(now) ? "expired" : "approved"
|
|
115
|
+
}
|
|
@@ -13,6 +13,7 @@ export type ReferenceCurationMetadataInput = {
|
|
|
13
13
|
decisionRelationId?: string | null
|
|
14
14
|
shareable?: boolean | null
|
|
15
15
|
actorKind?: CurationActorKind | null
|
|
16
|
+
sectionId?: string | null
|
|
16
17
|
delegation?: { delegationId: string; relayedBy: string; instruction: string } | null
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -39,6 +40,7 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
|
|
|
39
40
|
referenceId: input.referenceId,
|
|
40
41
|
referenceLineageId: input.referenceLineageId,
|
|
41
42
|
...(input.actorKind ? {actorKind: input.actorKind} : {}),
|
|
43
|
+
...(input.sectionId?.trim() ? {sectionId: input.sectionId.trim()} : {}),
|
|
42
44
|
...(input.delegation
|
|
43
45
|
? {attestation: "delegated", relayedBy: input.delegation.relayedBy, delegationId: input.delegation.delegationId,
|
|
44
46
|
instruction: input.delegation.instruction}
|
|
@@ -47,3 +49,32 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
|
|
|
47
49
|
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
export type CurationSectionCandidate = {
|
|
54
|
+
id?: string | null
|
|
55
|
+
enabled?: boolean | null
|
|
56
|
+
enabledStatus?: string | null
|
|
57
|
+
} | null | undefined
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A curation may name the newsroom Section the Reference belongs in, but only
|
|
61
|
+
* when it accepts the Reference and the Section exists and is enabled.
|
|
62
|
+
*/
|
|
63
|
+
export function validatedCurationSectionId(
|
|
64
|
+
requestedSectionId: string | null | undefined,
|
|
65
|
+
action: string,
|
|
66
|
+
section: CurationSectionCandidate,
|
|
67
|
+
): string | null {
|
|
68
|
+
const sectionId = requestedSectionId?.trim() || null
|
|
69
|
+
if (!sectionId) return null
|
|
70
|
+
if (action !== "accept") {
|
|
71
|
+
throw new Error("A section can be recorded only when accepting a reference.")
|
|
72
|
+
}
|
|
73
|
+
if (!section || section.id !== sectionId) {
|
|
74
|
+
throw new Error(`Newsroom section ${sectionId} was not found.`)
|
|
75
|
+
}
|
|
76
|
+
if (section.enabled === false || section.enabledStatus === "disabled") {
|
|
77
|
+
throw new Error(`Newsroom section ${sectionId} is disabled.`)
|
|
78
|
+
}
|
|
79
|
+
return sectionId
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleProspectusPublishingAccountCreation } from "../../../../../lib/prospectus-sign-in-routes";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleProspectusPublishingAccountCreation(request);
|
|
9
|
+
}
|
|
@@ -1,41 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
|
-
import { fetchAuthSession } from "aws-amplify/auth";
|
|
5
|
-
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
4
|
import {
|
|
7
|
-
|
|
8
|
-
MAXIMUM_DELEGATION_HOURS,
|
|
9
|
-
MINIMUM_DELEGATION_HOURS,
|
|
10
|
-
buildDelegatedCommandLine,
|
|
11
|
-
delegationExpiryText,
|
|
5
|
+
delegationStateText,
|
|
12
6
|
describeDelegationError,
|
|
13
|
-
|
|
7
|
+
isPendingDelegation,
|
|
8
|
+
pendingCountdownText,
|
|
9
|
+
requestedHoursText,
|
|
14
10
|
type CurationDelegationRecord,
|
|
15
11
|
} from "../lib/curation-delegation-helpers";
|
|
16
|
-
import {
|
|
12
|
+
import { approveCurationDelegation, denyCurationDelegation, listCurationDelegations, revokeCurationDelegation } from "./curation-delegation-client";
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
async function readSignedInEmail(): Promise<string> {
|
|
21
|
-
if (isLocalPreviewEnabled()) return "ryan@example.com";
|
|
22
|
-
try {
|
|
23
|
-
const session = await fetchAuthSession();
|
|
24
|
-
const email = session.tokens?.idToken?.payload?.email;
|
|
25
|
-
return typeof email === "string" ? email : "";
|
|
26
|
-
} catch {
|
|
27
|
-
return "";
|
|
28
|
-
}
|
|
29
|
-
}
|
|
14
|
+
const REFRESH_MILLISECONDS = 10_000;
|
|
30
15
|
|
|
31
16
|
export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean }) {
|
|
32
|
-
const [
|
|
33
|
-
const [hoursText, setHoursText] = useState(String(DEFAULT_DELEGATION_HOURS));
|
|
17
|
+
const [busyCode, setBusyCode] = useState<string | null>(null);
|
|
34
18
|
const [busy, setBusy] = useState(false);
|
|
35
19
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
36
|
-
const [issued, setIssued] = useState<IssuedToken | null>(null);
|
|
37
|
-
const [copied, setCopied] = useState<"token" | "command" | null>(null);
|
|
38
|
-
const [email, setEmail] = useState("");
|
|
39
20
|
const [delegations, setDelegations] = useState<CurationDelegationRecord[]>([]);
|
|
40
21
|
const [confirmingRevokeId, setConfirmingRevokeId] = useState<string | null>(null);
|
|
41
22
|
const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now());
|
|
@@ -43,15 +24,13 @@ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean
|
|
|
43
24
|
|
|
44
25
|
useEffect(() => {
|
|
45
26
|
mounted.current = true;
|
|
46
|
-
void readSignedInEmail().then((value) => {
|
|
47
|
-
if (mounted.current) setEmail(value);
|
|
48
|
-
});
|
|
49
27
|
void refreshDelegations();
|
|
50
|
-
const
|
|
28
|
+
const refreshTimer = window.setInterval(() => void refreshDelegations(), REFRESH_MILLISECONDS);
|
|
29
|
+
const clockTimer = window.setInterval(() => setNowMilliseconds(Date.now()), 1_000);
|
|
51
30
|
return () => {
|
|
52
31
|
mounted.current = false;
|
|
53
|
-
window.clearInterval(
|
|
54
|
-
|
|
32
|
+
window.clearInterval(refreshTimer);
|
|
33
|
+
window.clearInterval(clockTimer);
|
|
55
34
|
};
|
|
56
35
|
}, []);
|
|
57
36
|
|
|
@@ -64,25 +43,17 @@ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean
|
|
|
64
43
|
}
|
|
65
44
|
}
|
|
66
45
|
|
|
67
|
-
async function
|
|
68
|
-
|
|
69
|
-
if (!validation.ok) {
|
|
70
|
-
setErrorMessage(validation.message);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
setBusy(true);
|
|
46
|
+
async function decide(userCode: string, decision: "approve" | "deny") {
|
|
47
|
+
setBusyCode(userCode);
|
|
74
48
|
setErrorMessage(null);
|
|
75
49
|
try {
|
|
76
|
-
|
|
77
|
-
setIssued({ agentLabel: created.agentLabel, token: created.token });
|
|
78
|
-
setCopied(null);
|
|
50
|
+
await (decision === "approve" ? approveCurationDelegation(userCode) : denyCurationDelegation(userCode));
|
|
79
51
|
await refreshDelegations();
|
|
80
|
-
setAgentLabelText("");
|
|
81
|
-
setNowMilliseconds(Date.now());
|
|
82
52
|
} catch (error) {
|
|
83
53
|
setErrorMessage(describeDelegationError(error));
|
|
54
|
+
await refreshDelegations();
|
|
84
55
|
} finally {
|
|
85
|
-
|
|
56
|
+
setBusyCode(null);
|
|
86
57
|
}
|
|
87
58
|
}
|
|
88
59
|
|
|
@@ -100,81 +71,62 @@ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean
|
|
|
100
71
|
}
|
|
101
72
|
}
|
|
102
73
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await navigator.clipboard.writeText(text);
|
|
106
|
-
setCopied(kind);
|
|
107
|
-
} catch {
|
|
108
|
-
setErrorMessage("Copying was blocked by the browser. Select the text and copy it by hand.");
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const commandLine = issued ? buildDelegatedCommandLine({ email, agentLabel: issued.agentLabel, token: issued.token }) : "";
|
|
74
|
+
const pending = delegations.filter((entry) => isPendingDelegation(entry, nowMilliseconds));
|
|
75
|
+
const others = delegations.filter((entry) => entry.state !== "pending");
|
|
113
76
|
|
|
114
77
|
return (
|
|
115
78
|
<div data-news-desk-admin-panel="delegations">
|
|
116
|
-
<article className="news-desk-doctrine-card">
|
|
79
|
+
<article className="news-desk-doctrine-card" data-delegation-pending-block>
|
|
117
80
|
<div className="news-desk-doctrine-card__header">
|
|
118
81
|
<div>
|
|
119
|
-
<h3>
|
|
120
|
-
<span>
|
|
82
|
+
<h3>Pending requests</h3>
|
|
83
|
+
<span>An agent asked to record your decisions. Check the code matches what it showed you.</span>
|
|
121
84
|
</div>
|
|
122
85
|
</div>
|
|
123
|
-
<p className="news-desk-delegation-
|
|
124
|
-
|
|
125
|
-
|
|
86
|
+
{errorMessage ? <p className="news-desk-delegation-error" role="alert" data-delegation-error>{errorMessage}</p> : null}
|
|
87
|
+
{pending.length === 0 ? (
|
|
88
|
+
<p className="news-desk-delegation-note" data-delegation-pending-empty>No agent is waiting for approval. A request appears here as soon as an agent asks.</p>
|
|
89
|
+
) : (
|
|
90
|
+
<ul className="news-desk-delegation-pending-list">
|
|
91
|
+
{pending.map((entry) => {
|
|
92
|
+
const userCode = entry.userCode ?? "";
|
|
93
|
+
const working = busyCode === userCode;
|
|
94
|
+
return (
|
|
95
|
+
<li key={entry.delegationId} data-delegation-pending={entry.delegationId}>
|
|
96
|
+
<div>
|
|
97
|
+
<strong>{entry.agentLabel}</strong>
|
|
98
|
+
<span>{requestedHoursText(entry.requestedHours)}</span>
|
|
99
|
+
<span className="news-desk-delegation-countdown" data-delegation-countdown>{pendingCountdownText(entry.pendingExpiresAt ?? entry.expiresAt, nowMilliseconds)}</span>
|
|
100
|
+
</div>
|
|
101
|
+
<code className="news-desk-delegation-code" data-delegation-user-code aria-label={`Code ${userCode.split("").join(" ")}`}>{userCode}</code>
|
|
102
|
+
<span className="news-desk-delegation-decision">
|
|
103
|
+
<button type="button" data-delegation-approve onClick={() => void decide(userCode, "approve")} disabled={disabled || working || busyCode !== null}>Approve</button>
|
|
104
|
+
<button type="button" data-delegation-deny onClick={() => void decide(userCode, "deny")} disabled={disabled || working || busyCode !== null}>Deny</button>
|
|
105
|
+
</span>
|
|
106
|
+
</li>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</ul>
|
|
110
|
+
)}
|
|
126
111
|
<p className="news-desk-delegation-note" data-delegation-words-note>
|
|
127
|
-
|
|
112
|
+
While a delegation lasts, that agent's approvals and rejections are recorded under your name and marked as relayed, with the words you gave it kept with each record. You can revoke it at any moment.
|
|
128
113
|
</p>
|
|
129
|
-
<div className="news-desk-delegation-form">
|
|
130
|
-
<label className="news-desk-doctrine-card__field">
|
|
131
|
-
<span>Agent name</span>
|
|
132
|
-
<input value={agentLabelText} onChange={(event) => setAgentLabelText(event.target.value)} placeholder="curator-agent" autoComplete="off" disabled={disabled || busy} />
|
|
133
|
-
</label>
|
|
134
|
-
<label className="news-desk-doctrine-card__field">
|
|
135
|
-
<span>{`Hours (${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS})`}</span>
|
|
136
|
-
<input value={hoursText} onChange={(event) => setHoursText(event.target.value)} inputMode="numeric" disabled={disabled || busy} />
|
|
137
|
-
</label>
|
|
138
|
-
<button type="button" className="news-desk-primary-action-button" data-delegation-create onClick={() => void handleCreate()} disabled={disabled || busy}>
|
|
139
|
-
{busy && !issued ? "Creating" : "Create delegation"}
|
|
140
|
-
</button>
|
|
141
|
-
</div>
|
|
142
|
-
{errorMessage ? <p className="news-desk-delegation-error" role="alert" data-delegation-error>{errorMessage}</p> : null}
|
|
143
114
|
</article>
|
|
144
115
|
|
|
145
|
-
{issued ? (
|
|
146
|
-
<article className="news-desk-delegation-token" data-delegation-token-box aria-live="polite">
|
|
147
|
-
<h3>Copy this token now</h3>
|
|
148
|
-
<p>This token is shown once. Papyrus does not keep it, and it cannot be shown again. If you lose it, revoke the delegation and create a new one.</p>
|
|
149
|
-
<div className="news-desk-delegation-token__row">
|
|
150
|
-
<code data-delegation-token>{issued.token}</code>
|
|
151
|
-
<button type="button" onClick={() => void copyText("token", issued.token)}>{copied === "token" ? "Copied" : "Copy token"}</button>
|
|
152
|
-
</div>
|
|
153
|
-
<p className="story-label">Command line for {issued.agentLabel}</p>
|
|
154
|
-
<div className="news-desk-delegation-token__row">
|
|
155
|
-
<code data-delegation-command>{commandLine}</code>
|
|
156
|
-
<button type="button" onClick={() => void copyText("command", commandLine)}>{copied === "command" ? "Copied" : "Copy command"}</button>
|
|
157
|
-
</div>
|
|
158
|
-
<div className="news-desk-doctrine-card__actions">
|
|
159
|
-
<button type="button" className="news-desk-primary-action-button" data-delegation-done onClick={() => { setIssued(null); setCopied(null); }}>Done</button>
|
|
160
|
-
</div>
|
|
161
|
-
</article>
|
|
162
|
-
) : null}
|
|
163
|
-
|
|
164
116
|
<article className="news-desk-doctrine-card">
|
|
165
117
|
<div className="news-desk-doctrine-card__header">
|
|
166
118
|
<div>
|
|
167
119
|
<h3>Delegations</h3>
|
|
168
|
-
<span>
|
|
120
|
+
<span>Decided requests, newest first</span>
|
|
169
121
|
</div>
|
|
170
122
|
</div>
|
|
171
|
-
{
|
|
123
|
+
{others.length === 0 ? (
|
|
172
124
|
<p className="news-desk-delegation-note" data-delegation-empty>You have not granted any delegations.</p>
|
|
173
125
|
) : (
|
|
174
126
|
<ul className="news-desk-delegation-list">
|
|
175
|
-
{
|
|
176
|
-
const stateText =
|
|
177
|
-
const live = !entry.revokedAt && stateText !== "Expired";
|
|
127
|
+
{others.map((entry) => {
|
|
128
|
+
const stateText = delegationStateText(entry, nowMilliseconds);
|
|
129
|
+
const live = entry.state === "approved" && !entry.revokedAt && stateText !== "Expired";
|
|
178
130
|
return (
|
|
179
131
|
<li key={entry.delegationId} data-delegation-row={entry.delegationId} data-delegation-live={live || undefined}>
|
|
180
132
|
<div>
|
|
@@ -7,20 +7,24 @@ import { configureAmplifyClient } from "./amplify-client-provider";
|
|
|
7
7
|
|
|
8
8
|
const USER_POOL_AUTH_MODE = "userPool";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const DELEGATION_FIELDS = "delegationId agentLabel grantorLabel expiresAt revokedAt state userCode requestedHours pendingExpiresAt approvedAt deniedAt";
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
mutation
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
const APPROVE_MUTATION = `
|
|
13
|
+
mutation ApproveCurationDelegation($userCode: String!) {
|
|
14
|
+
approveCurationDelegation(userCode: $userCode) { ${DELEGATION_FIELDS} }
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const DENY_MUTATION = `
|
|
19
|
+
mutation DenyCurationDelegation($userCode: String!) {
|
|
20
|
+
denyCurationDelegation(userCode: $userCode) { ${DELEGATION_FIELDS} }
|
|
17
21
|
}
|
|
18
22
|
`;
|
|
19
23
|
|
|
20
24
|
const REVOKE_MUTATION = `
|
|
21
25
|
mutation RevokeCurationDelegation($delegationId: ID!) {
|
|
22
26
|
revokeCurationDelegation(delegationId: $delegationId) {
|
|
23
|
-
|
|
27
|
+
${DELEGATION_FIELDS}
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
`;
|
|
@@ -28,12 +32,24 @@ const REVOKE_MUTATION = `
|
|
|
28
32
|
const LIST_QUERY = `
|
|
29
33
|
query ListCurationDelegations {
|
|
30
34
|
listCurationDelegations {
|
|
31
|
-
|
|
35
|
+
${DELEGATION_FIELDS}
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
`;
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
function previewPendingRequest(agentLabel: string, userCode: string, requestedHours: number, minutesLeft: number, id: string): CurationDelegationRecord {
|
|
41
|
+
const pendingExpiresAt = new Date(Date.now() + minutesLeft * 60_000).toISOString();
|
|
42
|
+
return { delegationId: id, agentLabel, grantorLabel: null, expiresAt: pendingExpiresAt, revokedAt: null, state: "pending", userCode, requestedHours, pendingExpiresAt, approvedAt: null, deniedAt: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const previewDelegations: CurationDelegationRecord[] = [
|
|
46
|
+
previewPendingRequest("e2e-check", "WDJB-MJHT", 1, 9.5, "curation-delegation-preview-pending-1"),
|
|
47
|
+
previewPendingRequest("section-curator", "KQPR-4XNZ", 8, 4, "curation-delegation-preview-pending-2"),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function normalizedCode(code: string): string {
|
|
51
|
+
return code.replace(/[^a-z0-9]/gi, "").toUpperCase();
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
async function runGraphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
39
55
|
configureAmplifyClient();
|
|
@@ -46,21 +62,27 @@ async function runGraphql<T>(query: string, variables: Record<string, unknown>):
|
|
|
46
62
|
return response.data;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
export async function
|
|
65
|
+
export async function approveCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
|
|
50
66
|
if (isLocalPreviewEnabled()) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
const record = previewDelegations.find((entry) => entry.state === "pending" && normalizedCode(entry.userCode ?? "") === normalizedCode(userCode));
|
|
68
|
+
if (!record) throw new Error("That code was not found, has expired, or was already decided.");
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
Object.assign(record, { state: "approved", userCode: null, grantorLabel: "ryan@example.com", approvedAt: new Date(now).toISOString(), expiresAt: new Date(now + (record.requestedHours ?? 1) * 3_600_000).toISOString() });
|
|
71
|
+
return { ...record };
|
|
72
|
+
}
|
|
73
|
+
const data = await runGraphql<{ approveCurationDelegation: CurationDelegationRecord }>(APPROVE_MUTATION, { userCode });
|
|
74
|
+
return data.approveCurationDelegation;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function denyCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
|
|
78
|
+
if (isLocalPreviewEnabled()) {
|
|
79
|
+
const record = previewDelegations.find((entry) => entry.state === "pending" && normalizedCode(entry.userCode ?? "") === normalizedCode(userCode));
|
|
80
|
+
if (!record) throw new Error("That code was not found, has expired, or was already decided.");
|
|
81
|
+
Object.assign(record, { state: "denied", userCode: null, deniedAt: new Date().toISOString() });
|
|
82
|
+
return { ...record };
|
|
61
83
|
}
|
|
62
|
-
const data = await runGraphql<{
|
|
63
|
-
return data.
|
|
84
|
+
const data = await runGraphql<{ denyCurationDelegation: CurationDelegationRecord }>(DENY_MUTATION, { userCode });
|
|
85
|
+
return data.denyCurationDelegation;
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
export async function revokeCurationDelegation(delegationId: string): Promise<CurationDelegationRecord> {
|
|
@@ -68,6 +90,7 @@ export async function revokeCurationDelegation(delegationId: string): Promise<Cu
|
|
|
68
90
|
const record = previewDelegations.find((entry) => entry.delegationId === delegationId);
|
|
69
91
|
if (!record) throw new Error("That delegation was not found.");
|
|
70
92
|
record.revokedAt = record.revokedAt ?? new Date().toISOString();
|
|
93
|
+
record.state = "revoked";
|
|
71
94
|
return { ...record };
|
|
72
95
|
}
|
|
73
96
|
const data = await runGraphql<{ revokeCurationDelegation: CurationDelegationRecord }>(REVOKE_MUTATION, { delegationId });
|
|
@@ -75,7 +98,7 @@ export async function revokeCurationDelegation(delegationId: string): Promise<Cu
|
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
export async function listCurationDelegations(): Promise<CurationDelegationRecord[]> {
|
|
78
|
-
if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry }))
|
|
101
|
+
if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry }));
|
|
79
102
|
const data = await runGraphql<{ listCurationDelegations: CurationDelegationRecord[] }>(LIST_QUERY, {});
|
|
80
103
|
return data.listCurationDelegations ?? [];
|
|
81
104
|
}
|
|
@@ -3628,6 +3628,18 @@ export async function loadCyclotronShadowAgreement(cyclotronId: string): Promise
|
|
|
3628
3628
|
return latestShadowAgreement(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
|
|
3629
3629
|
}
|
|
3630
3630
|
|
|
3631
|
+
/** The site cyclotron's latest per-Section agreement payload, as the Section sweep recorded it. */
|
|
3632
|
+
export async function loadSiteSectionAgreement(cyclotronId: string): Promise<import("../lib/site-section-suggestions").SiteSectionAgreement | null> {
|
|
3633
|
+
const { shadowAgreementPayloadId } = await import("../lib/cyclotron-suggestions");
|
|
3634
|
+
const { parseSiteSectionAgreement } = await import("../lib/site-section-suggestions");
|
|
3635
|
+
const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", shadowAgreementPayloadId(cyclotronId), ["raw_payload"]);
|
|
3636
|
+
const parsed = payloads
|
|
3637
|
+
.map((payload) => ({ agreement: parseSiteSectionAgreement(payload.json), updatedAt: payload.attachment.updatedAt }))
|
|
3638
|
+
.filter((entry) => entry.agreement !== null)
|
|
3639
|
+
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
|
|
3640
|
+
return parsed.length ? parsed[0].agreement : null;
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3631
3643
|
/** Ask the next decide-relevance sweep to set or clear a manual review rate. */
|
|
3632
3644
|
export async function requestCyclotronReviewRate(
|
|
3633
3645
|
cyclotronId: string,
|