@anthusai/papyrus 5.36.0 → 5.37.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/plugins/reader-steering/function/handler.py +12 -5
- package/amplify/plugins/reader-steering/function/proposals.py +104 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.js +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +1 -1
- package/amplify/plugins/reader-steering/schema.js +19 -0
- package/amplify/plugins/reader-steering/schema.ts +19 -0
- package/lib/prospectus-sign-in-routes.ts +5 -4
- package/lib/prospectus-site-identity.ts +22 -5
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -1015,15 +1015,22 @@ class DynamoReaderStore:
|
|
|
1015
1015
|
raise
|
|
1016
1016
|
return True
|
|
1017
1017
|
|
|
1018
|
-
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
|
|
1018
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
|
|
1019
|
+
"""Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
|
|
1019
1020
|
names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
|
|
1020
|
-
|
|
1021
|
-
|
|
1021
|
+
sets = ", ".join(f"#c{index} = :c{index}" for index, value in enumerate(changes.values()) if value is not None)
|
|
1022
|
+
removes = ", ".join(f"#c{index}" for index, value in enumerate(changes.values()) if value is None)
|
|
1023
|
+
values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values()) if value is not None}}
|
|
1024
|
+
condition = "#st = :expected"
|
|
1025
|
+
if expected_updated_at is not None:
|
|
1026
|
+
condition += " AND #u = :seen"
|
|
1027
|
+
values[":seen"] = expected_updated_at
|
|
1028
|
+
expression = f"SET {sets + ', ' if sets else ''}#u = :now" + (f" REMOVE {removes}" if removes else "")
|
|
1022
1029
|
try:
|
|
1023
1030
|
self._table("proposal").update_item(
|
|
1024
1031
|
Key={"id": proposal_id},
|
|
1025
|
-
UpdateExpression=
|
|
1026
|
-
ConditionExpression=
|
|
1032
|
+
UpdateExpression=expression,
|
|
1033
|
+
ConditionExpression=condition,
|
|
1027
1034
|
ExpressionAttributeNames=names,
|
|
1028
1035
|
ExpressionAttributeValues=values,
|
|
1029
1036
|
)
|
|
@@ -6,7 +6,7 @@ and steered by votes; an editor decides. Staff reach this through the ``fileProp
|
|
|
6
6
|
mutations (editors and admins; the function checks the groups again). An operator's IAM role in the function's own account
|
|
7
7
|
(an unattended automation or an agent without a browser sign-in) may also file an AI proposal and move it from draft to
|
|
8
8
|
in_steering or declined, and nothing else: approving, deciding after review and overriding stay with editors and admins.
|
|
9
|
-
Every state change is appended to the proposal's
|
|
9
|
+
``editProposal`` is the editor tier (pin, hold, exclude and restore votes): editors and admins only, never an operator. Every state change is appended to the proposal's
|
|
10
10
|
``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
|
|
11
11
|
|
|
12
12
|
Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
|
|
@@ -56,7 +56,19 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
|
|
|
56
56
|
}
|
|
57
57
|
TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
58
58
|
|
|
59
|
-
MUTATIONS = ("fileProposal", "transitionProposal")
|
|
59
|
+
MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
|
|
60
|
+
|
|
61
|
+
#: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
|
|
62
|
+
PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
|
|
63
|
+
EDIT_ACTIONS = (PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION)
|
|
64
|
+
FINAL_STATES = (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
|
|
65
|
+
#: The batch rules for excluding votes. A rule excludes the votes on one proposal that were already cast, by accounts that were
|
|
66
|
+
#: younger than ``hours`` when they voted; the snapshot applies it (votes are not counted here). ``label`` is the public wording.
|
|
67
|
+
EXCLUSION_RULES: dict[str, dict[str, Any]] = {
|
|
68
|
+
"new-accounts-24h": {"hours": 24, "label": "accounts under 24 h"},
|
|
69
|
+
}
|
|
70
|
+
EXCLUSION_ID = re.compile(r"exc-[0-9a-f]{12}")
|
|
71
|
+
MAXIMUM_EDITOR_LOG = 200
|
|
60
72
|
|
|
61
73
|
|
|
62
74
|
def event_field_name(event: dict[str, Any]) -> str | None:
|
|
@@ -109,7 +121,7 @@ class ProposalStore(Protocol):
|
|
|
109
121
|
|
|
110
122
|
def create_proposal(self, row: dict[str, Any]) -> bool: ...
|
|
111
123
|
|
|
112
|
-
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool: ...
|
|
124
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
|
|
113
125
|
|
|
114
126
|
|
|
115
127
|
def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
@@ -238,6 +250,8 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
|
|
|
238
250
|
return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
|
|
239
251
|
if to_state not in TRANSITIONS.get(state, ()):
|
|
240
252
|
return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
|
|
253
|
+
if row.get("held"):
|
|
254
|
+
return refused("proposal_held", "That proposal is on hold. Release it before it moves.")
|
|
241
255
|
text = None
|
|
242
256
|
if reason is not None and str(reason).strip():
|
|
243
257
|
text = clean_text(reason, MAXIMUM_REASON)
|
|
@@ -254,7 +268,11 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
|
|
|
254
268
|
if result is not None:
|
|
255
269
|
entry["result"] = result
|
|
256
270
|
history = [*(parse_json(row.get("history")) or []), entry]
|
|
257
|
-
|
|
271
|
+
changes: dict[str, Any] = {"state": to_state, "history": json.dumps(history)}
|
|
272
|
+
if to_state == OVERRIDDEN_BY_EDITOR:
|
|
273
|
+
# Stored on the proposal beside the community result, which stays as it was: readers see both.
|
|
274
|
+
changes["override"] = json.dumps({"result": result, "reason": text, "at": now_iso})
|
|
275
|
+
return changes
|
|
258
276
|
|
|
259
277
|
|
|
260
278
|
def apply_transition(
|
|
@@ -285,8 +303,77 @@ def apply_transition(
|
|
|
285
303
|
return Outcome("ok", id=proposal_id)
|
|
286
304
|
|
|
287
305
|
|
|
306
|
+
def log_entry(action: str, actor_sub: str, now_iso: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
307
|
+
entry: dict[str, Any] = {"action": action, "by": BY_STAFF, "actor": actor_sub, "at": now_iso}
|
|
308
|
+
if detail:
|
|
309
|
+
entry["detail"] = detail
|
|
310
|
+
return entry
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def edit_plan(row: dict[str, Any], action: Any, *, rule: Any = None, exclusion_id: Any = None, actor_sub: str, now_iso: str) -> dict[str, Any] | Outcome:
|
|
314
|
+
"""The changes for a pin, hold or vote-exclusion action on ``row``, or why it may not be done.
|
|
315
|
+
|
|
316
|
+
Every action is appended to the private ``editorLog`` in the same write. Changing the exclusions clears the frozen
|
|
317
|
+
``communityResult`` so the next snapshot fixes it again with the exclusions applied; the function never counts votes itself.
|
|
318
|
+
"""
|
|
319
|
+
if action not in EDIT_ACTIONS:
|
|
320
|
+
return refused("invalid_action", f"action must be one of {', '.join(EDIT_ACTIONS)}.")
|
|
321
|
+
detail: dict[str, Any] | None = None
|
|
322
|
+
changes: dict[str, Any] = {}
|
|
323
|
+
if action in (PIN, UNPIN):
|
|
324
|
+
if bool(row.get("pinned")) == (action == PIN):
|
|
325
|
+
return refused("already_pinned" if action == PIN else "not_pinned", "That proposal is already pinned." if action == PIN else "That proposal is not pinned.")
|
|
326
|
+
changes["pinned"] = action == PIN
|
|
327
|
+
elif action in (HOLD, RELEASE):
|
|
328
|
+
if action == HOLD and row.get("state") in FINAL_STATES:
|
|
329
|
+
return refused("proposal_decided", "A decided proposal cannot be put on hold.")
|
|
330
|
+
if bool(row.get("held")) == (action == HOLD):
|
|
331
|
+
return refused("already_held" if action == HOLD else "not_held", "That proposal is already on hold." if action == HOLD else "That proposal is not on hold.")
|
|
332
|
+
changes["held"] = action == HOLD
|
|
333
|
+
else:
|
|
334
|
+
exclusions = parse_json(row.get("exclusions")) or []
|
|
335
|
+
if action == EXCLUDE_VOTES:
|
|
336
|
+
if rule not in EXCLUSION_RULES:
|
|
337
|
+
return refused("invalid_rule", f"rule must be one of {', '.join(EXCLUSION_RULES)}.")
|
|
338
|
+
identifier = "exc-" + uuid.uuid4().hex[:12]
|
|
339
|
+
exclusions = [*exclusions, {"id": identifier, "rule": rule, "at": now_iso, "actor": actor_sub}]
|
|
340
|
+
detail = {"exclusion": identifier, "rule": rule}
|
|
341
|
+
else:
|
|
342
|
+
if not isinstance(exclusion_id, str) or not EXCLUSION_ID.fullmatch(exclusion_id):
|
|
343
|
+
return refused("invalid_exclusion", "That is not an exclusion id.")
|
|
344
|
+
found = next((entry for entry in exclusions if entry.get("id") == exclusion_id), None)
|
|
345
|
+
if found is None:
|
|
346
|
+
return refused("unknown_exclusion", "That proposal has no such exclusion.")
|
|
347
|
+
if found.get("undoneAt"):
|
|
348
|
+
return refused("already_undone", "That exclusion was already undone.")
|
|
349
|
+
found["undoneAt"], found["undoneBy"] = now_iso, actor_sub
|
|
350
|
+
detail = {"exclusion": exclusion_id, "rule": found.get("rule")}
|
|
351
|
+
changes["exclusions"] = json.dumps(exclusions)
|
|
352
|
+
if row.get("communityResult"):
|
|
353
|
+
changes["communityResult"] = None # removed: the next snapshot fixes it again without the excluded votes
|
|
354
|
+
log = [*(parse_json(row.get("editorLog")) or []), log_entry(action, actor_sub, now_iso, detail)][-MAXIMUM_EDITOR_LOG:]
|
|
355
|
+
changes["editorLog"] = json.dumps(log)
|
|
356
|
+
return changes
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def apply_edit(store: ProposalStore, *, proposal_id: Any, action: Any, rule: Any, exclusion_id: Any, actor_sub: str, now_iso: str) -> Outcome:
|
|
360
|
+
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
361
|
+
return refused("invalid_proposal", "That is not a proposal id.")
|
|
362
|
+
row = store.get_proposal(proposal_id)
|
|
363
|
+
if row is None:
|
|
364
|
+
return refused("not_found", "There is no such proposal.")
|
|
365
|
+
plan = edit_plan(row, action, rule=rule, exclusion_id=exclusion_id, actor_sub=actor_sub, now_iso=now_iso)
|
|
366
|
+
if isinstance(plan, Outcome):
|
|
367
|
+
return plan
|
|
368
|
+
if not store.change_proposal(proposal_id, plan, row["state"], now_iso, expected_updated_at=row.get("updatedAt")):
|
|
369
|
+
return refused("target_changed", "Someone else changed that proposal first.")
|
|
370
|
+
# An exclusion's id is what undo_exclusion takes; the pin and hold receipts carry the proposal id.
|
|
371
|
+
created = json.loads(plan["exclusions"])[-1]["id"] if action == EXCLUDE_VOTES else proposal_id
|
|
372
|
+
return Outcome("ok", id=created)
|
|
373
|
+
|
|
374
|
+
|
|
288
375
|
def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
|
|
289
|
-
"""``fileProposal`` or ``
|
|
376
|
+
"""``fileProposal``, ``transitionProposal`` or ``editProposal`` from AppSync. Answers a receipt, never an exception.
|
|
290
377
|
|
|
291
378
|
Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
|
|
292
379
|
it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
|
|
@@ -299,6 +386,18 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
|
|
|
299
386
|
return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
|
|
300
387
|
arguments = event.get("arguments") or {}
|
|
301
388
|
field = event_field_name(event)
|
|
389
|
+
if field == "editProposal":
|
|
390
|
+
if staff is None:
|
|
391
|
+
return refused("operator_not_allowed", "Pinning, holding and excluding votes are for editors and admins; an operator can only file proposals and move a draft to in_steering or declined.").to_dict()
|
|
392
|
+
return apply_edit(
|
|
393
|
+
store,
|
|
394
|
+
proposal_id=arguments.get("proposalId"),
|
|
395
|
+
action=arguments.get("action"),
|
|
396
|
+
rule=arguments.get("rule"),
|
|
397
|
+
exclusion_id=arguments.get("exclusionId"),
|
|
398
|
+
actor_sub=staff,
|
|
399
|
+
now_iso=now_iso,
|
|
400
|
+
).to_dict()
|
|
302
401
|
if field == "fileProposal":
|
|
303
402
|
row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
|
|
304
403
|
if problem is not None:
|
|
@@ -15,7 +15,7 @@ export const readerSteeringManifest = {
|
|
|
15
15
|
name: "reader-steering",
|
|
16
16
|
contract: 1,
|
|
17
17
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
18
|
-
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
18
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
19
19
|
guest: { mutations: [] },
|
|
20
20
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
21
21
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -22,7 +22,7 @@ export const readerSteeringManifest: PluginManifest = {
|
|
|
22
22
|
name: "reader-steering",
|
|
23
23
|
contract: 1,
|
|
24
24
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
25
|
-
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
25
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
26
26
|
guest: { mutations: [] },
|
|
27
27
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
28
28
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -148,6 +148,12 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
148
148
|
state: a.string().required(),
|
|
149
149
|
history: a.json().required(),
|
|
150
150
|
communityResult: a.json(),
|
|
151
|
+
pinned: a.boolean(),
|
|
152
|
+
held: a.boolean(),
|
|
153
|
+
override: a.json(),
|
|
154
|
+
exclusions: a.json(),
|
|
155
|
+
editorLog: a.json(),
|
|
156
|
+
needsLook: a.json(),
|
|
151
157
|
createdAt: a.datetime().required(),
|
|
152
158
|
})
|
|
153
159
|
.disableOperations(["subscriptions"])
|
|
@@ -188,6 +194,19 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
188
194
|
.returns(a.ref("ProposalReceipt"))
|
|
189
195
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
190
196
|
.handler(functionHandler("readerActions", readerActions)),
|
|
197
|
+
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
198
|
+
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
199
|
+
editProposal: a
|
|
200
|
+
.mutation()
|
|
201
|
+
.arguments({
|
|
202
|
+
proposalId: a.string().required(),
|
|
203
|
+
action: a.string().required(),
|
|
204
|
+
rule: a.string(),
|
|
205
|
+
exclusionId: a.string(),
|
|
206
|
+
})
|
|
207
|
+
.returns(a.ref("ProposalReceipt"))
|
|
208
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
209
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
191
210
|
ModerationReceipt: a.customType({
|
|
192
211
|
status: a.string().required(),
|
|
193
212
|
id: a.string(),
|
|
@@ -150,6 +150,12 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
150
150
|
state: a.string().required(),
|
|
151
151
|
history: a.json().required(),
|
|
152
152
|
communityResult: a.json(),
|
|
153
|
+
pinned: a.boolean(),
|
|
154
|
+
held: a.boolean(),
|
|
155
|
+
override: a.json(),
|
|
156
|
+
exclusions: a.json(),
|
|
157
|
+
editorLog: a.json(),
|
|
158
|
+
needsLook: a.json(),
|
|
153
159
|
createdAt: a.datetime().required(),
|
|
154
160
|
})
|
|
155
161
|
.disableOperations(["subscriptions"])
|
|
@@ -190,6 +196,19 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
190
196
|
.returns(a.ref("ProposalReceipt"))
|
|
191
197
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
192
198
|
.handler(functionHandler("readerActions", readerActions)),
|
|
199
|
+
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
200
|
+
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
201
|
+
editProposal: a
|
|
202
|
+
.mutation()
|
|
203
|
+
.arguments({
|
|
204
|
+
proposalId: a.string().required(),
|
|
205
|
+
action: a.string().required(),
|
|
206
|
+
rule: a.string(),
|
|
207
|
+
exclusionId: a.string(),
|
|
208
|
+
})
|
|
209
|
+
.returns(a.ref("ProposalReceipt"))
|
|
210
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
211
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
193
212
|
ModerationReceipt: a.customType({
|
|
194
213
|
status: a.string().required(),
|
|
195
214
|
id: a.string(),
|
|
@@ -98,12 +98,13 @@ type SignInFailureBranch = "pending_cookie" | "provider_error" | "missing_parame
|
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* One structured line per failed callback: `prospectus_sign_in_failed {"code":...,"branch":...}`. It carries only the
|
|
101
|
-
* code, the branch, and (for errors from completing the sign-in) the HTTP status, Prospectus error code
|
|
101
|
+
* code, the branch, and (for errors from completing the sign-in) the HTTP status, Prospectus error code, its rule text
|
|
102
|
+
* (`prospectusMessage`, which names the broken rule and carries no personal data) and error name.
|
|
102
103
|
* Never tokens, authorization codes, state, emails or subjects.
|
|
103
104
|
*/
|
|
104
105
|
function logSignInFailure(
|
|
105
106
|
options: ProspectusSignInRouteOptions,
|
|
106
|
-
entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; errorName?: string },
|
|
107
|
+
entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string },
|
|
107
108
|
): void {
|
|
108
109
|
(options.log ?? console.error)(`prospectus_sign_in_failed ${JSON.stringify(entry)}`);
|
|
109
110
|
}
|
|
@@ -186,7 +187,7 @@ async function completeCallback(
|
|
|
186
187
|
options: ProspectusSignInRouteOptions,
|
|
187
188
|
nowInSeconds: number,
|
|
188
189
|
): Promise<NextResponse> {
|
|
189
|
-
const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; errorName?: string } = {}) => {
|
|
190
|
+
const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string } = {}) => {
|
|
190
191
|
logSignInFailure(options, { code, branch, ...detail });
|
|
191
192
|
return signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
|
|
192
193
|
};
|
|
@@ -220,7 +221,7 @@ async function completeCallback(
|
|
|
220
221
|
);
|
|
221
222
|
return response;
|
|
222
223
|
} catch (error) {
|
|
223
|
-
if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code });
|
|
224
|
+
if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code, prospectusMessage: error.message.slice(0, 300) });
|
|
224
225
|
return failed("sign_in_failed", "unexpected", { errorName: error instanceof Error ? error.name : "error" });
|
|
225
226
|
}
|
|
226
227
|
}
|
|
@@ -132,19 +132,28 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
|
|
|
132
132
|
export class ProspectusSiteAccounts {
|
|
133
133
|
constructor(private readonly transport: ProspectusTransport) {}
|
|
134
134
|
|
|
135
|
-
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
|
|
135
|
+
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number; challengeToken?: string }> {
|
|
136
136
|
return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
|
|
137
137
|
challengeId: string;
|
|
138
138
|
nonce: string;
|
|
139
139
|
expiresAt: number;
|
|
140
|
+
challengeToken?: string;
|
|
140
141
|
}>;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
/**
|
|
145
|
+
* When Prospectus signs its challenges it stores nothing, so the signed `challengeToken` is the only proof the
|
|
146
|
+
* challenge exists and must be sent back unchanged. Without a token (a service that stores challenges) the ID is sent.
|
|
147
|
+
*/
|
|
148
|
+
resolveGoogleSignIn(
|
|
149
|
+
challenge: { challengeId: string; challengeToken?: string },
|
|
150
|
+
humanAccessToken: string,
|
|
151
|
+
identityToken: string,
|
|
152
|
+
): Promise<{ person: ProspectusPerson }> {
|
|
144
153
|
return this.transport({
|
|
145
154
|
path: "/v1/identities/google/resolve",
|
|
146
155
|
method: "POST",
|
|
147
|
-
body: { challengeId },
|
|
156
|
+
body: challenge.challengeToken ? { challengeToken: challenge.challengeToken } : { challengeId: challenge.challengeId },
|
|
148
157
|
humanAccessToken,
|
|
149
158
|
identityToken,
|
|
150
159
|
}) as Promise<{ person: ProspectusPerson }>;
|
|
@@ -197,6 +206,8 @@ export interface GoogleOnlyCognitoClient {
|
|
|
197
206
|
export interface PendingProspectusSignIn {
|
|
198
207
|
challengeId: string;
|
|
199
208
|
nonce: string;
|
|
209
|
+
/** The signed challenge from Prospectus, present when it signs challenges instead of storing them. */
|
|
210
|
+
challengeToken?: string;
|
|
200
211
|
state: string;
|
|
201
212
|
codeVerifier: string;
|
|
202
213
|
expiresAt: number;
|
|
@@ -242,7 +253,13 @@ export async function beginProspectusGoogleSignIn(
|
|
|
242
253
|
}).toString();
|
|
243
254
|
return {
|
|
244
255
|
authorizationUrl: authorizationUrl.toString(),
|
|
245
|
-
pendingSignIn: {
|
|
256
|
+
pendingSignIn: {
|
|
257
|
+
challengeId: challenge.challengeId,
|
|
258
|
+
nonce: challenge.nonce,
|
|
259
|
+
...(challenge.challengeToken ? { challengeToken: challenge.challengeToken } : {}),
|
|
260
|
+
state, codeVerifier,
|
|
261
|
+
expiresAt: challenge.expiresAt,
|
|
262
|
+
},
|
|
246
263
|
};
|
|
247
264
|
}
|
|
248
265
|
|
|
@@ -303,7 +320,7 @@ export async function completeProspectusGoogleSignIn(
|
|
|
303
320
|
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
304
321
|
};
|
|
305
322
|
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
306
|
-
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn
|
|
323
|
+
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
|
|
307
324
|
return {
|
|
308
325
|
person,
|
|
309
326
|
accessToken: tokens.accessToken,
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED