@anthusai/papyrus 1.0.0-next.9 → 1.1.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/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +23 -5
- package/amplify/auth/resource.ts +31 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -62
- package/amplify/data/schema.ts +47 -62
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +150 -176
- package/amplify/site-backend.ts +189 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -6
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +94 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -8
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +166 -27
- package/infra/amplify-app-shell.ts +180 -31
- package/infra/build-specs.js +23 -7
- package/infra/build-specs.ts +27 -10
- package/infra/site-config.js +248 -36
- package/infra/site-config.ts +313 -51
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +73 -8
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +123 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/components/ai-elements/message.tsx +0 -360
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
CYCLOTRON_LAB_PRODUCT,
|
|
4
|
+
ProspectusAccountsError,
|
|
5
|
+
authorizeSignedInPersonForAccount,
|
|
6
|
+
beginProspectusGoogleSignIn,
|
|
7
|
+
completeProspectusGoogleSignIn,
|
|
8
|
+
type CompleteProspectusGoogleSignInInput,
|
|
9
|
+
} from "./prospectus-site-identity";
|
|
10
|
+
import {
|
|
11
|
+
PROSPECTUS_PENDING_SIGN_IN_COOKIE,
|
|
12
|
+
PROSPECTUS_SESSION_COOKIE,
|
|
13
|
+
openProspectusCookie,
|
|
14
|
+
resolveProspectusSignInRuntime,
|
|
15
|
+
sameSiteReturnPath,
|
|
16
|
+
sealProspectusCookie,
|
|
17
|
+
sealedCookieAttributes,
|
|
18
|
+
type PendingProspectusSignInCookie,
|
|
19
|
+
type ProspectusSignInRuntime,
|
|
20
|
+
type ProspectusSignedInSession,
|
|
21
|
+
} from "./prospectus-session";
|
|
22
|
+
import {
|
|
23
|
+
prospectusSiteProduct,
|
|
24
|
+
publicationCapabilitiesForProspectusRole,
|
|
25
|
+
publishingAccountsForSite,
|
|
26
|
+
type PublicationCapability,
|
|
27
|
+
} from "./prospectus-publication-access";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The site's sign-in routes for Prospectus accounts: start (challenge, then the
|
|
31
|
+
* Google-only Cognito redirect), callback (verify, resolve, seal the session),
|
|
32
|
+
* session (who is signed in and which accounts they belong to) and sign-out.
|
|
33
|
+
* Every route answers 503 until the site is registered and configured, so an
|
|
34
|
+
* unregistered deployment never half-signs anyone in.
|
|
35
|
+
*/
|
|
36
|
+
export interface ProspectusSignInRouteOptions {
|
|
37
|
+
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
38
|
+
siteProduct?: string | null;
|
|
39
|
+
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
40
|
+
nowInSeconds?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
44
|
+
|
|
45
|
+
function siteProductFor(options: ProspectusSignInRouteOptions): string | null {
|
|
46
|
+
return options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
|
|
50
|
+
return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
|
|
54
|
+
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function signInUnavailableResponse(): NextResponse {
|
|
58
|
+
return NextResponse.json(
|
|
59
|
+
{ error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." },
|
|
60
|
+
{ status: 503, headers: NO_STORE_HEADERS },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clearSealedCookie(response: NextResponse, cookieName: string): void {
|
|
65
|
+
response.cookies.set(cookieName, "", sealedCookieAttributes(0, 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function signInFailedResponse(status: number, code: string, message: string): NextResponse {
|
|
69
|
+
return NextResponse.json({ error: code, message }, { status, headers: NO_STORE_HEADERS });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function signedInProspectusSession(
|
|
73
|
+
request: NextRequest,
|
|
74
|
+
sealingSecret: string,
|
|
75
|
+
nowInSeconds?: number,
|
|
76
|
+
): ProspectusSignedInSession | null {
|
|
77
|
+
return openProspectusCookie<ProspectusSignedInSession>(
|
|
78
|
+
sealingSecret,
|
|
79
|
+
PROSPECTUS_SESSION_COOKIE,
|
|
80
|
+
request.cookies.get(PROSPECTUS_SESSION_COOKIE)?.value,
|
|
81
|
+
nowInSeconds,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
86
|
+
const runtime = await signInRuntimeFor(request, options);
|
|
87
|
+
if (!runtime) return signInUnavailableResponse();
|
|
88
|
+
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
89
|
+
const pendingSignInCookie: PendingProspectusSignInCookie = {
|
|
90
|
+
...pendingSignIn,
|
|
91
|
+
returnPath: sameSiteReturnPath(request.nextUrl.searchParams.get("returnTo")),
|
|
92
|
+
};
|
|
93
|
+
const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
94
|
+
response.cookies.set(
|
|
95
|
+
PROSPECTUS_PENDING_SIGN_IN_COOKIE,
|
|
96
|
+
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_PENDING_SIGN_IN_COOKIE, pendingSignInCookie, pendingSignIn.expiresAt),
|
|
97
|
+
sealedCookieAttributes(pendingSignIn.expiresAt, currentTimeInSeconds(options)),
|
|
98
|
+
);
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
103
|
+
const runtime = await signInRuntimeFor(request, options);
|
|
104
|
+
if (!runtime) return signInUnavailableResponse();
|
|
105
|
+
const nowInSeconds = currentTimeInSeconds(options);
|
|
106
|
+
const pendingSignIn = openProspectusCookie<PendingProspectusSignInCookie>(
|
|
107
|
+
runtime.sealingSecret,
|
|
108
|
+
PROSPECTUS_PENDING_SIGN_IN_COOKIE,
|
|
109
|
+
request.cookies.get(PROSPECTUS_PENDING_SIGN_IN_COOKIE)?.value,
|
|
110
|
+
nowInSeconds,
|
|
111
|
+
);
|
|
112
|
+
const response = await completeCallback(request, runtime, pendingSignIn, options, nowInSeconds);
|
|
113
|
+
clearSealedCookie(response, PROSPECTUS_PENDING_SIGN_IN_COOKIE);
|
|
114
|
+
return response;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function completeCallback(
|
|
118
|
+
request: NextRequest,
|
|
119
|
+
runtime: ProspectusSignInRuntime,
|
|
120
|
+
pendingSignIn: PendingProspectusSignInCookie | null,
|
|
121
|
+
options: ProspectusSignInRouteOptions,
|
|
122
|
+
nowInSeconds: number,
|
|
123
|
+
): Promise<NextResponse> {
|
|
124
|
+
if (!pendingSignIn) return signInFailedResponse(401, "sign_in_expired", "This sign-in is no longer pending. Start again.");
|
|
125
|
+
const searchParams = request.nextUrl.searchParams;
|
|
126
|
+
if (searchParams.get("error")) return signInFailedResponse(401, "sign_in_declined", "Google sign-in did not complete.");
|
|
127
|
+
const authorizationCode = searchParams.get("code");
|
|
128
|
+
const returnedState = searchParams.get("state");
|
|
129
|
+
if (!authorizationCode || !returnedState) return signInFailedResponse(400, "sign_in_incomplete", "The sign-in response is missing its code or state.");
|
|
130
|
+
try {
|
|
131
|
+
const signedIn = await completeProspectusGoogleSignIn({
|
|
132
|
+
accounts: runtime.accounts,
|
|
133
|
+
cognitoClient: runtime.cognitoClient,
|
|
134
|
+
pendingSignIn,
|
|
135
|
+
returnedState,
|
|
136
|
+
authorizationCode,
|
|
137
|
+
nowInSeconds,
|
|
138
|
+
...options.completionOverrides,
|
|
139
|
+
});
|
|
140
|
+
const session: ProspectusSignedInSession = {
|
|
141
|
+
personId: signedIn.person.id,
|
|
142
|
+
accessToken: signedIn.accessToken,
|
|
143
|
+
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
144
|
+
};
|
|
145
|
+
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request.nextUrl.origin), { status: 303, headers: NO_STORE_HEADERS });
|
|
146
|
+
response.cookies.set(
|
|
147
|
+
PROSPECTUS_SESSION_COOKIE,
|
|
148
|
+
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
|
|
149
|
+
sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
|
|
150
|
+
);
|
|
151
|
+
return response;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
|
|
154
|
+
console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
|
|
155
|
+
return signInFailedResponse(401, "sign_in_failed", "Sign-in could not be completed.");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
160
|
+
const runtime = await signInRuntimeFor(request, options);
|
|
161
|
+
if (!runtime) return signInUnavailableResponse();
|
|
162
|
+
const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
|
|
163
|
+
if (!session) return NextResponse.json({ signedIn: false }, { headers: NO_STORE_HEADERS });
|
|
164
|
+
try {
|
|
165
|
+
const directory = await runtime.accounts.accountDirectory(session.accessToken);
|
|
166
|
+
if (!directory.person || directory.person.id !== session.personId) {
|
|
167
|
+
const response = NextResponse.json({ signedIn: false }, { headers: NO_STORE_HEADERS });
|
|
168
|
+
clearSealedCookie(response, PROSPECTUS_SESSION_COOKIE);
|
|
169
|
+
return response;
|
|
170
|
+
}
|
|
171
|
+
const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
|
|
172
|
+
return NextResponse.json(
|
|
173
|
+
{
|
|
174
|
+
signedIn: true,
|
|
175
|
+
person: directory.person,
|
|
176
|
+
accounts: directory.accounts,
|
|
177
|
+
cyclotronLabAccess: directory.accounts.some((account) =>
|
|
178
|
+
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
179
|
+
),
|
|
180
|
+
publishingAccounts,
|
|
181
|
+
selectedAccountId: publishingAccounts.some((account) => account.accountId === session.selectedAccountId)
|
|
182
|
+
? session.selectedAccountId
|
|
183
|
+
: null,
|
|
184
|
+
},
|
|
185
|
+
{ headers: NO_STORE_HEADERS },
|
|
186
|
+
);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof ProspectusAccountsError && error.status === 401) {
|
|
189
|
+
const response = NextResponse.json({ signedIn: false }, { headers: NO_STORE_HEADERS });
|
|
190
|
+
clearSealedCookie(response, PROSPECTUS_SESSION_COOKIE);
|
|
191
|
+
return response;
|
|
192
|
+
}
|
|
193
|
+
if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
|
|
194
|
+
console.error(`Prospectus account directory failed (${error instanceof Error ? error.name : "error"}).`);
|
|
195
|
+
return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
200
|
+
const runtime = await signInRuntimeFor(request, options);
|
|
201
|
+
const signedOutDestination = new URL("/", request.nextUrl.origin);
|
|
202
|
+
const destination = runtime
|
|
203
|
+
? new URL(
|
|
204
|
+
`/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
|
|
205
|
+
runtime.cognitoClient.hostedUiOrigin,
|
|
206
|
+
)
|
|
207
|
+
: signedOutDestination;
|
|
208
|
+
const response = NextResponse.redirect(destination, { status: 303, headers: NO_STORE_HEADERS });
|
|
209
|
+
clearSealedCookie(response, PROSPECTUS_SESSION_COOKIE);
|
|
210
|
+
clearSealedCookie(response, PROSPECTUS_PENDING_SIGN_IN_COOKIE);
|
|
211
|
+
return response;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ProspectusPublishingSelection {
|
|
215
|
+
personId: string;
|
|
216
|
+
accountId: string;
|
|
217
|
+
role: string;
|
|
218
|
+
publicationCapabilities: PublicationCapability[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function freshlyAuthorizedPublishingSelection(
|
|
222
|
+
runtime: ProspectusSignInRuntime,
|
|
223
|
+
session: ProspectusSignedInSession,
|
|
224
|
+
accountId: string,
|
|
225
|
+
siteProduct: string | null,
|
|
226
|
+
): Promise<ProspectusPublishingSelection | null> {
|
|
227
|
+
const directory = await runtime.accounts.accountDirectory(session.accessToken);
|
|
228
|
+
if (!directory.person || directory.person.id !== session.personId) return null;
|
|
229
|
+
if (!publishingAccountsForSite(directory.accounts, siteProduct).some((account) => account.accountId === accountId)) return null;
|
|
230
|
+
const authorization = await authorizeSignedInPersonForAccount(runtime.accounts, session.accessToken, accountId, "console:read");
|
|
231
|
+
if (authorization.personId !== session.personId) return null;
|
|
232
|
+
const publicationCapabilities = publicationCapabilitiesForProspectusRole(authorization.role);
|
|
233
|
+
if (publicationCapabilities.length === 0) return null;
|
|
234
|
+
return { personId: session.personId, accountId, role: authorization.role, publicationCapabilities };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function currentProspectusPublishingSelection(
|
|
238
|
+
request: NextRequest,
|
|
239
|
+
options: ProspectusSignInRouteOptions = {},
|
|
240
|
+
): Promise<ProspectusPublishingSelection | null> {
|
|
241
|
+
const runtime = await signInRuntimeFor(request, options);
|
|
242
|
+
if (!runtime) return null;
|
|
243
|
+
const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
|
|
244
|
+
if (!session?.selectedAccountId) return null;
|
|
245
|
+
try {
|
|
246
|
+
return await freshlyAuthorizedPublishingSelection(runtime, session, session.selectedAccountId, siteProductFor(options));
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof ProspectusAccountsError) return null;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
254
|
+
const runtime = await signInRuntimeFor(request, options);
|
|
255
|
+
if (!runtime) return signInUnavailableResponse();
|
|
256
|
+
if (request.headers.get("origin") !== request.nextUrl.origin) {
|
|
257
|
+
return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
|
|
258
|
+
}
|
|
259
|
+
const nowInSeconds = currentTimeInSeconds(options);
|
|
260
|
+
const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
|
|
261
|
+
if (!session) return signInFailedResponse(401, "signed_out", "Sign in first.");
|
|
262
|
+
const body = await request.json().catch(() => null) as { accountId?: unknown } | null;
|
|
263
|
+
const accountId = typeof body?.accountId === "string" ? body.accountId.trim() : "";
|
|
264
|
+
if (!accountId) return signInFailedResponse(400, "account_required", "Choose an account.");
|
|
265
|
+
try {
|
|
266
|
+
const selection = await freshlyAuthorizedPublishingSelection(runtime, session, accountId, siteProductFor(options));
|
|
267
|
+
if (!selection) return signInFailedResponse(403, "not_a_publishing_account", "You cannot work on this site's publications in that account.");
|
|
268
|
+
const response = NextResponse.json(
|
|
269
|
+
{ selectedAccountId: selection.accountId, role: selection.role, publicationCapabilities: selection.publicationCapabilities },
|
|
270
|
+
{ headers: NO_STORE_HEADERS },
|
|
271
|
+
);
|
|
272
|
+
const selectedSession: ProspectusSignedInSession = { ...session, selectedAccountId: selection.accountId };
|
|
273
|
+
response.cookies.set(
|
|
274
|
+
PROSPECTUS_SESSION_COOKIE,
|
|
275
|
+
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, selectedSession, session.accessTokenExpiresAt),
|
|
276
|
+
sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
|
|
277
|
+
);
|
|
278
|
+
return response;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
|
|
281
|
+
console.error(`Prospectus account selection failed (${error instanceof Error ? error.name : "error"}).`);
|
|
282
|
+
return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { defaultProvider } from "@aws-sdk/credential-provider-node";
|
|
4
|
+
import { SignatureV4 } from "@smithy/signature-v4";
|
|
5
|
+
import { HttpRequest } from "@smithy/protocol-http";
|
|
6
|
+
import { Sha256 } from "@aws-crypto/sha256-js";
|
|
7
|
+
import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
8
|
+
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Papyrus site's server-side view of Prospectus, the single account service.
|
|
12
|
+
*
|
|
13
|
+
* The site signs requests with its own runtime role (the Amplify SSR compute role
|
|
14
|
+
* registered in Prospectus). People sign in through the site's Google-only Cognito
|
|
15
|
+
* client; Prospectus binds that sign-in to a canonical person through a one-time
|
|
16
|
+
* challenge nonce. Papyrus keeps no account or membership tables: every answer here
|
|
17
|
+
* comes from Prospectus, and publication-level grants are never inferred from an
|
|
18
|
+
* account role.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type ProspectusRole = "owner" | "admin" | "member" | "viewer";
|
|
22
|
+
export type ProspectusOperation = "console:read" | "console:write" | "members:read" | "members:write";
|
|
23
|
+
|
|
24
|
+
export interface ProspectusPerson {
|
|
25
|
+
id: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProspectusAccountAuthorization {
|
|
30
|
+
allowed: true;
|
|
31
|
+
accountId: string;
|
|
32
|
+
personId: string;
|
|
33
|
+
product: string;
|
|
34
|
+
role: ProspectusRole;
|
|
35
|
+
operation: ProspectusOperation;
|
|
36
|
+
revision: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProspectusDirectoryAccount {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
role: ProspectusRole;
|
|
43
|
+
products: Array<{ product: string; status: "active" | "disabled" }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProspectusRequest {
|
|
47
|
+
path: string;
|
|
48
|
+
method: "GET" | "POST";
|
|
49
|
+
body?: unknown;
|
|
50
|
+
humanAccessToken?: string;
|
|
51
|
+
identityToken?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
|
|
55
|
+
|
|
56
|
+
export class ProspectusAccountsError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
readonly status: number,
|
|
59
|
+
readonly code: string,
|
|
60
|
+
message: string,
|
|
61
|
+
) {
|
|
62
|
+
super(message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TemporaryRuntimeCredentials {
|
|
67
|
+
accessKeyId: string;
|
|
68
|
+
secretAccessKey: string;
|
|
69
|
+
sessionToken?: string;
|
|
70
|
+
expiration?: Date;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SignedProspectusTransportOptions {
|
|
74
|
+
endpoint: string;
|
|
75
|
+
region: string;
|
|
76
|
+
runtimeCredentials?: () => Promise<TemporaryRuntimeCredentials>;
|
|
77
|
+
fetcher?: typeof fetch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function signedProspectusTransport(options: SignedProspectusTransportOptions): ProspectusTransport {
|
|
81
|
+
const endpoint = new URL(options.endpoint);
|
|
82
|
+
if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
83
|
+
throw new Error("Use an HTTPS Prospectus accounts endpoint.");
|
|
84
|
+
}
|
|
85
|
+
const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
|
|
86
|
+
const fetcher = options.fetcher ?? fetch;
|
|
87
|
+
return async ({ path, method, body, humanAccessToken, identityToken }) => {
|
|
88
|
+
const credentials = await runtimeCredentials();
|
|
89
|
+
if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
|
|
90
|
+
throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
|
|
91
|
+
}
|
|
92
|
+
const url = new URL(`${endpoint.toString().replace(/\/$/, "")}${path}`);
|
|
93
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
94
|
+
const headers: Record<string, string> = {
|
|
95
|
+
host: url.host,
|
|
96
|
+
...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
|
|
97
|
+
...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
|
|
98
|
+
...(payload ? { "content-type": "application/json" } : {}),
|
|
99
|
+
};
|
|
100
|
+
const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
|
|
101
|
+
const signed = await signer.sign(
|
|
102
|
+
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
|
|
103
|
+
);
|
|
104
|
+
const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
|
|
105
|
+
const responseBody = await response.json().catch(() => ({}));
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new ProspectusAccountsError(response.status, responseBody.error ?? "request_failed", responseBody.message ?? "Prospectus request failed.");
|
|
108
|
+
}
|
|
109
|
+
return responseBody;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ProspectusSiteAccounts {
|
|
114
|
+
constructor(private readonly transport: ProspectusTransport) {}
|
|
115
|
+
|
|
116
|
+
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
|
|
117
|
+
return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
|
|
118
|
+
challengeId: string;
|
|
119
|
+
nonce: string;
|
|
120
|
+
expiresAt: number;
|
|
121
|
+
}>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
|
|
125
|
+
return this.transport({
|
|
126
|
+
path: "/v1/identities/google/resolve",
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: { challengeId },
|
|
129
|
+
humanAccessToken,
|
|
130
|
+
identityToken,
|
|
131
|
+
}) as Promise<{ person: ProspectusPerson }>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
currentPerson(humanAccessToken: string): Promise<{ person: ProspectusPerson | null }> {
|
|
135
|
+
return this.transport({ path: "/v1/identities/me", method: "GET", humanAccessToken }) as Promise<{ person: ProspectusPerson | null }>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
authorizeAccountOperation(humanAccessToken: string, accountId: string, operation: ProspectusOperation): Promise<ProspectusAccountAuthorization> {
|
|
139
|
+
return this.transport({
|
|
140
|
+
path: `/v1/accounts/${encodeURIComponent(accountId)}/authorize`,
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: { operation },
|
|
143
|
+
humanAccessToken,
|
|
144
|
+
}) as Promise<ProspectusAccountAuthorization>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
|
|
148
|
+
return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
|
|
149
|
+
person: ProspectusPerson | null;
|
|
150
|
+
accounts: ProspectusDirectoryAccount[];
|
|
151
|
+
}>;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface GoogleOnlyCognitoClient {
|
|
156
|
+
hostedUiOrigin: string;
|
|
157
|
+
userPoolId: string;
|
|
158
|
+
clientId: string;
|
|
159
|
+
redirectUri: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface PendingProspectusSignIn {
|
|
163
|
+
challengeId: string;
|
|
164
|
+
nonce: string;
|
|
165
|
+
state: string;
|
|
166
|
+
codeVerifier: string;
|
|
167
|
+
expiresAt: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface CognitoTokens {
|
|
171
|
+
accessToken: string;
|
|
172
|
+
idToken: string;
|
|
173
|
+
refreshToken?: string;
|
|
174
|
+
expiresInSeconds: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type CognitoTokenVerifier = (tokens: CognitoTokens, expectedNonce: string) => Promise<void>;
|
|
178
|
+
|
|
179
|
+
function base64UrlRandom(byteLength: number): string {
|
|
180
|
+
return randomBytes(byteLength).toString("base64url");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function constantTimeEquals(left: string, right: string): boolean {
|
|
184
|
+
const leftBytes = Buffer.from(left);
|
|
185
|
+
const rightBytes = Buffer.from(right);
|
|
186
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function beginProspectusGoogleSignIn(
|
|
190
|
+
accounts: ProspectusSiteAccounts,
|
|
191
|
+
cognitoClient: GoogleOnlyCognitoClient,
|
|
192
|
+
): Promise<{ authorizationUrl: string; pendingSignIn: PendingProspectusSignIn }> {
|
|
193
|
+
const challenge = await accounts.requestGoogleSignInChallenge();
|
|
194
|
+
const state = base64UrlRandom(32);
|
|
195
|
+
const codeVerifier = base64UrlRandom(48);
|
|
196
|
+
const authorizationUrl = new URL("/oauth2/authorize", cognitoClient.hostedUiOrigin);
|
|
197
|
+
authorizationUrl.search = new URLSearchParams({
|
|
198
|
+
response_type: "code",
|
|
199
|
+
client_id: cognitoClient.clientId,
|
|
200
|
+
redirect_uri: cognitoClient.redirectUri,
|
|
201
|
+
scope: "openid email profile",
|
|
202
|
+
identity_provider: "Google",
|
|
203
|
+
state,
|
|
204
|
+
nonce: challenge.nonce,
|
|
205
|
+
code_challenge: createHash("sha256").update(codeVerifier).digest("base64url"),
|
|
206
|
+
code_challenge_method: "S256",
|
|
207
|
+
}).toString();
|
|
208
|
+
return {
|
|
209
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
210
|
+
pendingSignIn: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function cognitoTokenVerifier(cognitoClient: GoogleOnlyCognitoClient, signingKeyCache?: JwksCache): CognitoTokenVerifier {
|
|
215
|
+
const verifierOptions = signingKeyCache ? { jwksCache: signingKeyCache } : undefined;
|
|
216
|
+
const accessTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "access", clientId: cognitoClient.clientId }, verifierOptions);
|
|
217
|
+
const identityTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "id", clientId: cognitoClient.clientId }, verifierOptions);
|
|
218
|
+
return async (tokens, expectedNonce) => {
|
|
219
|
+
const accessClaims = await accessTokenVerifier.verify(tokens.accessToken);
|
|
220
|
+
const identityClaims = await identityTokenVerifier.verify(tokens.idToken);
|
|
221
|
+
if (identityClaims.nonce !== expectedNonce) throw new ProspectusAccountsError(401, "nonce_mismatch", "Sign-in did not carry this site's Prospectus challenge.");
|
|
222
|
+
if (identityClaims.sub !== accessClaims.sub) throw new ProspectusAccountsError(401, "token_pair_mismatch", "Access and identity tokens describe different people.");
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CompleteProspectusGoogleSignInInput {
|
|
227
|
+
accounts: ProspectusSiteAccounts;
|
|
228
|
+
cognitoClient: GoogleOnlyCognitoClient;
|
|
229
|
+
pendingSignIn: PendingProspectusSignIn;
|
|
230
|
+
returnedState: string;
|
|
231
|
+
authorizationCode: string;
|
|
232
|
+
verifyCognitoTokens?: CognitoTokenVerifier;
|
|
233
|
+
fetcher?: typeof fetch;
|
|
234
|
+
nowInSeconds?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function completeProspectusGoogleSignIn(
|
|
238
|
+
input: CompleteProspectusGoogleSignInInput,
|
|
239
|
+
): Promise<{ person: ProspectusPerson; accessToken: string; refreshToken?: string; accessTokenExpiresAt: number }> {
|
|
240
|
+
const nowInSeconds = input.nowInSeconds ?? Math.floor(Date.now() / 1000);
|
|
241
|
+
if (nowInSeconds >= input.pendingSignIn.expiresAt) {
|
|
242
|
+
throw new ProspectusAccountsError(401, "sign_in_expired", "Sign-in took too long. Start again.");
|
|
243
|
+
}
|
|
244
|
+
if (!constantTimeEquals(input.returnedState, input.pendingSignIn.state)) {
|
|
245
|
+
throw new ProspectusAccountsError(401, "state_mismatch", "Sign-in response does not belong to this browser's sign-in.");
|
|
246
|
+
}
|
|
247
|
+
const tokenResponse = await (input.fetcher ?? fetch)(new URL("/oauth2/token", input.cognitoClient.hostedUiOrigin), {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
250
|
+
body: new URLSearchParams({
|
|
251
|
+
grant_type: "authorization_code",
|
|
252
|
+
client_id: input.cognitoClient.clientId,
|
|
253
|
+
code: input.authorizationCode,
|
|
254
|
+
redirect_uri: input.cognitoClient.redirectUri,
|
|
255
|
+
code_verifier: input.pendingSignIn.codeVerifier,
|
|
256
|
+
}).toString(),
|
|
257
|
+
cache: "no-store",
|
|
258
|
+
redirect: "error",
|
|
259
|
+
});
|
|
260
|
+
const tokenBody = await tokenResponse.json().catch(() => ({}));
|
|
261
|
+
if (!tokenResponse.ok || typeof tokenBody.access_token !== "string" || typeof tokenBody.id_token !== "string") {
|
|
262
|
+
throw new ProspectusAccountsError(401, "code_exchange_failed", "Cognito did not complete the sign-in.");
|
|
263
|
+
}
|
|
264
|
+
const tokens: CognitoTokens = {
|
|
265
|
+
accessToken: tokenBody.access_token,
|
|
266
|
+
idToken: tokenBody.id_token,
|
|
267
|
+
refreshToken: typeof tokenBody.refresh_token === "string" ? tokenBody.refresh_token : undefined,
|
|
268
|
+
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
269
|
+
};
|
|
270
|
+
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
271
|
+
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn.challengeId, tokens.accessToken, tokens.idToken);
|
|
272
|
+
return {
|
|
273
|
+
person,
|
|
274
|
+
accessToken: tokens.accessToken,
|
|
275
|
+
refreshToken: tokens.refreshToken,
|
|
276
|
+
accessTokenExpiresAt: nowInSeconds + tokens.expiresInSeconds,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function authorizeSignedInPersonForAccount(
|
|
281
|
+
accounts: ProspectusSiteAccounts,
|
|
282
|
+
humanAccessToken: string,
|
|
283
|
+
accountId: string,
|
|
284
|
+
operation: ProspectusOperation,
|
|
285
|
+
): Promise<ProspectusAccountAuthorization> {
|
|
286
|
+
const { person } = await accounts.currentPerson(humanAccessToken);
|
|
287
|
+
if (!person) throw new ProspectusAccountsError(401, "unknown_person", "Sign in again: Prospectus does not recognize this session.");
|
|
288
|
+
const authorization = await accounts.authorizeAccountOperation(humanAccessToken, accountId, operation);
|
|
289
|
+
if (authorization.allowed !== true || authorization.personId !== person.id || authorization.accountId !== accountId || authorization.operation !== operation) {
|
|
290
|
+
throw new ProspectusAccountsError(403, "not_authorized", "Prospectus did not authorize this account operation.");
|
|
291
|
+
}
|
|
292
|
+
return authorization;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export const CYCLOTRON_LAB_PRODUCT = "cyclotron";
|
|
296
|
+
|
|
297
|
+
export async function signedInPersonHasCyclotronLabAccess(accounts: ProspectusSiteAccounts, humanAccessToken: string): Promise<boolean> {
|
|
298
|
+
const directory = await accounts.accountDirectory(humanAccessToken);
|
|
299
|
+
if (!directory.person) return false;
|
|
300
|
+
return directory.accounts.some((account) =>
|
|
301
|
+
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {CyclotronDecision,CyclotronReview} from 'cyclotron/sdk-types';
|
|
2
|
+
import type {CyclotronStatus} from 'cyclotron/cyclotron-status';
|
|
3
|
+
import type {ReviewControlReview} from 'cyclotron/components/review-control';
|
|
4
|
+
|
|
5
|
+
export type PublicationScope={accountId:string;publicationId:string;bindingRevision:string};
|
|
6
|
+
export type PublicationEnvelope=PublicationScope&{contractVersion:'cyclotron-publication/v1';publicationId:string;bindingRevision:string;features?:{frozenContext:boolean;replayExport:boolean;steering:boolean};outputs:Array<{id:string;question:string;classes:string[];positiveLabel:string|null}>};
|
|
7
|
+
export type PublicationStatus=PublicationEnvelope&{classifiers:Record<string,CyclotronStatus>};
|
|
8
|
+
export type PublicationRevisionRef={listId:string;itemId:string;revision:number;contentHash:string};
|
|
9
|
+
export type PublicationContext={contextId:string;decisionId:string;publicationId:string;sourceRef:PublicationRevisionRef;bindingRef:PublicationRevisionRef;source:{id:string;values:Record<string,unknown>};binding:{values:{accountId:string;publicationId:string;revision:string;outputs:string[]}}};
|
|
10
|
+
export type PublicationHistoryRow={decision:CyclotronDecision;item:Record<string,unknown>;state?:string;reviews:Record<string,CyclotronReview>;context?:PublicationContext;historicalContext?:boolean;provenanceUnavailable?:boolean;feedbackReconciliationRequired?:boolean};
|
|
11
|
+
export type PublicationHistory=PublicationEnvelope&{history:PublicationHistoryRow[]};
|
|
12
|
+
export type PublicationReviewPayload={bindingRevision:string;decisionId:string;classifier:string;predictionVersion:number;predictionFingerprint:string;requestId:string;label:string;explanation:string;reasonCode:string|null;contextId?:string};
|
|
13
|
+
export type PublicationSteeringInput={requestId:string;explanation:string}&({action:'evaluate';proposal:Record<string,unknown>}|{action:'promote';trialFingerprint:string});
|
|
14
|
+
export type PublicationSteeringPayload=PublicationSteeringInput&{bindingRevision:string;bindingRef:PublicationRevisionRef;classifier:string;expectedFingerprint:string};
|
|
15
|
+
|
|
16
|
+
function ordered(value:unknown):unknown{
|
|
17
|
+
if(Array.isArray(value))return value.map(ordered);
|
|
18
|
+
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,ordered(item)]));
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stable cache/component identity; length-safe even when IDs contain delimiters. */
|
|
23
|
+
export function publicationScopeKey(scope:PublicationScope):string{
|
|
24
|
+
if([scope.accountId,scope.publicationId,scope.bindingRevision].some(value=>typeof value!=='string'||!value.trim()))throw new Error('Complete publication account scope required');
|
|
25
|
+
return JSON.stringify([scope.accountId,scope.publicationId,scope.bindingRevision]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertPublicationScope(status:PublicationStatus,history:PublicationHistory){
|
|
29
|
+
if(status.contractVersion!=='cyclotron-publication/v1'||history.contractVersion!==status.contractVersion||publicationScopeKey(status)!==publicationScopeKey(history)||status.publicationId!==history.publicationId||status.bindingRevision!==history.bindingRevision)throw new Error('Publication snapshot binding mismatch');
|
|
30
|
+
const outputs=new Set(status.outputs.map(output=>output.id));
|
|
31
|
+
if(!outputs.size||outputs.size!==status.outputs.length||status.outputs.some(output=>status.classifiers[output.id]?.cyclotron.classifier!==output.id)||JSON.stringify(history.outputs)!==JSON.stringify(status.outputs))throw new Error('Publication snapshot is incomplete');
|
|
32
|
+
if(Object.keys(status.classifiers).some(id=>!outputs.has(id))||history.outputs.some(output=>!outputs.has(output.id))||history.history.some(row=>[...Object.keys(row.decision.classifiers),...Object.keys(row.reviews)].some(id=>!outputs.has(id))))throw new Error('Snapshot includes an output outside this publication');
|
|
33
|
+
if(history.history.some(row=>row.context&&(row.context.binding.values.accountId!==status.accountId||row.context.publicationId!==status.publicationId||row.context.decisionId!==row.decision.decisionId||JSON.stringify(ordered(row.context.source.values))!==JSON.stringify(ordered(row.item)))))throw new Error('Historical source context mismatch');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Explicit output target and incumbent pin; never a global review-rate mutation. */
|
|
37
|
+
export function publicationSteeringPayload(status:PublicationStatus,classifier:string,bindingRef:PublicationRevisionRef,command:PublicationSteeringInput):PublicationSteeringPayload{
|
|
38
|
+
if(!status.outputs.some(output=>output.id===classifier)||!status.classifiers[classifier])throw new Error('Steering output is outside this publication');
|
|
39
|
+
if(!command.requestId.trim()||!command.explanation.trim())throw new Error('Steering needs a stable identity and explicit explanation');
|
|
40
|
+
const fingerprint=status.classifiers[classifier].cyclotron.fingerprint;
|
|
41
|
+
if(!fingerprint)throw new Error('Active classifier fingerprint unavailable');
|
|
42
|
+
if(command.action==='evaluate'){
|
|
43
|
+
if(!Object.keys(command.proposal).length||Object.keys(command.proposal).some(key=>!['rubric','tasks','example_ids','dynamic_elements'].includes(key)))throw new Error('Native proposal fields required');
|
|
44
|
+
}else if(command.action!=='promote'||!command.trialFingerprint.trim())throw new Error('An explicitly evaluated trial is required');
|
|
45
|
+
return {...command,explanation:command.explanation.trim(),bindingRevision:status.bindingRevision,bindingRef,classifier,expectedFingerprint:fingerprint};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function publicationReviewPayload(binding:PublicationEnvelope,row:PublicationHistoryRow,classifier:string,review:ReviewControlReview,requestId:string):PublicationReviewPayload{
|
|
49
|
+
if(row.context&&row.context.binding.values.accountId!==binding.accountId)throw new Error('Review account context mismatch');
|
|
50
|
+
if(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired||row.context&&row.context.binding.values.revision!==binding.bindingRevision)throw new Error('Historical source or binding revision is read-only');
|
|
51
|
+
const output=binding.outputs.find(output=>output.id===classifier);const prediction=row.decision.classifiers[classifier];
|
|
52
|
+
if(!output||!prediction||review.decisionId!==row.decision.decisionId||review.itemId!==row.decision.itemId)throw new Error('Review does not match the displayed publication decision');
|
|
53
|
+
if(!review.label||!output.classes.includes(review.label))throw new Error('Choose an explicit classification label');
|
|
54
|
+
if(!requestId.trim())throw new Error('A stable review request identity is required');
|
|
55
|
+
return {bindingRevision:binding.bindingRevision,decisionId:row.decision.decisionId,classifier,predictionVersion:prediction.version,predictionFingerprint:prediction.fingerprint,requestId,label:review.label,explanation:review.explanation?.trim()??'',reasonCode:review.reasonCode,...(row.context?{contextId:row.context.contextId}:{})};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Host chooses an authenticated same-origin BFF; storage mode never changes UI semantics. */
|
|
59
|
+
export function createPublicationClient(scope:PublicationScope,request:(operation:string,payload:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<PublicationEnvelope&Record<string,unknown>>,currentScope:()=>PublicationScope|null){
|
|
60
|
+
const pinned=Object.freeze({accountId:scope.accountId,publicationId:scope.publicationId,bindingRevision:scope.bindingRevision});
|
|
61
|
+
const key=publicationScopeKey(pinned);
|
|
62
|
+
const assertSelected=()=>{const current=currentScope();if(!current||publicationScopeKey(current)!==key)throw new Error('Publication account selection changed; refresh context');};
|
|
63
|
+
const command=async(operation:string,payload:Record<string,unknown>)=>{
|
|
64
|
+
assertSelected();
|
|
65
|
+
if('bindingRevision' in payload&&payload.bindingRevision!==pinned.bindingRevision)throw new Error('Publication binding changed; refresh context');
|
|
66
|
+
const value=await request(operation,payload,pinned);
|
|
67
|
+
assertSelected();
|
|
68
|
+
if(value.contractVersion!=='cyclotron-publication/v1'||publicationScopeKey(value)!==key)throw new Error('Publication response contract mismatch');
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
return {scope:pinned,cacheKey:key,command,status:async()=>await command('status',{}) as PublicationStatus,history:async()=>await command('history',{}) as PublicationHistory,
|
|
72
|
+
review:async(payload:PublicationReviewPayload)=>command('review',payload),
|
|
73
|
+
steer:async(payload:PublicationSteeringPayload)=>command('steer',payload),replayRecords:async()=>command('replay',{})};
|
|
74
|
+
}
|